mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-29 01:42:21 +00:00
Merge remote-tracking branch 'origin/main' into feat/settings-workflows-nav-section
This commit is contained in:
commit
4abce34ba4
47 changed files with 3686 additions and 196 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1726,6 +1726,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"fabro-automation",
|
||||
"fabro-config",
|
||||
"fabro-environment",
|
||||
"fabro-model",
|
||||
"fabro-types",
|
||||
"openapiv3",
|
||||
|
|
@ -2366,6 +2367,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"clap",
|
||||
"cookie",
|
||||
"croner",
|
||||
"dirs",
|
||||
"fabro-agent",
|
||||
"fabro-api",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import { useRef, type ReactNode } from "react";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client";
|
||||
import type {
|
||||
Automation,
|
||||
AutomationTrigger,
|
||||
Run,
|
||||
WorkflowSettings,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import { findApiTrigger, findScheduleTrigger } from "../lib/automation";
|
||||
import { Panel, Row } from "./settings-panel";
|
||||
import { INPUT_CLASS } from "./ui";
|
||||
import { sandboxRuntime } from "../lib/run-sandbox-lifecycle";
|
||||
|
||||
export interface AutomationFormValues {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
repository: string;
|
||||
ref: string;
|
||||
workflow: string;
|
||||
|
|
@ -22,7 +28,6 @@ export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
|
|||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
enabled: true,
|
||||
repository: "",
|
||||
ref: "main",
|
||||
workflow: "",
|
||||
|
|
@ -39,13 +44,12 @@ const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
|
|||
];
|
||||
|
||||
export function automationToFormValues(automation: Automation): AutomationFormValues {
|
||||
const apiTrigger = automation.triggers.find((t) => t.type === "api");
|
||||
const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule");
|
||||
const apiTrigger = findApiTrigger(automation);
|
||||
const scheduleTrigger = findScheduleTrigger(automation);
|
||||
return {
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description ?? "",
|
||||
enabled: automation.enabled,
|
||||
repository: automation.target.repository,
|
||||
ref: automation.target.ref,
|
||||
workflow: automation.target.workflow,
|
||||
|
|
@ -55,6 +59,37 @@ export function automationToFormValues(automation: Automation): AutomationFormVa
|
|||
};
|
||||
}
|
||||
|
||||
export function automationFormValuesFromRun(
|
||||
run: Run,
|
||||
settings?: WorkflowSettings | null,
|
||||
): AutomationFormValues {
|
||||
const name = firstPresentString(
|
||||
run.title,
|
||||
run.workflow.name,
|
||||
run.workflow.graph_name,
|
||||
run.workflow.slug,
|
||||
"New automation",
|
||||
);
|
||||
const workflowName = firstPresentString(
|
||||
run.workflow.name,
|
||||
run.workflow.graph_name,
|
||||
name,
|
||||
);
|
||||
const repository = githubRepositoryFromSettings(settings)
|
||||
?? githubRepositoryName(run.repository?.name)
|
||||
?? githubRepositoryFromOriginUrl(run.repository?.origin_url)
|
||||
?? "";
|
||||
const cloneBranch = sandboxRuntime(run.sandbox)?.clone_branch;
|
||||
return {
|
||||
...EMPTY_AUTOMATION_FORM,
|
||||
id: kebabify(name),
|
||||
name,
|
||||
repository,
|
||||
ref: cloneBranch ?? EMPTY_AUTOMATION_FORM.ref,
|
||||
workflow: run.workflow.slug?.trim() || snakeify(workflowName),
|
||||
};
|
||||
}
|
||||
|
||||
export function triggersFromFormValues(values: AutomationFormValues): AutomationTrigger[] {
|
||||
const triggers: AutomationTrigger[] = [];
|
||||
if (values.manualEnabled) {
|
||||
|
|
@ -77,8 +112,7 @@ export function isFormValid(values: AutomationFormValues): boolean {
|
|||
values.name.trim() !== "" &&
|
||||
values.repository.trim() !== "" &&
|
||||
values.ref.trim() !== "" &&
|
||||
values.workflow.trim() !== "" &&
|
||||
(values.manualEnabled || values.scheduleEnabled)
|
||||
values.workflow.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +132,54 @@ export function snakeify(value: string): string {
|
|||
.replace(/^_|_$/g, "");
|
||||
}
|
||||
|
||||
function firstPresentString(...values: Array<string | null | undefined>): string {
|
||||
for (const value of values) {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function githubRepositoryFromSettings(
|
||||
settings?: WorkflowSettings | null,
|
||||
): string | null {
|
||||
const owner = settings?.run?.scm?.owner;
|
||||
const repository = settings?.run?.scm?.repository;
|
||||
if (!owner || !repository) return null;
|
||||
return githubRepositoryName(`${owner}/${repository}`);
|
||||
}
|
||||
|
||||
function githubRepositoryName(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim().replace(/\.git$/i, "");
|
||||
if (!trimmed) return null;
|
||||
|
||||
const match = trimmed.match(/^([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+)$/);
|
||||
if (!match) return null;
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
function githubRepositoryFromOriginUrl(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const scpLikeMatch = trimmed.match(
|
||||
/^git@github\.com:([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i,
|
||||
);
|
||||
if (scpLikeMatch) {
|
||||
return githubRepositoryName(`${scpLikeMatch[1]}/${scpLikeMatch[2]}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hostname.toLowerCase() !== "github.com") return null;
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length !== 2) return null;
|
||||
return githubRepositoryName(`${parts[0]}/${parts[1]}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function describeCron(expression: string): string {
|
||||
const trimmed = expression.trim();
|
||||
const preset = CRON_PRESETS.find((p) => p.value === trimmed);
|
||||
|
|
@ -187,13 +269,6 @@ export function AutomationFormFields({
|
|||
className={`${INPUT_CLASS} resize-y`}
|
||||
/>
|
||||
</Row>
|
||||
<Row title="Enabled" help="Disabled automations skip scheduled triggers and reject API runs.">
|
||||
<ToggleSwitch
|
||||
checked={values.enabled}
|
||||
onChange={(enabled) => patch({ enabled })}
|
||||
label="Enable automation"
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Source">
|
||||
|
|
|
|||
20
apps/fabro-web/app/lib/automation.ts
Normal file
20
apps/fabro-web/app/lib/automation.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client";
|
||||
|
||||
type TriggerOfType<K extends AutomationTrigger["type"]> = Extract<
|
||||
AutomationTrigger,
|
||||
{ type: K }
|
||||
>;
|
||||
|
||||
export function findApiTrigger(automation: Automation): TriggerOfType<"api"> | undefined {
|
||||
return automation.triggers.find((t): t is TriggerOfType<"api"> => t.type === "api");
|
||||
}
|
||||
|
||||
export function findScheduleTrigger(
|
||||
automation: Automation,
|
||||
): TriggerOfType<"schedule"> | undefined {
|
||||
return automation.triggers.find((t): t is TriggerOfType<"schedule"> => t.type === "schedule");
|
||||
}
|
||||
|
||||
export function hasEnabledApiTrigger(automation: Automation): boolean {
|
||||
return findApiTrigger(automation)?.enabled === true;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import type {
|
|||
|
||||
import { toRunWithStatus } from "../data/runs";
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import { findApiTrigger, findScheduleTrigger } from "../lib/automation";
|
||||
import { useAutomation, useAutomationRuns } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useDataUpdatedAt } from "../hooks/use-data-updated-at";
|
||||
|
|
@ -90,9 +91,9 @@ function AutomationHeader({ automation }: { automation: Automation }) {
|
|||
const toast = useToast();
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule");
|
||||
const apiTrigger = automation.triggers.find((t) => t.type === "api");
|
||||
const canRun = apiTrigger?.enabled === true && automation.enabled;
|
||||
const scheduleTrigger = findScheduleTrigger(automation);
|
||||
const apiTrigger = findApiTrigger(automation);
|
||||
const canRun = apiTrigger?.enabled === true;
|
||||
|
||||
async function onRun() {
|
||||
if (!canRun || running) return;
|
||||
|
|
@ -137,7 +138,6 @@ function AutomationHeader({ automation }: { automation: Automation }) {
|
|||
<span className="font-mono text-xs text-fg-muted">{automation.id}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<StatusChip enabled={automation.enabled} />
|
||||
<Chip icon={FolderIcon}>
|
||||
{automation.target.repository}
|
||||
<span className="text-fg-muted/70"> · {automation.target.ref}</span>
|
||||
|
|
@ -165,13 +165,7 @@ function AutomationHeader({ automation }: { automation: Automation }) {
|
|||
type="button"
|
||||
onClick={onRun}
|
||||
disabled={!canRun || running}
|
||||
title={
|
||||
!automation.enabled
|
||||
? "Enable the automation to run it"
|
||||
: !apiTrigger?.enabled
|
||||
? "Enable the API trigger to run it"
|
||||
: undefined
|
||||
}
|
||||
title={canRun ? undefined : "Enable the API trigger to run it"}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
<PlayIcon className="size-4" aria-hidden="true" />
|
||||
|
|
@ -183,19 +177,6 @@ function AutomationHeader({ automation }: { automation: Automation }) {
|
|||
);
|
||||
}
|
||||
|
||||
function StatusChip({ enabled }: { enabled: boolean }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-2 rounded-full ${enabled ? "bg-teal-500" : "bg-fg-muted"}`}
|
||||
/>
|
||||
<span className={`font-medium ${enabled ? "text-teal-500" : "text-fg-muted"}`}>
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
icon: Icon,
|
||||
children,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
|
|||
automationsApi.replaceAutomation(automation.id, automation.revision, {
|
||||
name: trimmedName,
|
||||
description: values.description.trim() || null,
|
||||
enabled: values.enabled,
|
||||
target: {
|
||||
repository: values.repository.trim(),
|
||||
ref: values.ref.trim(),
|
||||
|
|
|
|||
300
apps/fabro-web/app/routes/automations-new.test.tsx
Normal file
300
apps/fabro-web/app/routes/automations-new.test.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
|
||||
import { ToastProvider } from "../components/toast";
|
||||
import { setupReactTestEnv } from "../lib/test-utils";
|
||||
|
||||
let currentRun: any = null;
|
||||
let currentRunError: unknown = null;
|
||||
let currentRunLoading = false;
|
||||
let currentRunSettings: any = null;
|
||||
const queryCalls: Array<{ hook: string; id: string | undefined }> = [];
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
let teardownReactEnv: (() => void) | undefined;
|
||||
|
||||
const createAutomationMock = mock((_payload: unknown) =>
|
||||
Promise.resolve({ data: {} }),
|
||||
);
|
||||
const swrMutateMock = mock((_key: unknown) => Promise.resolve(undefined));
|
||||
|
||||
mock.module("@headlessui/react", () => ({
|
||||
Dialog: ({ open, children }: any) =>
|
||||
open ? createElement("div", { role: "dialog" }, children) : null,
|
||||
DialogPanel: ({ children, ...props }: any) =>
|
||||
createElement("div", props, children),
|
||||
DialogTitle: ({ children, ...props }: any) =>
|
||||
createElement("h2", props, children),
|
||||
Switch: ({ checked, onChange, children, ...props }: any) =>
|
||||
createElement(
|
||||
"button",
|
||||
{
|
||||
...props,
|
||||
type: "button",
|
||||
role: "switch",
|
||||
"aria-checked": checked,
|
||||
onClick: () => onChange(!checked),
|
||||
},
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
mock.module("../lib/queries", () => ({
|
||||
useRun: (id: string | undefined) => {
|
||||
queryCalls.push({ hook: "useRun", id });
|
||||
return {
|
||||
data: currentRun,
|
||||
error: currentRunError,
|
||||
isLoading: currentRunLoading,
|
||||
};
|
||||
},
|
||||
useRunSettings: (id: string | undefined) => {
|
||||
queryCalls.push({ hook: "useRunSettings", id });
|
||||
return {
|
||||
data: currentRunSettings,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("../lib/api-client", () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly requestId: string | null;
|
||||
readonly body: unknown;
|
||||
|
||||
constructor({
|
||||
status,
|
||||
message,
|
||||
requestId,
|
||||
body,
|
||||
}: {
|
||||
status: number;
|
||||
message: string;
|
||||
requestId: string | null;
|
||||
body: unknown;
|
||||
}) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.requestId = requestId;
|
||||
this.body = body;
|
||||
}
|
||||
},
|
||||
apiData: async function apiData<T>(
|
||||
call: () => Promise<{ data: T }>,
|
||||
): Promise<T> {
|
||||
const response = await call();
|
||||
return response.data;
|
||||
},
|
||||
automationsApi: {
|
||||
createAutomation: createAutomationMock,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("swr", () => ({
|
||||
useSWRConfig: () => ({ mutate: swrMutateMock }),
|
||||
}));
|
||||
|
||||
const { default: AutomationsNew } = await import("./automations-new");
|
||||
mock.restore();
|
||||
|
||||
function makeRun(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "run_1",
|
||||
children_count: 0,
|
||||
goal: "Fix CI",
|
||||
title: "Fix failing tests",
|
||||
workflow: {
|
||||
slug: "fix_ci",
|
||||
name: "Fix CI",
|
||||
graph_name: "ci_graph",
|
||||
node_count: 0,
|
||||
edge_count: 0,
|
||||
},
|
||||
automation: null,
|
||||
repository: {
|
||||
name: "fallback/repo",
|
||||
origin_url: "https://github.com/fallback/repo.git",
|
||||
provider: "github",
|
||||
},
|
||||
created_by: null,
|
||||
origin: { kind: "api" },
|
||||
labels: {},
|
||||
lifecycle: {
|
||||
status: { kind: "succeeded", reason: "completed" },
|
||||
approval: null,
|
||||
pending_control: null,
|
||||
queue_position: null,
|
||||
error: null,
|
||||
archived: false,
|
||||
archived_at: null,
|
||||
},
|
||||
sandbox: {
|
||||
kind: "ready",
|
||||
plan: { provider: "docker", image: null, snapshot: null },
|
||||
instance: {
|
||||
provider: "docker",
|
||||
image: null,
|
||||
snapshot: null,
|
||||
runtime: {
|
||||
id: "container_1",
|
||||
working_directory: "/workspace",
|
||||
repo_cloned: true,
|
||||
clone_origin_url: "https://github.com/qltysh/fabro.git",
|
||||
clone_branch: "feature/from-run",
|
||||
},
|
||||
},
|
||||
},
|
||||
models: [],
|
||||
source_directory: null,
|
||||
timestamps: {
|
||||
created_at: "2026-04-20T12:00:00Z",
|
||||
started_at: null,
|
||||
last_event_at: null,
|
||||
completed_at: null,
|
||||
},
|
||||
timing: null,
|
||||
billing: null,
|
||||
size: "XS",
|
||||
ask_fabro: {
|
||||
available: false,
|
||||
unavailable_reason: "no_sandbox",
|
||||
default_model: null,
|
||||
},
|
||||
diff: null,
|
||||
pull_request: null,
|
||||
current_question: null,
|
||||
superseded_by: null,
|
||||
retried_from: null,
|
||||
links: { web: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRunSettings() {
|
||||
return {
|
||||
run: {
|
||||
scm: {
|
||||
provider: "github",
|
||||
owner: "qltysh",
|
||||
repository: "fabro",
|
||||
github: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function renderAutomationsNew(initialEntry: string) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/automations",
|
||||
element: <div data-route="automations">Automations</div>,
|
||||
},
|
||||
{
|
||||
path: "/automations/new",
|
||||
element: <AutomationsNew />,
|
||||
},
|
||||
],
|
||||
{ initialEntries: [initialEntry] },
|
||||
);
|
||||
|
||||
let renderer!: TestRenderer.ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
mountedRenderers.push(renderer);
|
||||
return { renderer, router };
|
||||
}
|
||||
|
||||
function fieldValue(renderer: TestRenderer.ReactTestRenderer, label: string) {
|
||||
return renderer.root.findByProps({ "aria-label": label }).props.value;
|
||||
}
|
||||
|
||||
function switchChecked(renderer: TestRenderer.ReactTestRenderer, label: string) {
|
||||
const props = renderer.root.findByProps({ "aria-label": label }).props;
|
||||
return props["aria-checked"] ?? props.checked;
|
||||
}
|
||||
|
||||
function textFromNode(
|
||||
node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>,
|
||||
): string {
|
||||
if (!node) return "";
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(textFromNode).join(" ");
|
||||
return (node.children ?? []).map(textFromNode).join(" ");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
teardownReactEnv = setupReactTestEnv();
|
||||
currentRun = null;
|
||||
currentRunError = null;
|
||||
currentRunLoading = false;
|
||||
currentRunSettings = null;
|
||||
queryCalls.length = 0;
|
||||
createAutomationMock.mockClear();
|
||||
swrMutateMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const renderer of mountedRenderers.splice(0)) {
|
||||
act(() => renderer.unmount());
|
||||
}
|
||||
teardownReactEnv?.();
|
||||
teardownReactEnv = undefined;
|
||||
});
|
||||
|
||||
describe("AutomationsNew", () => {
|
||||
test("/automations/new renders empty form values", async () => {
|
||||
const { renderer } = await renderAutomationsNew("/automations/new");
|
||||
|
||||
expect(fieldValue(renderer, "Automation name")).toBe("");
|
||||
expect(fieldValue(renderer, "Automation slug")).toBe("");
|
||||
expect(fieldValue(renderer, "Repository")).toBe("");
|
||||
expect(fieldValue(renderer, "Default branch")).toBe("main");
|
||||
expect(fieldValue(renderer, "Workflow slug")).toBe("");
|
||||
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
|
||||
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
|
||||
});
|
||||
|
||||
test("/automations/new?from_run=run_1 pre-populates from run and settings data", async () => {
|
||||
currentRun = makeRun();
|
||||
currentRunSettings = makeRunSettings();
|
||||
|
||||
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
|
||||
|
||||
expect(fieldValue(renderer, "Automation name")).toBe("Fix failing tests");
|
||||
expect(fieldValue(renderer, "Automation slug")).toBe("fix-failing-tests");
|
||||
expect(fieldValue(renderer, "Repository")).toBe("qltysh/fabro");
|
||||
expect(fieldValue(renderer, "Default branch")).toBe("feature/from-run");
|
||||
expect(fieldValue(renderer, "Workflow slug")).toBe("fix_ci");
|
||||
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
|
||||
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
|
||||
expect(
|
||||
renderer.root.findAllByProps({ "aria-label": "Cron expression" }),
|
||||
).toHaveLength(0);
|
||||
expect(queryCalls).toContainEqual({ hook: "useRun", id: "run_1" });
|
||||
expect(queryCalls).toContainEqual({ hook: "useRunSettings", id: "run_1" });
|
||||
});
|
||||
|
||||
test("missing source run data renders an editable empty form with a non-blocking error", async () => {
|
||||
currentRun = null;
|
||||
currentRunError = new Error("not found");
|
||||
|
||||
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
|
||||
|
||||
expect(textFromNode(renderer.toJSON())).toContain("could not be loaded");
|
||||
expect(textFromNode(renderer.toJSON())).toContain("fill it out manually");
|
||||
expect(fieldValue(renderer, "Automation name")).toBe("");
|
||||
expect(fieldValue(renderer, "Repository")).toBe("");
|
||||
expect(fieldValue(renderer, "Default branch")).toBe("main");
|
||||
expect(fieldValue(renderer, "Workflow slug")).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useRun, useRunSettings } from "../lib/queries";
|
||||
import {
|
||||
AutomationFormFields,
|
||||
EMPTY_AUTOMATION_FORM,
|
||||
automationFormValuesFromRun,
|
||||
isFormValid,
|
||||
triggersFromFormValues,
|
||||
type AutomationFormValues,
|
||||
|
|
@ -26,10 +28,69 @@ export function meta() {
|
|||
export const handle = { hideHeader: true };
|
||||
|
||||
export default function AutomationsNew() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const fromRunId = searchParams.get("from_run")?.trim() || undefined;
|
||||
const runQuery = useRun(fromRunId);
|
||||
const settingsQuery = useRunSettings(fromRunId);
|
||||
|
||||
if (!fromRunId) {
|
||||
return (
|
||||
<AutomationCreateForm
|
||||
key="blank"
|
||||
initialValues={EMPTY_AUTOMATION_FORM}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for both queries to settle before mounting the form, so the user's
|
||||
// edits aren't blown away when settings arrive after the run.
|
||||
const runPending = runQuery.isLoading && !runQuery.data;
|
||||
const settingsPending = settingsQuery.isLoading && !settingsQuery.data;
|
||||
if (runPending || settingsPending) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader />
|
||||
<p className="rounded-lg bg-panel-alt px-4 py-3 text-sm text-fg-3">
|
||||
Loading source run…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!runQuery.data) {
|
||||
return (
|
||||
<AutomationCreateForm
|
||||
key={`missing:${fromRunId}`}
|
||||
initialValues={EMPTY_AUTOMATION_FORM}
|
||||
sourceError="The source run could not be loaded. You can still fill it out manually."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const initialValues = automationFormValuesFromRun(
|
||||
runQuery.data,
|
||||
settingsQuery.data ?? null,
|
||||
);
|
||||
|
||||
return (
|
||||
<AutomationCreateForm
|
||||
key={`from-run:${fromRunId}`}
|
||||
initialValues={initialValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationCreateForm({
|
||||
initialValues,
|
||||
sourceError = null,
|
||||
}: {
|
||||
initialValues: AutomationFormValues;
|
||||
sourceError?: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
const [values, setValues] = useState<AutomationFormValues>(EMPTY_AUTOMATION_FORM);
|
||||
const [values, setValues] = useState<AutomationFormValues>(initialValues);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -47,7 +108,6 @@ export default function AutomationsNew() {
|
|||
id: values.id.trim(),
|
||||
name: trimmedName,
|
||||
description: values.description.trim() || null,
|
||||
enabled: values.enabled,
|
||||
target: {
|
||||
repository: values.repository.trim(),
|
||||
ref: values.ref.trim(),
|
||||
|
|
@ -75,6 +135,7 @@ export default function AutomationsNew() {
|
|||
|
||||
<AutomationFormFields values={values} onChange={setValues} />
|
||||
|
||||
{sourceError ? <ErrorMessage message={sourceError} /> : null}
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
|
||||
<FormFooter
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { FilterButton } from "../components/runs-list/filter-button";
|
|||
import type { Automation, AutomationListResponse } from "@qltysh/fabro-api-client";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ApiError, apiData, automationsApi } from "../lib/api-client";
|
||||
import { findScheduleTrigger, hasEnabledApiTrigger } from "../lib/automation";
|
||||
import { useAutomations } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { ConfirmDialog } from "../components/ui";
|
||||
|
|
@ -48,6 +49,7 @@ interface AutomationRow {
|
|||
workflow: string;
|
||||
repository: string;
|
||||
schedule?: string;
|
||||
apiEnabled: boolean;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}
|
||||
|
|
@ -76,11 +78,6 @@ const MENU_ITEM_CLASS =
|
|||
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";
|
||||
|
||||
function scheduleFor(automation: Automation): string | undefined {
|
||||
const schedule = automation.triggers.find((t) => t.type === "schedule");
|
||||
return schedule?.expression;
|
||||
}
|
||||
|
||||
function mapAutomations(result: AutomationListResponse | undefined): AutomationRow[] {
|
||||
const automations = result?.data ?? [];
|
||||
return automations.map((a) => ({
|
||||
|
|
@ -89,7 +86,8 @@ function mapAutomations(result: AutomationListResponse | undefined): AutomationR
|
|||
name: a.name,
|
||||
workflow: a.target.workflow,
|
||||
repository: a.target.repository,
|
||||
schedule: scheduleFor(a),
|
||||
schedule: findScheduleTrigger(a)?.expression,
|
||||
apiEnabled: hasEnabledApiTrigger(a),
|
||||
icon: slugIconMap[a.target.workflow] ?? CodeBracketIcon,
|
||||
color: slugColorMap[a.target.workflow] ?? "var(--color-teal-500)",
|
||||
}));
|
||||
|
|
@ -105,18 +103,19 @@ function PlayIcon({ className }: { className?: string }) {
|
|||
|
||||
function AutomationCard({
|
||||
automation,
|
||||
disabled,
|
||||
busy,
|
||||
running,
|
||||
onRun,
|
||||
onDelete,
|
||||
}: {
|
||||
automation: AutomationRow;
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
running: boolean;
|
||||
onRun: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const Icon = automation.icon;
|
||||
const runDisabled = busy || !automation.apiEnabled;
|
||||
return (
|
||||
<div className="group flex items-center gap-4 rounded-md border border-line bg-panel/80 p-4 transition-all duration-200 hover:border-line-strong hover:bg-panel hover:shadow-lg hover:shadow-black/20">
|
||||
<Link to={`/automations/${automation.id}`} className="flex min-w-0 flex-1 items-center gap-4">
|
||||
|
|
@ -154,9 +153,15 @@ function AutomationCard({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
disabled={running || disabled}
|
||||
disabled={running || runDisabled}
|
||||
aria-label={running ? "Starting run…" : "Run automation"}
|
||||
title={running ? "Starting run…" : "Run automation"}
|
||||
title={
|
||||
running
|
||||
? "Starting run..."
|
||||
: automation.apiEnabled
|
||||
? "Run automation"
|
||||
: "Enable the API trigger to run it"
|
||||
}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-mint/20 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-mint"
|
||||
>
|
||||
{running ? (
|
||||
|
|
@ -167,7 +172,7 @@ function AutomationCard({
|
|||
</button>
|
||||
)}
|
||||
|
||||
<RowMenu automation={automation} disabled={disabled} onDelete={onDelete} />
|
||||
<RowMenu automation={automation} disabled={busy} onDelete={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -260,14 +265,15 @@ export default function Automations() {
|
|||
}
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const filtered = automations.filter(
|
||||
(a) =>
|
||||
(triggerFilter === "all" ||
|
||||
(triggerFilter === "scheduled" && a.schedule != null) ||
|
||||
(triggerFilter === "manual" && a.schedule == null)) &&
|
||||
(a.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
a.workflow.toLowerCase().includes(query.toLowerCase()) ||
|
||||
a.repository.toLowerCase().includes(query.toLowerCase())),
|
||||
(a.name.toLowerCase().includes(lowerQuery) ||
|
||||
a.workflow.toLowerCase().includes(lowerQuery) ||
|
||||
a.repository.toLowerCase().includes(lowerQuery)),
|
||||
);
|
||||
|
||||
async function confirmDelete() {
|
||||
|
|
@ -322,7 +328,7 @@ export default function Automations() {
|
|||
<AutomationCard
|
||||
key={automation.id}
|
||||
automation={automation}
|
||||
disabled={deleting || (runningId !== null && runningId !== automation.id)}
|
||||
busy={deleting || (runningId !== null && runningId !== automation.id)}
|
||||
running={runningId === automation.id}
|
||||
onRun={() => runAutomation(automation)}
|
||||
onDelete={() => setPendingDelete(automation)}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ mock.module("../lib/queries", () => ({
|
|||
data: currentRunSummary,
|
||||
isLoading: false,
|
||||
}),
|
||||
useRunSettings: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
useRunQuestions: () => ({
|
||||
data: currentQuestions,
|
||||
}),
|
||||
|
|
@ -184,13 +188,21 @@ type RunDetailActionResult = import("./run-detail/lifecycle-toasts").RunDetailAc
|
|||
|
||||
const h = createElement;
|
||||
|
||||
function makeRunSummary(
|
||||
function makeRunSummary({
|
||||
status = "succeeded",
|
||||
diffSummary: any = null,
|
||||
pullRequest: any = null,
|
||||
diffSummary = null as any,
|
||||
pullRequest = null as any,
|
||||
title = "Run 1",
|
||||
askFabro: any = null,
|
||||
) {
|
||||
askFabro = null as any,
|
||||
automation = null as any,
|
||||
}: {
|
||||
status?: string;
|
||||
diffSummary?: any;
|
||||
pullRequest?: any;
|
||||
title?: string;
|
||||
askFabro?: any;
|
||||
automation?: any;
|
||||
} = {}) {
|
||||
const apiStatus =
|
||||
status === "succeeded"
|
||||
? { kind: "succeeded", reason: "completed" }
|
||||
|
|
@ -207,7 +219,7 @@ function makeRunSummary(
|
|||
goal: "Run 1",
|
||||
title,
|
||||
workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 },
|
||||
automation: null,
|
||||
automation,
|
||||
repository: { name: "fabro", origin_url: null, provider: "unknown" },
|
||||
created_by: null,
|
||||
origin: { kind: "api" },
|
||||
|
|
@ -269,6 +281,7 @@ async function renderRunDetailHarness({
|
|||
pullRequest = null,
|
||||
title,
|
||||
askFabro = null,
|
||||
automation = null,
|
||||
}: {
|
||||
initialEntry: string;
|
||||
status?: string;
|
||||
|
|
@ -277,8 +290,9 @@ async function renderRunDetailHarness({
|
|||
pullRequest?: any;
|
||||
title?: string;
|
||||
askFabro?: any;
|
||||
automation?: any;
|
||||
}) {
|
||||
currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title, askFabro);
|
||||
currentRunSummary = makeRunSummary({ status, diffSummary, pullRequest, title, askFabro, automation });
|
||||
currentQuestions = questions;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
|
@ -303,6 +317,14 @@ async function renderRunDetailHarness({
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/automations/new",
|
||||
element: h("div", { "data-route": "automations-new" }, "New automation"),
|
||||
},
|
||||
{
|
||||
path: "/automations/:automationId",
|
||||
element: h("div", { "data-route": "automation-detail" }, "Automation detail"),
|
||||
},
|
||||
],
|
||||
{ initialEntries: [initialEntry] },
|
||||
);
|
||||
|
|
@ -364,13 +386,20 @@ function textFromTestNode(node: TestRenderer.ReactTestInstance): string {
|
|||
}).join("");
|
||||
}
|
||||
|
||||
function findButtonByText(
|
||||
function findButtonsByText(
|
||||
renderer: TestRenderer.ReactTestRenderer,
|
||||
text: string,
|
||||
) {
|
||||
return renderer.root.findAll(
|
||||
(node) => node.type === "button" && textFromTestNode(node).includes(text),
|
||||
)[0];
|
||||
);
|
||||
}
|
||||
|
||||
function findButtonByText(
|
||||
renderer: TestRenderer.ReactTestRenderer,
|
||||
text: string,
|
||||
) {
|
||||
return findButtonsByText(renderer, text)[0];
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
|
|
@ -474,7 +503,7 @@ describe("handleLifecycleToastResult", () => {
|
|||
const result: RunDetailActionResult = {
|
||||
intent: "cancel",
|
||||
ok: true,
|
||||
run: makeRunSummary("failed"),
|
||||
run: makeRunSummary({ status: "failed" }),
|
||||
};
|
||||
result.run.lifecycle.status = { kind: "failed", reason: "cancelled" };
|
||||
|
||||
|
|
@ -495,7 +524,7 @@ describe("handleLifecycleToastResult", () => {
|
|||
const result: RunDetailActionResult = {
|
||||
intent: "cancel",
|
||||
ok: true,
|
||||
run: makeRunSummary("running"),
|
||||
run: makeRunSummary({ status: "running" }),
|
||||
};
|
||||
|
||||
handleLifecycleToastResult("cancel", result, initialState, api);
|
||||
|
|
@ -508,7 +537,7 @@ describe("handleLifecycleToastResult", () => {
|
|||
const result: RunDetailActionResult = {
|
||||
intent: "archive",
|
||||
ok: true,
|
||||
run: makeRunSummary("archived"),
|
||||
run: makeRunSummary({ status: "archived" }),
|
||||
};
|
||||
|
||||
const firstState = handleLifecycleToastResult("archive", result, initialState, api);
|
||||
|
|
@ -528,7 +557,7 @@ describe("handleLifecycleToastResult", () => {
|
|||
const result: RunDetailActionResult = {
|
||||
intent: "unarchive",
|
||||
ok: true,
|
||||
run: makeRunSummary("succeeded"),
|
||||
run: makeRunSummary({ status: "succeeded" }),
|
||||
};
|
||||
const stateWithActiveToast: LifecycleToastState = {
|
||||
activeArchiveToastId: "toast-9",
|
||||
|
|
@ -614,7 +643,7 @@ describe("RunDetail full-height child routes", () => {
|
|||
intent: "retry",
|
||||
ok: true,
|
||||
run: {
|
||||
...makeRunSummary("runnable"),
|
||||
...makeRunSummary({ status: "runnable" }),
|
||||
id: "run_retry",
|
||||
retried_from: "run_1",
|
||||
},
|
||||
|
|
@ -782,6 +811,38 @@ describe("RunDetail full-height child routes", () => {
|
|||
expect(tabCountBadges(renderer)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("ordinary runs can navigate to create an automation from the run", async () => {
|
||||
const { renderer, router } = await renderRunDetailHarness({
|
||||
initialEntry: "/runs/run_1",
|
||||
});
|
||||
|
||||
expect(findButtonsByText(renderer, "Create automation from run")).toHaveLength(1);
|
||||
expect(findButtonsByText(renderer, "View automation")).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(renderer, "Create automation from run")!.props.onClick();
|
||||
});
|
||||
|
||||
expect(router.state.location.pathname).toBe("/automations/new");
|
||||
expect(router.state.location.search).toBe("?from_run=run_1");
|
||||
});
|
||||
|
||||
test("automation-created runs navigate to the existing automation instead of duplicate creation", async () => {
|
||||
const { renderer, router } = await renderRunDetailHarness({
|
||||
initialEntry: "/runs/run_1",
|
||||
automation: { id: "fix build", name: "Fix Build", trigger_id: "manual" },
|
||||
});
|
||||
|
||||
expect(findButtonsByText(renderer, "View automation")).toHaveLength(1);
|
||||
expect(findButtonsByText(renderer, "Create automation from run")).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(renderer, "View automation")!.props.onClick();
|
||||
});
|
||||
|
||||
expect(router.state.location.pathname).toBe("/automations/fix%20build");
|
||||
});
|
||||
|
||||
test("confirms deleting an archived run and navigates back to runs", async () => {
|
||||
const deletion = deferred<unknown>();
|
||||
deleteRunApiResult = deletion.promise;
|
||||
|
|
|
|||
|
|
@ -200,6 +200,18 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
}
|
||||
};
|
||||
const hasPendingQuestions = isBlocked && pendingQuestions.length > 0;
|
||||
const automationId = summary.automation?.id ?? null;
|
||||
const automationAction = automationId
|
||||
? {
|
||||
key: "view-automation",
|
||||
label: "View automation",
|
||||
onSelect: () => navigate(`/automations/${encodeURIComponent(automationId)}`),
|
||||
}
|
||||
: {
|
||||
key: "create-automation",
|
||||
label: "Create automation from run",
|
||||
onSelect: () => navigate(`/automations/new?from_run=${encodeURIComponent(params.id)}`),
|
||||
};
|
||||
const actionGroups: ActionGroups = {
|
||||
operations: [
|
||||
...(hasSandbox
|
||||
|
|
@ -211,6 +223,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
onSelect: () => void handlePreview(),
|
||||
}]
|
||||
: []),
|
||||
automationAction,
|
||||
{
|
||||
key: "interrupt",
|
||||
label: "Send interrupt",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ tags:
|
|||
description: Run management operations
|
||||
- name: Automations
|
||||
description: Server-managed automation definitions and automation-triggered runs
|
||||
- name: Environments
|
||||
description: Server-managed execution environment catalog
|
||||
- name: Sandboxes
|
||||
description: Provider-backed sandbox inventory
|
||||
- name: Sessions
|
||||
|
|
@ -4206,7 +4208,7 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"409":
|
||||
description: Automation is disabled or has no enabled API trigger
|
||||
description: Automation has no enabled API trigger
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
|
|
@ -4224,6 +4226,272 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Environments ─────────────────────────────────────────────────────
|
||||
|
||||
/api/v1/environments:
|
||||
get:
|
||||
operationId: listEnvironments
|
||||
tags: [Environments]
|
||||
summary: List environments
|
||||
description: Returns all server-managed environment definitions, sorted by id.
|
||||
responses:
|
||||
"200":
|
||||
description: Environment definitions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EnvironmentListResponse"
|
||||
"500":
|
||||
description: Environment store operation failed
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
post:
|
||||
operationId: createEnvironment
|
||||
tags: [Environments]
|
||||
summary: Create environment
|
||||
description: |
|
||||
Creates a server-owned environment definition in the environment catalog.
|
||||
REST environment requests only accept inline Dockerfile content; local
|
||||
Dockerfile paths are supported by workflow/settings files but rejected
|
||||
by this API.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateEnvironmentRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Environment created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Environment"
|
||||
"400":
|
||||
description: Malformed JSON request body
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"409":
|
||||
description: Environment id already exists
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Environment failed domain validation
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Environment store operation failed
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/environments/{id}:
|
||||
get:
|
||||
operationId: retrieveEnvironment
|
||||
tags: [Environments]
|
||||
summary: Retrieve environment
|
||||
description: Returns one server-managed environment definition by id.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EnvironmentId"
|
||||
responses:
|
||||
"200":
|
||||
description: Environment definition
|
||||
headers:
|
||||
ETag:
|
||||
$ref: "#/components/headers/ETag"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Environment"
|
||||
"400":
|
||||
description: Invalid environment id
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"404":
|
||||
description: Environment not found
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Environment store operation failed
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
put:
|
||||
operationId: replaceEnvironment
|
||||
tags: [Environments]
|
||||
summary: Replace environment
|
||||
description: |
|
||||
Replaces an environment definition when `If-Match` matches the current
|
||||
environment revision. The path id is authoritative; the request body
|
||||
omits `id`.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EnvironmentId"
|
||||
- $ref: "#/components/parameters/IfMatch"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ReplaceEnvironmentRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Environment replaced
|
||||
headers:
|
||||
ETag:
|
||||
$ref: "#/components/headers/ETag"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Environment"
|
||||
"400":
|
||||
description: Malformed JSON request body, invalid environment id, or invalid revision header
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"404":
|
||||
description: Environment not found
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"409":
|
||||
description: Environment revision mismatch or protected environment conflict
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Environment failed domain validation
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"428":
|
||||
description: Missing required `If-Match` header
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Environment store operation failed
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
delete:
|
||||
operationId: deleteEnvironment
|
||||
tags: [Environments]
|
||||
summary: Delete environment
|
||||
description: Deletes a non-default environment definition when `If-Match` matches the current environment revision.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EnvironmentId"
|
||||
- $ref: "#/components/parameters/IfMatch"
|
||||
responses:
|
||||
"204":
|
||||
description: Environment deleted
|
||||
"400":
|
||||
description: Invalid environment id or revision header
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"404":
|
||||
description: Environment not found
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"409":
|
||||
description: Environment revision mismatch or protected environment conflict
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"428":
|
||||
description: Missing required `If-Match` header
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Environment store operation failed
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# ── Workflows ────────────────────────────────────────────────────────
|
||||
|
||||
/api/v1/workflows:
|
||||
|
|
@ -5104,6 +5372,16 @@ components:
|
|||
pattern: "^[a-z0-9][a-z0-9-]{0,62}$"
|
||||
example: nightly-deps
|
||||
|
||||
EnvironmentId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Unique environment identifier.
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[a-z0-9][a-z0-9-]{0,62}$"
|
||||
example: docker
|
||||
|
||||
IfMatch:
|
||||
name: If-Match
|
||||
in: header
|
||||
|
|
@ -5841,7 +6119,6 @@ components:
|
|||
- revision
|
||||
- name
|
||||
- description
|
||||
- enabled
|
||||
- target
|
||||
- triggers
|
||||
properties:
|
||||
|
|
@ -5860,9 +6137,6 @@ components:
|
|||
description:
|
||||
type: ["string", "null"]
|
||||
example: Keeps dependencies fresh.
|
||||
enabled:
|
||||
type: boolean
|
||||
example: true
|
||||
target:
|
||||
$ref: "#/components/schemas/AutomationTarget"
|
||||
triggers:
|
||||
|
|
@ -5970,9 +6244,6 @@ components:
|
|||
description:
|
||||
type: ["string", "null"]
|
||||
example: Keeps dependencies fresh.
|
||||
enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
target:
|
||||
$ref: "#/components/schemas/AutomationTarget"
|
||||
triggers:
|
||||
|
|
@ -5986,7 +6257,6 @@ components:
|
|||
additionalProperties: false
|
||||
required:
|
||||
- name
|
||||
- enabled
|
||||
- target
|
||||
- triggers
|
||||
properties:
|
||||
|
|
@ -5996,8 +6266,6 @@ components:
|
|||
description:
|
||||
type: ["string", "null"]
|
||||
example: Keeps dependencies fresh.
|
||||
enabled:
|
||||
type: boolean
|
||||
target:
|
||||
$ref: "#/components/schemas/AutomationTarget"
|
||||
triggers:
|
||||
|
|
@ -6033,6 +6301,181 @@ components:
|
|||
minimum: 0
|
||||
description: Total number of configured automation definitions.
|
||||
|
||||
# ── Environments ─────────────────────────────────────────────────────
|
||||
|
||||
Environment:
|
||||
description: Public server-managed environment definition.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- revision
|
||||
- provider
|
||||
- image
|
||||
- resources
|
||||
- network
|
||||
- lifecycle
|
||||
- labels
|
||||
- volumes
|
||||
- env
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
pattern: "^[a-z0-9][a-z0-9-]{0,62}$"
|
||||
example: docker
|
||||
revision:
|
||||
type: string
|
||||
pattern: "^[0-9a-f]{64}$"
|
||||
description: Stable revision used with `If-Match` for optimistic concurrency.
|
||||
example: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
provider:
|
||||
$ref: "#/components/schemas/EnvironmentProvider"
|
||||
image:
|
||||
$ref: "#/components/schemas/EnvironmentApiImageSettings"
|
||||
resources:
|
||||
$ref: "#/components/schemas/EnvironmentResourcesSettings"
|
||||
network:
|
||||
$ref: "#/components/schemas/EnvironmentNetworkSettings"
|
||||
lifecycle:
|
||||
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
|
||||
labels:
|
||||
$ref: "#/components/schemas/StringMap"
|
||||
volumes:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/EnvironmentVolumeSettings"
|
||||
env:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/InterpString"
|
||||
|
||||
CreateEnvironmentRequest:
|
||||
description: Request body for creating a server-managed environment.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- provider
|
||||
- image
|
||||
- resources
|
||||
- network
|
||||
- lifecycle
|
||||
- labels
|
||||
- volumes
|
||||
- env
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
pattern: "^[a-z0-9][a-z0-9-]{0,62}$"
|
||||
example: docker
|
||||
provider:
|
||||
$ref: "#/components/schemas/EnvironmentProvider"
|
||||
image:
|
||||
$ref: "#/components/schemas/EnvironmentApiImageSettings"
|
||||
resources:
|
||||
$ref: "#/components/schemas/EnvironmentResourcesSettings"
|
||||
network:
|
||||
$ref: "#/components/schemas/EnvironmentNetworkSettings"
|
||||
lifecycle:
|
||||
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
|
||||
labels:
|
||||
$ref: "#/components/schemas/StringMap"
|
||||
volumes:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/EnvironmentVolumeSettings"
|
||||
env:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/InterpString"
|
||||
|
||||
ReplaceEnvironmentRequest:
|
||||
description: Request body for replacing a server-managed environment. The path id is authoritative.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- provider
|
||||
- image
|
||||
- resources
|
||||
- network
|
||||
- lifecycle
|
||||
- labels
|
||||
- volumes
|
||||
- env
|
||||
properties:
|
||||
provider:
|
||||
$ref: "#/components/schemas/EnvironmentProvider"
|
||||
image:
|
||||
$ref: "#/components/schemas/EnvironmentApiImageSettings"
|
||||
resources:
|
||||
$ref: "#/components/schemas/EnvironmentResourcesSettings"
|
||||
network:
|
||||
$ref: "#/components/schemas/EnvironmentNetworkSettings"
|
||||
lifecycle:
|
||||
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
|
||||
labels:
|
||||
$ref: "#/components/schemas/StringMap"
|
||||
volumes:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/EnvironmentVolumeSettings"
|
||||
env:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/InterpString"
|
||||
|
||||
EnvironmentApiImageSettings:
|
||||
description: REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [docker, dockerfile]
|
||||
properties:
|
||||
docker:
|
||||
type: ["string", "null"]
|
||||
dockerfile:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/EnvironmentApiDockerfileSourceInline"
|
||||
- type: "null"
|
||||
|
||||
EnvironmentApiDockerfileSourceInline:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [type, value]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [inline]
|
||||
value:
|
||||
type: string
|
||||
|
||||
EnvironmentListResponse:
|
||||
description: List envelope for environment definitions.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Environment"
|
||||
meta:
|
||||
$ref: "#/components/schemas/EnvironmentListMeta"
|
||||
|
||||
EnvironmentListMeta:
|
||||
description: Metadata for environment list responses.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- total
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
description: Total number of server-managed environment definitions.
|
||||
|
||||
# ── Pagination ───────────────────────────────────────────────────────
|
||||
|
||||
PaginationMeta:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ wildcard_imports = "warn"
|
|||
chrono = { workspace = true, features = ["serde"] }
|
||||
fabro-automation = { path = "../fabro-automation" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-environment.workspace = true
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
progenitor-client = "0.13"
|
||||
|
|
|
|||
|
|
@ -635,6 +635,7 @@ fn main() {
|
|||
"fabro_automation::AutomationReplace",
|
||||
&[],
|
||||
),
|
||||
("Environment", "fabro_environment::Environment", &[]),
|
||||
("SessionId", "fabro_types::SessionId", &[]),
|
||||
("TurnId", "fabro_types::TurnId", &[]),
|
||||
("SessionStatus", "fabro_types::SessionStatus", &[]),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub mod types {
|
|||
Automation, AutomationDraft as CreateAutomationRequest,
|
||||
AutomationReplace as ReplaceAutomationRequest, AutomationTarget, AutomationTrigger,
|
||||
};
|
||||
pub use fabro_environment::Environment;
|
||||
pub use fabro_model::{
|
||||
Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode,
|
||||
Provider, ReasoningEffort, ReasoningEffortFeature, Speed as BillingSpeed,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ fn automation_response_round_trips_public_json_shape() {
|
|||
"revision": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"name": "Nightly dependency update",
|
||||
"description": null,
|
||||
"enabled": true,
|
||||
"target": {
|
||||
"repository": "fabro-sh/fabro",
|
||||
"ref": "main",
|
||||
|
|
@ -57,7 +56,6 @@ fn create_automation_request_round_trips_public_json_shape() {
|
|||
"id": "nightly-deps",
|
||||
"name": "Nightly dependency update",
|
||||
"description": "Keep dependencies fresh",
|
||||
"enabled": true,
|
||||
"target": {
|
||||
"repository": "fabro-sh/fabro",
|
||||
"ref": "main",
|
||||
|
|
@ -81,7 +79,6 @@ fn replace_automation_request_round_trips_public_json_shape() {
|
|||
let value = json!({
|
||||
"name": "Nightly dependency update",
|
||||
"description": "Keep dependencies fresh",
|
||||
"enabled": true,
|
||||
"target": {
|
||||
"repository": "fabro-sh/fabro",
|
||||
"ref": "main",
|
||||
|
|
|
|||
88
lib/crates/fabro-api/tests/environment_round_trip.rs
Normal file
88
lib/crates/fabro-api/tests/environment_round_trip.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use fabro_api::types::{
|
||||
CreateEnvironmentRequest as ApiCreateEnvironmentRequest, Environment as ApiEnvironment,
|
||||
ReplaceEnvironmentRequest as ApiReplaceEnvironmentRequest,
|
||||
};
|
||||
use fabro_environment::Environment;
|
||||
use serde_json::json;
|
||||
|
||||
// Compile-time witness that the generated API response type resolves to the
|
||||
// same type as the `fabro-environment` domain type via `with_replacement(...)`.
|
||||
// Request types intentionally stay API-specific so REST Dockerfile sources can
|
||||
// remain inline-only without changing workflow/settings schemas.
|
||||
const _: fn(ApiEnvironment) -> Environment = |value| value;
|
||||
|
||||
fn environment_settings_json() -> serde_json::Value {
|
||||
json!({
|
||||
"provider": "docker",
|
||||
"image": {
|
||||
"docker": null,
|
||||
"dockerfile": {
|
||||
"type": "inline",
|
||||
"value": "FROM alpine\n"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"cpu": null,
|
||||
"memory": null,
|
||||
"disk": null
|
||||
},
|
||||
"network": {
|
||||
"mode": "allow_all",
|
||||
"allow": []
|
||||
},
|
||||
"lifecycle": {
|
||||
"preserve": false,
|
||||
"stop_on_terminal": true,
|
||||
"auto_stop": null
|
||||
},
|
||||
"labels": {},
|
||||
"volumes": [],
|
||||
"env": {}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_response_round_trips_public_json_shape() {
|
||||
let mut value = environment_settings_json();
|
||||
value["id"] = json!("docker-inline");
|
||||
value["revision"] = json!("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
||||
|
||||
let api: ApiEnvironment = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_environment_request_round_trips_inline_dockerfile_json_shape() {
|
||||
let mut value = environment_settings_json();
|
||||
value["id"] = json!("docker-inline");
|
||||
|
||||
let api: ApiCreateEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_environment_request_round_trips_inline_dockerfile_json_shape() {
|
||||
let value = environment_settings_json();
|
||||
|
||||
let api: ApiReplaceEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_request_schema_rejects_dockerfile_path_sources() {
|
||||
let mut value = environment_settings_json();
|
||||
value["id"] = json!("docker-path");
|
||||
value["image"]["dockerfile"] = json!({
|
||||
"type": "path",
|
||||
"path": "Dockerfile"
|
||||
});
|
||||
|
||||
let err = serde_json::from_value::<ApiCreateEnvironmentRequest>(value)
|
||||
.expect_err("generated REST request type should reject Dockerfile path sources");
|
||||
assert!(
|
||||
err.to_string().contains("dockerfile")
|
||||
|| err.to_string().contains("type")
|
||||
|| err.to_string().contains("path"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,6 @@ pub use error::{AutomationStoreError, AutomationValidationError};
|
|||
pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, AutomationTriggerId};
|
||||
pub use model::{
|
||||
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTarget,
|
||||
AutomationTrigger, ScheduleTrigger,
|
||||
AutomationTrigger, ScheduleTrigger, parse_schedule_expression,
|
||||
};
|
||||
pub use store::AutomationStore;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use croner::Cron;
|
||||
use croner::errors::CronError;
|
||||
use croner::parser::{CronParser, Seconds, Year};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -8,6 +11,23 @@ use crate::{
|
|||
AutomationValidationError,
|
||||
};
|
||||
|
||||
/// Shared cron parser used to validate and evaluate automation schedule trigger
|
||||
/// expressions. Schedule triggers use the same five-field UTC cron grammar as
|
||||
/// validation, so both sites must share configuration.
|
||||
static SCHEDULE_CRON_PARSER: LazyLock<CronParser> = LazyLock::new(|| {
|
||||
CronParser::builder()
|
||||
.seconds(Seconds::Disallowed)
|
||||
.year(Year::Disallowed)
|
||||
.build()
|
||||
});
|
||||
|
||||
/// Parse an automation schedule trigger expression with the canonical
|
||||
/// configuration (no seconds, no year). Returned `Cron` instances can be cached
|
||||
/// and used to find next occurrences.
|
||||
pub fn parse_schedule_expression(expression: &str) -> Result<Cron, CronError> {
|
||||
SCHEDULE_CRON_PARSER.parse(expression)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Automation {
|
||||
|
|
@ -15,7 +35,6 @@ pub struct Automation {
|
|||
pub revision: AutomationRevision,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub target: AutomationTarget,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
|
@ -54,7 +73,6 @@ impl Automation {
|
|||
PersistedAutomation {
|
||||
name: self.name.clone(),
|
||||
description: self.description.clone(),
|
||||
enabled: self.enabled,
|
||||
target: self.target.clone(),
|
||||
triggers: self.triggers.clone(),
|
||||
}
|
||||
|
|
@ -64,20 +82,26 @@ impl Automation {
|
|||
toml::to_string_pretty(&self.to_persisted()).map_err(AutomationStoreError::from)
|
||||
}
|
||||
|
||||
/// Returns the enabled API trigger if the automation itself is enabled and
|
||||
/// has one. Returns `None` when the automation is disabled or has no
|
||||
/// enabled API trigger.
|
||||
/// Returns the enabled API trigger if the automation has one.
|
||||
/// Returns `None` when the automation has no enabled API trigger.
|
||||
#[must_use]
|
||||
pub fn enabled_api_trigger(&self) -> Option<&ApiTrigger> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
self.triggers.iter().find_map(|trigger| match trigger {
|
||||
AutomationTrigger::Api(trigger) if trigger.enabled => Some(trigger),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Iterate the enabled schedule triggers.
|
||||
pub fn enabled_schedule_triggers(&self) -> impl Iterator<Item = &ScheduleTrigger> {
|
||||
self.triggers
|
||||
.iter()
|
||||
.filter_map(move |trigger| match trigger {
|
||||
AutomationTrigger::Schedule(trigger) if trigger.enabled => Some(trigger),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_persisted(
|
||||
id: AutomationId,
|
||||
revision: AutomationRevision,
|
||||
|
|
@ -98,7 +122,6 @@ impl Automation {
|
|||
revision,
|
||||
name: replace.name,
|
||||
description: replace.description,
|
||||
enabled: replace.enabled,
|
||||
target: replace.target,
|
||||
triggers: replace.triggers,
|
||||
}
|
||||
|
|
@ -161,8 +184,6 @@ pub struct AutomationDraft {
|
|||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
pub target: AutomationTarget,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
|
@ -172,7 +193,6 @@ impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
|
|||
(value.id, AutomationReplace {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
enabled: value.enabled,
|
||||
target: value.target,
|
||||
triggers: value.triggers,
|
||||
})
|
||||
|
|
@ -185,7 +205,6 @@ pub struct AutomationReplace {
|
|||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub target: AutomationTarget,
|
||||
pub triggers: Vec<AutomationTrigger>,
|
||||
}
|
||||
|
|
@ -196,8 +215,6 @@ pub(crate) struct PersistedAutomation {
|
|||
name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
enabled: bool,
|
||||
target: AutomationTarget,
|
||||
#[serde(default)]
|
||||
triggers: Vec<AutomationTrigger>,
|
||||
|
|
@ -208,7 +225,6 @@ impl From<AutomationReplace> for PersistedAutomation {
|
|||
Self {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
enabled: value.enabled,
|
||||
target: value.target,
|
||||
triggers: value.triggers,
|
||||
}
|
||||
|
|
@ -220,7 +236,6 @@ impl From<PersistedAutomation> for AutomationReplace {
|
|||
Self {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
enabled: value.enabled,
|
||||
target: value.target,
|
||||
triggers: value.triggers,
|
||||
}
|
||||
|
|
@ -352,10 +367,6 @@ fn has_lock_suffix(value: &str) -> bool {
|
|||
fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationValidationError> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut has_api_trigger = false;
|
||||
let cron_parser = CronParser::builder()
|
||||
.seconds(Seconds::Disallowed)
|
||||
.year(Year::Disallowed)
|
||||
.build();
|
||||
|
||||
for trigger in triggers {
|
||||
let id = trigger.id().as_str();
|
||||
|
|
@ -376,7 +387,7 @@ fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationVal
|
|||
expression: trigger.expression.clone(),
|
||||
});
|
||||
}
|
||||
cron_parser.parse(&trigger.expression).map_err(|source| {
|
||||
parse_schedule_expression(&trigger.expression).map_err(|source| {
|
||||
AutomationValidationError::InvalidCronExpression {
|
||||
trigger_id: id.to_string(),
|
||||
expression: trigger.expression.clone(),
|
||||
|
|
@ -390,10 +401,6 @@ fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationVal
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
|
|
@ -417,9 +424,13 @@ mod tests {
|
|||
}
|
||||
|
||||
fn schedule_trigger(id: &str, cron: &str) -> AutomationTrigger {
|
||||
schedule_trigger_with_enabled(id, cron, true)
|
||||
}
|
||||
|
||||
fn schedule_trigger_with_enabled(id: &str, cron: &str, enabled: bool) -> AutomationTrigger {
|
||||
AutomationTrigger::Schedule(ScheduleTrigger {
|
||||
id: AutomationTriggerId::new(id).unwrap(),
|
||||
enabled: true,
|
||||
id: AutomationTriggerId::new(id).unwrap(),
|
||||
enabled,
|
||||
expression: cron.to_string(),
|
||||
})
|
||||
}
|
||||
|
|
@ -450,30 +461,72 @@ expression = "0 0 * * *"
|
|||
Automation::from_toml_bytes(AutomationId::new("nightly").unwrap(), bytes).unwrap();
|
||||
|
||||
assert_eq!(automation.description, None);
|
||||
assert!(automation.enabled);
|
||||
assert!(automation.triggers.iter().all(AutomationTrigger::enabled));
|
||||
|
||||
let toml = automation.to_toml_string().unwrap();
|
||||
assert!(!top_level_lines(&toml).any(|line| line.starts_with("id = ")));
|
||||
assert!(!top_level_lines(&toml).any(|line| line.starts_with("revision = ")));
|
||||
assert!(toml.contains("enabled = true"));
|
||||
assert!(!top_level_lines(&toml).any(|line| line.starts_with("enabled = ")));
|
||||
assert!(toml.contains("type = \"api\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_toml_rejects_legacy_top_level_enabled() {
|
||||
let bytes = br#"
|
||||
name = "Legacy"
|
||||
enabled = false
|
||||
|
||||
[target]
|
||||
repository = "fabro-sh/fabro"
|
||||
ref = "main"
|
||||
workflow = "release"
|
||||
|
||||
[[triggers]]
|
||||
type = "api"
|
||||
id = "manual"
|
||||
enabled = true
|
||||
"#;
|
||||
|
||||
let result = Automation::from_toml_bytes(AutomationId::new("legacy").unwrap(), bytes);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_schedule_triggers_returns_only_enabled_schedule_triggers() {
|
||||
let (automation, _) =
|
||||
Automation::from_replace(AutomationId::new("nightly").unwrap(), AutomationReplace {
|
||||
name: "Nightly".to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
triggers: vec![
|
||||
api_trigger("manual"),
|
||||
schedule_trigger_with_enabled("nightly", "0 0 * * *", true),
|
||||
schedule_trigger_with_enabled("disabled", "0 1 * * *", false),
|
||||
],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let trigger_ids = automation
|
||||
.enabled_schedule_triggers()
|
||||
.map(|trigger| trigger.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(trigger_ids, vec!["nightly"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_invalid_inputs() {
|
||||
let cases = [
|
||||
AutomationReplace {
|
||||
name: " ".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
target: target(),
|
||||
triggers: vec![api_trigger("manual")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad repo".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
target: AutomationTarget {
|
||||
repository: "not/github/slug".to_string(),
|
||||
ref_selector: "main".to_string(),
|
||||
|
|
@ -484,7 +537,6 @@ expression = "0 0 * * *"
|
|||
AutomationReplace {
|
||||
name: "Bad ref".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
target: AutomationTarget {
|
||||
repository: "fabro-sh/fabro".to_string(),
|
||||
ref_selector: "main;rm".to_string(),
|
||||
|
|
@ -495,7 +547,6 @@ expression = "0 0 * * *"
|
|||
AutomationReplace {
|
||||
name: "Bad workflow".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
target: AutomationTarget {
|
||||
repository: "fabro-sh/fabro".to_string(),
|
||||
ref_selector: "main".to_string(),
|
||||
|
|
@ -506,7 +557,6 @@ expression = "0 0 * * *"
|
|||
AutomationReplace {
|
||||
name: "Duplicate trigger".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
target: target(),
|
||||
triggers: vec![
|
||||
api_trigger("manual"),
|
||||
|
|
@ -516,21 +566,18 @@ expression = "0 0 * * *"
|
|||
AutomationReplace {
|
||||
name: "Two API triggers".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
target: target(),
|
||||
triggers: vec![api_trigger("one"), api_trigger("two")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Six field cron".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
target: target(),
|
||||
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
|
||||
},
|
||||
AutomationReplace {
|
||||
name: "Bad cron".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
target: target(),
|
||||
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -290,7 +290,6 @@ mod tests {
|
|||
id: AutomationId::new(id).unwrap(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
target: target(),
|
||||
triggers: vec![
|
||||
AutomationTrigger::Api(ApiTrigger {
|
||||
|
|
@ -310,7 +309,6 @@ mod tests {
|
|||
AutomationReplace {
|
||||
name: name.to_string(),
|
||||
description: Some("updated".to_string()),
|
||||
enabled: false,
|
||||
target: target(),
|
||||
triggers: vec![AutomationTrigger::Api(ApiTrigger {
|
||||
id: AutomationTriggerId::new("manual").unwrap(),
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ impl Environment {
|
|||
) -> Result<(Self, Vec<u8>), EnvironmentStoreError> {
|
||||
let settings = inline_dense_dockerfile(settings, dockerfile_base_dir).await?;
|
||||
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((
|
||||
|
|
|
|||
|
|
@ -404,8 +404,8 @@ mod tests {
|
|||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
|
||||
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
|
||||
EnvironmentSettings,
|
||||
EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider,
|
||||
EnvironmentResourcesSettings, EnvironmentSettings,
|
||||
};
|
||||
use tokio::fs;
|
||||
|
||||
|
|
@ -572,6 +572,28 @@ path = "Dockerfile"
|
|||
assert!(matches!(err, EnvironmentStoreError::AlreadyExists { .. }));
|
||||
}
|
||||
|
||||
#[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 mut settings = settings(EnvironmentProvider::Local);
|
||||
settings.network.mode = EnvironmentNetworkMode::Block;
|
||||
|
||||
let err = store
|
||||
.create(EnvironmentDraft {
|
||||
id: EnvironmentId::new("invalid").unwrap(),
|
||||
settings,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("local environments cannot enforce")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_stale_revision_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ fabro-http.workspace = true
|
|||
fabro-redact.workspace = true
|
||||
fabro-static.workspace = true
|
||||
chrono.workspace = true
|
||||
croner.workspace = true
|
||||
futures-util.workspace = true
|
||||
axum = { workspace = true, features = ["ws"] }
|
||||
axum-extra.workspace = true
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECR
|
|||
use crate::server::{
|
||||
AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state,
|
||||
build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers,
|
||||
spawn_scheduler,
|
||||
spawn_automation_scheduler, spawn_scheduler,
|
||||
};
|
||||
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
|
||||
use crate::startup::{prepare_startup_vault, resolve_startup, validate_startup_configuration};
|
||||
|
|
@ -789,6 +789,7 @@ where
|
|||
);
|
||||
}
|
||||
spawn_scheduler(Arc::clone(&state));
|
||||
spawn_automation_scheduler(Arc::clone(&state));
|
||||
let router = build_router_with_options(Arc::clone(&state), &auth_mode, RouterOptions {
|
||||
web_enabled,
|
||||
#[cfg(debug_assertions)]
|
||||
|
|
|
|||
|
|
@ -168,10 +168,12 @@ use crate::{
|
|||
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
|
||||
};
|
||||
|
||||
mod automation_scheduler;
|
||||
mod handler;
|
||||
mod resource_sampler;
|
||||
mod session_runtime;
|
||||
|
||||
pub(crate) use automation_scheduler::spawn_automation_scheduler;
|
||||
pub(crate) use handler::events::EventListParams;
|
||||
#[cfg(test)]
|
||||
pub(in crate::server) use handler::events::filtered_global_events;
|
||||
|
|
@ -1073,6 +1075,7 @@ pub struct AppState {
|
|||
pub(crate) worker_control_bus: Arc<dyn WorkerControlBus>,
|
||||
pub(crate) worker_runtime: Arc<dyn WorkerRuntime>,
|
||||
scheduler_notify: Notify,
|
||||
automation_scheduler_notify: Notify,
|
||||
global_event_tx: broadcast::Sender<EventEnvelope>,
|
||||
/// Per-run coalescing registry for `GET /runs/{id}/files`. Concurrent
|
||||
/// callers for the same run share one materialization; different runs
|
||||
|
|
@ -1136,6 +1139,16 @@ impl AppState {
|
|||
.materialize(input)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn notify_automation_scheduler(&self) {
|
||||
self.automation_scheduler_notify.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) fn automation_scheduler_notified(
|
||||
&self,
|
||||
) -> impl std::future::Future<Output = ()> + '_ {
|
||||
self.automation_scheduler_notify.notified()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AskFabroReadiness {
|
||||
|
|
@ -1317,6 +1330,18 @@ impl AppState {
|
|||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_manifest_run_settings_from_environment_catalog(&self) {
|
||||
let manifest_run_defaults = self.manifest_run_defaults();
|
||||
let manifest_run_settings = resolve_manifest_run_settings_with_catalog(
|
||||
manifest_run_defaults.as_ref(),
|
||||
&self.environment_store,
|
||||
);
|
||||
*self
|
||||
.manifest_run_settings
|
||||
.write()
|
||||
.expect("manifest run settings lock poisoned") = manifest_run_settings;
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Result<fabro_http::HttpClient, fabro_http::HttpClientBuildError> {
|
||||
match &self.http_client {
|
||||
Some(client) => Ok(client.clone()),
|
||||
|
|
@ -1331,6 +1356,14 @@ impl AppState {
|
|||
)
|
||||
}
|
||||
|
||||
/// Scratch directory used by the automation materializer when staging
|
||||
/// per-run manifests. Shared by API-triggered and scheduled fires.
|
||||
pub(crate) fn automation_temp_root(&self) -> PathBuf {
|
||||
Storage::new(self.server_storage_dir())
|
||||
.scratch_dir()
|
||||
.join("automations")
|
||||
}
|
||||
|
||||
/// Snapshotted at create-time so attach replays surface the same link
|
||||
/// even if `server.web.url` is later changed. `None` when the UI is
|
||||
/// turned off or `server.web.url` is unset/invalid.
|
||||
|
|
@ -1518,6 +1551,7 @@ impl AppState {
|
|||
fn begin_shutdown(&self) {
|
||||
self.shutting_down.store(true, Ordering::Relaxed);
|
||||
self.scheduler_notify.notify_waiters();
|
||||
self.automation_scheduler_notify.notify_waiters();
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown_token(&self) -> CancellationToken {
|
||||
|
|
@ -2413,6 +2447,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
worker_control_bus,
|
||||
worker_runtime,
|
||||
scheduler_notify: Notify::new(),
|
||||
automation_scheduler_notify: Notify::new(),
|
||||
global_event_tx,
|
||||
files_in_flight: new_files_in_flight(),
|
||||
pull_request_create_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
|
|
|
|||
702
lib/crates/fabro-server/src/server/automation_scheduler.rs
Normal file
702
lib/crates/fabro-server/src/server/automation_scheduler.rs
Normal file
|
|
@ -0,0 +1,702 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use chrono::{DateTime, Utc};
|
||||
use croner::errors::CronError;
|
||||
use fabro_automation::{
|
||||
Automation, AutomationId, AutomationRevision, AutomationTriggerId, parse_schedule_expression,
|
||||
};
|
||||
use fabro_types::{AutomationRef, Principal, RunId, SystemActorKind};
|
||||
use tokio::time::sleep;
|
||||
use tracing::{Instrument, info, info_span, warn};
|
||||
|
||||
use super::{AppState, handler};
|
||||
use crate::automation_materializer::AutomationRunMaterializeInput;
|
||||
|
||||
const AUTOMATION_SCHEDULER_MAX_SLEEP: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ScheduleTriggerKey {
|
||||
automation_id: AutomationId,
|
||||
trigger_id: AutomationTriggerId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ScheduleCursor {
|
||||
automation_revision: AutomationRevision,
|
||||
expression: String,
|
||||
next_due_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DueScheduleTrigger {
|
||||
automation: Automation,
|
||||
trigger_id: AutomationTriggerId,
|
||||
due_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct AutomationSchedulePlanner {
|
||||
cursors: HashMap<ScheduleTriggerKey, ScheduleCursor>,
|
||||
}
|
||||
|
||||
fn next_occurrence(expression: &str, after: DateTime<Utc>) -> Result<DateTime<Utc>, CronError> {
|
||||
parse_schedule_expression(expression)?.find_next_occurrence(&after, false)
|
||||
}
|
||||
|
||||
impl AutomationSchedulePlanner {
|
||||
fn reconcile(&mut self, automations: &[Automation], now: DateTime<Utc>) {
|
||||
let mut reconciled = HashMap::new();
|
||||
|
||||
for automation in automations {
|
||||
for trigger in automation.enabled_schedule_triggers() {
|
||||
let key = ScheduleTriggerKey {
|
||||
automation_id: automation.id.clone(),
|
||||
trigger_id: trigger.id.clone(),
|
||||
};
|
||||
if let Some(cursor) = self.cursors.get(&key).filter(|cursor| {
|
||||
cursor.automation_revision == automation.revision
|
||||
&& cursor.expression == trigger.expression
|
||||
}) {
|
||||
reconciled.insert(key, cursor.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
let next_due_at = match next_occurrence(&trigger.expression, now) {
|
||||
Ok(next_due_at) => next_due_at,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
automation_id = %automation.id,
|
||||
trigger_id = %trigger.id,
|
||||
error = %err,
|
||||
"Skipping invalid automation schedule trigger",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
reconciled.insert(key, ScheduleCursor {
|
||||
automation_revision: automation.revision.clone(),
|
||||
expression: trigger.expression.clone(),
|
||||
next_due_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.cursors = reconciled;
|
||||
}
|
||||
|
||||
fn take_due(
|
||||
&mut self,
|
||||
automations: &[Automation],
|
||||
now: DateTime<Utc>,
|
||||
) -> Vec<DueScheduleTrigger> {
|
||||
let mut due_keys = self
|
||||
.cursors
|
||||
.iter()
|
||||
.filter(|(_, cursor)| cursor.next_due_at <= now)
|
||||
.map(|(key, cursor)| (key.clone(), cursor.next_due_at))
|
||||
.collect::<Vec<_>>();
|
||||
// Deterministic order for spawn scheduling, log output, and tests.
|
||||
due_keys.sort_by(|a, b| {
|
||||
a.0.automation_id
|
||||
.cmp(&b.0.automation_id)
|
||||
.then_with(|| a.0.trigger_id.cmp(&b.0.trigger_id))
|
||||
});
|
||||
if due_keys.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let automations_by_id = automations
|
||||
.iter()
|
||||
.map(|automation| (&automation.id, automation))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut due = Vec::with_capacity(due_keys.len());
|
||||
for (key, due_at) in due_keys {
|
||||
let Some(cursor) = self.cursors.get_mut(&key) else {
|
||||
continue;
|
||||
};
|
||||
match next_occurrence(&cursor.expression, now) {
|
||||
Ok(next_due_at) => {
|
||||
cursor.next_due_at = next_due_at;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
automation_id = %key.automation_id,
|
||||
trigger_id = %key.trigger_id,
|
||||
error = %err,
|
||||
"Removing automation schedule cursor after next occurrence failed",
|
||||
);
|
||||
self.cursors.remove(&key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(automation) = automations_by_id.get(&key.automation_id) else {
|
||||
continue;
|
||||
};
|
||||
due.push(DueScheduleTrigger {
|
||||
automation: (*automation).clone(),
|
||||
trigger_id: key.trigger_id,
|
||||
due_at,
|
||||
});
|
||||
}
|
||||
|
||||
due
|
||||
}
|
||||
|
||||
/// Reconcile cursors against the current automation set, then drain due
|
||||
/// triggers. Single entry point used by the production loop and tests.
|
||||
fn tick(&mut self, automations: &[Automation], now: DateTime<Utc>) -> Vec<DueScheduleTrigger> {
|
||||
self.reconcile(automations, now);
|
||||
self.take_due(automations, now)
|
||||
}
|
||||
|
||||
fn sleep_duration(&self, now: DateTime<Utc>) -> Duration {
|
||||
let until_next_due = self
|
||||
.cursors
|
||||
.values()
|
||||
.map(|cursor| cursor.next_due_at)
|
||||
.min()
|
||||
.map_or(AUTOMATION_SCHEDULER_MAX_SLEEP, |next_due_at| {
|
||||
if next_due_at <= now {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
(next_due_at - now).to_std().unwrap_or(Duration::ZERO)
|
||||
}
|
||||
});
|
||||
until_next_due.min(AUTOMATION_SCHEDULER_MAX_SLEEP)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_automation_scheduler(state: Arc<AppState>) {
|
||||
tokio::spawn(async move {
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
let shutdown = state.shutdown_token();
|
||||
|
||||
loop {
|
||||
if state.is_shutting_down() {
|
||||
break;
|
||||
}
|
||||
|
||||
let automations = state.automation_store().list().await;
|
||||
let now = Utc::now();
|
||||
for due in planner.tick(&automations, now) {
|
||||
let state = Arc::clone(&state);
|
||||
let span = info_span!(
|
||||
"automation_run",
|
||||
automation_id = %due.automation.id,
|
||||
trigger_id = %due.trigger_id,
|
||||
);
|
||||
tokio::spawn(
|
||||
fire_scheduled_automation_run(
|
||||
state,
|
||||
due.automation,
|
||||
due.trigger_id,
|
||||
due.due_at,
|
||||
)
|
||||
.instrument(span),
|
||||
);
|
||||
}
|
||||
|
||||
let sleep_duration = planner.sleep_duration(now);
|
||||
tokio::select! {
|
||||
() = shutdown.cancelled() => break,
|
||||
() = state.automation_scheduler_notified() => {},
|
||||
() = sleep(sleep_duration) => {},
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn fire_scheduled_automation_run(
|
||||
state: Arc<AppState>,
|
||||
automation: Automation,
|
||||
trigger_id: AutomationTriggerId,
|
||||
due_at: DateTime<Utc>,
|
||||
) {
|
||||
let automation_id = automation.id.clone();
|
||||
let run_id = RunId::new();
|
||||
let materialized = match state
|
||||
.materialize_automation_run(AutomationRunMaterializeInput {
|
||||
automation_id: automation_id.clone(),
|
||||
target: automation.target.clone(),
|
||||
run_id,
|
||||
user_settings_path: state.active_config_path().to_path_buf(),
|
||||
temp_root: state.automation_temp_root(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(materialized) => materialized,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
due_at = %due_at,
|
||||
error = %err,
|
||||
"Failed to materialize scheduled automation run",
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let explicit_title_supplied = materialized.manifest.title.is_some();
|
||||
let actor = Principal::System {
|
||||
system_kind: SystemActorKind::Engine,
|
||||
};
|
||||
let automation_ref = AutomationRef {
|
||||
id: automation_id.to_string(),
|
||||
name: Some(automation.name.clone()),
|
||||
trigger_id: Some(trigger_id.to_string()),
|
||||
};
|
||||
// `create_run_from_manifest` produces a large future; box it to keep our
|
||||
// stack frame small (matches handler/automations.rs).
|
||||
let response = Box::pin(handler::runs::create_run_from_manifest(
|
||||
Arc::clone(&state),
|
||||
handler::runs::CreateRunFromManifestRequest {
|
||||
manifest: materialized.manifest,
|
||||
submitted_manifest_bytes: materialized.submitted_manifest_bytes,
|
||||
explicit_run_id: Some(run_id),
|
||||
explicit_title_supplied,
|
||||
actor: actor.clone(),
|
||||
headers: HeaderMap::new(),
|
||||
automation: Some(automation_ref),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
warn!(
|
||||
run_id = %run_id,
|
||||
due_at = %due_at,
|
||||
status = %status,
|
||||
"Failed to create scheduled automation run",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) =
|
||||
handler::lifecycle::queue_run_start(state.as_ref(), run_id, false, actor).await
|
||||
{
|
||||
warn!(
|
||||
run_id = %run_id,
|
||||
due_at = %due_at,
|
||||
status = %err.status(),
|
||||
code = err.code().unwrap_or(""),
|
||||
"Created scheduled automation run but failed to start it",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
run_id = %run_id,
|
||||
due_at = %due_at,
|
||||
"Scheduled automation run queued",
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive one tick of the scheduler from a test. Boxed so the calling test
|
||||
/// future stays small (clippy `large_futures`).
|
||||
#[cfg(test)]
|
||||
fn run_due_schedules_once<'a>(
|
||||
state: Arc<AppState>,
|
||||
planner: &'a mut AutomationSchedulePlanner,
|
||||
now: DateTime<Utc>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let automations = state.automation_store().list().await;
|
||||
for trigger in planner.tick(&automations, now) {
|
||||
Box::pin(fire_scheduled_automation_run(
|
||||
Arc::clone(&state),
|
||||
trigger.automation,
|
||||
trigger.trigger_id,
|
||||
trigger.due_at,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_api::types::RunManifest;
|
||||
use fabro_automation::{AutomationDraft, AutomationTarget, AutomationTrigger, ScheduleTrigger};
|
||||
use fabro_store::ListRunsQuery;
|
||||
use fabro_types::RunStatus;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::{TestAppStateBuilder, TestAutomationRunMaterializer};
|
||||
|
||||
fn dt(value: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.expect("test datetime should parse")
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn target() -> AutomationTarget {
|
||||
AutomationTarget {
|
||||
repository: "fabro-sh/fabro".to_string(),
|
||||
ref_selector: "main".to_string(),
|
||||
workflow: "workflow.fabro".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_trigger(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
|
||||
AutomationTrigger::Schedule(ScheduleTrigger {
|
||||
id: AutomationTriggerId::new(id).expect("test trigger id should be valid"),
|
||||
enabled,
|
||||
expression: expression.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn automation(id: &str, name: &str, triggers: Vec<AutomationTrigger>) -> Automation {
|
||||
Automation {
|
||||
id: AutomationId::new(id).expect("test automation id should be valid"),
|
||||
revision: AutomationRevision::from_bytes(format!("{id}:{name}").as_bytes()),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
triggers,
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_automation(
|
||||
state: &AppState,
|
||||
id: &str,
|
||||
name: &str,
|
||||
triggers: Vec<AutomationTrigger>,
|
||||
) -> Automation {
|
||||
state
|
||||
.automation_store()
|
||||
.create(AutomationDraft {
|
||||
id: AutomationId::new(id).expect("test automation id should be valid"),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
target: target(),
|
||||
triggers,
|
||||
})
|
||||
.await
|
||||
.expect("test automation should be created")
|
||||
}
|
||||
|
||||
fn minimal_manifest() -> RunManifest {
|
||||
serde_json::from_value(json!({
|
||||
"version": 1,
|
||||
"cwd": "/tmp",
|
||||
"target": {
|
||||
"identifier": "workflow.fabro",
|
||||
"path": "workflow.fabro",
|
||||
},
|
||||
"workflows": {
|
||||
"workflow.fabro": {
|
||||
"source": r#"digraph Test {
|
||||
graph [goal="Test"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#,
|
||||
"files": {},
|
||||
},
|
||||
},
|
||||
}))
|
||||
.expect("minimal manifest should deserialize")
|
||||
}
|
||||
|
||||
fn succeeding_materializer() -> TestAutomationRunMaterializer {
|
||||
let manifest = minimal_manifest();
|
||||
let submitted_manifest_bytes =
|
||||
serde_json::to_vec(&manifest).expect("manifest should serialize");
|
||||
TestAutomationRunMaterializer::succeed(manifest, submitted_manifest_bytes)
|
||||
}
|
||||
|
||||
fn test_state_with_materializer(materializer: TestAutomationRunMaterializer) -> Arc<AppState> {
|
||||
TestAppStateBuilder::new()
|
||||
.env_lookup(|_| None)
|
||||
.automation_materializer(materializer)
|
||||
.build()
|
||||
}
|
||||
|
||||
async fn cached_runs(state: &AppState) -> Vec<fabro_types::Run> {
|
||||
state
|
||||
.store
|
||||
.list_cached_runs(&ListRunsQuery::default(), Utc::now())
|
||||
.await
|
||||
.expect("cached runs should list")
|
||||
.into_iter()
|
||||
.map(|entry| entry.summary)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prime_time() -> DateTime<Utc> {
|
||||
dt("2026-05-29T00:00:30Z")
|
||||
}
|
||||
|
||||
fn first_due_time() -> DateTime<Utc> {
|
||||
dt("2026-05-29T00:01:00Z")
|
||||
}
|
||||
|
||||
fn second_due_time() -> DateTime<Utc> {
|
||||
dt("2026-05-29T00:02:00Z")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_cursor_starts_at_next_future_occurrence_without_backfill() {
|
||||
let now = dt("2026-05-29T00:00:30Z");
|
||||
let automation = automation("nightly", "Nightly", vec![schedule_trigger(
|
||||
"schedule",
|
||||
"* * * * *",
|
||||
true,
|
||||
)]);
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
planner.reconcile(&[automation], now);
|
||||
|
||||
assert_eq!(planner.cursors.len(), 1);
|
||||
let cursor = planner.cursors.values().next().unwrap();
|
||||
assert_eq!(cursor.next_due_at, dt("2026-05-29T00:01:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn due_cursor_is_returned_once_and_advanced_beyond_now() {
|
||||
let automation = automation("nightly", "Nightly", vec![schedule_trigger(
|
||||
"schedule",
|
||||
"* * * * *",
|
||||
true,
|
||||
)]);
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
planner.reconcile(std::slice::from_ref(&automation), prime_time());
|
||||
|
||||
let due = planner.take_due(std::slice::from_ref(&automation), first_due_time());
|
||||
let second_due = planner.take_due(std::slice::from_ref(&automation), first_due_time());
|
||||
|
||||
assert_eq!(due.len(), 1);
|
||||
assert_eq!(due[0].trigger_id.as_str(), "schedule");
|
||||
assert_eq!(due[0].due_at, first_due_time());
|
||||
assert!(second_due.is_empty());
|
||||
let cursor = planner.cursors.values().next().unwrap();
|
||||
assert_eq!(cursor.next_due_at, second_due_time());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_schedule_trigger_removes_cursor() {
|
||||
let mut automation = automation("nightly", "Nightly", vec![schedule_trigger(
|
||||
"schedule",
|
||||
"* * * * *",
|
||||
true,
|
||||
)]);
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
planner.reconcile(std::slice::from_ref(&automation), prime_time());
|
||||
assert_eq!(planner.cursors.len(), 1);
|
||||
|
||||
automation.triggers = vec![schedule_trigger("schedule", "* * * * *", false)];
|
||||
planner.reconcile(std::slice::from_ref(&automation), first_due_time());
|
||||
assert!(planner.cursors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_automation_revision_or_expression_resets_cursor() {
|
||||
let mut automation = automation("nightly", "Nightly", vec![schedule_trigger(
|
||||
"schedule",
|
||||
"* * * * *",
|
||||
true,
|
||||
)]);
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
planner.reconcile(std::slice::from_ref(&automation), prime_time());
|
||||
|
||||
let original_due = planner.cursors.values().next().unwrap().next_due_at;
|
||||
automation.revision = AutomationRevision::from_bytes(b"new revision");
|
||||
planner.reconcile(std::slice::from_ref(&automation), first_due_time());
|
||||
let reset_due = planner.cursors.values().next().unwrap().next_due_at;
|
||||
|
||||
assert_eq!(original_due, first_due_time());
|
||||
assert_eq!(reset_due, second_due_time());
|
||||
|
||||
automation.triggers = vec![schedule_trigger("schedule", "*/5 * * * *", true)];
|
||||
planner.reconcile(std::slice::from_ref(&automation), second_due_time());
|
||||
let expression_reset_due = planner.cursors.values().next().unwrap().next_due_at;
|
||||
assert_eq!(expression_reset_due, dt("2026-05-29T00:05:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_schedule_triggers_on_one_automation_have_independent_cursors() {
|
||||
let automation = automation("nightly", "Nightly", vec![
|
||||
schedule_trigger("every_minute", "* * * * *", true),
|
||||
schedule_trigger("every_five", "*/5 * * * *", true),
|
||||
]);
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
planner.reconcile(std::slice::from_ref(&automation), prime_time());
|
||||
let due = planner.take_due(std::slice::from_ref(&automation), first_due_time());
|
||||
|
||||
assert_eq!(planner.cursors.len(), 2);
|
||||
assert_eq!(due.len(), 1);
|
||||
assert_eq!(due[0].trigger_id.as_str(), "every_minute");
|
||||
let five_minute_cursor = planner
|
||||
.cursors
|
||||
.iter()
|
||||
.find(|(key, _)| key.trigger_id.as_str() == "every_five")
|
||||
.map(|(_, cursor)| cursor)
|
||||
.unwrap();
|
||||
assert_eq!(five_minute_cursor.next_due_at, dt("2026-05-29T00:05:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_duration_uses_nearest_due_time_capped_at_thirty_seconds() {
|
||||
let automation = automation("nightly", "Nightly", vec![schedule_trigger(
|
||||
"schedule",
|
||||
"* * * * *",
|
||||
true,
|
||||
)]);
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
planner.reconcile(std::slice::from_ref(&automation), prime_time());
|
||||
|
||||
assert_eq!(
|
||||
planner.sleep_duration(prime_time()),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
assert_eq!(
|
||||
planner.sleep_duration(dt("2026-05-29T00:00:45Z")),
|
||||
Duration::from_secs(15)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn due_schedule_only_automation_creates_started_run_with_automation_metadata() {
|
||||
let materializer = succeeding_materializer();
|
||||
let state = test_state_with_materializer(materializer);
|
||||
create_automation(state.as_ref(), "nightly", "Nightly", vec![
|
||||
schedule_trigger("schedule", "* * * * *", true),
|
||||
])
|
||||
.await;
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
|
||||
|
||||
let runs = cached_runs(state.as_ref()).await;
|
||||
assert_eq!(runs.len(), 1);
|
||||
let automation_ref = runs[0].automation.as_ref().unwrap();
|
||||
assert_eq!(automation_ref.id, "nightly");
|
||||
assert_eq!(automation_ref.name.as_deref(), Some("Nightly"));
|
||||
assert_eq!(automation_ref.trigger_id.as_deref(), Some("schedule"));
|
||||
let run_id = runs[0].id;
|
||||
assert!(matches!(
|
||||
state
|
||||
.runs
|
||||
.lock()
|
||||
.expect("runs lock should not be poisoned")
|
||||
.get(&run_id)
|
||||
.map(|run| run.status),
|
||||
Some(RunStatus::Runnable)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_only_automation_fires_without_api_trigger() {
|
||||
let materializer = succeeding_materializer();
|
||||
let state = test_state_with_materializer(materializer);
|
||||
create_automation(state.as_ref(), "schedule-only", "Schedule only", vec![
|
||||
schedule_trigger("schedule", "* * * * *", true),
|
||||
])
|
||||
.await;
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
|
||||
|
||||
assert_eq!(cached_runs(state.as_ref()).await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_schedule_trigger_does_not_create_run() {
|
||||
let materializer = succeeding_materializer();
|
||||
let state = test_state_with_materializer(materializer);
|
||||
create_automation(
|
||||
state.as_ref(),
|
||||
"disabled-trigger",
|
||||
"Disabled trigger",
|
||||
vec![schedule_trigger("schedule", "* * * * *", false)],
|
||||
)
|
||||
.await;
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
|
||||
|
||||
assert!(cached_runs(state.as_ref()).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_due_triggers_create_multiple_runs() {
|
||||
let materializer = succeeding_materializer();
|
||||
let state = test_state_with_materializer(materializer);
|
||||
create_automation(state.as_ref(), "nightly", "Nightly", vec![
|
||||
schedule_trigger("first", "* * * * *", true),
|
||||
schedule_trigger("second", "* * * * *", true),
|
||||
])
|
||||
.await;
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
|
||||
|
||||
let mut trigger_ids = cached_runs(state.as_ref())
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|run| run.automation.unwrap().trigger_id.unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
trigger_ids.sort();
|
||||
assert_eq!(trigger_ids, ["first", "second"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_prior_run_does_not_suppress_new_due_run() {
|
||||
let materializer = succeeding_materializer();
|
||||
let state = test_state_with_materializer(materializer);
|
||||
create_automation(state.as_ref(), "nightly", "Nightly", vec![
|
||||
schedule_trigger("schedule", "* * * * *", true),
|
||||
])
|
||||
.await;
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
|
||||
assert_eq!(cached_runs(state.as_ref()).await.len(), 1);
|
||||
assert!(
|
||||
state
|
||||
.runs
|
||||
.lock()
|
||||
.expect("runs lock should not be poisoned")
|
||||
.values()
|
||||
.any(|run| run.status == RunStatus::Runnable)
|
||||
);
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, second_due_time()).await;
|
||||
|
||||
assert_eq!(cached_runs(state.as_ref()).await.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failing_materializer_waits_until_next_cron_occurrence() {
|
||||
let materializer = TestAutomationRunMaterializer::fail_invalid_target("boom");
|
||||
let state = test_state_with_materializer(materializer.clone());
|
||||
create_automation(state.as_ref(), "nightly", "Nightly", vec![
|
||||
schedule_trigger("schedule", "* * * * *", true),
|
||||
])
|
||||
.await;
|
||||
let mut planner = AutomationSchedulePlanner::default();
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
|
||||
|
||||
assert!(cached_runs(state.as_ref()).await.is_empty());
|
||||
assert_eq!(materializer.captured_inputs().len(), 1);
|
||||
|
||||
run_due_schedules_once(Arc::clone(&state), &mut planner, second_due_time()).await;
|
||||
|
||||
assert!(cached_runs(state.as_ref()).await.is_empty());
|
||||
assert_eq!(materializer.captured_inputs().len(), 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue, header};
|
||||
use axum::http::HeaderMap;
|
||||
use axum_extra::extract::Query as ExtraQuery;
|
||||
use chrono::Utc;
|
||||
use fabro_automation::{
|
||||
Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
|
||||
AutomationStoreError,
|
||||
Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationStoreError,
|
||||
};
|
||||
use fabro_config::Storage;
|
||||
use fabro_types::{AutomationRef, RunId};
|
||||
use serde::Serialize;
|
||||
|
||||
|
|
@ -15,7 +13,7 @@ use super::super::{
|
|||
ApiError, AppState, IntoResponse, Json, PaginationParams, Path, RequiredUser, Response, Router,
|
||||
State, StatusCode, get, paginate_items,
|
||||
};
|
||||
use super::{lifecycle, runs};
|
||||
use super::{json_with_etag_response, lifecycle, parse_required_if_match, runs};
|
||||
use crate::automation_materializer::AutomationRunMaterializeInput;
|
||||
use crate::principal_middleware::RequiredRunToolActor;
|
||||
|
||||
|
|
@ -133,7 +131,7 @@ async fn create_automation_run(
|
|||
let Some(api_trigger) = automation.enabled_api_trigger() else {
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"automation is disabled or has no enabled API trigger",
|
||||
"automation has no enabled API trigger",
|
||||
"automation_api_trigger_disabled",
|
||||
)
|
||||
.into_response();
|
||||
|
|
@ -141,16 +139,13 @@ async fn create_automation_run(
|
|||
let api_trigger_id = api_trigger.id.to_string();
|
||||
|
||||
let run_id = RunId::new();
|
||||
let temp_root = Storage::new(state.server_storage_dir())
|
||||
.scratch_dir()
|
||||
.join("automations");
|
||||
let materialized = match state
|
||||
.materialize_automation_run(AutomationRunMaterializeInput {
|
||||
automation_id: automation.id.clone(),
|
||||
target: automation.target.clone(),
|
||||
run_id,
|
||||
user_settings_path: state.active_config_path().to_path_buf(),
|
||||
temp_root,
|
||||
temp_root: state.automation_temp_root(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
|
@ -204,6 +199,7 @@ async fn create_automation(
|
|||
Json(draft): Json<AutomationDraft>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let automation = state.automation_store().create(draft).await?;
|
||||
state.notify_automation_scheduler();
|
||||
Ok((StatusCode::CREATED, Json(automation)).into_response())
|
||||
}
|
||||
|
||||
|
|
@ -227,11 +223,12 @@ async fn replace_automation(
|
|||
Json(replacement): Json<AutomationReplace>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let id = parse_path_id(id)?;
|
||||
let expected = parse_required_if_match(&headers, &id)?;
|
||||
let expected = parse_required_if_match(&headers, "automation", &id)?;
|
||||
let automation = state
|
||||
.automation_store()
|
||||
.replace(&id, &expected, replacement)
|
||||
.await?;
|
||||
state.notify_automation_scheduler();
|
||||
Ok(automation_with_etag_response(StatusCode::OK, automation))
|
||||
}
|
||||
|
||||
|
|
@ -242,8 +239,9 @@ async fn delete_automation(
|
|||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let id = parse_path_id(id)?;
|
||||
let expected = parse_required_if_match(&headers, &id)?;
|
||||
let expected = parse_required_if_match(&headers, "automation", &id)?;
|
||||
state.automation_store().delete(&id, &expected).await?;
|
||||
state.notify_automation_scheduler();
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
|
|
@ -252,38 +250,9 @@ fn parse_path_id(id: String) -> Result<AutomationId, ApiError> {
|
|||
.map_err(|err| ApiError::bad_request(format!("invalid automation id: {err}")))
|
||||
}
|
||||
|
||||
fn parse_required_if_match(
|
||||
headers: &HeaderMap,
|
||||
id: &AutomationId,
|
||||
) -> Result<AutomationRevision, ApiError> {
|
||||
let Some(value) = headers.get(header::IF_MATCH) else {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
format!("If-Match header is required for automation: {id}"),
|
||||
));
|
||||
};
|
||||
let value = value
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::bad_request("If-Match header must be visible ASCII"))?;
|
||||
let value = unquote_etag(value.trim());
|
||||
value.parse::<AutomationRevision>().map_err(|err| {
|
||||
ApiError::bad_request(format!("invalid If-Match automation revision: {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn unquote_etag(value: &str) -> &str {
|
||||
value
|
||||
.strip_prefix('"')
|
||||
.and_then(|unquoted| unquoted.strip_suffix('"'))
|
||||
.unwrap_or(value)
|
||||
}
|
||||
|
||||
fn automation_with_etag_response(status: StatusCode, automation: Automation) -> Response {
|
||||
let etag = HeaderValue::from_str(&format!("\"{}\"", automation.revision))
|
||||
.expect("automation revisions are valid ETag header values");
|
||||
let mut response = (status, Json(automation)).into_response();
|
||||
response.headers_mut().insert(header::ETAG, etag);
|
||||
response
|
||||
let revision = automation.revision.clone();
|
||||
json_with_etag_response(status, "automation", &revision, automation)
|
||||
}
|
||||
|
||||
impl From<AutomationStoreError> for ApiError {
|
||||
|
|
|
|||
260
lib/crates/fabro-server/src/server/handler/environments.rs
Normal file
260
lib/crates/fabro-server/src/server/handler/environments.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use fabro_environment::{Environment, EnvironmentDraft, EnvironmentId, EnvironmentStoreError};
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
|
||||
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
|
||||
EnvironmentSettings, EnvironmentVolumeSettings,
|
||||
};
|
||||
use serde::de::IgnoredAny;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, IntoResponse, Json, Path, RequiredUser, Response, Router, State,
|
||||
StatusCode, get,
|
||||
};
|
||||
use super::{json_with_etag_response, parse_required_if_match};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EnvironmentListResponse {
|
||||
data: Vec<Environment>,
|
||||
meta: EnvironmentListMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EnvironmentListMeta {
|
||||
total: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateEnvironmentRequest {
|
||||
id: EnvironmentId,
|
||||
provider: EnvironmentProvider,
|
||||
image: ApiEnvironmentImageSettings,
|
||||
resources: EnvironmentResourcesSettings,
|
||||
network: EnvironmentNetworkSettings,
|
||||
lifecycle: EnvironmentLifecycleSettings,
|
||||
labels: HashMap<String, String>,
|
||||
volumes: Vec<EnvironmentVolumeSettings>,
|
||||
env: HashMap<String, InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ReplaceEnvironmentRequest {
|
||||
provider: EnvironmentProvider,
|
||||
image: ApiEnvironmentImageSettings,
|
||||
resources: EnvironmentResourcesSettings,
|
||||
network: EnvironmentNetworkSettings,
|
||||
lifecycle: EnvironmentLifecycleSettings,
|
||||
labels: HashMap<String, String>,
|
||||
volumes: Vec<EnvironmentVolumeSettings>,
|
||||
env: HashMap<String, InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ApiEnvironmentImageSettings {
|
||||
docker: Option<String>,
|
||||
dockerfile: Option<ApiDockerfileSource>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum ApiDockerfileSource {
|
||||
Inline {
|
||||
value: String,
|
||||
},
|
||||
// Recognized so the handler can return a 422 with bespoke guidance.
|
||||
// The `path` payload is parsed and discarded — never read from disk.
|
||||
Path {
|
||||
#[serde(rename = "path")]
|
||||
_path: IgnoredAny,
|
||||
},
|
||||
}
|
||||
|
||||
impl CreateEnvironmentRequest {
|
||||
fn into_draft(self) -> Result<EnvironmentDraft, ApiError> {
|
||||
Ok(EnvironmentDraft {
|
||||
id: self.id,
|
||||
settings: EnvironmentSettings {
|
||||
provider: self.provider,
|
||||
image: self.image.into_settings()?,
|
||||
resources: self.resources,
|
||||
network: self.network,
|
||||
lifecycle: self.lifecycle,
|
||||
labels: self.labels,
|
||||
volumes: self.volumes,
|
||||
env: self.env,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplaceEnvironmentRequest {
|
||||
fn into_settings(self) -> Result<EnvironmentSettings, ApiError> {
|
||||
Ok(EnvironmentSettings {
|
||||
provider: self.provider,
|
||||
image: self.image.into_settings()?,
|
||||
resources: self.resources,
|
||||
network: self.network,
|
||||
lifecycle: self.lifecycle,
|
||||
labels: self.labels,
|
||||
volumes: self.volumes,
|
||||
env: self.env,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiEnvironmentImageSettings {
|
||||
fn into_settings(self) -> Result<EnvironmentImageSettings, ApiError> {
|
||||
Ok(EnvironmentImageSettings {
|
||||
docker: self.docker,
|
||||
dockerfile: self
|
||||
.dockerfile
|
||||
.map(ApiDockerfileSource::into_settings)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiDockerfileSource {
|
||||
fn into_settings(self) -> Result<DockerfileSource, ApiError> {
|
||||
match self {
|
||||
Self::Inline { value } => Ok(DockerfileSource::Inline(value)),
|
||||
Self::Path { .. } => Err(ApiError::new(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"Dockerfile path sources are not supported by the environments REST API; use inline Dockerfile content",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/environments",
|
||||
get(list_environments).post(create_environment),
|
||||
)
|
||||
.route(
|
||||
"/environments/{id}",
|
||||
get(get_environment)
|
||||
.put(replace_environment)
|
||||
.delete(delete_environment),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_environments(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
|
||||
let data = state.environment_store().list();
|
||||
let total = data.len();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(EnvironmentListResponse {
|
||||
data,
|
||||
meta: EnvironmentListMeta { total },
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn create_environment(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateEnvironmentRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let environment = state
|
||||
.environment_store()
|
||||
.create(request.into_draft()?)
|
||||
.await?;
|
||||
state.refresh_manifest_run_settings_from_environment_catalog();
|
||||
Ok((StatusCode::CREATED, Json(environment)).into_response())
|
||||
}
|
||||
|
||||
async fn get_environment(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let id = parse_path_id(id)?;
|
||||
match state.environment_store().get(&id) {
|
||||
Some(environment) => Ok(environment_with_etag_response(StatusCode::OK, environment)),
|
||||
None => Err(ApiError::not_found(format!("environment not found: {id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn replace_environment(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<ReplaceEnvironmentRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let id = parse_path_id(id)?;
|
||||
let expected = parse_required_if_match(&headers, "environment", &id)?;
|
||||
let environment = state
|
||||
.environment_store()
|
||||
.replace(&id, &expected, request.into_settings()?)
|
||||
.await?;
|
||||
state.refresh_manifest_run_settings_from_environment_catalog();
|
||||
Ok(environment_with_etag_response(StatusCode::OK, environment))
|
||||
}
|
||||
|
||||
async fn delete_environment(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let id = parse_path_id(id)?;
|
||||
let expected = parse_required_if_match(&headers, "environment", &id)?;
|
||||
state.environment_store().delete(&id, &expected).await?;
|
||||
state.refresh_manifest_run_settings_from_environment_catalog();
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
fn parse_path_id(id: String) -> Result<EnvironmentId, ApiError> {
|
||||
EnvironmentId::new(id)
|
||||
.map_err(|err| ApiError::bad_request(format!("invalid environment id: {err}")))
|
||||
}
|
||||
|
||||
fn environment_with_etag_response(status: StatusCode, environment: Environment) -> Response {
|
||||
let revision = environment.revision.clone();
|
||||
json_with_etag_response(status, "environment", &revision, environment)
|
||||
}
|
||||
|
||||
impl From<EnvironmentStoreError> for ApiError {
|
||||
fn from(err: EnvironmentStoreError) -> Self {
|
||||
match err {
|
||||
EnvironmentStoreError::NotFound { id } => {
|
||||
Self::not_found(format!("environment not found: {id}"))
|
||||
}
|
||||
EnvironmentStoreError::AlreadyExists { id } => Self::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!("environment already exists: {id}"),
|
||||
),
|
||||
EnvironmentStoreError::StaleRevision { id, .. } => Self::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!("environment revision is stale: {id}"),
|
||||
),
|
||||
EnvironmentStoreError::Protected { id } => Self::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!("environment is protected and cannot be deleted: {id}"),
|
||||
),
|
||||
EnvironmentStoreError::Validation { source } => {
|
||||
Self::new(StatusCode::UNPROCESSABLE_ENTITY, source.to_string())
|
||||
}
|
||||
EnvironmentStoreError::InvalidFilename { .. }
|
||||
| EnvironmentStoreError::Parse { .. }
|
||||
| EnvironmentStoreError::InvalidUtf8 { .. }
|
||||
| EnvironmentStoreError::Serialize { .. }
|
||||
| EnvironmentStoreError::Io { .. } => Self::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"environment store operation failed",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ async fn start_run(
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) async fn queue_run_start(
|
||||
pub(in crate::server) async fn queue_run_start(
|
||||
state: &AppState,
|
||||
id: RunId,
|
||||
resume: bool,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::http::{HeaderMap, HeaderValue, header};
|
||||
use axum::routing::{get, post};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ApiError, AppState, IntoResponse, Response, StatusCode, demo};
|
||||
use super::{ApiError, AppState, IntoResponse, Json, Response, StatusCode, demo};
|
||||
|
||||
mod artifacts;
|
||||
mod automations;
|
||||
mod billing;
|
||||
mod completions;
|
||||
mod environments;
|
||||
pub(in crate::server) mod events;
|
||||
pub(in crate::server) mod graph;
|
||||
mod lifecycle;
|
||||
pub(in crate::server) mod lifecycle;
|
||||
mod models;
|
||||
mod pair;
|
||||
mod pull_requests;
|
||||
|
|
@ -31,6 +34,53 @@ async fn not_implemented() -> Response {
|
|||
ApiError::new(StatusCode::NOT_IMPLEMENTED, "Not implemented.").into_response()
|
||||
}
|
||||
|
||||
fn parse_required_if_match<R>(
|
||||
headers: &HeaderMap,
|
||||
resource: &str,
|
||||
id: &impl std::fmt::Display,
|
||||
) -> Result<R, ApiError>
|
||||
where
|
||||
R: std::str::FromStr,
|
||||
R::Err: std::fmt::Display,
|
||||
{
|
||||
let Some(value) = headers.get(header::IF_MATCH) else {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
format!("If-Match header is required for {resource}: {id}"),
|
||||
));
|
||||
};
|
||||
let value = value
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::bad_request("If-Match header must be visible ASCII"))?;
|
||||
let value = unquote_etag(value.trim());
|
||||
value.parse::<R>().map_err(|err| {
|
||||
ApiError::bad_request(format!("invalid If-Match {resource} revision: {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn unquote_etag(value: &str) -> &str {
|
||||
value
|
||||
.strip_prefix('"')
|
||||
.and_then(|unquoted| unquoted.strip_suffix('"'))
|
||||
.unwrap_or(value)
|
||||
}
|
||||
|
||||
fn json_with_etag_response<T>(
|
||||
status: StatusCode,
|
||||
resource: &str,
|
||||
revision: &impl std::fmt::Display,
|
||||
body: T,
|
||||
) -> Response
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let etag = HeaderValue::from_str(&format!("\"{revision}\""))
|
||||
.unwrap_or_else(|_| panic!("{resource} revisions are valid ETag header values"));
|
||||
let mut response = (status, Json(body)).into_response();
|
||||
response.headers_mut().insert(header::ETAG, etag);
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn demo_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/runs", get(demo::list_runs).post(demo::create_run_stub))
|
||||
|
|
@ -158,6 +208,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
|
|||
.merge(pull_requests::routes())
|
||||
.merge(artifacts::routes())
|
||||
.merge(automations::routes())
|
||||
.merge(environments::routes())
|
||||
.merge(sandbox::routes())
|
||||
.merge(sandboxes::routes())
|
||||
.merge(lifecycle::routes())
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ fn automation_body(id: &str, name: &str) -> Value {
|
|||
"id": id,
|
||||
"name": name,
|
||||
"description": "Runs on a schedule.",
|
||||
"enabled": true,
|
||||
"target": {
|
||||
"repository": "fabro-sh/fabro",
|
||||
"ref": "main",
|
||||
|
|
@ -45,7 +44,6 @@ fn replacement_body(name: &str) -> Value {
|
|||
json!({
|
||||
"name": name,
|
||||
"description": null,
|
||||
"enabled": false,
|
||||
"target": {
|
||||
"repository": "fabro-sh/fabro",
|
||||
"ref": "main",
|
||||
|
|
@ -289,6 +287,7 @@ async fn schedule_trigger_round_trips_through_create_list_get_and_toml() {
|
|||
assert_persisted_schedule_trigger(&persisted, "0 3 * * *", true);
|
||||
assert!(persisted.get("id").is_none());
|
||||
assert!(persisted.get("revision").is_none());
|
||||
assert!(persisted.get("enabled").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -692,21 +691,6 @@ async fn automations_routes_require_authenticated_user() {
|
|||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_automation_run_endpoint_returns_conflict_code() {
|
||||
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
||||
let mut body = automation_body("nightly", "Nightly");
|
||||
body["enabled"] = json!(false);
|
||||
create_automation_with_body(&app, &body).await;
|
||||
|
||||
let error = create_automation_run(&app, "nightly", StatusCode::CONFLICT).await;
|
||||
|
||||
assert_eq!(
|
||||
error["errors"][0]["code"],
|
||||
"automation_api_trigger_disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_automation_run_endpoint_returns_not_found() {
|
||||
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
||||
|
|
|
|||
602
lib/crates/fabro-server/tests/it/api/environments.rs
Normal file
602
lib/crates/fabro-server/tests/it/api/environments.rs
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode, header};
|
||||
use fabro_config::{RunEnvironmentLayer, RunLayer};
|
||||
use fabro_server::server::build_router;
|
||||
use fabro_server::test_support::{
|
||||
TestAppStateBuilder, build_test_router, default_test_server_settings, test_auth_mode,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::helpers::{api, checked_response, response_json, response_status};
|
||||
|
||||
fn environment_settings(provider: &str) -> Value {
|
||||
json!({
|
||||
"provider": provider,
|
||||
"image": {
|
||||
"docker": if provider == "docker" { json!("alpine:3.20") } else { Value::Null },
|
||||
"dockerfile": null
|
||||
},
|
||||
"resources": {
|
||||
"cpu": null,
|
||||
"memory": null,
|
||||
"disk": null
|
||||
},
|
||||
"network": {
|
||||
"mode": "allow_all",
|
||||
"allow": []
|
||||
},
|
||||
"lifecycle": {
|
||||
"preserve": false,
|
||||
"stop_on_terminal": true,
|
||||
"auto_stop": null
|
||||
},
|
||||
"labels": {},
|
||||
"volumes": [],
|
||||
"env": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn environment_body(id: &str, provider: &str) -> Value {
|
||||
let mut body = environment_settings(provider);
|
||||
body["id"] = json!(id);
|
||||
body
|
||||
}
|
||||
|
||||
fn environment_app() -> (axum::Router, tempfile::TempDir, PathBuf) {
|
||||
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
|
||||
let active_config_path = temp_dir.path().join("settings.toml");
|
||||
let environment_dir = temp_dir.path().join("environments");
|
||||
let state = TestAppStateBuilder::new()
|
||||
.active_config_path(active_config_path)
|
||||
.build();
|
||||
(build_test_router(state), temp_dir, environment_dir)
|
||||
}
|
||||
|
||||
fn environment_app_with_default_environment(
|
||||
environment_id: &str,
|
||||
) -> (axum::Router, tempfile::TempDir) {
|
||||
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
|
||||
let active_config_path = temp_dir.path().join("settings.toml");
|
||||
let manifest_run_defaults = RunLayer {
|
||||
environment: Some(RunEnvironmentLayer {
|
||||
id: Some(environment_id.to_string()),
|
||||
..RunEnvironmentLayer::default()
|
||||
}),
|
||||
..RunLayer::default()
|
||||
};
|
||||
let state = TestAppStateBuilder::new()
|
||||
.runtime_settings(default_test_server_settings(), manifest_run_defaults)
|
||||
.active_config_path(active_config_path)
|
||||
.build();
|
||||
(build_test_router(state), temp_dir)
|
||||
}
|
||||
|
||||
fn json_request(method: Method, path: &str, body: &Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(api(path))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(body).expect("environment fixture should serialize"),
|
||||
))
|
||||
.expect("environment JSON request should build")
|
||||
}
|
||||
|
||||
fn empty_request(method: Method, path: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(api(path))
|
||||
.body(Body::empty())
|
||||
.expect("environment request should build")
|
||||
}
|
||||
|
||||
fn request_with_if_match(
|
||||
method: Method,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
body: Option<Value>,
|
||||
) -> Request<Body> {
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(api(path))
|
||||
.header(header::IF_MATCH, revision);
|
||||
let body = match body {
|
||||
Some(value) => {
|
||||
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
||||
Body::from(serde_json::to_vec(&value).expect("environment fixture should serialize"))
|
||||
}
|
||||
None => Body::empty(),
|
||||
};
|
||||
builder
|
||||
.body(body)
|
||||
.expect("environment If-Match request should build")
|
||||
}
|
||||
|
||||
async fn create_environment(app: &axum::Router, id: &str, provider: &str) -> Value {
|
||||
create_environment_with_body(app, &environment_body(id, provider)).await
|
||||
}
|
||||
|
||||
async fn create_environment_with_body(app: &axum::Router, body: &Value) -> Value {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(json_request(Method::POST, "/environments", body))
|
||||
.await
|
||||
.expect("create environment should respond");
|
||||
response_json(response, StatusCode::CREATED, "POST /api/v1/environments").await
|
||||
}
|
||||
|
||||
fn revision_from(body: &Value) -> &str {
|
||||
body["revision"]
|
||||
.as_str()
|
||||
.expect("environment response should include a revision")
|
||||
}
|
||||
|
||||
async fn persisted_environment_toml(environment_dir: &Path, id: &str) -> toml::Value {
|
||||
let persisted = tokio::fs::read_to_string(environment_dir.join(format!("{id}.toml")))
|
||||
.await
|
||||
.expect("persisted environment TOML should be readable");
|
||||
toml::from_str(&persisted).expect("persisted environment TOML should parse")
|
||||
}
|
||||
|
||||
async fn system_info(app: &axum::Router) -> Value {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, "/system/info"))
|
||||
.await
|
||||
.expect("system info should respond");
|
||||
response_json(response, StatusCode::OK, "GET /api/v1/system/info").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_environments_returns_seeded_catalog_sorted_by_id() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
|
||||
let response = app
|
||||
.oneshot(empty_request(Method::GET, "/environments"))
|
||||
.await
|
||||
.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["data"]
|
||||
.as_array()
|
||||
.expect("environment list data should be an array")
|
||||
.iter()
|
||||
.map(|environment| environment["id"]
|
||||
.as_str()
|
||||
.expect("environment should have id"))
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["daytona", "default", "docker", "local"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_environment_persists_sibling_toml_and_is_visible() {
|
||||
let (app, _temp_dir, environment_dir) = environment_app();
|
||||
|
||||
let created = create_environment(&app, "custom-env", "docker").await;
|
||||
|
||||
assert_eq!(created["id"], "custom-env");
|
||||
assert_eq!(created["provider"], "docker");
|
||||
assert!(environment_dir.join("custom-env.toml").exists());
|
||||
|
||||
let retrieved = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, "/environments/custom-env"))
|
||||
.await
|
||||
.expect("get environment should respond");
|
||||
let retrieved = response_json(
|
||||
retrieved,
|
||||
StatusCode::OK,
|
||||
"GET /api/v1/environments/custom-env",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(retrieved["id"], "custom-env");
|
||||
|
||||
let list = app
|
||||
.oneshot(empty_request(Method::GET, "/environments"))
|
||||
.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!(
|
||||
list["data"]
|
||||
.as_array()
|
||||
.expect("environment list data should be an array")
|
||||
.iter()
|
||||
.any(|environment| environment["id"] == "custom-env")
|
||||
);
|
||||
|
||||
let persisted = persisted_environment_toml(&environment_dir, "custom-env").await;
|
||||
assert_eq!(
|
||||
persisted.get("provider").and_then(toml::Value::as_str),
|
||||
Some("docker")
|
||||
);
|
||||
assert!(persisted.get("id").is_none());
|
||||
assert!(persisted.get("revision").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_environment_returns_current_etag() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
let created = create_environment(&app, "etag-env", "local").await;
|
||||
let revision = revision_from(&created);
|
||||
|
||||
let response = app
|
||||
.oneshot(empty_request(Method::GET, "/environments/etag-env"))
|
||||
.await
|
||||
.expect("get environment should respond");
|
||||
let response = checked_response(
|
||||
response,
|
||||
StatusCode::OK,
|
||||
"GET /api/v1/environments/etag-env",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.expect("GET environment should include ETag"),
|
||||
&format!("\"{revision}\"")
|
||||
);
|
||||
let body = crate::helpers::body_json(response.into_body()).await;
|
||||
assert_eq!(body["revision"], revision);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_environment_updates_file_and_returns_new_etag() {
|
||||
let (app, _temp_dir, environment_dir) = environment_app();
|
||||
let created = create_environment(&app, "replace-env", "docker").await;
|
||||
let revision = revision_from(&created);
|
||||
let mut replacement = environment_settings("local");
|
||||
replacement["labels"] = json!({ "tier": "dev" });
|
||||
|
||||
let response = app
|
||||
.oneshot(request_with_if_match(
|
||||
Method::PUT,
|
||||
"/environments/replace-env",
|
||||
revision,
|
||||
Some(replacement),
|
||||
))
|
||||
.await
|
||||
.expect("replace environment should respond");
|
||||
let response = checked_response(
|
||||
response,
|
||||
StatusCode::OK,
|
||||
"PUT /api/v1/environments/replace-env",
|
||||
)
|
||||
.await;
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.expect("PUT environment should include ETag")
|
||||
.to_str()
|
||||
.expect("ETag should be ASCII")
|
||||
.to_string();
|
||||
let body = crate::helpers::body_json(response.into_body()).await;
|
||||
|
||||
assert_eq!(body["provider"], "local");
|
||||
assert_eq!(body["labels"]["tier"], "dev");
|
||||
assert_ne!(body["revision"], revision);
|
||||
assert_eq!(etag, format!("\"{}\"", revision_from(&body)));
|
||||
let persisted = persisted_environment_toml(&environment_dir, "replace-env").await;
|
||||
assert_eq!(
|
||||
persisted
|
||||
.get("labels")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|labels| labels.get("tier"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("dev")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_and_delete_environment_require_if_match() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
create_environment(&app, "match-env", "local").await;
|
||||
|
||||
let replace_response = app
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
Method::PUT,
|
||||
"/environments/match-env",
|
||||
&environment_settings("docker"),
|
||||
))
|
||||
.await
|
||||
.expect("replace without If-Match should respond");
|
||||
response_status(
|
||||
replace_response,
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
"PUT /api/v1/environments/match-env without If-Match",
|
||||
)
|
||||
.await;
|
||||
|
||||
let delete_response = app
|
||||
.oneshot(empty_request(Method::DELETE, "/environments/match-env"))
|
||||
.await
|
||||
.expect("delete without If-Match should respond");
|
||||
response_status(
|
||||
delete_response,
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
"DELETE /api/v1/environments/match-env without If-Match",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_environment_replace_and_delete_return_conflict() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
let created = create_environment(&app, "stale-env", "docker").await;
|
||||
let stale_revision = revision_from(&created).to_string();
|
||||
|
||||
let replaced = app
|
||||
.clone()
|
||||
.oneshot(request_with_if_match(
|
||||
Method::PUT,
|
||||
"/environments/stale-env",
|
||||
&stale_revision,
|
||||
Some(environment_settings("local")),
|
||||
))
|
||||
.await
|
||||
.expect("first replace should respond");
|
||||
response_status(
|
||||
replaced,
|
||||
StatusCode::OK,
|
||||
"PUT /api/v1/environments/stale-env first replace",
|
||||
)
|
||||
.await;
|
||||
|
||||
let stale_replace = app
|
||||
.clone()
|
||||
.oneshot(request_with_if_match(
|
||||
Method::PUT,
|
||||
"/environments/stale-env",
|
||||
&stale_revision,
|
||||
Some(environment_settings("docker")),
|
||||
))
|
||||
.await
|
||||
.expect("stale replace should respond");
|
||||
response_status(
|
||||
stale_replace,
|
||||
StatusCode::CONFLICT,
|
||||
"PUT /api/v1/environments/stale-env stale",
|
||||
)
|
||||
.await;
|
||||
|
||||
let stale_delete = app
|
||||
.oneshot(request_with_if_match(
|
||||
Method::DELETE,
|
||||
"/environments/stale-env",
|
||||
&stale_revision,
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("stale delete should respond");
|
||||
response_status(
|
||||
stale_delete,
|
||||
StatusCode::CONFLICT,
|
||||
"DELETE /api/v1/environments/stale-env stale",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_environment_create_returns_conflict() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
create_environment(&app, "duplicate-env", "local").await;
|
||||
|
||||
let response = app
|
||||
.oneshot(json_request(
|
||||
Method::POST,
|
||||
"/environments",
|
||||
&environment_body("duplicate-env", "docker"),
|
||||
))
|
||||
.await
|
||||
.expect("duplicate create should respond");
|
||||
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::CONFLICT,
|
||||
"POST /api/v1/environments duplicate",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_environment_id_and_if_match_return_bad_request() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
|
||||
let invalid_id = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, "/environments/Bad!"))
|
||||
.await
|
||||
.expect("invalid id request should respond");
|
||||
response_status(
|
||||
invalid_id,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"GET /api/v1/environments/Bad!",
|
||||
)
|
||||
.await;
|
||||
|
||||
create_environment(&app, "header-env", "local").await;
|
||||
let invalid_header = app
|
||||
.oneshot(request_with_if_match(
|
||||
Method::PUT,
|
||||
"/environments/header-env",
|
||||
"not-a-revision",
|
||||
Some(environment_settings("docker")),
|
||||
))
|
||||
.await
|
||||
.expect("invalid If-Match request should respond");
|
||||
response_status(
|
||||
invalid_header,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"PUT /api/v1/environments/header-env invalid If-Match",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_environment_settings_return_unprocessable_entity() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
let mut body = environment_body("invalid-env", "local");
|
||||
body["network"]["mode"] = json!("block");
|
||||
|
||||
let response = app
|
||||
.oneshot(json_request(Method::POST, "/environments", &body))
|
||||
.await
|
||||
.expect("invalid environment create should respond");
|
||||
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"POST /api/v1/environments invalid settings",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerfile_path_over_rest_is_rejected_without_persisting_or_exposing_contents() {
|
||||
let (app, temp_dir, environment_dir) = environment_app();
|
||||
tokio::fs::write(
|
||||
temp_dir.path().join("Dockerfile"),
|
||||
"FROM private.example/secret\n",
|
||||
)
|
||||
.await
|
||||
.expect("secret Dockerfile fixture should be written");
|
||||
let mut body = environment_body("path-env", "docker");
|
||||
body["image"]["docker"] = Value::Null;
|
||||
body["image"]["dockerfile"] = json!({
|
||||
"type": "path",
|
||||
"path": "Dockerfile"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(json_request(Method::POST, "/environments", &body))
|
||||
.await
|
||||
.expect("path Dockerfile create should respond");
|
||||
let error = response_json(
|
||||
response,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"POST /api/v1/environments Dockerfile path",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!environment_dir.join("path-env.toml").exists());
|
||||
assert!(
|
||||
!serde_json::to_string(&error)
|
||||
.expect("error body should serialize")
|
||||
.contains("private.example/secret")
|
||||
);
|
||||
let list = app
|
||||
.oneshot(empty_request(Method::GET, "/environments"))
|
||||
.await
|
||||
.expect("list environments should respond");
|
||||
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
|
||||
assert!(
|
||||
!list["data"]
|
||||
.as_array()
|
||||
.expect("environment list data should be an array")
|
||||
.iter()
|
||||
.any(|environment| environment["id"] == "path-env")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_environment_removes_non_default_and_default_is_protected() {
|
||||
let (app, _temp_dir, environment_dir) = environment_app();
|
||||
let created = create_environment(&app, "delete-env", "local").await;
|
||||
let revision = revision_from(&created);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request_with_if_match(
|
||||
Method::DELETE,
|
||||
"/environments/delete-env",
|
||||
&format!("\"{revision}\""),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("delete environment should respond");
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::NO_CONTENT,
|
||||
"DELETE /api/v1/environments/delete-env",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!environment_dir.join("delete-env.toml").exists());
|
||||
let missing = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, "/environments/delete-env"))
|
||||
.await
|
||||
.expect("get deleted environment should respond");
|
||||
response_status(
|
||||
missing,
|
||||
StatusCode::NOT_FOUND,
|
||||
"GET /api/v1/environments/delete-env after delete",
|
||||
)
|
||||
.await;
|
||||
|
||||
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
|
||||
.oneshot(request_with_if_match(
|
||||
Method::DELETE,
|
||||
"/environments/default",
|
||||
revision_from(&default),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("delete default environment should respond");
|
||||
response_status(
|
||||
protected,
|
||||
StatusCode::CONFLICT,
|
||||
"DELETE /api/v1/environments/default",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn environment_routes_require_authenticated_user() {
|
||||
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
|
||||
let state = TestAppStateBuilder::new()
|
||||
.active_config_path(temp_dir.path().join("settings.toml"))
|
||||
.build();
|
||||
let app = build_router(state, test_auth_mode());
|
||||
|
||||
let response = app
|
||||
.oneshot(empty_request(Method::GET, "/environments"))
|
||||
.await
|
||||
.expect("unauthenticated environment list should respond");
|
||||
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /api/v1/environments without auth",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_environment_refreshes_cached_manifest_run_settings() {
|
||||
let (app, _temp_dir) = environment_app_with_default_environment("api-default");
|
||||
|
||||
let before = system_info(&app).await;
|
||||
assert_eq!(before["sandbox_provider"], "local");
|
||||
|
||||
create_environment(&app, "api-default", "daytona").await;
|
||||
|
||||
let after = system_info(&app).await;
|
||||
assert_eq!(after["sandbox_provider"], "daytona");
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ mod auth_sessions;
|
|||
mod automations;
|
||||
mod cli_auth_token;
|
||||
mod docs;
|
||||
mod environments;
|
||||
mod events;
|
||||
mod install;
|
||||
mod install_openai_compatible;
|
||||
|
|
|
|||
|
|
@ -141,6 +141,54 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_spec_and_sdk_expose_crud_without_dockerfile_paths() {
|
||||
let spec = load_spec();
|
||||
let paths = spec
|
||||
.get("paths")
|
||||
.and_then(Value::as_mapping)
|
||||
.expect("spec is missing `paths`");
|
||||
for path in ["/api/v1/environments", "/api/v1/environments/{id}"] {
|
||||
assert!(
|
||||
paths.contains_key(Value::String(path.to_string())),
|
||||
"OpenAPI spec should expose {path}"
|
||||
);
|
||||
}
|
||||
|
||||
let generated_api = read_repo_file("lib/packages/fabro-api-client/src/api/environments-api.ts");
|
||||
assert!(
|
||||
generated_api.contains("export class EnvironmentsApi"),
|
||||
"generated TypeScript client should expose EnvironmentsApi"
|
||||
);
|
||||
for operation in [
|
||||
"createEnvironment",
|
||||
"deleteEnvironment",
|
||||
"listEnvironments",
|
||||
"replaceEnvironment",
|
||||
"retrieveEnvironment",
|
||||
] {
|
||||
assert!(
|
||||
generated_api.contains(operation),
|
||||
"generated EnvironmentsApi should expose {operation}"
|
||||
);
|
||||
}
|
||||
|
||||
let generated_image = read_repo_file(
|
||||
"lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts",
|
||||
);
|
||||
assert!(
|
||||
!generated_image.contains("DockerfileSourcePath") && !generated_image.contains("'path'"),
|
||||
"generated REST environment image model should not expose Dockerfile path sources"
|
||||
);
|
||||
|
||||
let workflow_dockerfile =
|
||||
read_repo_file("lib/packages/fabro-api-client/src/models/dockerfile-source.ts");
|
||||
assert!(
|
||||
workflow_dockerfile.contains("DockerfileSourcePath"),
|
||||
"workflow/settings Dockerfile schema should keep exposing path sources"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() {
|
||||
let secret = "test-webhook-secret";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ api/automations-api.ts
|
|||
api/billing-api.ts
|
||||
api/completions-api.ts
|
||||
api/discovery-api.ts
|
||||
api/environments-api.ts
|
||||
api/human-in-the-loop-api.ts
|
||||
api/insights-api.ts
|
||||
api/install-api.ts
|
||||
|
|
@ -94,6 +95,7 @@ models/completion-usage.ts
|
|||
models/conclusion.ts
|
||||
models/create-automation-request.ts
|
||||
models/create-completion-request.ts
|
||||
models/create-environment-request.ts
|
||||
models/create-run-pull-request-request.ts
|
||||
models/create-run-session-request.ts
|
||||
models/create-secret-request.ts
|
||||
|
|
@ -118,14 +120,19 @@ models/disk-usage-summary-row.ts
|
|||
models/dockerfile-source-inline.ts
|
||||
models/dockerfile-source-path.ts
|
||||
models/dockerfile-source.ts
|
||||
models/environment-api-dockerfile-source-inline.ts
|
||||
models/environment-api-image-settings.ts
|
||||
models/environment-image-settings.ts
|
||||
models/environment-lifecycle-settings.ts
|
||||
models/environment-list-meta.ts
|
||||
models/environment-list-response.ts
|
||||
models/environment-network-mode.ts
|
||||
models/environment-network-settings.ts
|
||||
models/environment-provider.ts
|
||||
models/environment-resources-settings.ts
|
||||
models/environment-settings.ts
|
||||
models/environment-volume-settings.ts
|
||||
models/environment.ts
|
||||
models/error-response-entry.ts
|
||||
models/error-response.ts
|
||||
models/event-envelope.ts
|
||||
|
|
@ -300,6 +307,7 @@ models/render-workflow-graph-direction.ts
|
|||
models/render-workflow-graph-format.ts
|
||||
models/render-workflow-graph-request.ts
|
||||
models/replace-automation-request.ts
|
||||
models/replace-environment-request.ts
|
||||
models/repo-check-response-permissions.ts
|
||||
models/repo-check-response.ts
|
||||
models/repository-ref.ts
|
||||
|
|
|
|||
1
lib/packages/fabro-api-client/src/api.ts
generated
1
lib/packages/fabro-api-client/src/api.ts
generated
|
|
@ -19,6 +19,7 @@ export * from './api/automations-api';
|
|||
export * from './api/billing-api';
|
||||
export * from './api/completions-api';
|
||||
export * from './api/discovery-api';
|
||||
export * from './api/environments-api';
|
||||
export * from './api/human-in-the-loop-api';
|
||||
export * from './api/insights-api';
|
||||
export * from './api/install-api';
|
||||
|
|
|
|||
453
lib/packages/fabro-api-client/src/api/environments-api.ts
generated
Normal file
453
lib/packages/fabro-api-client/src/api/environments-api.ts
generated
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
import type { Configuration } from '../configuration';
|
||||
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
|
||||
import globalAxios from 'axios';
|
||||
// Some imports not used depending on template conditions
|
||||
// @ts-ignore
|
||||
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
|
||||
// @ts-ignore
|
||||
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
|
||||
// @ts-ignore
|
||||
import type { CreateEnvironmentRequest } from '../models';
|
||||
// @ts-ignore
|
||||
import type { Environment } from '../models';
|
||||
// @ts-ignore
|
||||
import type { EnvironmentListResponse } from '../models';
|
||||
// @ts-ignore
|
||||
import type { ErrorResponse } from '../models';
|
||||
// @ts-ignore
|
||||
import type { ReplaceEnvironmentRequest } from '../models';
|
||||
/**
|
||||
* EnvironmentsApi - axios parameter creator
|
||||
*/
|
||||
export const EnvironmentsApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
|
||||
* @summary Create environment
|
||||
* @param {CreateEnvironmentRequest} createEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
createEnvironment: async (createEnvironmentRequest: CreateEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'createEnvironmentRequest' is not null or undefined
|
||||
assertParamExists('createEnvironment', 'createEnvironmentRequest', createEnvironmentRequest)
|
||||
const localVarPath = `/api/v1/environments`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(createEnvironmentRequest, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
|
||||
* @summary Delete environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
deleteEnvironment: async (id: string, ifMatch: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('deleteEnvironment', 'id', id)
|
||||
// verify required parameter 'ifMatch' is not null or undefined
|
||||
assertParamExists('deleteEnvironment', 'ifMatch', ifMatch)
|
||||
const localVarPath = `/api/v1/environments/{id}`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
if (ifMatch != null) {
|
||||
localVarHeaderParameter['If-Match'] = String(ifMatch);
|
||||
}
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Returns all server-managed environment definitions, sorted by id.
|
||||
* @summary List environments
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listEnvironments: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/api/v1/environments`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
|
||||
* @summary Replace environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
replaceEnvironment: async (id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('replaceEnvironment', 'id', id)
|
||||
// verify required parameter 'ifMatch' is not null or undefined
|
||||
assertParamExists('replaceEnvironment', 'ifMatch', ifMatch)
|
||||
// verify required parameter 'replaceEnvironmentRequest' is not null or undefined
|
||||
assertParamExists('replaceEnvironment', 'replaceEnvironmentRequest', replaceEnvironmentRequest)
|
||||
const localVarPath = `/api/v1/environments/{id}`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
if (ifMatch != null) {
|
||||
localVarHeaderParameter['If-Match'] = String(ifMatch);
|
||||
}
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(replaceEnvironmentRequest, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Returns one server-managed environment definition by id.
|
||||
* @summary Retrieve environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
retrieveEnvironment: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('retrieveEnvironment', 'id', id)
|
||||
const localVarPath = `/api/v1/environments/{id}`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EnvironmentsApi - functional programming interface
|
||||
*/
|
||||
export const EnvironmentsApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosParamCreator = EnvironmentsApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
|
||||
* @summary Create environment
|
||||
* @param {CreateEnvironmentRequest} createEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.createEnvironment(createEnvironmentRequest, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.createEnvironment']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
|
||||
* @summary Delete environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteEnvironment(id, ifMatch, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.deleteEnvironment']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns all server-managed environment definitions, sorted by id.
|
||||
* @summary List environments
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async listEnvironments(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<EnvironmentListResponse>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listEnvironments(options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.listEnvironments']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
|
||||
* @summary Replace environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.replaceEnvironment']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns one server-managed environment definition by id.
|
||||
* @summary Retrieve environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveEnvironment(id, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.retrieveEnvironment']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EnvironmentsApi - factory interface
|
||||
*/
|
||||
export const EnvironmentsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||
const localVarFp = EnvironmentsApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
|
||||
* @summary Create environment
|
||||
* @param {CreateEnvironmentRequest} createEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
|
||||
return localVarFp.createEnvironment(createEnvironmentRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
|
||||
* @summary Delete environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
|
||||
return localVarFp.deleteEnvironment(id, ifMatch, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns all server-managed environment definitions, sorted by id.
|
||||
* @summary List environments
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listEnvironments(options?: RawAxiosRequestConfig): AxiosPromise<EnvironmentListResponse> {
|
||||
return localVarFp.listEnvironments(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
|
||||
* @summary Replace environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
|
||||
return localVarFp.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns one server-managed environment definition by id.
|
||||
* @summary Retrieve environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
|
||||
return localVarFp.retrieveEnvironment(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* EnvironmentsApi - object-oriented interface
|
||||
*/
|
||||
export class EnvironmentsApi extends BaseAPI {
|
||||
/**
|
||||
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
|
||||
* @summary Create environment
|
||||
* @param {CreateEnvironmentRequest} createEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).createEnvironment(createEnvironmentRequest, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
|
||||
* @summary Delete environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).deleteEnvironment(id, ifMatch, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all server-managed environment definitions, sorted by id.
|
||||
* @summary List environments
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public listEnvironments(options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).listEnvironments(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
|
||||
* @summary Replace environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one server-managed environment definition by id.
|
||||
* @summary Retrieve environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public retrieveEnvironment(id: string, options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).retrieveEnvironment(id, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,6 @@ export interface Automation {
|
|||
'revision': string;
|
||||
'name': string;
|
||||
'description': string | null;
|
||||
'enabled': boolean;
|
||||
'target': AutomationTarget;
|
||||
'triggers': Array<AutomationTrigger>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export interface CreateAutomationRequest {
|
|||
'id': string;
|
||||
'name': string;
|
||||
'description'?: string | null;
|
||||
'enabled'?: boolean;
|
||||
'target': AutomationTarget;
|
||||
'triggers': Array<AutomationTrigger>;
|
||||
}
|
||||
|
|
|
|||
48
lib/packages/fabro-api-client/src/models/create-environment-request.ts
generated
Normal file
48
lib/packages/fabro-api-client/src/models/create-environment-request.ts
generated
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentNetworkSettings } from './environment-network-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentProvider } from './environment-provider';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
|
||||
|
||||
/**
|
||||
* Request body for creating a server-managed environment.
|
||||
*/
|
||||
export interface CreateEnvironmentRequest {
|
||||
'id': string;
|
||||
'provider': EnvironmentProvider;
|
||||
'image': EnvironmentApiImageSettings;
|
||||
'resources': EnvironmentResourcesSettings;
|
||||
'network': EnvironmentNetworkSettings;
|
||||
'lifecycle': EnvironmentLifecycleSettings;
|
||||
'labels': { [key: string]: string; };
|
||||
'volumes': Array<EnvironmentVolumeSettings>;
|
||||
'env': { [key: string]: string; };
|
||||
}
|
||||
26
lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts
generated
Normal file
26
lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts
generated
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export interface EnvironmentApiDockerfileSourceInline {
|
||||
'type': EnvironmentApiDockerfileSourceInlineTypeEnum;
|
||||
'value': string;
|
||||
}
|
||||
|
||||
export const EnvironmentApiDockerfileSourceInlineTypeEnum = {
|
||||
INLINE: 'inline'
|
||||
} as const;
|
||||
|
||||
export type EnvironmentApiDockerfileSourceInlineTypeEnum = typeof EnvironmentApiDockerfileSourceInlineTypeEnum[keyof typeof EnvironmentApiDockerfileSourceInlineTypeEnum];
|
||||
26
lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts
generated
Normal file
26
lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts
generated
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentApiDockerfileSourceInline } from './environment-api-dockerfile-source-inline';
|
||||
|
||||
/**
|
||||
* REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API.
|
||||
*/
|
||||
export interface EnvironmentApiImageSettings {
|
||||
'docker': string | null;
|
||||
'dockerfile': EnvironmentApiDockerfileSourceInline | null;
|
||||
}
|
||||
25
lib/packages/fabro-api-client/src/models/environment-list-meta.ts
generated
Normal file
25
lib/packages/fabro-api-client/src/models/environment-list-meta.ts
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Metadata for environment list responses.
|
||||
*/
|
||||
export interface EnvironmentListMeta {
|
||||
/**
|
||||
* Total number of server-managed environment definitions.
|
||||
*/
|
||||
'total': number;
|
||||
}
|
||||
29
lib/packages/fabro-api-client/src/models/environment-list-response.ts
generated
Normal file
29
lib/packages/fabro-api-client/src/models/environment-list-response.ts
generated
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { Environment } from './environment';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentListMeta } from './environment-list-meta';
|
||||
|
||||
/**
|
||||
* List envelope for environment definitions.
|
||||
*/
|
||||
export interface EnvironmentListResponse {
|
||||
'data': Array<Environment>;
|
||||
'meta': EnvironmentListMeta;
|
||||
}
|
||||
52
lib/packages/fabro-api-client/src/models/environment.ts
generated
Normal file
52
lib/packages/fabro-api-client/src/models/environment.ts
generated
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentNetworkSettings } from './environment-network-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentProvider } from './environment-provider';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
|
||||
|
||||
/**
|
||||
* Public server-managed environment definition.
|
||||
*/
|
||||
export interface Environment {
|
||||
'id': string;
|
||||
/**
|
||||
* Stable revision used with `If-Match` for optimistic concurrency.
|
||||
*/
|
||||
'revision': string;
|
||||
'provider': EnvironmentProvider;
|
||||
'image': EnvironmentApiImageSettings;
|
||||
'resources': EnvironmentResourcesSettings;
|
||||
'network': EnvironmentNetworkSettings;
|
||||
'lifecycle': EnvironmentLifecycleSettings;
|
||||
'labels': { [key: string]: string; };
|
||||
'volumes': Array<EnvironmentVolumeSettings>;
|
||||
'env': { [key: string]: string; };
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ export * from './completion-usage';
|
|||
export * from './conclusion';
|
||||
export * from './create-automation-request';
|
||||
export * from './create-completion-request';
|
||||
export * from './create-environment-request';
|
||||
export * from './create-run-pull-request-request';
|
||||
export * from './create-run-session-request';
|
||||
export * from './create-secret-request';
|
||||
|
|
@ -92,8 +93,13 @@ export * from './disk-usage-summary-row';
|
|||
export * from './dockerfile-source';
|
||||
export * from './dockerfile-source-inline';
|
||||
export * from './dockerfile-source-path';
|
||||
export * from './environment';
|
||||
export * from './environment-api-dockerfile-source-inline';
|
||||
export * from './environment-api-image-settings';
|
||||
export * from './environment-image-settings';
|
||||
export * from './environment-lifecycle-settings';
|
||||
export * from './environment-list-meta';
|
||||
export * from './environment-list-response';
|
||||
export * from './environment-network-mode';
|
||||
export * from './environment-network-settings';
|
||||
export * from './environment-provider';
|
||||
|
|
@ -273,6 +279,7 @@ export * from './render-workflow-graph-direction';
|
|||
export * from './render-workflow-graph-format';
|
||||
export * from './render-workflow-graph-request';
|
||||
export * from './replace-automation-request';
|
||||
export * from './replace-environment-request';
|
||||
export * from './repo-check-response';
|
||||
export * from './repo-check-response-permissions';
|
||||
export * from './repository-ref';
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import type { AutomationTrigger } from './automation-trigger';
|
|||
export interface ReplaceAutomationRequest {
|
||||
'name': string;
|
||||
'description'?: string | null;
|
||||
'enabled': boolean;
|
||||
'target': AutomationTarget;
|
||||
'triggers': Array<AutomationTrigger>;
|
||||
}
|
||||
|
|
|
|||
47
lib/packages/fabro-api-client/src/models/replace-environment-request.ts
generated
Normal file
47
lib/packages/fabro-api-client/src/models/replace-environment-request.ts
generated
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentNetworkSettings } from './environment-network-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentProvider } from './environment-provider';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
|
||||
|
||||
/**
|
||||
* Request body for replacing a server-managed environment. The path id is authoritative.
|
||||
*/
|
||||
export interface ReplaceEnvironmentRequest {
|
||||
'provider': EnvironmentProvider;
|
||||
'image': EnvironmentApiImageSettings;
|
||||
'resources': EnvironmentResourcesSettings;
|
||||
'network': EnvironmentNetworkSettings;
|
||||
'lifecycle': EnvironmentLifecycleSettings;
|
||||
'labels': { [key: string]: string; };
|
||||
'volumes': Array<EnvironmentVolumeSettings>;
|
||||
'env': { [key: string]: string; };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue