feat(web): add server-managed Environments CRUD settings UI (#462)
Some checks are pending
Rust / Clippy (push) Waiting to run
Rust / Format (push) Waiting to run
TypeScript / Build (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run

## What

Adds a CRUD interface for **server-managed Environments** at
`/settings/environments`, driven by the `/api/v1/environments` REST API
(list / create / retrieve / replace / delete), and reshapes how built-in
environments are provisioned and protected.

The page lives in the **Workflows** settings nav section (also
introduced in this branch), positioned before Variables.

## Why

The Environments REST API shipped (#453) but had no UI — environments
could only be managed via the API/CLI. This gives operators a web UI
alongside Variables and Secrets, and along the way tightens the model:
environments are seeded at install time (not silently re-created on
every boot), and the `default` fallback is an ordinary, deletable
environment.

## Web UI

**Pages & component**
- `settings-environments.tsx` — list view: provider badge,
image/resource summary, row actions (Edit/Delete). **"New environment"
is a dropdown** of the enabled sandbox providers; the chosen provider is
fixed for the environment's lifetime.
- `settings-environments-new.tsx` / `settings-environments-edit.tsx` —
create/edit flows; create reads the provider from a query param.
- `environment-form.tsx` — shared form, reorganized:
- **General** panel (merged identity + image): id, and an **image-source
selector** (Image reference *vs* inline Dockerfile) that shows,
requires, and sends only the selected, mutually-exclusive source.
- **Resources**: CPU / memory / disk as **range sliders** (CPU 1–8,
memory 1–16 GB, disk 1–20 GB), each always writing a concrete value.
  - **Environment variables** key/value editor.
- **Advanced** progressive-disclosure section holding **Network** (a
single "Block all network access" toggle — allow-all vs block) and
**Lifecycle** (preserve / stop-on-terminal / auto-stop). Opens by
default when any advanced value is non-default.
- The in-form **provider control and the Labels editor were removed** —
labels remain API-managed and are round-tripped untouched so UI edits
never clear them.

**Data layer**: `environmentsApi` client, `queryKeys.environments`,
`useEnvironments` / `useEnvironment` SWR hooks.

**Nav & routing**: "Environments" item in the Workflows section before
Variables; routes registered in `router.tsx`.

## Backend: seed at install, deletable `default`

- **Seeding moved to install time.** The server no longer seeds
built-ins on startup; `EnvironmentStore::load_or_seed` → `load`
(load-only). A new public `seed_environments(dir)` (idempotent,
preserves operator edits) is called by both the web installer and the
CLI installer. An uninstalled instance therefore has no managed
environments, and a run selecting an absent environment fails explicitly
(`unknown environment: default`) rather than resurrecting a built-in.
- **`default` is no longer protected.** The delete guard and the
`Protected` error variant are gone; deleting `default` succeeds (204)
and removes the run fallback on purpose — forcing an explicit choice.
`local` is unchanged (reserved, in-memory).
- **`volumes` removed** from environment settings across the OpenAPI
spec, generated Rust + TS clients, config layers,
sandbox/server/workflow plumbing, docs, and tests.

## API contract details honored
- Edit sends the environment `revision` as `If-Match`; 409 conflicts
surface a "changed since you opened it" message.
- The REST API accepts inline Dockerfiles only — the form never sends a
Dockerfile path.

## Verification
- Rust: `cargo build` (touched crates) , `cargo nextest -p
fabro-environment` 21/21 , server env unit + `tests/it` integration 2/2
+ 15/15 , `clippy` (nightly, touched crates, all targets) clean , `fmt
--check` clean . Full `--workspace` suite not run here — worth a CI
pass.
- Web: `bun run typecheck` , `bun run build` ,
`environment-form.test.ts` 5/5 . Web suite: 512 pass / 1 unrelated
pre-existing `RunDetail` failure.
- **Not visually verified in-browser** — the local app is login-gated
and automated loads redirect to `/login`; rendering of the form, the
New-environment dropdown, and `default` delete should be confirmed in a
logged-in session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Release Repro <release-repro@example.com>
This commit is contained in:
Bryan Helmkamp 2026-06-13 08:44:38 -04:00 committed by GitHub
parent 64ece23473
commit bc0bda73a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
150 changed files with 2338 additions and 981 deletions

2
Cargo.lock generated
View file

@ -2550,6 +2550,7 @@ dependencies = [
"assert_cmd",
"axum",
"fabro-config",
"fabro-environment",
"fabro-http",
"fabro-proc",
"fabro-static",
@ -2609,6 +2610,7 @@ dependencies = [
"clap",
"dirs",
"fabro-model",
"fabro-types",
"fabro-util",
"hex",
"serde",

View file

@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";
import {
EMPTY_ENVIRONMENT_FORM,
createRequestFromForm,
isEnvironmentFormValid,
type EnvironmentFormValues,
} from "./environment-form";
function form(overrides: Partial<EnvironmentFormValues>): EnvironmentFormValues {
return { ...EMPTY_ENVIRONMENT_FORM, id: "docker", ...overrides };
}
describe("environment image source", () => {
test("image source requires a non-empty image reference", () => {
expect(isEnvironmentFormValid(form({ imageSource: "image", dockerRef: "" }))).toBe(false);
expect(
isEnvironmentFormValid(form({ imageSource: "image", dockerRef: "ubuntu:24.04" })),
).toBe(true);
});
test("dockerfile source requires non-empty Dockerfile contents", () => {
expect(isEnvironmentFormValid(form({ imageSource: "dockerfile", dockerfile: "" }))).toBe(false);
expect(
isEnvironmentFormValid(form({ imageSource: "dockerfile", dockerfile: "FROM ubuntu" })),
).toBe(true);
});
test("an empty Dockerfile does not satisfy the image-reference source", () => {
expect(
isEnvironmentFormValid(form({ imageSource: "image", dockerRef: "", dockerfile: "FROM x" })),
).toBe(false);
});
test("image source sends only the docker reference", () => {
const request = createRequestFromForm(
form({ imageSource: "image", dockerRef: "ubuntu:24.04", dockerfile: "FROM leftover" }),
);
expect(request.image.docker).toBe("ubuntu:24.04");
expect(request.image.dockerfile).toBeNull();
});
test("dockerfile source sends only the inline Dockerfile", () => {
const request = createRequestFromForm(
form({ imageSource: "dockerfile", dockerRef: "leftover", dockerfile: "FROM ubuntu" }),
);
expect(request.image.docker).toBeNull();
expect(request.image.dockerfile?.value).toBe("FROM ubuntu");
});
});

View file

@ -0,0 +1,600 @@
import type { ReactNode } from "react";
import { Disclosure, DisclosureButton, DisclosurePanel, Switch } from "@headlessui/react";
import { PlusIcon, XMarkIcon } from "@heroicons/react/16/solid";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import {
EnvironmentApiDockerfileSourceInlineTypeEnum,
EnvironmentNetworkMode,
EnvironmentProvider,
} from "@qltysh/fabro-api-client";
import type {
CreateEnvironmentRequest,
Environment,
EnvironmentApiImageSettings,
EnvironmentLifecycleSettings,
EnvironmentNetworkSettings,
EnvironmentResourcesSettings,
ReplaceEnvironmentRequest,
} from "@qltysh/fabro-api-client";
import { Panel, Row } from "./settings-panel";
import { INPUT_CLASS } from "./ui";
// Providers a managed environment can be created with. `local` is a reserved,
// in-memory environment, never a managed-environment provider, so it is never
// offered. The provider is fixed at creation time and cannot be changed.
export const CREATABLE_PROVIDERS = [
EnvironmentProvider.DOCKER,
EnvironmentProvider.DAYTONA,
] as const;
// Parse the `provider` query param used by the create flow into a creatable
// provider, defaulting to Docker for anything unexpected.
export function parseCreatableProvider(value: string | null): EnvironmentProvider {
return value === EnvironmentProvider.DAYTONA
? EnvironmentProvider.DAYTONA
: EnvironmentProvider.DOCKER;
}
// Environment ids are server-managed file names: lowercase, digits, hyphens.
const ENVIRONMENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
// Resource sliders pick a concrete value within a fixed range. Memory and disk
// are expressed in whole GB; the wire format keeps the `GB` suffix string.
const CPU = { min: 1, max: 8, step: 1, default: 4 };
const MEMORY = { min: 1, max: 16, step: 1, default: 8 };
const DISK = { min: 1, max: 20, step: 1, default: 16 };
interface KeyValueEntry {
key: string;
value: string;
}
// An environment image comes from exactly one source: a prebuilt image
// reference or an inline Dockerfile. The form keeps both field values around so
// switching back and forth doesn't lose typed text, and this discriminator
// decides which one is shown, required, and sent.
type ImageSource = "image" | "dockerfile";
export interface EnvironmentFormValues {
id: string;
provider: EnvironmentProvider;
imageSource: ImageSource;
dockerRef: string;
dockerfile: string;
cpu: number;
memory: number;
disk: number;
blockNetwork: boolean;
preserve: boolean;
stopOnTerminal: boolean;
autoStop: string;
// Labels are not editable in the web UI — they're managed through the REST
// API only. The form carries the loaded value verbatim so saving an edited
// environment preserves any API-set labels instead of clearing them.
labels: { [key: string]: string };
envVars: KeyValueEntry[];
}
export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = {
id: "",
provider: EnvironmentProvider.DOCKER,
imageSource: "image",
dockerRef: "",
dockerfile: "",
cpu: CPU.default,
memory: MEMORY.default,
disk: DISK.default,
blockNetwork: false,
preserve: false,
stopOnTerminal: true,
autoStop: "",
labels: {},
envVars: [],
};
export function environmentToFormValues(environment: Environment): EnvironmentFormValues {
return {
id: environment.id,
provider: environment.provider,
imageSource: environment.image.dockerfile ? "dockerfile" : "image",
dockerRef: environment.image.docker ?? "",
dockerfile: environment.image.dockerfile?.value ?? "",
cpu: clampGb(environment.resources.cpu, CPU),
memory: parseGb(environment.resources.memory, MEMORY),
disk: parseGb(environment.resources.disk, DISK),
blockNetwork: environment.network.mode === EnvironmentNetworkMode.BLOCK,
preserve: environment.lifecycle.preserve,
stopOnTerminal: environment.lifecycle.stop_on_terminal,
autoStop: environment.lifecycle.auto_stop ?? "",
labels: environment.labels,
envVars: entriesFromMap(environment.env),
};
}
export function isEnvironmentFormValid(values: EnvironmentFormValues): boolean {
if (!ENVIRONMENT_ID_PATTERN.test(values.id.trim())) return false;
return imageSourceValue(values).trim() !== "";
}
// The currently selected image source's text, used both for validation and to
// drive which field is rendered as required.
function imageSourceValue(values: EnvironmentFormValues): string {
return values.imageSource === "dockerfile" ? values.dockerfile : values.dockerRef;
}
// The Advanced disclosure (Network + Lifecycle) starts open when any of its
// values deviate from the defaults, so editing an environment never hides
// settings the operator already configured.
function hasNonDefaultAdvanced(values: EnvironmentFormValues): boolean {
return (
values.blockNetwork !== EMPTY_ENVIRONMENT_FORM.blockNetwork ||
values.preserve !== EMPTY_ENVIRONMENT_FORM.preserve ||
values.stopOnTerminal !== EMPTY_ENVIRONMENT_FORM.stopOnTerminal ||
values.autoStop.trim() !== ""
);
}
export function createRequestFromForm(values: EnvironmentFormValues): CreateEnvironmentRequest {
return { id: values.id.trim(), ...settingsFromForm(values) };
}
export function replaceRequestFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequest {
return settingsFromForm(values);
}
function settingsFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequest {
return {
provider: values.provider,
image: imageFromForm(values),
resources: resourcesFromForm(values),
network: networkFromForm(values),
lifecycle: lifecycleFromForm(values),
labels: values.labels,
env: mapFromEntries(values.envVars),
};
}
function imageFromForm(values: EnvironmentFormValues): EnvironmentApiImageSettings {
if (values.imageSource === "dockerfile") {
return {
docker: null,
dockerfile: {
type: EnvironmentApiDockerfileSourceInlineTypeEnum.INLINE,
value: values.dockerfile,
},
};
}
return {
docker: values.dockerRef.trim() || null,
dockerfile: null,
};
}
function resourcesFromForm(values: EnvironmentFormValues): EnvironmentResourcesSettings {
return {
cpu: values.cpu,
memory: `${values.memory}GB`,
disk: `${values.disk}GB`,
};
}
interface ResourceRange {
min: number;
max: number;
step: number;
default: number;
}
// Snap a numeric value into the slider range, falling back to the default when
// the environment leaves the resource unset (provider default).
function clampGb(value: number | null, range: ResourceRange): number {
if (value === null) return range.default;
return Math.min(range.max, Math.max(range.min, Math.round(value)));
}
// Parse a size string ("16GB", "512MiB", or a bare integer interpreted as GB)
// into whole GB within the slider range. Existing values may use other units or
// fall outside the range, so the result is rounded and clamped.
function parseGb(value: string | null, range: ResourceRange): number {
if (value === null) return range.default;
const match = value.trim().match(/^([\d.]+)\s*([a-zA-Z]*)$/);
if (!match) return range.default;
const amount = Number(match[1]);
if (!Number.isFinite(amount)) return range.default;
const perGb: { [unit: string]: number } = {
"": 1, g: 1, gb: 1, gib: 1,
m: 1 / 1000, mb: 1 / 1000, mib: 1 / 1000,
t: 1000, tb: 1000, tib: 1000,
};
const factor = perGb[match[2].toLowerCase()] ?? 1;
return clampGb(amount * factor, range);
}
function networkFromForm(values: EnvironmentFormValues): EnvironmentNetworkSettings {
return {
mode: values.blockNetwork ? EnvironmentNetworkMode.BLOCK : EnvironmentNetworkMode.ALLOW_ALL,
allow: [],
};
}
function lifecycleFromForm(values: EnvironmentFormValues): EnvironmentLifecycleSettings {
return {
preserve: values.preserve,
stop_on_terminal: values.stopOnTerminal,
auto_stop: values.autoStop.trim() || null,
};
}
function entriesFromMap(map: { [key: string]: string }): KeyValueEntry[] {
return Object.entries(map).map(([key, value]) => ({ key, value }));
}
function mapFromEntries(entries: KeyValueEntry[]): { [key: string]: string } {
return Object.fromEntries(
entries
.map((entry): [string, string] => [entry.key.trim(), entry.value])
.filter((entry) => entry[0] !== ""),
);
}
function parseImageSource(value: string): ImageSource {
return value === "dockerfile" ? "dockerfile" : "image";
}
interface EnvironmentFormFieldsProps {
values: EnvironmentFormValues;
onChange: (values: EnvironmentFormValues) => void;
lockId?: boolean;
}
export function EnvironmentFormFields({
values,
onChange,
lockId = false,
}: EnvironmentFormFieldsProps) {
function patch(partial: Partial<EnvironmentFormValues>) {
onChange({ ...values, ...partial });
}
const idValid = ENVIRONMENT_ID_PATTERN.test(values.id.trim());
return (
<>
<Panel title="General">
<Row
title={<Label required>ID</Label>}
help="Lowercase identifier (letters, digits, hyphens). Runs select this environment by id. Cannot be changed after creation."
>
{lockId ? (
<div className="font-mono text-sm text-fg">{values.id}</div>
) : (
<input
type="text"
name="id"
aria-label="Environment ID"
value={values.id}
onChange={(e) => patch({ id: e.target.value })}
placeholder="fabro-dev"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
)}
</Row>
<Row
title={<Label required>Source</Label>}
help="Whether this environment runs a prebuilt image reference or builds from an inline Dockerfile."
>
<select
name="image_source"
aria-label="Image source"
value={values.imageSource}
onChange={(e) => patch({ imageSource: parseImageSource(e.target.value) })}
className={INPUT_CLASS}
>
<option value="image">Image reference</option>
<option value="dockerfile">Dockerfile</option>
</select>
</Row>
{values.imageSource === "image" ? (
<Row
title={<Label required>Image reference</Label>}
help="Docker image or Daytona snapshot name (e.g. fabro-v11)."
>
<input
type="text"
name="docker_ref"
aria-label="Image reference"
value={values.dockerRef}
onChange={(e) => patch({ dockerRef: e.target.value })}
placeholder="ubuntu:24.04"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
) : (
<Row
title={<Label required>Dockerfile</Label>}
help="Inline Dockerfile contents. The REST API accepts inline Dockerfiles only — local paths are rejected."
>
<textarea
name="dockerfile"
aria-label="Dockerfile"
value={values.dockerfile}
onChange={(e) => patch({ dockerfile: e.target.value })}
rows={5}
placeholder={"FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y git"}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} resize-y font-mono`}
/>
</Row>
)}
</Panel>
<Panel title="Resources">
<Row title="CPU" help="Number of vCPUs allocated to each run.">
<ResourceSlider
ariaLabel="CPU"
range={CPU}
value={values.cpu}
onChange={(cpu) => patch({ cpu })}
format={(n) => `${n} CPU`}
/>
</Row>
<Row title="Memory" help="Memory limit for each run.">
<ResourceSlider
ariaLabel="Memory"
range={MEMORY}
value={values.memory}
onChange={(memory) => patch({ memory })}
format={(n) => `${n} GB`}
/>
</Row>
<Row title="Disk" help="Disk limit for each run.">
<ResourceSlider
ariaLabel="Disk"
range={DISK}
value={values.disk}
onChange={(disk) => patch({ disk })}
format={(n) => `${n} GB`}
/>
</Row>
</Panel>
<Panel title="Environment variables">
<div className="px-4 py-3.5">
<p className="mb-3 text-xs/5 text-fg-3">
Variables injected into the sandbox for every run.
</p>
<KeyValueEditor
entries={values.envVars}
onChange={(envVars) => patch({ envVars })}
keyPlaceholder="TZ"
valuePlaceholder="UTC"
addLabel="Add variable"
/>
</div>
</Panel>
<Disclosure as="div" className="space-y-4" defaultOpen={hasNonDefaultAdvanced(values)}>
<DisclosureButton className="group flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-muted transition-colors hover:text-fg-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
<ChevronRightIcon
className="size-3.5 transition-transform duration-150 group-data-open:rotate-90"
aria-hidden="true"
/>
Advanced
</DisclosureButton>
<DisclosurePanel className="space-y-6">
<Panel title="Network">
<Row
title="Block all network access"
help="Block all outbound network access from the sandbox."
>
<ToggleSwitch
checked={values.blockNetwork}
onChange={(blockNetwork) => patch({ blockNetwork })}
label="Block all network access"
/>
</Row>
</Panel>
<Panel title="Lifecycle">
<Row title="Preserve" help="Keep the sandbox after the run finishes instead of tearing it down.">
<ToggleSwitch
checked={values.preserve}
onChange={(preserve) => patch({ preserve })}
label="Preserve sandbox after run"
/>
</Row>
<Row title="Stop on terminal" help="Stop the sandbox when the run reaches a terminal state.">
<ToggleSwitch
checked={values.stopOnTerminal}
onChange={(stopOnTerminal) => patch({ stopOnTerminal })}
label="Stop sandbox on terminal state"
/>
</Row>
<Row title={<Label optional>Auto-stop</Label>} help="Idle duration before the sandbox is stopped (e.g. 30m). Leave blank to disable.">
<input
type="text"
name="auto_stop"
aria-label="Auto-stop"
value={values.autoStop}
onChange={(e) => patch({ autoStop: e.target.value })}
placeholder="30m"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
</Panel>
</DisclosurePanel>
</Disclosure>
{!lockId && values.id.trim() !== "" && !idValid ? (
<p className="text-xs text-coral">
ID must be lowercase letters, digits, or hyphens and start with a letter or digit.
</p>
) : null}
</>
);
}
function KeyValueEditor({
entries,
onChange,
keyPlaceholder,
valuePlaceholder,
addLabel,
}: {
entries: KeyValueEntry[];
onChange: (entries: KeyValueEntry[]) => void;
keyPlaceholder: string;
valuePlaceholder: string;
addLabel: string;
}) {
function update(index: number, partial: Partial<KeyValueEntry>) {
onChange(entries.map((entry, i) => (i === index ? { ...entry, ...partial } : entry)));
}
return (
<div className="space-y-2">
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
aria-label="Key"
value={entry.key}
onChange={(e) => update(index, { key: e.target.value })}
placeholder={keyPlaceholder}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
<input
type="text"
aria-label="Value"
value={entry.value}
onChange={(e) => update(index, { value: e.target.value })}
placeholder={valuePlaceholder}
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
<RemoveButton onClick={() => onChange(entries.filter((_, i) => i !== index))} />
</div>
))}
<AddButton label={addLabel} onClick={() => onChange([...entries, { key: "", value: "" }])} />
</div>
);
}
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-xs font-medium text-fg-3 transition-colors hover:border-line-strong hover:bg-panel hover:text-fg"
>
<PlusIcon className="size-3.5" aria-hidden="true" />
{label}
</button>
);
}
function RemoveButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label="Remove row"
title="Remove"
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-overlay hover:text-coral"
>
<XMarkIcon className="size-4" aria-hidden="true" />
</button>
);
}
function Label({
children,
required,
optional,
}: {
children: ReactNode;
required?: boolean;
optional?: boolean;
}) {
return (
<span className="inline-flex items-baseline gap-1.5">
<span>{children}</span>
{required ? (
<span aria-label="required" className="text-coral">
*
</span>
) : null}
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
</span>
);
}
function ResourceSlider({
value,
range,
ariaLabel,
format,
onChange,
}: {
value: number;
range: ResourceRange;
ariaLabel: string;
format: (value: number) => string;
onChange: (value: number) => void;
}) {
const fill = ((value - range.min) / (range.max - range.min)) * 100;
return (
<div className="flex items-center gap-4">
<div className="relative h-4 flex-1">
<div className="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-overlay-strong">
<div className="h-full rounded-full bg-teal-500" style={{ width: `${fill}%` }} />
</div>
<input
type="range"
aria-label={ariaLabel}
value={value}
min={range.min}
max={range.max}
step={range.step}
onChange={(e) => onChange(Number(e.target.value))}
className="relative h-4 w-full cursor-pointer appearance-none bg-transparent focus-visible:outline-none [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-fg [&::-moz-range-thumb]:shadow-sm [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-fg [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:outline [&::-webkit-slider-thumb]:outline-1 [&::-webkit-slider-thumb]:-outline-offset-1 [&::-webkit-slider-thumb]:outline-line-strong focus-visible:[&::-webkit-slider-thumb]:outline-2 focus-visible:[&::-webkit-slider-thumb]:outline-teal-500"
/>
</div>
<output className="w-16 shrink-0 text-right font-mono text-sm tabular-nums text-fg">
{format(value)}
</output>
</div>
);
}
function ToggleSwitch({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (next: boolean) => void;
label: string;
}) {
return (
<Switch
checked={checked}
onChange={onChange}
aria-label={label}
className="group relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-overlay-strong outline-1 -outline-offset-1 outline-line-strong transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 data-checked:bg-teal-500"
>
<span className="pointer-events-none inline-block size-4 translate-x-0.5 rounded-full bg-fg shadow-sm transition-transform duration-150 group-data-checked:translate-x-[1.125rem]" />
</Switch>
);
}

View file

@ -6,6 +6,7 @@ import {
RunSummaryPanelView,
type RunSummaryPanelViewProps,
} from "./run-summary-panel";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
function instanceText(instance: TestRenderer.ReactTestInstance): string {
const parts: string[] = [];
@ -53,7 +54,7 @@ function cellAfterLabel(
function makeRun(overrides: Record<string, any> = {}) {
return {
id: "run_1",
created_by: null,
created_by: TEST_PRINCIPAL,
diff: null,
billing: null,
...overrides,
@ -71,9 +72,9 @@ describe("RunSummaryPanelView", () => {
}
});
test("shows unavailable copy for missing run fields after load", () => {
test("shows creator and unavailable copy for optional missing run fields after load", () => {
const tree = render({ run: makeRun() });
expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe(EMPTY_VALUE);
expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe("Ttest");
expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(EMPTY_VALUE);
expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe(EMPTY_VALUE);
});
@ -226,7 +227,7 @@ describe("RunSummaryPanelView", () => {
kind: "user",
identity: { issuer: "github", subject: "1" },
login: "brynary",
auth_method: "oauth",
auth_method: "github",
},
}),
});
@ -240,7 +241,7 @@ describe("RunSummaryPanelView", () => {
kind: "user",
identity: { issuer: "github", subject: "1" },
login: "brynary",
auth_method: "oauth",
auth_method: "github",
avatar_url: "https://example.com/brynary.png",
},
}),
@ -252,7 +253,7 @@ describe("RunSummaryPanelView", () => {
});
test("renders non-user actor with kind label", () => {
for (const kind of ["agent", "system", "slack", "webhook", "worker", "anonymous"]) {
for (const kind of ["agent", "system", "slack", "webhook", "worker"]) {
const tree = render({ run: makeRun({ created_by: { kind } as any }) });
expect(instanceText(cellAfterLabel(tree, "Created by"))).toContain(kind);
}

View file

@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type {
Principal,
Run,
SandboxResources,
SandboxState,
@ -50,6 +51,16 @@ function Cell({ label, children }: { label: string; children: ReactNode }) {
);
}
function CreatedByValue({ actor }: { actor: Principal }) {
const created = principalDisplay(actor);
return (
<div className="flex items-center gap-2">
{created.glyph}
<span className={VALUE_CLASS}>{created.label}</span>
</div>
);
}
export interface RunSummaryPanelViewProps {
run: Run | null;
runLoading: boolean;
@ -116,7 +127,6 @@ export function RunSummaryPanelView({
artifactsCount,
artifactsLoading,
}: RunSummaryPanelViewProps) {
const created = run?.created_by ? principalDisplay(run.created_by) : null;
const diff = run?.diff ?? null;
const cost = formatUsdMicros(run?.billing?.total_usd_micros);
const sandboxKind = sandboxLifecycleKind(run?.sandbox);
@ -127,11 +137,8 @@ export function RunSummaryPanelView({
<Cell label="Created by">
{runLoading ? (
<Skeleton widthClass="w-20" />
) : created ? (
<div className="flex items-center gap-2">
{created.glyph}
<span className={VALUE_CLASS}>{created.label}</span>
</div>
) : run ? (
<CreatedByValue actor={run.created_by} />
) : (
<EmptyValue />
)}

View file

@ -35,6 +35,7 @@ export function RunTableRow({
}) {
const lifecycleLabel = listLifecycleStatusLabel(run);
const statusDisplay = columnStatusDisplay[run.status];
const creator = principalDisplay(run.createdBy);
const show = (col: ToggleableColumn) => !hiddenColumns.has(col);
return (
@ -54,14 +55,9 @@ export function RunTableRow({
</td>
{show("created_by") && (
<td className="relative z-10 w-8 whitespace-nowrap px-3 py-2.5">
{run.createdBy && (() => {
const display = principalDisplay(run.createdBy);
return (
<Tooltip label={display.label}>
<span aria-label={`Created by ${display.label}`}>{display.glyph}</span>
</Tooltip>
);
})()}
<Tooltip label={creator.label}>
<span aria-label={`Created by ${creator.label}`}>{creator.glyph}</span>
</Tooltip>
</td>
)}
{show("repo") && (

View file

@ -8,6 +8,7 @@ import {
mapRunToRunItem,
runStatusDisplay,
} from "./runs";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
function makeRun(overrides: Partial<Run> = {}): Run {
return {
@ -17,7 +18,7 @@ function makeRun(overrides: Partial<Run> = {}): Run {
workflow: { slug: "fix_build", name: "Fix Build", graph_name: "FixBuild", node_count: 0, edge_count: 0 },
automation: null,
repository: { name: "myrepo", origin_url: null, provider: "unknown" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -41,7 +41,7 @@ export interface RunItem {
sandboxWorkingDirectory?: string;
sourceDirectory?: string;
createdAt?: string;
createdBy?: Principal | null;
createdBy: Principal;
lastEventAt?: string;
size?: RunSize;
}

View file

@ -8,6 +8,7 @@ import {
AuthApi,
AutomationsApi,
Configuration,
EnvironmentsApi,
HumanInTheLoopApi,
InsightsApi,
InstallApi,
@ -81,6 +82,11 @@ export const automationsApi = new AutomationsApi(
"",
generatedAxios,
);
export const environmentsApi = new EnvironmentsApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const humanInTheLoopApi = new HumanInTheLoopApi(
generatedApiConfiguration,
"",

View file

@ -4,7 +4,6 @@ import {
ChatBubbleLeftEllipsisIcon,
Cog6ToothIcon,
CpuChipIcon,
QuestionMarkCircleIcon,
ServerIcon,
} from "@heroicons/react/20/solid";
import type { Principal } from "@qltysh/fabro-api-client";
@ -57,10 +56,5 @@ export function principalDisplay(actor: Principal): PrincipalDisplay {
return { glyph: principalIconGlyph(<BoltIcon className="size-3" />), label: "webhook" };
case "worker":
return { glyph: principalIconGlyph(<ServerIcon className="size-3" />), label: "worker" };
case "anonymous":
return {
glyph: principalIconGlyph(<QuestionMarkCircleIcon className="size-3" />),
label: "anonymous",
};
}
}

View file

@ -8,6 +8,8 @@ import type {
AutomationListResponse,
BoardColumn,
CommandLogResponse,
Environment,
EnvironmentListResponse,
EventEnvelope,
ListRunsDirectionEnum,
ListRunsSortEnum,
@ -45,6 +47,7 @@ import {
apiResponse,
authApi,
automationsApi,
environmentsApi,
fetchAllPages,
fetchAllStageEvents,
generatedAxios,
@ -427,6 +430,20 @@ export function useAutomationRuns(id: string | undefined, opts: AutomationRunsPa
);
}
export function useEnvironments() {
return useSWR<EnvironmentListResponse>(
queryKeys.environments.list(),
() => apiData(() => environmentsApi.listEnvironments()),
);
}
export function useEnvironment(id: string | undefined) {
return useSWR<Environment | null>(
id ? queryKeys.environments.detail(id) : null,
id ? () => apiNullableData(() => environmentsApi.retrieveEnvironment(id)) : null,
);
}
export function useWorkflows() {
return useSWR<PaginatedWorkflowListResponse | null>(
queryKeys.workflows.list(),

View file

@ -117,4 +117,8 @@ export const queryKeys = {
list: () => ["variables", "list"] as const,
detail: (name: string) => ["variables", "detail", name] as const,
},
environments: {
list: () => ["environments", "list"] as const,
detail: (id: string) => ["environments", "detail", id] as const,
},
};

View file

@ -24,6 +24,7 @@ import {
unarchiveRuns,
} from "./run-actions";
import { generatedAxios } from "./api-client";
import { TEST_PRINCIPAL } from "./test-fixtures";
type StubResponseInit = {
status: number;
@ -47,7 +48,7 @@ function makeRun(status: RunStatus, archived = false): Run {
workflow: { slug: "fix_build", name: "Fix Build", graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: null,
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -0,0 +1,8 @@
import type { Principal } from "@qltysh/fabro-api-client";
export const TEST_PRINCIPAL: Principal = {
kind: "user",
identity: { issuer: "fabro:test", subject: "test-user" },
login: "test",
auth_method: "dev_token",
};

View file

@ -38,6 +38,9 @@ import * as SettingsGeneral from "./routes/settings-general";
import * as SettingsIntegrations from "./routes/settings-integrations";
import * as SettingsModels from "./routes/settings-models";
import * as SettingsSandboxes from "./routes/settings-sandboxes";
import * as SettingsEnvironments from "./routes/settings-environments";
import * as SettingsEnvironmentsNew from "./routes/settings-environments-new";
import * as SettingsEnvironmentsEdit from "./routes/settings-environments-edit";
import * as SettingsSecrets from "./routes/settings-secrets";
import * as SettingsSecretsNew from "./routes/settings-secrets-new";
import * as SettingsVariables from "./routes/settings-variables";
@ -148,6 +151,9 @@ export const routes: RouteObject[] = [
route("integrations", SettingsIntegrations),
route("sandboxes", SettingsSandboxes),
route("security", SettingsSecurity),
route("environments", SettingsEnvironments),
route("environments/new", SettingsEnvironmentsNew),
route("environments/:id/edit", SettingsEnvironmentsEdit),
route("variables", SettingsVariables),
route("variables/new", SettingsVariablesNew),
route("variables/:name/edit", SettingsVariablesEdit),

View file

@ -4,6 +4,7 @@ import TestRenderer, { act } from "react-test-renderer";
import { createMemoryRouter, RouterProvider } from "react-router";
import { ToastProvider } from "../components/toast";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { setupReactTestEnv } from "../lib/test-utils";
let currentRun: any = null;
@ -120,7 +121,7 @@ function makeRun(overrides: Record<string, unknown> = {}) {
origin_url: "https://github.com/fallback/repo.git",
provider: "github",
},
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -13,6 +13,7 @@ import {
import { ToastProvider } from "../components/toast";
import { DemoModeProvider } from "../lib/demo-mode";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
let currentRunSummary: any = null;
let currentRunState: any = null;
@ -221,7 +222,7 @@ function makeRunSummary({
workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 },
automation,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {
@ -886,6 +887,9 @@ describe("RunDetail full-height child routes", () => {
await deletion.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(deleteRunApiMock).toHaveBeenCalledTimes(1);
expect(deleteRunApiMock.mock.calls[0]?.[0]).toBe("run_1");

View file

@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router";
import { toast as sonnerToast } from "sonner";
import { ToastProvider } from "../components/toast";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
let currentFilesPayload: any = null;
let currentCommitsPayload: any = null;
@ -51,7 +52,7 @@ mock.module("../lib/queries", () => ({
workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -5,6 +5,7 @@ import type { PaginatedRunList, Run } from "@qltysh/fabro-api-client";
import { ToastProvider } from "../components/toast";
import { CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY } from "../components/runs-list/preferences";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { setupReactTestEnv } from "../lib/test-utils";
class MemoryStorage {
@ -35,7 +36,7 @@ function run(id: string, repo = "qlty/fabro", workflow = "release"): Run {
workflow: { slug: workflow, name: workflow, graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: { name: repo, origin_url: null, provider: "github" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -11,6 +11,7 @@ import {
shouldRefreshBoardForEvent,
} from "./runs";
import { summarizeBatchLifecycleAction } from "../components/runs-list/batch-lifecycle";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
const status =
@ -34,7 +35,7 @@ function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
workflow: { slug: "test", name: "Test", graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: { name: "repo", origin_url: null, provider: "unknown" },
created_by: null,
created_by: TEST_PRINCIPAL,
origin: { kind: "api" },
labels: {},
lifecycle: {

View file

@ -0,0 +1,129 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import type { Environment } from "@qltysh/fabro-api-client";
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import { useEnvironment } from "../lib/queries";
import {
EnvironmentFormFields,
environmentToFormValues,
isEnvironmentFormValid,
replaceRequestFromForm,
type EnvironmentFormValues,
} from "../components/environment-form";
import { Panel, PanelSkeleton } from "../components/settings-panel";
import {
ErrorMessage,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "../components/ui";
import { useToast } from "../components/toast";
export function meta() {
return [{ title: "Edit environment — Fabro" }];
}
export default function SettingsEnvironmentsEdit() {
const { id } = useParams<{ id: string }>();
const query = useEnvironment(id);
return (
<div className="space-y-6">
<PageHeader id={id ?? ""} />
{query.data ? (
<EditEnvironmentForm key={query.data.revision} environment={query.data} />
) : query.error ? (
<Panel title="Environment">
<div className="px-4 py-6 text-sm text-fg-2">
Couldn&apos;t load this environment. It may have been deleted.
</div>
</Panel>
) : (
<PanelSkeleton />
)}
</div>
);
}
function PageHeader({ id }: { id: string }) {
return (
<nav className="flex items-center gap-1 text-sm text-fg-muted">
<Link to="/settings/environments" className="text-fg-3 hover:text-fg">
Environments
</Link>
<ChevronRightIcon className="size-3" aria-hidden="true" />
<span className="font-mono text-fg-2">{id}</span>
</nav>
);
}
function EditEnvironmentForm({ environment }: { environment: Environment }) {
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const toast = useToast();
const [values, setValues] = useState<EnvironmentFormValues>(() =>
environmentToFormValues(environment),
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = isEnvironmentFormValid(values) && !submitting;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
await apiData(() =>
environmentsApi.replaceEnvironment(
environment.id,
environment.revision,
replaceRequestFromForm(values),
),
);
await mutate(queryKeys.environments.list());
await mutate(queryKeys.environments.detail(environment.id));
toast.push({ message: `Environment “${environment.id}” updated.` });
navigate("/settings/environments");
} catch (cause) {
setError(staleAwareMessage(cause));
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-6">
<EnvironmentFormFields values={values} onChange={setValues} lockId />
{error ? <ErrorMessage message={error} /> : null}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={() => navigate("/settings/environments")}
disabled={submitting}
className={SECONDARY_BUTTON_CLASS}
>
Cancel
</button>
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
{submitting ? "Saving…" : "Save changes"}
</button>
</div>
</form>
);
}
function staleAwareMessage(cause: unknown): string {
if (cause instanceof ApiError && cause.status === 409) {
return "This environment changed since you opened it. Reload the page to get the latest version, then reapply your edits.";
}
if (cause instanceof ApiError && cause.message) {
return cause.message;
}
return "Couldn't update the environment. Please try again.";
}

View file

@ -0,0 +1,106 @@
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import {
EMPTY_ENVIRONMENT_FORM,
EnvironmentFormFields,
createRequestFromForm,
isEnvironmentFormValid,
parseCreatableProvider,
type EnvironmentFormValues,
} from "../components/environment-form";
import {
ErrorMessage,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "../components/ui";
import { useToast } from "../components/toast";
export function meta() {
return [{ title: "New environment — Fabro" }];
}
export default function SettingsEnvironmentsNew() {
return (
<div className="space-y-6">
<PageHeader />
<CreateEnvironmentForm />
</div>
);
}
function PageHeader() {
return (
<nav className="flex items-center gap-1 text-sm text-fg-muted">
<Link to="/settings/environments" className="text-fg-3 hover:text-fg">
Environments
</Link>
<ChevronRightIcon className="size-3" aria-hidden="true" />
<span>New environment</span>
</nav>
);
}
function CreateEnvironmentForm() {
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const toast = useToast();
const [searchParams] = useSearchParams();
// The provider is selected via the "New environment" dropdown and arrives as
// a query param; it's fixed for the lifetime of the environment.
const [values, setValues] = useState<EnvironmentFormValues>(() => ({
...EMPTY_ENVIRONMENT_FORM,
provider: parseCreatableProvider(searchParams.get("provider")),
}));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = isEnvironmentFormValid(values) && !submitting;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
const id = values.id.trim();
try {
await apiData(() => environmentsApi.createEnvironment(createRequestFromForm(values)));
await mutate(queryKeys.environments.list());
toast.push({ message: `Environment “${id}” created.` });
navigate("/settings/environments");
} catch (cause) {
setError(
cause instanceof ApiError && cause.message
? cause.message
: "Couldn't create the environment. Please try again.",
);
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-6">
<EnvironmentFormFields values={values} onChange={setValues} />
{error ? <ErrorMessage message={error} /> : null}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={() => navigate("/settings/environments")}
disabled={submitting}
className={SECONDARY_BUTTON_CLASS}
>
Cancel
</button>
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
{submitting ? "Creating…" : "Create environment"}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,322 @@
import { useState } from "react";
import { Link } from "react-router";
import { useSWRConfig } from "swr";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/16/solid";
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
import type { Environment } from "@qltysh/fabro-api-client";
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
import { useEnvironments, useServerSettings } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { CREATABLE_PROVIDERS } from "../components/environment-form";
import {
Badge,
Muted,
Panel,
PanelSkeleton,
SettingsPageIntro,
} from "../components/settings-panel";
import { ConfirmDialog } from "../components/ui";
import { useToast } from "../components/toast";
// `local` is a reserved, in-memory environment the server includes only when
// the local sandbox provider is enabled. It has no configurable settings, so it
// gets its own panel instead of a row in the managed environments list.
const RESERVED_ID = "local";
const MENU_ITEM_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
const MENU_ITEM_DANGER_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-coral transition-colors data-focus:bg-coral/10 data-focus:text-coral data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
export function meta() {
return [{ title: "Environments — Fabro" }];
}
const DESCRIPTION =
"Environments are server-managed runtime definitions — provider, image, resources, network, and lifecycle — that workflow runs select by id. They are operator policy stored on this Fabro server.";
export default function SettingsEnvironments() {
const query = useEnvironments();
return (
<div className="space-y-6">
<SettingsPageIntro description={DESCRIPTION} action={<NewEnvironmentMenu />} />
{query.data ? (
<EnvironmentsContent environments={query.data.data} />
) : query.error ? (
<Panel title="Environments">
<div className="px-4 py-6 text-sm text-fg-2">
Couldn&apos;t load environments. Please try again.
</div>
</Panel>
) : (
<PanelSkeleton />
)}
</div>
);
}
const NEW_BUTTON_CLASS =
"inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-sm font-medium text-fg-3 transition-colors hover:border-line-strong hover:bg-panel hover:text-fg disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-line disabled:hover:bg-panel/80 disabled:hover:text-fg-3";
function providerLabel(provider: string): string {
return provider.charAt(0).toUpperCase() + provider.slice(1);
}
// "New environment" is a provider picker: each enabled sandbox provider opens
// the create form pre-set to that provider, which is then fixed for the
// environment's lifetime. `local` is never offered (it's reserved/in-memory).
function NewEnvironmentMenu() {
const { data } = useServerSettings();
const providers = data
? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled)
: [];
if (providers.length === 0) {
return (
<button
type="button"
disabled
title={data ? "Enable a sandbox provider to create environments" : "Loading providers…"}
className={NEW_BUTTON_CLASS}
>
<PlusIcon className="size-3.5" aria-hidden="true" />
New environment
</button>
);
}
return (
<Menu as="div" className="relative inline-block">
<MenuButton className={NEW_BUTTON_CLASS}>
<PlusIcon className="size-3.5" aria-hidden="true" />
New environment
<ChevronDownIcon className="size-3.5" aria-hidden="true" />
</MenuButton>
<MenuItems
transition
anchor={{ to: "bottom end", gap: 4 }}
className="z-30 w-44 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
{providers.map((provider) => (
<MenuItem key={provider}>
<Link
to={`/settings/environments/new?provider=${encodeURIComponent(provider)}`}
className={MENU_ITEM_CLASS}
>
{providerLabel(provider)}
</Link>
</MenuItem>
))}
</MenuItems>
</Menu>
);
}
function EnvironmentsContent({ environments }: { environments: Environment[] }) {
const local = environments.find((environment) => environment.id === RESERVED_ID);
const managed = environments.filter((environment) => environment.id !== RESERVED_ID);
return (
<>
<EnvironmentsPanel environments={managed} />
{local ? <LocalEnvironmentPanel environment={local} /> : null}
</>
);
}
function LocalEnvironmentPanel({ environment }: { environment: Environment }) {
return (
<Panel title="Local sandbox">
<div className="flex items-start justify-between gap-4 px-4 py-3.5">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg">{environment.id}</span>
<Badge>{environment.provider}</Badge>
</div>
<p className="mt-1 text-xs/5 text-fg-3 text-pretty">
Built-in environment that runs tools directly on the host. Available because the local
sandbox provider is enabled; it has no configurable settings and can&apos;t be edited or
deleted.
</p>
</div>
<StatusTag>reserved</StatusTag>
</div>
</Panel>
);
}
function EnvironmentsPanel({ environments }: { environments: Environment[] }) {
const { mutate } = useSWRConfig();
const toast = useToast();
const [pendingDelete, setPendingDelete] = useState<Environment | null>(null);
const [deleting, setDeleting] = useState(false);
async function confirmDelete() {
if (!pendingDelete) return;
const target = pendingDelete;
setDeleting(true);
try {
await apiData(() => environmentsApi.deleteEnvironment(target.id, target.revision));
await mutate(queryKeys.environments.list());
toast.push({ message: `Environment “${target.id}” deleted.` });
setPendingDelete(null);
} catch (cause) {
toast.push({
tone: "error",
message:
cause instanceof ApiError && cause.message
? cause.message
: "Couldn't delete the environment. Please try again.",
});
} finally {
setDeleting(false);
}
}
return (
<>
<Panel title="Environments">
{environments.length === 0 ? (
<div className="px-4 py-6 text-sm text-fg-muted">
No environments defined yet.
</div>
) : (
environments.map((environment) => (
<EnvironmentRow
key={environment.id}
environment={environment}
disabled={deleting}
onDelete={() => setPendingDelete(environment)}
/>
))
)}
</Panel>
<ConfirmDialog
open={pendingDelete !== null}
title="Delete environment"
description={
<>
Delete{" "}
<span className="font-mono text-fg-2">{pendingDelete?.id}</span>? Runs that
select this environment will fail until it is recreated.
</>
}
confirmLabel="Delete"
pendingLabel="Deleting…"
pending={deleting}
onConfirm={confirmDelete}
onCancel={() => {
if (!deleting) setPendingDelete(null);
}}
/>
</>
);
}
function EnvironmentRow({
environment,
disabled,
onDelete,
}: {
environment: Environment;
disabled: boolean;
onDelete: () => void;
}) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_auto] items-center gap-4 px-4 py-3.5">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-mono text-sm text-fg" title={environment.id}>
{environment.id}
</span>
<Badge>{environment.provider}</Badge>
</div>
<div className="mt-0.5 truncate text-xs/5 text-fg-3">
{resourcesSummary(environment)}
</div>
</div>
<div
className="min-w-0 truncate font-mono text-xs text-fg-2"
title={imageSummary(environment) ?? undefined}
>
{imageSummary(environment) ?? <Muted>No image</Muted>}
</div>
<RowMenu environment={environment} disabled={disabled} onDelete={onDelete} />
</div>
);
}
function imageSummary(environment: Environment): string | null {
if (environment.image.docker) return environment.image.docker;
if (environment.image.dockerfile) return "Dockerfile (inline)";
return null;
}
function resourcesSummary(environment: Environment): string {
const parts = [
environment.resources.cpu === null ? null : `${environment.resources.cpu} CPU`,
environment.resources.memory,
environment.resources.disk,
].filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" · ") : "Default resources";
}
function StatusTag({ children }: { children: string }) {
return (
<span className="rounded-sm bg-overlay px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
{children}
</span>
);
}
function RowMenu({
environment,
disabled,
onDelete,
}: {
environment: Environment;
disabled: boolean;
onDelete: () => void;
}) {
return (
<Menu as="div" className="relative inline-block">
<MenuButton
type="button"
disabled={disabled}
aria-label={`Actions for ${environment.id}`}
title="Actions"
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:cursor-not-allowed disabled:opacity-60"
>
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
</MenuButton>
<MenuItems
transition
anchor={{ to: "bottom end", gap: 4 }}
className="z-30 w-36 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<Link
to={`/settings/environments/${encodeURIComponent(environment.id)}/edit`}
className={MENU_ITEM_CLASS}
>
Edit
</Link>
</MenuItem>
<hr className="my-1 h-px border-0 bg-line" />
<MenuItem>
<button
type="button"
onClick={onDelete}
disabled={disabled}
className={MENU_ITEM_DANGER_CLASS}
>
Delete
</button>
</MenuItem>
</MenuItems>
</Menu>
);
}

View file

@ -9,6 +9,7 @@ import {
KeyIcon,
PuzzlePieceIcon,
ShieldCheckIcon,
Square3Stack3DIcon,
} from "@heroicons/react/24/outline";
import { Fragment } from "react";
import { Link, Outlet, useLocation, useMatches } from "react-router";
@ -65,6 +66,13 @@ export const navSections: NavSection[] = [
key: "workflows",
label: "Workflows",
items: [
{
name: "Environments",
href: "/settings/environments",
icon: Square3Stack3DIcon,
description: "Server-managed runtime definitions for runs.",
match: (p) => p.startsWith("/settings/environments"),
},
{
name: "Variables",
href: "/settings/variables",

View file

@ -80,7 +80,10 @@ Important rules:
- Optional envelope fields are omitted, not serialized as `null`.
- Event-specific fields do not get flattened into the top level.
- Actor identity lives only in top-level `actor: Principal`; never duplicate it in event-specific properties.
- Actor identity normally lives only in top-level `actor: Principal`; do not duplicate it in
event-specific properties. The exception is `run.created`, whose
`properties.provenance.subject` is the durable run creator stored in `RunSpec`; its envelope
`actor` is derived from the same principal.
- User actors must carry canonical IdP identity through `Principal::User { identity, login, auth_method }`, not a login-only string.
- `EventPayload` validation requires `id`, `ts`, `run_id`, and `event`.

View file

@ -58,7 +58,18 @@ Emitted when the run record is created.
}
},
"fork_source_ref": null,
"in_place": false
"in_place": false,
"provenance": {
"subject": {
"kind": "user",
"identity": {
"issuer": "https://github.com",
"subject": "12345"
},
"login": "octocat",
"auth_method": "github"
}
}
}
}
```
@ -76,7 +87,7 @@ Emitted when the run record is created.
| `base_branch` | string? | Submitter-side base branch |
| `workflow_slug` | string? | Workflow slug |
| `db_prefix` | string? | Store prefix used for the run |
| `provenance` | object? | Actor and request provenance |
| `provenance` | object | Actor and request provenance |
| `manifest_blob` | string? | Blob id for the submitted manifest |
| `pre_run_git` | object? | Submitter-side pre-run git context and push outcome |
| `fork_source_ref` | object? | Source run/checkpoint reference when this run was forked |

View file

@ -118,11 +118,11 @@ Fields are key-value pairs that make events queryable. Include enough context th
| `error` | Error value on failure |
| `path` | File system path |
| `duration_ms` | Elapsed time in milliseconds |
| `principal_kind` | HTTP caller category (`user`, `worker`, `webhook`, `anonymous`, etc.) |
| `principal_kind` | HTTP caller category (`user`, `worker`, `webhook`, `none`, etc.) |
| `auth_status` | HTTP authentication result (`missing`, `invalid`, `expired`, `authenticated`) |
| `idp_issuer`, `idp_subject` | Canonical user identity for authenticated user requests |
For HTTP request logs, use the request `Principal` projection rather than hand-assembled auth strings. User identity fields are present only for `Principal::User`; worker and webhook requests use their variant-specific fields (`run_id`, `delivery_id`).
For HTTP request logs, use the request `Principal` projection rather than hand-assembled auth strings. User identity fields are present only for `Principal::User`; worker and webhook requests use their variant-specific fields (`run_id`, `delivery_id`). Requests without a principal use `principal_kind="none"`; `auth_status` distinguishes missing, invalid, expired, and authenticated auth state.
Server auth intentionally exposes a mutable `RequestAuth` context slot for public auth routes and guard extractors such as `RequiredUser` / `RequireRunScoped` for protected routes. There is no loose `RequestPrincipal` extractor; route-facing extractors should enforce the route's auth contract while the slot supplies the final HTTP log fields.
| `input_tokens` | Token count for LLM input |

View file

@ -258,7 +258,7 @@ On a same-machine setup, `settings.toml` is the shared machine-default layer und
On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, and `[server.scheduler]` always come from the server machine's own `settings.toml` or `fabro server start` flags.
Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, CLI `-I` / `--input` values replayed from run manifests merge per key at highest precedence, environment `env` and `labels` merge by key, environment `volumes` replace as a whole list, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging.
Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, CLI `-I` / `--input` values replayed from run manifests merge per key at highest precedence, environment `env` and `labels` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging.
### `[server.logging]` section

View file

@ -6358,7 +6358,6 @@ components:
- network
- lifecycle
- labels
- volumes
- env
properties:
id:
@ -6382,10 +6381,6 @@ components:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
@ -6403,7 +6398,6 @@ components:
- network
- lifecycle
- labels
- volumes
- env
properties:
id:
@ -6422,10 +6416,6 @@ components:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
@ -6442,7 +6432,6 @@ components:
- network
- lifecycle
- labels
- volumes
- env
properties:
provider:
@ -6457,10 +6446,6 @@ components:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
@ -9084,6 +9069,8 @@ components:
RunProvenance:
type: object
required:
- subject
properties:
server:
oneOf:
@ -9094,9 +9081,7 @@ components:
- $ref: "#/components/schemas/RunClientProvenance"
- type: "null"
subject:
oneOf:
- $ref: "#/components/schemas/Principal"
- type: "null"
$ref: "#/components/schemas/Principal"
Principal:
oneOf:
@ -9106,7 +9091,6 @@ components:
- $ref: "#/components/schemas/PrincipalSlack"
- $ref: "#/components/schemas/PrincipalAgent"
- $ref: "#/components/schemas/PrincipalSystem"
- $ref: "#/components/schemas/PrincipalAnonymous"
discriminator:
propertyName: kind
mapping:
@ -9116,7 +9100,6 @@ components:
slack: "#/components/schemas/PrincipalSlack"
agent: "#/components/schemas/PrincipalAgent"
system: "#/components/schemas/PrincipalSystem"
anonymous: "#/components/schemas/PrincipalAnonymous"
PrincipalUser:
type: object
@ -9206,15 +9189,6 @@ components:
system_kind:
$ref: "#/components/schemas/SystemActorKind"
PrincipalAnonymous:
type: object
required:
- kind
properties:
kind:
type: string
enum: [anonymous]
RunEvent:
description: >
Internal RunEvent-compatible JSON payload. The server validates this
@ -10342,6 +10316,7 @@ components:
- run_id
- settings
- graph
- provenance
properties:
run_id:
type: string
@ -10365,9 +10340,7 @@ components:
additionalProperties:
type: string
provenance:
oneOf:
- $ref: "#/components/schemas/RunProvenance"
- type: "null"
$ref: "#/components/schemas/RunProvenance"
manifest_blob:
type: ["string", "null"]
definition_blob:
@ -10679,9 +10652,7 @@ components:
- $ref: "#/components/schemas/RepositoryRef"
- type: "null"
created_by:
oneOf:
- $ref: "#/components/schemas/Principal"
- type: "null"
$ref: "#/components/schemas/Principal"
origin:
$ref: "#/components/schemas/RunOrigin"
labels:
@ -13365,7 +13336,7 @@ components:
RunEnvironmentSettings:
type: object
required: [id, provider, image, resources, network, lifecycle, labels, volumes, env]
required: [id, provider, image, resources, network, lifecycle, labels, env]
properties:
id:
type: string
@ -13381,10 +13352,6 @@ components:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
@ -13392,7 +13359,7 @@ components:
EnvironmentSettings:
type: object
required: [provider, image, resources, network, lifecycle, labels, volumes, env]
required: [provider, image, resources, network, lifecycle, labels, env]
properties:
provider:
$ref: "#/components/schemas/EnvironmentProvider"
@ -13406,10 +13373,6 @@ components:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
@ -13469,17 +13432,6 @@ components:
auto_stop:
type: ["string", "null"]
EnvironmentVolumeSettings:
type: object
required: [id, mount_path, subpath]
properties:
id:
type: string
mount_path:
type: string
subpath:
type: ["string", "null"]
DockerfileSource:
oneOf:
- $ref: "#/components/schemas/DockerfileSourceInline"

View file

@ -11,7 +11,7 @@ The dock listens to interview events and refreshes as questions arrive, so a par
## Principal attribution and auth routing
Run events and run creation now carry clearer principal information for users, workers, systems, Slack interactions, webhooks, agents, and anonymous actors. API clients get explicit provenance objects instead of older actor-shaped fields that could lose where a run came from.
Run events and run creation now carry clearer principal information for users, workers, systems, Slack interactions, webhooks, and agents. API clients get explicit provenance objects instead of older actor-shaped fields that could lose where a run came from.
This also closes attribution gaps across web, CLI, worker-token, Slack, and human-interview paths. Runs created or advanced through different surfaces now preserve who or what took the action more consistently.
@ -19,7 +19,7 @@ This also closes attribution gaps across web, CLI, worker-token, Slack, and huma
<Accordion title="API">
- Run specs now include client and server provenance shapes
- Run events use unified principal shapes for user, worker, system, Slack, webhook, agent, and anonymous subjects
- Run events use unified principal shapes for user, worker, system, Slack, webhook, and agent subjects
</Accordion>
<Accordion title="CLI">

View file

@ -5,7 +5,7 @@ description: "Reusable named execution environments for workflow runs"
Fabro separates **environments** from **sandboxes**:
- An **environment** is reusable desired configuration: provider, image, resources, network, lifecycle, labels, volumes, and environment variables.
- An **environment** is reusable desired configuration: provider, image, resources, network, lifecycle, labels, and environment variables.
- A **sandbox** is the concrete runtime instance Fabro creates for a run from the selected environment.
<Warning>
@ -52,11 +52,6 @@ auto_stop = "30m"
[labels]
repo = "fabro-sh/fabro"
[[volumes]]
id = "vol-agent-state"
mount_path = "/home/daytona/agent-state"
subpath = "auth"
[env]
NODE_ENV = "development"
```
@ -72,6 +67,26 @@ provider = "daytona"
[environments.fabro-dev.image]
dockerfile = { path = "Dockerfile" }
[environments.fabro-dev.resources]
cpu = 8
memory = "16GB"
disk = "20GB"
[environments.fabro-dev.network]
mode = "cidr_allow_list" # allow_all | block | cidr_allow_list
allow = ["10.0.0.0/8"]
[environments.fabro-dev.lifecycle]
preserve = false
stop_on_terminal = true
auto_stop = "30m"
[environments.fabro-dev.labels]
repo = "fabro-sh/fabro"
[environments.fabro-dev.env]
NODE_ENV = "development"
```
Run-level overrides are sparse and apply only to the selected environment:
@ -87,7 +102,7 @@ memory = "32GB"
preserve = true
```
`env` and `labels` merge by key. `volumes` replace as a whole list when set at a higher-precedence layer.
`env` and `labels` merge by key.
## Selecting an environment from the CLI
@ -133,7 +148,6 @@ stop_on_terminal = true
| `network.mode = "block"` | Error | Docker `none` network | Daytona block |
| `network.mode = "cidr_allow_list"` | Error | Error | Daytona CIDR allow-list |
| `labels` | Warning; ignored | Warning; ignored | Daytona labels |
| `volumes` | Warning; ignored | Warning; ignored | Daytona volume mounts |
| `lifecycle.auto_stop` | Warning; ignored | Warning; ignored | Daytona auto-stop |
| `env` | Process environment overlay | Container environment | Sandbox environment |
@ -189,12 +203,3 @@ auto_stop = "30m"
mode = "cidr_allow_list"
allow = ["208.80.154.232/32", "10.0.0.0/8"]
```
Daytona volumes reference existing provider-managed volumes:
```toml title="environments/cloud.toml"
[[volumes]]
id = "vol-agent-state"
mount_path = "/home/daytona/agent-state"
subpath = "agent-auth"
```

View file

@ -282,7 +282,6 @@ memory = "8GB"
| `lifecycle.stop_on_terminal` | Stop the sandbox when the run reaches a terminal state. |
| `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. |
| `labels` | Provider labels. Merge by key across layers. |
| `volumes` | Provider volume hints. Lists replace wholesale across layers. |
| `env` | Environment variables passed to command and agent execution. Merge by key across layers. |
When `provider = "local"`, Fabro runs directly in the resolved working
@ -559,7 +558,7 @@ provider = "daytona"
dockerfile = { path = "Dockerfile" }
```
Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), TOML `run.inputs` tables replace wholesale, CLI input flags merge per key at highest precedence, environment `env` and `labels` merge by key, environment `volumes` replace as a whole list, and `run.prepare.steps` replaces whole-list.
Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), TOML `run.inputs` tables replace wholesale, CLI input flags merge per key at highest precedence, environment `env` and `labels` merge by key, and `run.prepare.steps` replaces whole-list.
### Machine defaults

View file

@ -34,3 +34,6 @@ serde_json = "1"
serde_yaml = "0.9"
prettyplease = "0.2"
syn = "2"
[dev-dependencies]
fabro-types = { path = "../fabro-types", features = ["test-support"] }

View file

@ -36,7 +36,6 @@ fn environment_settings_json() -> serde_json::Value {
"auto_stop": null
},
"labels": {},
"volumes": [],
"env": {}
})
}

View file

@ -118,7 +118,6 @@ fn principal_round_trips_every_variant_through_api_type() {
Principal::System {
system_kind: SystemActorKind::Watchdog,
},
Principal::Anonymous,
];
for principal in variants {
@ -140,9 +139,9 @@ fn run_provenance_subject_round_trips_as_principal() {
name: Some("fabro-cli".to_string()),
version: Some("0.1.0".to_string()),
}),
subject: Some(Principal::Worker {
subject: Principal::Worker {
run_id: fixtures::RUN_1,
}),
},
};
let json = serde_json::to_value(&provenance).unwrap();

View file

@ -1,7 +1,7 @@
use std::any::{TypeId, type_name};
use fabro_api::types::RunEvent as ApiRunEvent;
use fabro_types::{Graph, RunEvent, WorkflowSettings, fixtures};
use fabro_types::{Graph, RunEvent, WorkflowSettings, fixtures, test_support};
use serde_json::{Value, json};
#[test]
@ -20,7 +20,8 @@ fn run_event_round_trips_run_created() {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"run_dir": "/tmp/fabro/run-1",
"source_directory": "/tmp/fabro/run-1"
"source_directory": "/tmp/fabro/run-1",
"provenance": test_support::test_run_provenance()
}
});
@ -39,7 +40,8 @@ fn run_event_round_trips_run_created_with_web_url() {
"graph": Graph::new("test"),
"run_dir": "/tmp/fabro/run-1",
"source_directory": "/tmp/fabro/run-1",
"web_url": format!("http://localhost:3000/runs/{}", fixtures::RUN_1)
"web_url": format!("http://localhost:3000/runs/{}", fixtures::RUN_1),
"provenance": test_support::test_run_provenance()
}
});

View file

@ -1,9 +1,8 @@
use std::any::{TypeId, type_name};
use fabro_api::types::RunProjection as ApiRunProjection;
use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings};
use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings, test_support};
use serde_json::json;
#[test]
fn run_projection_reuses_canonical_type() {
assert_same_type::<ApiRunProjection, RunProjection>();
@ -137,7 +136,7 @@ fn run_spec_json() -> serde_json::Value {
automation: None,
source_directory: None,
labels: std::collections::HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -12,7 +12,7 @@ use fabro_types::{
AskFabro, AskFabroUnavailableReason, AutomationRef, DiffSummary, PullRequestLink,
RepositoryProvider, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary,
RunId, RunLifecycle, RunLinks, RunOrigin, RunRunnableSource, RunSize, RunTimestamps, RunTiming,
WorkflowRef, fixtures,
WorkflowRef, fixtures, test_support,
};
use serde_json::json;
@ -88,7 +88,7 @@ fn run_summary_json_matches_openapi_shape() {
origin_url: None,
provider: RepositoryProvider::Unknown,
}),
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::from([("team".to_string(), "core".to_string())]),
lifecycle: RunLifecycle {
@ -161,7 +161,15 @@ fn run_summary_json_matches_openapi_shape() {
"origin_url": null,
"provider": "unknown"
},
"created_by": null,
"created_by": {
"kind": "user",
"identity": {
"issuer": "fabro:test",
"subject": "test-user"
},
"login": "test",
"auth_method": "dev_token"
},
"origin": {
"kind": "api"
},
@ -253,6 +261,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
"origin_url": null,
"provider": "unknown"
},
"created_by": test_support::test_principal(),
"models": [],
"timestamps": {
"created_at": "2026-04-20T12:00:00Z",
@ -275,6 +284,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
assert_eq!(summary.workflow.edge_count, 0);
assert_eq!(summary.goal, "ship it");
assert_eq!(summary.title, "ship it");
assert_eq!(summary.created_by, test_support::test_principal());
assert_eq!(summary.labels, HashMap::new());
assert_eq!(summary.source_directory, None);
assert_eq!(

View file

@ -119,6 +119,7 @@ assert_cmd = "2"
fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
fabro-build-support = { path = "../build-support" }
fabro-server = { path = "../fabro-server", features = ["test-support"] }
fabro-types = { path = "../fabro-types", features = ["clap", "test-support"] }
insta = { workspace = true, features = ["filters"] }
paste = "1"
predicates = "3"

View file

@ -39,6 +39,7 @@ use fabro_model::{Catalog, CredentialRef, ProviderId};
use fabro_server::serve;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::settings::server::ServerAuthMethod;
use fabro_types::settings::validate_public_url_with_label;
use fabro_util::printer::Printer;
@ -2012,6 +2013,24 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
dev_token_for_auth_store.as_deref(),
)
.await?;
// Seed the default environment next to the settings file. The server never
// seeds on startup, so install is the only place the default is written;
// existing files are preserved, so re-running install never clobbers edits.
let environment_dir = config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("environments");
if let Err(err) =
fabro_environment::seed_default_environment(&environment_dir, EnvironmentProvider::Docker)
{
fabro_util::printerr!(
printer,
" {} Failed to seed default environment: {err}",
s.yellow.apply_to("Warning:")
);
}
if let Some(token) = dev_token_for_auth_store {
let user_settings = UserSettingsBuilder::from_toml(&settings_toml)?;
let target = match user_config::resolve_nondefault_server_target(

View file

@ -822,6 +822,7 @@ mod tests {
)]
use fabro_interview::{Answer, AnswerValue};
use fabro_types::test_support;
use fabro_util::terminal::Styles;
use httpmock::MockServer;
@ -841,7 +842,7 @@ mod tests {
automation: None,
source_directory: None,
labels: std::collections::HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -955,8 +955,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
"cpu": null,
"disk": null,
"memory": null
},
"volumes": []
}
},
"execution": {
"approval": "prompt",

View file

@ -183,7 +183,6 @@ fn inspect_resolves_selector_via_server_endpoint() {
"auto_stop": null
},
"labels": {},
"volumes": [],
"env": {}
},
"notifications": {},
@ -221,7 +220,18 @@ fn inspect_resolves_selector_via_server_endpoint() {
"attrs": {}
},
"workflow_slug": "remote-workflow",
"source_directory": "/srv/repo"
"source_directory": "/srv/repo",
"provenance": {
"subject": {
"kind": "user",
"identity": {
"issuer": "fabro:test",
"subject": "test-user"
},
"login": "test",
"auth_method": "dev_token"
}
}
},
"start_record": null,
"conclusion": null,

View file

@ -21,6 +21,7 @@ use fabro_config::daemon::ServerDaemon;
use fabro_config::{Storage, envfile};
use fabro_store::EventEnvelope;
use fabro_test::{TestContext, expect_reqwest_status};
use fabro_types::test_support::test_principal;
use fabro_types::{RunId, StageId};
use httpmock::{Mock, MockServer};
use serde_json::Value;
@ -177,6 +178,8 @@ pub(crate) fn remote_run_summary_json(
"origin_url": null,
"provider": "unknown"
},
"created_by": serde_json::to_value(test_principal())
.expect("test principal should serialize"),
"origin": {
"kind": "api"
},

View file

@ -1,3 +1,4 @@
use fabro_types::test_support;
mod auth_harness;
mod auth_tokens;
@ -49,7 +50,7 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
automation: None,
source_directory: Some("/srv/repo".to_string()),
labels: std::collections::HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -3,7 +3,7 @@ use std::path::Path;
use std::str::FromStr;
use fabro_types::settings::run::EnvironmentProvider;
use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value};
use toml_edit::{DocumentMut, Item, Table, Value};
use crate::{Error, Result};
@ -205,7 +205,6 @@ fn migrate_daytona(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<Strin
}
}
"snapshot" => migrate_daytona_snapshot(item, env, unsupported),
"volumes" => copy_array_of_tables_with_volume_id(item, env, unsupported),
_ => item_path_keys(&format!("run.sandbox.daytona.{key}"), item, unsupported),
}
}
@ -291,50 +290,6 @@ fn copy_table(source: &Item, target: &mut Table) {
}
}
fn copy_array_of_tables_with_volume_id(
source: &Item,
target: &mut Table,
unsupported: &mut Vec<String>,
) {
let Some(volumes) = source.as_array_of_tables() else {
unsupported.push("run.sandbox.daytona.volumes".to_string());
return;
};
let mut migrated = ArrayOfTables::new();
for volume in volumes {
let mut migrated_volume = Table::new();
let mut has_id = false;
let mut has_mount_path = false;
for (key, item) in volume {
match key {
"volume_id" => {
has_id = true;
migrated_volume["id"] = item.clone();
}
"mount_path" => {
has_mount_path = true;
migrated_volume["mount_path"] = item.clone();
}
"subpath" => migrated_volume["subpath"] = item.clone(),
_ => item_path_keys(
&format!("run.sandbox.daytona.volumes.{key}"),
item,
unsupported,
),
}
}
if !has_id {
unsupported.push("run.sandbox.daytona.volumes.volume_id".to_string());
}
if !has_mount_path {
unsupported.push("run.sandbox.daytona.volumes.mount_path".to_string());
}
migrated.push(migrated_volume);
}
target["volumes"] = Item::ArrayOfTables(migrated);
}
fn item_path_keys(prefix: &str, item: &Item, out: &mut Vec<String>) {
if let Some(table) = item.as_table() {
if table.is_empty() {
@ -422,7 +377,7 @@ provider = "daytona"
reason = "test asserts the raw template source"
)]
#[test]
fn daytona_snapshot_labels_lifecycle_and_volumes_migrate() {
fn daytona_snapshot_labels_and_lifecycle_migrate() {
let migrated = migrate(
r#"
_version = 1
@ -446,11 +401,6 @@ cpu = 8
memory = "16GB"
disk = "20GB"
dockerfile = { path = "Dockerfile" }
[[run.sandbox.daytona.volumes]]
volume_id = "vol_auth"
mount_path = "/home/daytona/.config"
subpath = "agents"
"#,
);
@ -490,10 +440,30 @@ subpath = "agents"
resolved.env.get("NODE_ENV").map(InterpString::as_source),
Some("development".to_string())
);
assert_eq!(resolved.volumes.len(), 1);
assert_eq!(resolved.volumes[0].id, "vol_auth");
assert_eq!(resolved.volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(resolved.volumes[0].subpath.as_deref(), Some("agents"));
}
#[test]
fn daytona_volumes_are_reported_unsupported() {
let err = migrate_contents(
r#"
_version = 1
[run.sandbox]
provider = "daytona"
[[run.sandbox.daytona.volumes]]
volume_id = "vol_auth"
mount_path = "/home/daytona/.config"
"#,
Path::new("settings.toml"),
)
.expect_err("daytona volumes can no longer be migrated");
let message = err.to_string();
assert!(
message.contains("run.sandbox.daytona.volumes"),
"message was: {message}"
);
}
#[test]

View file

@ -4,6 +4,7 @@
reason = "temporary startup config migration uses synchronous file I/O before config is loaded"
)]
use std::fmt::Write as _;
use std::io::Write;
use std::path::{Path, PathBuf};
@ -43,16 +44,15 @@ pub(crate) fn migrate_settings_path(
let settings_dir = path.parent().unwrap_or_else(|| Path::new("."));
let environment_dir = settings_dir.join("environments");
let existing = extracted
.iter()
.map(|environment| environment_dir.join(format!("{}.toml", environment.id)))
.find(|target| target.exists());
if let Some(target) = existing {
return Err(Error::other(format!(
"Settings environments in {} could not be auto-migrated because target environment file {} already exists. Move or merge the file and retry.",
path.display(),
target.display()
)));
let mut to_write = Vec::new();
let mut preserved_existing = Vec::new();
for environment in extracted {
let target = environment_dir.join(format!("{}.toml", environment.id));
if target.exists() {
preserved_existing.push(environment);
} else {
to_write.push(environment);
}
}
let backup_path = write_next_backup(path, original_contents)?;
@ -62,7 +62,7 @@ pub(crate) fn migrate_settings_path(
environment_dir.display()
))
})?;
for environment in &extracted {
for environment in &to_write {
let target = environment_dir.join(format!("{}.toml", environment.id));
write_new_file(&target, &environment.contents)?;
}
@ -73,17 +73,30 @@ pub(crate) fn migrate_settings_path(
))
})?;
let ids = extracted
let ids = to_write
.iter()
.chain(preserved_existing.iter())
.map(|environment| environment.id.as_str())
.collect::<Vec<_>>()
.join(", ");
let warning = format!(
let mut warning = format!(
"Migrated [environments] settings in {} to server environment files under {} ({ids}). Backup written to {}. {REMOVAL_NOTE}",
path.display(),
environment_dir.display(),
backup_path.display()
);
if !preserved_existing.is_empty() {
let preserved_ids = preserved_existing
.iter()
.map(|environment| environment.id.as_str())
.collect::<Vec<_>>()
.join(", ");
write!(
warning,
" preserved existing environment files: {preserved_ids}."
)
.expect("writing to String should not fail");
}
Ok(Some(SettingsEnvironmentsMigrationReport {
contents: next_contents,
@ -280,7 +293,7 @@ docker = "ubuntu:24.04"
}
#[test]
fn target_conflict_fails_without_changing_settings_or_files() {
fn target_conflict_preserves_existing_environment_and_removes_catalog() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("settings.toml");
let environment_dir = dir.path().join("environments");
@ -295,10 +308,18 @@ provider = "docker"
"#;
std::fs::write(&path, source).expect("write settings");
let err = migrate_settings_path(&path, source).expect_err("conflict should fail");
let report = migrate_settings_path(&path, source)
.expect("conflict should warn and continue")
.expect("catalog should migrate out of settings");
assert!(err.to_string().contains("already exists"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
let rewritten = std::fs::read_to_string(&path).unwrap();
assert!(report.backup_path.exists());
assert!(
report
.warning
.contains("preserved existing environment files: cloud")
);
assert!(!rewritten.contains("[environments"));
assert_eq!(
std::fs::read_to_string(environment_dir.join("cloud.toml")).unwrap(),
"provider = \"local\"\n"

View file

@ -14,7 +14,7 @@ use fabro_types::settings::{Duration, InterpString, Size};
use super::LogFilter;
use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer};
use super::environment::{EnvironmentDockerfileLayer, EnvironmentVolumeLayer};
use super::environment::EnvironmentDockerfileLayer;
use super::llm::{CostRates, CredentialRef, HeaderValueRef, ReasoningEffortFeature};
use super::run::{
HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, ModelRefOrSplice,
@ -45,12 +45,6 @@ impl<T: Combine> Combine for Option<T> {
}
}
impl Combine for Option<Vec<EnvironmentVolumeLayer>> {
fn combine(self, other: Self) -> Self {
self.or(other)
}
}
macro_rules! impl_combine_or_option {
($($ty:ty),+ $(,)?) => {
$(

View file

@ -21,8 +21,6 @@ pub struct EnvironmentLayer {
pub lifecycle: Option<EnvironmentLifecycleLayer>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub labels: StickyMap<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volumes: Option<Vec<EnvironmentVolumeLayer>>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub env: StickyMap<InterpString>,
}
@ -42,8 +40,6 @@ pub struct RunEnvironmentLayer {
pub lifecycle: Option<EnvironmentLifecycleLayer>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub labels: StickyMap<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volumes: Option<Vec<EnvironmentVolumeLayer>>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub env: StickyMap<InterpString>,
}
@ -58,7 +54,6 @@ impl RunEnvironmentLayer {
network: self.network,
lifecycle: self.lifecycle,
labels: self.labels,
volumes: self.volumes,
env: self.env,
}
}
@ -117,15 +112,6 @@ pub struct EnvironmentLifecycleLayer {
pub auto_stop: Option<Duration>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvironmentVolumeLayer {
pub id: String,
pub mount_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subpath: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum EnvironmentDockerfileLayer {

View file

@ -18,8 +18,7 @@ pub use cli::{
pub(crate) use combine::Combine;
pub use environment::{
EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer,
EnvironmentNetworkLayer, EnvironmentResourcesLayer, EnvironmentVolumeLayer,
RunEnvironmentLayer,
EnvironmentNetworkLayer, EnvironmentResourcesLayer, RunEnvironmentLayer,
};
pub use llm::{
CostRates, CredentialRef, CredentialRefParseError, HeaderValueRef, LlmLayer, ModelControls,

View file

@ -44,21 +44,21 @@ pub use layers::{
CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer,
CliOutputLayer, CliTargetLayer, CliUpdatesLayer, CostRates, CredentialRef,
CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer,
EnvironmentVolumeLayer, GitAuthorLayer, GithubIntegrationLayer, HeaderValueRef,
HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer,
InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, McpEntryLayer,
MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer,
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer,
RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer,
RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer,
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer,
ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, GitAuthorLayer,
GithubIntegrationLayer, HeaderValueRef, HookAgentMarker, HookEntry, HookTlsMode,
IntegrationWebhooksLayer, InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures,
LlmModelLimits, LogFilter, McpEntryLayer, MergeMap, ModelControls, ModelCostTable,
ModelRefOrSplice, ModelSettings, NotificationProviderLayer, NotificationRouteLayer,
ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, ProviderSettings,
ReasoningEffortFeature, ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer,
RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer,
RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer,
RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer,
RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer,
ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer,
SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
};
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
pub use parse::ParseError;

View file

@ -1,15 +1,14 @@
use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider,
EnvironmentResourcesSettings, EnvironmentSettings, EnvironmentVolumeSettings,
RunEnvironmentSettings,
EnvironmentResourcesSettings, EnvironmentSettings, RunEnvironmentSettings,
};
use super::ResolveError;
use crate::{
Combine, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer,
EnvironmentVolumeLayer, MergeMap, RunEnvironmentLayer,
EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, MergeMap,
RunEnvironmentLayer,
};
pub(crate) fn resolve_run_environment(
@ -77,7 +76,6 @@ fn resolve_environment_fields(
network: resolve_network(layer.network.as_ref(), &format!("{path}.network"), errors),
lifecycle: resolve_lifecycle(layer.lifecycle.as_ref()),
labels: layer.labels.clone().into_inner(),
volumes: resolve_volumes(layer.volumes.as_deref()),
env: layer.env.clone().into_inner(),
};
validate_daytona_image_settings(&environment, path, errors);
@ -174,18 +172,6 @@ fn resolve_lifecycle(layer: Option<&EnvironmentLifecycleLayer>) -> EnvironmentLi
}
}
fn resolve_volumes(layers: Option<&[EnvironmentVolumeLayer]>) -> Vec<EnvironmentVolumeSettings> {
layers
.unwrap_or(&[])
.iter()
.map(|volume| EnvironmentVolumeSettings {
id: volume.id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect()
}
fn dockerfile_source(dockerfile: &EnvironmentDockerfileLayer) -> DockerfileSource {
match dockerfile {
EnvironmentDockerfileLayer::Inline(text) => DockerfileSource::Inline(text.clone()),

View file

@ -131,11 +131,6 @@ auto_stop = "30m"
[environments.fabro-dev.labels]
repo = "fabro-sh/fabro"
[[environments.fabro-dev.volumes]]
id = "vol_auth"
mount_path = "/home/daytona/.config"
subpath = "agents"
[environments.fabro-dev.env]
NODE_ENV = "development"
"#,
@ -174,10 +169,6 @@ NODE_ENV = "development"
environment.labels.get("repo").map(String::as_str),
Some("fabro-sh/fabro")
);
assert_eq!(environment.volumes.len(), 1);
assert_eq!(environment.volumes[0].id, "vol_auth");
assert_eq!(environment.volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(environment.volumes[0].subpath.as_deref(), Some("agents"));
assert_eq!(
environment
.env

View file

@ -476,6 +476,7 @@ mod tests {
Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunSandboxInstance,
RunSandboxPlan, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,
StageOutcome, StartRecord, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
test_support,
};
use futures::executor;
@ -498,7 +499,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,

View file

@ -31,8 +31,8 @@ pub enum EnvironmentStoreError {
expected: EnvironmentRevision,
actual: EnvironmentRevision,
},
#[error("environment is protected and cannot be deleted: {id}")]
Protected { id: EnvironmentId },
#[error("environment is reserved and cannot be modified: {id}")]
Reserved { id: EnvironmentId },
#[error("environment validation failed: {source}")]
Validation {
#[from]
@ -93,7 +93,7 @@ impl EnvironmentStoreError {
Self::NotFound { .. } => "not_found",
Self::AlreadyExists { .. } => "already_exists",
Self::StaleRevision { .. } => "stale_revision",
Self::Protected { .. } => "protected",
Self::Reserved { .. } => "reserved",
Self::Validation { .. } => "validation",
Self::InvalidFilename { .. } => "invalid_filename",
Self::Parse { .. } | Self::InvalidUtf8 { .. } => "parse",

View file

@ -6,4 +6,6 @@ mod store;
pub use error::{EnvironmentStoreError, EnvironmentValidationError};
pub use id::{EnvironmentId, EnvironmentRevision, EnvironmentRevisionParseError};
pub use model::{Environment, EnvironmentDraft};
pub use store::{EnvironmentStore, seeded_catalog_layer};
pub use store::{
EnvironmentStore, seed_default_environment, seed_environments, seeded_catalog_layer,
};

View file

@ -3,17 +3,17 @@ use std::path::Path;
use fabro_config::{
EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer,
EnvironmentNetworkLayer, EnvironmentResourcesLayer, EnvironmentVolumeLayer, StickyMap,
EnvironmentNetworkLayer, EnvironmentResourcesLayer, StickyMap,
};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentResourcesSettings,
EnvironmentSettings, EnvironmentVolumeSettings,
EnvironmentSettings,
};
use serde::{Deserialize, Serialize};
use tokio::fs;
use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value, value};
use toml_edit::{Array, DocumentMut, Item, Table, Value, value};
use crate::{
EnvironmentId, EnvironmentRevision, EnvironmentStoreError, EnvironmentValidationError,
@ -65,6 +65,25 @@ impl Environment {
))
}
/// Builds an in-memory environment from settings without touching the
/// filesystem. Unlike [`from_settings`], this never inlines Dockerfile
/// paths, so it stays synchronous — suitable for reserved environments
/// (e.g. `local`) that carry no Dockerfile and are never persisted.
pub(crate) fn synthetic(
id: EnvironmentId,
settings: &EnvironmentSettings,
) -> Result<Self, EnvironmentStoreError> {
let persisted = environment_settings_to_layer(settings);
let settings = resolve_environment(&persisted)?;
let bytes = canonical_bytes(&persisted).into_bytes();
let revision = EnvironmentRevision::from_bytes(&bytes);
Ok(Self {
id,
revision,
settings,
})
}
pub(crate) fn to_layer(&self) -> EnvironmentLayer {
environment_settings_to_layer(&self.settings)
}
@ -95,9 +114,6 @@ pub(crate) fn canonical_bytes(layer: &EnvironmentLayer) -> String {
append_lifecycle(doc.as_table_mut(), lifecycle);
}
append_string_map(doc.as_table_mut(), "labels", &layer.labels);
if let Some(volumes) = layer.volumes.as_deref() {
append_volumes(doc.as_table_mut(), volumes);
}
append_interp_map(doc.as_table_mut(), "env", &layer.env);
doc.to_string()
}
@ -169,7 +185,6 @@ fn environment_settings_to_layer(settings: &EnvironmentSettings) -> EnvironmentL
network: network_settings_to_layer(&settings.network),
lifecycle: lifecycle_settings_to_layer(&settings.lifecycle),
labels: StickyMap::from(settings.labels.clone()),
volumes: volumes_settings_to_layer(&settings.volumes),
env: StickyMap::from(settings.env.clone()),
}
}
@ -229,23 +244,6 @@ fn lifecycle_settings_to_layer(
})
}
fn volumes_settings_to_layer(
settings: &[EnvironmentVolumeSettings],
) -> Option<Vec<EnvironmentVolumeLayer>> {
if settings.is_empty() {
return None;
}
Some(settings.iter().map(volume_settings_to_layer).collect())
}
fn volume_settings_to_layer(settings: &EnvironmentVolumeSettings) -> EnvironmentVolumeLayer {
EnvironmentVolumeLayer {
id: settings.id.clone(),
mount_path: settings.mount_path.clone(),
subpath: settings.subpath.clone(),
}
}
fn append_image(root: &mut Table, image: &EnvironmentImageLayer) {
let table = ensure_table(root, &["image"]);
if let Some(docker) = image.docker.as_deref() {
@ -325,23 +323,6 @@ fn append_interp_map(root: &mut Table, name: &str, map: &StickyMap<InterpString>
}
}
fn append_volumes(root: &mut Table, volumes: &[EnvironmentVolumeLayer]) {
if volumes.is_empty() {
return;
}
let mut array = ArrayOfTables::new();
for volume in volumes {
let mut table = Table::new();
table["id"] = value(volume.id.as_str());
table["mount_path"] = value(volume.mount_path.as_str());
if let Some(subpath) = volume.subpath.as_deref() {
table["subpath"] = value(subpath);
}
array.push(table);
}
root["volumes"] = Item::ArrayOfTables(array);
}
fn ensure_table<'a>(root: &'a mut Table, path: &[&str]) -> &'a mut Table {
let mut current = root;
for key in path {

View file

@ -5,7 +5,7 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use fabro_config::{EnvironmentLayer, MergeMap};
use fabro_types::settings::run::EnvironmentSettings;
use fabro_types::settings::run::{EnvironmentProvider, EnvironmentSettings};
use tokio::fs;
use tokio::io::AsyncWriteExt as _;
use tokio::sync::Mutex;
@ -14,23 +14,32 @@ use crate::{
Environment, EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStoreError,
};
const SEEDS: &[(&str, &str)] = &[
("default", DEFAULT_ENVIRONMENT_TOML),
("local", LOCAL_ENVIRONMENT_TOML),
("docker", DOCKER_ENVIRONMENT_TOML),
("daytona", DAYTONA_ENVIRONMENT_TOML),
];
/// Built-in default environment written to disk by the installer (see
/// [`seed_default_environment`]). The server itself never seeds: a Fabro
/// instance that has not been installed has no managed environments, and a run
/// that selects an absent environment fails explicitly. `local` is
/// intentionally absent: it is a reserved, in-memory environment (see
/// [`RESERVED_LOCAL_ID`]).
const DEFAULT_ENVIRONMENT_ID: &str = "default";
/// `local` is a reserved environment: it is synthesized in memory only when the
/// local sandbox provider is enabled, is never persisted to disk, and cannot be
/// created, replaced, or deleted through the store.
const RESERVED_LOCAL_ID: &str = "local";
/// Returns the built-in seeded environment catalog as a `MergeMap` of
/// `EnvironmentLayer`s. Useful for client-side manifest validation where no
/// live `EnvironmentStore` is available.
/// live `EnvironmentStore` is available. Includes the reserved `local` entry so
/// manifests selecting `id = "local"` validate; server-side provider-enablement
/// policy decides whether such a run may actually execute.
pub fn seeded_catalog_layer() -> MergeMap<EnvironmentLayer> {
let mut catalog: HashMap<String, EnvironmentLayer> = HashMap::new();
for (id, body) in SEEDS {
let layer: EnvironmentLayer =
toml::from_str(body).expect("built-in environment seed should parse");
catalog.insert((*id).to_string(), layer);
}
let default: EnvironmentLayer =
toml::from_str(DEFAULT_ENVIRONMENT_TOML).expect("built-in environment seed should parse");
catalog.insert(DEFAULT_ENVIRONMENT_ID.to_string(), default);
let local: EnvironmentLayer = toml::from_str(LOCAL_ENVIRONMENT_TOML)
.expect("built-in local environment seed should parse");
catalog.insert(RESERVED_LOCAL_ID.to_string(), local);
MergeMap::from(catalog)
}
@ -51,10 +60,10 @@ stop_on_terminal = true
const LOCAL_ENVIRONMENT_TOML: &str = r#"provider = "local"
"#;
const DOCKER_ENVIRONMENT_TOML: &str = r#"provider = "docker"
const DAYTONA_DEFAULT_ENVIRONMENT_TOML: &str = r#"provider = "daytona"
[image]
docker = "buildpack-deps:noble"
dockerfile = "FROM buildpack-deps:noble\n"
[resources]
cpu = 2
@ -65,9 +74,6 @@ preserve = false
stop_on_terminal = true
"#;
const DAYTONA_ENVIRONMENT_TOML: &str = r#"provider = "daytona"
"#;
#[derive(Debug)]
pub struct EnvironmentStore {
dir: PathBuf,
@ -96,6 +102,18 @@ impl CatalogState {
}
}
/// Builds the reserved, in-memory `local` environment. It carries only
/// `provider = "local"`; image/resources/network/etc. are irrelevant to the
/// local sandbox and stay at their defaults.
fn synthetic_local_environment() -> Result<Environment, EnvironmentStoreError> {
let id = EnvironmentId::new(RESERVED_LOCAL_ID).expect("reserved local id is valid");
let settings = EnvironmentSettings {
provider: EnvironmentProvider::Local,
..EnvironmentSettings::default()
};
Environment::synthetic(id, &settings)
}
fn build_catalog_layer(
environments: &HashMap<EnvironmentId, Environment>,
) -> MergeMap<EnvironmentLayer> {
@ -107,13 +125,25 @@ fn build_catalog_layer(
}
impl EnvironmentStore {
/// Synchronously seed missing built-in environment files and load all
/// persisted environments. The synchronous file access runs during server
/// startup before request handling begins.
pub fn load_or_seed(dir: impl Into<PathBuf>) -> Result<Self, EnvironmentStoreError> {
/// Synchronously load all persisted environments. The synchronous file
/// access runs during server startup before request handling begins.
///
/// The server never seeds the default environment; seeding is an
/// install-time action (see [`seed_default_environment`]). An uninstalled
/// instance therefore
/// has no managed environments on disk, and the reserved `local`
/// environment is the only entry present (when the local provider is
/// enabled).
pub fn load(
dir: impl Into<PathBuf>,
local_enabled: bool,
) -> Result<Self, EnvironmentStoreError> {
let dir = dir.into();
seed_missing_environments(&dir)?;
let environments = load_environments(&dir)?;
let mut environments = load_environments(&dir)?;
if local_enabled {
let local = synthetic_local_environment()?;
environments.insert(local.id.clone(), local);
}
let request_base_dir = dir.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
Ok(Self {
dir,
@ -147,6 +177,9 @@ impl EnvironmentStore {
draft: EnvironmentDraft,
) -> Result<Environment, EnvironmentStoreError> {
let EnvironmentDraft { id, settings } = draft;
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id });
}
let (environment, bytes) =
Environment::from_settings(id.clone(), settings, &self.request_base_dir).await?;
let _mutation = self.mutations.lock().await;
@ -171,6 +204,9 @@ impl EnvironmentStore {
expected: &EnvironmentRevision,
settings: EnvironmentSettings,
) -> Result<Environment, EnvironmentStoreError> {
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id: id.clone() });
}
let (environment, bytes) =
Environment::from_settings(id.clone(), settings, &self.request_base_dir).await?;
let _mutation = self.mutations.lock().await;
@ -188,8 +224,11 @@ impl EnvironmentStore {
id: &EnvironmentId,
expected: &EnvironmentRevision,
) -> Result<(), EnvironmentStoreError> {
if id.as_str() == "default" {
return Err(EnvironmentStoreError::Protected { id: id.clone() });
// `default` is an ordinary deletable environment. Deleting it removes the
// run fallback, which is intentional: a run that selects `default` after
// it is gone fails explicitly rather than silently using a built-in.
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id: id.clone() });
}
let _mutation = self.mutations.lock().await;
@ -228,30 +267,48 @@ fn check_revision(
Ok(())
}
/// Writes the built-in Docker default environment into `dir`, creating the
/// directory if needed. Existing files are left untouched, so this is
/// idempotent and never clobbers operator edits. Called by legacy installer
/// paths; the running server does not seed.
pub fn seed_environments(dir: &Path) -> Result<(), EnvironmentStoreError> {
seed_default_environment(dir, EnvironmentProvider::Docker)
}
/// Writes the selected built-in `default` environment into `dir`, creating the
/// directory if needed. Existing files are left untouched, so this is
/// idempotent and never clobbers operator edits. Called by the installer; the
/// running server does not seed.
#[expect(
clippy::disallowed_methods,
clippy::disallowed_types,
reason = "Environment directory seeding runs synchronously during startup before request handling."
reason = "Install-time environment seeding runs synchronously from the installer before the server starts."
)]
fn seed_missing_environments(dir: &Path) -> Result<(), EnvironmentStoreError> {
pub fn seed_default_environment(
dir: &Path,
provider: EnvironmentProvider,
) -> Result<(), EnvironmentStoreError> {
std::fs::create_dir_all(dir).map_err(|err| EnvironmentStoreError::io(dir, err))?;
for (id, content) in SEEDS {
let path = dir.join(format!("{id}.toml"));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write as _;
file.write_all(content.as_bytes())
.map_err(|err| EnvironmentStoreError::io(&path, err))?;
file.sync_all()
.map_err(|err| EnvironmentStoreError::io(&path, err))?;
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
Err(err) => return Err(EnvironmentStoreError::io(path, err)),
let content = match provider {
EnvironmentProvider::Docker => DEFAULT_ENVIRONMENT_TOML,
EnvironmentProvider::Daytona => DAYTONA_DEFAULT_ENVIRONMENT_TOML,
EnvironmentProvider::Local => LOCAL_ENVIRONMENT_TOML,
};
let path = dir.join(format!("{DEFAULT_ENVIRONMENT_ID}.toml"));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write as _;
file.write_all(content.as_bytes())
.map_err(|err| EnvironmentStoreError::io(&path, err))?;
file.sync_all()
.map_err(|err| EnvironmentStoreError::io(&path, err))?;
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
Err(err) => return Err(EnvironmentStoreError::io(path, err)),
}
Ok(())
}
@ -279,6 +336,11 @@ fn load_environments(
if !file_type.is_file() || !is_toml_file(&path) {
continue;
}
// `local` is reserved and synthesized in memory; never load a stale
// `local.toml` left behind by an earlier build that seeded it.
if id_from_path(&path).is_ok_and(|id| id.as_str() == RESERVED_LOCAL_ID) {
continue;
}
let environment = load_environment_file(&path)?;
environments.insert(environment.id.clone(), environment);
}
@ -422,7 +484,6 @@ mod tests {
network: EnvironmentNetworkSettings::default(),
lifecycle: EnvironmentLifecycleSettings::default(),
labels: HashMap::new(),
volumes: Vec::new(),
env: HashMap::new(),
}
}
@ -438,29 +499,138 @@ mod tests {
fn seeded_catalog_layer_contains_built_ins() {
let catalog = super::seeded_catalog_layer();
let inner = catalog.into_inner();
for id in ["default", "local", "docker", "daytona"] {
for id in ["default", "local"] {
assert!(inner.contains_key(id), "missing {id}");
}
assert!(!inner.contains_key("docker"));
assert!(!inner.contains_key("daytona"));
}
#[tokio::test]
async fn load_does_not_seed_built_ins() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
// The server loads without seeding: an uninstalled instance has only the
// reserved in-memory `local` environment, and nothing is written to disk.
let store = EnvironmentStore::load(&environment_dir, true).unwrap();
assert_eq!(
store
.list()
.iter()
.map(|environment| environment.id.as_str())
.collect::<Vec<_>>(),
vec!["local"]
);
for id in ["default", "docker", "daytona"] {
assert!(!environment_dir.join(format!("{id}.toml")).exists());
}
}
#[tokio::test]
async fn absent_directory_loads_and_seeds_built_ins() {
async fn seed_environments_writes_default_only_and_load_picks_it_up() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap();
let environments = store.list();
super::seed_environments(&environment_dir).unwrap();
assert!(environment_dir.join("default.toml").exists());
assert!(!environment_dir.join("docker.toml").exists());
assert!(!environment_dir.join("daytona.toml").exists());
// `local` is reserved and in-memory; it is never written to disk.
assert!(!environment_dir.join("local.toml").exists());
let store = EnvironmentStore::load(&environment_dir, true).unwrap();
assert_eq!(
environments
store
.list()
.iter()
.map(|environment| environment.id.as_str())
.collect::<Vec<_>>(),
vec!["daytona", "default", "docker", "local"]
vec!["default", "local"]
);
for id in ["default", "local", "docker", "daytona"] {
assert!(environment_dir.join(format!("{id}.toml")).exists());
}
}
#[tokio::test]
async fn seed_environments_is_idempotent_and_preserves_edits() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
super::seed_environments(&environment_dir).unwrap();
// An operator edit to a seeded file must survive a re-seed.
fs::write(
environment_dir.join("default.toml"),
"provider = \"docker\"\n[resources]\ncpu = 7\n",
)
.await
.unwrap();
super::seed_environments(&environment_dir).unwrap();
let store = EnvironmentStore::load(&environment_dir, false).unwrap();
let default = store.get(&EnvironmentId::new("default").unwrap()).unwrap();
assert_eq!(default.settings.resources.cpu, Some(7));
}
#[tokio::test]
async fn local_present_only_when_enabled() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
let enabled = EnvironmentStore::load(&environment_dir, true).unwrap();
assert!(enabled.get(&EnvironmentId::new("local").unwrap()).is_some());
let disabled = EnvironmentStore::load(&environment_dir, false).unwrap();
assert!(
disabled
.get(&EnvironmentId::new("local").unwrap())
.is_none()
);
}
#[tokio::test]
async fn on_disk_local_is_ignored_in_favor_of_synthetic() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
fs::create_dir_all(&environment_dir).await.unwrap();
// A stale `local.toml` left by an earlier build that seeded it.
fs::write(
environment_dir.join("local.toml"),
"provider = \"local\"\n[resources]\ncpu = 99\n",
)
.await
.unwrap();
let store = EnvironmentStore::load(&environment_dir, true).unwrap();
let local = store.get(&EnvironmentId::new("local").unwrap()).unwrap();
// The synthetic local carries no resources; the stale file was ignored.
assert_eq!(local.settings.resources.cpu, None);
}
#[tokio::test]
async fn local_mutations_are_reserved() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load(dir.path().join("environments"), true).unwrap();
let local = EnvironmentId::new("local").unwrap();
let revision = store.get(&local).unwrap().revision;
let create_err = store
.create(draft("local", EnvironmentProvider::Local))
.await
.unwrap_err();
assert!(matches!(create_err, EnvironmentStoreError::Reserved { .. }));
let replace_err = store
.replace(&local, &revision, settings(EnvironmentProvider::Local))
.await
.unwrap_err();
assert!(matches!(
replace_err,
EnvironmentStoreError::Reserved { .. }
));
let delete_err = store.delete(&local, &revision).await.unwrap_err();
assert!(matches!(delete_err, EnvironmentStoreError::Reserved { .. }));
}
#[tokio::test]
@ -475,7 +645,7 @@ mod tests {
.await
.unwrap();
let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap();
let store = EnvironmentStore::load(&environment_dir, true).unwrap();
assert_eq!(
store
@ -483,7 +653,7 @@ mod tests {
.iter()
.map(|environment| environment.id.as_str())
.collect::<Vec<_>>(),
vec!["a", "daytona", "default", "docker", "local", "z"]
vec!["a", "local", "z"]
);
}
@ -494,7 +664,7 @@ mod tests {
std::fs::create_dir_all(&environment_dir).unwrap();
std::fs::write(environment_dir.join("Bad.toml"), r#"provider = "local""#).unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
let err = EnvironmentStore::load(&environment_dir, true).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::InvalidFilename { .. }));
}
@ -506,7 +676,7 @@ mod tests {
std::fs::create_dir_all(&environment_dir).unwrap();
std::fs::write(environment_dir.join("bad.toml"), r#"provider = "bogus""#).unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
let err = EnvironmentStore::load(&environment_dir, true).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(err.to_string().contains("unknown environment provider"));
@ -528,7 +698,7 @@ mode = "cidr_allow_list"
)
.unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
let err = EnvironmentStore::load(&environment_dir, true).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(
@ -553,7 +723,7 @@ path = "Dockerfile"
)
.unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
let err = EnvironmentStore::load(&environment_dir, true).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(err.to_string().contains("Dockerfile"));
@ -562,10 +732,14 @@ path = "Dockerfile"
#[tokio::test]
async fn create_conflict_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let store = EnvironmentStore::load(dir.path().join("environments"), true).unwrap();
store
.create(draft("docker", EnvironmentProvider::Docker))
.await
.unwrap();
let err = store
.create(draft("local", EnvironmentProvider::Local))
.create(draft("docker", EnvironmentProvider::Docker))
.await
.unwrap_err();
@ -575,7 +749,7 @@ path = "Dockerfile"
#[tokio::test]
async fn create_invalid_settings_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let store = EnvironmentStore::load(dir.path().join("environments"), true).unwrap();
let mut settings = settings(EnvironmentProvider::Local);
settings.network.mode = EnvironmentNetworkMode::Block;
@ -597,8 +771,11 @@ path = "Dockerfile"
#[tokio::test]
async fn replace_stale_revision_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let current = store.get(&EnvironmentId::new("local").unwrap()).unwrap();
let store = EnvironmentStore::load(dir.path().join("environments"), true).unwrap();
let current = store
.create(draft("docker", EnvironmentProvider::Docker))
.await
.unwrap();
let stale = EnvironmentRevision::from_bytes(b"stale");
let err = store
@ -610,24 +787,26 @@ path = "Dockerfile"
}
#[tokio::test]
async fn default_delete_is_rejected() {
async fn default_is_deletable() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let environment_dir = dir.path().join("environments");
super::seed_environments(&environment_dir).unwrap();
let store = EnvironmentStore::load(&environment_dir, true).unwrap();
let default = store.get(&EnvironmentId::new("default").unwrap()).unwrap();
let err = store
.delete(&default.id, &default.revision)
.await
.unwrap_err();
// `default` is an ordinary environment: deleting it succeeds and removes
// the run fallback rather than being protected.
store.delete(&default.id, &default.revision).await.unwrap();
assert!(matches!(err, EnvironmentStoreError::Protected { .. }));
assert!(store.get(&default.id).is_none());
assert!(!environment_dir.join("default.toml").exists());
}
#[tokio::test]
async fn delete_success_removes_file_and_memory_entry() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap();
let store = EnvironmentStore::load(&environment_dir, true).unwrap();
let created = store
.create(draft("tmp", EnvironmentProvider::Local))
.await
@ -642,7 +821,7 @@ path = "Dockerfile"
#[tokio::test]
async fn canonical_revision_changes_when_persisted_bytes_change() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let store = EnvironmentStore::load(dir.path().join("environments"), true).unwrap();
let created = store
.create(draft("rev", EnvironmentProvider::Local))
.await
@ -667,7 +846,7 @@ path = "Dockerfile"
fs::write(dir.path().join("Dockerfile"), "FROM alpine\n")
.await
.unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let store = EnvironmentStore::load(dir.path().join("environments"), true).unwrap();
let mut settings = settings(EnvironmentProvider::Docker);
settings.image.dockerfile = Some(DockerfileSource::Path {
path: "Dockerfile".to_string(),

View file

@ -491,21 +491,11 @@ pub fn write_sandbox_settings(
selection: InstallSandboxSelection,
allow_local: bool,
) -> Result<()> {
let provider = match selection {
InstallSandboxSelection::Docker => "docker",
InstallSandboxSelection::Daytona => "daytona",
};
let root = root_table_mut(doc)?;
let run = ensure_table(root, "run")?;
let environment = ensure_table(run, "environment")?;
environment.insert("id".to_string(), toml::Value::String("default".to_string()));
let environments = ensure_table(root, "environments")?;
let default = ensure_table(environments, "default")?;
default.insert(
"provider".to_string(),
toml::Value::String(provider.to_string()),
);
let server = ensure_table(root, "server")?;
write_sandbox_provider_policy(server, selection, allow_local)?;
Ok(())
@ -1431,7 +1421,7 @@ stale = "remove-me"
}
#[test]
fn write_sandbox_settings_records_docker_provider() {
fn write_sandbox_settings_records_run_default_without_environment_catalog() {
let mut doc = toml::Value::Table(toml::Table::default());
write_sandbox_settings(&mut doc, InstallSandboxSelection::Docker, true)
.expect("docker sandbox selection should succeed");
@ -1445,22 +1435,14 @@ stale = "remove-me"
.and_then(toml::Value::as_str),
Some("default")
);
assert_eq!(
doc.get("environments")
.and_then(toml::Value::as_table)
.and_then(|envs| envs.get("default"))
.and_then(toml::Value::as_table)
.and_then(|env| env.get("provider"))
.and_then(toml::Value::as_str),
Some("docker")
);
assert!(doc.get("environments").is_none());
assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(true));
assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(true));
assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(false));
}
#[test]
fn write_sandbox_settings_records_daytona_provider() {
fn write_sandbox_settings_records_daytona_policy_without_environment_catalog() {
let mut doc = toml::Value::Table(toml::Table::default());
write_sandbox_settings(&mut doc, InstallSandboxSelection::Daytona, true)
.expect("daytona sandbox selection should succeed");
@ -1474,15 +1456,7 @@ stale = "remove-me"
.and_then(toml::Value::as_str),
Some("default")
);
assert_eq!(
doc.get("environments")
.and_then(toml::Value::as_table)
.and_then(|envs| envs.get("default"))
.and_then(toml::Value::as_table)
.and_then(|env| env.get("provider"))
.and_then(toml::Value::as_str),
Some("daytona")
);
assert!(doc.get("environments").is_none());
assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(true));
assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(false));
assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(true));

View file

@ -16,21 +16,12 @@ use serde::{Deserialize, Serialize};
pub struct DaytonaSettings {
pub auto_stop_interval: Option<i32>,
pub labels: Option<HashMap<String, String>>,
#[serde(default)]
pub volumes: Vec<DaytonaVolumeMount>,
pub snapshot: Option<DaytonaSnapshotSettings>,
pub network: Option<DaytonaNetwork>,
#[serde(default)]
pub skip_clone: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct DaytonaVolumeMount {
pub volume_id: String,
pub mount_path: String,
pub subpath: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum DaytonaNetwork {
Block,

View file

@ -54,7 +54,7 @@ pub const REQUIRED_DAYTONA_PERMISSIONS: &[Permissions] = &[
pub use crate::config::{
DaytonaNetwork, DaytonaSettings as DaytonaConfig,
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DaytonaVolumeMount, DockerfileSource,
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DockerfileSource,
};
pub mod snapshot_identity {
@ -173,20 +173,6 @@ fn perm_wire_str(permission: Permissions) -> &'static str {
}
}
fn volume_mounts_for_create(config: &DaytonaConfig) -> Option<Vec<daytona_sdk::VolumeMount>> {
(!config.volumes.is_empty()).then(|| {
config
.volumes
.iter()
.map(|volume| daytona_sdk::VolumeMount {
volume_id: volume.volume_id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect()
})
}
/// Build a [`daytona_sdk::Client`], forwarding an optional API key from the
/// vault so the SDK doesn't have to rely on `DAYTONA_API_KEY` being in the
/// process environment.
@ -555,7 +541,6 @@ impl DaytonaSandbox {
ephemeral: Some(false),
network_block_all,
network_allow_list,
volumes: volume_mounts_for_create(&self.config),
..Default::default()
}
}
@ -2368,45 +2353,6 @@ mod tests {
assert!(config.snapshot.is_none());
assert!(config.auto_stop_interval.is_none());
assert!(config.labels.is_none());
assert!(config.volumes.is_empty());
}
#[test]
fn parses_volume_mounts_from_config() {
let config: DaytonaConfig = toml::from_str(
r#"
[[volumes]]
volume_id = "vol_auth"
mount_path = "/home/daytona/.config"
subpath = "agents"
"#,
)
.expect("volume config should parse");
assert_eq!(config.volumes, vec![DaytonaVolumeMount {
volume_id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}]);
}
#[test]
fn maps_volume_mounts_to_daytona_create_params() {
let config = DaytonaConfig {
volumes: vec![DaytonaVolumeMount {
volume_id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}],
..DaytonaConfig::default()
};
let volumes = volume_mounts_for_create(&config).expect("volumes should map");
assert_eq!(volumes.len(), 1);
assert_eq!(volumes[0].volume_id, "vol_auth");
assert_eq!(volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(volumes[0].subpath.as_deref(), Some("agents"));
}
#[test]

View file

@ -9,8 +9,7 @@ use fabro_types::settings::run::{EnvironmentNetworkMode, RunEnvironmentSettings}
#[cfg(feature = "daytona")]
use crate::config::{
DaytonaNetwork, DaytonaSnapshotSettings, DaytonaVolumeMount,
DockerfileSource as SandboxDockerfileSource,
DaytonaNetwork, DaytonaSnapshotSettings, DockerfileSource as SandboxDockerfileSource,
};
#[cfg(feature = "daytona")]
use crate::daytona::DaytonaConfig;
@ -29,15 +28,6 @@ pub fn daytona_config_from_environment(
.auto_stop
.map(|duration| duration_to_minutes_i32(duration.as_std())),
labels: (!settings.labels.is_empty()).then(|| settings.labels.clone()),
volumes: settings
.volumes
.iter()
.map(|volume| DaytonaVolumeMount {
volume_id: volume.id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect(),
snapshot: settings
.image
.dockerfile

View file

@ -1671,11 +1671,11 @@ client_id = "github-client-id"
let [first, second, third] = <[RequestAuthContext; 3]>::try_from(contexts)
.expect("expected three captured auth contexts");
assert_eq!(first.auth_status, AuthStatus::Authenticated);
assert_eq!(first.principal.display(), "octocat");
assert_eq!(first.principal.expect("principal").display(), "octocat");
assert_eq!(second.auth_status, AuthStatus::Authenticated);
assert_eq!(second.principal.display(), "octocat");
assert_eq!(second.principal.expect("principal").display(), "octocat");
assert_eq!(third.auth_status, AuthStatus::Authenticated);
assert_eq!(third.principal.display(), "octocat");
assert_eq!(third.principal.expect("principal").display(), "octocat");
}
#[tokio::test]
@ -2080,7 +2080,10 @@ client_id = "github-client-id"
let contexts = captured.lock().expect("captured auth contexts").clone();
assert_eq!(contexts[0].auth_status, AuthStatus::Authenticated);
assert_eq!(contexts[0].principal.display(), "octocat");
assert_eq!(
contexts[0].principal.as_ref().expect("principal").display(),
"octocat"
);
assert_eq!(contexts[1].auth_status, AuthStatus::Invalid);
assert_eq!(
contexts[1].auth_error_code,
@ -2273,8 +2276,11 @@ client_id = "github-client-id"
let contexts = captured.lock().expect("captured auth contexts").clone();
assert_eq!(contexts[0].auth_status, AuthStatus::Authenticated);
assert_eq!(contexts[0].principal.display(), "octocat");
let Principal::User(user) = &contexts[0].principal else {
assert_eq!(
contexts[0].principal.as_ref().expect("principal").display(),
"octocat"
);
let Some(Principal::User(user)) = &contexts[0].principal else {
panic!("expected user principal");
};
assert_eq!(

View file

@ -1081,7 +1081,7 @@ fn ts(s: &str) -> DateTime<Utc> {
mod runs {
use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::{LazyLock, OnceLock};
use std::time::Duration;
use fabro_api::types::*;
@ -1092,13 +1092,22 @@ mod runs {
};
use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace};
use fabro_types::{
PendingReason, RepositoryRef, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin,
RunSize, RunTimestamps, StageId, WorkflowRef, WorkflowSettings,
AuthMethod, IdpIdentity, PendingReason, Principal, RepositoryRef, RunBillingSummary, RunId,
RunLifecycle, RunLinks, RunOrigin, RunSize, RunTimestamps, StageId, WorkflowRef,
WorkflowSettings,
};
use super::ts;
use crate::server::run_stage_from_stage_id;
static DEMO_PRINCIPAL: LazyLock<Principal> = LazyLock::new(|| {
Principal::user(
IdpIdentity::new("fabro:demo", "demo").expect("demo identity should be valid"),
"demo".to_string(),
AuthMethod::DevToken,
)
});
fn labels(entries: &[(&str, &str)]) -> HashMap<String, String> {
entries
.iter()
@ -1171,7 +1180,7 @@ mod runs {
repo_origin_url,
source_directory.as_deref(),
)),
created_by: None,
created_by: DEMO_PRINCIPAL.clone(),
origin: RunOrigin::default(),
labels: labels(entries),
lifecycle: RunLifecycle {

View file

@ -32,6 +32,7 @@ use fabro_static::EnvVars;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
use fabro_util::version::FABRO_VERSION;
@ -464,6 +465,13 @@ impl InstallSandboxState {
InstallSandboxProviderState::Daytona { .. } => InstallSandboxSelection::Daytona,
}
}
fn to_environment_provider(&self) -> EnvironmentProvider {
match &self.provider {
InstallSandboxProviderState::Docker => EnvironmentProvider::Docker,
InstallSandboxProviderState::Daytona { .. } => EnvironmentProvider::Daytona,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -742,6 +750,15 @@ fn install_listen_config(bind: &Bind) -> InstallListenConfig {
}
}
/// The environments directory sits next to the active settings file, matching
/// the server's own `environment_dir_for_active_config` derivation.
fn install_environment_dir(config_path: &Path) -> PathBuf {
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("environments")
}
async fn health() -> Response {
Json(serde_json::json!({
"status": "ok",
@ -1735,6 +1752,17 @@ async fn post_install_finish(
.into_response();
}
// Seed the default environment next to the settings file. The server does
// not seed on startup, so install is the only place the default is written;
// existing files are preserved, so re-running install never clobbers edits.
let environment_dir = install_environment_dir(state.config_path.as_ref());
if let Err(err) = fabro_environment::seed_default_environment(
&environment_dir,
sandbox.to_environment_provider(),
) {
warn!(error = %err, "failed to seed default environment after install");
}
if let Ok(settings) = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml) {
if let Err(err) = write_artifact_store_metadata(&settings, state.storage_dir.as_ref()).await
{

View file

@ -19,7 +19,7 @@ use crate::worker_token::{self, WORKER_TOKEN_KID, WorkerScopeSet};
#[derive(Clone, Debug)]
pub(crate) struct RequestAuthContext {
pub principal: Principal,
pub principal: Option<Principal>,
pub auth_status: AuthStatus,
pub auth_error_code: Option<AuthErrorCode>,
pub user_profile: Option<UserProfile>,
@ -76,7 +76,7 @@ impl RequestAuthContext {
#[must_use]
pub(crate) fn initial() -> Self {
Self {
principal: Principal::Anonymous,
principal: None,
auth_status: AuthStatus::Missing,
auth_error_code: None,
user_profile: None,
@ -87,7 +87,7 @@ impl RequestAuthContext {
#[must_use]
pub(crate) fn authenticated(principal: Principal, user_profile: Option<UserProfile>) -> Self {
Self {
principal,
principal: Some(principal),
auth_status: AuthStatus::Authenticated,
auth_error_code: None,
user_profile,
@ -98,7 +98,7 @@ impl RequestAuthContext {
#[must_use]
pub(crate) fn authenticated_worker(run_id: RunId, scopes: WorkerScopeSet) -> Self {
Self {
principal: Principal::Worker { run_id },
principal: Some(Principal::Worker { run_id }),
auth_status: AuthStatus::Authenticated,
auth_error_code: None,
user_profile: None,
@ -125,7 +125,7 @@ impl RequestAuthContext {
#[must_use]
pub(crate) fn rejected(status: AuthStatus, code: Option<AuthErrorCode>) -> Self {
Self {
principal: Principal::Anonymous,
principal: None,
auth_status: status,
auth_error_code: code,
user_profile: None,
@ -148,7 +148,7 @@ impl AuthStatus {
#[derive(Clone, Debug)]
pub(crate) struct RequestAuthLogContext {
pub principal: Principal,
pub principal: Option<Principal>,
pub auth_status: AuthStatus,
pub auth_error_code: Option<AuthErrorCode>,
}
@ -172,25 +172,13 @@ impl AuthContextSlot {
pub(crate) fn log_snapshot(&self) -> RequestAuthLogContext {
let context = self.0.lock().expect("auth context lock poisoned");
RequestAuthLogContext {
principal: principal_without_log_unused_fields(&context.principal),
principal: context.principal.clone(),
auth_status: context.auth_status,
auth_error_code: context.auth_error_code,
}
}
}
fn principal_without_log_unused_fields(principal: &Principal) -> Principal {
match principal {
Principal::User(user) => Principal::User(UserPrincipal {
identity: user.identity.clone(),
login: user.login.clone(),
auth_method: user.auth_method,
avatar_url: None,
}),
principal => principal.clone(),
}
}
impl<S: Send + Sync> FromRequestParts<S> for RequestAuth {
type Rejection = Infallible;
@ -402,8 +390,8 @@ fn auth_slot_from_parts(parts: &Parts) -> AuthContextSlot {
pub(crate) fn require_user(slot: &AuthContextSlot) -> Result<UserPrincipal, ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(user) => Ok(user.clone()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::User(user)) => Ok(user.clone()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
@ -412,7 +400,7 @@ pub(crate) fn require_authenticated_user(
) -> Result<AuthenticatedUser, ApiError> {
let context = slot.snapshot();
match context.principal {
Principal::User(principal) => {
Some(Principal::User(principal)) => {
let Some(profile) = context.user_profile else {
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
@ -421,19 +409,19 @@ pub(crate) fn require_authenticated_user(
};
Ok(AuthenticatedUser { principal, profile })
}
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
pub(crate) fn require_run_management_actor(slot: &AuthContextSlot) -> Result<Principal, ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(user) => Ok(Principal::User(user.clone())),
Principal::Worker { run_id } if context.worker_scopes.has_agent_run_tools() => {
Some(Principal::User(user)) => Ok(Principal::User(user.clone())),
Some(Principal::Worker { run_id }) if context.worker_scopes.has_agent_run_tools() => {
Ok(Principal::Worker { run_id: *run_id })
}
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::Worker { .. }) => Err(ApiError::forbidden()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
@ -443,19 +431,19 @@ fn require_worker_or_user_for_run(
) -> Result<(), ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(_) => Ok(()),
Principal::Worker { run_id } if run_id == route_run_id => Ok(()),
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::User(_)) => Ok(()),
Some(Principal::Worker { run_id }) if run_id == route_run_id => Ok(()),
Some(Principal::Worker { .. }) => Err(ApiError::forbidden()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
fn require_worker_for_run(slot: &AuthContextSlot, route_run_id: &RunId) -> Result<(), ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::Worker { run_id } if run_id == route_run_id => Ok(()),
Principal::Worker { .. } | Principal::User(_) => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::Worker { run_id }) if run_id == route_run_id => Ok(()),
Some(Principal::Worker { .. } | Principal::User(_)) => Err(ApiError::forbidden()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
@ -465,14 +453,14 @@ fn require_run_management_target(
) -> Result<Principal, ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(user) => Ok(Principal::User(user.clone())),
Principal::Worker { run_id }
Some(Principal::User(user)) => Ok(Principal::User(user.clone())),
Some(Principal::Worker { run_id })
if run_id == route_run_id || context.worker_scopes.has_agent_run_tools() =>
{
Ok(Principal::Worker { run_id: *run_id })
}
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
Some(Principal::Worker { .. }) => Err(ApiError::forbidden()),
None | Some(_) => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
@ -687,7 +675,7 @@ mod tests {
let context = classify_request(&request, state.as_ref());
assert_eq!(context.auth_status, AuthStatus::Authenticated);
assert!(matches!(context.principal, Principal::User(_)));
assert!(matches!(context.principal, Some(Principal::User(_))));
assert!(context.user_profile.is_some());
}
@ -727,7 +715,7 @@ mod tests {
let context = classify_request(&request, state.as_ref());
assert_eq!(context.auth_status, AuthStatus::Authenticated);
assert_eq!(context.principal, Principal::Worker { run_id });
assert_eq!(context.principal, Some(Principal::Worker { run_id }));
assert!(!context.worker_scopes.has_agent_run_tools());
}
@ -746,7 +734,7 @@ mod tests {
let context = classify_request(&request, state.as_ref());
assert_eq!(context.auth_status, AuthStatus::Authenticated);
assert_eq!(context.principal, Principal::Worker { run_id });
assert_eq!(context.principal, Some(Principal::Worker { run_id }));
assert!(context.worker_scopes.has_agent_run_tools());
}
@ -825,7 +813,7 @@ mod tests {
assert_eq!(context.auth_status, AuthStatus::Missing);
assert_eq!(context.auth_error_code, None);
assert_eq!(context.principal, Principal::Anonymous);
assert_eq!(context.principal, None);
}
#[test]

View file

@ -1717,7 +1717,7 @@ fn count_flags(data: &[FileDiff]) -> (u64, u64, u64, u64) {
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use fabro_types::{CommandTermination, RunId};
use fabro_types::{CommandTermination, RunId, test_support};
use tokio::time::{Duration, sleep};
use super::*;
@ -2389,7 +2389,7 @@ index 1111111..2222222 160000
automation: None,
source_directory: None,
labels: HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -27,7 +27,9 @@ use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{EnvironmentProvider, RunGoal, RunNamespace};
use fabro_types::{ManifestPath, RunId, SandboxProviderKind, ServerSettings, WorkflowSettings};
use fabro_types::{
ManifestPath, RunId, RunProvenance, SandboxProviderKind, ServerSettings, WorkflowSettings,
};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
@ -194,6 +196,7 @@ pub(crate) fn validate_prepared_manifest(
pub(crate) fn create_run_input(
prepared: PreparedManifest,
configured_providers: Vec<ProviderId>,
provenance: RunProvenance,
web_url: Option<String>,
) -> CreateRunInput {
CreateRunInput {
@ -210,7 +213,7 @@ pub(crate) fn create_run_input(
git: prepared.git,
fork_source_ref: None,
parent_id: prepared.parent_id,
provenance: None,
provenance,
configured_providers,
web_url,
}
@ -672,9 +675,6 @@ fn environment_capability_warnings(resolved_run: &RunNamespace) -> Vec<String> {
{
warnings.push("local provider ignores resource limits".to_string());
}
if !environment.volumes.is_empty() {
warnings.push("local provider ignores volume mounts".to_string());
}
if !environment.labels.is_empty() {
warnings.push("local provider ignores labels".to_string());
}
@ -686,9 +686,6 @@ fn environment_capability_warnings(resolved_run: &RunNamespace) -> Vec<String> {
if environment.resources.disk.is_some() {
warnings.push("docker provider ignores disk resource limits".to_string());
}
if !environment.volumes.is_empty() {
warnings.push("docker provider ignores volume mounts".to_string());
}
if !environment.labels.is_empty() {
warnings.push("docker provider ignores labels".to_string());
}
@ -1526,27 +1523,6 @@ enabled = {clone_enabled}
(prepared, resolved)
}
#[test]
fn runtime_daytona_config_preserves_volume_mounts() {
let settings = fabro_types::settings::run::RunEnvironmentSettings::from_environment(
"cloud".to_string(),
fabro_types::settings::run::EnvironmentSettings {
volumes: vec![fabro_types::settings::run::EnvironmentVolumeSettings {
id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}],
..fabro_types::settings::run::EnvironmentSettings::default()
},
);
let config = daytona_config_from_environment(&settings, false);
assert_eq!(config.volumes.len(), 1);
assert_eq!(config.volumes[0].volume_id, "vol_auth");
assert_eq!(config.volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(config.volumes[0].subpath.as_deref(), Some("agents"));
}
#[test]
fn prepare_manifest_accepts_project_environment_catalog_definitions() {
let mut manifest = minimal_manifest();

View file

@ -1862,7 +1862,10 @@ async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Resp
let status = response.status().as_u16();
let latency_ms = start.elapsed().as_millis();
let auth_context = auth_slot.log_snapshot();
let principal_kind = auth_context.principal.kind();
let principal_kind = auth_context
.principal
.as_ref()
.map_or("none", Principal::kind);
let auth_status = auth_context.auth_status.as_str();
macro_rules! emit_http_log {
@ -1900,27 +1903,27 @@ async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Resp
macro_rules! emit_principal_http_log {
($level:ident) => {{
match &auth_context.principal {
Principal::User(user) => emit_http_log!(
Some(Principal::User(user)) => emit_http_log!(
$level,
user_auth_method = user.auth_method.as_str(),
idp_issuer = user.identity.issuer(),
idp_subject = user.identity.subject(),
login = user.login.as_str(),
),
Principal::Worker { run_id } => {
Some(Principal::Worker { run_id }) => {
emit_http_log!($level, run_id = run_id.to_string().as_str(),)
}
Principal::Webhook { delivery_id } => {
Some(Principal::Webhook { delivery_id }) => {
emit_http_log!($level, delivery_id = delivery_id.as_str(),)
}
Principal::Slack {
Some(Principal::Slack {
team_id, user_id, ..
} => emit_http_log!(
}) => emit_http_log!(
$level,
team_id = team_id.as_str(),
user_id = user_id.as_str(),
),
Principal::Agent { .. } | Principal::System { .. } | Principal::Anonymous => {
None | Some(Principal::Agent { .. } | Principal::System { .. }) => {
emit_http_log!($level)
}
}
@ -2305,8 +2308,15 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
.context("load automations")?,
);
let environment_dir = environment_dir_for_active_config(&active_config_path);
let local_provider_enabled = resolved_settings
.server_settings
.server
.sandbox
.providers
.local
.enabled;
let environment_store = Arc::new(
EnvironmentStore::load_or_seed(environment_dir)
EnvironmentStore::load(environment_dir, local_provider_enabled)
.map_err(anyhow::Error::new)
.context("load environments")?,
);

View file

@ -7,7 +7,7 @@ use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
EnvironmentSettings, EnvironmentVolumeSettings,
EnvironmentSettings,
};
use serde::de::IgnoredAny;
use serde::{Deserialize, Serialize};
@ -39,7 +39,6 @@ struct CreateEnvironmentRequest {
network: EnvironmentNetworkSettings,
lifecycle: EnvironmentLifecycleSettings,
labels: HashMap<String, String>,
volumes: Vec<EnvironmentVolumeSettings>,
env: HashMap<String, InterpString>,
}
@ -52,7 +51,6 @@ struct ReplaceEnvironmentRequest {
network: EnvironmentNetworkSettings,
lifecycle: EnvironmentLifecycleSettings,
labels: HashMap<String, String>,
volumes: Vec<EnvironmentVolumeSettings>,
env: HashMap<String, InterpString>,
}
@ -88,7 +86,6 @@ impl CreateEnvironmentRequest {
network: self.network,
lifecycle: self.lifecycle,
labels: self.labels,
volumes: self.volumes,
env: self.env,
},
})
@ -104,7 +101,6 @@ impl ReplaceEnvironmentRequest {
network: self.network,
lifecycle: self.lifecycle,
labels: self.labels,
volumes: self.volumes,
env: self.env,
})
}
@ -240,9 +236,9 @@ impl From<EnvironmentStoreError> for ApiError {
StatusCode::CONFLICT,
format!("environment revision is stale: {id}"),
),
EnvironmentStoreError::Protected { id } => Self::new(
EnvironmentStoreError::Reserved { id } => Self::new(
StatusCode::CONFLICT,
format!("environment is protected and cannot be deleted: {id}"),
format!("environment is reserved and cannot be modified: {id}"),
),
EnvironmentStoreError::Validation { source } => {
Self::new(StatusCode::UNPROCESSABLE_ENTITY, source.to_string())

View file

@ -535,7 +535,7 @@ mod stage_events_tests {
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use fabro_store::EventPayload;
use fabro_types::{Graph, RunId, WorkflowSettings};
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
use fabro_workflow::event as workflow_event;
use http_body_util::BodyExt;
use serde_json::json;
@ -570,7 +570,7 @@ mod stage_events_tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -894,7 +894,7 @@ async fn retry_run(
let input = operations::RetryRunInput {
source_run_id: id,
new_run_id,
provenance: Some(run_provenance(&headers, &actor)),
provenance: run_provenance(&headers, &actor),
web_url: state.run_web_url(&new_run_id),
};
match Box::pin(operations::retry_run(&state.store, &input)).await {

View file

@ -850,7 +850,7 @@ mod tests {
use fabro_types::run_event::AgentMessageProps;
use fabro_types::{
BilledTokenCounts, EventEnvelope, Graph, PairMessageId, RunEvent, StageId,
WorkflowSettings, fixtures,
WorkflowSettings, fixtures, test_support,
};
use fabro_workflow::event as workflow_event;
use tower::ServiceExt;
@ -1024,7 +1024,7 @@ mod tests {
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -687,13 +687,14 @@ pub(crate) async fn create_run_from_manifest(
.as_ref()
.map(LlmClientResult::provider_ids)
.unwrap_or_default();
let provenance = run_provenance(&headers, &actor);
let mut create_input = run_manifest::create_run_input(
prepared.clone(),
ready_provider_ids.clone(),
provenance,
web_url.clone(),
);
create_input.run_id = Some(run_id);
create_input.provenance = Some(run_provenance(&headers, &actor));
create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes);
create_input.automation = automation;
@ -864,7 +865,7 @@ pub(super) fn run_provenance(headers: &HeaderMap, subject: &Principal) -> RunPro
version: FABRO_VERSION.to_string(),
}),
client: run_client_provenance(headers),
subject: Some(subject.clone()),
subject: subject.clone(),
}
}

View file

@ -1299,7 +1299,7 @@ FABRO_PROC_NET_TCP /proc/net/tcp6
mod retrieve_sandbox_tests {
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use fabro_types::{Graph, RunId, WorkflowSettings};
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
use serde_json::{Value, json};
use tower::ServiceExt;
@ -1339,6 +1339,7 @@ mod retrieve_sandbox_tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"run_dir": "/tmp/test",
"provenance": test_support::test_run_provenance(),
},
}),
run_id,

View file

@ -1506,6 +1506,7 @@ mod tests {
use fabro_agent::config::ToolAccess;
use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
use fabro_llm::types::{ToolCall, ToolDefinition};
use fabro_types::test_support;
use super::*;
@ -1700,7 +1701,7 @@ mod tests {
automation: None,
source_directory: None,
labels: HashMap::default(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,

View file

@ -23,13 +23,14 @@ use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest, TokenCounts
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, ModelRef, ProviderId, ReasoningEffort, Speed};
use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{
AgentBackend, AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph,
InterviewQuestionRecord, Node, Outcome, QuestionType, RunBlobId, RunId, RunSpec,
SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory,
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind,
WorkflowSettings, fixtures,
WorkflowSettings, fixtures, test_support,
};
use fabro_util::check_report::CheckStatus;
use fabro_workflow::records::CheckpointExt;
@ -1226,13 +1227,16 @@ id = "missing"
#[test]
fn system_sandbox_provider_uses_manifest_defaults() {
let temp = tempfile::tempdir().unwrap();
let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments"))
.expect("environment store should seed");
let environment_dir = temp.path().join("environments");
fabro_environment::seed_default_environment(&environment_dir, EnvironmentProvider::Daytona)
.expect("seed built-in environments");
let environment_store =
EnvironmentStore::load(&environment_dir, true).expect("environment store should load");
let source = r#"
_version = 1
[run.environment]
id = "daytona"
id = "default"
"#;
let manifest_run_settings = resolve_manifest_run_settings_with_catalog(
&run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))),
@ -1245,8 +1249,8 @@ id = "daytona"
#[test]
fn system_sandbox_provider_defaults_when_manifest_run_settings_do_not_resolve() {
let temp = tempfile::tempdir().unwrap();
let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments"))
.expect("environment store should seed");
let environment_store = EnvironmentStore::load(temp.path().join("environments"), true)
.expect("environment store should load");
let source = r#"
_version = 1
@ -4026,7 +4030,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -4080,7 +4084,7 @@ async fn create_slack_notification_run(
workflow_slug: workflow_slug.map(str::to_string),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -5087,7 +5091,7 @@ async fn list_run_stages_distinguishes_visits() {
workflow_slug: Some("test".to_string()),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -5931,6 +5935,12 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
let (store, artifact_store) = test_store_bundle();
let vault_path = test_secret_store_path();
let server_env_path = vault_path.with_file_name("server.env");
let active_config_path = vault_path.with_file_name("settings.toml");
let environment_dir = active_config_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("environments");
fabro_environment::seed_environments(&environment_dir).expect("test environments should seed");
let config = AppStateConfig {
resolved_settings: resolved_runtime_settings_for_tests(
github_token_settings(),
@ -5947,7 +5957,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
server_secrets: load_test_server_secrets(server_env_path, HashMap::new()),
env_lookup: Arc::new(env_lookup),
github_api_base_url,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
active_config_path,
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
@ -6144,7 +6154,7 @@ async fn create_completed_run_ready_for_pull_request(
source_directory: Some("/tmp/project".to_string()),
git: git.clone(),
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -9947,15 +9957,10 @@ async fn run_tool_worker_token_can_use_client_backend_routes_across_runs() {
.unwrap()
.expect("created run should be cached");
assert_eq!(
cached
.projection
.spec
.provenance
.as_ref()
.and_then(|provenance| provenance.subject.as_ref()),
Some(&Principal::Worker {
cached.projection.spec.provenance.subject,
Principal::Worker {
run_id: parent_run_id,
}),
},
);
let response = app
@ -12314,7 +12319,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
workflow_slug: Some("test".to_string()),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
@ -13066,7 +13071,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
workflow_slug: Some("test".to_string()),
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -238,6 +238,15 @@ impl TestAppStateBuilder {
let active_config_path = self
.active_config_path
.unwrap_or_else(|| vault_path.with_file_name("settings.toml"));
// Production seeds environments at install time, not on startup. Tests
// exercise an installed instance, so seed the built-ins next to the
// settings file before `build_app_state` loads them.
let environment_dir = active_config_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("environments");
fabro_environment::seed_environments(&environment_dir)
.expect("test environments should seed");
build_app_state(AppStateConfig {
resolved_settings: resolved_runtime_settings_for_tests(
self.server_settings,

View file

@ -1358,7 +1358,7 @@ client_id = "github-client-id"
let contexts = captured.lock().expect("captured auth contexts").clone();
assert_eq!(contexts[0].auth_status, AuthStatus::Authenticated);
assert!(matches!(contexts[0].principal, Principal::User(_)));
assert!(matches!(contexts[0].principal, Some(Principal::User(_))));
assert_eq!(contexts[1].auth_status, AuthStatus::Invalid);
assert_eq!(
contexts[1].auth_error_code,

View file

@ -34,7 +34,6 @@ fn environment_settings(provider: &str) -> Value {
"auto_stop": null
},
"labels": {},
"volumes": [],
"env": {}
})
}
@ -160,7 +159,7 @@ async fn list_environments_returns_seeded_catalog_sorted_by_id() {
.expect("list environments should respond");
let body = response_json(response, StatusCode::OK, "GET /api/v1/environments").await;
assert_eq!(body["meta"]["total"], 4);
assert_eq!(body["meta"]["total"], 2);
assert_eq!(
body["data"]
.as_array()
@ -170,7 +169,7 @@ async fn list_environments_returns_seeded_catalog_sorted_by_id() {
.as_str()
.expect("environment should have id"))
.collect::<Vec<_>>(),
vec!["daytona", "default", "docker", "local"]
vec!["default", "local"]
);
}
@ -202,7 +201,7 @@ async fn create_environment_persists_sibling_toml_and_is_visible() {
.await
.expect("list environments should respond");
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
assert_eq!(list["meta"]["total"], 5);
assert_eq!(list["meta"]["total"], 3);
assert!(
list["data"]
.as_array()
@ -407,6 +406,71 @@ async fn duplicate_environment_create_returns_conflict() {
.await;
}
#[tokio::test]
async fn reserved_local_environment_cannot_be_created_or_modified() {
let (app, _temp_dir, _environment_dir) = environment_app();
// `local` is reserved: creating it is rejected with a conflict.
let created = app
.clone()
.oneshot(json_request(
Method::POST,
"/environments",
&environment_body("local", "local"),
))
.await
.expect("reserved create should respond");
response_status(
created,
StatusCode::CONFLICT,
"POST /api/v1/environments local",
)
.await;
// It is synthesized in memory (local provider enabled) and readable.
let local = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/local"))
.await
.expect("get local should respond");
let local = response_json(local, StatusCode::OK, "GET /api/v1/environments/local").await;
let revision = revision_from(&local);
// Replace and delete are rejected even with a valid If-Match.
let replaced = app
.clone()
.oneshot(request_with_if_match(
Method::PUT,
"/environments/local",
&format!("\"{revision}\""),
Some(environment_settings("local")),
))
.await
.expect("reserved replace should respond");
response_status(
replaced,
StatusCode::CONFLICT,
"PUT /api/v1/environments/local",
)
.await;
let deleted = app
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/local",
&format!("\"{revision}\""),
None,
))
.await
.expect("reserved delete should respond");
response_status(
deleted,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/local",
)
.await;
}
#[tokio::test]
async fn invalid_environment_id_and_if_match_return_bad_request() {
let (app, _temp_dir, _environment_dir) = environment_app();
@ -509,7 +573,7 @@ async fn dockerfile_path_over_rest_is_rejected_without_persisting_or_exposing_co
}
#[tokio::test]
async fn delete_environment_removes_non_default_and_default_is_protected() {
async fn delete_environment_removes_non_default_and_default_is_deletable() {
let (app, _temp_dir, environment_dir) = environment_app();
let created = create_environment(&app, "delete-env", "local").await;
let revision = revision_from(&created);
@ -544,13 +608,16 @@ async fn delete_environment_removes_non_default_and_default_is_protected() {
)
.await;
// `default` is an ordinary environment: it can be deleted, which removes the
// run fallback. The server no longer protects it.
let default = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/default"))
.await
.expect("get default environment should respond");
let default = response_json(default, StatusCode::OK, "GET /api/v1/environments/default").await;
let protected = app
let deleted = app
.clone()
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/default",
@ -560,11 +627,23 @@ async fn delete_environment_removes_non_default_and_default_is_protected() {
.await
.expect("delete default environment should respond");
response_status(
protected,
StatusCode::CONFLICT,
deleted,
StatusCode::NO_CONTENT,
"DELETE /api/v1/environments/default",
)
.await;
assert!(!environment_dir.join("default.toml").exists());
let missing_default = app
.oneshot(empty_request(Method::GET, "/environments/default"))
.await
.expect("get deleted default environment should respond");
response_status(
missing_default,
StatusCode::NOT_FOUND,
"GET /api/v1/environments/default after delete",
)
.await;
}
#[tokio::test]

View file

@ -931,14 +931,21 @@ async fn token_install_finish_persists_settings_env_and_vault() {
"settings.toml should contain [run.environment]"
);
assert!(
settings.contains("[environments.default]"),
"settings.toml should contain [environments.default]"
);
assert!(
settings.contains("provider = \"docker\""),
"settings.toml should record explicit docker sandbox provider"
!settings.contains("[environments"),
"settings.toml should not contain environment catalog entries"
);
assert_sandbox_provider_policy(&settings, true, true, false);
let environment_dir = temp_dir.path().join("environments");
let mut environment_files = std::fs::read_dir(&environment_dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
environment_files.sort();
assert_eq!(environment_files, vec!["default.toml"]);
let default_environment =
std::fs::read_to_string(environment_dir.join("default.toml")).unwrap();
assert!(default_environment.contains("provider = \"docker\""));
assert!(default_environment.contains("docker = \"buildpack-deps:noble\""));
let resolved = ServerSettingsBuilder::from_toml(&settings)
.expect("settings should resolve")
.server;
@ -2577,7 +2584,13 @@ async fn sandbox_switching_from_daytona_to_docker_drops_saved_key() {
.await;
let settings = std::fs::read_to_string(&config_path).unwrap();
assert!(settings.contains("provider = \"docker\""));
assert!(
settings.contains("[run.environment]"),
"settings.toml should select the default environment"
);
let default_environment =
std::fs::read_to_string(temp_dir.path().join("environments/default.toml")).unwrap();
assert!(default_environment.contains("provider = \"docker\""));
let vault = Vault::load(Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert_eq!(vault.get("DAYTONA_API_KEY"), None);
}
@ -2691,14 +2704,21 @@ async fn daytona_install_finish_writes_settings_and_vault_secret() {
"settings.toml should contain [run.environment]"
);
assert!(
settings.contains("[environments.default]"),
"settings.toml should contain [environments.default]"
);
assert!(
settings.contains("provider = \"daytona\""),
"settings.toml should record daytona sandbox provider"
!settings.contains("[environments"),
"settings.toml should not contain environment catalog entries"
);
assert_sandbox_provider_policy(&settings, true, false, true);
let environment_dir = temp_dir.path().join("environments");
let mut environment_files = std::fs::read_dir(&environment_dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
environment_files.sort();
assert_eq!(environment_files, vec!["default.toml"]);
let default_environment =
std::fs::read_to_string(environment_dir.join("default.toml")).unwrap();
assert!(default_environment.contains("provider = \"daytona\""));
assert!(default_environment.contains("buildpack-deps:noble"));
let vault = Vault::load(Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));

View file

@ -14,7 +14,7 @@ use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::test_support::test_app_state_with_store;
use fabro_store::{ArtifactStore, Database};
use fabro_types::{Graph, RunId, SandboxProviderKind, WorkflowSettings};
use fabro_types::{Graph, RunId, SandboxProviderKind, WorkflowSettings, test_support};
use fabro_workflow::event as workflow_event;
use fabro_workflow::run_status::SuccessReason;
use object_store::memory::InMemory as MemoryObjectStore;
@ -69,7 +69,7 @@ async fn append_completed_run_with_final_patch(
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,

View file

@ -1,5 +1,6 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_types::settings::run::EnvironmentProvider;
use tower::ServiceExt;
use crate::helpers::{
@ -54,7 +55,7 @@ async fn request_json(
fn daytona_manifest() -> serde_json::Value {
let mut manifest = minimal_manifest_json(MINIMAL_DOT);
manifest["args"] = serde_json::json!({ "environment": "daytona" });
manifest["args"] = serde_json::json!({ "environment": "default" });
manifest
}
@ -69,12 +70,26 @@ enabled = false
)
}
fn daytona_disabled_app() -> (axum::Router, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().expect("daytona disabled test tempdir should be created");
let active_config_path = temp_dir.path().join("settings.toml");
let environment_dir = temp_dir.path().join("environments");
fabro_environment::seed_default_environment(&environment_dir, EnvironmentProvider::Daytona)
.expect("daytona default environment should seed");
let settings = daytona_disabled_settings();
let state = fabro_server::test_support::TestAppStateBuilder::new()
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
.active_config_path(active_config_path)
.build();
(
fabro_server::test_support::build_test_router(state),
temp_dir,
)
}
#[tokio::test]
async fn create_run_rejects_disabled_sandbox_provider() {
let app = fabro_server::test_support::build_test_router(test_app_state_with_options(
daytona_disabled_settings(),
5,
));
let (app, _temp_dir) = daytona_disabled_app();
let request = Request::builder()
.method("POST")
@ -97,10 +112,7 @@ async fn create_run_rejects_disabled_sandbox_provider() {
#[tokio::test]
async fn preflight_reports_disabled_sandbox_provider() {
let app = fabro_server::test_support::build_test_router(test_app_state_with_options(
daytona_disabled_settings(),
5,
));
let (app, _temp_dir) = daytona_disabled_app();
let request = Request::builder()
.method("POST")

View file

@ -32,6 +32,7 @@ futures.workspace = true
uuid.workspace = true
[dev-dependencies]
fabro-types = { path = "../fabro-types", features = ["test-support"] }
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"
ulid.workspace = true

View file

@ -922,11 +922,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
})
.map(|(_, record)| record.question.clone());
let models = run_models(state);
let created_by = state
.spec
.provenance
.as_ref()
.and_then(|provenance| provenance.subject.clone());
let created_by = state.spec.provenance.subject.clone();
let source_directory = state.spec.source_directory.clone();
let repo_origin_url = state.spec.git.as_ref().map(|git| git.origin_url.clone());
let start_time = state.start.as_ref().map(|start| start.start_time);
@ -1276,7 +1272,7 @@ mod tests {
StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning,
StageModelUsage, StageOutcome, StageState, SubAgentStatus, SuccessReason, WorkflowSettings,
first_event_seq, fixtures,
first_event_seq, fixtures, test_support,
};
use serde_json::json;
@ -1358,7 +1354,7 @@ mod tests {
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,
@ -1439,7 +1435,7 @@ mod tests {
}
#[test]
fn legacy_run_created_projects_retried_from_none() {
fn run_created_without_retried_from_projects_retried_from_none() {
let event = test_raw_event(
1,
"run.created",
@ -1447,7 +1443,8 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
);
@ -1475,7 +1472,8 @@ mod tests {
"graph": Graph::new("test"),
"automation": automation,
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
);
@ -1498,7 +1496,8 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
)])
@ -1520,7 +1519,8 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
)])
@ -1597,7 +1597,8 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
),
@ -1822,7 +1823,7 @@ mod tests {
"repo_origin_url": null,
"base_branch": null,
"labels": {},
"provenance": null,
"provenance": test_support::test_run_provenance(),
"manifest_blob": null,
"definition_blob": null,
"git": null,
@ -2851,7 +2852,7 @@ mod tests {
source_directory: Some("/tmp/repo".to_string()),
git: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -2877,7 +2878,7 @@ mod tests {
source_directory: Some("/tmp/repo".to_string()),
git: None,
labels: HashMap::new(),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
@ -2916,7 +2917,8 @@ mod tests {
"attrs": { "goal": { "String": "Goal title" } }
},
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
);
@ -2930,7 +2932,7 @@ mod tests {
}
#[test]
fn legacy_run_created_without_title_infers_projection_title() {
fn run_created_without_title_infers_projection_title() {
let event = test_raw_event(
1,
"run.created",
@ -2943,7 +2945,8 @@ mod tests {
"attrs": { "goal": { "String": "## Plan: Legacy title\n\nDetails" } }
},
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
);
@ -2972,7 +2975,8 @@ mod tests {
"attrs": { "goal": { "String": "Goal title" } }
},
"labels": {},
"run_dir": "/tmp/run"
"run_dir": "/tmp/run",
"provenance": test_support::test_run_provenance()
}),
None,
),
@ -3016,6 +3020,7 @@ mod tests {
"labels": {},
"run_dir": "/tmp/run",
"source_directory": "/tmp/run",
"provenance": test_support::test_run_provenance(),
"manifest_blob": manifest_blob
}
}))

View file

@ -472,7 +472,7 @@ mod tests {
use chrono::{DateTime, Utc};
use fabro_types::{
AttrValue, FailureReason, Graph, RunControlAction, RunSpec, RunStatus, StageId,
SuccessReason, WorkflowSettings,
SuccessReason, WorkflowSettings, test_support,
};
use futures::TryStreamExt;
use object_store::memory::InMemory;
@ -542,7 +542,7 @@ mod tests {
automation: None,
source_directory: Some(format!("/tmp/{label}")),
labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: Some(fabro_types::GitContext {
@ -601,6 +601,7 @@ mod tests {
"run_dir": format!("/tmp/{label}"),
"git": run_spec.git,
"labels": run_spec.labels,
"provenance": run_spec.provenance,
}),
))
.await
@ -627,6 +628,7 @@ mod tests {
"git": run_spec.git,
"labels": run_spec.labels,
"parent_id": parent_id,
"provenance": run_spec.provenance,
}),
))
.await

View file

@ -667,7 +667,7 @@ mod tests {
use std::sync::Arc;
use std::time::Duration;
use fabro_types::{Graph, RunId, SessionId, StageId, WorkflowSettings};
use fabro_types::{Graph, RunId, SessionId, StageId, WorkflowSettings, test_support};
use object_store::memory::InMemory;
use serde_json::json;
@ -723,6 +723,7 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"run_dir": "/tmp/test",
"provenance": test_support::test_run_provenance(),
},
}),
run_id,

View file

@ -8,7 +8,7 @@ use fabro_types::{
BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord,
QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime,
RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord,
WorkflowSettings, first_event_seq, fixtures,
WorkflowSettings, first_event_seq, fixtures, test_support,
};
use serde_json::json;
@ -22,7 +22,7 @@ fn sample_run_spec() -> RunSpec {
automation: None,
source_directory: Some("/tmp/project".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: Some(fabro_types::GitContext {

View file

@ -16,6 +16,7 @@ workspace = true
assert_cmd = "2"
axum = { workspace = true }
fabro-config = { path = "../fabro-config" }
fabro-environment.workspace = true
fabro-proc = { path = "../fabro-proc" }
fabro-static.workspace = true
fabro-types = { path = "../fabro-types" }

View file

@ -656,6 +656,15 @@ fn home_settings_path(home_dir: &Path) -> PathBuf {
home_dir.join(".fabro/settings.toml")
}
fn seed_settings_environments(settings_path: &Path) {
let environment_dir = settings_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("environments");
fabro_environment::seed_environments(&environment_dir)
.unwrap_or_else(|err| panic!("failed to seed {}: {err}", environment_dir.display()));
}
fn write_settings_file(path: &Path, storage_dir: &Path, rest: &str) {
ensure_parent_dir(path);
std::fs::write(
@ -666,6 +675,7 @@ fn write_settings_file(path: &Path, storage_dir: &Path, rest: &str) {
),
)
.unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display()));
seed_settings_environments(path);
}
fn write_test_server_dev_token(storage_dir: &Path) {
@ -701,6 +711,7 @@ fn write_settings_table(path: &Path, table: &TomlMap<String, TomlValue>) {
}
std::fs::write(path, contents)
.unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display()));
seed_settings_environments(path);
}
fn server_target_from_table(table: &TomlMap<String, TomlValue>) -> Option<String> {
@ -805,6 +816,7 @@ fn sync_home_settings(
ensure_parent_dir(settings_path);
std::fs::write(settings_path, contents)
.unwrap_or_else(|err| panic!("failed to write {}: {err}", settings_path.display()));
seed_settings_environments(settings_path);
return;
}
@ -858,6 +870,7 @@ fn ensure_home_server_auth_methods(
};
if has_explicit_server_auth_methods(&table) {
seed_settings_environments(settings_path);
return;
}
@ -956,6 +969,7 @@ fn ensure_server_running(fabro_bin: &Path, server: &ServerPaths, config_path: &P
ensure_parent_dir(config_path);
std::fs::create_dir_all(&server.storage_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", server.storage_dir.display()));
seed_settings_environments(config_path);
write_test_server_dev_token(&server.storage_dir);
ServerDaemon::remove(&server_runtime_directory(server));
let _ = std::fs::remove_file(&server.socket_path);

View file

@ -29,4 +29,5 @@ tokio.workspace = true
toml.workspace = true
[dev-dependencies]
fabro-types = { path = "../fabro-types", features = ["test-support"] }
tempfile = "3"

View file

@ -307,7 +307,9 @@ fn format_tool_error(err: &anyhow::Error) -> String {
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use fabro_types::{RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef};
use fabro_types::{
RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef, test_support,
};
use super::*;
@ -413,7 +415,7 @@ mod tests {
},
automation: None,
repository: None,
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {

View file

@ -508,7 +508,7 @@ mod tests {
use fabro_api::types;
use fabro_types::{
EventEnvelope, Run, RunLifecycle, RunLinks, RunOrigin, RunProjection, RunStatus,
RunTimestamps, WorkflowRef,
RunTimestamps, WorkflowRef, test_support,
};
use schemars::SchemaGenerator;
use serde_json::json;
@ -902,7 +902,7 @@ mod tests {
},
automation: None,
repository: None,
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {

View file

@ -453,7 +453,7 @@ mod tests {
use chrono::{TimeZone, Utc};
use fabro_types::{
EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunProjection,
RunStatus, RunTimestamps, WorkflowRef,
RunStatus, RunTimestamps, WorkflowRef, test_support,
};
use serde_json::json;
@ -690,7 +690,7 @@ mod tests {
},
automation: None,
repository: None,
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {

View file

@ -293,7 +293,9 @@ mod tests {
use std::collections::HashMap;
use chrono::{TimeZone, Utc};
use fabro_types::{RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef};
use fabro_types::{
RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef, test_support,
};
use super::*;
@ -444,7 +446,7 @@ mod tests {
},
automation: None,
repository: None,
created_by: None,
created_by: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::from([("group".to_string(), group.to_string())]),
lifecycle: RunLifecycle {

View file

@ -33,4 +33,5 @@ ulid.workspace = true
url.workspace = true
[dev-dependencies]
fabro-types = { path = ".", features = ["test-support"] }
tempfile = "3"

View file

@ -44,6 +44,8 @@ pub mod start;
pub mod status;
pub mod steering;
pub mod system_integrations;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
pub mod timing;
pub mod todo;
pub mod transcript;

View file

@ -12,8 +12,9 @@ pub struct UserPrincipal {
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, IntoStaticStr)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Principal {
User(UserPrincipal),
Worker {
@ -39,7 +40,6 @@ pub enum Principal {
System {
system_kind: SystemActorKind,
},
Anonymous,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, IntoStaticStr)]
@ -90,15 +90,7 @@ impl Principal {
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::User(_) => "user",
Self::Worker { .. } => "worker",
Self::Webhook { .. } => "webhook",
Self::Slack { .. } => "slack",
Self::Agent { .. } => "agent",
Self::System { .. } => "system",
Self::Anonymous => "anonymous",
}
self.into()
}
#[must_use]
@ -123,7 +115,6 @@ impl Principal {
} => session_id.clone(),
Self::Agent { .. } => "agent".to_string(),
Self::System { system_kind } => format!("system:{system_kind}"),
Self::Anonymous => "anonymous".to_string(),
}
}
}
@ -291,11 +282,6 @@ mod tests {
});
}
#[test]
fn round_trips_anonymous_variant() {
assert_round_trip(&Principal::Anonymous);
}
#[test]
fn auth_method_as_str_matches_serde() {
assert_eq!(AuthMethod::Github.as_str(), "github");

View file

@ -24,14 +24,13 @@ pub struct RunClientProvenance {
pub version: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<RunServerProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<RunClientProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<Principal>,
pub subject: Principal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -93,8 +92,7 @@ pub struct RunSpec {
pub source_directory: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,
pub provenance: RunProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -933,7 +933,7 @@ mod tests {
use super::*;
use crate::{
AuthMethod, Edge, Graph, IdpIdentity, Node, PendingReason, RunBlobId, WorkflowSettings,
fixtures,
fixtures, test_support,
};
fn user_principal(login: &str) -> Principal {
@ -1017,7 +1017,8 @@ mod tests {
"graph": graph,
"labels": {},
"run_dir": "/tmp/run",
"source_directory": "/tmp/run"
"source_directory": "/tmp/run",
"provenance": test_support::test_run_provenance()
}
});
@ -1038,6 +1039,7 @@ mod tests {
"labels": {},
"run_dir": "/tmp/run",
"source_directory": "/tmp/run",
"provenance": test_support::test_run_provenance(),
"manifest_blob": RunBlobId::new(br#"{"version":1}"#).to_string()
}
});

Some files were not shown because too many files have changed in this diff Show more