mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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:
parent
0e224aa705
commit
037073d2b2
26 changed files with 2274 additions and 40 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1726,6 +1726,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"fabro-automation",
|
||||
"fabro-config",
|
||||
"fabro-environment",
|
||||
"fabro-model",
|
||||
"fabro-types",
|
||||
"openapiv3",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ wildcard_imports = "warn"
|
|||
chrono = { workspace = true, features = ["serde"] }
|
||||
fabro-automation = { path = "../fabro-automation" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-environment.workspace = true
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
progenitor-client = "0.13"
|
||||
|
|
|
|||
|
|
@ -635,6 +635,7 @@ fn main() {
|
|||
"fabro_automation::AutomationReplace",
|
||||
&[],
|
||||
),
|
||||
("Environment", "fabro_environment::Environment", &[]),
|
||||
("SessionId", "fabro_types::SessionId", &[]),
|
||||
("TurnId", "fabro_types::TurnId", &[]),
|
||||
("SessionStatus", "fabro_types::SessionStatus", &[]),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub mod types {
|
|||
Automation, AutomationDraft as CreateAutomationRequest,
|
||||
AutomationReplace as ReplaceAutomationRequest, AutomationTarget, AutomationTrigger,
|
||||
};
|
||||
pub use fabro_environment::Environment;
|
||||
pub use fabro_model::{
|
||||
Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode,
|
||||
Provider, ReasoningEffort, ReasoningEffortFeature, Speed as BillingSpeed,
|
||||
|
|
|
|||
88
lib/crates/fabro-api/tests/environment_round_trip.rs
Normal file
88
lib/crates/fabro-api/tests/environment_round_trip.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use fabro_api::types::{
|
||||
CreateEnvironmentRequest as ApiCreateEnvironmentRequest, Environment as ApiEnvironment,
|
||||
ReplaceEnvironmentRequest as ApiReplaceEnvironmentRequest,
|
||||
};
|
||||
use fabro_environment::Environment;
|
||||
use serde_json::json;
|
||||
|
||||
// Compile-time witness that the generated API response type resolves to the
|
||||
// same type as the `fabro-environment` domain type via `with_replacement(...)`.
|
||||
// Request types intentionally stay API-specific so REST Dockerfile sources can
|
||||
// remain inline-only without changing workflow/settings schemas.
|
||||
const _: fn(ApiEnvironment) -> Environment = |value| value;
|
||||
|
||||
fn environment_settings_json() -> serde_json::Value {
|
||||
json!({
|
||||
"provider": "docker",
|
||||
"image": {
|
||||
"docker": null,
|
||||
"dockerfile": {
|
||||
"type": "inline",
|
||||
"value": "FROM alpine\n"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"cpu": null,
|
||||
"memory": null,
|
||||
"disk": null
|
||||
},
|
||||
"network": {
|
||||
"mode": "allow_all",
|
||||
"allow": []
|
||||
},
|
||||
"lifecycle": {
|
||||
"preserve": false,
|
||||
"stop_on_terminal": true,
|
||||
"auto_stop": null
|
||||
},
|
||||
"labels": {},
|
||||
"volumes": [],
|
||||
"env": {}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_response_round_trips_public_json_shape() {
|
||||
let mut value = environment_settings_json();
|
||||
value["id"] = json!("docker-inline");
|
||||
value["revision"] = json!("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
||||
|
||||
let api: ApiEnvironment = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_environment_request_round_trips_inline_dockerfile_json_shape() {
|
||||
let mut value = environment_settings_json();
|
||||
value["id"] = json!("docker-inline");
|
||||
|
||||
let api: ApiCreateEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_environment_request_round_trips_inline_dockerfile_json_shape() {
|
||||
let value = environment_settings_json();
|
||||
|
||||
let api: ApiReplaceEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_request_schema_rejects_dockerfile_path_sources() {
|
||||
let mut value = environment_settings_json();
|
||||
value["id"] = json!("docker-path");
|
||||
value["image"]["dockerfile"] = json!({
|
||||
"type": "path",
|
||||
"path": "Dockerfile"
|
||||
});
|
||||
|
||||
let err = serde_json::from_value::<ApiCreateEnvironmentRequest>(value)
|
||||
.expect_err("generated REST request type should reject Dockerfile path sources");
|
||||
assert!(
|
||||
err.to_string().contains("dockerfile")
|
||||
|| err.to_string().contains("type")
|
||||
|| err.to_string().contains("path"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ impl Environment {
|
|||
) -> Result<(Self, Vec<u8>), EnvironmentStoreError> {
|
||||
let settings = inline_dense_dockerfile(settings, dockerfile_base_dir).await?;
|
||||
let persisted = environment_settings_to_layer(&settings);
|
||||
let settings = resolve_environment(&persisted)?;
|
||||
let bytes = canonical_bytes(&persisted).into_bytes();
|
||||
let revision = EnvironmentRevision::from_bytes(&bytes);
|
||||
Ok((
|
||||
|
|
|
|||
|
|
@ -404,8 +404,8 @@ mod tests {
|
|||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
|
||||
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
|
||||
EnvironmentSettings,
|
||||
EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider,
|
||||
EnvironmentResourcesSettings, EnvironmentSettings,
|
||||
};
|
||||
use tokio::fs;
|
||||
|
||||
|
|
@ -572,6 +572,28 @@ path = "Dockerfile"
|
|||
assert!(matches!(err, EnvironmentStoreError::AlreadyExists { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_invalid_settings_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
|
||||
let mut settings = settings(EnvironmentProvider::Local);
|
||||
settings.network.mode = EnvironmentNetworkMode::Block;
|
||||
|
||||
let err = store
|
||||
.create(EnvironmentDraft {
|
||||
id: EnvironmentId::new("invalid").unwrap(),
|
||||
settings,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("local environments cannot enforce")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_stale_revision_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
260
lib/crates/fabro-server/src/server/handler/environments.rs
Normal file
260
lib/crates/fabro-server/src/server/handler/environments.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use fabro_environment::{Environment, EnvironmentDraft, EnvironmentId, EnvironmentStoreError};
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
|
||||
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
|
||||
EnvironmentSettings, EnvironmentVolumeSettings,
|
||||
};
|
||||
use serde::de::IgnoredAny;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, IntoResponse, Json, Path, RequiredUser, Response, Router, State,
|
||||
StatusCode, get,
|
||||
};
|
||||
use super::{json_with_etag_response, parse_required_if_match};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EnvironmentListResponse {
|
||||
data: Vec<Environment>,
|
||||
meta: EnvironmentListMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EnvironmentListMeta {
|
||||
total: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateEnvironmentRequest {
|
||||
id: EnvironmentId,
|
||||
provider: EnvironmentProvider,
|
||||
image: ApiEnvironmentImageSettings,
|
||||
resources: EnvironmentResourcesSettings,
|
||||
network: EnvironmentNetworkSettings,
|
||||
lifecycle: EnvironmentLifecycleSettings,
|
||||
labels: HashMap<String, String>,
|
||||
volumes: Vec<EnvironmentVolumeSettings>,
|
||||
env: HashMap<String, InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ReplaceEnvironmentRequest {
|
||||
provider: EnvironmentProvider,
|
||||
image: ApiEnvironmentImageSettings,
|
||||
resources: EnvironmentResourcesSettings,
|
||||
network: EnvironmentNetworkSettings,
|
||||
lifecycle: EnvironmentLifecycleSettings,
|
||||
labels: HashMap<String, String>,
|
||||
volumes: Vec<EnvironmentVolumeSettings>,
|
||||
env: HashMap<String, InterpString>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ApiEnvironmentImageSettings {
|
||||
docker: Option<String>,
|
||||
dockerfile: Option<ApiDockerfileSource>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum ApiDockerfileSource {
|
||||
Inline {
|
||||
value: String,
|
||||
},
|
||||
// Recognized so the handler can return a 422 with bespoke guidance.
|
||||
// The `path` payload is parsed and discarded — never read from disk.
|
||||
Path {
|
||||
#[serde(rename = "path")]
|
||||
_path: IgnoredAny,
|
||||
},
|
||||
}
|
||||
|
||||
impl CreateEnvironmentRequest {
|
||||
fn into_draft(self) -> Result<EnvironmentDraft, ApiError> {
|
||||
Ok(EnvironmentDraft {
|
||||
id: self.id,
|
||||
settings: EnvironmentSettings {
|
||||
provider: self.provider,
|
||||
image: self.image.into_settings()?,
|
||||
resources: self.resources,
|
||||
network: self.network,
|
||||
lifecycle: self.lifecycle,
|
||||
labels: self.labels,
|
||||
volumes: self.volumes,
|
||||
env: self.env,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplaceEnvironmentRequest {
|
||||
fn into_settings(self) -> Result<EnvironmentSettings, ApiError> {
|
||||
Ok(EnvironmentSettings {
|
||||
provider: self.provider,
|
||||
image: self.image.into_settings()?,
|
||||
resources: self.resources,
|
||||
network: self.network,
|
||||
lifecycle: self.lifecycle,
|
||||
labels: self.labels,
|
||||
volumes: self.volumes,
|
||||
env: self.env,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiEnvironmentImageSettings {
|
||||
fn into_settings(self) -> Result<EnvironmentImageSettings, ApiError> {
|
||||
Ok(EnvironmentImageSettings {
|
||||
docker: self.docker,
|
||||
dockerfile: self
|
||||
.dockerfile
|
||||
.map(ApiDockerfileSource::into_settings)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiDockerfileSource {
|
||||
fn into_settings(self) -> Result<DockerfileSource, ApiError> {
|
||||
match self {
|
||||
Self::Inline { value } => Ok(DockerfileSource::Inline(value)),
|
||||
Self::Path { .. } => Err(ApiError::new(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"Dockerfile path sources are not supported by the environments REST API; use inline Dockerfile content",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/environments",
|
||||
get(list_environments).post(create_environment),
|
||||
)
|
||||
.route(
|
||||
"/environments/{id}",
|
||||
get(get_environment)
|
||||
.put(replace_environment)
|
||||
.delete(delete_environment),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_environments(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
|
||||
let data = state.environment_store().list();
|
||||
let total = data.len();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(EnvironmentListResponse {
|
||||
data,
|
||||
meta: EnvironmentListMeta { total },
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn create_environment(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateEnvironmentRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let environment = state
|
||||
.environment_store()
|
||||
.create(request.into_draft()?)
|
||||
.await?;
|
||||
state.refresh_manifest_run_settings_from_environment_catalog();
|
||||
Ok((StatusCode::CREATED, Json(environment)).into_response())
|
||||
}
|
||||
|
||||
async fn get_environment(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let id = parse_path_id(id)?;
|
||||
match state.environment_store().get(&id) {
|
||||
Some(environment) => Ok(environment_with_etag_response(StatusCode::OK, environment)),
|
||||
None => Err(ApiError::not_found(format!("environment not found: {id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn replace_environment(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<ReplaceEnvironmentRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let id = parse_path_id(id)?;
|
||||
let expected = parse_required_if_match(&headers, "environment", &id)?;
|
||||
let environment = state
|
||||
.environment_store()
|
||||
.replace(&id, &expected, request.into_settings()?)
|
||||
.await?;
|
||||
state.refresh_manifest_run_settings_from_environment_catalog();
|
||||
Ok(environment_with_etag_response(StatusCode::OK, environment))
|
||||
}
|
||||
|
||||
async fn delete_environment(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let id = parse_path_id(id)?;
|
||||
let expected = parse_required_if_match(&headers, "environment", &id)?;
|
||||
state.environment_store().delete(&id, &expected).await?;
|
||||
state.refresh_manifest_run_settings_from_environment_catalog();
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
fn parse_path_id(id: String) -> Result<EnvironmentId, ApiError> {
|
||||
EnvironmentId::new(id)
|
||||
.map_err(|err| ApiError::bad_request(format!("invalid environment id: {err}")))
|
||||
}
|
||||
|
||||
fn environment_with_etag_response(status: StatusCode, environment: Environment) -> Response {
|
||||
let revision = environment.revision.clone();
|
||||
json_with_etag_response(status, "environment", &revision, environment)
|
||||
}
|
||||
|
||||
impl From<EnvironmentStoreError> for ApiError {
|
||||
fn from(err: EnvironmentStoreError) -> Self {
|
||||
match err {
|
||||
EnvironmentStoreError::NotFound { id } => {
|
||||
Self::not_found(format!("environment not found: {id}"))
|
||||
}
|
||||
EnvironmentStoreError::AlreadyExists { id } => Self::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!("environment already exists: {id}"),
|
||||
),
|
||||
EnvironmentStoreError::StaleRevision { id, .. } => Self::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!("environment revision is stale: {id}"),
|
||||
),
|
||||
EnvironmentStoreError::Protected { id } => Self::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!("environment is protected and cannot be deleted: {id}"),
|
||||
),
|
||||
EnvironmentStoreError::Validation { source } => {
|
||||
Self::new(StatusCode::UNPROCESSABLE_ENTITY, source.to_string())
|
||||
}
|
||||
EnvironmentStoreError::InvalidFilename { .. }
|
||||
| EnvironmentStoreError::Parse { .. }
|
||||
| EnvironmentStoreError::InvalidUtf8 { .. }
|
||||
| EnvironmentStoreError::Serialize { .. }
|
||||
| EnvironmentStoreError::Io { .. } => Self::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"environment store operation failed",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
602
lib/crates/fabro-server/tests/it/api/environments.rs
Normal file
602
lib/crates/fabro-server/tests/it/api/environments.rs
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode, header};
|
||||
use fabro_config::{RunEnvironmentLayer, RunLayer};
|
||||
use fabro_server::server::build_router;
|
||||
use fabro_server::test_support::{
|
||||
TestAppStateBuilder, build_test_router, default_test_server_settings, test_auth_mode,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::helpers::{api, checked_response, response_json, response_status};
|
||||
|
||||
fn environment_settings(provider: &str) -> Value {
|
||||
json!({
|
||||
"provider": provider,
|
||||
"image": {
|
||||
"docker": if provider == "docker" { json!("alpine:3.20") } else { Value::Null },
|
||||
"dockerfile": null
|
||||
},
|
||||
"resources": {
|
||||
"cpu": null,
|
||||
"memory": null,
|
||||
"disk": null
|
||||
},
|
||||
"network": {
|
||||
"mode": "allow_all",
|
||||
"allow": []
|
||||
},
|
||||
"lifecycle": {
|
||||
"preserve": false,
|
||||
"stop_on_terminal": true,
|
||||
"auto_stop": null
|
||||
},
|
||||
"labels": {},
|
||||
"volumes": [],
|
||||
"env": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn environment_body(id: &str, provider: &str) -> Value {
|
||||
let mut body = environment_settings(provider);
|
||||
body["id"] = json!(id);
|
||||
body
|
||||
}
|
||||
|
||||
fn environment_app() -> (axum::Router, tempfile::TempDir, PathBuf) {
|
||||
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
|
||||
let active_config_path = temp_dir.path().join("settings.toml");
|
||||
let environment_dir = temp_dir.path().join("environments");
|
||||
let state = TestAppStateBuilder::new()
|
||||
.active_config_path(active_config_path)
|
||||
.build();
|
||||
(build_test_router(state), temp_dir, environment_dir)
|
||||
}
|
||||
|
||||
fn environment_app_with_default_environment(
|
||||
environment_id: &str,
|
||||
) -> (axum::Router, tempfile::TempDir) {
|
||||
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
|
||||
let active_config_path = temp_dir.path().join("settings.toml");
|
||||
let manifest_run_defaults = RunLayer {
|
||||
environment: Some(RunEnvironmentLayer {
|
||||
id: Some(environment_id.to_string()),
|
||||
..RunEnvironmentLayer::default()
|
||||
}),
|
||||
..RunLayer::default()
|
||||
};
|
||||
let state = TestAppStateBuilder::new()
|
||||
.runtime_settings(default_test_server_settings(), manifest_run_defaults)
|
||||
.active_config_path(active_config_path)
|
||||
.build();
|
||||
(build_test_router(state), temp_dir)
|
||||
}
|
||||
|
||||
fn json_request(method: Method, path: &str, body: &Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(api(path))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(body).expect("environment fixture should serialize"),
|
||||
))
|
||||
.expect("environment JSON request should build")
|
||||
}
|
||||
|
||||
fn empty_request(method: Method, path: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(api(path))
|
||||
.body(Body::empty())
|
||||
.expect("environment request should build")
|
||||
}
|
||||
|
||||
fn request_with_if_match(
|
||||
method: Method,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
body: Option<Value>,
|
||||
) -> Request<Body> {
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(api(path))
|
||||
.header(header::IF_MATCH, revision);
|
||||
let body = match body {
|
||||
Some(value) => {
|
||||
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
||||
Body::from(serde_json::to_vec(&value).expect("environment fixture should serialize"))
|
||||
}
|
||||
None => Body::empty(),
|
||||
};
|
||||
builder
|
||||
.body(body)
|
||||
.expect("environment If-Match request should build")
|
||||
}
|
||||
|
||||
async fn create_environment(app: &axum::Router, id: &str, provider: &str) -> Value {
|
||||
create_environment_with_body(app, &environment_body(id, provider)).await
|
||||
}
|
||||
|
||||
async fn create_environment_with_body(app: &axum::Router, body: &Value) -> Value {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(json_request(Method::POST, "/environments", body))
|
||||
.await
|
||||
.expect("create environment should respond");
|
||||
response_json(response, StatusCode::CREATED, "POST /api/v1/environments").await
|
||||
}
|
||||
|
||||
fn revision_from(body: &Value) -> &str {
|
||||
body["revision"]
|
||||
.as_str()
|
||||
.expect("environment response should include a revision")
|
||||
}
|
||||
|
||||
async fn persisted_environment_toml(environment_dir: &Path, id: &str) -> toml::Value {
|
||||
let persisted = tokio::fs::read_to_string(environment_dir.join(format!("{id}.toml")))
|
||||
.await
|
||||
.expect("persisted environment TOML should be readable");
|
||||
toml::from_str(&persisted).expect("persisted environment TOML should parse")
|
||||
}
|
||||
|
||||
async fn system_info(app: &axum::Router) -> Value {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, "/system/info"))
|
||||
.await
|
||||
.expect("system info should respond");
|
||||
response_json(response, StatusCode::OK, "GET /api/v1/system/info").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_environments_returns_seeded_catalog_sorted_by_id() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
|
||||
let response = app
|
||||
.oneshot(empty_request(Method::GET, "/environments"))
|
||||
.await
|
||||
.expect("list environments should respond");
|
||||
let body = response_json(response, StatusCode::OK, "GET /api/v1/environments").await;
|
||||
|
||||
assert_eq!(body["meta"]["total"], 4);
|
||||
assert_eq!(
|
||||
body["data"]
|
||||
.as_array()
|
||||
.expect("environment list data should be an array")
|
||||
.iter()
|
||||
.map(|environment| environment["id"]
|
||||
.as_str()
|
||||
.expect("environment should have id"))
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["daytona", "default", "docker", "local"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_environment_persists_sibling_toml_and_is_visible() {
|
||||
let (app, _temp_dir, environment_dir) = environment_app();
|
||||
|
||||
let created = create_environment(&app, "custom-env", "docker").await;
|
||||
|
||||
assert_eq!(created["id"], "custom-env");
|
||||
assert_eq!(created["provider"], "docker");
|
||||
assert!(environment_dir.join("custom-env.toml").exists());
|
||||
|
||||
let retrieved = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, "/environments/custom-env"))
|
||||
.await
|
||||
.expect("get environment should respond");
|
||||
let retrieved = response_json(
|
||||
retrieved,
|
||||
StatusCode::OK,
|
||||
"GET /api/v1/environments/custom-env",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(retrieved["id"], "custom-env");
|
||||
|
||||
let list = app
|
||||
.oneshot(empty_request(Method::GET, "/environments"))
|
||||
.await
|
||||
.expect("list environments should respond");
|
||||
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
|
||||
assert_eq!(list["meta"]["total"], 5);
|
||||
assert!(
|
||||
list["data"]
|
||||
.as_array()
|
||||
.expect("environment list data should be an array")
|
||||
.iter()
|
||||
.any(|environment| environment["id"] == "custom-env")
|
||||
);
|
||||
|
||||
let persisted = persisted_environment_toml(&environment_dir, "custom-env").await;
|
||||
assert_eq!(
|
||||
persisted.get("provider").and_then(toml::Value::as_str),
|
||||
Some("docker")
|
||||
);
|
||||
assert!(persisted.get("id").is_none());
|
||||
assert!(persisted.get("revision").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_environment_returns_current_etag() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
let created = create_environment(&app, "etag-env", "local").await;
|
||||
let revision = revision_from(&created);
|
||||
|
||||
let response = app
|
||||
.oneshot(empty_request(Method::GET, "/environments/etag-env"))
|
||||
.await
|
||||
.expect("get environment should respond");
|
||||
let response = checked_response(
|
||||
response,
|
||||
StatusCode::OK,
|
||||
"GET /api/v1/environments/etag-env",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.expect("GET environment should include ETag"),
|
||||
&format!("\"{revision}\"")
|
||||
);
|
||||
let body = crate::helpers::body_json(response.into_body()).await;
|
||||
assert_eq!(body["revision"], revision);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_environment_updates_file_and_returns_new_etag() {
|
||||
let (app, _temp_dir, environment_dir) = environment_app();
|
||||
let created = create_environment(&app, "replace-env", "docker").await;
|
||||
let revision = revision_from(&created);
|
||||
let mut replacement = environment_settings("local");
|
||||
replacement["labels"] = json!({ "tier": "dev" });
|
||||
|
||||
let response = app
|
||||
.oneshot(request_with_if_match(
|
||||
Method::PUT,
|
||||
"/environments/replace-env",
|
||||
revision,
|
||||
Some(replacement),
|
||||
))
|
||||
.await
|
||||
.expect("replace environment should respond");
|
||||
let response = checked_response(
|
||||
response,
|
||||
StatusCode::OK,
|
||||
"PUT /api/v1/environments/replace-env",
|
||||
)
|
||||
.await;
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.expect("PUT environment should include ETag")
|
||||
.to_str()
|
||||
.expect("ETag should be ASCII")
|
||||
.to_string();
|
||||
let body = crate::helpers::body_json(response.into_body()).await;
|
||||
|
||||
assert_eq!(body["provider"], "local");
|
||||
assert_eq!(body["labels"]["tier"], "dev");
|
||||
assert_ne!(body["revision"], revision);
|
||||
assert_eq!(etag, format!("\"{}\"", revision_from(&body)));
|
||||
let persisted = persisted_environment_toml(&environment_dir, "replace-env").await;
|
||||
assert_eq!(
|
||||
persisted
|
||||
.get("labels")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|labels| labels.get("tier"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("dev")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_and_delete_environment_require_if_match() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
create_environment(&app, "match-env", "local").await;
|
||||
|
||||
let replace_response = app
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
Method::PUT,
|
||||
"/environments/match-env",
|
||||
&environment_settings("docker"),
|
||||
))
|
||||
.await
|
||||
.expect("replace without If-Match should respond");
|
||||
response_status(
|
||||
replace_response,
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
"PUT /api/v1/environments/match-env without If-Match",
|
||||
)
|
||||
.await;
|
||||
|
||||
let delete_response = app
|
||||
.oneshot(empty_request(Method::DELETE, "/environments/match-env"))
|
||||
.await
|
||||
.expect("delete without If-Match should respond");
|
||||
response_status(
|
||||
delete_response,
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
"DELETE /api/v1/environments/match-env without If-Match",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_environment_replace_and_delete_return_conflict() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
let created = create_environment(&app, "stale-env", "docker").await;
|
||||
let stale_revision = revision_from(&created).to_string();
|
||||
|
||||
let replaced = app
|
||||
.clone()
|
||||
.oneshot(request_with_if_match(
|
||||
Method::PUT,
|
||||
"/environments/stale-env",
|
||||
&stale_revision,
|
||||
Some(environment_settings("local")),
|
||||
))
|
||||
.await
|
||||
.expect("first replace should respond");
|
||||
response_status(
|
||||
replaced,
|
||||
StatusCode::OK,
|
||||
"PUT /api/v1/environments/stale-env first replace",
|
||||
)
|
||||
.await;
|
||||
|
||||
let stale_replace = app
|
||||
.clone()
|
||||
.oneshot(request_with_if_match(
|
||||
Method::PUT,
|
||||
"/environments/stale-env",
|
||||
&stale_revision,
|
||||
Some(environment_settings("docker")),
|
||||
))
|
||||
.await
|
||||
.expect("stale replace should respond");
|
||||
response_status(
|
||||
stale_replace,
|
||||
StatusCode::CONFLICT,
|
||||
"PUT /api/v1/environments/stale-env stale",
|
||||
)
|
||||
.await;
|
||||
|
||||
let stale_delete = app
|
||||
.oneshot(request_with_if_match(
|
||||
Method::DELETE,
|
||||
"/environments/stale-env",
|
||||
&stale_revision,
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("stale delete should respond");
|
||||
response_status(
|
||||
stale_delete,
|
||||
StatusCode::CONFLICT,
|
||||
"DELETE /api/v1/environments/stale-env stale",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_environment_create_returns_conflict() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
create_environment(&app, "duplicate-env", "local").await;
|
||||
|
||||
let response = app
|
||||
.oneshot(json_request(
|
||||
Method::POST,
|
||||
"/environments",
|
||||
&environment_body("duplicate-env", "docker"),
|
||||
))
|
||||
.await
|
||||
.expect("duplicate create should respond");
|
||||
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::CONFLICT,
|
||||
"POST /api/v1/environments duplicate",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_environment_id_and_if_match_return_bad_request() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
|
||||
let invalid_id = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, "/environments/Bad!"))
|
||||
.await
|
||||
.expect("invalid id request should respond");
|
||||
response_status(
|
||||
invalid_id,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"GET /api/v1/environments/Bad!",
|
||||
)
|
||||
.await;
|
||||
|
||||
create_environment(&app, "header-env", "local").await;
|
||||
let invalid_header = app
|
||||
.oneshot(request_with_if_match(
|
||||
Method::PUT,
|
||||
"/environments/header-env",
|
||||
"not-a-revision",
|
||||
Some(environment_settings("docker")),
|
||||
))
|
||||
.await
|
||||
.expect("invalid If-Match request should respond");
|
||||
response_status(
|
||||
invalid_header,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"PUT /api/v1/environments/header-env invalid If-Match",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_environment_settings_return_unprocessable_entity() {
|
||||
let (app, _temp_dir, _environment_dir) = environment_app();
|
||||
let mut body = environment_body("invalid-env", "local");
|
||||
body["network"]["mode"] = json!("block");
|
||||
|
||||
let response = app
|
||||
.oneshot(json_request(Method::POST, "/environments", &body))
|
||||
.await
|
||||
.expect("invalid environment create should respond");
|
||||
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"POST /api/v1/environments invalid settings",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dockerfile_path_over_rest_is_rejected_without_persisting_or_exposing_contents() {
|
||||
let (app, temp_dir, environment_dir) = environment_app();
|
||||
tokio::fs::write(
|
||||
temp_dir.path().join("Dockerfile"),
|
||||
"FROM private.example/secret\n",
|
||||
)
|
||||
.await
|
||||
.expect("secret Dockerfile fixture should be written");
|
||||
let mut body = environment_body("path-env", "docker");
|
||||
body["image"]["docker"] = Value::Null;
|
||||
body["image"]["dockerfile"] = json!({
|
||||
"type": "path",
|
||||
"path": "Dockerfile"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(json_request(Method::POST, "/environments", &body))
|
||||
.await
|
||||
.expect("path Dockerfile create should respond");
|
||||
let error = response_json(
|
||||
response,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"POST /api/v1/environments Dockerfile path",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!environment_dir.join("path-env.toml").exists());
|
||||
assert!(
|
||||
!serde_json::to_string(&error)
|
||||
.expect("error body should serialize")
|
||||
.contains("private.example/secret")
|
||||
);
|
||||
let list = app
|
||||
.oneshot(empty_request(Method::GET, "/environments"))
|
||||
.await
|
||||
.expect("list environments should respond");
|
||||
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
|
||||
assert!(
|
||||
!list["data"]
|
||||
.as_array()
|
||||
.expect("environment list data should be an array")
|
||||
.iter()
|
||||
.any(|environment| environment["id"] == "path-env")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_environment_removes_non_default_and_default_is_protected() {
|
||||
let (app, _temp_dir, environment_dir) = environment_app();
|
||||
let created = create_environment(&app, "delete-env", "local").await;
|
||||
let revision = revision_from(&created);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request_with_if_match(
|
||||
Method::DELETE,
|
||||
"/environments/delete-env",
|
||||
&format!("\"{revision}\""),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("delete environment should respond");
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::NO_CONTENT,
|
||||
"DELETE /api/v1/environments/delete-env",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!environment_dir.join("delete-env.toml").exists());
|
||||
let missing = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, "/environments/delete-env"))
|
||||
.await
|
||||
.expect("get deleted environment should respond");
|
||||
response_status(
|
||||
missing,
|
||||
StatusCode::NOT_FOUND,
|
||||
"GET /api/v1/environments/delete-env after delete",
|
||||
)
|
||||
.await;
|
||||
|
||||
let default = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, "/environments/default"))
|
||||
.await
|
||||
.expect("get default environment should respond");
|
||||
let default = response_json(default, StatusCode::OK, "GET /api/v1/environments/default").await;
|
||||
let protected = app
|
||||
.oneshot(request_with_if_match(
|
||||
Method::DELETE,
|
||||
"/environments/default",
|
||||
revision_from(&default),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("delete default environment should respond");
|
||||
response_status(
|
||||
protected,
|
||||
StatusCode::CONFLICT,
|
||||
"DELETE /api/v1/environments/default",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn environment_routes_require_authenticated_user() {
|
||||
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
|
||||
let state = TestAppStateBuilder::new()
|
||||
.active_config_path(temp_dir.path().join("settings.toml"))
|
||||
.build();
|
||||
let app = build_router(state, test_auth_mode());
|
||||
|
||||
let response = app
|
||||
.oneshot(empty_request(Method::GET, "/environments"))
|
||||
.await
|
||||
.expect("unauthenticated environment list should respond");
|
||||
|
||||
response_status(
|
||||
response,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /api/v1/environments without auth",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_environment_refreshes_cached_manifest_run_settings() {
|
||||
let (app, _temp_dir) = environment_app_with_default_environment("api-default");
|
||||
|
||||
let before = system_info(&app).await;
|
||||
assert_eq!(before["sandbox_provider"], "local");
|
||||
|
||||
create_environment(&app, "api-default", "daytona").await;
|
||||
|
||||
let after = system_info(&app).await;
|
||||
assert_eq!(after["sandbox_provider"], "daytona");
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ mod auth_sessions;
|
|||
mod automations;
|
||||
mod cli_auth_token;
|
||||
mod docs;
|
||||
mod environments;
|
||||
mod events;
|
||||
mod install;
|
||||
mod install_openai_compatible;
|
||||
|
|
|
|||
|
|
@ -141,6 +141,54 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_spec_and_sdk_expose_crud_without_dockerfile_paths() {
|
||||
let spec = load_spec();
|
||||
let paths = spec
|
||||
.get("paths")
|
||||
.and_then(Value::as_mapping)
|
||||
.expect("spec is missing `paths`");
|
||||
for path in ["/api/v1/environments", "/api/v1/environments/{id}"] {
|
||||
assert!(
|
||||
paths.contains_key(Value::String(path.to_string())),
|
||||
"OpenAPI spec should expose {path}"
|
||||
);
|
||||
}
|
||||
|
||||
let generated_api = read_repo_file("lib/packages/fabro-api-client/src/api/environments-api.ts");
|
||||
assert!(
|
||||
generated_api.contains("export class EnvironmentsApi"),
|
||||
"generated TypeScript client should expose EnvironmentsApi"
|
||||
);
|
||||
for operation in [
|
||||
"createEnvironment",
|
||||
"deleteEnvironment",
|
||||
"listEnvironments",
|
||||
"replaceEnvironment",
|
||||
"retrieveEnvironment",
|
||||
] {
|
||||
assert!(
|
||||
generated_api.contains(operation),
|
||||
"generated EnvironmentsApi should expose {operation}"
|
||||
);
|
||||
}
|
||||
|
||||
let generated_image = read_repo_file(
|
||||
"lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts",
|
||||
);
|
||||
assert!(
|
||||
!generated_image.contains("DockerfileSourcePath") && !generated_image.contains("'path'"),
|
||||
"generated REST environment image model should not expose Dockerfile path sources"
|
||||
);
|
||||
|
||||
let workflow_dockerfile =
|
||||
read_repo_file("lib/packages/fabro-api-client/src/models/dockerfile-source.ts");
|
||||
assert!(
|
||||
workflow_dockerfile.contains("DockerfileSourcePath"),
|
||||
"workflow/settings Dockerfile schema should keep exposing path sources"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() {
|
||||
let secret = "test-webhook-secret";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ api/automations-api.ts
|
|||
api/billing-api.ts
|
||||
api/completions-api.ts
|
||||
api/discovery-api.ts
|
||||
api/environments-api.ts
|
||||
api/human-in-the-loop-api.ts
|
||||
api/insights-api.ts
|
||||
api/install-api.ts
|
||||
|
|
@ -94,6 +95,7 @@ models/completion-usage.ts
|
|||
models/conclusion.ts
|
||||
models/create-automation-request.ts
|
||||
models/create-completion-request.ts
|
||||
models/create-environment-request.ts
|
||||
models/create-run-pull-request-request.ts
|
||||
models/create-run-session-request.ts
|
||||
models/create-secret-request.ts
|
||||
|
|
@ -118,14 +120,19 @@ models/disk-usage-summary-row.ts
|
|||
models/dockerfile-source-inline.ts
|
||||
models/dockerfile-source-path.ts
|
||||
models/dockerfile-source.ts
|
||||
models/environment-api-dockerfile-source-inline.ts
|
||||
models/environment-api-image-settings.ts
|
||||
models/environment-image-settings.ts
|
||||
models/environment-lifecycle-settings.ts
|
||||
models/environment-list-meta.ts
|
||||
models/environment-list-response.ts
|
||||
models/environment-network-mode.ts
|
||||
models/environment-network-settings.ts
|
||||
models/environment-provider.ts
|
||||
models/environment-resources-settings.ts
|
||||
models/environment-settings.ts
|
||||
models/environment-volume-settings.ts
|
||||
models/environment.ts
|
||||
models/error-response-entry.ts
|
||||
models/error-response.ts
|
||||
models/event-envelope.ts
|
||||
|
|
@ -300,6 +307,7 @@ models/render-workflow-graph-direction.ts
|
|||
models/render-workflow-graph-format.ts
|
||||
models/render-workflow-graph-request.ts
|
||||
models/replace-automation-request.ts
|
||||
models/replace-environment-request.ts
|
||||
models/repo-check-response-permissions.ts
|
||||
models/repo-check-response.ts
|
||||
models/repository-ref.ts
|
||||
|
|
|
|||
1
lib/packages/fabro-api-client/src/api.ts
generated
1
lib/packages/fabro-api-client/src/api.ts
generated
|
|
@ -19,6 +19,7 @@ export * from './api/automations-api';
|
|||
export * from './api/billing-api';
|
||||
export * from './api/completions-api';
|
||||
export * from './api/discovery-api';
|
||||
export * from './api/environments-api';
|
||||
export * from './api/human-in-the-loop-api';
|
||||
export * from './api/insights-api';
|
||||
export * from './api/install-api';
|
||||
|
|
|
|||
453
lib/packages/fabro-api-client/src/api/environments-api.ts
generated
Normal file
453
lib/packages/fabro-api-client/src/api/environments-api.ts
generated
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
import type { Configuration } from '../configuration';
|
||||
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
|
||||
import globalAxios from 'axios';
|
||||
// Some imports not used depending on template conditions
|
||||
// @ts-ignore
|
||||
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
|
||||
// @ts-ignore
|
||||
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
|
||||
// @ts-ignore
|
||||
import type { CreateEnvironmentRequest } from '../models';
|
||||
// @ts-ignore
|
||||
import type { Environment } from '../models';
|
||||
// @ts-ignore
|
||||
import type { EnvironmentListResponse } from '../models';
|
||||
// @ts-ignore
|
||||
import type { ErrorResponse } from '../models';
|
||||
// @ts-ignore
|
||||
import type { ReplaceEnvironmentRequest } from '../models';
|
||||
/**
|
||||
* EnvironmentsApi - axios parameter creator
|
||||
*/
|
||||
export const EnvironmentsApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
|
||||
* @summary Create environment
|
||||
* @param {CreateEnvironmentRequest} createEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
createEnvironment: async (createEnvironmentRequest: CreateEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'createEnvironmentRequest' is not null or undefined
|
||||
assertParamExists('createEnvironment', 'createEnvironmentRequest', createEnvironmentRequest)
|
||||
const localVarPath = `/api/v1/environments`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(createEnvironmentRequest, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
|
||||
* @summary Delete environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
deleteEnvironment: async (id: string, ifMatch: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('deleteEnvironment', 'id', id)
|
||||
// verify required parameter 'ifMatch' is not null or undefined
|
||||
assertParamExists('deleteEnvironment', 'ifMatch', ifMatch)
|
||||
const localVarPath = `/api/v1/environments/{id}`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
if (ifMatch != null) {
|
||||
localVarHeaderParameter['If-Match'] = String(ifMatch);
|
||||
}
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Returns all server-managed environment definitions, sorted by id.
|
||||
* @summary List environments
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listEnvironments: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/api/v1/environments`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
|
||||
* @summary Replace environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
replaceEnvironment: async (id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('replaceEnvironment', 'id', id)
|
||||
// verify required parameter 'ifMatch' is not null or undefined
|
||||
assertParamExists('replaceEnvironment', 'ifMatch', ifMatch)
|
||||
// verify required parameter 'replaceEnvironmentRequest' is not null or undefined
|
||||
assertParamExists('replaceEnvironment', 'replaceEnvironmentRequest', replaceEnvironmentRequest)
|
||||
const localVarPath = `/api/v1/environments/{id}`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
if (ifMatch != null) {
|
||||
localVarHeaderParameter['If-Match'] = String(ifMatch);
|
||||
}
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(replaceEnvironmentRequest, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Returns one server-managed environment definition by id.
|
||||
* @summary Retrieve environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
retrieveEnvironment: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('retrieveEnvironment', 'id', id)
|
||||
const localVarPath = `/api/v1/environments/{id}`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EnvironmentsApi - functional programming interface
|
||||
*/
|
||||
export const EnvironmentsApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosParamCreator = EnvironmentsApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
|
||||
* @summary Create environment
|
||||
* @param {CreateEnvironmentRequest} createEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.createEnvironment(createEnvironmentRequest, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.createEnvironment']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
|
||||
* @summary Delete environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteEnvironment(id, ifMatch, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.deleteEnvironment']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns all server-managed environment definitions, sorted by id.
|
||||
* @summary List environments
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async listEnvironments(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<EnvironmentListResponse>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listEnvironments(options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.listEnvironments']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
|
||||
* @summary Replace environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.replaceEnvironment']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns one server-managed environment definition by id.
|
||||
* @summary Retrieve environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveEnvironment(id, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.retrieveEnvironment']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EnvironmentsApi - factory interface
|
||||
*/
|
||||
export const EnvironmentsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||
const localVarFp = EnvironmentsApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
|
||||
* @summary Create environment
|
||||
* @param {CreateEnvironmentRequest} createEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
|
||||
return localVarFp.createEnvironment(createEnvironmentRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
|
||||
* @summary Delete environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
|
||||
return localVarFp.deleteEnvironment(id, ifMatch, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns all server-managed environment definitions, sorted by id.
|
||||
* @summary List environments
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listEnvironments(options?: RawAxiosRequestConfig): AxiosPromise<EnvironmentListResponse> {
|
||||
return localVarFp.listEnvironments(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
|
||||
* @summary Replace environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
|
||||
return localVarFp.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns one server-managed environment definition by id.
|
||||
* @summary Retrieve environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
|
||||
return localVarFp.retrieveEnvironment(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* EnvironmentsApi - object-oriented interface
|
||||
*/
|
||||
export class EnvironmentsApi extends BaseAPI {
|
||||
/**
|
||||
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
|
||||
* @summary Create environment
|
||||
* @param {CreateEnvironmentRequest} createEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).createEnvironment(createEnvironmentRequest, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
|
||||
* @summary Delete environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).deleteEnvironment(id, ifMatch, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all server-managed environment definitions, sorted by id.
|
||||
* @summary List environments
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public listEnvironments(options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).listEnvironments(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
|
||||
* @summary Replace environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.
|
||||
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one server-managed environment definition by id.
|
||||
* @summary Retrieve environment
|
||||
* @param {string} id Unique environment identifier.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public retrieveEnvironment(id: string, options?: RawAxiosRequestConfig) {
|
||||
return EnvironmentsApiFp(this.configuration).retrieveEnvironment(id, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
48
lib/packages/fabro-api-client/src/models/create-environment-request.ts
generated
Normal file
48
lib/packages/fabro-api-client/src/models/create-environment-request.ts
generated
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentNetworkSettings } from './environment-network-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentProvider } from './environment-provider';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
|
||||
|
||||
/**
|
||||
* Request body for creating a server-managed environment.
|
||||
*/
|
||||
export interface CreateEnvironmentRequest {
|
||||
'id': string;
|
||||
'provider': EnvironmentProvider;
|
||||
'image': EnvironmentApiImageSettings;
|
||||
'resources': EnvironmentResourcesSettings;
|
||||
'network': EnvironmentNetworkSettings;
|
||||
'lifecycle': EnvironmentLifecycleSettings;
|
||||
'labels': { [key: string]: string; };
|
||||
'volumes': Array<EnvironmentVolumeSettings>;
|
||||
'env': { [key: string]: string; };
|
||||
}
|
||||
26
lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts
generated
Normal file
26
lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts
generated
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export interface EnvironmentApiDockerfileSourceInline {
|
||||
'type': EnvironmentApiDockerfileSourceInlineTypeEnum;
|
||||
'value': string;
|
||||
}
|
||||
|
||||
export const EnvironmentApiDockerfileSourceInlineTypeEnum = {
|
||||
INLINE: 'inline'
|
||||
} as const;
|
||||
|
||||
export type EnvironmentApiDockerfileSourceInlineTypeEnum = typeof EnvironmentApiDockerfileSourceInlineTypeEnum[keyof typeof EnvironmentApiDockerfileSourceInlineTypeEnum];
|
||||
26
lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts
generated
Normal file
26
lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts
generated
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentApiDockerfileSourceInline } from './environment-api-dockerfile-source-inline';
|
||||
|
||||
/**
|
||||
* REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API.
|
||||
*/
|
||||
export interface EnvironmentApiImageSettings {
|
||||
'docker': string | null;
|
||||
'dockerfile': EnvironmentApiDockerfileSourceInline | null;
|
||||
}
|
||||
25
lib/packages/fabro-api-client/src/models/environment-list-meta.ts
generated
Normal file
25
lib/packages/fabro-api-client/src/models/environment-list-meta.ts
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Metadata for environment list responses.
|
||||
*/
|
||||
export interface EnvironmentListMeta {
|
||||
/**
|
||||
* Total number of server-managed environment definitions.
|
||||
*/
|
||||
'total': number;
|
||||
}
|
||||
29
lib/packages/fabro-api-client/src/models/environment-list-response.ts
generated
Normal file
29
lib/packages/fabro-api-client/src/models/environment-list-response.ts
generated
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { Environment } from './environment';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentListMeta } from './environment-list-meta';
|
||||
|
||||
/**
|
||||
* List envelope for environment definitions.
|
||||
*/
|
||||
export interface EnvironmentListResponse {
|
||||
'data': Array<Environment>;
|
||||
'meta': EnvironmentListMeta;
|
||||
}
|
||||
52
lib/packages/fabro-api-client/src/models/environment.ts
generated
Normal file
52
lib/packages/fabro-api-client/src/models/environment.ts
generated
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentNetworkSettings } from './environment-network-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentProvider } from './environment-provider';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
|
||||
|
||||
/**
|
||||
* Public server-managed environment definition.
|
||||
*/
|
||||
export interface Environment {
|
||||
'id': string;
|
||||
/**
|
||||
* Stable revision used with `If-Match` for optimistic concurrency.
|
||||
*/
|
||||
'revision': string;
|
||||
'provider': EnvironmentProvider;
|
||||
'image': EnvironmentApiImageSettings;
|
||||
'resources': EnvironmentResourcesSettings;
|
||||
'network': EnvironmentNetworkSettings;
|
||||
'lifecycle': EnvironmentLifecycleSettings;
|
||||
'labels': { [key: string]: string; };
|
||||
'volumes': Array<EnvironmentVolumeSettings>;
|
||||
'env': { [key: string]: string; };
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ export * from './completion-usage';
|
|||
export * from './conclusion';
|
||||
export * from './create-automation-request';
|
||||
export * from './create-completion-request';
|
||||
export * from './create-environment-request';
|
||||
export * from './create-run-pull-request-request';
|
||||
export * from './create-run-session-request';
|
||||
export * from './create-secret-request';
|
||||
|
|
@ -92,8 +93,13 @@ export * from './disk-usage-summary-row';
|
|||
export * from './dockerfile-source';
|
||||
export * from './dockerfile-source-inline';
|
||||
export * from './dockerfile-source-path';
|
||||
export * from './environment';
|
||||
export * from './environment-api-dockerfile-source-inline';
|
||||
export * from './environment-api-image-settings';
|
||||
export * from './environment-image-settings';
|
||||
export * from './environment-lifecycle-settings';
|
||||
export * from './environment-list-meta';
|
||||
export * from './environment-list-response';
|
||||
export * from './environment-network-mode';
|
||||
export * from './environment-network-settings';
|
||||
export * from './environment-provider';
|
||||
|
|
@ -273,6 +279,7 @@ export * from './render-workflow-graph-direction';
|
|||
export * from './render-workflow-graph-format';
|
||||
export * from './render-workflow-graph-request';
|
||||
export * from './replace-automation-request';
|
||||
export * from './replace-environment-request';
|
||||
export * from './repo-check-response';
|
||||
export * from './repo-check-response-permissions';
|
||||
export * from './repository-ref';
|
||||
|
|
|
|||
47
lib/packages/fabro-api-client/src/models/replace-environment-request.ts
generated
Normal file
47
lib/packages/fabro-api-client/src/models/replace-environment-request.ts
generated
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentNetworkSettings } from './environment-network-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentProvider } from './environment-provider';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
|
||||
|
||||
/**
|
||||
* Request body for replacing a server-managed environment. The path id is authoritative.
|
||||
*/
|
||||
export interface ReplaceEnvironmentRequest {
|
||||
'provider': EnvironmentProvider;
|
||||
'image': EnvironmentApiImageSettings;
|
||||
'resources': EnvironmentResourcesSettings;
|
||||
'network': EnvironmentNetworkSettings;
|
||||
'lifecycle': EnvironmentLifecycleSettings;
|
||||
'labels': { [key: string]: string; };
|
||||
'volumes': Array<EnvironmentVolumeSettings>;
|
||||
'env': { [key: string]: string; };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue