fix(install): expose local object store root

This commit is contained in:
Bryan Helmkamp 2026-04-24 12:38:04 -04:00
parent b975a802dc
commit 533785cd4c
11 changed files with 412 additions and 191 deletions

View file

@ -6,6 +6,10 @@ import InstallApp from "./install-app";
const INSTALL_ERROR_MESSAGE =
"GitHub App setup failed before Fabro could save the app credentials. Continue again to retry the callback.";
const INSTALL_PREFILL = {
canonical_url: "https://fabro.example.com",
object_store_local_root: "/home/test/.fabro/storage/objects",
};
const SESSION_RESPONSE = {
completed_steps: ["server", "object_store", "llm"],
@ -13,7 +17,7 @@ const SESSION_RESPONSE = {
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
github: null,
prefill: { canonical_url: "https://fabro.example.com" },
prefill: INSTALL_PREFILL,
};
type TestWindow = {
@ -201,7 +205,7 @@ describe("InstallApp", () => {
slug: "fabro-brynary",
allowed_username: "brynary",
},
prefill: { canonical_url: "https://fabro.example.com" },
prefill: INSTALL_PREFILL,
};
const fetchMock = mock((input: RequestInfo | URL) => {
expect(String(input)).toBe("/install/session");
@ -270,7 +274,7 @@ describe("InstallApp", () => {
server: { canonical_url: "https://fabro.example.com" },
object_store: null,
github: null,
prefill: { canonical_url: "https://fabro.example.com" },
prefill: INSTALL_PREFILL,
}),
{
status: 200,
@ -291,7 +295,7 @@ describe("InstallApp", () => {
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
github: null,
prefill: { canonical_url: "https://fabro.example.com" },
prefill: INSTALL_PREFILL,
}),
{
status: 200,
@ -345,7 +349,12 @@ describe("InstallApp", () => {
"/install/object-store",
"/install/session",
]);
expect(fetchCalls[1]?.init?.body).toBe(JSON.stringify({ provider: "local" }));
expect(fetchCalls[1]?.init?.body).toBe(
JSON.stringify({
provider: "local",
root: INSTALL_PREFILL.object_store_local_root,
}),
);
await act(async () => {
renderer?.unmount();
@ -384,7 +393,7 @@ describe("InstallApp", () => {
manual_credentials_saved: true,
},
github: null,
prefill: { canonical_url: "https://fabro.example.com" },
prefill: INSTALL_PREFILL,
}),
{
status: 200,
@ -459,7 +468,7 @@ describe("InstallApp", () => {
manual_credentials_saved: true,
},
github: { strategy: "token", username: "octocat" },
prefill: { canonical_url: "https://fabro.example.com" },
prefill: INSTALL_PREFILL,
}),
{
status: 200,

View file

@ -84,6 +84,7 @@ type ObjectStoreProvider = "local" | "s3";
type ObjectStoreCredentialMode = "runtime" | "access_key";
type ObjectStoreForm = {
provider: ObjectStoreProvider;
localRoot: string;
bucket: string;
region: string;
credentialMode: ObjectStoreCredentialMode;
@ -120,6 +121,7 @@ export default function InstallApp() {
const [finishState, setFinishState] = useState<FinishState>(null);
const [timedOut, setTimedOut] = useState(false);
const canonicalUrlInputRef = useRef<HTMLInputElement>(null);
const localRootInputRef = useRef<HTMLInputElement>(null);
const bucketInputRef = useRef<HTMLInputElement>(null);
const regionInputRef = useRef<HTMLInputElement>(null);
const accessKeyIdInputRef = useRef<HTMLInputElement>(null);
@ -421,7 +423,13 @@ export default function InstallApp() {
}
backHref="/install/server"
onSubmit={async () => {
if (objectStoreForm.provider === "s3") {
if (objectStoreForm.provider === "local") {
if (!objectStoreForm.localRoot.trim()) {
setSaveError("Enter the local object-store directory before continuing.");
focusInput(localRootInputRef);
return;
}
} else {
if (!objectStoreForm.bucket.trim()) {
setSaveError("Enter the S3 bucket before continuing.");
focusInput(bucketInputRef);
@ -473,6 +481,8 @@ export default function InstallApp() {
setObjectStoreForm((current) => ({ ...current, provider }));
if (provider === "s3") {
focusInput(bucketInputRef);
} else {
focusInput(localRootInputRef);
}
}}
/>
@ -574,9 +584,31 @@ export default function InstallApp() {
)}
</div>
) : (
<p className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-3 outline-1 -outline-offset-1 outline-white/10">
Fabro will keep using local disk for both SlateDB and run artifacts.
</p>
<div className="space-y-3">
<Field
label="Local directory"
hint="Shared root for SlateDB and run artifacts."
>
<input
ref={localRootInputRef}
name="object_store_local_root"
value={objectStoreForm.localRoot}
onChange={(event) =>
setObjectStoreForm((current) => ({
...current,
localRoot: event.target.value,
}))
}
className={`${INPUT_CLASS} font-mono`}
placeholder="Local object-store directory"
spellCheck={false}
autoCapitalize="off"
/>
</Field>
<p className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-3 outline-1 -outline-offset-1 outline-white/10">
Fabro will store SlateDB and run artifacts under this directory.
</p>
</div>
)}
</StepPanel>
) : location.pathname === "/install/github/done" ? (
@ -1614,9 +1646,10 @@ function defaultProviderSelection(): ProviderSelection {
);
}
function defaultObjectStoreForm(): ObjectStoreForm {
function defaultObjectStoreForm(localRoot = ""): ObjectStoreForm {
return {
provider: "local",
localRoot,
bucket: "",
region: "",
credentialMode: "runtime",
@ -1643,10 +1676,13 @@ function hydrateProviderSelection(
function hydrateObjectStoreForm(session: InstallSessionResponse): ObjectStoreForm {
const summary = session.object_store;
if (!summary || summary.provider === "local") {
return defaultObjectStoreForm();
return defaultObjectStoreForm(
summary?.root ?? session.prefill.object_store_local_root,
);
}
return {
provider: "s3",
localRoot: session.prefill.object_store_local_root,
bucket: summary.bucket ?? "",
region: summary.region ?? "",
credentialMode: summary.credential_mode === "access_key" ? "access_key" : "runtime",
@ -1658,7 +1694,7 @@ function hydrateObjectStoreForm(session: InstallSessionResponse): ObjectStoreFor
function buildObjectStorePayload(form: ObjectStoreForm): InstallObjectStoreInput {
if (form.provider === "local") {
return { provider: "local" };
return { provider: "local", root: form.localRoot.trim() };
}
const payload: InstallObjectStoreInput = {
@ -1727,7 +1763,12 @@ function renderObjectStoreSummaryRows(
return <SummaryRow label="Object store" value="Not configured" />;
}
if (objectStore.provider === "local") {
return <SummaryRow label="Object store" value="Local disk" />;
return (
<>
<SummaryRow label="Object store" value="Local disk" />
<SummaryRow label="Directory" value={objectStore.root ?? "Not set"} mono />
</>
);
}
return (
<>

View file

@ -192,8 +192,9 @@ region = "us-east-1"
```
The browser install wizard's `Object store` step manages both `[server.slatedb]` and
`[server.artifacts]` together. `Local disk` leaves both sections implicit and keeps the built-in
local defaults. `AWS S3` writes one shared bucket with fixed prefixes `slatedb` and `artifacts`.
`[server.artifacts]` together. `Local disk` uses the detected local object-store root, defaulting
to `<storage_root>/objects`, with fixed prefixes `slatedb` and `artifacts`. `AWS S3` writes one
shared bucket with the same fixed prefixes.
The wizard only covers AWS S3 bucket/region plus one of:

View file

@ -2556,10 +2556,13 @@ components:
type: object
required:
- canonical_url
- object_store_local_root
properties:
canonical_url:
type: string
format: uri
object_store_local_root:
type: string
InstallLlmValidationResponse:
description: Successful response from install-time LLM credential validation.
@ -2655,6 +2658,8 @@ components:
provider:
type: string
enum: [local, s3]
root:
type: string
bucket:
type: string
region:
@ -2676,6 +2681,8 @@ components:
provider:
type: string
enum: [local, s3]
root:
type: string
bucket:
type: string
region:

View file

@ -41,7 +41,9 @@ pub enum InstallObjectStoreCredentialMode {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallObjectStoreSelection {
Local,
Local {
root: String,
},
S3 {
bucket: String,
region: String,
@ -287,15 +289,44 @@ fn write_s3_store_settings(
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 => Ok(InstallObjectStoreEnvPlan {
writes: Vec::new(),
removals: object_store_env_removals(),
}),
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,
@ -671,8 +702,10 @@ name = "custom"
#[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)
.expect("local object store selection should succeed");
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::Local {
root: String::new(),
})
.expect("local object store selection should succeed");
assert!(
doc.get("server")
@ -684,6 +717,74 @@ name = "custom"
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());

View file

@ -230,6 +230,7 @@ enum InstallObjectStoreCredentialMode {
#[derive(Clone, Debug, Deserialize)]
struct InstallObjectStoreInput {
provider: InstallObjectStoreProvider,
root: Option<String>,
bucket: Option<String>,
region: Option<String>,
credential_mode: Option<InstallObjectStoreCredentialMode>,
@ -297,7 +298,9 @@ impl std::fmt::Debug for InstallAwsCredentialPair {
#[derive(Clone, Debug)]
enum InstallObjectStoreState {
Local,
Local {
root: String,
},
S3 {
bucket: String,
region: String,
@ -309,8 +312,9 @@ enum InstallObjectStoreState {
impl InstallObjectStoreState {
fn as_session_value(&self) -> serde_json::Value {
match self {
Self::Local => serde_json::json!({
Self::Local { root } => serde_json::json!({
"provider": "local",
"root": root,
}),
Self::S3 {
bucket,
@ -332,7 +336,9 @@ impl InstallObjectStoreState {
fn to_persistence_selection(&self) -> fabro_install::InstallObjectStoreSelection {
match self {
Self::Local => fabro_install::InstallObjectStoreSelection::Local,
Self::Local { root } => {
fabro_install::InstallObjectStoreSelection::Local { root: root.clone() }
}
Self::S3 {
bucket,
region,
@ -648,6 +654,7 @@ async fn get_install_session(
"github": redacted_github(&pending_install),
"prefill": {
"canonical_url": detect_canonical_url(&headers),
"object_store_local_root": default_local_object_store_root(&state),
}
}))
.into_response()
@ -766,7 +773,11 @@ async fn post_install_object_store_test(
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) {
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),
}
@ -793,11 +804,14 @@ async fn put_install_object_store(
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) {
Ok(selection) => selection,
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
};
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");
@ -810,10 +824,21 @@ fn trim_install_field(value: Option<String>) -> Option<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_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);
@ -832,7 +857,13 @@ fn resolve_install_object_store_state(
.to_string(),
);
}
Ok(InstallObjectStoreState::Local)
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())?;
@ -900,7 +931,7 @@ fn object_store_validation_settings(
selection: &InstallObjectStoreState,
) -> Option<ObjectStoreSettings> {
match selection {
InstallObjectStoreState::Local => None,
InstallObjectStoreState::Local { .. } => None,
InstallObjectStoreState::S3 { bucket, region, .. } => Some(ObjectStoreSettings::S3 {
bucket: InterpString::parse(bucket),
region: InterpString::parse(region),
@ -935,7 +966,7 @@ async fn validate_install_object_store_selection(
};
let (bucket, region, manual_credentials) = match selection {
InstallObjectStoreState::Local => return Ok(()),
InstallObjectStoreState::Local { .. } => return Ok(()),
InstallObjectStoreState::S3 {
bucket,
region,
@ -1962,10 +1993,10 @@ mod tests {
use super::{
AWS_SESSION_TOKEN_ENV, DEFAULT_INSTALL_GITHUB_API_BASE_URL, InstallAppState,
InstallAwsCredentialPair, InstallFinishGuard, InstallObjectStoreCredentialMode,
InstallObjectStoreInput, InstallObjectStoreProvider, PendingInstall, ServerSecrets,
classify_object_store_validation_error, detect_canonical_url, install_object_store_lookup,
lock_unpoisoned, resolve_install_object_store_state, token_is_valid,
write_artifact_store_metadata,
InstallObjectStoreInput, InstallObjectStoreProvider, InstallObjectStoreState,
PendingInstall, ServerSecrets, classify_object_store_validation_error,
detect_canonical_url, install_object_store_lookup, lock_unpoisoned,
resolve_install_object_store_state, token_is_valid, write_artifact_store_metadata,
};
#[test]
@ -2080,14 +2111,19 @@ methods = ["dev-token"]
#[test]
fn resolve_install_object_store_state_rejects_local_with_s3_fields() {
let err = resolve_install_object_store_state(None, InstallObjectStoreInput {
provider: InstallObjectStoreProvider::Local,
bucket: Some("fabro-data".to_string()),
region: None,
credential_mode: None,
access_key_id: None,
secret_access_key: None,
})
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!(
@ -2096,16 +2132,44 @@ methods = ["dev-token"]
);
}
#[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,
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()),
})
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!(

View file

@ -58,7 +58,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-rwnsw2a9.js"></script>
<script type="module" src="/assets/entry-mvfx1rhm.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -19,6 +19,7 @@
*/
export interface InstallObjectStoreInput {
'provider': InstallObjectStoreInputProviderEnum;
'root'?: string;
'bucket'?: string;
'region'?: string;
'credential_mode'?: InstallObjectStoreInputCredentialModeEnum;
@ -38,5 +39,3 @@ export const InstallObjectStoreInputCredentialModeEnum = {
} as const;
export type InstallObjectStoreInputCredentialModeEnum = typeof InstallObjectStoreInputCredentialModeEnum[keyof typeof InstallObjectStoreInputCredentialModeEnum];

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -19,6 +19,7 @@
*/
export interface InstallObjectStoreSummary {
'provider': InstallObjectStoreSummaryProviderEnum;
'root'?: string;
'bucket'?: string;
'region'?: string;
'credential_mode'?: InstallObjectStoreSummaryCredentialModeEnum;
@ -37,5 +38,3 @@ export const InstallObjectStoreSummaryCredentialModeEnum = {
} as const;
export type InstallObjectStoreSummaryCredentialModeEnum = typeof InstallObjectStoreSummaryCredentialModeEnum[keyof typeof InstallObjectStoreSummaryCredentialModeEnum];

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -19,5 +19,5 @@
*/
export interface InstallPrefill {
'canonical_url': string;
'object_store_local_root': string;
}