mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
feat(install): add object-store step to web install
This commit is contained in:
parent
18cfd1b92d
commit
4bf0c40319
28 changed files with 6319 additions and 2562 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2070,6 +2070,7 @@ dependencies = [
|
|||
"url",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ minijinja = "2"
|
|||
fabro-http = { path = "lib/crates/fabro-http" }
|
||||
graphviz-sys = { git = "https://github.com/fabro-sh/graphviz-sys" }
|
||||
strum = { version = "0.28", features = ["derive"] }
|
||||
zeroize = "1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { readInstallError } from "./install-api";
|
||||
import {
|
||||
putInstallObjectStore,
|
||||
readInstallError,
|
||||
testInstallObjectStore,
|
||||
} from "./install-api";
|
||||
|
||||
describe("readInstallError", () => {
|
||||
test("prefers the structured install error payload", async () => {
|
||||
|
|
@ -31,3 +35,60 @@ describe("readInstallError", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("install object-store requests", () => {
|
||||
test("testInstallObjectStore posts the install payload to the validation endpoint", async () => {
|
||||
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ input, init });
|
||||
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
||||
}) as typeof fetch;
|
||||
|
||||
await testInstallObjectStore("test-install-token", {
|
||||
provider: "s3",
|
||||
bucket: "fabro-data",
|
||||
region: "us-east-1",
|
||||
credential_mode: "runtime",
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(String(calls[0]!.input)).toBe("/install/object-store/test");
|
||||
expect(calls[0]!.init?.method).toBe("POST");
|
||||
expect(calls[0]!.init?.headers).toEqual({
|
||||
Authorization: "Bearer test-install-token",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(calls[0]!.init?.body).toBe(
|
||||
JSON.stringify({
|
||||
provider: "s3",
|
||||
bucket: "fabro-data",
|
||||
region: "us-east-1",
|
||||
credential_mode: "runtime",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("putInstallObjectStore surfaces structured API errors", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [
|
||||
{
|
||||
status: "422",
|
||||
title: "Unprocessable Entity",
|
||||
detail: "Bucket is required.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 422,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
)) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
putInstallObjectStore("test-install-token", { provider: "s3" }),
|
||||
).rejects.toThrow("Bucket is required.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type {
|
|||
InstallGithubAppManifestResponse,
|
||||
InstallGithubAppOwner,
|
||||
InstallLlmProviderInput,
|
||||
InstallObjectStoreInput,
|
||||
InstallObjectStoreSummary,
|
||||
InstallSessionResponse,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
|
|
@ -13,6 +15,8 @@ export type {
|
|||
InstallGithubAppManifestResponse,
|
||||
InstallGithubAppOwner,
|
||||
InstallLlmProviderInput,
|
||||
InstallObjectStoreInput,
|
||||
InstallObjectStoreSummary,
|
||||
InstallSessionResponse,
|
||||
};
|
||||
|
||||
|
|
@ -111,6 +115,38 @@ export async function putInstallServer(token: string, canonicalUrl: string): Pro
|
|||
}
|
||||
}
|
||||
|
||||
export async function testInstallObjectStore(
|
||||
token: string,
|
||||
input: InstallObjectStoreInput,
|
||||
): Promise<void> {
|
||||
const response = await installFetch("/install/object-store/test", token, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readInstallError(response, "install object store validation failed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function putInstallObjectStore(
|
||||
token: string,
|
||||
input: InstallObjectStoreInput,
|
||||
): Promise<void> {
|
||||
const response = await installFetch("/install/object-store", token, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readInstallError(response, "install object store request failed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testInstallGithubToken(
|
||||
token: string,
|
||||
githubToken: string,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ const INSTALL_ERROR_MESSAGE =
|
|||
"GitHub App setup failed before Fabro could save the app credentials. Continue again to retry the callback.";
|
||||
|
||||
const SESSION_RESPONSE = {
|
||||
completed_steps: ["llm", "server"],
|
||||
completed_steps: ["server", "object_store", "llm"],
|
||||
llm: null,
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: { provider: "local" },
|
||||
github: null,
|
||||
prefill: { canonical_url: "https://fabro.example.com" },
|
||||
};
|
||||
|
|
@ -189,9 +190,10 @@ describe("InstallApp", () => {
|
|||
// Simulate the server-side /install/github/app/redirect handler having
|
||||
// just run — session returns a fully-populated `github.app` payload.
|
||||
const sessionResponse = {
|
||||
completed_steps: ["llm", "server", "github"],
|
||||
completed_steps: ["server", "object_store", "llm", "github"],
|
||||
llm: null,
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: { provider: "local" },
|
||||
github: {
|
||||
strategy: "app",
|
||||
owner: { kind: "personal" },
|
||||
|
|
@ -242,4 +244,261 @@ describe("InstallApp", () => {
|
|||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("saves local disk object-store settings and advances to the LLM step", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("react-test-renderer is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
const fetchMock = mock((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
if (String(input) === "/install/session" && fetchCalls.length === 1) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server"],
|
||||
llm: null,
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: null,
|
||||
github: null,
|
||||
prefill: { canonical_url: "https://fabro.example.com" },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (String(input) === "/install/object-store") {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
if (String(input) === "/install/session" && fetchCalls.length === 3) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server", "object_store"],
|
||||
llm: null,
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: { provider: "local" },
|
||||
github: null,
|
||||
prefill: { canonical_url: "https://fabro.example.com" },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/object-store");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
(globalThis as { window?: unknown }).window = testWindow;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<MemoryRouter initialEntries={["/install/object-store"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain(
|
||||
"Choose the shared object store",
|
||||
);
|
||||
});
|
||||
|
||||
const form = renderer!.root.findByType("form");
|
||||
await act(async () => {
|
||||
form.props.onSubmit({ preventDefault() {} });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
|
||||
});
|
||||
const backLink = renderer!.root.findAll(
|
||||
(node) =>
|
||||
node.type === "a" &&
|
||||
node.props.href === "/install/object-store" &&
|
||||
node.children.includes("Back"),
|
||||
);
|
||||
expect(backLink).toHaveLength(1);
|
||||
expect(fetchCalls.map((call) => String(call.input))).toEqual([
|
||||
"/install/session",
|
||||
"/install/object-store",
|
||||
"/install/session",
|
||||
]);
|
||||
expect(fetchCalls[1]?.init?.body).toBe(JSON.stringify({ provider: "local" }));
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("rehydrates saved manual S3 credentials without exposing the secrets", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("react-test-renderer is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
const fetchMock = mock((input: RequestInfo | URL) => {
|
||||
expect(String(input)).toBe("/install/session");
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server", "object_store"],
|
||||
llm: null,
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: {
|
||||
provider: "s3",
|
||||
bucket: "fabro-data",
|
||||
region: "us-east-1",
|
||||
credential_mode: "access_key",
|
||||
manual_credentials_saved: true,
|
||||
},
|
||||
github: null,
|
||||
prefill: { canonical_url: "https://fabro.example.com" },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/object-store");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
(globalThis as { window?: unknown }).window = testWindow;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<MemoryRouter initialEntries={["/install/object-store"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain(
|
||||
"Credentials saved. Leave both fields blank to keep them, or enter both fields to replace them.",
|
||||
);
|
||||
});
|
||||
|
||||
expect(renderer!.root.findByProps({ name: "aws_access_key_id" }).props.value).toBe("");
|
||||
expect(renderer!.root.findByProps({ name: "aws_secret_access_key" }).props.value).toBe("");
|
||||
expect(renderTreeText(renderer!.toJSON())).not.toContain("AKIA");
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the redacted object-store summary on the review step", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("react-test-renderer is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
const fetchMock = mock((input: RequestInfo | URL) => {
|
||||
expect(String(input)).toBe("/install/session");
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server", "object_store", "llm", "github"],
|
||||
llm: {
|
||||
providers: [{ provider: "anthropic" }],
|
||||
},
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: {
|
||||
provider: "s3",
|
||||
bucket: "fabro-data",
|
||||
region: "us-east-1",
|
||||
credential_mode: "access_key",
|
||||
manual_credentials_saved: true,
|
||||
},
|
||||
github: { strategy: "token", username: "octocat" },
|
||||
prefill: { canonical_url: "https://fabro.example.com" },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/review");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
(globalThis as { window?: unknown }).window = testWindow;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<MemoryRouter initialEntries={["/install/review"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const text = renderTreeText(renderer!.toJSON());
|
||||
expect(text).toContain("AWS S3");
|
||||
expect(text).toContain("fabro-data");
|
||||
expect(text).toContain("us-east-1");
|
||||
expect(text).toContain("Access key");
|
||||
expect(text).toContain("slatedb/, artifacts/");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, ReactNode, Ref } from "react";
|
||||
import { Link, Navigate, useLocation, useNavigate } from "react-router";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
type InstallFinishResponse,
|
||||
type InstallGithubAppOwner,
|
||||
type InstallLlmProviderInput,
|
||||
type InstallObjectStoreInput,
|
||||
type InstallSessionResponse,
|
||||
createInstallGithubAppManifest,
|
||||
finishInstall,
|
||||
|
|
@ -25,10 +26,12 @@ import {
|
|||
persistInstallToken,
|
||||
putInstallGithubToken,
|
||||
putInstallLlm,
|
||||
putInstallObjectStore,
|
||||
putInstallServer,
|
||||
readStoredInstallToken,
|
||||
testInstallGithubToken,
|
||||
testInstallLlm,
|
||||
testInstallObjectStore,
|
||||
} from "./install-api";
|
||||
import { INSTALL_PROVIDERS } from "./install-config";
|
||||
import { shouldRedirectAfterHealthPoll } from "./install-flow";
|
||||
|
|
@ -49,6 +52,7 @@ import { LoadingState } from "./components/state";
|
|||
const INSTALL_STEPS = [
|
||||
{ id: "welcome", label: "Welcome", href: "/install/welcome" },
|
||||
{ id: "server", label: "Server", href: "/install/server" },
|
||||
{ id: "object_store", label: "Object store", href: "/install/object-store" },
|
||||
{ id: "llm", label: "LLMs", href: "/install/llm" },
|
||||
{ id: "github", label: "GitHub", href: "/install/github" },
|
||||
{ id: "review", label: "Review", href: "/install/review" },
|
||||
|
|
@ -76,6 +80,17 @@ type AppForm = {
|
|||
};
|
||||
|
||||
type ProviderSelection = Record<string, { apiKey: string }>;
|
||||
type ObjectStoreProvider = "local" | "s3";
|
||||
type ObjectStoreCredentialMode = "runtime" | "access_key";
|
||||
type ObjectStoreForm = {
|
||||
provider: ObjectStoreProvider;
|
||||
bucket: string;
|
||||
region: string;
|
||||
credentialMode: ObjectStoreCredentialMode;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
manualCredentialsSaved: boolean;
|
||||
};
|
||||
|
||||
export default function InstallApp() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -89,6 +104,9 @@ export default function InstallApp() {
|
|||
const [llmSelection, setLlmSelection] = useState<ProviderSelection>(() =>
|
||||
defaultProviderSelection(),
|
||||
);
|
||||
const [objectStoreForm, setObjectStoreForm] = useState<ObjectStoreForm>(() =>
|
||||
defaultObjectStoreForm(),
|
||||
);
|
||||
const [canonicalUrl, setCanonicalUrl] = useState("");
|
||||
const [githubStrategy, setGithubStrategy] = useState<GithubStrategy>("token");
|
||||
const [tokenForm, setTokenForm] = useState<TokenForm>({ token: "", username: "" });
|
||||
|
|
@ -101,6 +119,11 @@ export default function InstallApp() {
|
|||
const [submitting, setSubmitting] = useState(false);
|
||||
const [finishState, setFinishState] = useState<FinishState>(null);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
const canonicalUrlInputRef = useRef<HTMLInputElement>(null);
|
||||
const bucketInputRef = useRef<HTMLInputElement>(null);
|
||||
const regionInputRef = useRef<HTMLInputElement>(null);
|
||||
const accessKeyIdInputRef = useRef<HTMLInputElement>(null);
|
||||
const secretAccessKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href);
|
||||
|
|
@ -138,6 +161,7 @@ export default function InstallApp() {
|
|||
setCanonicalUrl((current) =>
|
||||
current || nextSession.server?.canonical_url || nextSession.prefill.canonical_url,
|
||||
);
|
||||
setObjectStoreForm(hydrateObjectStoreForm(nextSession));
|
||||
setLlmSelection((current) =>
|
||||
hydrateProviderSelection(current, nextSession),
|
||||
);
|
||||
|
|
@ -224,6 +248,7 @@ export default function InstallApp() {
|
|||
}, [finishState]);
|
||||
|
||||
const currentStep = useMemo<StepId>(() => {
|
||||
if (location.pathname.startsWith("/install/object-store")) return "object_store";
|
||||
if (location.pathname.startsWith("/install/llm")) return "llm";
|
||||
if (location.pathname.startsWith("/install/server")) return "server";
|
||||
if (location.pathname.startsWith("/install/github")) return "github";
|
||||
|
|
@ -300,7 +325,7 @@ export default function InstallApp() {
|
|||
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank."
|
||||
error={saveError}
|
||||
submitting={submitting}
|
||||
backHref="/install/server"
|
||||
backHref="/install/object-store"
|
||||
onSubmit={async () => {
|
||||
const providers = INSTALL_PROVIDERS.map(({ id }) => {
|
||||
const current = llmSelection[id] ?? { apiKey: "" };
|
||||
|
|
@ -346,6 +371,7 @@ export default function InstallApp() {
|
|||
onSubmit={async () => {
|
||||
if (!canonicalUrl.trim()) {
|
||||
setSaveError("Enter the canonical server URL before continuing.");
|
||||
focusInput(canonicalUrlInputRef);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
|
|
@ -354,7 +380,7 @@ export default function InstallApp() {
|
|||
await putInstallServer(installToken, canonicalUrl.trim());
|
||||
const nextSession = await getInstallSession(installToken);
|
||||
setSessionState({ status: "ready", data: nextSession });
|
||||
navigate("/install/llm");
|
||||
navigate("/install/object-store");
|
||||
} catch (error) {
|
||||
setSaveError(
|
||||
error instanceof Error ? error.message : "Failed to save server settings.",
|
||||
|
|
@ -371,6 +397,7 @@ export default function InstallApp() {
|
|||
<input
|
||||
type="url"
|
||||
name="canonical_url"
|
||||
ref={canonicalUrlInputRef}
|
||||
value={canonicalUrl}
|
||||
onChange={(event) => setCanonicalUrl(event.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
|
|
@ -380,6 +407,176 @@ export default function InstallApp() {
|
|||
/>
|
||||
</Field>
|
||||
</StepPanel>
|
||||
) : location.pathname === "/install/object-store" ? (
|
||||
<StepPanel
|
||||
title="Choose the shared object store"
|
||||
description="This configures the shared backend for both SlateDB and run artifacts. Fabro still keeps its local storage root on disk."
|
||||
error={saveError}
|
||||
submitting={submitting}
|
||||
submittingLabel={
|
||||
objectStoreForm.provider === "s3" ? "Checking access..." : "Saving..."
|
||||
}
|
||||
backHref="/install/server"
|
||||
onSubmit={async () => {
|
||||
if (objectStoreForm.provider === "s3") {
|
||||
if (!objectStoreForm.bucket.trim()) {
|
||||
setSaveError("Enter the S3 bucket before continuing.");
|
||||
focusInput(bucketInputRef);
|
||||
return;
|
||||
}
|
||||
if (!objectStoreForm.region.trim()) {
|
||||
setSaveError("Enter the AWS region before continuing.");
|
||||
focusInput(regionInputRef);
|
||||
return;
|
||||
}
|
||||
if (objectStoreForm.credentialMode === "access_key") {
|
||||
const accessKeyId = objectStoreForm.accessKeyId.trim();
|
||||
const secretAccessKey = objectStoreForm.secretAccessKey.trim();
|
||||
const keepStoredCredentials =
|
||||
objectStoreForm.manualCredentialsSaved &&
|
||||
!accessKeyId &&
|
||||
!secretAccessKey;
|
||||
if (!keepStoredCredentials && !accessKeyId) {
|
||||
setSaveError("Enter the AWS access key ID before continuing.");
|
||||
focusInput(accessKeyIdInputRef);
|
||||
return;
|
||||
}
|
||||
if (!keepStoredCredentials && !secretAccessKey) {
|
||||
setSaveError("Enter the AWS secret access key before continuing.");
|
||||
focusInput(secretAccessKeyInputRef);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = buildObjectStorePayload(objectStoreForm);
|
||||
setSubmitting(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
if (objectStoreForm.provider === "s3") {
|
||||
await testInstallObjectStore(installToken, payload);
|
||||
}
|
||||
await putInstallObjectStore(installToken, payload);
|
||||
const nextSession = await getInstallSession(installToken);
|
||||
setSessionState({ status: "ready", data: nextSession });
|
||||
navigate("/install/llm");
|
||||
} catch (error) {
|
||||
setSaveError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to save object-store settings.",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ObjectStoreProviderPicker
|
||||
provider={objectStoreForm.provider}
|
||||
onChange={(provider) => {
|
||||
setObjectStoreForm((current) => ({ ...current, provider }));
|
||||
if (provider === "s3") {
|
||||
focusInput(bucketInputRef);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{objectStoreForm.provider === "s3" ? (
|
||||
<div className="space-y-5">
|
||||
<Field label="Bucket">
|
||||
<input
|
||||
ref={bucketInputRef}
|
||||
name="object_store_bucket"
|
||||
value={objectStoreForm.bucket}
|
||||
onChange={(event) =>
|
||||
setObjectStoreForm((current) => ({
|
||||
...current,
|
||||
bucket: event.target.value,
|
||||
}))
|
||||
}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
placeholder="my-fabro-data"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Region">
|
||||
<input
|
||||
ref={regionInputRef}
|
||||
name="object_store_region"
|
||||
value={objectStoreForm.region}
|
||||
onChange={(event) =>
|
||||
setObjectStoreForm((current) => ({
|
||||
...current,
|
||||
region: event.target.value,
|
||||
}))
|
||||
}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
placeholder="us-east-1"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</Field>
|
||||
<ObjectStoreCredentialModePicker
|
||||
credentialMode={objectStoreForm.credentialMode}
|
||||
onChange={(credentialMode) => {
|
||||
setObjectStoreForm((current) => ({ ...current, credentialMode }));
|
||||
if (credentialMode === "access_key") {
|
||||
focusInput(accessKeyIdInputRef);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{objectStoreForm.credentialMode === "access_key" ? (
|
||||
<div className="space-y-5">
|
||||
<Field label="AWS access key ID">
|
||||
<PasswordInput
|
||||
inputRef={accessKeyIdInputRef}
|
||||
id="aws_access_key_id"
|
||||
name="aws_access_key_id"
|
||||
value={objectStoreForm.accessKeyId}
|
||||
onChange={(value) =>
|
||||
setObjectStoreForm((current) => ({
|
||||
...current,
|
||||
accessKeyId: value,
|
||||
}))
|
||||
}
|
||||
placeholder="AKIA..."
|
||||
/>
|
||||
</Field>
|
||||
<Field label="AWS secret access key">
|
||||
<PasswordInput
|
||||
inputRef={secretAccessKeyInputRef}
|
||||
id="aws_secret_access_key"
|
||||
name="aws_secret_access_key"
|
||||
value={objectStoreForm.secretAccessKey}
|
||||
onChange={(value) =>
|
||||
setObjectStoreForm((current) => ({
|
||||
...current,
|
||||
secretAccessKey: value,
|
||||
}))
|
||||
}
|
||||
placeholder="Secret access key"
|
||||
/>
|
||||
</Field>
|
||||
{objectStoreForm.manualCredentialsSaved ? (
|
||||
<p className="text-xs text-fg-muted">
|
||||
Credentials saved. Leave both fields blank to keep them, or
|
||||
enter both fields to replace them.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs/5 text-fg-muted">
|
||||
Fabro will use AWS credentials already provided by the runtime,
|
||||
such as EC2, ECS, or IRSA-based auth.
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</StepPanel>
|
||||
) : location.pathname === "/install/github/done" ? (
|
||||
<GithubAppDoneScreen github={session?.github} />
|
||||
) : location.pathname === "/install/github" ? (
|
||||
|
|
@ -389,7 +586,7 @@ export default function InstallApp() {
|
|||
error={saveError}
|
||||
submitting={submitting}
|
||||
submitLabel={githubStrategy === "app" ? "Continue on GitHub" : "Continue"}
|
||||
backHref="/install/server"
|
||||
backHref="/install/llm"
|
||||
onSubmit={async () => {
|
||||
setSubmitting(true);
|
||||
setSaveError(null);
|
||||
|
|
@ -778,13 +975,17 @@ function WelcomeScreen() {
|
|||
Set up your Fabro server
|
||||
</h1>
|
||||
<p className="mt-4 max-w-[56ch] text-base/7 text-fg-3 text-pretty sm:text-[0.9375rem]/7">
|
||||
A short walkthrough to validate your LLM credentials, confirm the public
|
||||
server URL, and connect GitHub. When you finish, Fabro restarts into
|
||||
normal mode.
|
||||
A short walkthrough to confirm the public server URL, choose the shared
|
||||
object store, validate your LLM credentials, and connect GitHub. When
|
||||
you finish, Fabro restarts into normal mode.
|
||||
</p>
|
||||
<ol role="list" className="mt-10 divide-y divide-line border-y border-line">
|
||||
{[
|
||||
["Server URL", "Confirm where operators will reach Fabro."],
|
||||
[
|
||||
"Object store",
|
||||
"Choose local disk or AWS S3 for SlateDB and artifacts.",
|
||||
],
|
||||
["LLMs", "Validate API keys for Anthropic, OpenAI, or Gemini."],
|
||||
["GitHub", "Choose a personal access token or a GitHub App."],
|
||||
["Review", "Double-check the plan, then write the files."],
|
||||
|
|
@ -820,6 +1021,7 @@ function StepPanel({
|
|||
error,
|
||||
submitting,
|
||||
submitLabel = "Continue",
|
||||
submittingLabel = "Saving...",
|
||||
backHref,
|
||||
onSubmit,
|
||||
}: {
|
||||
|
|
@ -829,6 +1031,7 @@ function StepPanel({
|
|||
error: string | null;
|
||||
submitting: boolean;
|
||||
submitLabel?: string;
|
||||
submittingLabel?: string;
|
||||
backHref?: string;
|
||||
onSubmit: () => Promise<void>;
|
||||
}) {
|
||||
|
|
@ -864,7 +1067,7 @@ function StepPanel({
|
|||
{submitting ? (
|
||||
<>
|
||||
<Spinner />
|
||||
Saving
|
||||
{submittingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -913,13 +1116,14 @@ function ReviewScreen({
|
|||
</p>
|
||||
</header>
|
||||
<dl className="divide-y divide-line border-y border-line">
|
||||
<SummaryRow label="LLM providers" value={providers || "Not configured"} />
|
||||
<SummaryRow
|
||||
label="Server URL"
|
||||
value={serverUrl}
|
||||
mono
|
||||
action={<CopyButton value={serverUrl} label="Copy server URL" />}
|
||||
/>
|
||||
{renderObjectStoreSummaryRows(session?.object_store)}
|
||||
<SummaryRow label="LLM providers" value={providers || "Not configured"} />
|
||||
{renderGithubSummaryRows(session?.github)}
|
||||
</dl>
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
|
|
@ -1080,6 +1284,84 @@ function GithubStrategyPicker({
|
|||
);
|
||||
}
|
||||
|
||||
function ObjectStoreProviderPicker({
|
||||
provider,
|
||||
onChange,
|
||||
}: {
|
||||
provider: ObjectStoreProvider;
|
||||
onChange: (value: ObjectStoreProvider) => void;
|
||||
}) {
|
||||
const options: Array<{ id: ObjectStoreProvider; title: string; body: string }> = [
|
||||
{
|
||||
id: "local",
|
||||
title: "Local disk",
|
||||
body: "Uses the host filesystem for SlateDB and run artifacts.",
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
title: "AWS S3",
|
||||
body: "Uses one S3 bucket with fixed slatedb/ and artifacts/ prefixes.",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium text-fg">Object store</legend>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{options.map((option) => (
|
||||
<OptionCard
|
||||
key={option.id}
|
||||
selected={provider === option.id}
|
||||
onSelect={() => onChange(option.id)}
|
||||
title={option.title}
|
||||
body={option.body}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectStoreCredentialModePicker({
|
||||
credentialMode,
|
||||
onChange,
|
||||
}: {
|
||||
credentialMode: ObjectStoreCredentialMode;
|
||||
onChange: (value: ObjectStoreCredentialMode) => void;
|
||||
}) {
|
||||
const options: Array<{
|
||||
id: ObjectStoreCredentialMode;
|
||||
title: string;
|
||||
body: string;
|
||||
}> = [
|
||||
{
|
||||
id: "runtime",
|
||||
title: "Use AWS runtime credentials",
|
||||
body: "Use credentials already supplied by the deployment environment.",
|
||||
},
|
||||
{
|
||||
id: "access_key",
|
||||
title: "Enter AWS access key credentials",
|
||||
body: "Store an access key pair in server.env for startup and validation.",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium text-fg">Credentials</legend>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{options.map((option) => (
|
||||
<OptionCard
|
||||
key={option.id}
|
||||
selected={credentialMode === option.id}
|
||||
onSelect={() => onChange(option.id)}
|
||||
title={option.title}
|
||||
body={option.body}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function OwnerPicker({
|
||||
ownerKind,
|
||||
setOwnerKind,
|
||||
|
|
@ -1253,17 +1535,20 @@ function PasswordInput({
|
|||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
inputRef,
|
||||
}: {
|
||||
id?: string;
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={visible ? "text" : "password"}
|
||||
id={id}
|
||||
name={name}
|
||||
|
|
@ -1386,6 +1671,18 @@ function defaultProviderSelection(): ProviderSelection {
|
|||
);
|
||||
}
|
||||
|
||||
function defaultObjectStoreForm(): ObjectStoreForm {
|
||||
return {
|
||||
provider: "local",
|
||||
bucket: "",
|
||||
region: "",
|
||||
credentialMode: "runtime",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
manualCredentialsSaved: false,
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateProviderSelection(
|
||||
current: ProviderSelection,
|
||||
session: InstallSessionResponse,
|
||||
|
|
@ -1400,6 +1697,50 @@ function hydrateProviderSelection(
|
|||
return next;
|
||||
}
|
||||
|
||||
function hydrateObjectStoreForm(session: InstallSessionResponse): ObjectStoreForm {
|
||||
const summary = session.object_store;
|
||||
if (!summary || summary.provider === "local") {
|
||||
return defaultObjectStoreForm();
|
||||
}
|
||||
return {
|
||||
provider: "s3",
|
||||
bucket: summary.bucket ?? "",
|
||||
region: summary.region ?? "",
|
||||
credentialMode: summary.credential_mode === "access_key" ? "access_key" : "runtime",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
manualCredentialsSaved: Boolean(summary.manual_credentials_saved),
|
||||
};
|
||||
}
|
||||
|
||||
function buildObjectStorePayload(form: ObjectStoreForm): InstallObjectStoreInput {
|
||||
if (form.provider === "local") {
|
||||
return { provider: "local" };
|
||||
}
|
||||
|
||||
const payload: InstallObjectStoreInput = {
|
||||
provider: "s3",
|
||||
bucket: form.bucket.trim(),
|
||||
region: form.region.trim(),
|
||||
credential_mode: form.credentialMode,
|
||||
};
|
||||
const accessKeyId = form.accessKeyId.trim();
|
||||
const secretAccessKey = form.secretAccessKey.trim();
|
||||
if (form.credentialMode === "access_key") {
|
||||
if (accessKeyId) {
|
||||
payload.access_key_id = accessKeyId;
|
||||
}
|
||||
if (secretAccessKey) {
|
||||
payload.secret_access_key = secretAccessKey;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function focusInput(ref: { current: HTMLInputElement | null }): void {
|
||||
window.setTimeout(() => ref.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function describeProvider(id: string): string {
|
||||
const match = INSTALL_PROVIDERS.find((provider) => provider.id === id);
|
||||
return match?.label ?? id;
|
||||
|
|
@ -1436,6 +1777,33 @@ function renderGithubSummaryRows(
|
|||
);
|
||||
}
|
||||
|
||||
function renderObjectStoreSummaryRows(
|
||||
objectStore: InstallSessionResponse["object_store"],
|
||||
): ReactNode {
|
||||
if (!objectStore) {
|
||||
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="AWS S3" />
|
||||
<SummaryRow label="Bucket" value={objectStore.bucket ?? "Not set"} mono />
|
||||
<SummaryRow label="Region" value={objectStore.region ?? "Not set"} mono />
|
||||
<SummaryRow
|
||||
label="Credentials"
|
||||
value={
|
||||
objectStore.credential_mode === "access_key"
|
||||
? "Access key"
|
||||
: "Runtime credentials"
|
||||
}
|
||||
/>
|
||||
<SummaryRow label="Prefixes" value="slatedb/, artifacts/" mono />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function describeGithubAppOwner(
|
||||
owner: InstallGithubAppOwner | undefined,
|
||||
): string {
|
||||
|
|
@ -1468,4 +1836,3 @@ function submitGithubManifest(
|
|||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,14 @@ This starts the server on a Unix socket at `~/.fabro/fabro.sock` by default. Use
|
|||
|
||||
### First run: web install wizard
|
||||
|
||||
If `~/.fabro/settings.toml` does not yet exist, `fabro server start` enters **install mode**: it prints an install URL, attempts to open the URL in your default browser, and serves a web wizard that walks you through configuring your LLM provider, server URL, and GitHub integration.
|
||||
If `~/.fabro/settings.toml` does not yet exist, `fabro server start` enters **install mode**: it prints an install URL, attempts to open the URL in your default browser, and serves a web wizard that walks you through configuring your server URL, shared object store, LLM provider, and GitHub integration.
|
||||
|
||||
The `Object store` step offers two wizard-managed modes:
|
||||
|
||||
- `Local disk` for the default host-local SlateDB + artifact storage path
|
||||
- `AWS S3` for one shared bucket with fixed `slatedb/` and `artifacts/` prefixes
|
||||
|
||||
The wizard's manual-credential path stores only `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in `server.env`. It does not collect STS/session tokens or S3-compatible endpoint settings. If you need MinIO, Cloudflare R2, path-style options, or custom endpoints, finish install with local defaults and then edit `[server.slatedb]` / `[server.artifacts]` in `settings.toml` manually.
|
||||
|
||||
When you finish the wizard, the server writes `~/.fabro/settings.toml` and exits cleanly. Start it again to boot in configured mode:
|
||||
|
||||
|
|
|
|||
|
|
@ -56,8 +56,17 @@ strategy = "tailscale_funnel"
|
|||
[server.storage]
|
||||
root = "/var/lib/fabro"
|
||||
|
||||
[server.artifacts]
|
||||
provider = "s3"
|
||||
prefix = "artifacts"
|
||||
|
||||
[server.artifacts.s3]
|
||||
bucket = "my-fabro-data"
|
||||
region = "us-east-1"
|
||||
|
||||
[server.slatedb]
|
||||
provider = "s3"
|
||||
prefix = "slatedb"
|
||||
disk_cache = true
|
||||
|
||||
[server.slatedb.s3]
|
||||
|
|
@ -182,6 +191,20 @@ bucket = "{{ env.SLATEDB_BUCKET }}"
|
|||
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`.
|
||||
|
||||
The wizard only covers AWS S3 bucket/region plus one of:
|
||||
|
||||
- runtime credentials already supplied by the deployment environment
|
||||
- manually-entered `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`
|
||||
|
||||
Advanced S3-compatible settings such as custom `endpoint` or `path_style` remain a manual
|
||||
configuration path. If you need MinIO, R2, or another S3-compatible backend, configure
|
||||
`[server.slatedb]` and `[server.artifacts]` directly in `settings.toml`. The runtime still
|
||||
honors those hand-edited values even though the browser wizard does not manage them.
|
||||
|
||||
### Run defaults
|
||||
|
||||
The `[run.*]` sections in `settings.toml` act as defaults for every run.
|
||||
|
|
@ -277,6 +300,8 @@ For the auth model above, the main server runtime secrets are:
|
|||
- `FABRO_DEV_TOKEN` when `"dev-token"` auth is enabled
|
||||
- `GITHUB_APP_CLIENT_SECRET` when `"github"` auth is enabled
|
||||
- `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY`, provisioned during install for future CLI login flows
|
||||
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` when the install wizard or a manual config uses
|
||||
static S3 object-store credentials
|
||||
|
||||
Fabro no longer auto-loads `.env` files. Provider API keys are required for the models you want to use; everything else is optional.
|
||||
|
||||
|
|
@ -311,6 +336,32 @@ Fabro resolves these from `process env -> server.env`.
|
|||
| `FABRO_JWT_PUBLIC_KEY` | Ed25519 public key (base64-encoded PEM) for JWT verification |
|
||||
| `SESSION_SECRET` | Session encryption secret (64-character hex string) |
|
||||
|
||||
### Object store runtime secrets (optional)
|
||||
|
||||
Fabro resolves these from `process env -> server.env`.
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `AWS_ACCESS_KEY_ID` | Static AWS access key ID for S3-backed `[server.slatedb]` / `[server.artifacts]` |
|
||||
| `AWS_SECRET_ACCESS_KEY` | Matching static AWS secret access key |
|
||||
|
||||
The browser install wizard can write these into `server.env` for the AWS S3 manual-credential
|
||||
path. It does not support manual STS/session-token input; use runtime credentials instead for ECS,
|
||||
EC2 instance profiles, IRSA, or web-identity flows.
|
||||
|
||||
For the narrowest production policy, scope access to one bucket and the `slatedb/` and
|
||||
`artifacts/` prefixes with `s3:ListBucket` plus `s3:GetObject`, `s3:PutObject`, and
|
||||
`s3:DeleteObject`. Prefer a dedicated IAM user or role for Fabro instead of reusing broad AWS
|
||||
credentials.
|
||||
|
||||
If `POST /install/finish` fails after mutating `server.env`, the error response may include
|
||||
`leftover_env_keys` or `removed_env_keys`. For AWS object-store keys, treat `removed_env_keys` as
|
||||
informational: the prior managed key path was already cleared, so complete the retry before
|
||||
restart. If `leftover_env_keys` includes `AWS_ACCESS_KEY_ID` or `AWS_SECRET_ACCESS_KEY`, retry the
|
||||
install or remove the managed object-store lines from `server.env` before abandoning the host.
|
||||
Rotation is not required solely because finish failed after an atomic `0600` rewrite, but it
|
||||
remains the fallback if you no longer trust the host boundary.
|
||||
|
||||
### GitHub integration (optional)
|
||||
|
||||
| Variable | Description |
|
||||
|
|
|
|||
|
|
@ -185,6 +185,68 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/object-store/test:
|
||||
post:
|
||||
operationId: testInstallObjectStore
|
||||
tags: [Install]
|
||||
summary: Validate install object-store configuration
|
||||
description: Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallObjectStoreInput"
|
||||
responses:
|
||||
"200":
|
||||
description: Object-store configuration validated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallObjectStoreValidationResponse"
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Object-store validation failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/object-store:
|
||||
put:
|
||||
operationId: putInstallObjectStore
|
||||
tags: [Install]
|
||||
summary: Save install object-store configuration
|
||||
description: Records the object-store mode selected during browser install. Requires the one-time install token.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallObjectStoreInput"
|
||||
responses:
|
||||
"204":
|
||||
description: Object-store configuration recorded
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Invalid install input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/github/token/test:
|
||||
post:
|
||||
operationId: testInstallGithubToken
|
||||
|
|
@ -2185,6 +2247,10 @@ components:
|
|||
oneOf:
|
||||
- $ref: "#/components/schemas/InstallServerConfigInput"
|
||||
- type: "null"
|
||||
object_store:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/InstallObjectStoreSummary"
|
||||
- type: "null"
|
||||
github:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/InstallGithubSummary"
|
||||
|
|
@ -2277,6 +2343,56 @@ components:
|
|||
type: string
|
||||
format: uri
|
||||
|
||||
InstallObjectStoreValidationResponse:
|
||||
description: Successful response from install-time object-store validation.
|
||||
type: object
|
||||
required:
|
||||
- ok
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
InstallObjectStoreInput:
|
||||
description: Object-store mode selected during browser install.
|
||||
type: object
|
||||
required:
|
||||
- provider
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
enum: [local, s3]
|
||||
bucket:
|
||||
type: string
|
||||
region:
|
||||
type: string
|
||||
credential_mode:
|
||||
type: string
|
||||
enum: [runtime, access_key]
|
||||
access_key_id:
|
||||
type: string
|
||||
secret_access_key:
|
||||
type: string
|
||||
|
||||
InstallObjectStoreSummary:
|
||||
description: Redacted summary of the object-store mode selected during browser install.
|
||||
type: object
|
||||
required:
|
||||
- provider
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
enum: [local, s3]
|
||||
bucket:
|
||||
type: string
|
||||
region:
|
||||
type: string
|
||||
credential_mode:
|
||||
type: string
|
||||
enum: [runtime, access_key]
|
||||
manual_credentials_saved:
|
||||
type: boolean
|
||||
|
||||
InstallGithubTokenTestInput:
|
||||
description: Input for install-time GitHub token validation.
|
||||
type: object
|
||||
|
|
@ -3346,6 +3462,22 @@ components:
|
|||
description: List of error entries.
|
||||
items:
|
||||
$ref: "#/components/schemas/ErrorResponseEntry"
|
||||
leftover_env_keys:
|
||||
type: array
|
||||
description: >-
|
||||
Optional list of runtime env keys that were written before an install
|
||||
failure. Currently populated by `POST /install/finish` failure
|
||||
responses only.
|
||||
items:
|
||||
type: string
|
||||
removed_env_keys:
|
||||
type: array
|
||||
description: >-
|
||||
Optional list of runtime env keys that were actually removed before
|
||||
an install failure. Currently populated by `POST /install/finish`
|
||||
failure responses only.
|
||||
items:
|
||||
type: string
|
||||
|
||||
ActorKind:
|
||||
description: High-level category of an event actor.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
date: 2026-04-22
|
||||
topic: web-install-object-store-choice
|
||||
---
|
||||
|
||||
# Web Install Object Store Choice
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The browser install wizard currently configures server URL, LLM credentials, and GitHub integration, but it does not let the operator choose where Fabro's object-store-backed data lives. Fabro already supports a local object store and AWS S3 in server configuration, and the runtime model keeps a separate local storage root for host-local state. The missing product behavior is a guided first-run choice for the shared object store used by both SlateDB and artifacts.
|
||||
|
||||
Without this step, operators who want S3-backed storage must finish install and then hand-edit server configuration and startup secrets. That creates avoidable setup drift between "works locally" and "production-like" deployments, especially for remote-first installs where the web wizard is supposed to be the primary setup path.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[Welcome]
|
||||
B[Server]
|
||||
C[Object store]
|
||||
D[LLM]
|
||||
E[GitHub]
|
||||
F[Review]
|
||||
G[Finish]
|
||||
|
||||
A --> B --> C --> D --> E --> F --> G
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
**Wizard flow**
|
||||
- R1. The web install wizard must add a new `Object store` step after `Server` and before `LLM`, making the operator-visible order `Welcome → Server → Object store → LLM → GitHub → Review`.
|
||||
- R2. The sidebar/progress UI, route flow, back/next behavior, and review screen must treat `Object store` as a first-class step, with refresh/re-entry behavior matching the existing install steps.
|
||||
- R3. The wizard must explain that Fabro always keeps a local storage directory on the host and that this step chooses only the shared object store for object-store-backed server data.
|
||||
|
||||
**Storage modes**
|
||||
- R4. The `Object store` step must offer exactly two wizard-managed modes: `Local disk` and `AWS S3`.
|
||||
- R5. Choosing `Local disk` must configure both `server.slatedb` and `server.artifacts` to use the local object store provider.
|
||||
- R6. Choosing `Local disk` must not prompt for S3-only fields such as bucket, region, or AWS credentials.
|
||||
- R7. Choosing `AWS S3` must configure both `server.slatedb` and `server.artifacts` to use AWS S3, with one shared bucket and separate fixed prefixes so SlateDB data lives under `slatedb/` and artifact data lives under `artifacts/`.
|
||||
- R8. The wizard must not expose separate object-store choices for SlateDB and artifacts. Operators who need split backends or more advanced layouts must configure them manually after install.
|
||||
- R9. The wizard must not expose S3-compatible endpoint or path-style options in v1. The step must clearly note that non-AWS S3-compatible backends are out of scope for the wizard and require manual configuration.
|
||||
|
||||
**S3 fields and credentials**
|
||||
- R10. When `AWS S3` is selected, the wizard must collect `bucket` and `region`.
|
||||
- R11. The `AWS S3` path must offer exactly two credential modes: `Use AWS runtime credentials` and `Enter AWS access key credentials`.
|
||||
- R12. `Use AWS runtime credentials` must be described as the path for deployments where AWS credentials come from the runtime environment or attached role, and it must not prompt for AWS secret material.
|
||||
- R13. `Enter AWS access key credentials` must prompt only for `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`.
|
||||
- R14. Manual AWS access-key credentials are server-only startup secrets. They must be persisted as server startup env secrets, not as workflow-visible vault secrets.
|
||||
- R15. The wizard must not support session-token or STS inputs in v1. Operators who need temporary-session credential flows must configure object-store access manually outside the wizard.
|
||||
|
||||
**Validation and review**
|
||||
- R16. The `AWS S3` path must perform live validation before the operator can continue past the `Object store` step.
|
||||
- R17. Live validation must confirm that the configured bucket, region, chosen credential mode, and fixed `slatedb/` / `artifacts/` prefixes permit Fabro to reach the target S3 bucket with the intended object-store setup.
|
||||
- R18. Validation errors must render inline on the `Object store` step and make it clear whether the failure is due to bucket/region mismatch, missing runtime credentials, or invalid manually-entered credentials when that distinction is available.
|
||||
- R19. The review step must summarize the chosen object-store mode. For S3, it must show the bucket, region, credential mode, and that Fabro will use the fixed `slatedb/` and `artifacts/` prefixes in the shared bucket without revealing secret values.
|
||||
|
||||
## Success Criteria
|
||||
- Operators can complete first-run setup for either local object storage or AWS S3 without editing `settings.toml` by hand.
|
||||
- An install completed with `Local disk` produces the expected local-provider config for both SlateDB and artifacts.
|
||||
- An install completed with `AWS S3` produces the expected shared-bucket S3 config for both SlateDB and artifacts, with `slatedb/` and `artifacts/` prefixes.
|
||||
- Manual AWS credentials, when provided, are stored only as server startup secrets and are not exposed in workflow-visible secret surfaces.
|
||||
- Misconfigured S3 details are caught during the install wizard instead of surfacing only after install completes and the server restarts.
|
||||
|
||||
## Scope Boundaries
|
||||
- The local host storage root remains separate and is not replaced by S3.
|
||||
- The wizard does not support separate object-store backends for SlateDB and artifacts.
|
||||
- The wizard does not support non-AWS S3-compatible backends.
|
||||
- The wizard does not support session-token or STS credential inputs.
|
||||
- Advanced object-store layouts, custom prefixes beyond the fixed `slatedb/` and `artifacts/`, and other specialized storage topologies remain manual-configuration workflows.
|
||||
|
||||
## Key Decisions
|
||||
- One shared object-store choice for both SlateDB and artifacts: keeps the wizard simple and avoids first-run misconfiguration.
|
||||
- AWS S3 only in the wizard: reduces surface area and avoids exposing endpoint/path-style details meant for manual advanced setups.
|
||||
- Two S3 auth paths: ambient runtime credentials for AWS-native deployments, or manually-entered access keys for non-AWS deployments that still target S3.
|
||||
- Live validation on the wizard step: catches storage errors before the install succeeds and the server exits.
|
||||
- `Object store` comes before provider/integration setup: keeps server-level infrastructure decisions together near the server URL step.
|
||||
|
||||
## Dependencies / Assumptions
|
||||
- The existing server config model continues to treat `server.storage.root` as host-local storage and `server.slatedb` / `server.artifacts` as separate object-store-backed domains.
|
||||
- The runtime continues to initialize S3 access from startup environment credentials or the ambient AWS credential chain.
|
||||
- Using one shared S3 bucket with distinct `slatedb/` and `artifacts/` prefixes is sufficient for the wizard-managed path.
|
||||
|
||||
## Outstanding Questions
|
||||
|
||||
### Resolve Before Planning
|
||||
|
||||
(none)
|
||||
|
||||
### Deferred to Planning
|
||||
- [Affects R16-R18] [Technical] Define the exact validation probe shape for local vs S3 so the install flow checks the real runtime path without creating misleading side effects in the bucket.
|
||||
- [Affects R14] [Technical] Confirm the exact persistence path and lifecycle for manual AWS credentials in `server.env`, including whether existing install rollback behavior is sufficient for newly-added AWS env keys.
|
||||
- [Affects R19] [Technical] Decide how the review screen and session payload represent the credential mode without leaking secret values and while preserving edit/re-entry behavior.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ `/prompts:ce-plan` for structured implementation planning
|
||||
|
|
@ -0,0 +1,472 @@
|
|||
---
|
||||
title: "feat: Add object-store step to the browser install wizard"
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-04-22
|
||||
origin: docs/brainstorms/2026-04-22-web-install-object-store-choice-requirements.md
|
||||
---
|
||||
|
||||
# feat: Add object-store step to the browser install wizard
|
||||
|
||||
## Overview
|
||||
|
||||
Add a first-run `Object store` step to the browser install wizard between `Server` and `LLM`. The step lets the operator choose one shared object-store mode for both SlateDB and artifacts: `Local disk` or `AWS S3`. The S3 path collects `bucket`, `region`, and one of two credential modes: runtime credentials (deployment-provided AWS auth such as ECS/EKS/IRSA metadata flows, instance profiles, or AWS env vars already present in the server process) or manually-entered `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`.
|
||||
|
||||
> **Terminology.** This plan uses **runtime credentials** to refer to the deployment-provided AWS auth path (what the UI labels `Use AWS runtime credentials`). Earlier drafts called this "ambient", "ambient runtime", or "default chain"; those were shorthand for "credentials supplied by the runtime rather than typed into the wizard". **Manual credentials** refers to the static access-key-pair path.
|
||||
|
||||
The implementation is broader than a UI form. It touches the install API contract, server-side install session state, settings/env persistence, the browser wizard flow, and the server's runtime object-store initialization. The key technical requirement is that manual AWS credentials stored in `server.env` must actually be consumed by the S3 object-store builder at install-finish time and on subsequent server boots.
|
||||
|
||||
The wizard's goal is that `POST /install/finish` produces a fully bootable server with no subsequent operator env-editing step. A UI-only variant that writes `settings.toml` but requires the operator to separately place `AWS_*` into process env or `server.env` defeats that goal — it leaves the wizard claiming completion without persisting the secrets it just collected, and turns the "type keys into the wizard" affordance into a trap. That UX goal is what keeps Unit 2's runtime bridge load-bearing; the existing `process env -> server.env` precedence in `server_secrets.rs` is the precedence mechanism we reuse, not a standalone architectural invariant.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The browser install flow currently covers canonical URL, LLM credentials, and GitHub, but it does not expose the existing object-store configuration surface that already exists in server settings. Operators who want S3-backed SlateDB/artifact storage have to finish the wizard and then hand-edit `settings.toml` plus startup secrets.
|
||||
|
||||
That defeats the point of a remote-first web install flow. The object-store choice belongs in the install wizard because it is foundational server infrastructure, not an advanced follow-up tweak. The plan keeps the local storage root model unchanged: `server.storage.root` remains host-local storage, while the new step chooses only the shared object store used by `server.slatedb` and `server.artifacts` (see origin: `docs/brainstorms/2026-04-22-web-install-object-store-choice-requirements.md`).
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
### Wizard placement and step ordering (R1-R3)
|
||||
|
||||
- Add a first-class `Object store` wizard step after `Server` and before `LLM`, and make the UI copy explicit that the local host storage root still exists and this step only chooses the shared object store.
|
||||
|
||||
### Storage mode and scope (R4-R9)
|
||||
|
||||
- Offer exactly two wizard-managed modes: `Local disk` and `AWS S3`. The choice applies to both `server.slatedb` and `server.artifacts`. The S3 path uses one shared bucket with fixed prefixes `slatedb` and `artifacts` in config, which correspond to `slatedb/` and `artifacts/` keys in the bucket at runtime. No split backends, custom prefixes, endpoint, or path-style options are exposed in the wizard.
|
||||
|
||||
### Credential collection and persistence (R10-R15)
|
||||
|
||||
- The S3 path collects `bucket`, `region`, and one of two credential modes: runtime credentials or manual access-key credentials. Manual mode collects only `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, persists them as server-only startup secrets, and explicitly excludes STS/session-token inputs.
|
||||
|
||||
### Validation and session handling (R16-R18)
|
||||
|
||||
- S3 configuration is live-validated before the wizard advances. Validation errors render inline and should distinguish shape errors from access failures where practical. The review/session surfaces summarize the chosen object-store mode without echoing secret values.
|
||||
|
||||
### Review display (R19)
|
||||
|
||||
- The review step shows the chosen mode, and for S3 shows bucket, region, credential mode, and the fixed `slatedb` / `artifacts` prefixes.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- No changes to the terminal `fabro install` flow in this pass.
|
||||
- No attempt to make `server.storage.root` remote or optional. Host-local storage remains required.
|
||||
- No support for separate object-store backends for SlateDB and artifacts.
|
||||
- No S3-compatible endpoint/path-style options in the wizard.
|
||||
- No STS/session-token/manual-profile flows.
|
||||
- No SlateDB tuning changes such as `disk_cache` defaults; existing defaults remain in force.
|
||||
- No generic install-wizard refactor from the current monolithic `install-app.tsx` into route-local files unless a small local helper extraction is needed to keep the patch readable.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- **Install API contract:** `docs/api-reference/fabro-api.yaml` currently exposes install session, LLM, server, GitHub, and finish endpoints. `InstallSessionResponse` has `llm`, `server`, `github`, and `prefill`, but no storage/object-store field.
|
||||
- **Browser install client:** `apps/fabro-web/app/install-api.ts` is a thin raw-fetch wrapper over the install routes. It already follows the pattern `POST /test` then `PUT /step` for LLM and GitHub token validation.
|
||||
- **Browser install UI:** `apps/fabro-web/app/install-app.tsx` is the install wizard host. Step order, current-step routing, back/next links, session hydration, and review summary are all hard-coded there today.
|
||||
- **Server install session/router:** `lib/crates/fabro-server/src/install.rs` owns `PendingInstall`, the per-step DTOs, the `/install/*` handlers, `completed_steps`, and the `post_install_finish` persistence path.
|
||||
- **Shared install persistence:** `lib/crates/fabro-install/src/lib.rs` already centralizes pure install-time settings mutation (`merge_server_settings`, `write_token_settings`, `write_github_app_settings`) and direct persistence (`persist_install_outputs_direct`).
|
||||
- **Runtime object-store wiring:** `lib/crates/fabro-server/src/serve.rs` builds local/S3 object stores from resolved settings. The current S3 path uses `AmazonS3Builder::from_env()` directly, so it sees process env but does not currently consult `server.env`.
|
||||
- **Server secret precedence:** `lib/crates/fabro-server/src/server_secrets.rs` already defines the server runtime precedence model as `process env -> server.env`. `docs/administration/server-configuration.mdx` documents the same rule.
|
||||
- **Install router tests:** `lib/crates/fabro-server/tests/it/api/install.rs` covers install session/auth, finish semantics, and artifact metadata creation. `apps/fabro-web/app/install-app.test.tsx` already exercises wizard rehydration and GitHub callback flows.
|
||||
|
||||
### Existing Patterns to Reuse
|
||||
|
||||
- **Separate validation and persistence endpoints:** `POST /install/llm/test` and `POST /install/github/token/test` validate external credentials without mutating session state; `PUT /install/llm` and `PUT /install/github/token` persist only after client-side success.
|
||||
- **Redacted session snapshots:** `install.rs` stores raw secrets in memory but exposes only redacted summaries through `GET /install/session`. The LLM and GitHub flows already prove the pattern for "configured but not rehydrated" secret inputs.
|
||||
- **Shared settings helpers:** `fabro-install` is already the right place for pure config/env mutation helpers used by both CLI/server install surfaces.
|
||||
- **Server-env precedence tests:** `lib/crates/fabro-server/src/server.rs` already has coverage proving process env wins over `server.env`.
|
||||
|
||||
### External References
|
||||
|
||||
- `object_store::aws::AmazonS3Builder` supports `new()`, explicit access-key setters, and explicit env-driven credential-provider inputs. That is the right primitive for preserving AWS role-based runtime auth while avoiding accidental leakage from unrelated `AWS_*` process env settings such as endpoint overrides.
|
||||
- `object_store` list operations are a good fit for install-time validation because they exercise the real bucket/prefix access path without writing metadata during the wizard.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No `docs/solutions/` entry currently covers object-store-backed install flows or S3 credential bridging. This is net-new planning ground.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Use a dedicated object-store install contract.** Add a new install step and API shape instead of overloading `InstallServerConfigInput`. The server session response gets a separate redacted `object_store` summary field so the review screen and re-entry flow do not have to infer object-store state from raw settings text.
|
||||
- **Use `object_store` as the session/completion token, and `/install/object-store` as the route.** The user-facing label stays `Object store`, while the route stays readable and the completed-step token matches current string-based step handling.
|
||||
- **Keep `Local disk` as a settings no-op.** For the local path, do not write new `[server.slatedb]` / `[server.artifacts]` sections just to restate current defaults. `post_install_finish` already rebuilds `settings.toml` from a fresh TOML document on every run, so selecting `Local disk` naturally drops previously-written S3 sections without needing subtractive settings edits. The session/review surfaces still record the operator's choice during the wizard.
|
||||
- **Write explicit S3 config only when S3 is selected.** The S3 path writes `[server.artifacts] provider = "s3"`, `prefix = "artifacts"`, `[server.artifacts.s3] bucket/region`, and the analogous `[server.slatedb]` config with `prefix = "slatedb"`. Prefix values are stored without trailing slashes because artifact paths are joined by `ArtifactStore::prefixed_raw()` in `lib/crates/fabro-store/src/artifact_store.rs`, while SlateDB base prefixes are normalized by `normalize_base_prefix()` in `lib/crates/fabro-store/src/slate/mod.rs`.
|
||||
- **Extend `fabro-install` instead of hand-building new TOML in `install.rs`.** Add pure helper(s) in `lib/crates/fabro-install/src/lib.rs` for object-store settings mutation and server-env edits so the server install code stays declarative and future CLI parity has a reusable base.
|
||||
- **Support both env sets and env removals during direct install persistence, scoped to wizard-managed keys.** This feature needs explicit removal support for `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` when the operator switches away from manual-credential mode. However, `server.env` may contain AWS keys the operator set for reasons unrelated to Fabro's object store (workflow-level tools, other integrations). Removal must be scoped to keys the wizard itself placed: wizard-written env lines carry a marker comment (for example, `# managed by fabro-install: object-store`) and the removal path only deletes keys that still carry that marker. Keys in `server.env` without the marker survive untouched, even when the current wizard mode would not write them.
|
||||
- **Keep `server.env` persistence convergent on retry, not atomic at the install-flow level, but atomic at the filesystem level.** `server.env` mutations are applied immediately and persist even if later settings/vault writes fail. This plan does not widen scope into changing that broader install behavior. The new AWS-key removal path should follow the same model: retries must converge the file to the newly chosen state, and tests should make that lifecycle explicit. Each individual write must be atomic at the filesystem level — `envfile::merge_env_file` should write to a sibling tempfile in the same directory, `fchmod` it to `0600`, `fsync`, `rename(2)` it into place, and `fsync` the parent directory, so readers never observe a truncated or partial-credentials file.
|
||||
- **Make the runtime object-store builder env-aware without inheriting unrelated `AWS_*` process env.** Refactor the S3 builder path to start from `AmazonS3Builder::new()`, apply bucket/region/path-style/endpoint from resolved settings, and feed credential-related lookup values through the existing `process env -> server.env` precedence model. Reuse `ServerSecrets::{with_env_lookup,get}` rather than introducing a second precedence system; `server_secrets.rs` should only change if a tiny helper extraction is needed for readability.
|
||||
- **Allow only the credential-provider env inputs the runtime actually needs.** The env-aware builder should explicitly map the supported static-key, web-identity, container-credential, and metadata-provider inputs that `object_store` understands. It must not honor `AWS_ENDPOINT`, `AWS_ENDPOINT_URL_S3`, or similar endpoint-override env vars from process env or `server.env`; endpoint continues to come only from resolved settings, which keeps the wizard on the AWS-only path and avoids validation probing arbitrary internal hosts.
|
||||
- **Use explicit static credentials only when both halves are present, but scope that rule to the static access-key path.** If exactly one of `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` is available from the combined lookup, return a clear install/runtime error for the static-key path instead of falling back. Do not reinterpret other runtime-credential inputs or widen the rule into a generic failure mode for non-static provider flows.
|
||||
- **Use non-mutating prefix probes for S3 validation.** The validation endpoint should build the same object-store shape the runtime will use, then perform non-writing access probes against both `artifacts` and `slatedb` prefixes. Avoid using `write_metadata()` during the wizard because that adds avoidable side effects to a step the user may back out of.
|
||||
- **Keep wizard secret re-entry behavior consistent with existing install steps.** Manual AWS credentials are never returned in `GET /install/session`. If the user re-enters the step after saving manual credentials, the credential mode and bucket/region are shown, the secret fields remain blank, blank submit preserves the stored pair, and entering either secret field switches into replacement mode that requires both fields.
|
||||
- **Enforce `0600` permissions on `server.env`.** `fabro-config::envfile::set_private_permissions` already applies `0600` on unix and propagates the `io::Error` when the `chmod` fails; Unit 1's test only needs to assert the resulting permission bits on both create and update paths and decide whether the current non-unix no-op is acceptable for this feature. AWS access keys are long-lived, high-value credentials; world-readable or group-readable persistence would be an obvious pivot path for local attackers or low-privilege co-tenants.
|
||||
- **Hold raw AWS credentials in a redaction-safe wrapper, zeroized on session end.** The raw `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` values are reachable only through an `expose_secret()`-style accessor used exclusively at the builder boundary and at the `server.env` write boundary. The wrapper's `Serialize`/`Debug`/`Display` must print a fixed redaction token, never the raw value. Every error path — including those produced by `AmazonS3Builder` / `object_store` / `reqwest` — must be rewritten into install-domain error types that do not interpolate the raw secret; `format!("{}", raw_key)` and direct `tracing::*` field capture of the unwrapped value are forbidden. Assertions on at least two paths (validation-error response and finish-error response with a fabricated but structurally-realistic key) must confirm the raw value does not appear in response bodies or captured tracing output. The in-memory copy on `PendingInstall` uses the same `Zeroize`/`SecretString` wrapper and must be cleared on finish success, explicit session reset, install-session expiry, and when the operator submits a provider/mode change that no longer needs the stored pair.
|
||||
- **Do not widen scope into CLI parity or S3-compatible backends.** The plan stays anchored to the browser wizard requirements document and does not pull in `fabro install`, endpoint/path-style UI, or STS.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **How should local object-store choice persist?** Leave local object-store config implicit through existing defaults; only the S3 path writes explicit object-store sections.
|
||||
- **Where should manual AWS credentials live?** In `server.env`, not in the vault, and they must be removable when the chosen mode no longer needs them.
|
||||
- **How does the server actually consume manual AWS credentials?** Through an env-aware object-store builder that follows the existing `process env -> server.env` precedence model instead of relying on raw process env alone.
|
||||
- **What should install-time validation probe do?** Build the real object-store configuration and perform side-effect-free access probes against the fixed `artifacts` and `slatedb` prefixes.
|
||||
- **Do we need CLI install changes now?** No. This plan is browser-install-only.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- **Exact OpenAPI schema factoring:** Whether `credential_mode` becomes a dedicated schema or remains an inline string enum is mechanical. The important part is that the save payload and session summary are separate types.
|
||||
- **Exact shape of the validation success response:** `204 No Content` or `{ ok: true }` are both workable. Match the existing install API style chosen by the implementer, but keep the error path aligned with `readInstallError()`.
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
|
||||
|
||||
### User flow
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[Welcome]
|
||||
B[Server]
|
||||
C[Object store]
|
||||
D[LLM]
|
||||
E[GitHub]
|
||||
F[Review]
|
||||
G[Finish]
|
||||
|
||||
A --> B --> C --> D --> E --> F --> G
|
||||
```
|
||||
|
||||
### Mode matrix
|
||||
|
||||
| Wizard choice | settings.toml | server.env | Validation |
|
||||
|---|---|---|---|
|
||||
| `Local disk` | No new object-store sections; current defaults continue to resolve local SlateDB + local artifacts | Remove stale wizard-managed `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` entries if present | No network probe; validation is a no-op beyond schema checks |
|
||||
| `AWS S3` + `Use AWS runtime credentials` | Write explicit S3 sections for both `server.artifacts` and `server.slatedb` with fixed prefixes `artifacts` / `slatedb` and shared bucket/region | Remove stale wizard-managed static AWS key vars if present | Probe S3 access using runtime credentials supplied by the deployment environment |
|
||||
| `AWS S3` + `Enter AWS access key credentials` | Same explicit S3 sections as above | Set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` | Probe S3 access using the submitted explicit key pair |
|
||||
|
||||
### Dependency graph
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
U1[U1 Contract and shared install helpers]
|
||||
U2[U2 Env-aware object-store runtime and validation]
|
||||
U3[U3 Install router/session/finish integration]
|
||||
U4[U4 Browser wizard step and review UI]
|
||||
U5[U5 Docs and generated surfaces]
|
||||
|
||||
U1 --> U3
|
||||
U2 --> U3
|
||||
U1 --> U4
|
||||
U3 --> U4
|
||||
U3 --> U5
|
||||
U4 --> U5
|
||||
```
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [x] **Unit 1: Contract and shared install helpers**
|
||||
|
||||
**Goal:** Add the durable install contract for the new object-store step and extend shared install persistence helpers so the server install flow can write/remove object-store env keys without ad hoc logic.
|
||||
|
||||
**Requirements:** R1-R3, R4-R15, R19
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api-reference/fabro-api.yaml`
|
||||
- Modify: `lib/crates/fabro-install/src/lib.rs`
|
||||
- Regenerate: `lib/packages/fabro-api-client/` (via `cd lib/packages/fabro-api-client && bun run generate`; includes new/modified models under `src/api/install-api.ts`, `src/models/install-session-response.ts`, and `src/models/install-object-store*.ts`)
|
||||
- Test: `lib/crates/fabro-install/src/lib.rs`
|
||||
|
||||
**Approach:**
|
||||
- Add a dedicated object-store save payload and redacted session summary to the install OpenAPI surface, plus `POST /install/object-store/test` and `PUT /install/object-store`.
|
||||
- Model the save payload with one shared choice for both storage domains:
|
||||
- `provider: "local" | "s3"`
|
||||
- optional S3 payload with `bucket`, `region`, `credential_mode`
|
||||
- manual mode only: `access_key_id`, `secret_access_key`
|
||||
- Keep the session summary separate from the save payload so secrets never appear in `InstallSessionResponse`.
|
||||
- Add a pure `fabro-install` helper for writing the S3 settings shape into a mutable TOML doc, including fixed prefixes `artifacts` and `slatedb`.
|
||||
- Expand direct install persistence so it can remove stale env keys as well as merge new ones. The current CLI path (`server_env_remove` inside `fabro-cli`) and the shared `fabro_install::persist_install_outputs_direct` / `envfile::merge_env_file` are not symmetric today; extending `persist_install_outputs_direct` to accept a remove-list is part of this unit, not a free reuse. Hoisting the CLI's existing removal code into the shared crate is acceptable.
|
||||
- After the OpenAPI edit, run `cd lib/packages/fabro-api-client && bun run generate` so downstream units (and the web app) can consume the new `InstallObjectStore*` models.
|
||||
- Treat the local path as a no-op for object-store config but still return the AWS key removals so retries over stale `server.env` state converge correctly.
|
||||
- `persist_install_outputs_direct` must write `server.env` with `0600` permissions (create or rewrite) and must fail closed if it cannot set them. A unit test must assert the permission bits on both create and update paths.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `merge_server_settings`, `write_token_settings`, and `write_github_app_settings` in `lib/crates/fabro-install/src/lib.rs`
|
||||
- Existing install OpenAPI shapes in `docs/api-reference/fabro-api.yaml`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: local object-store selection writes no explicit object-store sections and requests removal of stale AWS key vars.
|
||||
- Happy path: S3 runtime-credential selection writes both `server.artifacts` and `server.slatedb` S3 sections with `bucket`, `region`, and fixed prefixes `artifacts` / `slatedb`.
|
||||
- Happy path: S3 manual-credential selection writes the same S3 config and returns `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` env edits.
|
||||
- Edge case: prefixes are written without trailing `/` in TOML and still represent `artifacts/` / `slatedb/` at runtime.
|
||||
- Edge case: unrelated `server.env` keys survive the set/remove rewrite unchanged.
|
||||
- Error path: malformed helper input (for example, S3 mode without bucket/region) fails before serializing install output.
|
||||
- Integration: direct persistence removes stale wizard-managed AWS keys when the selected mode no longer needs them while leaving unmarked operator-managed keys untouched.
|
||||
|
||||
**Verification:**
|
||||
- `fabro-install` helper tests cover local, S3 runtime, S3 manual, and env-removal behavior.
|
||||
- The generated TypeScript client exposes the new session field and object-store models expected by the web app.
|
||||
|
||||
- [x] **Unit 2: Env-aware object-store runtime and install-time validation**
|
||||
|
||||
**Goal:** Refactor object-store creation so manual AWS credentials stored in `server.env` are actually consumable at runtime and during install-finish metadata writes, while also providing a shared, side-effect-free validation path for the new install step.
|
||||
|
||||
**Requirements:** R7-R15 primary (shared S3 shape and credential-mode runtime consumption). R16-R18 only at the validation-helper layer, where Unit 2 translates object-store failures into install-domain validation outcomes that Units 3 and 4 surface.
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-server/src/serve.rs`
|
||||
- Modify: `lib/crates/fabro-server/src/install.rs`
|
||||
- Test: `lib/crates/fabro-server/src/serve.rs`
|
||||
- Test: `lib/crates/fabro-server/src/install.rs` or `lib/crates/fabro-server/tests/it/api/install.rs`
|
||||
|
||||
**Approach:**
|
||||
- Refactor the S3 builder path in `serve.rs` so it accepts the resolved `ObjectStoreSettings` plus an env lookup following `process env -> server.env`. The existing `ServerSecrets::{with_env_lookup,get}` API already matches the needed precedence shape; Unit 2 should reuse it rather than inventing a second lookup type.
|
||||
- Start from `AmazonS3Builder::new()`, not `from_env()`. Apply bucket/region from resolved settings, continue to honor settings-driven `endpoint` / `path_style` for hand-configured advanced deployments, and feed only the explicit credential-provider env inputs the runtime needs through the injected lookup.
|
||||
- Runtime-credential path: populate the builder from the lookup only for static-key vars and the `object_store`-supported runtime provider hints (web identity, ECS/EKS container credentials, IMDS-related inputs). Do not load endpoint-override env vars from process env or `server.env`.
|
||||
- Manual validation path: do not add a one-off explicit-credentials parameter. Instead, build a temporary overlay lookup that returns the submitted `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` and suppresses any `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` values that would otherwise come from process env or `server.env`. Non-credential lookups (for example region defaults) delegate to the same `ServerSecrets::get` the runtime uses, so the overlay composes through `ServerSecrets`, not around it. This keeps validation and startup on the same builder path and ensures the probe uses exactly the typed key pair rather than silently authenticating with stronger ambient credentials that happen to be available in the environment.
|
||||
- If both `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are available from the effective lookup, apply them explicitly with builder setters. If only one of the two keys is present, return a clear static-credential error instead of falling back.
|
||||
- Route `build_artifact_object_store`, `build_slatedb_store`, and `write_artifact_store_metadata()` through the env-aware path so manual credentials work immediately after `/install/finish`, not only on a later restart.
|
||||
- Add a shared install validation helper that:
|
||||
- short-circuits `Local disk`
|
||||
- for S3, builds the real object-store configuration for the chosen credential mode
|
||||
- pins the client to the submitted region (no global-endpoint fallback), so a bucket that lives in a different region surfaces as a distinct error rather than succeeding via transparent cross-region redirect
|
||||
- performs non-mutating access probes on both `artifacts` and `slatedb` prefixes via `object_store.list_with_delimiter(Some(&Path::from(prefix.trim_end_matches('/').to_string())))`
|
||||
- applies a short connect timeout plus a 20-second total timeout for the probe path, with no silent retry in v1
|
||||
- treats any successful response, including an empty result set, as "prefix reachable"
|
||||
- distinguishes, in the install-domain error translation, between `bucket_not_found`, `bucket_region_mismatch`, `access_denied`, and `prefix_access_failed` where the underlying SDK response makes the distinction possible
|
||||
- lives in `install.rs` as install-specific orchestration over the shared builder path in `serve.rs`
|
||||
- wraps lower-level failures in install-step-specific messages such as `Bucket is required.`, `Region is required. Use a value like us-east-1.`, `Enter both AWS access key fields or switch to runtime credentials.`, `Could not access bucket <bucket> in region <region> with the selected credentials.`, and `Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes.`
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing builder path in `lib/crates/fabro-server/src/serve.rs`
|
||||
- `ServerSecrets` precedence rules in `lib/crates/fabro-server/src/server_secrets.rs`
|
||||
|
||||
**Technical design:** *(directional only)*
|
||||
|
||||
```text
|
||||
runtime startup:
|
||||
server_secrets = ServerSecrets::load(server_env_path)
|
||||
env_lookup(name) = server_secrets.get(name) // process env first, then server.env
|
||||
build_object_store_from_settings_with_lookup(settings, env_lookup)
|
||||
|
||||
install validation, manual mode:
|
||||
server_secrets = ServerSecrets::load(server_env_path)
|
||||
env_lookup(name) = overlay_aws_static_keys(
|
||||
submitted_keys, // only AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||
suppress = {AWS_SESSION_TOKEN}, // do not leak ambient STS tokens
|
||||
base = server_secrets.get // same lookup runtime uses
|
||||
)
|
||||
build_object_store_from_settings_with_lookup(settings, env_lookup)
|
||||
|
||||
install validation, runtime mode:
|
||||
server_secrets = ServerSecrets::load(server_env_path)
|
||||
env_lookup(name) = server_secrets.get(name)
|
||||
build_object_store_from_settings_with_lookup(settings, env_lookup)
|
||||
```
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: env-aware S3 builder succeeds when credentials are supplied only through the injected lookup, not process env.
|
||||
- Happy path: manual validation overlay uses the same lookup semantics as runtime and overrides only the submitted static-key pair.
|
||||
- Happy path: runtime-credential mode still succeeds when the deployment provides auth through the supported env/metadata provider paths.
|
||||
- Edge case: the fixed prefixes `artifacts` and `slatedb` (stored without trailing `/` in TOML) still probe the intended `artifacts/` and `slatedb/` key namespaces in the bucket.
|
||||
- Error path: only one of `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` is present on the effective lookup -> clear static-credential error.
|
||||
- Error path: S3 validation failure is reported as an install-step access failure, not an opaque panic.
|
||||
- Error path: `AWS_ENDPOINT`, `AWS_ENDPOINT_URL_S3`, or similar endpoint-override env vars are ignored by the wizard-managed validation/runtime path.
|
||||
- Error path: manual-mode validation with intentionally-wrong submitted keys fails even when a valid distinct AWS key pair (or session token) is available in process env — the overlay must suppress the ambient keys.
|
||||
- Error path: validation against a bucket that lives in a different region than the submitted region reports `bucket_region_mismatch` rather than succeeding via redirect.
|
||||
- Integration: artifact-store metadata writing after `/install/finish` uses the same env-aware path and no longer ignores manual AWS credentials in `server.env`.
|
||||
|
||||
**Verification:**
|
||||
- `serve.rs` tests cover static-credential injection and partial-credential failure.
|
||||
- Install-side validation tests cover local no-op validation and S3 access failure surfacing.
|
||||
|
||||
- [x] **Unit 3: Install router, session state, and finish integration**
|
||||
|
||||
**Goal:** Extend the server-side browser install flow with a new object-store step, redacted session summary, and finish-time persistence that requires the new step.
|
||||
|
||||
**Requirements:** R10-R18 primary (route exposure, session redaction, finish-time persistence and env set/remove, validation surface). Secondary: R1 (step completion order) and R14-R15 (secret lifecycle). Units 1 and 2 own R4-R9 and R7-R15 respectively at the contract/runtime layer.
|
||||
|
||||
**Dependencies:** Unit 1, Unit 2
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-server/src/install.rs`
|
||||
- Test: `lib/crates/fabro-server/tests/it/api/install.rs`
|
||||
|
||||
**Approach:**
|
||||
- Add object-store state to `PendingInstall` and introduce the server-side DTOs/enums needed for the new step.
|
||||
- Add:
|
||||
- `POST /install/object-store/test`
|
||||
- `PUT /install/object-store`
|
||||
- redacted `object_store` data in `GET /install/session`
|
||||
- Keep the new object-store routes behind the same install-token auth boundary as the existing install endpoints, and make sure neither session snapshots nor validation/save errors ever echo submitted AWS secret material.
|
||||
- Update `completed_steps()` to return step tokens in wizard order: `server`, `object_store`, `llm`, `github`.
|
||||
- Require the object-store step in `POST /install/finish` before config is written.
|
||||
- Use the new `fabro-install` helper(s) to:
|
||||
- mutate the settings doc for S3 mode
|
||||
- set or remove AWS key vars according to credential mode
|
||||
- Keep the secret-handling pattern aligned with existing install steps:
|
||||
- raw access keys stay only in server memory until finish
|
||||
- session snapshots return only bucket/region/credential mode/configured state
|
||||
- Preserve the current finish-error ergonomics, but split env mutation reporting by direction once removals become first-class: on finish failure, keep `leftover_env_keys` for keys that were inserted before the failure, and add `removed_env_keys` for keys that were successfully removed before the failure. Successful finish responses remain unchanged; the new field is a failure-path recovery signal only.
|
||||
- Keep the broader install rollback behavior unchanged: settings/vault writes still roll back on later failure, while `server.env` mutations remain non-atomic and converge on retry rather than being restored automatically.
|
||||
- If the session already holds manual AWS credentials in memory and the operator revisits the step, blank secret fields on re-submit preserve the stored pair; entering one or both fields switches into replacement mode, and replacement requires both fields.
|
||||
- Any `PUT /install/object-store` with `provider = "local"` or `credential_mode = "runtime"` must clear any previously-stored manual AWS key pair from the in-memory session before returning, so a subsequent `POST /install/object-store/test` does not construct an overlay from stale submitted keys. `POST /install/object-store/test` must reject overlay application when the submitted mode is not `manual`.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `post_install_llm_test` + `put_install_llm` in `lib/crates/fabro-server/src/install.rs`
|
||||
- `redacted_llm()` / `redacted_github()` session-shaping helpers
|
||||
- Existing `POST /install/finish` persistence/error pattern
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `POST /install/object-store/test` returns success for `Local disk` without issuing a network probe.
|
||||
- Happy path: `POST /install/object-store/test` validates S3 runtime-credential and manual-credential payloads through the shared builder path.
|
||||
- Happy path: `PUT /install/object-store` with `Local disk` stores session state and marks `object_store` complete.
|
||||
- Happy path: `GET /install/session` includes the redacted object-store summary after save.
|
||||
- Happy path: `POST /install/finish` with local object-store selection writes no AWS key vars.
|
||||
- Happy path: `POST /install/finish` with S3 manual credentials writes both AWS key vars into `server.env`.
|
||||
- Happy path: `POST /install/finish` with S3 runtime credentials removes stale wizard-managed AWS key vars from an existing `server.env` while leaving unmarked keys untouched.
|
||||
- Edge case: reloading the session after manual-credential save shows the chosen mode and bucket/region but not the credential values, and a blank re-submit preserves the in-memory pair.
|
||||
- Error path: `POST /install/finish` before the object-store step is complete returns the existing missing-step style error.
|
||||
- Error path: failed finish after env merge includes AWS key names in `leftover_env_keys`.
|
||||
- Error path: failed finish after a stale-key removal reports the removed names in `removed_env_keys`, leaves the file-level mutation in place, and a retry still converges to the selected mode.
|
||||
- Integration: the object-store step does not disturb existing GitHub token/app finish behavior.
|
||||
|
||||
**Verification:**
|
||||
- Install API integration tests cover new step save/session/finish semantics.
|
||||
- Finish tests prove both env-set and env-remove behavior for AWS keys.
|
||||
|
||||
- [x] **Unit 4: Browser wizard step and review summary**
|
||||
|
||||
**Goal:** Add the new `Object store` UI step, reorder the browser wizard, and surface the redacted object-store summary in the review screen without refactoring the entire install app.
|
||||
|
||||
**Requirements:** R1-R3 (wizard placement/copy), R12-R13 (form inputs for manual credentials), R16 (inline validation errors), R17-R18 (redacted session re-entry), R19 (review rows). Server-side validation and persistence come from Units 2 and 3.
|
||||
|
||||
**Dependencies:** Unit 1, Unit 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/fabro-web/app/install-app.tsx`
|
||||
- Modify: `apps/fabro-web/app/install-api.ts`
|
||||
- Test: `apps/fabro-web/app/install-app.test.tsx`
|
||||
- Test: `apps/fabro-web/app/install-api.test.ts`
|
||||
|
||||
**Approach:**
|
||||
- Insert the new step into `INSTALL_STEPS` and `currentStep` routing, using `/install/object-store` between `/install/server` and `/install/llm`.
|
||||
- Update navigation:
|
||||
- `Server` next -> `Object store`
|
||||
- `Object store` back -> `Server`, next -> `LLM`
|
||||
- `LLM` back -> `Object store`
|
||||
- `GitHub` back -> `LLM`
|
||||
- Update the step-preview list in `WelcomeScreen` to include 'Object store — choose local disk or AWS S3 for SlateDB and artifacts' between 'Server URL' and 'LLMs'.
|
||||
- Render provider choice (`Local disk` / `AWS S3`) and credential mode (`Use AWS runtime credentials` / `Enter AWS access key credentials`) using the existing `OptionCard` pattern inside `fieldset`/`legend` wrappers, matching `GithubStrategyPicker`. Selecting `Local disk` hides the S3 sub-form entirely; selecting `Use AWS runtime credentials` hides the access-key inputs.
|
||||
- Default provider on first entry is `Local disk` (the common path becomes a one-click Continue). The S3 sub-form is collapsed until the operator picks `AWS S3`. Even with default-selected `Local disk`, the step must still be completed (`PUT /install/object-store`) before finish.
|
||||
- Add local component state for:
|
||||
- provider choice (`local` vs `s3`)
|
||||
- bucket/region
|
||||
- credential mode (`runtime` vs `access_key`)
|
||||
- access key id / secret access key
|
||||
- Within one unsaved visit to the step, toggling `AWS S3 -> Local disk -> AWS S3` preserves the typed bucket/region/credential fields in local component state so accidental toggles do not wipe work. After a successful save or a session rehydrate, the session snapshot becomes the source of truth and the manual secret inputs return blank.
|
||||
- Client-side preflight before submit: when `AWS S3` is selected, require non-empty `bucket` and `region`; when `Enter AWS access key credentials` is selected, additionally require both `access_key_id` and `secret_access_key`. Error copy matches the tone of the existing `"Enter the canonical server URL before continuing."` style string.
|
||||
- Keep manual credential fields blank on rehydrate; only hydrate redacted summary data from the session response. If the session summary indicates manual credentials are already stored in the pending install state, show helper text such as `Credentials saved. Leave both fields blank to keep them, or enter both fields to replace them.` Blank submit preserves the stored pair; entering either field requires both and replaces the stored pair.
|
||||
- Extend the thin install API wrapper with new test/save helpers for the object-store step.
|
||||
- Reuse the existing `StepPanel` pattern and inline error handling; do not split the install wizard into new route files in this pass.
|
||||
- Submit behavior: local mode saves immediately and shows `Saving...` with the primary action disabled while the request is in flight. S3 mode runs `POST /install/object-store/test` before save and shows `Checking access...` on the primary action while validation/save is in flight. If the validation probe exceeds the server-side 20-second timeout, show `Timed out while checking S3 access. Verify the bucket, region, and network path, then try again.`
|
||||
- Field order and copy are fixed for consistency: provider choice first; if `AWS S3` is selected, show `Bucket` (placeholder `my-fabro-data`), `Region` (placeholder `us-east-1`), then the credential-mode picker. If manual credentials are selected, show `AWS access key ID` and `AWS secret access key` in that order. Secret inputs are password-masked and use `autocomplete="off"`, `autocapitalize="none"`, and `spellcheck={false}`.
|
||||
- Keep the form behavior aligned with the existing wizard's accessibility/responsiveness baseline: explicit labels, keyboard-reachable provider/credential-mode controls, password-masked secret input, and a single-column layout that still reads cleanly on narrow screens. Inline validation errors should render in an `aria-live` region with `role="alert"`. Client-side preflight focuses the first invalid field. Selecting `AWS S3` moves focus to the `Bucket` input, selecting manual credentials moves focus to `AWS access key ID`, and `OptionCard` controls remain keyboard activatable via Enter/Space.
|
||||
- Extend `ReviewScreen` with object-store summary rows, implemented with `SummaryRow`:
|
||||
- Local: one row `Object store` -> `Local disk`.
|
||||
- S3: `Object store` -> `AWS S3`; `Bucket` -> value (monospaced); `Region` -> value (monospaced); `Credentials` -> `Runtime credentials` or `Access key` (static label only; no redacted key identifier is shown); `Prefixes` -> `slatedb/, artifacts/` (single row).
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing `LLM` and `GitHub token` submit flows in `apps/fabro-web/app/install-app.tsx`
|
||||
- Existing "configured but redacted" hydration pattern from `hydrateProviderSelection()` and GitHub username rehydrate behavior
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: wizard stepper/order now includes `Object store` between `Server` and `LLM`.
|
||||
- Happy path: local selection advances to `LLM` and does not require bucket/region/AWS credential fields.
|
||||
- Happy path: S3 runtime-credential selection requires bucket/region and surfaces runtime-credential copy.
|
||||
- Happy path: S3 manual-credential selection requires bucket/region/access key id/secret access key before save.
|
||||
- Edge case: rehydrated session with S3 manual mode preserves bucket/region/mode, leaves secret fields blank, and shows the "credentials saved" helper text.
|
||||
- Edge case: review screen shows `Local disk` or the redacted S3 summary as appropriate.
|
||||
- Error path: object-store validation errors from the server render inline on the step.
|
||||
- Error path: client-side preflight errors, access-denied errors, and timeout errors use the agreed copy and land focus on the appropriate field/error region.
|
||||
- Integration: existing GitHub callback/done tests still pass after `completed_steps` and step order change.
|
||||
|
||||
**Verification:**
|
||||
- Web tests cover new step routing, validation gating, rehydration, and review summary.
|
||||
- The new step integrates without breaking the existing install token and GitHub callback flows.
|
||||
|
||||
- [x] **Unit 5: Documentation and generated surfaces**
|
||||
|
||||
**Goal:** Bring the user-facing docs and generated/bundled outputs into sync with the new wizard step and server-runtime secret surface.
|
||||
|
||||
**Requirements:** R14-R15 documentation (server.env runtime secret surface for AWS keys) and R8-R9 (scope of the wizard-managed modes); user-facing docs for the new step exist because Units 3 and 4 introduce the feature.
|
||||
|
||||
**Dependencies:** Unit 3, Unit 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/administration/deploy-server.mdx`
|
||||
- Modify: `docs/administration/server-configuration.mdx`
|
||||
- Modify (generated): `lib/crates/fabro-spa/assets/`
|
||||
|
||||
**Approach:**
|
||||
- Update the "first run: web install wizard" section in `deploy-server.mdx` so it no longer says the wizard only configures LLMs, server URL, and GitHub.
|
||||
- Update the server-runtime secret documentation to include the optional AWS object-store keys and explain that they resolve with `process env -> server.env`.
|
||||
- Keep the wizard docs explicit that S3-compatible backends still require manual configuration.
|
||||
- Document that custom `endpoint` / `path_style` object-store settings remain a manual advanced-configuration path. The browser wizard neither prompts for them nor shows them on the review screen, even though the runtime still honors them for hand-edited configurations outside the wizard-managed flow.
|
||||
- Document a recommended minimum IAM policy for manual-credential mode (bucket-scoped, prefix-scoped to `artifacts/` and `slatedb/`, `s3:ListBucket` + `s3:GetObject`/`PutObject`/`DeleteObject` only) and recommend provisioning an IAM user dedicated to Fabro rather than reusing broad credentials. Call out that STS/session tokens are not supported in manual mode.
|
||||
- Add an operator recovery runbook entry for failed finish requests only: if `POST /install/finish` returns `leftover_env_keys` containing AWS key names, the managed object-store keys likely remain in `server.env`; the operator should retry the install or explicitly remove the managed object-store lines before abandoning the host. Rotation is not required solely because finish failed after an atomic `0600` write, but remains the operator's fallback if they no longer trust the host boundary. If the failure response returns `removed_env_keys` for AWS key names instead, treat it as informational: the previous manual-key path was already cleared and the operator should simply complete the retry before restart.
|
||||
- Regenerate the bundled SPA assets after the TypeScript change so the shipped binary stays in sync with the source.
|
||||
|
||||
**Test expectation:** none -- documentation and generated bundle refresh only. Behavioral verification comes from Units 3 and 4.
|
||||
|
||||
**Verification:**
|
||||
- User-facing docs mention the new object-store step and the AWS-only/non-STS constraints.
|
||||
- The committed SPA bundle matches the updated `apps/fabro-web` source.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** `GET /install/session` now returns one more redacted section. The browser wizard adds one more stateful step before LLMs. `POST /install/finish` now feeds object-store settings/env edits into `fabro-install`, and the same env-aware object-store builder is used by install-time validation, post-install artifact metadata writing, and normal server startup.
|
||||
- **Attack surface:** the new install endpoints stay within the existing install-token-protected router, but they handle AWS credential input. Secret values therefore need to stay out of session payloads, response bodies, and log messages across validation, save, and finish paths.
|
||||
- **Error propagation:** object-store validation failures should stay as `422` install-step errors that `readInstallError()` can display inline, with stable install-domain strings for missing fields, partial static credentials, access failures, permission failures, and timeouts. Finish-time persistence failures continue to return `500` plus env-mutation summaries for inserted vs removed AWS env keys.
|
||||
- **State lifecycle risks:** stale manual AWS keys are the main lifecycle hazard. The plan addresses that with explicit removal support for non-manual modes and by keeping finish failure reporting explicit when env merges have already happened.
|
||||
- **Persistence semantics:** install-finish remains partially non-atomic because `server.env` is not rolled back after later failures. This feature keeps that existing behavior, so the implementation needs explicit tests showing that both AWS-key insertion and AWS-key removal converge correctly on retry.
|
||||
- **API surface parity:** this change affects the browser install contract, the generated TypeScript client, and the user-visible wizard flow. The terminal install flow is intentionally unchanged in this pass.
|
||||
- **Integration coverage:** the critical cross-layer scenario is "manual AWS credentials entered in the wizard -> validated through the same lookup semantics runtime uses -> persisted to `server.env` -> artifact metadata write and later startup both use them." Unit tests on one side or the other are not enough by themselves.
|
||||
- **Unchanged invariants:** `server.storage.root` remains local disk, the wizard still exits the server on successful finish, GitHub and LLM install semantics are unchanged aside from step ordering, and non-AWS/S3-compatible options remain manual-only.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Manual AWS keys are persisted but not consumed by the runtime object-store builder | Refactor the builder path first and cover both startup and post-install metadata writing with the same env-aware helper |
|
||||
| Stale AWS keys in `server.env` silently override the chosen runtime-credential mode on a later boot | Add explicit env-key removal support and exercise it in finish-path tests |
|
||||
| Install-time validation accidentally mutates the bucket | Use non-writing prefix probes instead of metadata writes during the wizard step |
|
||||
| The monolithic `install-app.tsx` change set becomes brittle | Keep the feature local to the existing `StepPanel` pattern and add focused route/rehydration/review tests |
|
||||
| Generated contract drift between OpenAPI, TS client, and UI wrapper | Treat the regenerated `fabro-api-client` files as part of the unit, and keep `apps/fabro-web/app/install-api.ts` aligned with the new session/save shapes |
|
||||
| Validation relies on list permissions that some custom IAM policies may omit | Before selecting the probe shape, enumerate the exact S3 actions the runtime performs at steady state (SlateDB + artifact writers) and pick a probe that uses a strict subset of those permissions. If `s3:ListBucket` cannot be avoided, document it in the wizard error copy ("access to the configured bucket/prefix could not be verified — validation requires `s3:ListBucket`") and in the recommended IAM policy in Unit 5 |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- The operator docs should explicitly distinguish workflow-visible secrets (vault) from server runtime secrets (`server.env`), since this feature adds a new runtime-secret case.
|
||||
- The implementation should refresh the embedded SPA bundle after the TypeScript changes so the Rust binary ships the updated install UI.
|
||||
- If the feature ships before CLI parity, the docs should avoid implying that `fabro install` and the browser wizard offer the same object-store prompts.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- **Origin document:** [docs/brainstorms/2026-04-22-web-install-object-store-choice-requirements.md](/Users/bhelmkamp/p/fabro-sh/fabro-4/docs/brainstorms/2026-04-22-web-install-object-store-choice-requirements.md:1)
|
||||
- Related code: [lib/crates/fabro-server/src/install.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-server/src/install.rs:1)
|
||||
- Related code: [lib/crates/fabro-server/src/serve.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-server/src/serve.rs:323)
|
||||
- Related code: [lib/crates/fabro-server/src/server_secrets.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-server/src/server_secrets.rs:1)
|
||||
- Related code: [lib/crates/fabro-server/src/security_headers.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-server/src/security_headers.rs:1)
|
||||
- Related code: [lib/crates/fabro-install/src/lib.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-install/src/lib.rs:123)
|
||||
- Related code: [lib/crates/fabro-store/src/artifact_store.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-store/src/artifact_store.rs:42)
|
||||
- Related code: [lib/crates/fabro-store/src/slate/mod.rs](/Users/bhelmkamp/p/fabro-sh/fabro-4/lib/crates/fabro-store/src/slate/mod.rs:53)
|
||||
- Related code: [apps/fabro-web/app/install-app.tsx](/Users/bhelmkamp/p/fabro-sh/fabro-4/apps/fabro-web/app/install-app.tsx:43)
|
||||
- Related code: [docs/administration/deploy-server.mdx](/Users/bhelmkamp/p/fabro-sh/fabro-4/docs/administration/deploy-server.mdx:30)
|
||||
- Related code: [docs/administration/server-configuration.mdx](/Users/bhelmkamp/p/fabro-sh/fabro-4/docs/administration/server-configuration.mdx:270)
|
||||
- External docs: https://docs.rs/object_store/latest/object_store/
|
||||
- External docs: https://docs.rs/object_store/latest/object_store/aws/struct.AmazonS3Builder.html
|
||||
|
|
@ -4,20 +4,171 @@
|
|||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::io::{self, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EnvFileUpdate {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EnvFileRemoval {
|
||||
pub key: String,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct EnvFileEntry {
|
||||
value: String,
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct EnvFileRecord {
|
||||
key: String,
|
||||
value: String,
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EnvFileUpdateReport {
|
||||
pub entries: HashMap<String, String>,
|
||||
pub removed_keys: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn read_env_file(path: &Path) -> io::Result<HashMap<String, String>> {
|
||||
Ok(records_to_values(&read_env_records(path)?))
|
||||
}
|
||||
|
||||
pub fn merge_env_file<I, K, V>(path: &Path, updates: I) -> io::Result<HashMap<String, String>>
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: Into<String>,
|
||||
V: Into<String>,
|
||||
{
|
||||
let mut entries = read_env_entries(path)?;
|
||||
for (key, value) in updates {
|
||||
entries.insert(key.into(), EnvFileEntry {
|
||||
value: value.into(),
|
||||
comment: None,
|
||||
});
|
||||
}
|
||||
write_env_entries(path, &entries)?;
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|(key, entry)| (key, entry.value))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn update_env_file<I, J>(
|
||||
path: &Path,
|
||||
removals: I,
|
||||
updates: J,
|
||||
) -> io::Result<HashMap<String, String>>
|
||||
where
|
||||
I: IntoIterator<Item = EnvFileRemoval>,
|
||||
J: IntoIterator<Item = EnvFileUpdate>,
|
||||
{
|
||||
Ok(update_env_file_with_report(path, removals, updates)?.entries)
|
||||
}
|
||||
|
||||
pub fn write_env_file(path: &Path, entries: &HashMap<String, String>) -> io::Result<()> {
|
||||
let entries = entries
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
(key.clone(), EnvFileEntry {
|
||||
value: value.clone(),
|
||||
comment: None,
|
||||
})
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
write_env_entries(path, &entries)
|
||||
}
|
||||
|
||||
pub fn update_env_file_with_report<I, J>(
|
||||
path: &Path,
|
||||
removals: I,
|
||||
updates: J,
|
||||
) -> io::Result<EnvFileUpdateReport>
|
||||
where
|
||||
I: IntoIterator<Item = EnvFileRemoval>,
|
||||
J: IntoIterator<Item = EnvFileUpdate>,
|
||||
{
|
||||
let mut records = read_env_records(path)?;
|
||||
let mut removed_keys = Vec::new();
|
||||
|
||||
for removal in removals {
|
||||
let mut removed_this_key = false;
|
||||
records.retain(|record| {
|
||||
let should_remove = record.key == removal.key
|
||||
&& (removal.comment.is_none() || record.comment == removal.comment);
|
||||
if should_remove {
|
||||
removed_this_key = true;
|
||||
}
|
||||
!should_remove
|
||||
});
|
||||
if removed_this_key && !removed_keys.contains(&removal.key) {
|
||||
removed_keys.push(removal.key);
|
||||
}
|
||||
}
|
||||
|
||||
for update in updates {
|
||||
match update.comment.as_deref() {
|
||||
Some(comment) => {
|
||||
records.retain(|record| {
|
||||
!(record.key == update.key && record.comment.as_deref() == Some(comment))
|
||||
});
|
||||
}
|
||||
None => {
|
||||
records.retain(|record| record.key != update.key);
|
||||
}
|
||||
}
|
||||
records.push(EnvFileRecord {
|
||||
key: update.key,
|
||||
value: update.value,
|
||||
comment: update.comment,
|
||||
});
|
||||
}
|
||||
|
||||
if records.is_empty() {
|
||||
remove_optional_file(path)?;
|
||||
return Ok(EnvFileUpdateReport {
|
||||
entries: HashMap::new(),
|
||||
removed_keys,
|
||||
});
|
||||
}
|
||||
|
||||
write_env_records(path, &records)?;
|
||||
Ok(EnvFileUpdateReport {
|
||||
entries: records_to_values(&records),
|
||||
removed_keys,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_env_entries(path: &Path) -> io::Result<HashMap<String, EnvFileEntry>> {
|
||||
Ok(records_to_entries(&read_env_records(path)?))
|
||||
}
|
||||
|
||||
fn read_env_records(path: &Path) -> io::Result<Vec<EnvFileRecord>> {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(HashMap::new()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let mut entries = HashMap::new();
|
||||
let mut records = Vec::new();
|
||||
let mut pending_comment: Option<String> = None;
|
||||
for (index, raw_line) in contents.lines().enumerate() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
if line.is_empty() {
|
||||
pending_comment = None;
|
||||
continue;
|
||||
}
|
||||
if let Some(comment) = line.strip_prefix('#') {
|
||||
pending_comment = Some(comment.trim().to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -39,27 +190,29 @@ pub fn read_env_file(path: &Path) -> io::Result<HashMap<String, String>> {
|
|||
)));
|
||||
}
|
||||
|
||||
entries.insert(key.to_string(), decode_value(raw_value.trim())?);
|
||||
records.push(EnvFileRecord {
|
||||
key: key.to_string(),
|
||||
value: decode_value(raw_value.trim())?,
|
||||
comment: pending_comment.take(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn merge_env_file<I, K, V>(path: &Path, updates: I) -> io::Result<HashMap<String, String>>
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: Into<String>,
|
||||
V: Into<String>,
|
||||
{
|
||||
let mut entries = read_env_file(path)?;
|
||||
for (key, value) in updates {
|
||||
entries.insert(key.into(), value.into());
|
||||
}
|
||||
write_env_file(path, &entries)?;
|
||||
Ok(entries)
|
||||
fn write_env_entries(path: &Path, entries: &HashMap<String, EnvFileEntry>) -> io::Result<()> {
|
||||
let records = entries
|
||||
.iter()
|
||||
.map(|(key, entry)| EnvFileRecord {
|
||||
key: key.clone(),
|
||||
value: entry.value.clone(),
|
||||
comment: entry.comment.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
write_env_records(path, &records)
|
||||
}
|
||||
|
||||
pub fn write_env_file(path: &Path, entries: &HashMap<String, String>) -> io::Result<()> {
|
||||
fn write_env_records(path: &Path, records: &[EnvFileRecord]) -> io::Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||
|
|
@ -71,20 +224,57 @@ pub fn write_env_file(path: &Path, entries: &HashMap<String, String>) -> io::Res
|
|||
.unwrap_or("server.env");
|
||||
let tmp_path = parent.join(format!(".{file_name}.tmp-{}", ulid::Ulid::new()));
|
||||
|
||||
let mut data = entries.iter().collect::<Vec<_>>();
|
||||
data.sort_by_key(|(left, _)| *left);
|
||||
let contents = data
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}={}", encode_value(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let mut data = records.to_vec();
|
||||
data.sort_by(|left, right| left.key.cmp(&right.key));
|
||||
let mut contents = String::new();
|
||||
for record in data {
|
||||
if let Some(comment) = record.comment.as_deref() {
|
||||
if comment.contains('\n') {
|
||||
return Err(invalid_data(format!(
|
||||
"env comments must be single-line in {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
contents.push_str("# ");
|
||||
contents.push_str(comment);
|
||||
contents.push('\n');
|
||||
}
|
||||
contents.push_str(&record.key);
|
||||
contents.push('=');
|
||||
contents.push_str(&encode_value(&record.value));
|
||||
contents.push('\n');
|
||||
}
|
||||
|
||||
std::fs::write(&tmp_path, format!("{contents}\n"))?;
|
||||
let mut file = std::fs::File::create(&tmp_path)?;
|
||||
file.write_all(contents.as_bytes())?;
|
||||
set_private_permissions(&tmp_path)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
sync_parent_directory(&parent)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn records_to_entries(records: &[EnvFileRecord]) -> HashMap<String, EnvFileEntry> {
|
||||
records
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|record| {
|
||||
(record.key, EnvFileEntry {
|
||||
value: record.value,
|
||||
comment: record.comment,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn records_to_values(records: &[EnvFileRecord]) -> HashMap<String, String> {
|
||||
records
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|record| (record.key, record.value))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode_value(raw: &str) -> io::Result<String> {
|
||||
if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
|
||||
return serde_json::from_str(raw).map_err(|err| invalid_data(err.to_string()));
|
||||
|
|
@ -112,6 +302,29 @@ fn invalid_data(message: impl Into<String>) -> io::Error {
|
|||
io::Error::new(io::ErrorKind::InvalidData, message.into())
|
||||
}
|
||||
|
||||
fn remove_optional_file(path: &Path) -> io::Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => {
|
||||
let parent = path
|
||||
.parent()
|
||||
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||
sync_parent_directory(&parent)
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_parent_directory(path: &Path) -> io::Result<()> {
|
||||
std::fs::File::open(path)?.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_parent_directory(_path: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_private_permissions(path: &Path) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
|
@ -176,4 +389,71 @@ mod tests {
|
|||
let reloaded = read_env_file(&path).unwrap();
|
||||
assert_eq!(reloaded, entries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_env_file_only_removes_matching_marked_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.env");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"AWS_ACCESS_KEY_ID=operator\n# managed by fabro-install: object-store\nAWS_ACCESS_KEY_ID=managed\n# managed by fabro-install: object-store\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = update_env_file_with_report(
|
||||
&path,
|
||||
[EnvFileRemoval {
|
||||
key: "AWS_ACCESS_KEY_ID".to_string(),
|
||||
comment: Some("managed by fabro-install: object-store".to_string()),
|
||||
}],
|
||||
[EnvFileUpdate {
|
||||
key: "AWS_SECRET_ACCESS_KEY".to_string(),
|
||||
value: "replaced".to_string(),
|
||||
comment: Some("managed by fabro-install: object-store".to_string()),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let entries = report.entries;
|
||||
|
||||
assert_eq!(
|
||||
entries.get("AWS_SECRET_ACCESS_KEY").map(String::as_str),
|
||||
Some("replaced")
|
||||
);
|
||||
assert_eq!(
|
||||
entries.get("AWS_ACCESS_KEY_ID").map(String::as_str),
|
||||
Some("operator")
|
||||
);
|
||||
assert_eq!(entries.get("KEEP_ME").map(String::as_str), Some("1"));
|
||||
assert_eq!(report.removed_keys, vec!["AWS_ACCESS_KEY_ID".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_env_file_reports_only_keys_actually_removed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.env");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"# managed by fabro-install: object-store\nAWS_ACCESS_KEY_ID=managed\nKEEP_ME=1\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = update_env_file_with_report(
|
||||
&path,
|
||||
[
|
||||
EnvFileRemoval {
|
||||
key: "AWS_ACCESS_KEY_ID".to_string(),
|
||||
comment: Some("managed by fabro-install: object-store".to_string()),
|
||||
},
|
||||
EnvFileRemoval {
|
||||
key: "AWS_SECRET_ACCESS_KEY".to_string(),
|
||||
comment: Some("managed by fabro-install: object-store".to_string()),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.removed_keys, vec!["AWS_ACCESS_KEY_ID".to_string()]);
|
||||
assert_eq!(report.entries.get("KEEP_ME").map(String::as_str), Some("1"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ pub struct PendingSettingsWrite<'a> {
|
|||
pub previous_contents: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub const OBJECT_STORE_MANAGED_COMMENT: &str = "managed by fabro-install: object-store";
|
||||
pub const OBJECT_STORE_ACCESS_KEY_ID_ENV: &str = "AWS_ACCESS_KEY_ID";
|
||||
pub const OBJECT_STORE_SECRET_ACCESS_KEY_ENV: &str = "AWS_SECRET_ACCESS_KEY";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VaultSecretWrite {
|
||||
pub name: String,
|
||||
|
|
@ -38,6 +42,59 @@ pub enum InstallListenConfig {
|
|||
Unix(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InstallObjectStoreCredentialMode {
|
||||
Runtime,
|
||||
AccessKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InstallObjectStoreSelection {
|
||||
Local,
|
||||
S3 {
|
||||
bucket: String,
|
||||
region: String,
|
||||
credential_mode: InstallObjectStoreCredentialMode,
|
||||
access_key_id: Option<String>,
|
||||
secret_access_key: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InstallObjectStoreEnvPlan {
|
||||
pub writes: Vec<envfile::EnvFileUpdate>,
|
||||
pub removals: Vec<envfile::EnvFileRemoval>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PersistInstallOutputsError {
|
||||
source: anyhow::Error,
|
||||
pub server_env_applied: bool,
|
||||
pub removed_env_keys: Vec<String>,
|
||||
}
|
||||
|
||||
impl PersistInstallOutputsError {
|
||||
fn new(source: anyhow::Error, server_env_applied: bool, removed_env_keys: Vec<String>) -> Self {
|
||||
Self {
|
||||
source,
|
||||
server_env_applied,
|
||||
removed_env_keys,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PersistInstallOutputsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.source.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PersistInstallOutputsError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
self.source.source()
|
||||
}
|
||||
}
|
||||
|
||||
fn pem_encode(label: &str, bytes: &[u8]) -> String {
|
||||
let body = BASE64_STANDARD.encode(bytes);
|
||||
let mut pem = String::new();
|
||||
|
|
@ -239,6 +296,107 @@ pub fn write_github_app_settings(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn object_store_env_removals() -> Vec<envfile::EnvFileRemoval> {
|
||||
[
|
||||
OBJECT_STORE_ACCESS_KEY_ID_ENV,
|
||||
OBJECT_STORE_SECRET_ACCESS_KEY_ENV,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|key| envfile::EnvFileRemoval {
|
||||
key: key.to_string(),
|
||||
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_s3_store_settings(
|
||||
server: &mut toml::Table,
|
||||
domain: &str,
|
||||
prefix: &str,
|
||||
bucket: &str,
|
||||
region: &str,
|
||||
) -> Result<()> {
|
||||
let store = ensure_table(server, domain)?;
|
||||
store.insert(
|
||||
"provider".to_string(),
|
||||
toml::Value::String("s3".to_string()),
|
||||
);
|
||||
store.insert(
|
||||
"prefix".to_string(),
|
||||
toml::Value::String(prefix.to_string()),
|
||||
);
|
||||
let s3 = ensure_table(store, "s3")?;
|
||||
s3.insert(
|
||||
"bucket".to_string(),
|
||||
toml::Value::String(bucket.to_string()),
|
||||
);
|
||||
s3.insert(
|
||||
"region".to_string(),
|
||||
toml::Value::String(region.to_string()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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::S3 {
|
||||
bucket,
|
||||
region,
|
||||
credential_mode,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
} => {
|
||||
let bucket = bucket.trim();
|
||||
anyhow::ensure!(!bucket.is_empty(), "bucket is required");
|
||||
let region = region.trim();
|
||||
anyhow::ensure!(!region.is_empty(), "region is required");
|
||||
|
||||
let root = root_table_mut(doc)?;
|
||||
let server = ensure_table(root, "server")?;
|
||||
write_s3_store_settings(server, "artifacts", "artifacts", bucket, region)?;
|
||||
write_s3_store_settings(server, "slatedb", "slatedb", bucket, region)?;
|
||||
|
||||
let removals = object_store_env_removals();
|
||||
let writes = match credential_mode {
|
||||
InstallObjectStoreCredentialMode::Runtime => Vec::new(),
|
||||
InstallObjectStoreCredentialMode::AccessKey => {
|
||||
let access_key_id = access_key_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.context("access_key_id is required for manual credentials")?;
|
||||
let secret_access_key = secret_access_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.context("secret_access_key is required for manual credentials")?;
|
||||
vec![
|
||||
envfile::EnvFileUpdate {
|
||||
key: OBJECT_STORE_ACCESS_KEY_ID_ENV.to_string(),
|
||||
value: access_key_id.to_string(),
|
||||
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
|
||||
},
|
||||
envfile::EnvFileUpdate {
|
||||
key: OBJECT_STORE_SECRET_ACCESS_KEY_ENV.to_string(),
|
||||
value: secret_access_key.to_string(),
|
||||
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
|
||||
},
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
Ok(InstallObjectStoreEnvPlan { writes, removals })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_optional_file(path: &Path, previous_contents: Option<&str>) -> Result<()> {
|
||||
match previous_contents {
|
||||
Some(contents) => {
|
||||
|
|
@ -261,15 +419,25 @@ fn restore_optional_file(path: &Path, previous_contents: Option<&str>) -> Result
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)]) -> Result<()> {
|
||||
if secrets.is_empty() {
|
||||
return Ok(());
|
||||
fn persist_server_env_secrets(
|
||||
storage_dir: &Path,
|
||||
writes: &[envfile::EnvFileUpdate],
|
||||
removals: &[envfile::EnvFileRemoval],
|
||||
) -> Result<envfile::EnvFileUpdateReport> {
|
||||
if writes.is_empty() && removals.is_empty() {
|
||||
return Ok(envfile::EnvFileUpdateReport {
|
||||
entries: std::collections::HashMap::new(),
|
||||
removed_keys: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let env_path = Storage::new(storage_dir).runtime_directory().env_path();
|
||||
envfile::merge_env_file(&env_path, secrets.iter().cloned())
|
||||
.with_context(|| format!("merging server env secrets into {}", env_path.display()))?;
|
||||
Ok(())
|
||||
envfile::update_env_file_with_report(
|
||||
&env_path,
|
||||
removals.iter().cloned(),
|
||||
writes.iter().cloned(),
|
||||
)
|
||||
.with_context(|| format!("updating server env file {}", env_path.display()))
|
||||
}
|
||||
|
||||
fn persist_vault_secrets_direct(storage_dir: &Path, secrets: &[VaultSecretWrite]) -> Result<()> {
|
||||
|
|
@ -294,19 +462,27 @@ fn persist_vault_secrets_direct(storage_dir: &Path, secrets: &[VaultSecretWrite]
|
|||
|
||||
pub fn persist_install_outputs_direct(
|
||||
storage_dir: &Path,
|
||||
server_env_secrets: &[(String, String)],
|
||||
server_env_writes: &[envfile::EnvFileUpdate],
|
||||
server_env_removals: &[envfile::EnvFileRemoval],
|
||||
vault_secrets: &[VaultSecretWrite],
|
||||
settings_write: Option<&PendingSettingsWrite<'_>>,
|
||||
) -> Result<()> {
|
||||
persist_server_env_secrets(storage_dir, server_env_secrets)?;
|
||||
) -> std::result::Result<(), PersistInstallOutputsError> {
|
||||
let server_env_report =
|
||||
persist_server_env_secrets(storage_dir, server_env_writes, server_env_removals)
|
||||
.map_err(|err| PersistInstallOutputsError::new(err, false, Vec::new()))?;
|
||||
let removed_env_keys = server_env_report.removed_keys;
|
||||
|
||||
if let Some(write) = settings_write {
|
||||
if let Some(parent) = write.path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating settings directory {}", parent.display()))?;
|
||||
.with_context(|| format!("creating settings directory {}", parent.display()))
|
||||
.map_err(|err| {
|
||||
PersistInstallOutputsError::new(err, true, removed_env_keys.clone())
|
||||
})?;
|
||||
}
|
||||
std::fs::write(write.path, write.contents)
|
||||
.with_context(|| format!("writing settings file {}", write.path.display()))?;
|
||||
.with_context(|| format!("writing settings file {}", write.path.display()))
|
||||
.map_err(|err| PersistInstallOutputsError::new(err, true, removed_env_keys.clone()))?;
|
||||
}
|
||||
|
||||
let vault_path = Storage::new(storage_dir).secrets_path();
|
||||
|
|
@ -330,7 +506,11 @@ pub fn persist_install_outputs_direct(
|
|||
rollback_failures.join("; ")
|
||||
))
|
||||
};
|
||||
return Err(error);
|
||||
return Err(PersistInstallOutputsError::new(
|
||||
error,
|
||||
true,
|
||||
removed_env_keys,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -342,8 +522,11 @@ mod tests {
|
|||
use fabro_vault::{SecretType as VaultSecretType, Vault};
|
||||
|
||||
use super::{
|
||||
InstallListenConfig, PendingSettingsWrite, VaultSecretWrite, default_web_url,
|
||||
merge_server_settings, persist_install_outputs_direct, write_github_app_settings,
|
||||
InstallListenConfig, InstallObjectStoreCredentialMode, InstallObjectStoreSelection,
|
||||
OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_MANAGED_COMMENT,
|
||||
OBJECT_STORE_SECRET_ACCESS_KEY_ENV, PendingSettingsWrite, VaultSecretWrite,
|
||||
default_web_url, merge_server_settings, persist_install_outputs_direct,
|
||||
write_github_app_settings, write_object_store_settings,
|
||||
};
|
||||
|
||||
fn format_config_toml() -> String {
|
||||
|
|
@ -476,7 +659,12 @@ name = "custom"
|
|||
|
||||
let result = persist_install_outputs_direct(
|
||||
dir.path(),
|
||||
&[("SESSION_SECRET".to_string(), "session".to_string())],
|
||||
&[envfile::EnvFileUpdate {
|
||||
key: "SESSION_SECRET".to_string(),
|
||||
value: "session".to_string(),
|
||||
comment: None,
|
||||
}],
|
||||
&[],
|
||||
&[VaultSecretWrite {
|
||||
name: "bad-secret-name".to_string(),
|
||||
value: "boom".to_string(),
|
||||
|
|
@ -534,4 +722,171 @@ 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");
|
||||
|
||||
assert!(
|
||||
doc.get("server")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|server| server.get("artifacts"))
|
||||
.is_none()
|
||||
);
|
||||
assert!(plan.writes.is_empty());
|
||||
assert_eq!(plan.removals.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_object_store_settings_configures_s3_runtime_credentials() {
|
||||
let mut doc = toml::Value::Table(toml::Table::default());
|
||||
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::S3 {
|
||||
bucket: "fabro-data".to_string(),
|
||||
region: "us-east-1".to_string(),
|
||||
credential_mode: InstallObjectStoreCredentialMode::Runtime,
|
||||
access_key_id: None,
|
||||
secret_access_key: None,
|
||||
})
|
||||
.expect("runtime-credential object store selection should succeed");
|
||||
|
||||
let server = doc
|
||||
.get("server")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("server table should exist");
|
||||
assert_eq!(
|
||||
server
|
||||
.get("artifacts")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|artifacts| artifacts.get("prefix"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("artifacts")
|
||||
);
|
||||
assert_eq!(
|
||||
server
|
||||
.get("slatedb")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|slatedb| slatedb.get("prefix"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("slatedb")
|
||||
);
|
||||
assert!(plan.writes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_object_store_settings_configures_s3_manual_credentials() {
|
||||
let mut doc = toml::Value::Table(toml::Table::default());
|
||||
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::S3 {
|
||||
bucket: "fabro-data".to_string(),
|
||||
region: "us-east-1".to_string(),
|
||||
credential_mode: InstallObjectStoreCredentialMode::AccessKey,
|
||||
access_key_id: Some("AKIA_TEST".to_string()),
|
||||
secret_access_key: Some("secret-test".to_string()),
|
||||
})
|
||||
.expect("manual-credential object store selection should succeed");
|
||||
|
||||
assert_eq!(plan.writes.len(), 2);
|
||||
assert!(
|
||||
plan.writes
|
||||
.iter()
|
||||
.all(|write| write.comment.as_deref() == Some(OBJECT_STORE_MANAGED_COMMENT))
|
||||
);
|
||||
assert_eq!(
|
||||
plan.writes
|
||||
.iter()
|
||||
.find(|write| write.key == OBJECT_STORE_ACCESS_KEY_ID_ENV)
|
||||
.map(|write| write.value.as_str()),
|
||||
Some("AKIA_TEST")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.writes
|
||||
.iter()
|
||||
.find(|write| write.key == OBJECT_STORE_SECRET_ACCESS_KEY_ENV)
|
||||
.map(|write| write.value.as_str()),
|
||||
Some("secret-test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_install_outputs_direct_only_removes_marked_object_store_keys() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let storage = Storage::new(dir.path());
|
||||
let env_path = storage.runtime_directory().env_path();
|
||||
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&env_path,
|
||||
format!(
|
||||
"{OBJECT_STORE_ACCESS_KEY_ID_ENV}=operator-access\n# {OBJECT_STORE_MANAGED_COMMENT}\n{OBJECT_STORE_ACCESS_KEY_ID_ENV}=managed-access\n{OBJECT_STORE_SECRET_ACCESS_KEY_ENV}=operator-secret\nKEEP_ME=1\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
persist_install_outputs_direct(
|
||||
dir.path(),
|
||||
&[],
|
||||
&[envfile::EnvFileRemoval {
|
||||
key: OBJECT_STORE_ACCESS_KEY_ID_ENV.to_string(),
|
||||
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
|
||||
}],
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect("env-only persistence should succeed");
|
||||
|
||||
let server_env = envfile::read_env_file(&env_path).unwrap();
|
||||
assert_eq!(
|
||||
server_env
|
||||
.get(OBJECT_STORE_ACCESS_KEY_ID_ENV)
|
||||
.map(String::as_str),
|
||||
Some("operator-access")
|
||||
);
|
||||
assert_eq!(
|
||||
server_env
|
||||
.get(OBJECT_STORE_SECRET_ACCESS_KEY_ENV)
|
||||
.map(String::as_str),
|
||||
Some("operator-secret")
|
||||
);
|
||||
assert_eq!(server_env.get("KEEP_ME").map(String::as_str), Some("1"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn persist_install_outputs_direct_writes_private_server_env_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let storage = Storage::new(dir.path());
|
||||
let env_path = storage.runtime_directory().env_path();
|
||||
|
||||
persist_install_outputs_direct(
|
||||
dir.path(),
|
||||
&[envfile::EnvFileUpdate {
|
||||
key: "SESSION_SECRET".to_string(),
|
||||
value: "first".to_string(),
|
||||
comment: None,
|
||||
}],
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect("initial env write should succeed");
|
||||
let create_mode = std::fs::metadata(&env_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(create_mode, 0o600);
|
||||
|
||||
persist_install_outputs_direct(
|
||||
dir.path(),
|
||||
&[envfile::EnvFileUpdate {
|
||||
key: "SESSION_SECRET".to_string(),
|
||||
value: "second".to_string(),
|
||||
comment: None,
|
||||
}],
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect("rewrite env write should succeed");
|
||||
let update_mode = std::fs::metadata(&env_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(update_mode, 0o600);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ thiserror.workspace = true
|
|||
ipnet = "2.11.0"
|
||||
percent-encoding.workspace = true
|
||||
url = "2"
|
||||
zeroize.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
chrono = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -16,22 +16,29 @@ use fabro_auth::{AuthCredential, AuthDetails, credential_id_for};
|
|||
use fabro_config::bind::{Bind, BindRequest};
|
||||
use fabro_config::{Storage, resolve_server_from_file};
|
||||
use fabro_install::{
|
||||
InstallListenConfig, PendingSettingsWrite, VaultSecretWrite, generate_jwt_keypair,
|
||||
merge_server_settings, persist_install_outputs_direct, write_github_app_settings,
|
||||
InstallListenConfig, OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV,
|
||||
PendingSettingsWrite, VaultSecretWrite, generate_jwt_keypair, merge_server_settings,
|
||||
persist_install_outputs_direct, write_github_app_settings, write_object_store_settings,
|
||||
write_token_settings,
|
||||
};
|
||||
use fabro_model::Provider;
|
||||
use fabro_store::ArtifactStore;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::server::ObjectStoreSettings;
|
||||
use fabro_util::version::FABRO_VERSION;
|
||||
use fabro_util::{Home, dev_token, session_secret};
|
||||
use fabro_vault::SecretType as VaultSecretType;
|
||||
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;
|
||||
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};
|
||||
|
|
@ -68,6 +75,10 @@ const DEFAULT_INSTALL_TCP_LISTEN_ADDRESS: &str = "127.0.0.1:32276";
|
|||
const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com/v1";
|
||||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
const DEFAULT_GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
|
||||
const REDACTED_SECRET_VALUE: &str = "[REDACTED]";
|
||||
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const VALIDATION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const AWS_SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN";
|
||||
|
||||
impl InstallAppState {
|
||||
#[must_use]
|
||||
|
|
@ -162,6 +173,7 @@ struct InstallTokenQuery {
|
|||
struct PendingInstall {
|
||||
llm: Option<LlmProvidersInput>,
|
||||
server: Option<ServerConfigInput>,
|
||||
object_store: Option<InstallObjectStoreState>,
|
||||
github: Option<GithubInstallState>,
|
||||
pending_github_app: Option<PendingGithubApp>,
|
||||
}
|
||||
|
|
@ -182,6 +194,156 @@ struct ServerConfigInput {
|
|||
canonical_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum InstallObjectStoreProvider {
|
||||
Local,
|
||||
S3,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum InstallObjectStoreCredentialMode {
|
||||
Runtime,
|
||||
AccessKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct InstallObjectStoreInput {
|
||||
provider: InstallObjectStoreProvider,
|
||||
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,
|
||||
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 => serde_json::json!({
|
||||
"provider": "local",
|
||||
}),
|
||||
Self::S3 {
|
||||
bucket,
|
||||
region,
|
||||
credential_mode,
|
||||
manual_credentials,
|
||||
} => serde_json::json!({
|
||||
"provider": "s3",
|
||||
"bucket": bucket,
|
||||
"region": region,
|
||||
"credential_mode": match credential_mode {
|
||||
InstallObjectStoreCredentialMode::Runtime => "runtime",
|
||||
InstallObjectStoreCredentialMode::AccessKey => "access_key",
|
||||
},
|
||||
"manual_credentials_saved": matches!(
|
||||
credential_mode,
|
||||
InstallObjectStoreCredentialMode::AccessKey
|
||||
) && manual_credentials.is_some(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_persistence_selection(&self) -> fabro_install::InstallObjectStoreSelection {
|
||||
match self {
|
||||
Self::Local => fabro_install::InstallObjectStoreSelection::Local,
|
||||
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, Debug, Deserialize, Serialize)]
|
||||
struct GithubTokenInput {
|
||||
token: String,
|
||||
|
|
@ -325,6 +487,14 @@ pub async fn build_install_router(state: InstallAppState) -> Router {
|
|||
"/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/github/token/test",
|
||||
post(post_install_github_token_test),
|
||||
|
|
@ -458,6 +628,7 @@ async fn get_install_session(
|
|||
"completed_steps": completed_steps(&pending_install),
|
||||
"llm": redacted_llm(&pending_install),
|
||||
"server": pending_install.server,
|
||||
"object_store": redacted_object_store(&pending_install),
|
||||
"github": redacted_github(&pending_install),
|
||||
"prefill": {
|
||||
"canonical_url": detect_canonical_url(&headers),
|
||||
|
|
@ -566,6 +737,331 @@ async fn put_install_server(
|
|||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
|
||||
fn trim_install_field(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn resolve_install_object_store_state(
|
||||
current: Option<&InstallObjectStoreState>,
|
||||
input: InstallObjectStoreInput,
|
||||
) -> Result<InstallObjectStoreState, String> {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(InstallObjectStoreState::Local)
|
||||
}
|
||||
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 => {
|
||||
match (access_key_id, secret_access_key) {
|
||||
(Some(access_key_id), Some(secret_access_key)) => Some(
|
||||
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(),
|
||||
InstallObjectStoreState::Local
|
||||
| InstallObjectStoreState::S3 {
|
||||
credential_mode: InstallObjectStoreCredentialMode::Runtime,
|
||||
..
|
||||
} => None,
|
||||
}),
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
return Err(
|
||||
"Enter both AWS access key fields or switch to runtime credentials."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(credential_mode, InstallObjectStoreCredentialMode::AccessKey)
|
||||
&& manual_credentials.is_none()
|
||||
{
|
||||
return Err(
|
||||
"Enter both AWS access key fields or switch to runtime credentials."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(InstallObjectStoreState::S3 {
|
||||
bucket,
|
||||
region,
|
||||
credential_mode,
|
||||
manual_credentials,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn object_store_validation_settings(
|
||||
selection: &InstallObjectStoreState,
|
||||
) -> Option<ObjectStoreSettings> {
|
||||
match selection {
|
||||
InstallObjectStoreState::Local => None,
|
||||
InstallObjectStoreState::S3 { bucket, region, .. } => Some(ObjectStoreSettings::S3 {
|
||||
bucket: InterpString::parse(bucket),
|
||||
region: InterpString::parse(region),
|
||||
endpoint: None,
|
||||
path_style: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_object_store_lookup<'a>(
|
||||
server_secrets: &'a crate::server_secrets::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(_), AWS_SESSION_TOKEN_ENV) => None,
|
||||
_ => server_secrets.get(name),
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_install_object_store_selection(
|
||||
state: &InstallAppState,
|
||||
selection: &InstallObjectStoreState,
|
||||
) -> Result<(), String> {
|
||||
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 => {
|
||||
return Err(format!(
|
||||
"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") {
|
||||
return Err(format!("Bucket {bucket} was not found."));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(
|
||||
"Timed out while checking S3 access. Verify the bucket, region, and network path, then try again."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(Ok(_)) => {}
|
||||
}
|
||||
|
||||
let server_env_path = Storage::new(state.storage_dir.as_ref())
|
||||
.runtime_directory()
|
||||
.env_path();
|
||||
let server_secrets = crate::server_secrets::ServerSecrets::load(server_env_path)
|
||||
.map_err(|err| err.to_string())?;
|
||||
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),
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let prefixes = ["artifacts", "slatedb"];
|
||||
let probe = async {
|
||||
for (index, prefix) in prefixes.iter().enumerate() {
|
||||
let path = ObjectStorePath::from(*prefix);
|
||||
if let Err(err) = object_store.list_with_delimiter(Some(&path)).await {
|
||||
return Err((index, err));
|
||||
}
|
||||
}
|
||||
Ok::<(), (usize, object_store::Error)>(())
|
||||
};
|
||||
|
||||
match timeout(VALIDATION_TIMEOUT, probe).await {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Err(_) => Err(
|
||||
"Timed out while checking S3 access. Verify the bucket, region, and network path, then try again."
|
||||
.to_string(),
|
||||
),
|
||||
Ok(Err((index, err))) => Err(classify_object_store_validation_error(
|
||||
bucket,
|
||||
region,
|
||||
index,
|
||||
&err,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_object_store_validation_error(
|
||||
bucket: &str,
|
||||
region: &str,
|
||||
prefix_index: usize,
|
||||
err: &object_store::Error,
|
||||
) -> String {
|
||||
match err {
|
||||
object_store::Error::PermissionDenied { .. }
|
||||
| object_store::Error::Unauthenticated { .. } => {
|
||||
if prefix_index == 0 {
|
||||
format!(
|
||||
"Could not access bucket {bucket} in region {region} with the selected credentials."
|
||||
)
|
||||
} else {
|
||||
"Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
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 if prefix_index == 0 {
|
||||
format!(
|
||||
"Could not access bucket {bucket} in region {region} with the selected credentials."
|
||||
)
|
||||
} else {
|
||||
"Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
object_store::Error::NotSupported { .. }
|
||||
| object_store::Error::AlreadyExists { .. }
|
||||
| object_store::Error::Precondition { .. }
|
||||
| object_store::Error::NotModified { .. }
|
||||
| object_store::Error::InvalidPath { .. }
|
||||
| object_store::Error::NotImplemented { .. }
|
||||
| object_store::Error::UnknownConfigurationKey { .. } => {
|
||||
"Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes."
|
||||
.to_string()
|
||||
}
|
||||
_ => "Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_install_github_token_test(
|
||||
State(state): State<InstallAppState>,
|
||||
headers: HeaderMap,
|
||||
|
|
@ -758,12 +1254,15 @@ async fn post_install_finish(
|
|||
|
||||
let pending_install = lock_unpoisoned(&state.pending_install, "install session").clone();
|
||||
|
||||
let Some(llm) = pending_install.llm else {
|
||||
return missing_step_response("llm");
|
||||
};
|
||||
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(llm) = pending_install.llm else {
|
||||
return missing_step_response("llm");
|
||||
};
|
||||
let Some(github) = pending_install.github else {
|
||||
return missing_step_response("github");
|
||||
};
|
||||
|
|
@ -775,6 +1274,15 @@ async fn post_install_finish(
|
|||
{
|
||||
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());
|
||||
}
|
||||
};
|
||||
let mut vault_secrets = Vec::new();
|
||||
for provider in llm.providers {
|
||||
let credential = AuthCredential {
|
||||
|
|
@ -801,7 +1309,13 @@ async fn post_install_finish(
|
|||
});
|
||||
}
|
||||
|
||||
let mut server_env_secrets = Vec::new();
|
||||
let make_env_write = |key: &str, value: String| fabro_config::envfile::EnvFileUpdate {
|
||||
key: key.to_string(),
|
||||
value,
|
||||
comment: None,
|
||||
};
|
||||
let mut server_env_writes = object_store_env_plan.writes;
|
||||
let server_env_removals = object_store_env_plan.removals;
|
||||
let mut dev_token: Option<String> = None;
|
||||
match github {
|
||||
GithubInstallState::Token(github) => {
|
||||
|
|
@ -845,13 +1359,16 @@ async fn post_install_finish(
|
|||
) {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
server_env_secrets.push((
|
||||
"GITHUB_APP_PRIVATE_KEY".to_string(),
|
||||
server_env_writes.push(make_env_write(
|
||||
"GITHUB_APP_PRIVATE_KEY",
|
||||
BASE64_STANDARD.encode(github.pem.as_bytes()),
|
||||
));
|
||||
server_env_secrets.push(("GITHUB_APP_CLIENT_SECRET".to_string(), github.client_secret));
|
||||
server_env_writes.push(make_env_write(
|
||||
"GITHUB_APP_CLIENT_SECRET",
|
||||
github.client_secret,
|
||||
));
|
||||
if let Some(secret) = github.webhook_secret {
|
||||
server_env_secrets.push(("GITHUB_APP_WEBHOOK_SECRET".to_string(), secret));
|
||||
server_env_writes.push(make_env_write("GITHUB_APP_WEBHOOK_SECRET", secret));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -870,19 +1387,19 @@ async fn post_install_finish(
|
|||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
};
|
||||
server_env_secrets.extend([
|
||||
(
|
||||
"FABRO_JWT_PRIVATE_KEY".to_string(),
|
||||
server_env_writes.extend([
|
||||
make_env_write(
|
||||
"FABRO_JWT_PRIVATE_KEY",
|
||||
BASE64_STANDARD.encode(jwt_private_pem.as_bytes()),
|
||||
),
|
||||
(
|
||||
"FABRO_JWT_PUBLIC_KEY".to_string(),
|
||||
make_env_write(
|
||||
"FABRO_JWT_PUBLIC_KEY",
|
||||
BASE64_STANDARD.encode(jwt_public_pem.as_bytes()),
|
||||
),
|
||||
("SESSION_SECRET".to_string(), session_secret),
|
||||
make_env_write("SESSION_SECRET", session_secret),
|
||||
]);
|
||||
if let Some(token) = dev_token.as_ref() {
|
||||
server_env_secrets.push(("FABRO_DEV_TOKEN".to_string(), token.clone()));
|
||||
server_env_writes.push(make_env_write("FABRO_DEV_TOKEN", token.clone()));
|
||||
}
|
||||
|
||||
#[expect(
|
||||
|
|
@ -894,7 +1411,8 @@ async fn post_install_finish(
|
|||
|
||||
if let Err(err) = persist_install_outputs_direct(
|
||||
state.storage_dir.as_ref(),
|
||||
&server_env_secrets,
|
||||
&server_env_writes,
|
||||
&server_env_removals,
|
||||
&vault_secrets,
|
||||
Some(&PendingSettingsWrite {
|
||||
path: state.config_path.as_ref(),
|
||||
|
|
@ -906,10 +1424,19 @@ async fn post_install_finish(
|
|||
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> = server_env_secrets
|
||||
.iter()
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
let leftover_env_keys: Vec<String> = if err.server_env_applied {
|
||||
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!({
|
||||
|
|
@ -919,6 +1446,7 @@ async fn post_install_finish(
|
|||
"detail": detail,
|
||||
}],
|
||||
"leftover_env_keys": leftover_env_keys,
|
||||
"removed_env_keys": removed_env_keys,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
|
|
@ -930,6 +1458,19 @@ async fn post_install_finish(
|
|||
warn!(error = %err, "failed to write artifact store metadata after install");
|
||||
}
|
||||
}
|
||||
if let Some(pending_object_store) = lock_unpoisoned(&state.pending_install, "install session")
|
||||
.object_store
|
||||
.as_mut()
|
||||
{
|
||||
if let InstallObjectStoreState::S3 {
|
||||
credential_mode: InstallObjectStoreCredentialMode::AccessKey,
|
||||
manual_credentials,
|
||||
..
|
||||
} = pending_object_store
|
||||
{
|
||||
*manual_credentials = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(on_finish) = state.on_finish.clone() {
|
||||
info!(restart_url = %server.canonical_url, "install finish succeeded");
|
||||
|
|
@ -1054,12 +1595,15 @@ fn detect_canonical_url(headers: &HeaderMap) -> String {
|
|||
|
||||
fn completed_steps(pending_install: &PendingInstall) -> Vec<&'static str> {
|
||||
let mut steps = Vec::new();
|
||||
if pending_install.llm.is_some() {
|
||||
steps.push("llm");
|
||||
}
|
||||
if pending_install.server.is_some() {
|
||||
steps.push("server");
|
||||
}
|
||||
if pending_install.object_store.is_some() {
|
||||
steps.push("object_store");
|
||||
}
|
||||
if pending_install.llm.is_some() {
|
||||
steps.push("llm");
|
||||
}
|
||||
if pending_install.github.is_some() {
|
||||
steps.push("github");
|
||||
}
|
||||
|
|
@ -1099,6 +1643,13 @@ fn redacted_github(pending_install: &PendingInstall) -> serde_json::Value {
|
|||
)
|
||||
}
|
||||
|
||||
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 missing_step_response(step: &str) -> Response {
|
||||
ApiError::new(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
|
@ -1436,14 +1987,21 @@ async fn wait_for_shutdown(mut shutdown_rx: watch::Receiver<bool>) {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV};
|
||||
use object_store::Error as ObjectStoreError;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
DEFAULT_INSTALL_GITHUB_API_BASE_URL, InstallAppState, InstallFinishGuard, PendingInstall,
|
||||
detect_canonical_url, lock_unpoisoned, token_is_valid,
|
||||
AWS_SESSION_TOKEN_ENV, DEFAULT_INSTALL_GITHUB_API_BASE_URL, InstallAppState,
|
||||
InstallAwsCredentialPair, InstallFinishGuard, InstallObjectStoreCredentialMode,
|
||||
InstallObjectStoreInput, InstallObjectStoreProvider, PendingInstall,
|
||||
classify_object_store_validation_error, detect_canonical_url, install_object_store_lookup,
|
||||
lock_unpoisoned, resolve_install_object_store_state, token_is_valid,
|
||||
};
|
||||
|
||||
#[test]
|
||||
|
|
@ -1515,4 +2073,110 @@ mod tests {
|
|||
"https://api.github.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[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,
|
||||
})
|
||||
.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_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()),
|
||||
})
|
||||
.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]
|
||||
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 =
|
||||
crate::server_secrets::ServerSecrets::with_env_lookup(env_path.clone(), |_| None)
|
||||
.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(AWS_SESSION_TOKEN_ENV), None);
|
||||
assert_eq!(
|
||||
lookup("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]"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use fabro_config::bind::{self, Bind, BindRequest};
|
|||
use fabro_config::merge::combine_files;
|
||||
use fabro_config::user::load_settings_config;
|
||||
use fabro_config::{Storage, resolve_server_from_file};
|
||||
use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV};
|
||||
use fabro_sandbox::SandboxProvider;
|
||||
use fabro_types::settings::server::{
|
||||
GithubIntegrationStrategy, ServerLayer, ServerListenLayer, WebhookStrategy,
|
||||
|
|
@ -18,10 +19,10 @@ use fabro_types::settings::{
|
|||
ServerSettings as ResolvedServerSettings, SettingsLayer,
|
||||
};
|
||||
use fabro_util::terminal::Styles;
|
||||
use object_store::ObjectStore;
|
||||
use object_store::aws::AmazonS3Builder;
|
||||
use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
|
||||
use object_store::local::LocalFileSystem;
|
||||
use object_store::memory::InMemory;
|
||||
use object_store::{ClientOptions, ObjectStore, RetryConfig};
|
||||
use tokio::net::{TcpListener, UnixListener};
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::interval;
|
||||
|
|
@ -38,9 +39,25 @@ use crate::server::{
|
|||
use crate::server_secrets::ServerSecrets;
|
||||
|
||||
const TEST_IN_MEMORY_STORE_ENV: &str = "FABRO_TEST_IN_MEMORY_STORE";
|
||||
const AWS_SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN";
|
||||
pub const DEFAULT_TCP_PORT: u16 = 32276;
|
||||
type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ObjectStoreBuildOptions {
|
||||
pub client_options: ClientOptions,
|
||||
pub retry_config: RetryConfig,
|
||||
}
|
||||
|
||||
impl Default for ObjectStoreBuildOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
client_options: ClientOptions::new(),
|
||||
retry_config: RetryConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ServerTitlePhase {
|
||||
Boot,
|
||||
|
|
@ -320,13 +337,82 @@ fn build_local_object_store_with_preference(
|
|||
Ok(Arc::new(LocalFileSystem::new_with_prefix(store_path)?))
|
||||
}
|
||||
|
||||
fn build_object_store_from_settings(
|
||||
fn configure_s3_builder_from_env_lookup<F>(
|
||||
mut builder: AmazonS3Builder,
|
||||
env_lookup: &F,
|
||||
build_options: &ObjectStoreBuildOptions,
|
||||
) -> anyhow::Result<AmazonS3Builder>
|
||||
where
|
||||
F: Fn(&str) -> Option<String>,
|
||||
{
|
||||
builder = builder
|
||||
.with_client_options(build_options.client_options.clone())
|
||||
.with_retry(build_options.retry_config.clone());
|
||||
|
||||
let access_key_id = env_lookup(OBJECT_STORE_ACCESS_KEY_ID_ENV);
|
||||
let secret_access_key = env_lookup(OBJECT_STORE_SECRET_ACCESS_KEY_ENV);
|
||||
let session_token = env_lookup(AWS_SESSION_TOKEN_ENV);
|
||||
match (access_key_id, secret_access_key) {
|
||||
(Some(access_key_id), Some(secret_access_key)) => {
|
||||
builder = builder
|
||||
.with_access_key_id(access_key_id)
|
||||
.with_secret_access_key(secret_access_key);
|
||||
if let Some(session_token) = session_token {
|
||||
builder = builder.with_token(session_token);
|
||||
}
|
||||
}
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
anyhow::bail!(
|
||||
"AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must both be set when using static AWS credentials"
|
||||
);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
for (name, key) in [
|
||||
(
|
||||
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
||||
AmazonS3ConfigKey::WebIdentityTokenFile,
|
||||
),
|
||||
("AWS_ROLE_ARN", AmazonS3ConfigKey::RoleArn),
|
||||
("AWS_ROLE_SESSION_NAME", AmazonS3ConfigKey::RoleSessionName),
|
||||
("AWS_ENDPOINT_URL_STS", AmazonS3ConfigKey::StsEndpoint),
|
||||
(
|
||||
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
||||
AmazonS3ConfigKey::ContainerCredentialsRelativeUri,
|
||||
),
|
||||
(
|
||||
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
|
||||
AmazonS3ConfigKey::ContainerCredentialsFullUri,
|
||||
),
|
||||
(
|
||||
"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
|
||||
AmazonS3ConfigKey::ContainerAuthorizationTokenFile,
|
||||
),
|
||||
("AWS_METADATA_ENDPOINT", AmazonS3ConfigKey::MetadataEndpoint),
|
||||
("AWS_IMDSV1_FALLBACK", AmazonS3ConfigKey::ImdsV1Fallback),
|
||||
] {
|
||||
if let Some(value) = env_lookup(name) {
|
||||
builder = builder.with_config(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
pub(crate) fn build_object_store_from_settings_with_lookup<F>(
|
||||
settings: &ObjectStoreSettings,
|
||||
) -> anyhow::Result<Arc<dyn ObjectStore>> {
|
||||
env_lookup: &F,
|
||||
build_options: Option<&ObjectStoreBuildOptions>,
|
||||
) -> anyhow::Result<Arc<dyn ObjectStore>>
|
||||
where
|
||||
F: Fn(&str) -> Option<String>,
|
||||
{
|
||||
if use_in_memory_store() {
|
||||
return Ok(Arc::new(InMemory::new()));
|
||||
}
|
||||
|
||||
let build_options = build_options.cloned().unwrap_or_default();
|
||||
match settings {
|
||||
ObjectStoreSettings::Local { root } => {
|
||||
build_local_object_store_with_preference(&resolve_interp_path(root)?, false)
|
||||
|
|
@ -337,13 +423,14 @@ fn build_object_store_from_settings(
|
|||
endpoint,
|
||||
path_style,
|
||||
} => {
|
||||
let mut builder = AmazonS3Builder::from_env()
|
||||
let mut builder = AmazonS3Builder::new()
|
||||
.with_bucket_name(resolve_interp(bucket)?)
|
||||
.with_region(resolve_interp(region)?)
|
||||
.with_virtual_hosted_style_request(!*path_style);
|
||||
if let Some(endpoint) = endpoint.as_ref() {
|
||||
builder = builder.with_endpoint(resolve_interp(endpoint)?);
|
||||
}
|
||||
builder = configure_s3_builder_from_env_lookup(builder, env_lookup, &build_options)?;
|
||||
Ok(Arc::new(builder.build()?))
|
||||
}
|
||||
}
|
||||
|
|
@ -417,19 +504,44 @@ fn resolve_interp_path(value: &InterpString) -> anyhow::Result<PathBuf> {
|
|||
Ok(PathBuf::from(resolve_interp(value)?))
|
||||
}
|
||||
|
||||
pub fn build_artifact_object_store(
|
||||
fn load_server_secrets_for_settings(
|
||||
settings: &ResolvedServerSettings,
|
||||
) -> anyhow::Result<ServerSecrets> {
|
||||
let storage_root = resolve_interp_path(&settings.storage.root)?;
|
||||
let server_env_path = Storage::new(&storage_root).runtime_directory().env_path();
|
||||
ServerSecrets::load(server_env_path).map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
pub(crate) fn build_artifact_object_store_with_server_secrets(
|
||||
settings: &ResolvedServerSettings,
|
||||
server_secrets: &ServerSecrets,
|
||||
) -> anyhow::Result<(Arc<dyn ObjectStore>, String)> {
|
||||
let prefix = resolve_interp(&settings.artifacts.prefix)?;
|
||||
let object_store = build_object_store_from_settings(&settings.artifacts.store)?;
|
||||
let object_store = build_object_store_from_settings_with_lookup(
|
||||
&settings.artifacts.store,
|
||||
&|name| server_secrets.get(name),
|
||||
None,
|
||||
)?;
|
||||
Ok((object_store, prefix))
|
||||
}
|
||||
|
||||
fn build_slatedb_store(
|
||||
pub fn build_artifact_object_store(
|
||||
settings: &ResolvedServerSettings,
|
||||
) -> anyhow::Result<(Arc<dyn ObjectStore>, String)> {
|
||||
let server_secrets = load_server_secrets_for_settings(settings)?;
|
||||
build_artifact_object_store_with_server_secrets(settings, &server_secrets)
|
||||
}
|
||||
|
||||
fn build_slatedb_store_with_server_secrets(
|
||||
settings: &ResolvedServerSettings,
|
||||
server_secrets: &ServerSecrets,
|
||||
) -> anyhow::Result<(Arc<dyn ObjectStore>, String, Duration, bool)> {
|
||||
let prefix = resolve_interp(&settings.slatedb.prefix)?;
|
||||
let object_store = build_object_store_from_settings(&settings.slatedb.store)?;
|
||||
let object_store = build_object_store_from_settings_with_lookup(
|
||||
&settings.slatedb.store,
|
||||
&|name| server_secrets.get(name),
|
||||
None,
|
||||
)?;
|
||||
Ok((
|
||||
object_store,
|
||||
prefix,
|
||||
|
|
@ -438,6 +550,14 @@ fn build_slatedb_store(
|
|||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn build_slatedb_store(
|
||||
settings: &ResolvedServerSettings,
|
||||
) -> anyhow::Result<(Arc<dyn ObjectStore>, String, Duration, bool)> {
|
||||
let server_secrets = load_server_secrets_for_settings(settings)?;
|
||||
build_slatedb_store_with_server_secrets(settings, &server_secrets)
|
||||
}
|
||||
|
||||
/// Start the HTTP API server.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
@ -493,7 +613,7 @@ where
|
|||
let github_meta_resolver = GitHubMetaResolver::from_cache_dir(&storage.cache_dir())?;
|
||||
|
||||
let (object_store, slatedb_prefix, flush_interval, disk_cache) =
|
||||
build_slatedb_store(&resolved_server_settings)?;
|
||||
build_slatedb_store_with_server_secrets(&resolved_server_settings, &server_secrets)?;
|
||||
let cache_path = if disk_cache {
|
||||
Some(storage.slatedb_cache_dir())
|
||||
} else {
|
||||
|
|
@ -507,8 +627,10 @@ where
|
|||
));
|
||||
let auth_code_store = store.auth_codes().await?;
|
||||
let auth_token_store = store.refresh_tokens().await?;
|
||||
let (artifact_object_store, artifact_prefix) =
|
||||
build_artifact_object_store(&resolved_server_settings)?;
|
||||
let (artifact_object_store, artifact_prefix) = build_artifact_object_store_with_server_secrets(
|
||||
&resolved_server_settings,
|
||||
&server_secrets,
|
||||
)?;
|
||||
let artifact_store = fabro_store::ArtifactStore::new(artifact_object_store, artifact_prefix);
|
||||
let env_lookup: EnvLookup = Arc::new(|name| std::env::var(name).ok());
|
||||
resolve_canonical_origin(&resolved_server_settings, &env_lookup).map_err(anyhow::Error::msg)?;
|
||||
|
|
@ -900,11 +1022,14 @@ mod tests {
|
|||
use fabro_config::bind::{Bind, BindRequest};
|
||||
use fabro_config::parse_settings_layer;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::server::ObjectStoreSettings;
|
||||
use fabro_util::Home;
|
||||
|
||||
use super::{
|
||||
GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_runtime_settings,
|
||||
bind_tcp_host_with_fallback, build_local_object_store_with_preference, build_slatedb_store,
|
||||
bind_tcp_host_with_fallback, build_local_object_store_with_preference,
|
||||
build_object_store_from_settings_with_lookup, build_slatedb_store,
|
||||
resolve_bind_request_from_settings, resolve_github_webhook_ip_allowlist,
|
||||
resolve_server_settings, resolve_startup_github_webhook_ip_allowlist, router_web_enabled,
|
||||
server_bind_title, server_title,
|
||||
|
|
@ -1164,6 +1289,81 @@ disk_cache = true
|
|||
assert!(disk_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_store_from_settings_uses_injected_static_credentials() {
|
||||
let settings = ObjectStoreSettings::S3 {
|
||||
bucket: InterpString::parse("fabro-data"),
|
||||
region: InterpString::parse("us-east-1"),
|
||||
endpoint: None,
|
||||
path_style: false,
|
||||
};
|
||||
|
||||
let store = build_object_store_from_settings_with_lookup(
|
||||
&settings,
|
||||
&|name| match name {
|
||||
"AWS_ACCESS_KEY_ID" => Some("AKIA_TEST_VALUE".to_string()),
|
||||
"AWS_SECRET_ACCESS_KEY" => Some("secret-test-value".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(store.is_ok(), "injected static credentials should build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_store_from_settings_rejects_partial_static_credentials() {
|
||||
let settings = ObjectStoreSettings::S3 {
|
||||
bucket: InterpString::parse("fabro-data"),
|
||||
region: InterpString::parse("us-east-1"),
|
||||
endpoint: None,
|
||||
path_style: false,
|
||||
};
|
||||
|
||||
let err = build_object_store_from_settings_with_lookup(
|
||||
&settings,
|
||||
&|name| match name {
|
||||
"AWS_ACCESS_KEY_ID" => Some("AKIA_TEST_VALUE".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.expect_err("partial static credentials must fail");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must both be set")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_store_from_settings_ignores_endpoint_override_env_vars() {
|
||||
let settings = ObjectStoreSettings::S3 {
|
||||
bucket: InterpString::parse("fabro-data"),
|
||||
region: InterpString::parse("us-east-1"),
|
||||
endpoint: None,
|
||||
path_style: false,
|
||||
};
|
||||
|
||||
let store = build_object_store_from_settings_with_lookup(
|
||||
&settings,
|
||||
&|name| match name {
|
||||
"AWS_ACCESS_KEY_ID" => Some("AKIA_TEST_VALUE".to_string()),
|
||||
"AWS_SECRET_ACCESS_KEY" => Some("secret-test-value".to_string()),
|
||||
"AWS_ENDPOINT" | "AWS_ENDPOINT_URL_S3" => {
|
||||
Some("://not-a-valid-endpoint".to_string())
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
store.is_ok(),
|
||||
"unsupported endpoint env vars should be ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_host_request_uses_preferred_port_when_available() {
|
||||
let preferred = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
|
||||
)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use fabro_config::{Storage, parse_settings_layer, resolve_server_from_file};
|
||||
use fabro_install::OBJECT_STORE_MANAGED_COMMENT;
|
||||
use fabro_model::Provider;
|
||||
use fabro_server::install::{InstallAppState, build_install_router};
|
||||
use fabro_util::{Home, dev_token};
|
||||
|
|
@ -17,11 +19,81 @@ use fabro_vault::Vault;
|
|||
use httpmock::MockServer;
|
||||
use tokio::time::sleep;
|
||||
use tower::ServiceExt;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::{Event, Subscriber};
|
||||
use tracing_subscriber::layer::{Context, SubscriberExt};
|
||||
use tracing_subscriber::{Layer, Registry};
|
||||
|
||||
use crate::helpers::{checked_response, response_json, response_status, response_text};
|
||||
|
||||
async fn configure_token_install(app: &axum::Router, token: &str) {
|
||||
let llm_response = app
|
||||
#[derive(Default)]
|
||||
struct EventCapture {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Visit for EventCapture {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.fields
|
||||
.push((field.name().to_string(), format!("{value:?}")));
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.fields
|
||||
.push((field.name().to_string(), value.to_string()));
|
||||
}
|
||||
|
||||
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||
self.fields
|
||||
.push((field.name().to_string(), value.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
struct CaptureLayer {
|
||||
lines: Arc<StdMutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl<S: Subscriber> Layer<S> for CaptureLayer {
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
if !event
|
||||
.metadata()
|
||||
.target()
|
||||
.starts_with("fabro_server::install")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let mut capture = EventCapture::default();
|
||||
event.record(&mut capture);
|
||||
|
||||
let mut line = event.metadata().level().to_string();
|
||||
for (field, value) in capture.fields {
|
||||
let _ = write!(line, " {field}={value}");
|
||||
}
|
||||
self.lines.lock().unwrap().push(line);
|
||||
}
|
||||
}
|
||||
|
||||
async fn put_install_server(app: &axum::Router, token: &str, canonical_url: &str) {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/server")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"canonical_url":"{canonical_url}"}}"#
|
||||
)))
|
||||
.expect("server install request should build"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(response, StatusCode::NO_CONTENT, "PUT /install/server").await;
|
||||
}
|
||||
|
||||
async fn put_install_llm(app: &axum::Router, token: &str) {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
|
|
@ -36,31 +108,11 @@ async fn configure_token_install(app: &axum::Router, token: &str) {
|
|||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
|
||||
response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
|
||||
}
|
||||
|
||||
let server_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/server")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
||||
))
|
||||
.expect("server install request should build"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(
|
||||
server_response,
|
||||
StatusCode::NO_CONTENT,
|
||||
"PUT /install/server",
|
||||
)
|
||||
.await;
|
||||
|
||||
let github_response = app
|
||||
async fn put_install_github_token(app: &axum::Router, token: &str, username: &str) {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
|
|
@ -68,21 +120,54 @@ async fn configure_token_install(app: &axum::Router, token: &str) {
|
|||
.uri("/install/github/token")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"token":"ghp_test_token","username":"brynary"}"#,
|
||||
))
|
||||
.body(Body::from(format!(
|
||||
r#"{{"token":"ghp_test_token","username":"{username}"}}"#
|
||||
)))
|
||||
.expect("GitHub token install request should build"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(
|
||||
github_response,
|
||||
response,
|
||||
StatusCode::NO_CONTENT,
|
||||
"PUT /install/github/token",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn put_install_object_store(app: &axum::Router, token: &str, body: &str) {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/object-store")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.expect("object-store install request should build"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::NO_CONTENT,
|
||||
"PUT /install/object-store",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn put_install_object_store_local(app: &axum::Router, token: &str) {
|
||||
put_install_object_store(app, token, r#"{"provider":"local"}"#).await;
|
||||
}
|
||||
|
||||
async fn configure_token_install(app: &axum::Router, token: &str) {
|
||||
put_install_server(app, token, "https://fabro.example.com").await;
|
||||
put_install_object_store_local(app, token).await;
|
||||
put_install_llm(app, token).await;
|
||||
put_install_github_token(app, token, "brynary").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_router_isolated_from_normal_api_surface() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
|
|
@ -195,6 +280,16 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() {
|
|||
"/install/server",
|
||||
Some(r#"{"canonical_url":"https://fabro.example.com"}"#),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/install/object-store/test",
|
||||
Some(r#"{"provider":"local"}"#),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
"/install/object-store",
|
||||
Some(r#"{"provider":"local"}"#),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/install/github/token/test",
|
||||
|
|
@ -280,7 +375,124 @@ async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_install_finish_persists_settings_env_and_vault() {
|
||||
async fn object_store_local_validation_and_save_update_install_session() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
|
||||
let validation_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/object-store/test")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"provider":"local"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let validation_body = response_json(
|
||||
validation_response,
|
||||
StatusCode::OK,
|
||||
"POST /install/object-store/test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(validation_body["ok"], true);
|
||||
|
||||
put_install_object_store_local(&app, "test-install-token").await;
|
||||
|
||||
let session_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/install/session")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let session_body =
|
||||
response_json(session_response, StatusCode::OK, "GET /install/session").await;
|
||||
assert_eq!(session_body["object_store"]["provider"], "local");
|
||||
assert!(
|
||||
session_body["completed_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|value| value == "object_store")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_store_validation_rejects_runtime_mode_access_keys_without_echoing_secrets() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
let access_key_id = "AKIA_RUNTIME_SHOULD_NOT_LEAK";
|
||||
let secret_access_key = "runtime-secret-should-not-leak";
|
||||
|
||||
let validation_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/object-store/test")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime","access_key_id":"{access_key_id}","secret_access_key":"{secret_access_key}"}}"#
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let validation_body = response_json(
|
||||
validation_response,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"POST /install/object-store/test",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
validation_body["errors"][0]["detail"],
|
||||
"AWS access key fields are only allowed when using manual AWS access key credentials."
|
||||
);
|
||||
let rendered = validation_body.to_string();
|
||||
assert!(!rendered.contains(access_key_id));
|
||||
assert!(!rendered.contains(secret_access_key));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_finish_requires_object_store_step() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token")).await;
|
||||
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_llm(&app, "test-install-token").await;
|
||||
put_install_github_token(&app, "test-install-token", "brynary").await;
|
||||
|
||||
let finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/finish")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let finish_body = response_json(
|
||||
finish_response,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"POST /install/finish",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
finish_body["errors"][0]["detail"],
|
||||
"install step 'object_store' is incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_object_store_session_is_redacted_and_blank_resubmit_preserves_credentials() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
let app = build_install_router(InstallAppState::for_test_with_paths(
|
||||
|
|
@ -290,67 +502,220 @@ async fn token_install_finish_persists_settings_env_and_vault() {
|
|||
))
|
||||
.await;
|
||||
|
||||
let llm_response = app
|
||||
put_install_object_store(
|
||||
&app,
|
||||
"test-install-token",
|
||||
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"AKIA_TEST_VALUE","secret_access_key":"secret-test-value"}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let session_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/llm")
|
||||
.method("GET")
|
||||
.uri("/install/session")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
|
||||
let session_body =
|
||||
response_json(session_response, StatusCode::OK, "GET /install/session").await;
|
||||
assert_eq!(session_body["object_store"]["provider"], "s3");
|
||||
assert_eq!(session_body["object_store"]["bucket"], "fabro-data");
|
||||
assert_eq!(session_body["object_store"]["region"], "us-east-1");
|
||||
assert_eq!(
|
||||
session_body["object_store"]["credential_mode"],
|
||||
"access_key"
|
||||
);
|
||||
assert_eq!(
|
||||
session_body["object_store"]["manual_credentials_saved"],
|
||||
true
|
||||
);
|
||||
let rendered_session = session_body.to_string();
|
||||
assert!(!rendered_session.contains("AKIA_TEST_VALUE"));
|
||||
assert!(!rendered_session.contains("secret-test-value"));
|
||||
|
||||
let server_response = app
|
||||
.clone()
|
||||
put_install_object_store(
|
||||
&app,
|
||||
"test-install-token",
|
||||
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key"}"#,
|
||||
)
|
||||
.await;
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_llm(&app, "test-install-token").await;
|
||||
put_install_github_token(&app, "test-install-token", "brynary").await;
|
||||
|
||||
let finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/server")
|
||||
.method("POST")
|
||||
.uri("/install/finish")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(
|
||||
server_response,
|
||||
StatusCode::NO_CONTENT,
|
||||
"PUT /install/server",
|
||||
finish_response,
|
||||
StatusCode::ACCEPTED,
|
||||
"POST /install/finish",
|
||||
)
|
||||
.await;
|
||||
|
||||
let github_response = app
|
||||
let server_env =
|
||||
std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
|
||||
.unwrap();
|
||||
assert!(server_env.contains("AWS_ACCESS_KEY_ID=AKIA_TEST_VALUE"));
|
||||
assert!(server_env.contains("AWS_SECRET_ACCESS_KEY=secret-test-value"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn switching_object_store_from_manual_to_runtime_clears_saved_manual_credentials() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
let app = build_install_router(InstallAppState::for_test_with_paths(
|
||||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
|
||||
put_install_object_store(
|
||||
&app,
|
||||
"test-install-token",
|
||||
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"AKIA_SWITCH_ME","secret_access_key":"switch-secret-value"}"#,
|
||||
)
|
||||
.await;
|
||||
put_install_object_store(
|
||||
&app,
|
||||
"test-install-token",
|
||||
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let session_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/github/token")
|
||||
.method("GET")
|
||||
.uri("/install/session")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"token":"ghp_test_token","username":"brynary"}"#,
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let session_body =
|
||||
response_json(session_response, StatusCode::OK, "GET /install/session").await;
|
||||
assert_eq!(session_body["object_store"]["provider"], "s3");
|
||||
assert_eq!(session_body["object_store"]["credential_mode"], "runtime");
|
||||
assert_eq!(
|
||||
session_body["object_store"]["manual_credentials_saved"],
|
||||
false
|
||||
);
|
||||
let rendered_session = session_body.to_string();
|
||||
assert!(!rendered_session.contains("AKIA_SWITCH_ME"));
|
||||
assert!(!rendered_session.contains("switch-secret-value"));
|
||||
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_llm(&app, "test-install-token").await;
|
||||
put_install_github_token(&app, "test-install-token", "brynary").await;
|
||||
|
||||
let finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/finish")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(
|
||||
github_response,
|
||||
StatusCode::NO_CONTENT,
|
||||
"PUT /install/github/token",
|
||||
finish_response,
|
||||
StatusCode::ACCEPTED,
|
||||
"POST /install/finish",
|
||||
)
|
||||
.await;
|
||||
|
||||
let server_env =
|
||||
std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
|
||||
.unwrap();
|
||||
assert!(!server_env.contains("AWS_ACCESS_KEY_ID="));
|
||||
assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_object_store_finish_removes_managed_aws_keys_but_keeps_unmarked_entries() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
let storage = Storage::new(temp_dir.path());
|
||||
std::fs::write(
|
||||
storage.runtime_directory().env_path(),
|
||||
format!(
|
||||
"AWS_ACCESS_KEY_ID=operator-id\n# {OBJECT_STORE_MANAGED_COMMENT}\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let app = build_install_router(InstallAppState::for_test_with_paths(
|
||||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_object_store(
|
||||
&app,
|
||||
"test-install-token",
|
||||
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
|
||||
)
|
||||
.await;
|
||||
put_install_llm(&app, "test-install-token").await;
|
||||
put_install_github_token(&app, "test-install-token", "brynary").await;
|
||||
|
||||
let finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/finish")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(
|
||||
finish_response,
|
||||
StatusCode::ACCEPTED,
|
||||
"POST /install/finish",
|
||||
)
|
||||
.await;
|
||||
|
||||
let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
|
||||
assert!(server_env.contains("AWS_ACCESS_KEY_ID=operator-id"));
|
||||
assert!(!server_env.contains("managed-secret"));
|
||||
assert!(server_env.contains("KEEP_ME=1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_install_finish_persists_settings_env_and_vault() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
let app = build_install_router(InstallAppState::for_test_with_paths(
|
||||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
configure_token_install(&app, "test-install-token").await;
|
||||
|
||||
let finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
|
|
@ -403,6 +768,8 @@ async fn token_install_finish_persists_settings_env_and_vault() {
|
|||
assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY="));
|
||||
assert!(server_env.contains("SESSION_SECRET="));
|
||||
assert!(server_env.contains("FABRO_DEV_TOKEN="));
|
||||
assert!(!server_env.contains("AWS_ACCESS_KEY_ID="));
|
||||
assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY="));
|
||||
|
||||
let vault = Vault::load(fabro_config::Storage::new(temp_dir.path()).secrets_path()).unwrap();
|
||||
assert!(vault.get("anthropic").is_some());
|
||||
|
|
@ -480,6 +847,8 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
|
|||
)
|
||||
.await;
|
||||
|
||||
put_install_object_store_local(&app, "test-install-token").await;
|
||||
|
||||
let manifest_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
|
|
@ -1400,6 +1769,146 @@ async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys(
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn install_finish_failure_with_manual_credentials_does_not_leak_values() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
|
||||
|
||||
let storage = Storage::new(temp_dir.path());
|
||||
let vault_path = storage.secrets_path();
|
||||
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&vault_path, "{ not valid json").unwrap();
|
||||
|
||||
let app = build_install_router(InstallAppState::for_test_with_paths(
|
||||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
|
||||
let access_key_id = "AKIA_FINISH_SHOULD_NOT_LEAK";
|
||||
let secret_access_key = "finish-secret-should-not-leak";
|
||||
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_object_store(
|
||||
&app,
|
||||
"test-install-token",
|
||||
&format!(
|
||||
r#"{{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"{access_key_id}","secret_access_key":"{secret_access_key}"}}"#
|
||||
),
|
||||
)
|
||||
.await;
|
||||
put_install_llm(&app, "test-install-token").await;
|
||||
put_install_github_token(&app, "test-install-token", "brynary").await;
|
||||
|
||||
let lines = Arc::new(StdMutex::new(Vec::new()));
|
||||
let subscriber = Registry::default().with(CaptureLayer {
|
||||
lines: Arc::clone(&lines),
|
||||
});
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/finish")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let finish_body = response_json(
|
||||
finish_response,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"POST /install/finish",
|
||||
)
|
||||
.await;
|
||||
|
||||
let rendered = finish_body.to_string();
|
||||
assert!(!rendered.contains(access_key_id));
|
||||
assert!(!rendered.contains(secret_access_key));
|
||||
|
||||
let captured = lines.lock().unwrap().join("\n");
|
||||
assert!(
|
||||
captured.contains("install persistence failed"),
|
||||
"expected finish failure logs to be captured, got: {captured}"
|
||||
);
|
||||
assert!(!captured.contains(access_key_id));
|
||||
assert!(!captured.contains(secret_access_key));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_finish_failure_reports_only_env_keys_actually_removed() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
|
||||
|
||||
let storage = Storage::new(temp_dir.path());
|
||||
let env_path = storage.runtime_directory().env_path();
|
||||
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&env_path,
|
||||
format!(
|
||||
"#{OBJECT_STORE_MANAGED_COMMENT}\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let vault_path = storage.secrets_path();
|
||||
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&vault_path, "{ not valid json").unwrap();
|
||||
|
||||
let app = build_install_router(InstallAppState::for_test_with_paths(
|
||||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
))
|
||||
.await;
|
||||
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_object_store(
|
||||
&app,
|
||||
"test-install-token",
|
||||
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
|
||||
)
|
||||
.await;
|
||||
put_install_llm(&app, "test-install-token").await;
|
||||
put_install_github_token(&app, "test-install-token", "brynary").await;
|
||||
|
||||
let finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/finish")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let finish_body = response_json(
|
||||
finish_response,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"POST /install/finish",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
finish_body["removed_env_keys"]
|
||||
.as_array()
|
||||
.expect("removed_env_keys should be present"),
|
||||
&vec![serde_json::Value::String(
|
||||
"AWS_SECRET_ACCESS_KEY".to_string()
|
||||
)]
|
||||
);
|
||||
|
||||
let server_env = std::fs::read_to_string(env_path).unwrap();
|
||||
assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY=managed-secret"));
|
||||
assert!(server_env.contains("KEEP_ME=1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_finish_failure_leaves_home_dev_token_mirror_written() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
2402
lib/crates/fabro-spa/assets/assets/entry-k8y1hgqx.js
generated
Normal file
2402
lib/crates/fabro-spa/assets/assets/entry-k8y1hgqx.js
generated
Normal file
File diff suppressed because one or more lines are too long
2402
lib/crates/fabro-spa/assets/assets/entry-q11nrnd3.js
generated
2402
lib/crates/fabro-spa/assets/assets/entry-q11nrnd3.js
generated
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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-q11nrnd3.js"></script>
|
||||
<script type="module" src="/assets/entry-k8y1hgqx.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>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ models/install-llm-summary-providers-inner.ts
|
|||
models/install-llm-summary.ts
|
||||
models/install-llm-test-input.ts
|
||||
models/install-llm-validation-response.ts
|
||||
models/install-object-store-input.ts
|
||||
models/install-object-store-summary.ts
|
||||
models/install-object-store-validation-response.ts
|
||||
models/install-prefill.ts
|
||||
models/install-server-config-input.ts
|
||||
models/install-session-response.ts
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ import type { InstallLlmTestInput } from '../models';
|
|||
// @ts-ignore
|
||||
import type { InstallLlmValidationResponse } from '../models';
|
||||
// @ts-ignore
|
||||
import type { InstallObjectStoreInput } from '../models';
|
||||
// @ts-ignore
|
||||
import type { InstallObjectStoreValidationResponse } from '../models';
|
||||
// @ts-ignore
|
||||
import type { InstallServerConfigInput } from '../models';
|
||||
// @ts-ignore
|
||||
import type { InstallSessionResponse } from '../models';
|
||||
|
|
@ -259,6 +263,41 @@ export const InstallApiAxiosParamCreator = function (configuration?: Configurati
|
|||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Records the object-store mode selected during browser install. Requires the one-time install token.
|
||||
* @summary Save install object-store configuration
|
||||
* @param {InstallObjectStoreInput} installObjectStoreInput
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
putInstallObjectStore: async (installObjectStoreInput: InstallObjectStoreInput, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'installObjectStoreInput' is not null or undefined
|
||||
assertParamExists('putInstallObjectStore', 'installObjectStoreInput', installObjectStoreInput)
|
||||
const localVarPath = `/install/object-store`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(installObjectStoreInput, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
|
||||
* @summary Save install server configuration
|
||||
|
|
@ -359,6 +398,41 @@ export const InstallApiAxiosParamCreator = function (configuration?: Configurati
|
|||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(installLlmTestInput, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
|
||||
* @summary Validate install object-store configuration
|
||||
* @param {InstallObjectStoreInput} installObjectStoreInput
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
testInstallObjectStore: async (installObjectStoreInput: InstallObjectStoreInput, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'installObjectStoreInput' is not null or undefined
|
||||
assertParamExists('testInstallObjectStore', 'installObjectStoreInput', installObjectStoreInput)
|
||||
const localVarPath = `/install/object-store/test`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(installObjectStoreInput, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
|
|
@ -450,6 +524,19 @@ export const InstallApiFp = function(configuration?: Configuration) {
|
|||
const localVarOperationServerBasePath = operationServerMap['InstallApi.putInstallLlm']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Records the object-store mode selected during browser install. Requires the one-time install token.
|
||||
* @summary Save install object-store configuration
|
||||
* @param {InstallObjectStoreInput} installObjectStoreInput
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async putInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.putInstallObjectStore(installObjectStoreInput, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['InstallApi.putInstallObjectStore']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
|
||||
* @summary Save install server configuration
|
||||
|
|
@ -489,6 +576,19 @@ export const InstallApiFp = function(configuration?: Configuration) {
|
|||
const localVarOperationServerBasePath = operationServerMap['InstallApi.testInstallLlmCredentials']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
|
||||
* @summary Validate install object-store configuration
|
||||
* @param {InstallObjectStoreInput} installObjectStoreInput
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async testInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<InstallObjectStoreValidationResponse>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.testInstallObjectStore(installObjectStoreInput, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['InstallApi.testInstallObjectStore']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -557,6 +657,16 @@ export const InstallApiFactory = function (configuration?: Configuration, basePa
|
|||
putInstallLlm(installLlmProvidersInput: InstallLlmProvidersInput, options?: RawAxiosRequestConfig): AxiosPromise<void> {
|
||||
return localVarFp.putInstallLlm(installLlmProvidersInput, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Records the object-store mode selected during browser install. Requires the one-time install token.
|
||||
* @summary Save install object-store configuration
|
||||
* @param {InstallObjectStoreInput} installObjectStoreInput
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
putInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): AxiosPromise<void> {
|
||||
return localVarFp.putInstallObjectStore(installObjectStoreInput, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
|
||||
* @summary Save install server configuration
|
||||
|
|
@ -587,6 +697,16 @@ export const InstallApiFactory = function (configuration?: Configuration, basePa
|
|||
testInstallLlmCredentials(installLlmTestInput: InstallLlmTestInput, options?: RawAxiosRequestConfig): AxiosPromise<InstallLlmValidationResponse> {
|
||||
return localVarFp.testInstallLlmCredentials(installLlmTestInput, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
|
||||
* @summary Validate install object-store configuration
|
||||
* @param {InstallObjectStoreInput} installObjectStoreInput
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
testInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig): AxiosPromise<InstallObjectStoreValidationResponse> {
|
||||
return localVarFp.testInstallObjectStore(installObjectStoreInput, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -659,6 +779,17 @@ export class InstallApi extends BaseAPI {
|
|||
return InstallApiFp(this.configuration).putInstallLlm(installLlmProvidersInput, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the object-store mode selected during browser install. Requires the one-time install token.
|
||||
* @summary Save install object-store configuration
|
||||
* @param {InstallObjectStoreInput} installObjectStoreInput
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public putInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig) {
|
||||
return InstallApiFp(this.configuration).putInstallObjectStore(installObjectStoreInput, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the canonical server URL confirmed by the operator. Requires the one-time install token.
|
||||
* @summary Save install server configuration
|
||||
|
|
@ -691,5 +822,16 @@ export class InstallApi extends BaseAPI {
|
|||
public testInstallLlmCredentials(installLlmTestInput: InstallLlmTestInput, options?: RawAxiosRequestConfig) {
|
||||
return InstallApiFp(this.configuration).testInstallLlmCredentials(installLlmTestInput, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the browser-install object-store selection without persisting it. Requires the one-time install token.
|
||||
* @summary Validate install object-store configuration
|
||||
* @param {InstallObjectStoreInput} installObjectStoreInput
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public testInstallObjectStore(installObjectStoreInput: InstallObjectStoreInput, options?: RawAxiosRequestConfig) {
|
||||
return InstallApiFp(this.configuration).testInstallObjectStore(installObjectStoreInput, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,5 +25,13 @@ export interface ErrorResponse {
|
|||
* List of error entries.
|
||||
*/
|
||||
'errors': Array<ErrorResponseEntry>;
|
||||
/**
|
||||
* Optional list of runtime env keys that were written before an install failure. Currently populated by `POST /install/finish` failure responses only.
|
||||
*/
|
||||
'leftover_env_keys'?: Array<string>;
|
||||
/**
|
||||
* Optional list of runtime env keys that were actually removed before an install failure. Currently populated by `POST /install/finish` failure responses only.
|
||||
*/
|
||||
'removed_env_keys'?: Array<string>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ export * from './install-llm-summary';
|
|||
export * from './install-llm-summary-providers-inner';
|
||||
export * from './install-llm-test-input';
|
||||
export * from './install-llm-validation-response';
|
||||
export * from './install-object-store-input';
|
||||
export * from './install-object-store-summary';
|
||||
export * from './install-object-store-validation-response';
|
||||
export * from './install-prefill';
|
||||
export * from './install-server-config-input';
|
||||
export * from './install-session-response';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* 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
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Object-store mode selected during browser install.
|
||||
*/
|
||||
export interface InstallObjectStoreInput {
|
||||
'provider': InstallObjectStoreInputProviderEnum;
|
||||
'bucket'?: string;
|
||||
'region'?: string;
|
||||
'credential_mode'?: InstallObjectStoreInputCredentialModeEnum;
|
||||
'access_key_id'?: string;
|
||||
'secret_access_key'?: string;
|
||||
}
|
||||
|
||||
export const InstallObjectStoreInputProviderEnum = {
|
||||
LOCAL: 'local',
|
||||
S3: 's3'
|
||||
} as const;
|
||||
|
||||
export type InstallObjectStoreInputProviderEnum = typeof InstallObjectStoreInputProviderEnum[keyof typeof InstallObjectStoreInputProviderEnum];
|
||||
export const InstallObjectStoreInputCredentialModeEnum = {
|
||||
RUNTIME: 'runtime',
|
||||
ACCESS_KEY: 'access_key'
|
||||
} as const;
|
||||
|
||||
export type InstallObjectStoreInputCredentialModeEnum = typeof InstallObjectStoreInputCredentialModeEnum[keyof typeof InstallObjectStoreInputCredentialModeEnum];
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* 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
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Redacted summary of the object-store mode selected during browser install.
|
||||
*/
|
||||
export interface InstallObjectStoreSummary {
|
||||
'provider': InstallObjectStoreSummaryProviderEnum;
|
||||
'bucket'?: string;
|
||||
'region'?: string;
|
||||
'credential_mode'?: InstallObjectStoreSummaryCredentialModeEnum;
|
||||
'manual_credentials_saved'?: boolean;
|
||||
}
|
||||
|
||||
export const InstallObjectStoreSummaryProviderEnum = {
|
||||
LOCAL: 'local',
|
||||
S3: 's3'
|
||||
} as const;
|
||||
|
||||
export type InstallObjectStoreSummaryProviderEnum = typeof InstallObjectStoreSummaryProviderEnum[keyof typeof InstallObjectStoreSummaryProviderEnum];
|
||||
export const InstallObjectStoreSummaryCredentialModeEnum = {
|
||||
RUNTIME: 'runtime',
|
||||
ACCESS_KEY: 'access_key'
|
||||
} as const;
|
||||
|
||||
export type InstallObjectStoreSummaryCredentialModeEnum = typeof InstallObjectStoreSummaryCredentialModeEnum[keyof typeof InstallObjectStoreSummaryCredentialModeEnum];
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* 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
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Successful response from install-time object-store validation.
|
||||
*/
|
||||
export interface InstallObjectStoreValidationResponse {
|
||||
'ok': boolean;
|
||||
}
|
||||
|
||||
|
|
@ -21,6 +21,9 @@ import type { InstallGithubSummary } from './install-github-summary';
|
|||
import type { InstallLlmSummary } from './install-llm-summary';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { InstallObjectStoreSummary } from './install-object-store-summary';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { InstallPrefill } from './install-prefill';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
|
|
@ -33,6 +36,7 @@ export interface InstallSessionResponse {
|
|||
'completed_steps': Array<string>;
|
||||
'llm'?: InstallLlmSummary | null;
|
||||
'server'?: InstallServerConfigInput | null;
|
||||
'object_store'?: InstallObjectStoreSummary | null;
|
||||
'github'?: InstallGithubSummary | null;
|
||||
'prefill': InstallPrefill;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue