feat: Add Environment REST CRUD API under /api/v1/environments (#453)

## 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

<details>
<summary>Ran 8 stages in 59m 23s for $30.41</summary>

| 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** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```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
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-29 17:30:57 -04:00 • committed by GitHub
parent 0e224aa705
commit 037073d2b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2274 additions and 40 deletions

1
Cargo.lock generated
View file

@ -1726,6 +1726,7 @@ dependencies = [
"chrono",
"fabro-automation",
"fabro-config",
"fabro-environment",
"fabro-model",
"fabro-types",
"openapiv3",

View file

@ -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:

View file

@ -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"

View file

@ -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", &[]),

View file

@ -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,

View file

@ -0,0 +1,88 @@
use fabro_api::types::{
CreateEnvironmentRequest as ApiCreateEnvironmentRequest, Environment as ApiEnvironment,
ReplaceEnvironmentRequest as ApiReplaceEnvironmentRequest,
};
use fabro_environment::Environment;
use serde_json::json;
// Compile-time witness that the generated API response type resolves to the
// same type as the `fabro-environment` domain type via `with_replacement(...)`.
// Request types intentionally stay API-specific so REST Dockerfile sources can
// remain inline-only without changing workflow/settings schemas.
const _: fn(ApiEnvironment) -> Environment = |value| value;
fn environment_settings_json() -> serde_json::Value {
json!({
"provider": "docker",
"image": {
"docker": null,
"dockerfile": {
"type": "inline",
"value": "FROM alpine\n"
}
},
"resources": {
"cpu": null,
"memory": null,
"disk": null
},
"network": {
"mode": "allow_all",
"allow": []
},
"lifecycle": {
"preserve": false,
"stop_on_terminal": true,
"auto_stop": null
},
"labels": {},
"volumes": [],
"env": {}
})
}
#[test]
fn environment_response_round_trips_public_json_shape() {
let mut value = environment_settings_json();
value["id"] = json!("docker-inline");
value["revision"] = json!("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
let api: ApiEnvironment = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
#[test]
fn create_environment_request_round_trips_inline_dockerfile_json_shape() {
let mut value = environment_settings_json();
value["id"] = json!("docker-inline");
let api: ApiCreateEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
#[test]
fn replace_environment_request_round_trips_inline_dockerfile_json_shape() {
let value = environment_settings_json();
let api: ApiReplaceEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
#[test]
fn environment_request_schema_rejects_dockerfile_path_sources() {
let mut value = environment_settings_json();
value["id"] = json!("docker-path");
value["image"]["dockerfile"] = json!({
"type": "path",
"path": "Dockerfile"
});
let err = serde_json::from_value::<ApiCreateEnvironmentRequest>(value)
.expect_err("generated REST request type should reject Dockerfile path sources");
assert!(
err.to_string().contains("dockerfile")
|| err.to_string().contains("type")
|| err.to_string().contains("path"),
"unexpected error: {err}"
);
}

View file

@ -52,6 +52,7 @@ impl Environment {
) -> Result<(Self, Vec<u8>), EnvironmentStoreError> {
let settings = inline_dense_dockerfile(settings, dockerfile_base_dir).await?;
let persisted = environment_settings_to_layer(&settings);
let settings = resolve_environment(&persisted)?;
let bytes = canonical_bytes(&persisted).into_bytes();
let revision = EnvironmentRevision::from_bytes(&bytes);
Ok((

View file

@ -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();

View file

@ -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<fabro_http::HttpClient, fabro_http::HttpClientBuildError> {
match &self.http_client {
Some(client) => Ok(client.clone()),

View file

@ -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<AutomationReplace>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, &id)?;
let expected = parse_required_if_match(&headers, "automation", &id)?;
let automation = state
.automation_store()
.replace(&id, &expected, replacement)
@ -242,7 +241,7 @@ async fn delete_automation(
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, &id)?;
let expected = parse_required_if_match(&headers, "automation", &id)?;
state.automation_store().delete(&id, &expected).await?;
Ok(StatusCode::NO_CONTENT.into_response())
}
@ -252,38 +251,9 @@ fn parse_path_id(id: String) -> Result<AutomationId, ApiError> {
.map_err(|err| ApiError::bad_request(format!("invalid automation id: {err}")))
}
fn parse_required_if_match(
headers: &HeaderMap,
id: &AutomationId,
) -> Result<AutomationRevision, ApiError> {
let Some(value) = headers.get(header::IF_MATCH) else {
return Err(ApiError::new(
StatusCode::PRECONDITION_REQUIRED,
format!("If-Match header is required for automation: {id}"),
));
};
let value = value
.to_str()
.map_err(|_| ApiError::bad_request("If-Match header must be visible ASCII"))?;
let value = unquote_etag(value.trim());
value.parse::<AutomationRevision>().map_err(|err| {
ApiError::bad_request(format!("invalid If-Match automation revision: {err}"))
})
}
fn unquote_etag(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|unquoted| unquoted.strip_suffix('"'))
.unwrap_or(value)
}
fn automation_with_etag_response(status: StatusCode, automation: Automation) -> Response {
let etag = HeaderValue::from_str(&format!("\"{}\"", automation.revision))
.expect("automation revisions are valid ETag header values");
let mut response = (status, Json(automation)).into_response();
response.headers_mut().insert(header::ETAG, etag);
response
let revision = automation.revision.clone();
json_with_etag_response(status, "automation", &revision, automation)
}
impl From<AutomationStoreError> for ApiError {

View file

@ -0,0 +1,260 @@
use std::collections::HashMap;
use std::sync::Arc;
use axum::http::HeaderMap;
use fabro_environment::{Environment, EnvironmentDraft, EnvironmentId, EnvironmentStoreError};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
EnvironmentSettings, EnvironmentVolumeSettings,
};
use serde::de::IgnoredAny;
use serde::{Deserialize, Serialize};
use super::super::{
ApiError, AppState, IntoResponse, Json, Path, RequiredUser, Response, Router, State,
StatusCode, get,
};
use super::{json_with_etag_response, parse_required_if_match};
#[derive(Serialize)]
struct EnvironmentListResponse {
data: Vec<Environment>,
meta: EnvironmentListMeta,
}
#[derive(Serialize)]
struct EnvironmentListMeta {
total: usize,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CreateEnvironmentRequest {
id: EnvironmentId,
provider: EnvironmentProvider,
image: ApiEnvironmentImageSettings,
resources: EnvironmentResourcesSettings,
network: EnvironmentNetworkSettings,
lifecycle: EnvironmentLifecycleSettings,
labels: HashMap<String, String>,
volumes: Vec<EnvironmentVolumeSettings>,
env: HashMap<String, InterpString>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ReplaceEnvironmentRequest {
provider: EnvironmentProvider,
image: ApiEnvironmentImageSettings,
resources: EnvironmentResourcesSettings,
network: EnvironmentNetworkSettings,
lifecycle: EnvironmentLifecycleSettings,
labels: HashMap<String, String>,
volumes: Vec<EnvironmentVolumeSettings>,
env: HashMap<String, InterpString>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ApiEnvironmentImageSettings {
docker: Option<String>,
dockerfile: Option<ApiDockerfileSource>,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum ApiDockerfileSource {
Inline {
value: String,
},
// Recognized so the handler can return a 422 with bespoke guidance.
// The `path` payload is parsed and discarded — never read from disk.
Path {
#[serde(rename = "path")]
_path: IgnoredAny,
},
}
impl CreateEnvironmentRequest {
fn into_draft(self) -> Result<EnvironmentDraft, ApiError> {
Ok(EnvironmentDraft {
id: self.id,
settings: EnvironmentSettings {
provider: self.provider,
image: self.image.into_settings()?,
resources: self.resources,
network: self.network,
lifecycle: self.lifecycle,
labels: self.labels,
volumes: self.volumes,
env: self.env,
},
})
}
}
impl ReplaceEnvironmentRequest {
fn into_settings(self) -> Result<EnvironmentSettings, ApiError> {
Ok(EnvironmentSettings {
provider: self.provider,
image: self.image.into_settings()?,
resources: self.resources,
network: self.network,
lifecycle: self.lifecycle,
labels: self.labels,
volumes: self.volumes,
env: self.env,
})
}
}
impl ApiEnvironmentImageSettings {
fn into_settings(self) -> Result<EnvironmentImageSettings, ApiError> {
Ok(EnvironmentImageSettings {
docker: self.docker,
dockerfile: self
.dockerfile
.map(ApiDockerfileSource::into_settings)
.transpose()?,
})
}
}
impl ApiDockerfileSource {
fn into_settings(self) -> Result<DockerfileSource, ApiError> {
match self {
Self::Inline { value } => Ok(DockerfileSource::Inline(value)),
Self::Path { .. } => Err(ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"Dockerfile path sources are not supported by the environments REST API; use inline Dockerfile content",
)),
}
}
}
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route(
"/environments",
get(list_environments).post(create_environment),
)
.route(
"/environments/{id}",
get(get_environment)
.put(replace_environment)
.delete(delete_environment),
)
}
async fn list_environments(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
let data = state.environment_store().list();
let total = data.len();
(
StatusCode::OK,
Json(EnvironmentListResponse {
data,
meta: EnvironmentListMeta { total },
}),
)
.into_response()
}
async fn create_environment(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Json(request): Json<CreateEnvironmentRequest>,
) -> Result<Response, ApiError> {
let environment = state
.environment_store()
.create(request.into_draft()?)
.await?;
state.refresh_manifest_run_settings_from_environment_catalog();
Ok((StatusCode::CREATED, Json(environment)).into_response())
}
async fn get_environment(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
match state.environment_store().get(&id) {
Some(environment) => Ok(environment_with_etag_response(StatusCode::OK, environment)),
None => Err(ApiError::not_found(format!("environment not found: {id}"))),
}
}
async fn replace_environment(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(request): Json<ReplaceEnvironmentRequest>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, "environment", &id)?;
let environment = state
.environment_store()
.replace(&id, &expected, request.into_settings()?)
.await?;
state.refresh_manifest_run_settings_from_environment_catalog();
Ok(environment_with_etag_response(StatusCode::OK, environment))
}
async fn delete_environment(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, "environment", &id)?;
state.environment_store().delete(&id, &expected).await?;
state.refresh_manifest_run_settings_from_environment_catalog();
Ok(StatusCode::NO_CONTENT.into_response())
}
fn parse_path_id(id: String) -> Result<EnvironmentId, ApiError> {
EnvironmentId::new(id)
.map_err(|err| ApiError::bad_request(format!("invalid environment id: {err}")))
}
fn environment_with_etag_response(status: StatusCode, environment: Environment) -> Response {
let revision = environment.revision.clone();
json_with_etag_response(status, "environment", &revision, environment)
}
impl From<EnvironmentStoreError> for ApiError {
fn from(err: EnvironmentStoreError) -> Self {
match err {
EnvironmentStoreError::NotFound { id } => {
Self::not_found(format!("environment not found: {id}"))
}
EnvironmentStoreError::AlreadyExists { id } => Self::new(
StatusCode::CONFLICT,
format!("environment already exists: {id}"),
),
EnvironmentStoreError::StaleRevision { id, .. } => Self::new(
StatusCode::CONFLICT,
format!("environment revision is stale: {id}"),
),
EnvironmentStoreError::Protected { id } => Self::new(
StatusCode::CONFLICT,
format!("environment is protected and cannot be deleted: {id}"),
),
EnvironmentStoreError::Validation { source } => {
Self::new(StatusCode::UNPROCESSABLE_ENTITY, source.to_string())
}
EnvironmentStoreError::InvalidFilename { .. }
| EnvironmentStoreError::Parse { .. }
| EnvironmentStoreError::InvalidUtf8 { .. }
| EnvironmentStoreError::Serialize { .. }
| EnvironmentStoreError::Io { .. } => Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"environment store operation failed",
),
}
}
}

View file

@ -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<R>(
headers: &HeaderMap,
resource: &str,
id: &impl std::fmt::Display,
) -> Result<R, ApiError>
where
R: std::str::FromStr,
R::Err: std::fmt::Display,
{
let Some(value) = headers.get(header::IF_MATCH) else {
return Err(ApiError::new(
StatusCode::PRECONDITION_REQUIRED,
format!("If-Match header is required for {resource}: {id}"),
));
};
let value = value
.to_str()
.map_err(|_| ApiError::bad_request("If-Match header must be visible ASCII"))?;
let value = unquote_etag(value.trim());
value.parse::<R>().map_err(|err| {
ApiError::bad_request(format!("invalid If-Match {resource} revision: {err}"))
})
}
fn unquote_etag(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|unquoted| unquoted.strip_suffix('"'))
.unwrap_or(value)
}
fn json_with_etag_response<T>(
status: StatusCode,
resource: &str,
revision: &impl std::fmt::Display,
body: T,
) -> Response
where
T: Serialize,
{
let etag = HeaderValue::from_str(&format!("\"{revision}\""))
.unwrap_or_else(|_| panic!("{resource} revisions are valid ETag header values"));
let mut response = (status, Json(body)).into_response();
response.headers_mut().insert(header::ETAG, etag);
response
}
pub(super) fn demo_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runs", get(demo::list_runs).post(demo::create_run_stub))
@ -158,6 +208,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.merge(pull_requests::routes())
.merge(artifacts::routes())
.merge(automations::routes())
.merge(environments::routes())
.merge(sandbox::routes())
.merge(sandboxes::routes())
.merge(lifecycle::routes())

View file

@ -0,0 +1,602 @@
use std::path::{Path, PathBuf};
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use fabro_config::{RunEnvironmentLayer, RunLayer};
use fabro_server::server::build_router;
use fabro_server::test_support::{
TestAppStateBuilder, build_test_router, default_test_server_settings, test_auth_mode,
};
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::helpers::{api, checked_response, response_json, response_status};
fn environment_settings(provider: &str) -> Value {
json!({
"provider": provider,
"image": {
"docker": if provider == "docker" { json!("alpine:3.20") } else { Value::Null },
"dockerfile": null
},
"resources": {
"cpu": null,
"memory": null,
"disk": null
},
"network": {
"mode": "allow_all",
"allow": []
},
"lifecycle": {
"preserve": false,
"stop_on_terminal": true,
"auto_stop": null
},
"labels": {},
"volumes": [],
"env": {}
})
}
fn environment_body(id: &str, provider: &str) -> Value {
let mut body = environment_settings(provider);
body["id"] = json!(id);
body
}
fn environment_app() -> (axum::Router, tempfile::TempDir, PathBuf) {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
let active_config_path = temp_dir.path().join("settings.toml");
let environment_dir = temp_dir.path().join("environments");
let state = TestAppStateBuilder::new()
.active_config_path(active_config_path)
.build();
(build_test_router(state), temp_dir, environment_dir)
}
fn environment_app_with_default_environment(
environment_id: &str,
) -> (axum::Router, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
let active_config_path = temp_dir.path().join("settings.toml");
let manifest_run_defaults = RunLayer {
environment: Some(RunEnvironmentLayer {
id: Some(environment_id.to_string()),
..RunEnvironmentLayer::default()
}),
..RunLayer::default()
};
let state = TestAppStateBuilder::new()
.runtime_settings(default_test_server_settings(), manifest_run_defaults)
.active_config_path(active_config_path)
.build();
(build_test_router(state), temp_dir)
}
fn json_request(method: Method, path: &str, body: &Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(body).expect("environment fixture should serialize"),
))
.expect("environment JSON request should build")
}
fn empty_request(method: Method, path: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.body(Body::empty())
.expect("environment request should build")
}
fn request_with_if_match(
method: Method,
path: &str,
revision: &str,
body: Option<Value>,
) -> Request<Body> {
let mut builder = Request::builder()
.method(method)
.uri(api(path))
.header(header::IF_MATCH, revision);
let body = match body {
Some(value) => {
builder = builder.header(header::CONTENT_TYPE, "application/json");
Body::from(serde_json::to_vec(&value).expect("environment fixture should serialize"))
}
None => Body::empty(),
};
builder
.body(body)
.expect("environment If-Match request should build")
}
async fn create_environment(app: &axum::Router, id: &str, provider: &str) -> Value {
create_environment_with_body(app, &environment_body(id, provider)).await
}
async fn create_environment_with_body(app: &axum::Router, body: &Value) -> Value {
let response = app
.clone()
.oneshot(json_request(Method::POST, "/environments", body))
.await
.expect("create environment should respond");
response_json(response, StatusCode::CREATED, "POST /api/v1/environments").await
}
fn revision_from(body: &Value) -> &str {
body["revision"]
.as_str()
.expect("environment response should include a revision")
}
async fn persisted_environment_toml(environment_dir: &Path, id: &str) -> toml::Value {
let persisted = tokio::fs::read_to_string(environment_dir.join(format!("{id}.toml")))
.await
.expect("persisted environment TOML should be readable");
toml::from_str(&persisted).expect("persisted environment TOML should parse")
}
async fn system_info(app: &axum::Router) -> Value {
let response = app
.clone()
.oneshot(empty_request(Method::GET, "/system/info"))
.await
.expect("system info should respond");
response_json(response, StatusCode::OK, "GET /api/v1/system/info").await
}
#[tokio::test]
async fn list_environments_returns_seeded_catalog_sorted_by_id() {
let (app, _temp_dir, _environment_dir) = environment_app();
let response = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("list environments should respond");
let body = response_json(response, StatusCode::OK, "GET /api/v1/environments").await;
assert_eq!(body["meta"]["total"], 4);
assert_eq!(
body["data"]
.as_array()
.expect("environment list data should be an array")
.iter()
.map(|environment| environment["id"]
.as_str()
.expect("environment should have id"))
.collect::<Vec<_>>(),
vec!["daytona", "default", "docker", "local"]
);
}
#[tokio::test]
async fn create_environment_persists_sibling_toml_and_is_visible() {
let (app, _temp_dir, environment_dir) = environment_app();
let created = create_environment(&app, "custom-env", "docker").await;
assert_eq!(created["id"], "custom-env");
assert_eq!(created["provider"], "docker");
assert!(environment_dir.join("custom-env.toml").exists());
let retrieved = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/custom-env"))
.await
.expect("get environment should respond");
let retrieved = response_json(
retrieved,
StatusCode::OK,
"GET /api/v1/environments/custom-env",
)
.await;
assert_eq!(retrieved["id"], "custom-env");
let list = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("list environments should respond");
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
assert_eq!(list["meta"]["total"], 5);
assert!(
list["data"]
.as_array()
.expect("environment list data should be an array")
.iter()
.any(|environment| environment["id"] == "custom-env")
);
let persisted = persisted_environment_toml(&environment_dir, "custom-env").await;
assert_eq!(
persisted.get("provider").and_then(toml::Value::as_str),
Some("docker")
);
assert!(persisted.get("id").is_none());
assert!(persisted.get("revision").is_none());
}
#[tokio::test]
async fn get_environment_returns_current_etag() {
let (app, _temp_dir, _environment_dir) = environment_app();
let created = create_environment(&app, "etag-env", "local").await;
let revision = revision_from(&created);
let response = app
.oneshot(empty_request(Method::GET, "/environments/etag-env"))
.await
.expect("get environment should respond");
let response = checked_response(
response,
StatusCode::OK,
"GET /api/v1/environments/etag-env",
)
.await;
assert_eq!(
response
.headers()
.get(header::ETAG)
.expect("GET environment should include ETag"),
&format!("\"{revision}\"")
);
let body = crate::helpers::body_json(response.into_body()).await;
assert_eq!(body["revision"], revision);
}
#[tokio::test]
async fn replace_environment_updates_file_and_returns_new_etag() {
let (app, _temp_dir, environment_dir) = environment_app();
let created = create_environment(&app, "replace-env", "docker").await;
let revision = revision_from(&created);
let mut replacement = environment_settings("local");
replacement["labels"] = json!({ "tier": "dev" });
let response = app
.oneshot(request_with_if_match(
Method::PUT,
"/environments/replace-env",
revision,
Some(replacement),
))
.await
.expect("replace environment should respond");
let response = checked_response(
response,
StatusCode::OK,
"PUT /api/v1/environments/replace-env",
)
.await;
let etag = response
.headers()
.get(header::ETAG)
.expect("PUT environment should include ETag")
.to_str()
.expect("ETag should be ASCII")
.to_string();
let body = crate::helpers::body_json(response.into_body()).await;
assert_eq!(body["provider"], "local");
assert_eq!(body["labels"]["tier"], "dev");
assert_ne!(body["revision"], revision);
assert_eq!(etag, format!("\"{}\"", revision_from(&body)));
let persisted = persisted_environment_toml(&environment_dir, "replace-env").await;
assert_eq!(
persisted
.get("labels")
.and_then(toml::Value::as_table)
.and_then(|labels| labels.get("tier"))
.and_then(toml::Value::as_str),
Some("dev")
);
}
#[tokio::test]
async fn replace_and_delete_environment_require_if_match() {
let (app, _temp_dir, _environment_dir) = environment_app();
create_environment(&app, "match-env", "local").await;
let replace_response = app
.clone()
.oneshot(json_request(
Method::PUT,
"/environments/match-env",
&environment_settings("docker"),
))
.await
.expect("replace without If-Match should respond");
response_status(
replace_response,
StatusCode::PRECONDITION_REQUIRED,
"PUT /api/v1/environments/match-env without If-Match",
)
.await;
let delete_response = app
.oneshot(empty_request(Method::DELETE, "/environments/match-env"))
.await
.expect("delete without If-Match should respond");
response_status(
delete_response,
StatusCode::PRECONDITION_REQUIRED,
"DELETE /api/v1/environments/match-env without If-Match",
)
.await;
}
#[tokio::test]
async fn stale_environment_replace_and_delete_return_conflict() {
let (app, _temp_dir, _environment_dir) = environment_app();
let created = create_environment(&app, "stale-env", "docker").await;
let stale_revision = revision_from(&created).to_string();
let replaced = app
.clone()
.oneshot(request_with_if_match(
Method::PUT,
"/environments/stale-env",
&stale_revision,
Some(environment_settings("local")),
))
.await
.expect("first replace should respond");
response_status(
replaced,
StatusCode::OK,
"PUT /api/v1/environments/stale-env first replace",
)
.await;
let stale_replace = app
.clone()
.oneshot(request_with_if_match(
Method::PUT,
"/environments/stale-env",
&stale_revision,
Some(environment_settings("docker")),
))
.await
.expect("stale replace should respond");
response_status(
stale_replace,
StatusCode::CONFLICT,
"PUT /api/v1/environments/stale-env stale",
)
.await;
let stale_delete = app
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/stale-env",
&stale_revision,
None,
))
.await
.expect("stale delete should respond");
response_status(
stale_delete,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/stale-env stale",
)
.await;
}
#[tokio::test]
async fn duplicate_environment_create_returns_conflict() {
let (app, _temp_dir, _environment_dir) = environment_app();
create_environment(&app, "duplicate-env", "local").await;
let response = app
.oneshot(json_request(
Method::POST,
"/environments",
&environment_body("duplicate-env", "docker"),
))
.await
.expect("duplicate create should respond");
response_status(
response,
StatusCode::CONFLICT,
"POST /api/v1/environments duplicate",
)
.await;
}
#[tokio::test]
async fn invalid_environment_id_and_if_match_return_bad_request() {
let (app, _temp_dir, _environment_dir) = environment_app();
let invalid_id = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/Bad!"))
.await
.expect("invalid id request should respond");
response_status(
invalid_id,
StatusCode::BAD_REQUEST,
"GET /api/v1/environments/Bad!",
)
.await;
create_environment(&app, "header-env", "local").await;
let invalid_header = app
.oneshot(request_with_if_match(
Method::PUT,
"/environments/header-env",
"not-a-revision",
Some(environment_settings("docker")),
))
.await
.expect("invalid If-Match request should respond");
response_status(
invalid_header,
StatusCode::BAD_REQUEST,
"PUT /api/v1/environments/header-env invalid If-Match",
)
.await;
}
#[tokio::test]
async fn invalid_environment_settings_return_unprocessable_entity() {
let (app, _temp_dir, _environment_dir) = environment_app();
let mut body = environment_body("invalid-env", "local");
body["network"]["mode"] = json!("block");
let response = app
.oneshot(json_request(Method::POST, "/environments", &body))
.await
.expect("invalid environment create should respond");
response_status(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /api/v1/environments invalid settings",
)
.await;
}
#[tokio::test]
async fn dockerfile_path_over_rest_is_rejected_without_persisting_or_exposing_contents() {
let (app, temp_dir, environment_dir) = environment_app();
tokio::fs::write(
temp_dir.path().join("Dockerfile"),
"FROM private.example/secret\n",
)
.await
.expect("secret Dockerfile fixture should be written");
let mut body = environment_body("path-env", "docker");
body["image"]["docker"] = Value::Null;
body["image"]["dockerfile"] = json!({
"type": "path",
"path": "Dockerfile"
});
let response = app
.clone()
.oneshot(json_request(Method::POST, "/environments", &body))
.await
.expect("path Dockerfile create should respond");
let error = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /api/v1/environments Dockerfile path",
)
.await;
assert!(!environment_dir.join("path-env.toml").exists());
assert!(
!serde_json::to_string(&error)
.expect("error body should serialize")
.contains("private.example/secret")
);
let list = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("list environments should respond");
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
assert!(
!list["data"]
.as_array()
.expect("environment list data should be an array")
.iter()
.any(|environment| environment["id"] == "path-env")
);
}
#[tokio::test]
async fn delete_environment_removes_non_default_and_default_is_protected() {
let (app, _temp_dir, environment_dir) = environment_app();
let created = create_environment(&app, "delete-env", "local").await;
let revision = revision_from(&created);
let response = app
.clone()
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/delete-env",
&format!("\"{revision}\""),
None,
))
.await
.expect("delete environment should respond");
response_status(
response,
StatusCode::NO_CONTENT,
"DELETE /api/v1/environments/delete-env",
)
.await;
assert!(!environment_dir.join("delete-env.toml").exists());
let missing = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/delete-env"))
.await
.expect("get deleted environment should respond");
response_status(
missing,
StatusCode::NOT_FOUND,
"GET /api/v1/environments/delete-env after delete",
)
.await;
let default = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/default"))
.await
.expect("get default environment should respond");
let default = response_json(default, StatusCode::OK, "GET /api/v1/environments/default").await;
let protected = app
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/default",
revision_from(&default),
None,
))
.await
.expect("delete default environment should respond");
response_status(
protected,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/default",
)
.await;
}
#[tokio::test]
async fn environment_routes_require_authenticated_user() {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
let state = TestAppStateBuilder::new()
.active_config_path(temp_dir.path().join("settings.toml"))
.build();
let app = build_router(state, test_auth_mode());
let response = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("unauthenticated environment list should respond");
response_status(
response,
StatusCode::UNAUTHORIZED,
"GET /api/v1/environments without auth",
)
.await;
}
#[tokio::test]
async fn create_environment_refreshes_cached_manifest_run_settings() {
let (app, _temp_dir) = environment_app_with_default_environment("api-default");
let before = system_info(&app).await;
assert_eq!(before["sandbox_provider"], "local");
create_environment(&app, "api-default", "daytona").await;
let after = system_info(&app).await;
assert_eq!(after["sandbox_provider"], "daytona");
}

View file

@ -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;

View file

@ -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";

View file

@ -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

View file

@ -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';

View file

@ -0,0 +1,453 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from '../configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { CreateEnvironmentRequest } from '../models';
// @ts-ignore
import type { Environment } from '../models';
// @ts-ignore
import type { EnvironmentListResponse } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { ReplaceEnvironmentRequest } from '../models';
/**
* EnvironmentsApi - axios parameter creator
*/
export const EnvironmentsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
* @summary Create environment
* @param {CreateEnvironmentRequest} createEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createEnvironment: async (createEnvironmentRequest: CreateEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'createEnvironmentRequest' is not null or undefined
assertParamExists('createEnvironment', 'createEnvironmentRequest', createEnvironmentRequest)
const localVarPath = `/api/v1/environments`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(createEnvironmentRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
* @summary Delete environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteEnvironment: async (id: string, ifMatch: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('deleteEnvironment', 'id', id)
// verify required parameter 'ifMatch' is not null or undefined
assertParamExists('deleteEnvironment', 'ifMatch', ifMatch)
const localVarPath = `/api/v1/environments/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
if (ifMatch != null) {
localVarHeaderParameter['If-Match'] = String(ifMatch);
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns all server-managed environment definitions, sorted by id.
* @summary List environments
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listEnvironments: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/environments`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
* @summary Replace environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
replaceEnvironment: async (id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('replaceEnvironment', 'id', id)
// verify required parameter 'ifMatch' is not null or undefined
assertParamExists('replaceEnvironment', 'ifMatch', ifMatch)
// verify required parameter 'replaceEnvironmentRequest' is not null or undefined
assertParamExists('replaceEnvironment', 'replaceEnvironmentRequest', replaceEnvironmentRequest)
const localVarPath = `/api/v1/environments/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
if (ifMatch != null) {
localVarHeaderParameter['If-Match'] = String(ifMatch);
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(replaceEnvironmentRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns one server-managed environment definition by id.
* @summary Retrieve environment
* @param {string} id Unique environment identifier.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveEnvironment: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('retrieveEnvironment', 'id', id)
const localVarPath = `/api/v1/environments/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* EnvironmentsApi - functional programming interface
*/
export const EnvironmentsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = EnvironmentsApiAxiosParamCreator(configuration)
return {
/**
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
* @summary Create environment
* @param {CreateEnvironmentRequest} createEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createEnvironment(createEnvironmentRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.createEnvironment']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
* @summary Delete environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteEnvironment(id, ifMatch, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.deleteEnvironment']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns all server-managed environment definitions, sorted by id.
* @summary List environments
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listEnvironments(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<EnvironmentListResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listEnvironments(options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.listEnvironments']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
* @summary Replace environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.replaceEnvironment']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns one server-managed environment definition by id.
* @summary Retrieve environment
* @param {string} id Unique environment identifier.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveEnvironment(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.retrieveEnvironment']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* EnvironmentsApi - factory interface
*/
export const EnvironmentsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = EnvironmentsApiFp(configuration)
return {
/**
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
* @summary Create environment
* @param {CreateEnvironmentRequest} createEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
return localVarFp.createEnvironment(createEnvironmentRequest, options).then((request) => request(axios, basePath));
},
/**
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
* @summary Delete environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteEnvironment(id, ifMatch, options).then((request) => request(axios, basePath));
},
/**
* Returns all server-managed environment definitions, sorted by id.
* @summary List environments
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listEnvironments(options?: RawAxiosRequestConfig): AxiosPromise<EnvironmentListResponse> {
return localVarFp.listEnvironments(options).then((request) => request(axios, basePath));
},
/**
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
* @summary Replace environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
return localVarFp.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(axios, basePath));
},
/**
* Returns one server-managed environment definition by id.
* @summary Retrieve environment
* @param {string} id Unique environment identifier.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
return localVarFp.retrieveEnvironment(id, options).then((request) => request(axios, basePath));
},
};
};
/**
* EnvironmentsApi - object-oriented interface
*/
export class EnvironmentsApi extends BaseAPI {
/**
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
* @summary Create environment
* @param {CreateEnvironmentRequest} createEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig) {
return EnvironmentsApiFp(this.configuration).createEnvironment(createEnvironmentRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
* @summary Delete environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; 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 &#x60;ETag&#x60; 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));
}
}

View file

@ -0,0 +1,48 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentNetworkSettings } from './environment-network-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentProvider } from './environment-provider';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
/**
* Request body for creating a server-managed environment.
*/
export interface CreateEnvironmentRequest {
'id': string;
'provider': EnvironmentProvider;
'image': EnvironmentApiImageSettings;
'resources': EnvironmentResourcesSettings;
'network': EnvironmentNetworkSettings;
'lifecycle': EnvironmentLifecycleSettings;
'labels': { [key: string]: string; };
'volumes': Array<EnvironmentVolumeSettings>;
'env': { [key: string]: string; };
}

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface EnvironmentApiDockerfileSourceInline {
'type': EnvironmentApiDockerfileSourceInlineTypeEnum;
'value': string;
}
export const EnvironmentApiDockerfileSourceInlineTypeEnum = {
INLINE: 'inline'
} as const;
export type EnvironmentApiDockerfileSourceInlineTypeEnum = typeof EnvironmentApiDockerfileSourceInlineTypeEnum[keyof typeof EnvironmentApiDockerfileSourceInlineTypeEnum];

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentApiDockerfileSourceInline } from './environment-api-dockerfile-source-inline';
/**
* REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API.
*/
export interface EnvironmentApiImageSettings {
'docker': string | null;
'dockerfile': EnvironmentApiDockerfileSourceInline | null;
}

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Metadata for environment list responses.
*/
export interface EnvironmentListMeta {
/**
* Total number of server-managed environment definitions.
*/
'total': number;
}

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { Environment } from './environment';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentListMeta } from './environment-list-meta';
/**
* List envelope for environment definitions.
*/
export interface EnvironmentListResponse {
'data': Array<Environment>;
'meta': EnvironmentListMeta;
}

View file

@ -0,0 +1,52 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentNetworkSettings } from './environment-network-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentProvider } from './environment-provider';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
/**
* Public server-managed environment definition.
*/
export interface Environment {
'id': string;
/**
* Stable revision used with `If-Match` for optimistic concurrency.
*/
'revision': string;
'provider': EnvironmentProvider;
'image': EnvironmentApiImageSettings;
'resources': EnvironmentResourcesSettings;
'network': EnvironmentNetworkSettings;
'lifecycle': EnvironmentLifecycleSettings;
'labels': { [key: string]: string; };
'volumes': Array<EnvironmentVolumeSettings>;
'env': { [key: string]: string; };
}

View file

@ -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';

View file

@ -0,0 +1,47 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentNetworkSettings } from './environment-network-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentProvider } from './environment-provider';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
/**
* Request body for replacing a server-managed environment. The path id is authoritative.
*/
export interface ReplaceEnvironmentRequest {
'provider': EnvironmentProvider;
'image': EnvironmentApiImageSettings;
'resources': EnvironmentResourcesSettings;
'network': EnvironmentNetworkSettings;
'lifecycle': EnvironmentLifecycleSettings;
'labels': { [key: string]: string; };
'volumes': Array<EnvironmentVolumeSettings>;
'env': { [key: string]: string; };
}