mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-16 23:43:10 +00:00
DaytonaCredentials mirrored DaytonaConfig field for field and was copied into one at connect time. It is now a newtype over the SDK configuration with the API key always present and a Debug that never prints it; the driver's Daytona provider connects with the configuration as it is. Callers build it from an API key, a settings lookup, and the optional control-plane URL, organization, and HTTP client. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2771 lines
96 KiB
Rust
2771 lines
96 KiB
Rust
use std::collections::HashMap;
|
|
use std::convert::Infallible;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, LazyLock, Mutex, MutexGuard};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Context as _, anyhow, bail};
|
|
use axum::extract::{OriginalUri, Query, Request, State};
|
|
use axum::http::{HeaderMap, Method, StatusCode, header};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::{get, post, put};
|
|
use axum::{Json, Router, middleware};
|
|
use base64::Engine as _;
|
|
use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD};
|
|
use fabro_config::Storage;
|
|
use fabro_config::bind::{Bind, BindRequest};
|
|
use fabro_config::envfile::{EnvFileRemoval, EnvFileUpdate};
|
|
use fabro_install::{
|
|
GITHUB_APP_VAULT_KEYS, GITHUB_INSTALL_SECRET_KEYS, InstallListenConfig, InstallPersistencePlan,
|
|
InstallSandboxSelection, OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV,
|
|
PendingSettingsWrite, SecretStoreWrite, merge_server_settings,
|
|
prepare_dev_token_write_for_install, seed_default_environment_in_storage,
|
|
write_github_app_settings, write_object_store_settings, write_sandbox_settings,
|
|
write_token_settings,
|
|
};
|
|
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
|
|
use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus};
|
|
use fabro_sandbox::daytona;
|
|
use fabro_sandbox::driver::DaytonaCredentials;
|
|
use fabro_static::EnvVars;
|
|
use fabro_store::ArtifactStore;
|
|
use fabro_types::settings::server::ObjectStoreSettings;
|
|
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
|
|
use fabro_types::{SandboxProviderKind, ServerSettings};
|
|
use fabro_util::version::FABRO_VERSION;
|
|
use fabro_util::{Home, session_secret};
|
|
use fabro_vault::SecretType as VaultSecretType;
|
|
use lithos_llm::catalog::ProviderId;
|
|
use object_store::aws::resolve_bucket_region;
|
|
use object_store::path::Path as ObjectStorePath;
|
|
use object_store::{ClientOptions, RetryConfig};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::net::{TcpListener, UnixListener};
|
|
use tokio::sync::watch;
|
|
use tokio::time::{sleep, timeout};
|
|
use tower::service_fn;
|
|
use tracing::{error, info, warn};
|
|
use zeroize::Zeroizing;
|
|
|
|
use crate::error::ApiError;
|
|
use crate::serve::{self, DEFAULT_TCP_PORT};
|
|
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
|
|
use crate::{security_headers, server, static_files};
|
|
|
|
#[derive(Clone)]
|
|
pub struct InstallAppState {
|
|
install_token: Arc<str>,
|
|
pending_install: Arc<Mutex<PendingInstall>>,
|
|
storage_dir: Arc<Path>,
|
|
config_path: Arc<Path>,
|
|
home: Option<Home>,
|
|
install_listen: Arc<Mutex<InstallListenConfig>>,
|
|
first_operator: Arc<Mutex<Option<InstallOperatorFingerprint>>>,
|
|
finish_in_progress: Arc<AtomicBool>,
|
|
upstreams: InstallUpstreamConfig,
|
|
static_asset_root: Option<Arc<Path>>,
|
|
on_finish: Option<Arc<dyn Fn() + Send + Sync>>,
|
|
finish_hook: Option<InstallFinishHook>,
|
|
}
|
|
|
|
pub struct InstallFinishInfo {
|
|
pub canonical_url: String,
|
|
pub dev_token: Option<String>,
|
|
}
|
|
|
|
pub type InstallFinishHook = Arc<dyn Fn(&InstallFinishInfo) -> anyhow::Result<()> + Send + Sync>;
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct InstallUpstreamConfig {
|
|
provider_base_urls: HashMap<ProviderId, String>,
|
|
github_api_base_url: Option<String>,
|
|
daytona_api_base_url: Option<String>,
|
|
daytona_organization_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct InstallOperatorFingerprint {
|
|
user_agent: Option<String>,
|
|
remote_ip: Option<String>,
|
|
}
|
|
|
|
pub const DEFAULT_INSTALL_GITHUB_API_BASE_URL: &str = "https://api.github.com";
|
|
const DEFAULT_INSTALL_TCP_LISTEN_ADDRESS: &str = "127.0.0.1:32276";
|
|
const REDACTED_SECRET_VALUE: &str = "[REDACTED]";
|
|
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20);
|
|
const VALIDATION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
static INSTALL_CATALOG: LazyLock<Arc<Catalog>> =
|
|
LazyLock::new(|| Arc::new(fabro_llm::default_catalog()));
|
|
|
|
impl InstallAppState {
|
|
#[must_use]
|
|
pub fn new(token: String, storage_dir: &Path, config_path: &Path) -> Self {
|
|
Self {
|
|
install_token: Arc::from(token),
|
|
pending_install: Arc::new(Mutex::new(PendingInstall::default())),
|
|
storage_dir: Arc::from(storage_dir),
|
|
config_path: Arc::from(config_path),
|
|
home: None,
|
|
install_listen: Arc::new(Mutex::new(InstallListenConfig::Tcp(
|
|
DEFAULT_INSTALL_TCP_LISTEN_ADDRESS.to_string(),
|
|
))),
|
|
first_operator: Arc::new(Mutex::new(None)),
|
|
finish_in_progress: Arc::new(AtomicBool::new(false)),
|
|
upstreams: InstallUpstreamConfig::default(),
|
|
static_asset_root: None,
|
|
on_finish: None,
|
|
finish_hook: None,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn for_test(token: &str) -> Self {
|
|
let temp_root = std::env::temp_dir().join("fabro-install-test");
|
|
Self::for_test_with_paths(token, &temp_root, &temp_root.join("settings.toml"))
|
|
}
|
|
|
|
#[must_use]
|
|
#[expect(
|
|
unsafe_code,
|
|
reason = "test-only: set FABRO_TEST_IN_MEMORY_STORE to a constant so install tests \
|
|
don't hang on real S3; parallel tests race on the same value"
|
|
)]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "test-only: forces the in-memory object store for install tests so they \
|
|
don't contact real S3"
|
|
)]
|
|
pub fn for_test_with_paths(token: &str, storage_dir: &Path, config_path: &Path) -> Self {
|
|
// Install-flow tests verify persistence and redaction, not S3
|
|
// reachability. Force the in-memory object store shortcut so
|
|
// /install/finish can't hang on an unreachable bucket.
|
|
unsafe {
|
|
std::env::set_var(EnvVars::FABRO_TEST_IN_MEMORY_STORE, "1");
|
|
}
|
|
Self {
|
|
install_token: Arc::from(token),
|
|
pending_install: Arc::new(Mutex::new(PendingInstall::default())),
|
|
storage_dir: Arc::from(storage_dir),
|
|
config_path: Arc::from(config_path),
|
|
home: None,
|
|
install_listen: Arc::new(Mutex::new(InstallListenConfig::Tcp(
|
|
DEFAULT_INSTALL_TCP_LISTEN_ADDRESS.to_string(),
|
|
))),
|
|
first_operator: Arc::new(Mutex::new(None)),
|
|
finish_in_progress: Arc::new(AtomicBool::new(false)),
|
|
upstreams: InstallUpstreamConfig::default(),
|
|
static_asset_root: None,
|
|
on_finish: None,
|
|
finish_hook: None,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_finish_callback(self, on_finish: Arc<dyn Fn() + Send + Sync>) -> Self {
|
|
Self {
|
|
on_finish: Some(on_finish),
|
|
..self
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_finish_hook(self, finish_hook: InstallFinishHook) -> Self {
|
|
Self {
|
|
finish_hook: Some(finish_hook),
|
|
..self
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_home(mut self, home: Home) -> Self {
|
|
self.home = Some(home);
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_static_asset_root(mut self, root: impl Into<PathBuf>) -> Self {
|
|
self.static_asset_root = Some(Arc::from(root.into()));
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_provider_base_url(
|
|
mut self,
|
|
provider: impl Into<ProviderId>,
|
|
base_url: impl Into<String>,
|
|
) -> Self {
|
|
self.upstreams
|
|
.provider_base_urls
|
|
.insert(provider.into(), base_url.into());
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_github_api_base_url(mut self, base_url: impl Into<String>) -> Self {
|
|
self.upstreams.github_api_base_url = Some(base_url.into());
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_daytona_api_base_url(mut self, base_url: impl Into<String>) -> Self {
|
|
self.upstreams.daytona_api_base_url = Some(base_url.into());
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_daytona_organization_id(mut self, organization_id: impl Into<String>) -> Self {
|
|
self.upstreams.daytona_organization_id = Some(organization_id.into());
|
|
self
|
|
}
|
|
|
|
fn set_install_bind(&self, bind: &Bind) {
|
|
*lock_unpoisoned(&self.install_listen, "install listen") = install_listen_config(bind);
|
|
}
|
|
|
|
fn install_listen_config(&self) -> InstallListenConfig {
|
|
lock_unpoisoned(&self.install_listen, "install listen").clone()
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct InstallTokenQuery {
|
|
token: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct PendingInstall {
|
|
llm: Option<LlmProvidersInput>,
|
|
server: Option<ServerConfigInput>,
|
|
object_store: Option<InstallObjectStoreState>,
|
|
sandbox: Option<InstallSandboxState>,
|
|
github: Option<GithubInstallState>,
|
|
pending_github_app: Option<PendingGithubApp>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
struct LlmProvidersInput {
|
|
providers: Vec<LlmProviderInput>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
struct LlmProviderInput {
|
|
provider: ProviderId,
|
|
api_key: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
struct ServerConfigInput {
|
|
canonical_url: String,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::IntoStaticStr)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[strum(serialize_all = "snake_case")]
|
|
enum InstallObjectStoreProvider {
|
|
Local,
|
|
S3,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::IntoStaticStr)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[strum(serialize_all = "snake_case")]
|
|
enum InstallObjectStoreCredentialMode {
|
|
Runtime,
|
|
AccessKey,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct InstallObjectStoreInput {
|
|
provider: InstallObjectStoreProvider,
|
|
root: Option<String>,
|
|
bucket: Option<String>,
|
|
region: Option<String>,
|
|
credential_mode: Option<InstallObjectStoreCredentialMode>,
|
|
access_key_id: Option<String>,
|
|
secret_access_key: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct InstallSecret(Zeroizing<String>);
|
|
|
|
impl InstallSecret {
|
|
fn new(value: impl Into<String>) -> Self {
|
|
Self(Zeroizing::new(value.into()))
|
|
}
|
|
|
|
fn expose_secret(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for InstallSecret {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(REDACTED_SECRET_VALUE)
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for InstallSecret {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(REDACTED_SECRET_VALUE)
|
|
}
|
|
}
|
|
|
|
impl Serialize for InstallSecret {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
serializer.serialize_str(REDACTED_SECRET_VALUE)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct InstallAwsCredentialPair {
|
|
access_key_id: InstallSecret,
|
|
secret_access_key: InstallSecret,
|
|
}
|
|
|
|
impl InstallAwsCredentialPair {
|
|
fn new(access_key_id: impl Into<String>, secret_access_key: impl Into<String>) -> Self {
|
|
Self {
|
|
access_key_id: InstallSecret::new(access_key_id),
|
|
secret_access_key: InstallSecret::new(secret_access_key),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for InstallAwsCredentialPair {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("InstallAwsCredentialPair")
|
|
.field("access_key_id", &self.access_key_id)
|
|
.field("secret_access_key", &self.secret_access_key)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum InstallObjectStoreState {
|
|
Local {
|
|
root: String,
|
|
},
|
|
S3 {
|
|
bucket: String,
|
|
region: String,
|
|
credential_mode: InstallObjectStoreCredentialMode,
|
|
manual_credentials: Option<InstallAwsCredentialPair>,
|
|
},
|
|
}
|
|
|
|
impl InstallObjectStoreState {
|
|
fn as_session_value(&self) -> serde_json::Value {
|
|
match self {
|
|
Self::Local { root } => serde_json::json!({
|
|
"provider": "local",
|
|
"root": root,
|
|
}),
|
|
Self::S3 {
|
|
bucket,
|
|
region,
|
|
credential_mode,
|
|
manual_credentials,
|
|
} => serde_json::json!({
|
|
"provider": "s3",
|
|
"bucket": bucket,
|
|
"region": region,
|
|
"credential_mode": <&'static str>::from(*credential_mode),
|
|
"manual_credentials_saved": matches!(
|
|
credential_mode,
|
|
InstallObjectStoreCredentialMode::AccessKey
|
|
) && manual_credentials.is_some(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn to_persistence_selection(&self) -> fabro_install::InstallObjectStoreSelection {
|
|
match self {
|
|
Self::Local { root } => {
|
|
fabro_install::InstallObjectStoreSelection::Local { root: root.clone() }
|
|
}
|
|
Self::S3 {
|
|
bucket,
|
|
region,
|
|
credential_mode,
|
|
manual_credentials,
|
|
} => fabro_install::InstallObjectStoreSelection::S3 {
|
|
bucket: bucket.clone(),
|
|
region: region.clone(),
|
|
credential_mode: match credential_mode {
|
|
InstallObjectStoreCredentialMode::Runtime => {
|
|
fabro_install::InstallObjectStoreCredentialMode::Runtime
|
|
}
|
|
InstallObjectStoreCredentialMode::AccessKey => {
|
|
fabro_install::InstallObjectStoreCredentialMode::AccessKey
|
|
}
|
|
},
|
|
access_key_id: manual_credentials
|
|
.as_ref()
|
|
.map(|credentials| credentials.access_key_id.expose_secret().to_string()),
|
|
secret_access_key: manual_credentials
|
|
.as_ref()
|
|
.map(|credentials| credentials.secret_access_key.expose_secret().to_string()),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::IntoStaticStr)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[strum(serialize_all = "snake_case")]
|
|
enum InstallSandboxProvider {
|
|
Docker,
|
|
Daytona,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct InstallSandboxInput {
|
|
provider: InstallSandboxProvider,
|
|
api_key: Option<String>,
|
|
allow_local: Option<bool>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum InstallSandboxProviderState {
|
|
Docker,
|
|
Daytona { api_key: InstallSecret },
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct InstallSandboxState {
|
|
provider: InstallSandboxProviderState,
|
|
allow_local: bool,
|
|
}
|
|
|
|
impl InstallSandboxState {
|
|
fn as_session_value(&self) -> serde_json::Value {
|
|
match &self.provider {
|
|
InstallSandboxProviderState::Docker => serde_json::json!({
|
|
"provider": "docker",
|
|
"allow_local": self.allow_local,
|
|
}),
|
|
InstallSandboxProviderState::Daytona { .. } => serde_json::json!({
|
|
"provider": "daytona",
|
|
"api_key_saved": true,
|
|
"allow_local": self.allow_local,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn to_persistence_selection(&self) -> InstallSandboxSelection {
|
|
match &self.provider {
|
|
InstallSandboxProviderState::Docker => InstallSandboxSelection::Docker,
|
|
InstallSandboxProviderState::Daytona { .. } => InstallSandboxSelection::Daytona,
|
|
}
|
|
}
|
|
|
|
fn to_environment_provider(&self) -> SandboxProviderKind {
|
|
match &self.provider {
|
|
InstallSandboxProviderState::Docker => SandboxProviderKind::DOCKER,
|
|
InstallSandboxProviderState::Daytona { .. } => SandboxProviderKind::DAYTONA,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
struct GithubTokenInput {
|
|
token: String,
|
|
username: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum GithubInstallState {
|
|
Token(GithubTokenInput),
|
|
App(GithubAppInstall),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct PendingGithubApp {
|
|
state: String,
|
|
owner: GitHubAppOwner,
|
|
app_name: String,
|
|
allowed_username: String,
|
|
expires_at: Instant,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct GithubAppInstall {
|
|
owner: GitHubAppOwner,
|
|
app_name: String,
|
|
allowed_username: String,
|
|
app_id: String,
|
|
slug: String,
|
|
client_id: String,
|
|
client_secret: String,
|
|
webhook_secret: Option<String>,
|
|
pem: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct InstallLlmTestInput {
|
|
provider: ProviderId,
|
|
api_key: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct GithubTokenTestInput {
|
|
token: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct GithubAppManifestInput {
|
|
owner: GithubAppOwnerInput,
|
|
app_name: String,
|
|
allowed_username: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct GithubAppOwnerInput {
|
|
kind: GithubAppOwnerKind,
|
|
slug: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum GithubAppOwnerKind {
|
|
Personal,
|
|
Org,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct GithubAppRedirectQuery {
|
|
code: Option<String>,
|
|
state: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct GithubUserResponse {
|
|
login: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct GitHubAppManifestConversion {
|
|
id: i64,
|
|
slug: String,
|
|
client_id: String,
|
|
client_secret: String,
|
|
webhook_secret: Option<String>,
|
|
pem: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum GitHubAppOwner {
|
|
Personal,
|
|
Organization(String),
|
|
}
|
|
|
|
impl GitHubAppOwner {
|
|
fn manifest_form_action(&self) -> String {
|
|
match self {
|
|
Self::Personal => "https://github.com/settings/apps/new".to_string(),
|
|
Self::Organization(org) => {
|
|
format!("https://github.com/organizations/{org}/settings/apps/new")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn as_session_value(&self) -> serde_json::Value {
|
|
match self {
|
|
Self::Personal => serde_json::json!({ "kind": "personal" }),
|
|
Self::Organization(org) => serde_json::json!({ "kind": "org", "slug": org }),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<GithubAppOwnerInput> for GitHubAppOwner {
|
|
type Error = String;
|
|
|
|
fn try_from(value: GithubAppOwnerInput) -> Result<Self, Self::Error> {
|
|
match value.kind {
|
|
GithubAppOwnerKind::Personal => Ok(Self::Personal),
|
|
GithubAppOwnerKind::Org => {
|
|
let slug = value.slug.unwrap_or_default();
|
|
let trimmed = slug.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("organization owner requires a non-empty slug".to_string());
|
|
}
|
|
Ok(Self::Organization(trimmed.to_string()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn build_install_router(state: InstallAppState) -> Router {
|
|
let static_asset_root = state.static_asset_root.clone();
|
|
|
|
Router::new()
|
|
.route("/health", get(health))
|
|
.route("/install/session", get(get_install_session))
|
|
.route("/install/llm/test", post(post_install_llm_test))
|
|
.route(
|
|
"/install/llm",
|
|
get(render_install_shell).put(put_install_llm),
|
|
)
|
|
.route(
|
|
"/install/server",
|
|
get(render_install_shell).put(put_install_server),
|
|
)
|
|
.route(
|
|
"/install/object-store/test",
|
|
post(post_install_object_store_test),
|
|
)
|
|
.route(
|
|
"/install/object-store",
|
|
get(render_install_shell).put(put_install_object_store),
|
|
)
|
|
.route("/install/sandbox/test", post(post_install_sandbox_test))
|
|
.route(
|
|
"/install/sandbox",
|
|
get(render_install_shell).put(put_install_sandbox),
|
|
)
|
|
.route(
|
|
"/install/github/token/test",
|
|
post(post_install_github_token_test),
|
|
)
|
|
.route("/install/github/token", put(put_install_github_token))
|
|
.route(
|
|
"/install/github/app/manifest",
|
|
post(post_install_github_app_manifest),
|
|
)
|
|
.route(
|
|
"/install/github/app/redirect",
|
|
get(get_install_github_app_redirect),
|
|
)
|
|
.route("/install/finish", post(post_install_finish))
|
|
.with_state(state)
|
|
.fallback_service(service_fn(move |req: Request| {
|
|
let static_asset_root = static_asset_root.clone();
|
|
async move {
|
|
let path = req.uri().path().to_string();
|
|
if path.starts_with("/api/") {
|
|
Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response())
|
|
} else if matches!(req.method(), &Method::GET | &Method::HEAD) {
|
|
let headers = req.headers().clone();
|
|
Ok::<_, Infallible>(
|
|
static_files::serve_install_with_asset_root(
|
|
&path,
|
|
&headers,
|
|
static_asset_root.as_deref(),
|
|
false,
|
|
)
|
|
.await,
|
|
)
|
|
} else {
|
|
Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response())
|
|
}
|
|
}
|
|
}))
|
|
// Install mode serves the same multi-megabyte SPA bundle as the main
|
|
// router; a first-run setup over a slow link needs compression just
|
|
// as much.
|
|
.layer(server::compression_layer())
|
|
.layer(middleware::from_fn(security_headers::layer))
|
|
}
|
|
|
|
struct InstallFinishGuard {
|
|
flag: Arc<AtomicBool>,
|
|
release: bool,
|
|
}
|
|
|
|
impl InstallFinishGuard {
|
|
fn try_acquire(flag: Arc<AtomicBool>) -> Option<Self> {
|
|
flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
|
.ok()?;
|
|
Some(Self {
|
|
flag,
|
|
release: true,
|
|
})
|
|
}
|
|
|
|
fn disarm(mut self) {
|
|
self.release = false;
|
|
}
|
|
}
|
|
|
|
impl Drop for InstallFinishGuard {
|
|
fn drop(&mut self) {
|
|
if self.release {
|
|
self.flag.store(false, Ordering::Release);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn serve_install_command<F>(
|
|
bind_request: BindRequest,
|
|
state: InstallAppState,
|
|
on_ready: F,
|
|
) -> anyhow::Result<()>
|
|
where
|
|
F: FnOnce(&Bind) -> anyhow::Result<()>,
|
|
{
|
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
|
let finish_callback: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
|
let _ = shutdown_tx.send(true);
|
|
});
|
|
let bound_listener = bind_install_listener(&bind_request).await?;
|
|
state.set_install_bind(&bound_listener.bind);
|
|
let state = state.with_finish_callback(finish_callback);
|
|
let router = build_install_router(state);
|
|
let bind = bound_listener.bind.clone();
|
|
on_ready(&bind)?;
|
|
|
|
match bound_listener.listener {
|
|
BoundInstallListener::Unix(listener) => {
|
|
axum::serve(listener, router)
|
|
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()))
|
|
.await?;
|
|
}
|
|
BoundInstallListener::Tcp(listener) => {
|
|
axum::serve(
|
|
listener,
|
|
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
|
)
|
|
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn lock_unpoisoned<'a, T>(mutex: &'a Mutex<T>, label: &'static str) -> MutexGuard<'a, T> {
|
|
mutex.lock().unwrap_or_else(|poisoned| {
|
|
error!(lock = label, "recovering from poisoned install lock");
|
|
poisoned.into_inner()
|
|
})
|
|
}
|
|
|
|
fn install_listen_config(bind: &Bind) -> InstallListenConfig {
|
|
match bind {
|
|
Bind::Tcp(address) => InstallListenConfig::Tcp(address.to_string()),
|
|
Bind::Unix(path) => InstallListenConfig::Unix(path.clone()),
|
|
}
|
|
}
|
|
|
|
async fn health() -> Response {
|
|
Json(serde_json::json!({
|
|
"status": "ok",
|
|
"mode": "install",
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn get_install_session(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
) -> Response {
|
|
if !token_is_valid(&state, &headers, query.token.as_deref()) {
|
|
return ApiError::new(StatusCode::UNAUTHORIZED, "invalid install token").into_response();
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
let pending_install = lock_unpoisoned(&state.pending_install, "install session").clone();
|
|
|
|
Json(serde_json::json!({
|
|
"completed_steps": completed_steps(&pending_install),
|
|
"llm": redacted_llm(&pending_install),
|
|
"server": pending_install.server,
|
|
"object_store": redacted_object_store(&pending_install),
|
|
"sandbox": redacted_sandbox(&pending_install),
|
|
"github": redacted_github(&pending_install),
|
|
"prefill": {
|
|
"canonical_url": detect_canonical_url(&headers),
|
|
"object_store_local_root": default_local_object_store_root(&state),
|
|
}
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn post_install_llm_test(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(input): Json<InstallLlmTestInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
if let Err(error) = install_catalog_provider(&input.provider) {
|
|
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, error);
|
|
}
|
|
|
|
if input.api_key.trim().is_empty() {
|
|
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, "api_key is required");
|
|
}
|
|
match validate_llm_provider(&state, &input).await {
|
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
Err(err) => {
|
|
warn!(provider = %input.provider, error = ?err, "install LLM validation failed");
|
|
install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err.to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn put_install_llm(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(input): Json<LlmProvidersInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
// An empty providers list is an explicit skip: the LLM step is recorded as
|
|
// completed with zero credentials. `/install/finish` still requires the
|
|
// step to be present, just not populated.
|
|
for provider in &input.providers {
|
|
if let Err(error) = install_catalog_provider(&provider.provider) {
|
|
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, error);
|
|
}
|
|
if provider.api_key.trim().is_empty() {
|
|
return install_error_response(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
format!("api_key is required for {}", provider.provider),
|
|
);
|
|
}
|
|
}
|
|
|
|
lock_unpoisoned(&state.pending_install, "install session").llm = Some(input);
|
|
info!(step = "llm", "install step completed");
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogProvider, String> {
|
|
let catalog_provider = INSTALL_CATALOG
|
|
.enabled_provider(provider.as_str())
|
|
.ok_or_else(|| format!("provider '{provider}' is not configured in the model catalog"))?;
|
|
if fabro_auth::accepts_api_key(catalog_provider) {
|
|
Ok(catalog_provider)
|
|
} else {
|
|
Err(format!(
|
|
"provider '{}' does not define an API-key credential path",
|
|
catalog_provider.id()
|
|
))
|
|
}
|
|
}
|
|
|
|
fn provider_secret_name(provider: &ProviderId) -> Result<String, String> {
|
|
let catalog_provider = install_catalog_provider(provider)?;
|
|
fabro_auth::expected_secret_name(catalog_provider)
|
|
.ok_or_else(|| format!("provider '{provider}' does not define a vault credential path"))
|
|
}
|
|
|
|
async fn put_install_server(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(mut input): Json<ServerConfigInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
let canonical_url = input.canonical_url.trim();
|
|
if canonical_url.is_empty() {
|
|
return (
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
Json(serde_json::json!({ "error": "canonical_url is required" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let canonical_url = match validate_public_url_with_label(canonical_url, "canonical_url") {
|
|
Ok(value) => value,
|
|
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
|
|
};
|
|
input.canonical_url = canonical_url;
|
|
|
|
lock_unpoisoned(&state.pending_install, "install session").server = Some(input);
|
|
info!(step = "server", "install step completed");
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
async fn post_install_object_store_test(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(input): Json<InstallObjectStoreInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
let selection = {
|
|
let pending_install = lock_unpoisoned(&state.pending_install, "install session");
|
|
match resolve_install_object_store_state(
|
|
pending_install.object_store.as_ref(),
|
|
input,
|
|
&default_local_object_store_root(&state),
|
|
) {
|
|
Ok(selection) => selection,
|
|
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
|
|
}
|
|
};
|
|
|
|
match validate_install_object_store_selection(&state, &selection).await {
|
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
Err(err) => {
|
|
warn!(error = ?err, "install object store validation failed");
|
|
install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err.to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn put_install_object_store(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(input): Json<InstallObjectStoreInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
let mut pending_install = lock_unpoisoned(&state.pending_install, "install session");
|
|
let selection = match resolve_install_object_store_state(
|
|
pending_install.object_store.as_ref(),
|
|
input,
|
|
&default_local_object_store_root(&state),
|
|
) {
|
|
Ok(selection) => selection,
|
|
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
|
|
};
|
|
|
|
pending_install.object_store = Some(selection);
|
|
info!(step = "object_store", "install step completed");
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
async fn post_install_sandbox_test(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(input): Json<InstallSandboxInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
let api_key = {
|
|
let pending_install = lock_unpoisoned(&state.pending_install, "install session");
|
|
match resolve_install_sandbox_state(pending_install.sandbox.as_ref(), input) {
|
|
Ok(InstallSandboxState {
|
|
provider: InstallSandboxProviderState::Docker,
|
|
..
|
|
}) => {
|
|
return Json(serde_json::json!({ "ok": true })).into_response();
|
|
}
|
|
Ok(InstallSandboxState {
|
|
provider: InstallSandboxProviderState::Daytona { api_key },
|
|
..
|
|
}) => api_key.expose_secret().to_string(),
|
|
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
|
|
}
|
|
};
|
|
|
|
match check_install_daytona_api_key(&state, api_key).await {
|
|
Ok(check) if check.ok() => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
Ok(check) => {
|
|
warn!(
|
|
missing = %check.missing_display(),
|
|
"install sandbox scopes insufficient"
|
|
);
|
|
install_error_response(StatusCode::UNPROCESSABLE_ENTITY, check.missing_message())
|
|
}
|
|
Err(err) => {
|
|
warn!(error = %err, "install sandbox validation failed");
|
|
install_error_response(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
format!("daytona credential validation failed: {err}"),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn check_install_daytona_api_key(
|
|
state: &InstallAppState,
|
|
api_key: String,
|
|
) -> anyhow::Result<daytona::DaytonaKeyCheck> {
|
|
let credentials = DaytonaCredentials::new(api_key)
|
|
.with_api_url(state.upstreams.daytona_api_base_url.clone())
|
|
.with_organization_id(state.upstreams.daytona_organization_id.clone())
|
|
.with_http_client(Some(
|
|
fabro_http::http_client().context("failed to build HTTP client")?,
|
|
));
|
|
daytona::check_daytona_api_key(&credentials, daytona::DAYTONA_CREDENTIAL_PROBE_TIMEOUT).await
|
|
}
|
|
|
|
async fn put_install_sandbox(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(input): Json<InstallSandboxInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
let mut pending_install = lock_unpoisoned(&state.pending_install, "install session");
|
|
let selection = match resolve_install_sandbox_state(pending_install.sandbox.as_ref(), input) {
|
|
Ok(selection) => selection,
|
|
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
|
|
};
|
|
|
|
pending_install.sandbox = Some(selection);
|
|
info!(step = "sandbox", "install step completed");
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
fn trim_install_field(value: Option<String>) -> Option<String> {
|
|
value
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn default_local_object_store_root(state: &InstallAppState) -> String {
|
|
state
|
|
.storage_dir
|
|
.as_ref()
|
|
.join("objects")
|
|
.display()
|
|
.to_string()
|
|
}
|
|
|
|
fn resolve_install_sandbox_state(
|
|
current: Option<&InstallSandboxState>,
|
|
input: InstallSandboxInput,
|
|
) -> Result<InstallSandboxState, String> {
|
|
// Local sandboxes are allowed by default unless the wizard unchecked the box.
|
|
let allow_local = input.allow_local.unwrap_or(true);
|
|
let provider = match input.provider {
|
|
InstallSandboxProvider::Docker => InstallSandboxProviderState::Docker,
|
|
InstallSandboxProvider::Daytona => {
|
|
let api_key = match trim_install_field(input.api_key) {
|
|
Some(value) => InstallSecret::new(value),
|
|
None => match current.map(|state| &state.provider) {
|
|
Some(InstallSandboxProviderState::Daytona { api_key }) => {
|
|
InstallSecret::new(api_key.expose_secret())
|
|
}
|
|
_ => return Err("api_key is required for daytona".to_string()),
|
|
},
|
|
};
|
|
InstallSandboxProviderState::Daytona { api_key }
|
|
}
|
|
};
|
|
Ok(InstallSandboxState {
|
|
provider,
|
|
allow_local,
|
|
})
|
|
}
|
|
|
|
fn resolve_install_object_store_state(
|
|
current: Option<&InstallObjectStoreState>,
|
|
input: InstallObjectStoreInput,
|
|
default_local_root: &str,
|
|
) -> Result<InstallObjectStoreState, String> {
|
|
let root = trim_install_field(input.root);
|
|
let bucket = trim_install_field(input.bucket);
|
|
let region = trim_install_field(input.region);
|
|
let access_key_id = trim_install_field(input.access_key_id);
|
|
let secret_access_key = trim_install_field(input.secret_access_key);
|
|
|
|
match input.provider {
|
|
InstallObjectStoreProvider::Local => {
|
|
if bucket.is_some()
|
|
|| region.is_some()
|
|
|| input.credential_mode.is_some()
|
|
|| access_key_id.is_some()
|
|
|| secret_access_key.is_some()
|
|
{
|
|
return Err(
|
|
"Local disk does not accept S3 bucket, region, or AWS credential fields."
|
|
.to_string(),
|
|
);
|
|
}
|
|
let root = root
|
|
.or_else(|| match current {
|
|
Some(InstallObjectStoreState::Local { root }) => Some(root.clone()),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_else(|| default_local_root.to_string());
|
|
Ok(InstallObjectStoreState::Local { root })
|
|
}
|
|
InstallObjectStoreProvider::S3 => {
|
|
let bucket = bucket.ok_or_else(|| "Bucket is required.".to_string())?;
|
|
let region = region
|
|
.ok_or_else(|| "Region is required. Use a value like us-east-1.".to_string())?;
|
|
let credential_mode = input
|
|
.credential_mode
|
|
.ok_or_else(|| "Choose how Fabro should authenticate to AWS.".to_string())?;
|
|
let manual_credentials = match credential_mode {
|
|
InstallObjectStoreCredentialMode::Runtime => {
|
|
if access_key_id.is_some() || secret_access_key.is_some() {
|
|
return Err(
|
|
"AWS access key fields are only allowed when using manual AWS access key credentials."
|
|
.to_string(),
|
|
);
|
|
}
|
|
None
|
|
}
|
|
InstallObjectStoreCredentialMode::AccessKey => Some(resolve_s3_manual_credentials(
|
|
access_key_id,
|
|
secret_access_key,
|
|
current,
|
|
)?),
|
|
};
|
|
|
|
Ok(InstallObjectStoreState::S3 {
|
|
bucket,
|
|
region,
|
|
credential_mode,
|
|
manual_credentials,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_s3_manual_credentials(
|
|
access_key_id: Option<String>,
|
|
secret_access_key: Option<String>,
|
|
current: Option<&InstallObjectStoreState>,
|
|
) -> Result<InstallAwsCredentialPair, String> {
|
|
match (access_key_id, secret_access_key) {
|
|
(Some(access_key_id), Some(secret_access_key)) => Ok(InstallAwsCredentialPair::new(
|
|
access_key_id,
|
|
secret_access_key,
|
|
)),
|
|
(None, None) => current
|
|
.and_then(|state| match state {
|
|
InstallObjectStoreState::S3 {
|
|
credential_mode: InstallObjectStoreCredentialMode::AccessKey,
|
|
manual_credentials,
|
|
..
|
|
} => manual_credentials.clone(),
|
|
_ => None,
|
|
})
|
|
.ok_or_else(|| {
|
|
"Enter both AWS access key fields or switch to runtime credentials.".to_string()
|
|
}),
|
|
(Some(_), None) | (None, Some(_)) => {
|
|
Err("Enter both AWS access key fields or switch to runtime credentials.".to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn object_store_validation_settings(
|
|
selection: &InstallObjectStoreState,
|
|
) -> Option<ObjectStoreSettings> {
|
|
match selection {
|
|
InstallObjectStoreState::Local { .. } => None,
|
|
InstallObjectStoreState::S3 { bucket, region, .. } => Some(ObjectStoreSettings::S3 {
|
|
bucket: bucket.clone(),
|
|
region: region.clone(),
|
|
endpoint: None,
|
|
path_style: false,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn install_object_store_lookup<'a>(
|
|
server_secrets: &'a ServerSecrets,
|
|
manual_credentials: Option<&'a InstallAwsCredentialPair>,
|
|
) -> impl Fn(&str) -> Option<String> + 'a {
|
|
move |name| match (manual_credentials, name) {
|
|
(Some(credentials), OBJECT_STORE_ACCESS_KEY_ID_ENV) => {
|
|
Some(credentials.access_key_id.expose_secret().to_string())
|
|
}
|
|
(Some(credentials), OBJECT_STORE_SECRET_ACCESS_KEY_ENV) => {
|
|
Some(credentials.secret_access_key.expose_secret().to_string())
|
|
}
|
|
(Some(_), EnvVars::AWS_SESSION_TOKEN) => None,
|
|
_ => server_secrets.get(name),
|
|
}
|
|
}
|
|
|
|
async fn validate_install_object_store_selection(
|
|
state: &InstallAppState,
|
|
selection: &InstallObjectStoreState,
|
|
) -> anyhow::Result<()> {
|
|
let Some(settings) = object_store_validation_settings(selection) else {
|
|
return Ok(());
|
|
};
|
|
|
|
let (bucket, region, manual_credentials) = match selection {
|
|
InstallObjectStoreState::Local { .. } => return Ok(()),
|
|
InstallObjectStoreState::S3 {
|
|
bucket,
|
|
region,
|
|
credential_mode: _,
|
|
manual_credentials,
|
|
} => (
|
|
bucket.as_str(),
|
|
region.as_str(),
|
|
manual_credentials.as_ref(),
|
|
),
|
|
};
|
|
|
|
let client_options = ClientOptions::new()
|
|
.with_connect_timeout(VALIDATION_CONNECT_TIMEOUT)
|
|
.with_timeout(VALIDATION_TIMEOUT);
|
|
match timeout(
|
|
VALIDATION_TIMEOUT,
|
|
resolve_bucket_region(bucket, &client_options),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(actual_region)) if actual_region != region => {
|
|
bail!(
|
|
"Bucket {bucket} is in region {actual_region}, not {region}. Use the bucket's AWS region and try again."
|
|
);
|
|
}
|
|
Ok(Err(err)) => {
|
|
let rendered = err.to_string();
|
|
if rendered.contains("not found") {
|
|
bail!("Bucket {bucket} was not found.");
|
|
}
|
|
}
|
|
Err(_) => {
|
|
bail!(VALIDATION_TIMEOUT_MSG);
|
|
}
|
|
Ok(Ok(_)) => {}
|
|
}
|
|
|
|
let server_env_path = Storage::new(state.storage_dir.as_ref())
|
|
.runtime_directory()
|
|
.env_path();
|
|
let server_secrets =
|
|
ServerSecrets::load(server_env_path, process_env_snapshot()).map_err(anyhow::Error::new)?;
|
|
let build_options = serve::ObjectStoreBuildOptions {
|
|
client_options,
|
|
retry_config: RetryConfig {
|
|
max_retries: 0,
|
|
retry_timeout: VALIDATION_TIMEOUT,
|
|
..RetryConfig::default()
|
|
},
|
|
};
|
|
let env_lookup = install_object_store_lookup(&server_secrets, manual_credentials);
|
|
let object_store = serve::build_object_store_from_settings_with_lookup(
|
|
&settings,
|
|
&env_lookup,
|
|
Some(&build_options),
|
|
)?;
|
|
|
|
let probe_prefix = |index: usize, prefix: &'static str| {
|
|
let object_store = &object_store;
|
|
async move {
|
|
let path = ObjectStorePath::from(prefix);
|
|
object_store
|
|
.list_with_delimiter(Some(&path))
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|err| (index, err))
|
|
}
|
|
};
|
|
let probe = async {
|
|
tokio::try_join!(probe_prefix(0, "artifacts"), probe_prefix(1, "slatedb")).map(|_| ())
|
|
};
|
|
|
|
match timeout(VALIDATION_TIMEOUT, probe).await {
|
|
Ok(Ok(())) => Ok(()),
|
|
Err(_) => bail!(VALIDATION_TIMEOUT_MSG),
|
|
Ok(Err((index, err))) => bail!(
|
|
"{}",
|
|
classify_object_store_validation_error(bucket, region, index, &err)
|
|
),
|
|
}
|
|
}
|
|
|
|
const PREFIX_ACCESS_ERROR_MSG: &str = "Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes.";
|
|
const VALIDATION_TIMEOUT_MSG: &str = "Timed out while checking S3 access. Verify the bucket, region, and network path, then try again.";
|
|
|
|
fn bucket_credentials_error(bucket: &str, region: &str) -> String {
|
|
format!("Could not access bucket {bucket} in region {region} with the selected credentials.")
|
|
}
|
|
|
|
fn classify_object_store_validation_error(
|
|
bucket: &str,
|
|
region: &str,
|
|
prefix_index: usize,
|
|
err: &object_store::Error,
|
|
) -> String {
|
|
let credentials_or_prefix_error = || {
|
|
if prefix_index == 0 {
|
|
bucket_credentials_error(bucket, region)
|
|
} else {
|
|
PREFIX_ACCESS_ERROR_MSG.to_string()
|
|
}
|
|
};
|
|
match err {
|
|
object_store::Error::PermissionDenied { .. }
|
|
| object_store::Error::Unauthenticated { .. } => credentials_or_prefix_error(),
|
|
object_store::Error::NotFound { .. } => format!("Bucket {bucket} was not found."),
|
|
object_store::Error::Generic { .. } => {
|
|
let rendered = err.to_string();
|
|
if rendered.contains("incorrectly configured region") {
|
|
format!(
|
|
"Bucket {bucket} is not reachable in region {region}. Verify the AWS region and try again."
|
|
)
|
|
} else if rendered.contains("not found") {
|
|
format!("Bucket {bucket} was not found.")
|
|
} else {
|
|
credentials_or_prefix_error()
|
|
}
|
|
}
|
|
_ => PREFIX_ACCESS_ERROR_MSG.to_string(),
|
|
}
|
|
}
|
|
|
|
async fn post_install_github_token_test(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(input): Json<GithubTokenTestInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
if input.token.trim().is_empty() {
|
|
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, "token is required");
|
|
}
|
|
|
|
match validate_github_token(&state, input.token.trim()).await {
|
|
Ok(username) => Json(serde_json::json!({ "username": username })).into_response(),
|
|
Err(err) => {
|
|
warn!(error = ?err, "install GitHub token validation failed");
|
|
install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err.to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn put_install_github_token(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(input): Json<GithubTokenInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
if input.token.trim().is_empty() || input.username.trim().is_empty() {
|
|
return (
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
Json(serde_json::json!({ "error": "token and username are required" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
lock_unpoisoned(&state.pending_install, "install session").github =
|
|
Some(GithubInstallState::Token(input));
|
|
info!(step = "github_token", "install step completed");
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
async fn post_install_github_app_manifest(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
Json(input): Json<GithubAppManifestInput>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
|
|
let owner = match GitHubAppOwner::try_from(input.owner) {
|
|
Ok(owner) => owner,
|
|
Err(err) => {
|
|
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err);
|
|
}
|
|
};
|
|
if input.app_name.trim().is_empty() {
|
|
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, "app_name is required");
|
|
}
|
|
if input.allowed_username.trim().is_empty() {
|
|
return install_error_response(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"allowed_username is required",
|
|
);
|
|
}
|
|
|
|
let mut pending_install = lock_unpoisoned(&state.pending_install, "install session");
|
|
let Some(server) = pending_install.server.clone() else {
|
|
return missing_step_response("server");
|
|
};
|
|
|
|
let state_token = generate_ephemeral_secret();
|
|
let manifest = build_github_app_manifest(
|
|
input.app_name.trim(),
|
|
&format!("{}/install/github/app/redirect", server.canonical_url),
|
|
&format!("{}/auth/callback/github", server.canonical_url),
|
|
&format!("{}/setup", server.canonical_url),
|
|
);
|
|
|
|
pending_install.pending_github_app = Some(PendingGithubApp {
|
|
state: state_token.clone(),
|
|
owner: owner.clone(),
|
|
app_name: input.app_name.trim().to_string(),
|
|
allowed_username: input.allowed_username.trim().to_string(),
|
|
expires_at: Instant::now() + Duration::from_mins(10),
|
|
});
|
|
|
|
Json(serde_json::json!({
|
|
"manifest": manifest,
|
|
"github_form_action": owner.manifest_form_action(),
|
|
"state": state_token,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn get_install_github_app_redirect(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<GithubAppRedirectQuery>,
|
|
) -> Response {
|
|
observe_operator(&state, &headers);
|
|
|
|
let Some(state_token) = query
|
|
.state
|
|
.as_deref()
|
|
.filter(|value| !value.trim().is_empty())
|
|
else {
|
|
return install_github_redirect_error(&state, "missing-install-github-app-state");
|
|
};
|
|
let Some(code) = query
|
|
.code
|
|
.as_deref()
|
|
.filter(|value| !value.trim().is_empty())
|
|
else {
|
|
return install_github_redirect_error(&state, "missing-install-github-app-code");
|
|
};
|
|
|
|
let pending = {
|
|
let pending_install = lock_unpoisoned(&state.pending_install, "install session");
|
|
let Some(pending) = pending_install.pending_github_app.clone() else {
|
|
return install_github_redirect_error(&state, "missing-install-github-app-state");
|
|
};
|
|
if pending.expires_at <= Instant::now() {
|
|
return install_github_redirect_error(&state, "expired-install-github-app-state");
|
|
}
|
|
if pending.state != state_token {
|
|
return install_github_redirect_error(&state, "invalid-install-github-app-state");
|
|
}
|
|
pending
|
|
};
|
|
|
|
match exchange_github_app_manifest_code(&state, code).await {
|
|
Ok(conversion) => {
|
|
let mut pending_install = lock_unpoisoned(&state.pending_install, "install session");
|
|
let Some(still_pending) = pending_install.pending_github_app.as_ref() else {
|
|
return install_github_redirect_error(&state, "missing-install-github-app-state");
|
|
};
|
|
if still_pending.state != pending.state {
|
|
return install_github_redirect_error(&state, "invalid-install-github-app-state");
|
|
}
|
|
pending_install.pending_github_app = None;
|
|
pending_install.github = Some(GithubInstallState::App(GithubAppInstall {
|
|
owner: pending.owner,
|
|
app_name: pending.app_name,
|
|
allowed_username: pending.allowed_username,
|
|
app_id: conversion.id.to_string(),
|
|
slug: conversion.slug,
|
|
client_id: conversion.client_id,
|
|
client_secret: conversion.client_secret,
|
|
webhook_secret: conversion.webhook_secret,
|
|
pem: conversion.pem,
|
|
}));
|
|
info!(step = "github_app", "install step completed");
|
|
(StatusCode::FOUND, [(
|
|
header::LOCATION,
|
|
format!("/install/github/done?token={}", &*state.install_token),
|
|
)])
|
|
.into_response()
|
|
}
|
|
Err(err) => {
|
|
error!(error = ?err, "install GitHub app exchange failed");
|
|
install_github_redirect_error(&state, "github-app-manifest-conversion-failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn post_install_finish(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<InstallTokenQuery>,
|
|
) -> Response {
|
|
if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) {
|
|
return response;
|
|
}
|
|
observe_operator(&state, &headers);
|
|
let Some(finish_guard) = InstallFinishGuard::try_acquire(Arc::clone(&state.finish_in_progress))
|
|
else {
|
|
return install_error_response(StatusCode::CONFLICT, "install finish already in progress");
|
|
};
|
|
|
|
let pending_install = lock_unpoisoned(&state.pending_install, "install session").clone();
|
|
|
|
let Some(server) = pending_install.server else {
|
|
return missing_step_response("server");
|
|
};
|
|
let Some(object_store) = pending_install.object_store else {
|
|
return missing_step_response("object_store");
|
|
};
|
|
let Some(sandbox) = pending_install.sandbox else {
|
|
return missing_step_response("sandbox");
|
|
};
|
|
let Some(llm) = pending_install.llm else {
|
|
return missing_step_response("llm");
|
|
};
|
|
let Some(github) = pending_install.github else {
|
|
return missing_step_response("github");
|
|
};
|
|
|
|
let mut settings_doc = toml::Value::Table(toml::Table::default());
|
|
let install_listen = state.install_listen_config();
|
|
if let Err(err) =
|
|
merge_server_settings(&mut settings_doc, &server.canonical_url, &install_listen)
|
|
{
|
|
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
let object_store_env_plan = match write_object_store_settings(
|
|
&mut settings_doc,
|
|
&object_store.to_persistence_selection(),
|
|
) {
|
|
Ok(plan) => plan,
|
|
Err(err) => {
|
|
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
};
|
|
if let Err(err) = write_sandbox_settings(
|
|
&mut settings_doc,
|
|
sandbox.to_persistence_selection(),
|
|
sandbox.allow_local,
|
|
) {
|
|
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
let mut vault_secrets = Vec::new();
|
|
if let InstallSandboxProviderState::Daytona { api_key } = &sandbox.provider {
|
|
vault_secrets.push(SecretStoreWrite {
|
|
name: EnvVars::DAYTONA_API_KEY.to_string(),
|
|
value: api_key.expose_secret().to_string(),
|
|
secret_type: VaultSecretType::Token,
|
|
description: None,
|
|
});
|
|
}
|
|
for provider in llm.providers {
|
|
let name = match provider_secret_name(&provider.provider) {
|
|
Ok(name) => name,
|
|
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
|
|
};
|
|
vault_secrets.push(SecretStoreWrite {
|
|
name,
|
|
value: provider.api_key,
|
|
secret_type: VaultSecretType::Token,
|
|
description: None,
|
|
});
|
|
}
|
|
|
|
let make_env_write = |key: &str, value: String| EnvFileUpdate {
|
|
key: key.to_string(),
|
|
value,
|
|
comment: None,
|
|
};
|
|
let make_env_removal = |key: &str| EnvFileRemoval {
|
|
key: key.to_string(),
|
|
comment: None,
|
|
};
|
|
let mut server_env_writes = object_store_env_plan.writes;
|
|
let mut server_env_removals = object_store_env_plan.removals;
|
|
let mut vault_removals = Vec::new();
|
|
let mut dev_token: Option<String> = None;
|
|
let mut dev_token_write = None;
|
|
match github {
|
|
GithubInstallState::Token(github) => {
|
|
if let Err(err) = write_token_settings(&mut settings_doc) {
|
|
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
vault_secrets.push(SecretStoreWrite {
|
|
name: EnvVars::GITHUB_TOKEN.to_string(),
|
|
value: github.token,
|
|
secret_type: VaultSecretType::Token,
|
|
description: None,
|
|
});
|
|
vault_removals.extend(GITHUB_APP_VAULT_KEYS.iter().map(|k| (*k).to_string()));
|
|
server_env_removals.extend(
|
|
GITHUB_INSTALL_SECRET_KEYS
|
|
.iter()
|
|
.map(|k| make_env_removal(k)),
|
|
);
|
|
let dev_token_path = Storage::new(state.storage_dir.as_ref())
|
|
.runtime_directory()
|
|
.dev_token_path();
|
|
let prepared = match prepare_dev_token_write_for_install(&dev_token_path) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
return install_error_response(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
err.to_string(),
|
|
);
|
|
}
|
|
};
|
|
dev_token_write = prepared.write;
|
|
dev_token = Some(prepared.token);
|
|
}
|
|
GithubInstallState::App(github) => {
|
|
if let Err(err) = write_github_app_settings(
|
|
&mut settings_doc,
|
|
&github.app_id,
|
|
&github.slug,
|
|
&github.client_id,
|
|
&[github.allowed_username],
|
|
) {
|
|
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
vault_secrets.push(SecretStoreWrite {
|
|
name: EnvVars::GITHUB_APP_PRIVATE_KEY.to_string(),
|
|
value: BASE64_STANDARD.encode(github.pem.as_bytes()),
|
|
secret_type: VaultSecretType::File,
|
|
description: None,
|
|
});
|
|
vault_secrets.push(SecretStoreWrite {
|
|
name: EnvVars::GITHUB_APP_CLIENT_SECRET.to_string(),
|
|
value: github.client_secret,
|
|
secret_type: VaultSecretType::Token,
|
|
description: None,
|
|
});
|
|
if let Some(secret) = github.webhook_secret {
|
|
vault_secrets.push(SecretStoreWrite {
|
|
name: EnvVars::GITHUB_APP_WEBHOOK_SECRET.to_string(),
|
|
value: secret,
|
|
secret_type: VaultSecretType::Token,
|
|
description: None,
|
|
});
|
|
} else {
|
|
vault_removals.push(EnvVars::GITHUB_APP_WEBHOOK_SECRET.to_string());
|
|
}
|
|
vault_removals.push(EnvVars::GITHUB_TOKEN.to_string());
|
|
server_env_removals.extend(
|
|
GITHUB_INSTALL_SECRET_KEYS
|
|
.iter()
|
|
.map(|k| make_env_removal(k)),
|
|
);
|
|
}
|
|
}
|
|
|
|
let settings_toml = match toml::to_string_pretty(&settings_doc) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
};
|
|
|
|
let session_secret = session_secret::generate_session_secret();
|
|
server_env_writes.push(make_env_write(EnvVars::SESSION_SECRET, session_secret));
|
|
if let Some(token) = dev_token.as_ref() {
|
|
server_env_writes.push(make_env_write(EnvVars::FABRO_DEV_TOKEN, token.clone()));
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "install-finish handler: reads current settings file once to produce a rollback \
|
|
snapshot before writing the new settings; one-shot per install-finish request"
|
|
)]
|
|
let previous_settings = std::fs::read_to_string(state.config_path.as_ref()).ok();
|
|
|
|
let persistence_plan = InstallPersistencePlan {
|
|
storage_dir: state.storage_dir.as_ref(),
|
|
settings_write: Some(PendingSettingsWrite {
|
|
path: state.config_path.as_ref(),
|
|
contents: &settings_toml,
|
|
previous_contents: previous_settings.as_deref(),
|
|
}),
|
|
server_env_writes,
|
|
server_env_removals,
|
|
dev_token_write,
|
|
vault_writes: vault_secrets,
|
|
vault_removals,
|
|
};
|
|
if let Err(err) = persistence_plan.persist_direct().await {
|
|
error!(error = %err, "install persistence failed");
|
|
let status = StatusCode::INTERNAL_SERVER_ERROR;
|
|
let detail = err.to_string();
|
|
let title = status.canonical_reason().unwrap_or("Unknown").to_string();
|
|
let leftover_env_keys: Vec<String> = if err.server_env_applied {
|
|
persistence_plan
|
|
.server_env_writes
|
|
.iter()
|
|
.map(|write| write.key.clone())
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let removed_env_keys: Vec<String> = if err.server_env_applied {
|
|
err.removed_env_keys.clone()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
return (
|
|
status,
|
|
Json(serde_json::json!({
|
|
"errors": [{
|
|
"status": status.as_u16().to_string(),
|
|
"title": title,
|
|
"detail": detail,
|
|
}],
|
|
"leftover_env_keys": leftover_env_keys,
|
|
"removed_env_keys": removed_env_keys,
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// Seed the default environment in SQLite. The server does not seed on
|
|
// startup, so install is the only place the default is written; existing
|
|
// rows are preserved, so re-running install never clobbers edits.
|
|
if let Err(err) = seed_default_environment_in_storage(
|
|
state.storage_dir.as_ref(),
|
|
sandbox.to_environment_provider(),
|
|
)
|
|
.await
|
|
{
|
|
warn!(error = %err, "failed to seed default environment after install");
|
|
}
|
|
|
|
if let Ok(settings) = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml) {
|
|
if let Err(err) = write_artifact_store_metadata(&settings, state.storage_dir.as_ref()).await
|
|
{
|
|
warn!(error = %err, "failed to write artifact store metadata after install");
|
|
}
|
|
}
|
|
if let Some(InstallObjectStoreState::S3 {
|
|
credential_mode: InstallObjectStoreCredentialMode::AccessKey,
|
|
manual_credentials,
|
|
..
|
|
}) = lock_unpoisoned(&state.pending_install, "install session")
|
|
.object_store
|
|
.as_mut()
|
|
{
|
|
*manual_credentials = None;
|
|
}
|
|
{
|
|
let mut pending_install = lock_unpoisoned(&state.pending_install, "install session");
|
|
if let Some(sandbox) = pending_install.sandbox.as_mut() {
|
|
if matches!(
|
|
sandbox.provider,
|
|
InstallSandboxProviderState::Daytona { .. }
|
|
) {
|
|
sandbox.provider = InstallSandboxProviderState::Docker;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(finish_hook) = state.finish_hook.clone() {
|
|
let info = InstallFinishInfo {
|
|
canonical_url: server.canonical_url.clone(),
|
|
dev_token: dev_token.clone(),
|
|
};
|
|
if let Err(err) = finish_hook(&info) {
|
|
warn!(error = %err, "install finish hook failed");
|
|
}
|
|
}
|
|
|
|
if let Some(on_finish) = state.on_finish.clone() {
|
|
info!(restart_url = %server.canonical_url, "install finish succeeded");
|
|
info!("install exit scheduled");
|
|
tokio::spawn(async move {
|
|
sleep(Duration::from_millis(500)).await;
|
|
on_finish();
|
|
});
|
|
} else {
|
|
info!(restart_url = %server.canonical_url, "install finish succeeded");
|
|
}
|
|
finish_guard.disarm();
|
|
|
|
let mut body = serde_json::json!({
|
|
"status": "completing",
|
|
"restart_url": server.canonical_url,
|
|
});
|
|
if let Some(token) = dev_token {
|
|
body["dev_token"] = serde_json::Value::String(token);
|
|
}
|
|
(StatusCode::ACCEPTED, Json(body)).into_response()
|
|
}
|
|
|
|
async fn render_install_shell(
|
|
State(state): State<InstallAppState>,
|
|
headers: HeaderMap,
|
|
uri: OriginalUri,
|
|
) -> Response {
|
|
static_files::serve_install_with_asset_root(
|
|
uri.path(),
|
|
&headers,
|
|
state.static_asset_root.as_deref(),
|
|
false,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn token_is_valid(state: &InstallAppState, headers: &HeaderMap, query_token: Option<&str>) -> bool {
|
|
[
|
|
headers
|
|
.get("authorization")
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.strip_prefix("Bearer ")),
|
|
query_token,
|
|
headers
|
|
.get("x-install-token")
|
|
.and_then(|value| value.to_str().ok()),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.any(|token| token == &*state.install_token)
|
|
}
|
|
|
|
fn require_valid_token(
|
|
state: &InstallAppState,
|
|
headers: &HeaderMap,
|
|
query_token: Option<&str>,
|
|
) -> Option<Response> {
|
|
(!token_is_valid(state, headers, query_token))
|
|
.then(|| ApiError::new(StatusCode::UNAUTHORIZED, "invalid install token").into_response())
|
|
}
|
|
|
|
fn observe_operator(state: &InstallAppState, headers: &HeaderMap) {
|
|
let current = InstallOperatorFingerprint {
|
|
user_agent: headers
|
|
.get(header::USER_AGENT)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToString::to_string),
|
|
remote_ip: detect_remote_ip(headers),
|
|
};
|
|
if current.user_agent.is_none() && current.remote_ip.is_none() {
|
|
return;
|
|
}
|
|
|
|
let mut first = lock_unpoisoned(&state.first_operator, "install operator");
|
|
match first.as_ref() {
|
|
None => *first = Some(current),
|
|
Some(initial) if initial != ¤t => {
|
|
warn!(
|
|
initial_user_agent = ?initial.user_agent,
|
|
current_user_agent = ?current.user_agent,
|
|
initial_remote_ip = ?initial.remote_ip,
|
|
current_remote_ip = ?current.remote_ip,
|
|
"suspected concurrent install operators"
|
|
);
|
|
}
|
|
Some(_) => {}
|
|
}
|
|
}
|
|
|
|
fn detect_remote_ip(headers: &HeaderMap) -> Option<String> {
|
|
headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.split(',').next())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToString::to_string)
|
|
}
|
|
|
|
fn install_github_redirect_error(state: &InstallAppState, error: &str) -> Response {
|
|
(StatusCode::FOUND, [(
|
|
header::LOCATION,
|
|
format!(
|
|
"/install/github?token={}&error={error}",
|
|
state.install_token
|
|
),
|
|
)])
|
|
.into_response()
|
|
}
|
|
|
|
fn detect_canonical_url(headers: &HeaderMap) -> String {
|
|
let scheme = headers
|
|
.get("x-forwarded-proto")
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.split(',').next())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or("http");
|
|
|
|
let host = headers
|
|
.get("x-forwarded-host")
|
|
.or_else(|| headers.get("host"))
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.split(',').next())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or("127.0.0.1:32276");
|
|
|
|
format!("{scheme}://{}", sanitize_client_facing_host(host))
|
|
}
|
|
|
|
fn sanitize_client_facing_host(host: &str) -> String {
|
|
let host = host.trim();
|
|
if let Some(end) = host
|
|
.strip_prefix('[')
|
|
.and_then(|rest| rest.find(']').map(|end| end + 1))
|
|
{
|
|
let address = &host[1..end];
|
|
let suffix = &host[end + 1..];
|
|
if is_wildcard_host(address) {
|
|
return format!("localhost{suffix}");
|
|
}
|
|
return host.to_string();
|
|
}
|
|
|
|
if let Some((address, port)) = host.rsplit_once(':') {
|
|
if !address.contains(':') && is_wildcard_host(address) {
|
|
return format!("localhost:{port}");
|
|
}
|
|
}
|
|
|
|
if is_wildcard_host(host) {
|
|
return "localhost".to_string();
|
|
}
|
|
|
|
host.to_string()
|
|
}
|
|
|
|
fn completed_steps(pending_install: &PendingInstall) -> Vec<&'static str> {
|
|
let mut steps = Vec::new();
|
|
if pending_install.server.is_some() {
|
|
steps.push("server");
|
|
}
|
|
if pending_install.object_store.is_some() {
|
|
steps.push("object_store");
|
|
}
|
|
if pending_install.sandbox.is_some() {
|
|
steps.push("sandbox");
|
|
}
|
|
if pending_install.llm.is_some() {
|
|
steps.push("llm");
|
|
}
|
|
if pending_install.github.is_some() {
|
|
steps.push("github");
|
|
}
|
|
steps
|
|
}
|
|
|
|
fn redacted_llm(pending_install: &PendingInstall) -> serde_json::Value {
|
|
pending_install.llm.as_ref().map_or_else(
|
|
|| serde_json::Value::Null,
|
|
|llm| {
|
|
serde_json::json!({
|
|
"providers": llm.providers.iter().map(|provider| serde_json::json!({
|
|
"provider": provider.provider.to_string(),
|
|
"configured": true,
|
|
})).collect::<Vec<_>>()
|
|
})
|
|
},
|
|
)
|
|
}
|
|
|
|
fn redacted_github(pending_install: &PendingInstall) -> serde_json::Value {
|
|
pending_install.github.as_ref().map_or_else(
|
|
|| serde_json::Value::Null,
|
|
|github| match github {
|
|
GithubInstallState::Token(github) => serde_json::json!({
|
|
"strategy": "token",
|
|
"username": github.username,
|
|
}),
|
|
GithubInstallState::App(github) => serde_json::json!({
|
|
"strategy": "app",
|
|
"owner": github.owner.as_session_value(),
|
|
"app_name": github.app_name,
|
|
"slug": github.slug,
|
|
"allowed_username": github.allowed_username,
|
|
}),
|
|
},
|
|
)
|
|
}
|
|
|
|
fn redacted_object_store(pending_install: &PendingInstall) -> serde_json::Value {
|
|
pending_install.object_store.as_ref().map_or_else(
|
|
|| serde_json::Value::Null,
|
|
InstallObjectStoreState::as_session_value,
|
|
)
|
|
}
|
|
|
|
fn redacted_sandbox(pending_install: &PendingInstall) -> serde_json::Value {
|
|
pending_install.sandbox.as_ref().map_or_else(
|
|
|| serde_json::Value::Null,
|
|
InstallSandboxState::as_session_value,
|
|
)
|
|
}
|
|
|
|
fn missing_step_response(step: &str) -> Response {
|
|
ApiError::new(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
format!("install step '{step}' is incomplete"),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn install_error_response(status: StatusCode, message: impl Into<String>) -> Response {
|
|
ApiError::new(status, message).into_response()
|
|
}
|
|
|
|
fn generate_ephemeral_secret() -> String {
|
|
URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>())
|
|
}
|
|
|
|
fn build_github_app_manifest(
|
|
app_name: &str,
|
|
redirect_url: &str,
|
|
callback_url: &str,
|
|
setup_url: &str,
|
|
) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"name": app_name,
|
|
"url": "https://fabro.sh",
|
|
"redirect_url": redirect_url,
|
|
"callback_urls": [callback_url],
|
|
"setup_url": setup_url,
|
|
"public": false,
|
|
"default_permissions": {
|
|
"contents": "write",
|
|
"metadata": "read",
|
|
"pull_requests": "write",
|
|
"checks": "write",
|
|
"issues": "write",
|
|
"emails": "read",
|
|
"vulnerability_alerts": "write",
|
|
"organization_projects": "write",
|
|
"packages": "read"
|
|
},
|
|
"default_events": []
|
|
})
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
reason = "Install HTTP client selection parses a public upstream base URL only to decide localhost proxy behavior."
|
|
)]
|
|
fn install_http_client_for_url(base_url: &str) -> anyhow::Result<fabro_http::HttpClient> {
|
|
let mut builder = fabro_http::HttpClientBuilder::new();
|
|
if fabro_http::Url::parse(base_url)
|
|
.ok()
|
|
.and_then(|url| url.host_str().map(ToString::to_string))
|
|
.is_some_and(|host| host == "127.0.0.1" || host == "localhost")
|
|
{
|
|
builder = builder.no_proxy();
|
|
}
|
|
builder.build().map_err(anyhow::Error::new)
|
|
}
|
|
|
|
/// Parse and validate an install-time upstream URL.
|
|
///
|
|
/// In production the base URL comes from installer defaults and provider
|
|
/// catalog base-url settings.
|
|
/// Only test code can override via
|
|
/// [`InstallAppState::with_github_api_base_url`]
|
|
/// or [`InstallAppState::with_provider_base_url`], but CodeQL sees those
|
|
/// `pub` setters as external entry points and traces taint into the
|
|
/// `format!` URL construction sites below. Passing every upstream URL
|
|
/// through this parser turns it into a typed `Url` with a verified scheme
|
|
/// and host before it is combined with a path segment.
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
reason = "Install upstream endpoints are raw HTTP request URLs; logging uses separate redacted boundaries."
|
|
)]
|
|
fn parse_install_upstream_url(raw: &str) -> anyhow::Result<fabro_http::Url> {
|
|
let url = fabro_http::Url::parse(raw).map_err(anyhow::Error::new)?;
|
|
match url.scheme() {
|
|
"http" | "https" => {}
|
|
other => {
|
|
bail!("install upstream URL must use http or https, got {other}");
|
|
}
|
|
}
|
|
if url.host_str().is_none() {
|
|
bail!("install upstream URL must include a host");
|
|
}
|
|
Ok(url)
|
|
}
|
|
|
|
/// Append `segments` as new path segments to a validated base URL.
|
|
///
|
|
/// Each segment is percent-encoded by `url`, so caller-controlled values
|
|
/// (e.g. a GitHub manifest `code`) cannot insert additional path components,
|
|
/// alter the host, or redirect the request to a different URL scheme.
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
reason = "Install upstream endpoints are raw HTTP request URLs; logging uses separate redacted boundaries."
|
|
)]
|
|
fn install_upstream_endpoint(base_url: &str, segments: &[&str]) -> anyhow::Result<fabro_http::Url> {
|
|
let mut url = parse_install_upstream_url(base_url)?;
|
|
{
|
|
let mut path = url
|
|
.path_segments_mut()
|
|
.map_err(|()| anyhow!("install upstream URL cannot be a base"))?;
|
|
for segment in segments {
|
|
path.push(segment);
|
|
}
|
|
}
|
|
Ok(url)
|
|
}
|
|
|
|
async fn validate_llm_provider(
|
|
state: &InstallAppState,
|
|
input: &InstallLlmTestInput,
|
|
) -> anyhow::Result<()> {
|
|
let provider = install_catalog_provider(&input.provider).map_err(anyhow::Error::msg)?;
|
|
let catalog = install_catalog_with_base_url(state, provider)?;
|
|
let outcome = probe::probe_provider_with_api_key(
|
|
catalog,
|
|
provider.id(),
|
|
input.api_key.clone(),
|
|
Duration::from_secs(30),
|
|
)
|
|
.await
|
|
.map_err(|err| match err {
|
|
ApiKeyProbeError::Setup(err) => {
|
|
anyhow::Error::new(err).context("failed to create LLM client for install validation")
|
|
}
|
|
other => anyhow::Error::msg(other.to_string()),
|
|
})?;
|
|
match outcome.status {
|
|
ModelTestStatus::Ok => Ok(()),
|
|
ModelTestStatus::Error => Err(anyhow::anyhow!(
|
|
"LLM provider validation request failed: {}",
|
|
outcome
|
|
.error_message
|
|
.unwrap_or_else(|| "unknown error".to_string())
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// The install catalog with the provider's base URL replaced by the state
|
|
/// override, when the install flow points a provider at a test upstream.
|
|
fn install_catalog_with_base_url(
|
|
state: &InstallAppState,
|
|
provider: &CatalogProvider,
|
|
) -> anyhow::Result<Catalog> {
|
|
let Some(base_url) = state.upstreams.provider_base_urls.get(provider.id()) else {
|
|
return Ok(Catalog::clone(&INSTALL_CATALOG));
|
|
};
|
|
let overlay = fabro_config::LlmLayer(
|
|
toml::from_str(&format!(
|
|
"[providers.{}]\nbase_url = {}\n",
|
|
toml_key(provider.id().as_str()),
|
|
toml::Value::String(base_url.clone())
|
|
))
|
|
.context("install provider base URL overlay should parse")?,
|
|
);
|
|
fabro_llm::build_catalog(&overlay, &|_| None)
|
|
.context("install catalog with provider base URL override should build")
|
|
}
|
|
|
|
fn toml_key(key: &str) -> String {
|
|
if key
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
{
|
|
key.to_string()
|
|
} else {
|
|
format!("{key:?}")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn provider_base_url_override(state: &InstallAppState, provider: &CatalogProvider) -> String {
|
|
state
|
|
.upstreams
|
|
.provider_base_urls
|
|
.get(provider.id())
|
|
.cloned()
|
|
.unwrap_or_else(|| provider.base_url().to_string())
|
|
}
|
|
|
|
async fn validate_github_token(state: &InstallAppState, token: &str) -> anyhow::Result<String> {
|
|
let base_url = state
|
|
.upstreams
|
|
.github_api_base_url
|
|
.clone()
|
|
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
|
|
let endpoint = install_upstream_endpoint(&base_url, &["user"])?;
|
|
let client = install_http_client_for_url(&base_url)?;
|
|
let response = client
|
|
.get(endpoint)
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.header("Accept", "application/vnd.github+json")
|
|
.header("User-Agent", "fabro-server")
|
|
.send()
|
|
.await
|
|
.map_err(anyhow::Error::new)?;
|
|
if !response.status().is_success() {
|
|
bail!("GitHub returned {}", response.status());
|
|
}
|
|
let body: GithubUserResponse = response
|
|
.json()
|
|
.await
|
|
.context("Failed to parse GitHub user response")?;
|
|
Ok(body.login)
|
|
}
|
|
|
|
async fn exchange_github_app_manifest_code(
|
|
state: &InstallAppState,
|
|
code: &str,
|
|
) -> anyhow::Result<GitHubAppManifestConversion> {
|
|
if !is_valid_github_manifest_code(code) {
|
|
bail!("install GitHub manifest code is not in the expected format");
|
|
}
|
|
let base_url = state
|
|
.upstreams
|
|
.github_api_base_url
|
|
.clone()
|
|
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
|
|
let endpoint = install_upstream_endpoint(&base_url, &["app-manifests", code, "conversions"])?;
|
|
let client = install_http_client_for_url(&base_url)?;
|
|
let response = client
|
|
.post(endpoint)
|
|
.header("Accept", "application/vnd.github+json")
|
|
.header("User-Agent", "fabro-server")
|
|
.send()
|
|
.await
|
|
.map_err(anyhow::Error::new)?;
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let _ = response.text().await;
|
|
bail!("GitHub manifest conversion failed ({status})");
|
|
}
|
|
response
|
|
.json()
|
|
.await
|
|
.context("Failed to parse GitHub manifest conversion response")
|
|
}
|
|
|
|
/// GitHub's manifest-conversion `code` is short, unpadded-base64url by
|
|
/// construction. Reject anything outside that alphabet so a malicious
|
|
/// browser callback cannot smuggle extra path segments, host overrides, or
|
|
/// query parameters into the request.
|
|
fn is_valid_github_manifest_code(code: &str) -> bool {
|
|
!code.is_empty()
|
|
&& code.len() <= 256
|
|
&& code
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
}
|
|
|
|
async fn write_artifact_store_metadata(
|
|
settings: &ServerSettings,
|
|
storage_dir: &Path,
|
|
) -> anyhow::Result<()> {
|
|
let mut settings = settings.clone();
|
|
settings.server.storage.root = storage_dir.display().to_string();
|
|
let (object_store, prefix) = serve::build_artifact_object_store(&settings.server)?;
|
|
let artifact_store = ArtifactStore::new(object_store, prefix);
|
|
artifact_store.write_metadata(FABRO_VERSION).await?;
|
|
Ok(())
|
|
}
|
|
|
|
struct InstallListener {
|
|
listener: BoundInstallListener,
|
|
bind: Bind,
|
|
}
|
|
|
|
enum BoundInstallListener {
|
|
Unix(UnixListener),
|
|
Tcp(TcpListener),
|
|
}
|
|
|
|
async fn bind_install_listener(requested: &BindRequest) -> anyhow::Result<InstallListener> {
|
|
match requested {
|
|
BindRequest::Unix(path) => {
|
|
if path.exists() {
|
|
std::fs::remove_file(path)?;
|
|
}
|
|
let listener = UnixListener::bind(path)?;
|
|
Ok(InstallListener {
|
|
listener: BoundInstallListener::Unix(listener),
|
|
bind: Bind::Unix(path.clone()),
|
|
})
|
|
}
|
|
BindRequest::Tcp(address) => {
|
|
let listener = TcpListener::bind(address).await?;
|
|
Ok(InstallListener {
|
|
bind: Bind::Tcp(listener.local_addr()?),
|
|
listener: BoundInstallListener::Tcp(listener),
|
|
})
|
|
}
|
|
BindRequest::TcpHost(host) => {
|
|
let listener = TcpListener::bind((*host, DEFAULT_TCP_PORT)).await?;
|
|
Ok(InstallListener {
|
|
bind: Bind::Tcp(listener.local_addr()?),
|
|
listener: BoundInstallListener::Tcp(listener),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_for_shutdown(mut shutdown_rx: watch::Receiver<bool>) {
|
|
if *shutdown_rx.borrow() {
|
|
return;
|
|
}
|
|
let _ = shutdown_rx.changed().await;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
use std::io;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use axum::extract::{Query, State};
|
|
use axum::http::{HeaderMap, StatusCode};
|
|
use base64::Engine as _;
|
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
|
use fabro_config::{Storage, envfile};
|
|
use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV};
|
|
use fabro_static::EnvVars;
|
|
use fabro_vault::SecretType as VaultSecretType;
|
|
use object_store::Error as ObjectStoreError;
|
|
use serde_json::json;
|
|
|
|
use super::{
|
|
DEFAULT_INSTALL_GITHUB_API_BASE_URL, GitHubAppOwner, GithubAppInstall, GithubInstallState,
|
|
InstallAppState, InstallAwsCredentialPair, InstallFinishGuard,
|
|
InstallObjectStoreCredentialMode, InstallObjectStoreInput, InstallObjectStoreProvider,
|
|
InstallObjectStoreState, InstallSandboxProviderState, InstallSandboxState,
|
|
InstallTokenQuery, LlmProvidersInput, PendingInstall, ServerConfigInput, ServerSecrets,
|
|
build_github_app_manifest, classify_object_store_validation_error, detect_canonical_url,
|
|
install_catalog_provider, install_object_store_lookup, lock_unpoisoned,
|
|
post_install_finish, provider_base_url_override, resolve_install_object_store_state,
|
|
token_is_valid, write_artifact_store_metadata,
|
|
};
|
|
|
|
#[test]
|
|
fn github_app_manifest_excludes_workflows_permission() {
|
|
let manifest = build_github_app_manifest(
|
|
"Fabro Test",
|
|
"https://fabro.example/setup",
|
|
"https://fabro.example/auth/callback/github",
|
|
"https://fabro.example/setup",
|
|
);
|
|
|
|
assert!(
|
|
manifest["default_permissions"].get("workflows").is_none(),
|
|
"GitHub App must not be able to write workflow files"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn github_app_manifest_includes_packages_read_permission() {
|
|
let manifest = build_github_app_manifest(
|
|
"Fabro Test",
|
|
"https://fabro.example/setup",
|
|
"https://fabro.example/auth/callback/github",
|
|
"https://fabro.example/setup",
|
|
);
|
|
|
|
assert_eq!(
|
|
manifest["default_permissions"]["packages"],
|
|
serde_json::json!("read"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn token_validation_accepts_any_matching_source() {
|
|
let state = InstallAppState::for_test("expected");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("authorization", "Bearer wrong".parse().unwrap());
|
|
headers.insert("x-install-token", "also-wrong".parse().unwrap());
|
|
|
|
assert!(token_is_valid(&state, &headers, Some("expected")));
|
|
}
|
|
|
|
#[test]
|
|
fn token_validation_falls_back_to_custom_header() {
|
|
let state = InstallAppState::for_test("expected");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("authorization", "Bearer wrong".parse().unwrap());
|
|
headers.insert("x-install-token", "expected".parse().unwrap());
|
|
|
|
assert!(token_is_valid(&state, &headers, None));
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_url_prefers_forwarded_headers() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-forwarded-proto", "https".parse().unwrap());
|
|
headers.insert("x-forwarded-host", "fabro.example.com".parse().unwrap());
|
|
|
|
assert_eq!(detect_canonical_url(&headers), "https://fabro.example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn token_validation_requires_exact_match() {
|
|
let state = InstallAppState::for_test("expected");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("authorization", "Bearer expected".parse().unwrap());
|
|
assert!(token_is_valid(&state, &headers, None));
|
|
|
|
headers.insert("authorization", "Bearer wrong".parse().unwrap());
|
|
assert!(!token_is_valid(&state, &headers, None));
|
|
}
|
|
|
|
#[test]
|
|
fn pending_install_lock_recovers_after_poison() {
|
|
let pending = Arc::new(Mutex::new(PendingInstall::default()));
|
|
let poisoned = Arc::clone(&pending);
|
|
let _ = std::panic::catch_unwind(move || {
|
|
let _guard = poisoned.lock().unwrap();
|
|
panic!("poison install lock");
|
|
});
|
|
|
|
let _guard = lock_unpoisoned(&pending, "install session");
|
|
}
|
|
|
|
#[test]
|
|
fn finish_guard_rejects_concurrent_finish_calls() {
|
|
let flag = Arc::new(AtomicBool::new(false));
|
|
let first = InstallFinishGuard::try_acquire(Arc::clone(&flag));
|
|
assert!(first.is_some());
|
|
assert!(InstallFinishGuard::try_acquire(Arc::clone(&flag)).is_none());
|
|
drop(first);
|
|
assert!(InstallFinishGuard::try_acquire(flag).is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn finish_with_github_app_writes_runtime_secrets_to_vault_not_server_env() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = Storage::new(dir.path());
|
|
let config_path = dir.path().join("settings.toml");
|
|
let server_env_path = storage.runtime_directory().env_path();
|
|
envfile::write_env_file(
|
|
&server_env_path,
|
|
&HashMap::from([
|
|
(
|
|
EnvVars::GITHUB_APP_PRIVATE_KEY.to_string(),
|
|
"stale-private".to_string(),
|
|
),
|
|
(
|
|
EnvVars::GITHUB_APP_CLIENT_SECRET.to_string(),
|
|
"stale-client".to_string(),
|
|
),
|
|
(
|
|
EnvVars::GITHUB_APP_WEBHOOK_SECRET.to_string(),
|
|
"stale-webhook".to_string(),
|
|
),
|
|
]),
|
|
)
|
|
.unwrap();
|
|
|
|
let mut stale_vault = fabro_vault::Vault::load(storage.secrets_path()).unwrap();
|
|
stale_vault
|
|
.set(
|
|
EnvVars::GITHUB_TOKEN,
|
|
"stale-token",
|
|
VaultSecretType::Token,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let state = InstallAppState::for_test_with_paths("install-token", dir.path(), &config_path);
|
|
{
|
|
let mut pending = lock_unpoisoned(&state.pending_install, "install session");
|
|
pending.server = Some(ServerConfigInput {
|
|
canonical_url: "https://fabro.example".to_string(),
|
|
});
|
|
pending.object_store = Some(InstallObjectStoreState::Local {
|
|
root: dir.path().join("runs").display().to_string(),
|
|
});
|
|
pending.sandbox = Some(InstallSandboxState {
|
|
provider: InstallSandboxProviderState::Docker,
|
|
allow_local: true,
|
|
});
|
|
pending.llm = Some(LlmProvidersInput {
|
|
providers: Vec::new(),
|
|
});
|
|
pending.github = Some(GithubInstallState::App(GithubAppInstall {
|
|
owner: GitHubAppOwner::Personal,
|
|
app_name: "Fabro Test".to_string(),
|
|
allowed_username: "octocat".to_string(),
|
|
app_id: "12345".to_string(),
|
|
slug: "fabro-test".to_string(),
|
|
client_id: "Iv1.test".to_string(),
|
|
client_secret: "vault-client-secret".to_string(),
|
|
webhook_secret: Some("vault-webhook-secret".to_string()),
|
|
pem: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
|
|
.to_string(),
|
|
}));
|
|
}
|
|
|
|
let response = post_install_finish(
|
|
State(state),
|
|
HeaderMap::new(),
|
|
Query(InstallTokenQuery {
|
|
token: Some("install-token".to_string()),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
|
let server_env = envfile::read_env_file(&server_env_path).unwrap();
|
|
assert!(server_env.contains_key(EnvVars::SESSION_SECRET));
|
|
assert!(!server_env.contains_key(EnvVars::GITHUB_APP_PRIVATE_KEY));
|
|
assert!(!server_env.contains_key(EnvVars::GITHUB_APP_CLIENT_SECRET));
|
|
assert!(!server_env.contains_key(EnvVars::GITHUB_APP_WEBHOOK_SECRET));
|
|
|
|
let vault =
|
|
fabro_vault::SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(vault.get(EnvVars::GITHUB_TOKEN), None);
|
|
assert_eq!(
|
|
vault.get(EnvVars::GITHUB_APP_CLIENT_SECRET),
|
|
Some("vault-client-secret")
|
|
);
|
|
assert_eq!(
|
|
vault.get(EnvVars::GITHUB_APP_WEBHOOK_SECRET),
|
|
Some("vault-webhook-secret")
|
|
);
|
|
let private_key_entry = vault
|
|
.get_entry(EnvVars::GITHUB_APP_PRIVATE_KEY)
|
|
.expect("private key should be stored in vault");
|
|
assert_eq!(private_key_entry.secret_type, VaultSecretType::File);
|
|
assert_eq!(
|
|
private_key_entry.value,
|
|
BASE64_STANDARD.encode(
|
|
"-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n".as_bytes()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_github_requests_default_to_fixed_github_api_base_url() {
|
|
assert_eq!(
|
|
DEFAULT_INSTALL_GITHUB_API_BASE_URL,
|
|
"https://api.github.com"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_provider_base_url_falls_back_to_catalog_base_url() {
|
|
let state = InstallAppState::for_test("expected");
|
|
let provider = install_catalog_provider(&lithos_llm::catalog::builtin::openai()).unwrap();
|
|
|
|
assert_eq!(
|
|
provider_base_url_override(&state, provider),
|
|
"https://api.openai.com"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_provider_base_url_prefers_state_override() {
|
|
let state = InstallAppState::for_test("expected").with_provider_base_url(
|
|
lithos_llm::catalog::builtin::openai(),
|
|
"https://proxy.example.com/v1",
|
|
);
|
|
let provider = install_catalog_provider(&lithos_llm::catalog::builtin::openai()).unwrap();
|
|
|
|
assert_eq!(
|
|
provider_base_url_override(&state, provider),
|
|
"https://proxy.example.com/v1"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_artifact_store_metadata_creates_marker_in_overridden_storage_root() {
|
|
use object_store::path::Path as ObjectPath;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let settings = fabro_config::ServerSettingsBuilder::from_toml(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = ["dev-token"]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
write_artifact_store_metadata(&settings, dir.path())
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut overridden = settings.clone();
|
|
overridden.server.storage.root = dir.path().display().to_string();
|
|
let (object_store, prefix) =
|
|
crate::serve::build_artifact_object_store(&overridden.server).unwrap();
|
|
let marker = if prefix.is_empty() {
|
|
"store-metadata.json".to_string()
|
|
} else {
|
|
format!("{prefix}/store-metadata.json")
|
|
};
|
|
let bytes = object_store
|
|
.get(&ObjectPath::from(marker))
|
|
.await
|
|
.unwrap()
|
|
.bytes()
|
|
.await
|
|
.unwrap();
|
|
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
assert_eq!(value["fabro_version"], super::FABRO_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_install_object_store_state_rejects_local_with_s3_fields() {
|
|
let err = resolve_install_object_store_state(
|
|
None,
|
|
InstallObjectStoreInput {
|
|
provider: InstallObjectStoreProvider::Local,
|
|
root: Some("/srv/fabro/objects".to_string()),
|
|
bucket: Some("fabro-data".to_string()),
|
|
region: None,
|
|
credential_mode: None,
|
|
access_key_id: None,
|
|
secret_access_key: None,
|
|
},
|
|
"/srv/fabro/objects",
|
|
)
|
|
.expect_err("local mode should reject S3-only fields");
|
|
|
|
assert_eq!(
|
|
err,
|
|
"Local disk does not accept S3 bucket, region, or AWS credential fields."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_install_object_store_state_uses_local_root() {
|
|
let selection = resolve_install_object_store_state(
|
|
None,
|
|
InstallObjectStoreInput {
|
|
provider: InstallObjectStoreProvider::Local,
|
|
root: Some(" /srv/fabro/objects ".to_string()),
|
|
bucket: None,
|
|
region: None,
|
|
credential_mode: None,
|
|
access_key_id: None,
|
|
secret_access_key: None,
|
|
},
|
|
"/default/fabro/objects",
|
|
)
|
|
.expect("local mode should accept a root");
|
|
|
|
assert!(matches!(
|
|
selection,
|
|
InstallObjectStoreState::Local { ref root } if root == "/srv/fabro/objects"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_install_object_store_state_rejects_runtime_with_submitted_access_keys() {
|
|
let err = resolve_install_object_store_state(
|
|
None,
|
|
InstallObjectStoreInput {
|
|
provider: InstallObjectStoreProvider::S3,
|
|
root: None,
|
|
bucket: Some("fabro-data".to_string()),
|
|
region: Some("us-east-1".to_string()),
|
|
credential_mode: Some(InstallObjectStoreCredentialMode::Runtime),
|
|
access_key_id: Some("AKIA_FAKE_VALUE".to_string()),
|
|
secret_access_key: Some("fake-secret-value".to_string()),
|
|
},
|
|
"/srv/fabro/objects",
|
|
)
|
|
.expect_err("runtime mode should reject submitted access keys");
|
|
|
|
assert_eq!(
|
|
err,
|
|
"AWS access key fields are only allowed when using manual AWS access key credentials."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "sync std::fs::write in a unit test fixture; not on a Tokio path"
|
|
)]
|
|
fn install_object_store_lookup_overrides_static_keys_and_suppresses_session_token() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let env_path = temp_dir.path().join("server.env");
|
|
std::fs::write(
|
|
&env_path,
|
|
"\
|
|
AWS_ACCESS_KEY_ID=ambient-access\n\
|
|
AWS_SECRET_ACCESS_KEY=ambient-secret\n\
|
|
AWS_SESSION_TOKEN=ambient-session\n\
|
|
AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/fabro-web-identity-token\n",
|
|
)
|
|
.unwrap();
|
|
let server_secrets = ServerSecrets::load(env_path.clone(), HashMap::new()).unwrap();
|
|
let manual_credentials =
|
|
InstallAwsCredentialPair::new("submitted-access", "submitted-secret");
|
|
|
|
let lookup = install_object_store_lookup(&server_secrets, Some(&manual_credentials));
|
|
|
|
assert_eq!(
|
|
lookup(OBJECT_STORE_ACCESS_KEY_ID_ENV).as_deref(),
|
|
Some("submitted-access")
|
|
);
|
|
assert_eq!(
|
|
lookup(OBJECT_STORE_SECRET_ACCESS_KEY_ENV).as_deref(),
|
|
Some("submitted-secret")
|
|
);
|
|
assert_eq!(lookup(EnvVars::AWS_SESSION_TOKEN), None);
|
|
assert_eq!(
|
|
lookup(EnvVars::AWS_WEB_IDENTITY_TOKEN_FILE).as_deref(),
|
|
Some("/tmp/fabro-web-identity-token")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_object_store_validation_error_reports_region_mismatch() {
|
|
let err = ObjectStoreError::Generic {
|
|
store: "AmazonS3",
|
|
source: Box::new(io::Error::other(
|
|
"Received redirect without LOCATION, this normally indicates an incorrectly configured region",
|
|
)),
|
|
};
|
|
|
|
assert_eq!(
|
|
classify_object_store_validation_error("fabro-data", "us-east-1", 0, &err),
|
|
"Bucket fabro-data is not reachable in region us-east-1. Verify the AWS region and try again."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_secret_debug_display_and_json_are_redacted() {
|
|
let manual_credentials =
|
|
InstallAwsCredentialPair::new("AKIA_STRUCTURALLY_REALISTIC", "secret-value-123");
|
|
|
|
let debug = format!("{manual_credentials:?}");
|
|
let rendered = json!({
|
|
"access_key_id": &manual_credentials.access_key_id,
|
|
"secret_access_key": &manual_credentials.secret_access_key,
|
|
})
|
|
.to_string();
|
|
|
|
assert!(!debug.contains("AKIA_STRUCTURALLY_REALISTIC"));
|
|
assert!(!debug.contains("secret-value-123"));
|
|
assert!(!rendered.contains("AKIA_STRUCTURALLY_REALISTIC"));
|
|
assert!(!rendered.contains("secret-value-123"));
|
|
assert!(rendered.contains("[REDACTED]"));
|
|
}
|
|
}
|