From 037073d2b20d4acf7554c5f85eb04d46143b97ee Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 17:30:57 -0400 Subject: [PATCH] feat: Add Environment REST CRUD API under /api/v1/environments (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a server-managed Environment CRUD API at `/api/v1/environments`, modeled after the existing Automations API and backed by `EnvironmentStore`. The API manages only server-side environment definitions in `environments/*.toml`; client-side catalogs (workflow, project TOML, run inputs) are unaffected. ### Plan Summary - **OpenAPI contract**: new `Environments` tag, `EnvironmentId` path parameter, five CRUD paths, list envelope, and REST-specific inline-only image schema (`EnvironmentApiImageSettings`) - **Server handler** (`environments.rs`): mirrors `automations.rs` — auth guard, ETag/If-Match, and `EnvironmentStoreError → ApiError` mapping - **Shared handler utilities**: `parse_required_if_match` and `json_with_etag_response` extracted from `automations.rs` into `handler/mod.rs` so both modules share them - **Inline-only Dockerfile enforcement**: `ApiDockerfileSource::Path` is parsed and immediately rejected with `422`; the file is never read - **Manifest refresh**: `refresh_manifest_run_settings_from_environment_catalog()` called after create, replace, and delete so `/system/info` and default run settings stay consistent - **Client regeneration**: TypeScript Axios client regenerated with `EnvironmentsApi` and new model files; Rust `fabro-api` type aliases updated - **Tests**: integration suite in `tests/it/api/environments.rs` covering all CRUD paths, error cases, and the manifest-refresh invariant; OpenAPI conformance test verifies generated surfaces ## Key Design Decisions **Inline-only Dockerfile at the REST boundary.** Allowing `path` sources over REST would let callers silently read arbitrary server-local files into the environment catalog. The handler recognizes the `path` discriminant so it can return a descriptive `422` rather than a generic parse error, but the payload is discarded via `IgnoredAny` — no disk access occurs. **Shared ETag utilities instead of per-handler helpers.** The original `parse_required_if_match` and ETag header builder in `automations.rs` were duplicated for environments. They're now generic over any `FromStr` revision type in `handler/mod.rs`, making future resource handlers cheaper to add. **`Environment` response type aliased to domain type.** The OpenAPI-generated `Environment` response struct is replaced with `fabro_environment::Environment` via `build.rs` `with_replacement`. A compile-time function-cast witness in `fabro-api/tests/environment_round_trip.rs` confirms the alias holds. Request types (`CreateEnvironmentRequest`, `ReplaceEnvironmentRequest`) stay API-specific because their image schema differs from the workflow/settings schema. **Stale revision → `409`.** Consistent with Automations; `428` is reserved for missing `If-Match` only. ### Fabro Details
Ran 8 stages in 59m 23s for $30.41 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 1s | – | 0 | | preflight_compile | 2m 9s | – | 0 | | preflight_lint | 2m 25s | – | 0 | | implement | 25m 43s | $19.79 | 0 | | simplify_opus | 14m 34s | $6.98 | 0 | | simplify_gpt | 4m 39s | $3.64 | 0 | | verify | 9m 14s | – | 0 | | **Total** | **59m 23s** | **$30.41** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14 edges) ```dot digraph ImplementPlan { graph [ goal="Implement and simplify", model_stylesheet=" * { model: claude-opus-4-7; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> exit [condition="outcome=succeeded"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- Cargo.lock | 1 + docs/public/api-reference/fabro-api.yaml | 453 +++++++++++++ lib/crates/fabro-api/Cargo.toml | 1 + lib/crates/fabro-api/build.rs | 1 + lib/crates/fabro-api/src/lib.rs | 1 + .../fabro-api/tests/environment_round_trip.rs | 88 +++ lib/crates/fabro-environment/src/model.rs | 1 + lib/crates/fabro-environment/src/store.rs | 26 +- lib/crates/fabro-server/src/server.rs | 12 + .../src/server/handler/automations.rs | 44 +- .../src/server/handler/environments.rs | 260 ++++++++ .../fabro-server/src/server/handler/mod.rs | 53 +- .../fabro-server/tests/it/api/environments.rs | 602 ++++++++++++++++++ lib/crates/fabro-server/tests/it/api/mod.rs | 1 + .../tests/it/openapi_conformance.rs | 48 ++ .../src/.openapi-generator/FILES | 8 + lib/packages/fabro-api-client/src/api.ts | 1 + .../src/api/environments-api.ts | 453 +++++++++++++ .../src/models/create-environment-request.ts | 48 ++ ...nvironment-api-dockerfile-source-inline.ts | 26 + .../models/environment-api-image-settings.ts | 26 + .../src/models/environment-list-meta.ts | 25 + .../src/models/environment-list-response.ts | 29 + .../src/models/environment.ts | 52 ++ .../fabro-api-client/src/models/index.ts | 7 + .../src/models/replace-environment-request.ts | 47 ++ 26 files changed, 2274 insertions(+), 40 deletions(-) create mode 100644 lib/crates/fabro-api/tests/environment_round_trip.rs create mode 100644 lib/crates/fabro-server/src/server/handler/environments.rs create mode 100644 lib/crates/fabro-server/tests/it/api/environments.rs create mode 100644 lib/packages/fabro-api-client/src/api/environments-api.ts create mode 100644 lib/packages/fabro-api-client/src/models/create-environment-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-list-meta.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-list-response.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment.ts create mode 100644 lib/packages/fabro-api-client/src/models/replace-environment-request.ts diff --git a/Cargo.lock b/Cargo.lock index 1c0b593e9..dc705e2cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1726,6 +1726,7 @@ dependencies = [ "chrono", "fabro-automation", "fabro-config", + "fabro-environment", "fabro-model", "fabro-types", "openapiv3", diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 103ab4cad..cb2659bb5 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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 @@ -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 @@ -6033,6 +6311,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: diff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml index ce21c0986..284a1956f 100644 --- a/lib/crates/fabro-api/Cargo.toml +++ b/lib/crates/fabro-api/Cargo.toml @@ -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" diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 3ef344399..c87635923 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -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", &[]), diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 28903a67d..815883853 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -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, diff --git a/lib/crates/fabro-api/tests/environment_round_trip.rs b/lib/crates/fabro-api/tests/environment_round_trip.rs new file mode 100644 index 000000000..362b78bbc --- /dev/null +++ b/lib/crates/fabro-api/tests/environment_round_trip.rs @@ -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::(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}" + ); +} diff --git a/lib/crates/fabro-environment/src/model.rs b/lib/crates/fabro-environment/src/model.rs index 90e0f934a..2943ac538 100644 --- a/lib/crates/fabro-environment/src/model.rs +++ b/lib/crates/fabro-environment/src/model.rs @@ -52,6 +52,7 @@ impl Environment { ) -> Result<(Self, Vec), 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(( diff --git a/lib/crates/fabro-environment/src/store.rs b/lib/crates/fabro-environment/src/store.rs index 172c518b1..b6265d7ed 100644 --- a/lib/crates/fabro-environment/src/store.rs +++ b/lib/crates/fabro-environment/src/store.rs @@ -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(); diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 8fafb6678..46a62c864 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1317,6 +1317,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 { match &self.http_client { Some(client) => Ok(client.clone()), diff --git a/lib/crates/fabro-server/src/server/handler/automations.rs b/lib/crates/fabro-server/src/server/handler/automations.rs index c29920d1d..51e09a980 100644 --- a/lib/crates/fabro-server/src/server/handler/automations.rs +++ b/lib/crates/fabro-server/src/server/handler/automations.rs @@ -1,11 +1,10 @@ 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}; @@ -15,7 +14,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; @@ -227,7 +226,7 @@ async fn replace_automation( Json(replacement): Json, ) -> Result { 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) @@ -242,7 +241,7 @@ async fn delete_automation( Path(id): Path, ) -> Result { 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?; Ok(StatusCode::NO_CONTENT.into_response()) } @@ -252,38 +251,9 @@ fn parse_path_id(id: String) -> Result { .map_err(|err| ApiError::bad_request(format!("invalid automation id: {err}"))) } -fn parse_required_if_match( - headers: &HeaderMap, - id: &AutomationId, -) -> Result { - 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::().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 for ApiError { diff --git a/lib/crates/fabro-server/src/server/handler/environments.rs b/lib/crates/fabro-server/src/server/handler/environments.rs new file mode 100644 index 000000000..686bd089f --- /dev/null +++ b/lib/crates/fabro-server/src/server/handler/environments.rs @@ -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, + 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, + volumes: Vec, + env: HashMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ReplaceEnvironmentRequest { + provider: EnvironmentProvider, + image: ApiEnvironmentImageSettings, + resources: EnvironmentResourcesSettings, + network: EnvironmentNetworkSettings, + lifecycle: EnvironmentLifecycleSettings, + labels: HashMap, + volumes: Vec, + env: HashMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ApiEnvironmentImageSettings { + docker: Option, + dockerfile: Option, +} + +#[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 { + 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 { + 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 { + Ok(EnvironmentImageSettings { + docker: self.docker, + dockerfile: self + .dockerfile + .map(ApiDockerfileSource::into_settings) + .transpose()?, + }) + } +} + +impl ApiDockerfileSource { + fn into_settings(self) -> Result { + 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> { + 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>) -> 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>, + Json(request): Json, +) -> Result { + 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>, + Path(id): Path, +) -> Result { + 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>, + headers: HeaderMap, + Path(id): Path, + Json(request): Json, +) -> Result { + 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>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + 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::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 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", + ), + } + } +} diff --git a/lib/crates/fabro-server/src/server/handler/mod.rs b/lib/crates/fabro-server/src/server/handler/mod.rs index 33c6aa8eb..5b931f76f 100644 --- a/lib/crates/fabro-server/src/server/handler/mod.rs +++ b/lib/crates/fabro-server/src/server/handler/mod.rs @@ -1,14 +1,17 @@ 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; @@ -31,6 +34,53 @@ async fn not_implemented() -> Response { ApiError::new(StatusCode::NOT_IMPLEMENTED, "Not implemented.").into_response() } +fn parse_required_if_match( + headers: &HeaderMap, + resource: &str, + id: &impl std::fmt::Display, +) -> Result +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::().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( + 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> { Router::new() .route("/runs", get(demo::list_runs).post(demo::create_run_stub)) @@ -158,6 +208,7 @@ pub(super) fn real_routes() -> Router> { .merge(pull_requests::routes()) .merge(artifacts::routes()) .merge(automations::routes()) + .merge(environments::routes()) .merge(sandbox::routes()) .merge(sandboxes::routes()) .merge(lifecycle::routes()) diff --git a/lib/crates/fabro-server/tests/it/api/environments.rs b/lib/crates/fabro-server/tests/it/api/environments.rs new file mode 100644 index 000000000..fb0c9fbfa --- /dev/null +++ b/lib/crates/fabro-server/tests/it/api/environments.rs @@ -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 { + 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 { + 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, +) -> Request { + 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!["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"); +} diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs index 843501072..98bf2a4f8 100644 --- a/lib/crates/fabro-server/tests/it/api/mod.rs +++ b/lib/crates/fabro-server/tests/it/api/mod.rs @@ -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; diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index fdfa8d894..c586b8b6e 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -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"; diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 513fea0b3..5e97116d9 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts index 608a29c63..3dd9c3391 100644 --- a/lib/packages/fabro-api-client/src/api.ts +++ b/lib/packages/fabro-api-client/src/api.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/api/environments-api.ts b/lib/packages/fabro-api-client/src/api/environments-api.ts new file mode 100644 index 000000000..6fbcd086e --- /dev/null +++ b/lib/packages/fabro-api-client/src/api/environments-api.ts @@ -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 => { + // 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 => { + // 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 => { + 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 => { + // 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 => { + // 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> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/lib/packages/fabro-api-client/src/models/create-environment-request.ts b/lib/packages/fabro-api-client/src/models/create-environment-request.ts new file mode 100644 index 000000000..0ad9dc556 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/create-environment-request.ts @@ -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; + 'env': { [key: string]: string; }; +} diff --git a/lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts b/lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts new file mode 100644 index 000000000..35180b10a --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts @@ -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]; diff --git a/lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts b/lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts new file mode 100644 index 000000000..54578b0cd --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/environment-list-meta.ts b/lib/packages/fabro-api-client/src/models/environment-list-meta.ts new file mode 100644 index 000000000..aa92bad87 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-list-meta.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/environment-list-response.ts b/lib/packages/fabro-api-client/src/models/environment-list-response.ts new file mode 100644 index 000000000..cf25725b1 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-list-response.ts @@ -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; + 'meta': EnvironmentListMeta; +} diff --git a/lib/packages/fabro-api-client/src/models/environment.ts b/lib/packages/fabro-api-client/src/models/environment.ts new file mode 100644 index 000000000..6451f9d57 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment.ts @@ -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; + 'env': { [key: string]: string; }; +} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index e66b048ca..87b9c189c 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/replace-environment-request.ts b/lib/packages/fabro-api-client/src/models/replace-environment-request.ts new file mode 100644 index 000000000..56bf4913e --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/replace-environment-request.ts @@ -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; + 'env': { [key: string]: string; }; +}