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; };
+}