mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
refactor: remove devcontainer support (#433)
## Summary Remove devcontainer support from the product surface and codebase: the parser crate, workflow bridge, lifecycle execution path, typed events, CLI progress rendering, generated client field, and public/internal documentation references are all gone. ## What Changed - Deleted the dedicated parser crate and removed its Cargo dependencies and lockfile entries. - Removed workflow initialization paths that resolved repository devcontainer metadata, applied Daytona snapshots from it, merged environment variables from it, or ran its lifecycle commands. - Removed the typed event variants and CLI progress handlers for the retired lifecycle events while leaving shared unknown-event handling intact. - Cleaned the generated TypeScript client and tracked docs so repository search has no remaining devcontainer references outside git history. ## Verification - `cargo +nightly-2026-04-14 fmt --all` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo build --workspace` - `cargo nextest run -p fabro-types` - `cargo nextest run -p fabro-workflow` - `cargo nextest run -p fabro-cli run_progress` - `cd lib/packages/fabro-api-client && bun run generate && bun run typecheck` - `cargo metadata --no-deps --format-version 1 | rg -i "fabro-devcontainer|devcontainer"` - `rg -n -i "devcontainer|dev container|dev-container|dev_container|fabro-devcontainer|\\.devcontainer" . --glob '!target/**' --glob '!.worktrees/**'` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
This commit is contained in:
parent
8df6fff947
commit
352b7c5de4
91 changed files with 45 additions and 6627 deletions
|
|
@ -29,7 +29,6 @@ Which source files affect which doc pages. Use this as guidance — also apply j
|
|||
| `lib/crates/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` |
|
||||
| `lib/crates/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` |
|
||||
| `lib/crates/fabro-llm/src/catalog.rs`, `lib/crates/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` |
|
||||
| `lib/crates/fabro-devcontainer/src/*.rs` | `docs/public/execution/devcontainers.mdx` |
|
||||
| `lib/crates/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` |
|
||||
| `lib/crates/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` |
|
||||
| `lib/crates/fabro-api/src/*.rs` | `docs/public/api-reference/overview.mdx`, `docs/public/api-reference/demo-mode.mdx` |
|
||||
|
|
|
|||
|
|
@ -15,10 +15,6 @@ leak-timeout = "500ms"
|
|||
filter = "package(fabro-server) & test(all_spec_routes_are_routable)"
|
||||
slow-timeout = { period = "15s", terminate-after = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(fabro-devcontainer) & test(resolve_features_integration)"
|
||||
slow-timeout = { period = "10s", terminate-after = 3 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(fabro-workflow)"
|
||||
slow-timeout = { period = "2s", terminate-after = 3 }
|
||||
|
|
|
|||
|
|
@ -118,7 +118,6 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
- **fabro-github** — GitHub App auth (JWT signing, installation tokens, PR creation)
|
||||
- **fabro-mcp** — Model Context Protocol client/server
|
||||
- **fabro-slack** — Slack integration (socket mode, blocks API)
|
||||
- **fabro-devcontainer** — Parses `.devcontainer/devcontainer.json` for container setup
|
||||
- **fabro-checkpoint** — Git-based checkpoint storage with branch store and metadata branches
|
||||
- **fabro-telemetry** — CLI analytics (Segment) and crash reporting (Sentry), with anonymous IDs, command sanitization, and detached subprocess delivery
|
||||
- **fabro-util** — Shared utilities (redaction, terminal formatting)
|
||||
|
|
|
|||
19
Cargo.lock
generated
19
Cargo.lock
generated
|
|
@ -1827,7 +1827,6 @@ dependencies = [
|
|||
"fabro-checkpoint",
|
||||
"fabro-client",
|
||||
"fabro-config",
|
||||
"fabro-devcontainer",
|
||||
"fabro-dump",
|
||||
"fabro-github",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -1992,23 +1991,6 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-devcontainer"
|
||||
version = "0.246.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-static",
|
||||
"fabro-util",
|
||||
"insta",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.246.0-nightly.0"
|
||||
|
|
@ -2693,7 +2675,6 @@ dependencies = [
|
|||
"fabro-checkpoint",
|
||||
"fabro-config",
|
||||
"fabro-core",
|
||||
"fabro-devcontainer",
|
||||
"fabro-dump",
|
||||
"fabro-github",
|
||||
"fabro-graphviz",
|
||||
|
|
|
|||
|
|
@ -2229,138 +2229,6 @@ These legacy events may appear in older run logs. Current CLI backend runs do no
|
|||
|----------|------|-------------|
|
||||
| `error` | string | Error message |
|
||||
|
||||
---
|
||||
|
||||
## Devcontainer events
|
||||
|
||||
### `devcontainer.resolved`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "ts": "...", "run_id": "...",
|
||||
"event": "devcontainer.resolved",
|
||||
"properties": {
|
||||
"dockerfile_lines": 15,
|
||||
"environment_count": 3,
|
||||
"lifecycle_command_count": 2,
|
||||
"workspace_folder": "/workspace"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `dockerfile_lines` | number | Lines in generated Dockerfile |
|
||||
| `environment_count` | number | Environment variables defined |
|
||||
| `lifecycle_command_count` | number | Lifecycle commands to run |
|
||||
| `workspace_folder` | string | Workspace folder path |
|
||||
|
||||
### `devcontainer.lifecycle.started`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "ts": "...", "run_id": "...",
|
||||
"event": "devcontainer.lifecycle.started",
|
||||
"properties": {
|
||||
"phase": "postCreateCommand",
|
||||
"command_count": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `phase` | string | Lifecycle phase name |
|
||||
| `command_count` | number | Commands in this phase |
|
||||
|
||||
### `devcontainer.lifecycle.command.started`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "ts": "...", "run_id": "...",
|
||||
"event": "devcontainer.lifecycle.command.started",
|
||||
"properties": {
|
||||
"phase": "postCreateCommand",
|
||||
"command": "npm install",
|
||||
"index": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `phase` | string | Lifecycle phase name |
|
||||
| `command` | string | Command being run |
|
||||
| `index` | number | Command index |
|
||||
|
||||
### `devcontainer.lifecycle.command.completed`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "ts": "...", "run_id": "...",
|
||||
"event": "devcontainer.lifecycle.command.completed",
|
||||
"properties": {
|
||||
"phase": "postCreateCommand",
|
||||
"command": "npm install",
|
||||
"index": 0,
|
||||
"exit_code": 0,
|
||||
"duration_ms": 8000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `phase` | string | Lifecycle phase name |
|
||||
| `command` | string | Command that ran |
|
||||
| `index` | number | Command index |
|
||||
| `exit_code` | number | Process exit code |
|
||||
| `duration_ms` | number | Command duration |
|
||||
|
||||
### `devcontainer.lifecycle.completed`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "ts": "...", "run_id": "...",
|
||||
"event": "devcontainer.lifecycle.completed",
|
||||
"properties": {
|
||||
"phase": "postCreateCommand",
|
||||
"duration_ms": 12000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `phase` | string | Lifecycle phase name |
|
||||
| `duration_ms` | number | Phase duration |
|
||||
|
||||
### `devcontainer.lifecycle.failed`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "ts": "...", "run_id": "...",
|
||||
"event": "devcontainer.lifecycle.failed",
|
||||
"properties": {
|
||||
"phase": "postCreateCommand",
|
||||
"command": "npm install",
|
||||
"index": 0,
|
||||
"exit_code": 1,
|
||||
"stderr": "npm ERR! ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `phase` | string | Lifecycle phase name |
|
||||
| `command` | string | Command that failed |
|
||||
| `index` | number | Command index |
|
||||
| `exit_code` | number | Process exit code |
|
||||
| `stderr` | string | Standard error output |
|
||||
|
||||
---
|
||||
|
||||
## Asset events
|
||||
|
||||
### `asset.captured`
|
||||
|
|
@ -2435,4 +2303,4 @@ Emitted when the stall watchdog detects no progress.
|
|||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `idle_seconds` | number | Seconds since last activity |
|
||||
| `idle_seconds` | number | Seconds since last activity |
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ V2 should keep the current hand-coded domain split for prop structs:
|
|||
- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/run.rs)
|
||||
- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/stage.rs)
|
||||
- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/agent.rs)
|
||||
- infra/setup/devcontainer props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/infra.rs)
|
||||
- infra/setup props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/infra.rs)
|
||||
- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/misc.rs)
|
||||
|
||||
That split is part of the design quality. V2 should keep adding hand-coded prop structs, not collapse everything into generic maps.
|
||||
|
|
@ -404,7 +404,6 @@ V2 keeps the current durable family surface broadly intact.
|
|||
- `cli.ensure.*` (legacy only)
|
||||
- `command.*`
|
||||
- `agent.cli.*`
|
||||
- `devcontainer.*`
|
||||
- `pull_request.*`
|
||||
- `artifact.captured`
|
||||
- `ssh.ready`
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ There is no startup-time secret generation. A temporary startup migration moves
|
|||
|
||||
- Worker and render-graph subprocesses start from `env_clear()` and re-add only explicit allowlisted variables.
|
||||
- Authority-bearing values are re-injected intentionally. For worker subprocesses this is `FABRO_WORKER_TOKEN`, plus any explicitly required internal value such as a vault-derived `GITHUB_APP_PRIVATE_KEY`; it is not user auth state such as `FABRO_DEV_TOKEN` or `auth.json`.
|
||||
- The worker reads `FABRO_WORKER_TOKEN` from its env at startup (in `main()` before Tokio initializes) and immediately calls `std::env::remove_var` to scrub it. The token then flows through function arguments to `runner::execute`. Every descendant process (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore inherits a worker env that no longer contains the bearer, so an unscrubbed spawn site cannot leak it.
|
||||
- The worker reads `FABRO_WORKER_TOKEN` from its env at startup (in `main()` before Tokio initializes) and immediately calls `std::env::remove_var` to scrub it. The token then flows through function arguments to `runner::execute`. Every descendant process (hooks, sandbox commands, MCP stdio, etc.) therefore inherits a worker env that no longer contains the bearer, so an unscrubbed spawn site cannot leak it.
|
||||
- The daemon child inherits the parent env unchanged except for output-format hygiene (`FABRO_JSON` removal).
|
||||
|
||||
## Tests
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ The artifact-upload-token mechanism (`server.rs:752`, `server.rs:846`) already p
|
|||
- Out: lifecycle/admin/user-action routes (`/runs/{id}/cancel`, `/pause`, `/unpause`, `/archive`, `/unarchive`, `DELETE /runs/{id}`, `submit_answer`, `start_run`, `create_run`, list endpoints). Worker token explicitly rejected on these — they remain user-JWT-only.
|
||||
- Out: any `Display` impl on `Credential::Worker` payload. Plan keeps redacted `Debug` only; do not add `Display`.
|
||||
- Out: distinct exit codes for auth failure. Worker bails clearly at startup if env is missing; server 401/403 mid-run flows through normal client error handling.
|
||||
- Out: blanket env scrubbing of trusted internal subprocesses (`gh auth token`, MCP servers, devcontainer setup, git). These may legitimately need credentials. Scrubbing scope: workflow/sandbox stage-execution chokepoint (`LocalSandbox::execute`) AND host-mode hooks (defense-in-depth; shell commands have no business reading the worker token).
|
||||
- Out: blanket env scrubbing of trusted internal subprocesses (`gh auth token`, MCP servers, git). These may legitimately need credentials. Scrubbing scope: workflow/sandbox stage-execution chokepoint (`LocalSandbox::execute`) AND host-mode hooks (defense-in-depth; shell commands have no business reading the worker token).
|
||||
|
||||
## Threat Model
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ These are the **only** routes that gain worker-token acceptance. Lifecycle/admin
|
|||
| New worker-only client constructor `connect_server_target_with_bearer(target, token)`, bypasses `AuthStore`/`OAuthSession` entirely | Worker should never read user OAuth. Surgical to fix at the worker callsite (one caller, `runner.rs:67`) rather than gating `resolve_target_credential` with a "are you a worker" flag. |
|
||||
| Stamp `system:worker` actor in a worker-side sink wrapper inside `RunEventSink::fanout`, NOT in `to_run_event_at` | `to_run_event_at` and `stored_event_fields` are shared with the server (server flushes lifecycle events through `workflow_event::to_run_event` at `server.rs:6702`). Default-filling there mis-stamps server-emitted events. The wrapper is worker-local. |
|
||||
| Route API is a set of typed `FromRequestParts` extractors (`AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact`), NOT a bare helper function | Composes with existing `Json<...>` / `Bytes` body extractors (a `Parts`-taking helper would force full-`Request` extraction everywhere and break body handling). Each extractor returns the already-parsed run-id (and secondary path params) so handlers drop their own `Path<String>` + `parse_*` dance. Replaces `_auth: AuthenticatedService` and the existing `authorize_artifact_upload` inline call. One fall-through behavior (worker token first, else user JWT) shared across all three. `authorize_worker_token` remains a `pub(crate)` internal helper used by the extractors. |
|
||||
| Env scrubbing at two sites: `LocalSandbox::execute` (stage execution) and host-mode hooks | Stage commands run user-supplied code → MUST NOT see `FABRO_WORKER_TOKEN`. `LocalSandbox::execute` filters both inherited env AND the explicit `env_vars` extras path (today's code appends extras AFTER the filter — defense-in-depth gap this plan closes). Host-mode hooks get a targeted `env_remove("FABRO_WORKER_TOKEN")` (shell commands have no business reading the worker token, even when operator-configured). Trusted internal subprocesses (`gh auth token`, MCP server stdio, devcontainer setup, git) are NOT scrubbed — they may legitimately need credentials, and they aren't user-attack surfaces. |
|
||||
| Env scrubbing at two sites: `LocalSandbox::execute` (stage execution) and host-mode hooks | Stage commands run user-supplied code → MUST NOT see `FABRO_WORKER_TOKEN`. `LocalSandbox::execute` filters both inherited env AND the explicit `env_vars` extras path (today's code appends extras AFTER the filter — defense-in-depth gap this plan closes). Host-mode hooks get a targeted `env_remove("FABRO_WORKER_TOKEN")` (shell commands have no business reading the worker token, even when operator-configured). Trusted internal subprocesses (`gh auth token`, MCP server stdio, git) are NOT scrubbed — they may legitimately need credentials, and they aren't user-attack surfaces. |
|
||||
| `authorize_worker_token` lives in `worker_token.rs` and takes `&WorkerTokenKeys` directly (NOT `&AppState`) | Sibling modules can't access private `AppState` fields. Mirroring `maybe_authorize_artifact_upload_token`'s signature (which already takes the typed keys) keeps the helper testable without a fixture `AppState`. The thin `authorize_run_scoped(parts, state, run_id)` adapter lives where it can see `AppState` and pulls `&state.worker_tokens` into the call. |
|
||||
| Missing/invalid `FABRO_WORKER_TOKEN` → worker errors at startup with a clear message; mid-run 401/403 flow through normal client error handling | No special exit codes. Distinct operational telemetry isn't worth the machinery for the current scale. |
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ These are the **only** routes that gain worker-token acceptance. Lifecycle/admin
|
|||
- New `RunAuthMethod::Worker` variant: no — worker token bypasses `AuthenticatedSubject` entirely.
|
||||
- Stamp worker events server-side vs. worker-side: worker-side, in a dedicated sink wrapper inside the worker's `RunEventSink::fanout` chain.
|
||||
- Multi-token-per-run on rapid pause/resume: accept and document. Each prior token remains valid up to 72h `exp`. Bounded by run-id; out-of-scope to fix here.
|
||||
- Env scrubbing scope: workflow stage-execution chokepoint at `LocalSandbox::execute` (inherited env + explicit `env_vars` extras) AND host-mode hooks at `fabro-hooks/src/executor.rs`. Trusted internal subprocesses (`gh auth token`, MCP stdio, devcontainer features, git) are not scrubbed.
|
||||
- Env scrubbing scope: workflow stage-execution chokepoint at `LocalSandbox::execute` (inherited env + explicit `env_vars` extras) AND host-mode hooks at `fabro-hooks/src/executor.rs`. Trusted internal subprocesses (`gh auth token`, MCP stdio, git) are not scrubbed.
|
||||
- Auth-failure exit codes: no — generic error handling.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
|
@ -407,7 +407,7 @@ The spawn site that runs user-supplied workflow stage commands must NOT see `FAB
|
|||
|
||||
- Modify: `lib/crates/fabro-hooks/src/executor.rs:186` — `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names listed above) on host-mode hook spawns. Targeted, defense-in-depth. Hooks remain operator-trusted; this just keeps the worker token out of their env.
|
||||
|
||||
**Out of scope for env scrubbing:** trusted internal subprocesses that run server-controlled code and may legitimately need credentials in their env: `gh auth token` (`fabro-github/src/lib.rs:129`), MCP server stdio (`fabro-mcp/src/client.rs:47`), devcontainer features (`fabro-devcontainer/src/features.rs:67-199`), git (`fabro-workflow/src/git.rs:35`). These are not user-attack surfaces. Do NOT scrub them.
|
||||
**Out of scope for env scrubbing:** trusted internal subprocesses that run server-controlled code and may legitimately need credentials in their env: `gh auth token` (`fabro-github/src/lib.rs:129`), MCP server stdio (`fabro-mcp/src/client.rs:47`), git (`fabro-workflow/src/git.rs:35`). These are not user-attack surfaces. Do NOT scrub them.
|
||||
|
||||
**Approach:**
|
||||
- `connect_server_target_with_bearer` is the smallest possible surface: it skips the `AuthStore`/`OAuthSession` machinery entirely. The user-facing `connect_server_target` and `connect_server_with_settings` are unchanged.
|
||||
|
|
@ -535,7 +535,7 @@ The spawn site that runs user-supplied workflow stage commands must NOT see `FAB
|
|||
| `FABRO_WORKER_TOKEN` readable via `/proc/<pid>/environ` to same-UID processes on Linux | Documented in Threat Model: env-var transport does not protect against same-UID reads. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers. NOT a property of this design. |
|
||||
| Workflow stage child processes (sandbox-executed Bash) inherit `FABRO_WORKER_TOKEN` via env or via explicitly-supplied `env_vars` extras | `LocalSandbox::execute` filters BOTH the inherited env (existing safelist + denylist) AND the `env_vars` extras path (new in Unit 4). Two regression tests prove both paths. |
|
||||
| Host-mode hook commands inherit `FABRO_WORKER_TOKEN` | `fabro-hooks/src/executor.rs` does targeted `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names) on host-mode hook spawns. Hooks remain operator-trusted; this is defense-in-depth — shell commands have no business reading the worker token. |
|
||||
| Trusted internal subprocesses (`gh`, MCP, devcontainer features, git) inherit env including `FABRO_WORKER_TOKEN` | NOT scrubbed by design — these run server-controlled code, may legitimately need credentials, and are not user-attack surfaces. Documented in Unit 4. |
|
||||
| Trusted internal subprocesses (`gh`, MCP, git) inherit env including `FABRO_WORKER_TOKEN` | NOT scrubbed by design — these run server-controlled code, may legitimately need credentials, and are not user-attack surfaces. Documented in Unit 4. |
|
||||
| `client.upload_stage_artifact_*` API requires a per-call bearer parameter | Per Unit 4: `HttpArtifactUploader` holds the token in a `worker_token: String` field (same string read from `FABRO_WORKER_TOKEN`) and threads it per call. No `Client::credential()` accessor today; per-call threading is the path of least churn. |
|
||||
| Same-run concurrent worker spawn (scheduler race) → two valid tokens for one `run_id` racing on event/state appends | Scheduler's at-most-one-worker-per-run guarantee is assumed but not verified by this plan. If a race exists today, follow-up plan adds a server-side spawn lock or a per-spawn nonce. Out of scope here. |
|
||||
| Rapid pause/resume cycles leave multiple valid tokens per run | Each prior worker token remains valid up to its 72h `exp`. Multiplicative compromise window bounded by run-id. Accepted; out of scope to fix here. |
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ These calls live in `lib/crates/fabro-workflow/src/pipeline/initialize.rs:77-204
|
|||
- **Docker socket permission management.** No GID shim, `group_add` automation, or Docker Desktop-specific setup logic in Fabro.
|
||||
- **Non-GitHub clone origins.** GitLab, Bitbucket, arbitrary SSH/HTTPS remotes, and generic credentials are follow-up work. With `skip_clone = false`, present non-GitHub origins fail clearly. With `skip_clone = true`, the provider creates an empty workspace as an escape hatch, but repository files are not present.
|
||||
- **Exact-SHA execution.** Branch-based clone behavior matches Daytona's current model. Optional submitted-SHA pinning is a follow-up.
|
||||
- **Devcontainer integration with Docker provider.** Today's devcontainer code resolves config against the host filesystem before sandbox init; the clone-only model breaks that. Devcontainer-mode runs require a follow-up plan that resolves devcontainer config against the cloned `/workspace`.
|
||||
- **Repository-derived setup with Docker provider.** Host-resolved setup metadata would break under the clone-only model. Any repository-derived setup must resolve against the cloned `/workspace`.
|
||||
- **Auto-fallback to local when Docker is unreachable.** If `connect_with_local_defaults()` fails, the run fails with the Docker connection error.
|
||||
- **Named-volume copy-from-host as an alternative to clone.** Evaluated and rejected: it reintroduces host-CLI / server-Docker coupling.
|
||||
- **Real DinD nesting.** Socket-mounted sibling containers through the host daemon are sufficient for self-hosting.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
- Update `RunServices::new(...)` and add a doc comment: production construction is expected to happen from pipeline initialization with the run's root token; use `with_cancel_token(...)` only with the same root token or a `child_token()` derived from it.
|
||||
- Make `with_cancel_token(token: CancellationToken)` `pub(crate)`. It must document that the token semantically means "cancel this run or child run," not a generic shutdown signal.
|
||||
- Update `lib/crates/fabro-workflow/src/pipeline/execute.rs` to pass `run_options.cancel_token.clone()` into `ExecutorBuilder::cancel_token(...)`.
|
||||
- Update setup/devcontainer paths in `lib/crates/fabro-workflow/src/pipeline/initialize.rs` and `lib/crates/fabro-workflow/src/devcontainer_bridge.rs` to pass `Some(run_options.cancel_token.child_token())` into sandbox commands instead of creating a new bridge from an atomic.
|
||||
- Update setup paths in `lib/crates/fabro-workflow/src/pipeline/initialize.rs` to pass `Some(run_options.cancel_token.child_token())` into sandbox commands instead of creating a new bridge from an atomic.
|
||||
- Update `lib/crates/fabro-workflow/src/handler/command.rs` to pass `Some(services.run.cancel_token().child_token())` into `exec_command_streaming` instead of calling `services.run.sandbox_cancel_token()`.
|
||||
- Do not wire stall timeout into the run cancel token. If `lib/crates/fabro-core/src/stall.rs` is migrated away from `Arc<AtomicBool>`, give it a field named `stall_token: CancellationToken` and call `stall_token.cancel()` on timeout. The executor must continue racing node execution against `ExecutorOptions.stall_token` and returning `Error::StallTimeout { node_id }` from that select branch.
|
||||
- Update CLI and server run entry points (`lib/crates/fabro-cli/src/commands/run/runner.rs`, `lib/crates/fabro-server/src/server.rs`, `lib/crates/fabro-workflow/src/operations/start.rs`) to create/store/cancel `CancellationToken` directly. `StartServices.cancel_token` and `RunSession.cancel_token` must become non-optional `CancellationToken` fields; managed server run state and CLI worker-control/signal handlers must use `CancellationToken`; places that currently call `load(Ordering::SeqCst)` must use `token.is_cancelled()`.
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ visits. Parallel work is summed, so run active time can exceed run wall time.
|
|||
not `runtime_secs`.
|
||||
- Keep `duration_ms` only for unrelated subsystem-specific operational events
|
||||
where the name is still local and unambiguous, such as sandbox setup,
|
||||
metadata snapshot, devcontainer lifecycle, and hook execution. The cleanup
|
||||
metadata snapshot, setup commands, and hook execution. The cleanup
|
||||
target is public run/stage runtime semantics.
|
||||
- Update OpenAPI and regenerate the Rust and TypeScript API clients after
|
||||
schema edits.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ Remove the `session_sandboxes` feature flag and the now-empty `[features]` setti
|
|||
- Regenerate Rust API types and TypeScript Axios client.
|
||||
- Update current docs:
|
||||
- Remove `[features]` from active configuration docs, generated options docs, API docs, and unknown-key guidance.
|
||||
- Do not touch unrelated meanings of "features" such as Cargo features, LLM model features, or devcontainer features.
|
||||
- Do not touch unrelated meanings of "features" such as Cargo features or LLM model features.
|
||||
|
||||
## Test Plan
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ date: "2026-02-26"
|
|||
|
||||
## Daytona cloud sandboxes
|
||||
|
||||
Workflows can now execute in Daytona cloud environments — full dev containers with SSH access, persistent storage, and network isolation. Previously, Docker was the only sandbox option, which meant running everything locally. Daytona moves execution to the cloud, freeing up your machine and providing a more production-like environment.
|
||||
Workflows can now execute in Daytona cloud environments with SSH access, persistent storage, and network isolation. Previously, Docker was the only sandbox option, which meant running everything locally. Daytona moves execution to the cloud, freeing up your machine and providing a more production-like environment.
|
||||
|
||||
```bash
|
||||
fabro run start --execution-env daytona my-workflow.fabro
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
---
|
||||
title: "Devcontainer support and sessions"
|
||||
title: "Sessions and workflow improvements"
|
||||
date: "2026-03-02"
|
||||
---
|
||||
|
||||
## Devcontainer support
|
||||
|
||||
Sandbox environments can now be defined using standard `devcontainer.json` files. Fabro parses and resolves the full devcontainer spec — features, lifecycle hooks (`onCreateCommand`, `postStartCommand`), build args, `containerEnv`, feature dependencies, and Compose-based configurations.
|
||||
|
||||
If your project already has a `.devcontainer/devcontainer.json`, Fabro can use it directly instead of requiring a separate sandbox configuration.
|
||||
|
||||
## Sessions
|
||||
|
||||
Persistent chat sessions with SQLite storage. Start a conversation with an agent, close the terminal, and pick up where you left off. Sessions track messages, model, and conversation state.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: "One-line installer, MCP servers, devcontainers, and new CLI commands"
|
||||
title: "One-line installer, MCP servers, and new CLI commands"
|
||||
date: "2026-03-10"
|
||||
---
|
||||
|
||||
|
|
@ -25,15 +25,6 @@ command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"]
|
|||
port = 3100
|
||||
```
|
||||
|
||||
## Devcontainer support in sandboxes
|
||||
|
||||
Workflows can now use your project's `devcontainer.json` to configure sandbox environments. When `devcontainer = true` is set in the sandbox config, Fabro resolves the devcontainer from the repo, uses its Dockerfile for the Daytona snapshot, runs lifecycle hooks (`onCreateCommand`, `postCreateCommand`, `postStartCommand`), and merges devcontainer environment variables into the sandbox. Unsupported `COPY`/`ADD` instructions in base Dockerfiles are detected and reported.
|
||||
|
||||
```toml title="workflow.toml"
|
||||
[sandbox]
|
||||
devcontainer = true
|
||||
```
|
||||
|
||||
<Warning>
|
||||
**Historical note.** This release temporarily standardized on `~/.fabro/.env`, but later releases removed automatic dotenv loading in favor of server-owned secrets plus explicit process environment variables.
|
||||
</Warning>
|
||||
|
|
|
|||
|
|
@ -52,10 +52,6 @@ fabro graph -o flow.svg # SVG to file
|
|||
- Retro step now shows tool call progress while the retro agent works
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="API">
|
||||
- Added `devcontainer` field to `SandboxConfiguration` OpenAPI schema
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Improvements">
|
||||
- Context compaction now produces higher-quality summaries: the summarization prompt is framed as a handoff document, and recent user messages are preserved through compaction so the agent retains the user's actual words
|
||||
- Retro agent limits increased to 20 tool rounds and a 3-minute timeout for complex runs
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ fabro uninstall --force # skip confirmation
|
|||
<Accordion title="Fixes">
|
||||
- Fixed GitHub App setup flow failing on nullable webhook secrets, duplicate POST requests, and incorrect port detection
|
||||
- Fixed session cookie decryption errors on server restart
|
||||
- Fixed devcontainer lifecycle commands not being cancelled during shutdown
|
||||
- Fixed setup commands continuing to run after the server received a shutdown signal
|
||||
- Fixed dark theme not being selected by default for new users
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -13,15 +13,15 @@ To migrate:
|
|||
|
||||
## Tighter server secret boundaries
|
||||
|
||||
Server startup now validates authority-bearing secrets at the server boundary instead of letting every subprocess inherit whatever happened to be in the parent environment. Worker subprocesses receive a scoped worker token when they need one, then scrub it from their process environment before launching hooks, sandbox commands, devcontainer setup, MCP stdio, or other descendants.
|
||||
Server startup now validates authority-bearing secrets at the server boundary instead of letting every subprocess inherit whatever happened to be in the parent environment. Worker subprocesses receive a scoped worker token when they need one, then scrub it from their process environment before launching hooks, sandbox commands, MCP stdio, or other descendants.
|
||||
|
||||
That change reduces the chance of leaking server-level credentials into user-controlled command paths while preserving authenticated run operations. It also makes install-time and startup-time secret handling easier to reason about for self-hosted deployments.
|
||||
|
||||
## Faster workflow finishing and devcontainer setup
|
||||
## Faster workflow finishing
|
||||
|
||||
Several workflow phases now do less serialized work. Retros load the event log once, devcontainer `Command::Parallel` entries actually run concurrently, and final patch creation can overlap with finalize commit work.
|
||||
Several workflow phases now do less serialized work. Retros load the event log once, and final patch creation can overlap with finalize commit work.
|
||||
|
||||
Users should notice this most on longer workflows with large event logs, devcontainer initialization, or expensive final patch generation. The behavior is the same, but the slow tail of a run has fewer avoidable waits.
|
||||
Users should notice this most on longer workflows with large event logs or expensive final patch generation. The behavior is the same, but the slow tail of a run has fewer avoidable waits.
|
||||
|
||||
## More
|
||||
|
||||
|
|
|
|||
|
|
@ -57,8 +57,7 @@
|
|||
"execution/checkpoints",
|
||||
"execution/outcomes",
|
||||
"execution/failures",
|
||||
"execution/observability",
|
||||
"execution/devcontainers"
|
||||
"execution/observability"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
title: "Devcontainers"
|
||||
description: "Using repository devcontainer metadata with Fabro environments"
|
||||
---
|
||||
|
||||
Named environments are the supported configuration surface for run execution. Define reusable environments under `[environments.<slug>]` and select one with `[run.environment] id = "..."`.
|
||||
|
||||
Devcontainer-specific run configuration (`[run.sandbox] devcontainer = true`) has been removed with the named environments configuration break. To use a devcontainer-style image today, build or reference it through an environment image:
|
||||
|
||||
```toml
|
||||
[run.environment]
|
||||
id = "dev"
|
||||
|
||||
[environments.dev]
|
||||
provider = "docker"
|
||||
|
||||
[environments.dev.image]
|
||||
docker = "ghcr.io/acme/project-devcontainer:latest"
|
||||
```
|
||||
|
||||
For Daytona snapshot creation, provide a Dockerfile path on the selected environment:
|
||||
|
||||
```toml
|
||||
[run.environment]
|
||||
id = "cloud-dev"
|
||||
|
||||
[environments.cloud-dev]
|
||||
provider = "daytona"
|
||||
|
||||
[environments.cloud-dev.image]
|
||||
dockerfile = { path = ".devcontainer/Dockerfile" }
|
||||
```
|
||||
|
|
@ -21,13 +21,13 @@
|
|||
- Modify `lib/crates/fabro-workflow/src/event.rs`: carry `exec_output_tail` through internal events and event-body conversion; trace only safe metadata about tails, not tail content.
|
||||
- Modify `lib/crates/fabro-workflow/src/sandbox_metadata.rs`, `lib/crates/fabro-workflow/src/lifecycle/git.rs`, and `lib/crates/fabro-workflow/src/pipeline/finalize.rs`: preserve push/write diagnostic projections without storing `fabro_sandbox::Error` inside `MetadataSnapshot`.
|
||||
- Modify `lib/crates/fabro-workflow/src/pipeline/initialize.rs`: add output-tail diagnostics to setup failures while preserving existing `stderr` field for compatibility.
|
||||
- Modify `lib/crates/fabro-workflow/src/devcontainer_bridge.rs`: add output-tail diagnostics to devcontainer lifecycle failures while preserving existing `stderr` field for compatibility.
|
||||
- Modify `lib/crates/fabro-workflow/src/pipeline/initialize.rs`: add output-tail diagnostics to setup failures while preserving existing `stderr` field for compatibility.
|
||||
- Modify `lib/crates/fabro-workflow/src/handler/llm/cli.rs`: replace CLI install's ad hoc 500-character embedded error detail with `exec_output_tail`.
|
||||
- Modify `docs/internal/logging-strategy.md`: document the policy that event payloads may contain bounded redacted tails, while tracing logs must not contain tail content by default.
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
- Do not remove, deprecate, or stop populating `SetupFailedProps.stderr` or `DevcontainerLifecycleFailedProps.stderr` in this change. Any future removal requires a separate public event-contract deprecation plan.
|
||||
- Do not remove, deprecate, or stop populating `SetupFailedProps.stderr` in this change. Any future removal requires a separate public event-contract deprecation plan.
|
||||
- Do not add stdout/stderr tail content to `server.log`. Tracing should record safe metadata only: whether a tail exists, stream lengths, truncation booleans, and the existing safe error message.
|
||||
- Do not change `HookDecision::Block.reason` to include stdout/stderr. That is user-visible hook semantics and needs a separate design if we want durable hook diagnostics later.
|
||||
- Do not broadly refactor `sandbox_git.rs` error plumbing beyond constructor/signature updates needed by the `Error::Exec` refactor.
|
||||
|
|
@ -84,14 +84,14 @@ Keep `is_false` private to the module. Do not add another full process result ty
|
|||
|
||||
- [x] **Step 2: Add `exec_output_tail` additively to failure props**
|
||||
|
||||
Add this optional field to `MetadataSnapshotFailedProps`, `SetupFailedProps`, `CliEnsureFailedProps`, and `DevcontainerLifecycleFailedProps`:
|
||||
Add this optional field to `MetadataSnapshotFailedProps`, `SetupFailedProps`, and `CliEnsureFailedProps`:
|
||||
|
||||
```rust
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exec_output_tail: Option<ExecOutputTail>,
|
||||
```
|
||||
|
||||
Do not remove existing fields, including `stderr` on setup/devcontainer failure props.
|
||||
Do not remove existing fields, including `stderr` on setup failure props.
|
||||
|
||||
- [x] **Step 3: Re-export the projection**
|
||||
|
||||
|
|
@ -360,9 +360,9 @@ Add `exec_output_tail: Option<fabro_types::ExecOutputTail>` to:
|
|||
- `MetadataSnapshotFailed`
|
||||
- `SetupFailed`
|
||||
- `CliEnsureFailed`
|
||||
- `DevcontainerLifecycleFailed`
|
||||
- `SetupFailed`
|
||||
|
||||
Keep existing `stderr` fields on `SetupFailed` and `DevcontainerLifecycleFailed`.
|
||||
Keep existing `stderr` fields on `SetupFailed`.
|
||||
|
||||
- [x] **Step 2: Map tails into `EventBody`**
|
||||
|
||||
|
|
@ -543,11 +543,11 @@ cargo nextest run -p fabro-workflow metadata_snapshot
|
|||
|
||||
Expected: metadata push/write failure events contain `exec_output_tail` when command output exists.
|
||||
|
||||
## Task 5: Add Tails To Setup, Devcontainer, And CLI Install Failures
|
||||
## Task 5: Add Tails To Setup And CLI Install Failures
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-workflow/src/pipeline/initialize.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/devcontainer_bridge.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/pipeline/initialize.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/handler/llm/cli.rs`
|
||||
|
||||
- [x] **Step 1: Add setup failure tails without removing `stderr`**
|
||||
|
|
@ -567,13 +567,13 @@ options.emitter.emit(&Event::SetupFailed {
|
|||
|
||||
Keep the existing `stderr` value for compatibility in this change.
|
||||
|
||||
- [x] **Step 2: Add devcontainer failure tails without removing `stderr`**
|
||||
- [x] **Step 2: Add setup failure tails without removing `stderr`**
|
||||
|
||||
For both parallel and single-command lifecycle failures, emit:
|
||||
|
||||
```rust
|
||||
let exec_output_tail = result.default_redacted_output_tail();
|
||||
emitter.emit(&Event::DevcontainerLifecycleFailed {
|
||||
emitter.emit(&Event::SetupFailed {
|
||||
phase: phase.clone(),
|
||||
command: name.clone(),
|
||||
index,
|
||||
|
|
@ -611,14 +611,14 @@ Add or update tests so that:
|
|||
|
||||
- setup failure with stderr preserves `props.stderr` and adds `props.exec_output_tail.stderr`.
|
||||
- setup failure with stdout-only output adds `props.exec_output_tail.stdout`.
|
||||
- devcontainer lifecycle failure adds the nested tail while preserving `stderr`.
|
||||
- setup failure adds the nested tail while preserving `stderr`.
|
||||
- CLI ensure failure no longer embeds command output in `error`, but includes `exec_output_tail`.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo nextest run -p fabro-workflow setup
|
||||
cargo nextest run -p fabro-workflow devcontainer
|
||||
cargo nextest run -p fabro-workflow setup
|
||||
cargo nextest run -p fabro-workflow cli
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ Attach-existing must not:
|
|||
- create an empty provider workspace
|
||||
- overwrite persisted sandbox identity
|
||||
|
||||
Setup-command/devcontainer behavior on resume should remain checkpoint-aware: do not rerun provider creation, clone, or devcontainer snapshot creation. Only rerun explicit resume setup commands already defined by the sandbox/provider when needed to reattach to the existing run branch.
|
||||
Setup-command behavior on resume should remain checkpoint-aware: do not rerun provider creation or clone. Only rerun explicit resume setup commands already defined by the sandbox/provider when needed to reattach to the existing run branch.
|
||||
|
||||
## Delete And Preserve API Semantics
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
- Use only `ss -H -ltnp` for v1; do not add `netstat` or `lsof` fallback.
|
||||
- Return all parsed listening TCP ports, including ports outside Daytona's preview range.
|
||||
- Compute preview support outside the sandbox command: `provider == "daytona" && 3000 <= port <= 9999`.
|
||||
- Do not probe HTTP readiness and do not read `devcontainer.json`.
|
||||
- Do not probe HTTP readiness or read repository setup metadata.
|
||||
|
||||
## Files
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
- Place it between **Terminal** and **Filesystem**.
|
||||
- List all services returned by the backend.
|
||||
- Show a **Preview** action only for rows with `preview_supported: true`.
|
||||
- Do not read `devcontainer.json`.
|
||||
- Do not read repository setup metadata.
|
||||
- Do not perform browser-side HTTP checks.
|
||||
- Do not implement polling beyond normal SWR refresh/manual refresh behavior.
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ fabro-model = { path = "../fabro-model" }
|
|||
fabro-oauth = { path = "../fabro-oauth" }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-devcontainer = { path = "../fabro-devcontainer" }
|
||||
fabro-dump = { path = "../fabro-dump" }
|
||||
fabro-hooks = { path = "../fabro-hooks" }
|
||||
fabro-install = { path = "../fabro-install" }
|
||||
|
|
|
|||
|
|
@ -100,32 +100,6 @@ pub(super) enum ProgressEvent {
|
|||
CliEnsureFailed {
|
||||
cli_name: String,
|
||||
},
|
||||
DevcontainerResolved {
|
||||
dockerfile_lines: u64,
|
||||
environment_count: u64,
|
||||
lifecycle_command_count: u64,
|
||||
workspace_folder: String,
|
||||
},
|
||||
DevcontainerLifecycleStarted {
|
||||
phase: String,
|
||||
command_count: u64,
|
||||
},
|
||||
DevcontainerLifecycleCompleted {
|
||||
phase: String,
|
||||
duration_ms: u64,
|
||||
},
|
||||
DevcontainerLifecycleFailed {
|
||||
phase: String,
|
||||
command: String,
|
||||
exit_code: i64,
|
||||
stderr: String,
|
||||
},
|
||||
DevcontainerLifecycleCommandCompleted {
|
||||
command: String,
|
||||
command_index: u64,
|
||||
exit_code: i64,
|
||||
duration_ms: u64,
|
||||
},
|
||||
StageStarted {
|
||||
node_id: String,
|
||||
name: String,
|
||||
|
|
@ -308,40 +282,6 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
|
|||
EventBody::CliEnsureFailed(props) => Some(ProgressEvent::CliEnsureFailed {
|
||||
cli_name: props.cli_name.clone(),
|
||||
}),
|
||||
EventBody::DevcontainerResolved(props) => Some(ProgressEvent::DevcontainerResolved {
|
||||
dockerfile_lines: props.dockerfile_lines as u64,
|
||||
environment_count: props.environment_count as u64,
|
||||
lifecycle_command_count: props.lifecycle_command_count as u64,
|
||||
workspace_folder: props.workspace_folder.clone(),
|
||||
}),
|
||||
EventBody::DevcontainerLifecycleStarted(props) => {
|
||||
Some(ProgressEvent::DevcontainerLifecycleStarted {
|
||||
phase: props.phase.clone(),
|
||||
command_count: props.command_count as u64,
|
||||
})
|
||||
}
|
||||
EventBody::DevcontainerLifecycleCompleted(props) => {
|
||||
Some(ProgressEvent::DevcontainerLifecycleCompleted {
|
||||
phase: props.phase.clone(),
|
||||
duration_ms: props.duration_ms,
|
||||
})
|
||||
}
|
||||
EventBody::DevcontainerLifecycleFailed(props) => {
|
||||
Some(ProgressEvent::DevcontainerLifecycleFailed {
|
||||
phase: props.phase.clone(),
|
||||
command: props.command.clone(),
|
||||
exit_code: i64::from(props.exit_code),
|
||||
stderr: props.stderr.clone(),
|
||||
})
|
||||
}
|
||||
EventBody::DevcontainerLifecycleCommandCompleted(props) => {
|
||||
Some(ProgressEvent::DevcontainerLifecycleCommandCompleted {
|
||||
command: props.command.clone(),
|
||||
command_index: props.index as u64,
|
||||
exit_code: i64::from(props.exit_code),
|
||||
duration_ms: props.duration_ms,
|
||||
})
|
||||
}
|
||||
EventBody::StageStarted(_) => Some(ProgressEvent::StageStarted {
|
||||
node_id,
|
||||
name: node_label,
|
||||
|
|
|
|||
|
|
@ -195,55 +195,6 @@ impl ProgressUI {
|
|||
ProgressEvent::CliEnsureFailed { cli_name } => {
|
||||
self.setup.on_cli_ensure_failed(renderer, &cli_name);
|
||||
}
|
||||
ProgressEvent::DevcontainerResolved {
|
||||
dockerfile_lines,
|
||||
environment_count,
|
||||
lifecycle_command_count,
|
||||
workspace_folder,
|
||||
} => {
|
||||
SetupDisplay::on_devcontainer_resolved(
|
||||
renderer,
|
||||
dockerfile_lines,
|
||||
environment_count,
|
||||
lifecycle_command_count,
|
||||
&workspace_folder,
|
||||
);
|
||||
}
|
||||
ProgressEvent::DevcontainerLifecycleStarted {
|
||||
phase,
|
||||
command_count,
|
||||
} => {
|
||||
self.setup
|
||||
.on_devcontainer_lifecycle_started(renderer, &phase, command_count);
|
||||
}
|
||||
ProgressEvent::DevcontainerLifecycleCompleted { phase, duration_ms } => {
|
||||
self.setup
|
||||
.on_devcontainer_lifecycle_completed(renderer, &phase, duration_ms);
|
||||
}
|
||||
ProgressEvent::DevcontainerLifecycleFailed {
|
||||
phase,
|
||||
command,
|
||||
exit_code,
|
||||
stderr,
|
||||
} => {
|
||||
self.setup.on_devcontainer_lifecycle_failed(
|
||||
renderer, &phase, &command, exit_code, &stderr,
|
||||
);
|
||||
}
|
||||
ProgressEvent::DevcontainerLifecycleCommandCompleted {
|
||||
command,
|
||||
command_index,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
} => {
|
||||
self.setup.on_devcontainer_lifecycle_command_completed(
|
||||
renderer,
|
||||
&command,
|
||||
command_index,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
);
|
||||
}
|
||||
ProgressEvent::StageStarted {
|
||||
node_id,
|
||||
name,
|
||||
|
|
@ -810,21 +761,6 @@ mod tests {
|
|||
duration_ms: 2200,
|
||||
},
|
||||
Event::SetupCompleted { duration_ms: 2200 },
|
||||
Event::DevcontainerLifecycleStarted {
|
||||
phase: "postCreate".into(),
|
||||
command_count: 1,
|
||||
},
|
||||
Event::DevcontainerLifecycleCommandCompleted {
|
||||
phase: "postCreate".into(),
|
||||
command: "npm run setup".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
duration_ms: 1400,
|
||||
},
|
||||
Event::DevcontainerLifecycleCompleted {
|
||||
phase: "postCreate".into(),
|
||||
duration_ms: 1400,
|
||||
},
|
||||
];
|
||||
|
||||
let (mut event_ui, event_buffer) = capture_ui(true);
|
||||
|
|
@ -903,31 +839,12 @@ mod tests {
|
|||
duration_ms: 600,
|
||||
}),
|
||||
);
|
||||
emit(&mut ui, Event::DevcontainerResolved {
|
||||
dockerfile_lines: 24,
|
||||
environment_count: 3,
|
||||
lifecycle_command_count: 2,
|
||||
workspace_folder: "/workspace".into(),
|
||||
});
|
||||
emit(&mut ui, Event::DevcontainerLifecycleStarted {
|
||||
phase: "postCreate".into(),
|
||||
command_count: 2,
|
||||
});
|
||||
emit(&mut ui, Event::DevcontainerLifecycleCompleted {
|
||||
phase: "postCreate".into(),
|
||||
duration_ms: 1800,
|
||||
});
|
||||
|
||||
insta::assert_snapshot!(rendered(&buffer), @r"
|
||||
Sandbox: daytona (ready in 2s)
|
||||
sandbox-1 (4 cpu, 8 GB)
|
||||
ssh daytona@example
|
||||
Setup: 2 commands (8s)
|
||||
CLI: gh (installed, 600ms)
|
||||
Devcontainer: resolved
|
||||
24 Dockerfile lines, 3 env vars, 2 lifecycle cmds, /workspace
|
||||
Running devcontainer postCreate (2 commands)...
|
||||
Devcontainer: postCreate (1s)
|
||||
");
|
||||
}
|
||||
|
||||
|
|
@ -1209,21 +1126,6 @@ mod tests {
|
|||
duration_ms: 2200,
|
||||
});
|
||||
emit(&mut ui, Event::SetupCompleted { duration_ms: 2200 });
|
||||
emit(&mut ui, Event::DevcontainerLifecycleStarted {
|
||||
phase: "postCreate".into(),
|
||||
command_count: 1,
|
||||
});
|
||||
emit(&mut ui, Event::DevcontainerLifecycleCommandCompleted {
|
||||
phase: "postCreate".into(),
|
||||
command: "npm run setup".into(),
|
||||
index: 0,
|
||||
exit_code: 0,
|
||||
duration_ms: 1400,
|
||||
});
|
||||
emit(&mut ui, Event::DevcontainerLifecycleCompleted {
|
||||
phase: "postCreate".into(),
|
||||
duration_ms: 1400,
|
||||
});
|
||||
emit(&mut ui, stage_completed("code", "Code"));
|
||||
|
||||
insta::assert_snapshot!(rendered(&buffer), @r#"
|
||||
|
|
@ -1235,9 +1137,6 @@ mod tests {
|
|||
✓ subagent[a1] (3 turns)
|
||||
✓ [1/1] bun install 2s
|
||||
Setup: 1 command (2s)
|
||||
Running devcontainer postCreate (1 commands)...
|
||||
✓ [1/1] npm run setup 1s
|
||||
Devcontainer: postCreate (1s)
|
||||
✓ Code 5s (1 turns, 0 tools, 1.5k toks)
|
||||
"#);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ pub(super) struct SetupDisplay {
|
|||
pub(super) sandbox_bar: Option<ProgressBar>,
|
||||
pub(super) setup_bar: Option<ProgressBar>,
|
||||
pub(super) setup_command_count: u64,
|
||||
pub(super) devcontainer_bar: Option<ProgressBar>,
|
||||
pub(super) devcontainer_command_count: u64,
|
||||
pub(super) cli_ensure_bar: Option<ProgressBar>,
|
||||
}
|
||||
|
||||
|
|
@ -25,8 +23,6 @@ impl SetupDisplay {
|
|||
sandbox_bar: None,
|
||||
setup_bar: None,
|
||||
setup_command_count: 0,
|
||||
devcontainer_bar: None,
|
||||
devcontainer_command_count: 0,
|
||||
cli_ensure_bar: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -38,9 +34,6 @@ impl SetupDisplay {
|
|||
if let Some(bar) = self.setup_bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
if let Some(bar) = self.devcontainer_bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
if let Some(bar) = self.cli_ensure_bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
|
|
@ -305,143 +298,6 @@ impl SetupDisplay {
|
|||
renderer.print_line(4, &message);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_devcontainer_resolved(
|
||||
renderer: &ProgressRenderer,
|
||||
dockerfile_lines: u64,
|
||||
environment_count: u64,
|
||||
lifecycle_command_count: u64,
|
||||
workspace_folder: &str,
|
||||
) {
|
||||
let detail = format!(
|
||||
"{dockerfile_lines} Dockerfile lines, {environment_count} env vars, \
|
||||
{lifecycle_command_count} lifecycle cmds, {workspace_folder}"
|
||||
);
|
||||
|
||||
if renderer.is_tty() {
|
||||
let bar = renderer.add_spinner();
|
||||
bar.set_style(styles::style_header_done());
|
||||
bar.finish_with_message("Devcontainer: resolved".to_string());
|
||||
let detail_bar = renderer.insert_after(&bar);
|
||||
detail_bar.set_style(styles::style_sandbox_detail());
|
||||
detail_bar.finish_with_message(detail);
|
||||
} else {
|
||||
renderer.print_line(4, "Devcontainer: resolved");
|
||||
renderer.print_line(13, &detail);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_devcontainer_lifecycle_started(
|
||||
&mut self,
|
||||
renderer: &ProgressRenderer,
|
||||
phase: &str,
|
||||
command_count: u64,
|
||||
) {
|
||||
self.devcontainer_command_count = command_count;
|
||||
|
||||
if renderer.is_tty() {
|
||||
let bar = renderer.add_spinner();
|
||||
bar.set_style(styles::style_header_running());
|
||||
bar.set_message(format!(
|
||||
"Running devcontainer {phase} ({command_count} commands)..."
|
||||
));
|
||||
bar.enable_steady_tick(Duration::from_millis(100));
|
||||
self.devcontainer_bar = Some(bar);
|
||||
} else {
|
||||
renderer.print_line(
|
||||
4,
|
||||
&format!("Running devcontainer {phase} ({command_count} commands)..."),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_devcontainer_lifecycle_completed(
|
||||
&mut self,
|
||||
renderer: &ProgressRenderer,
|
||||
phase: &str,
|
||||
duration_ms: u64,
|
||||
) {
|
||||
let dur = format_duration_ms(duration_ms);
|
||||
|
||||
if renderer.is_tty() {
|
||||
if let Some(bar) = self.devcontainer_bar.take() {
|
||||
bar.set_style(styles::style_header_done());
|
||||
bar.set_prefix(dur);
|
||||
bar.finish_with_message(format!("Devcontainer: {phase}"));
|
||||
}
|
||||
} else {
|
||||
renderer.print_line(4, &format!("Devcontainer: {phase} ({dur})"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_devcontainer_lifecycle_failed(
|
||||
&mut self,
|
||||
renderer: &ProgressRenderer,
|
||||
phase: &str,
|
||||
command: &str,
|
||||
exit_code: i64,
|
||||
stderr: &str,
|
||||
) {
|
||||
if let Some(bar) = self.devcontainer_bar.take() {
|
||||
bar.abandon();
|
||||
}
|
||||
|
||||
let summary = if stderr.len() > 120 {
|
||||
&stderr[..120]
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
let message = format!(
|
||||
"{} Devcontainer {phase} command failed (exit {exit_code}): {command}\n {summary}",
|
||||
renderer.styles().red.apply_to("Error:")
|
||||
);
|
||||
|
||||
if renderer.is_tty() {
|
||||
let bar = renderer.add_spinner();
|
||||
bar.set_style(styles::style_static_dim());
|
||||
bar.finish_with_message(message);
|
||||
} else {
|
||||
renderer.print_line(4, &message);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_devcontainer_lifecycle_command_completed(
|
||||
&self,
|
||||
renderer: &ProgressRenderer,
|
||||
command: &str,
|
||||
command_index: u64,
|
||||
exit_code: i64,
|
||||
duration_ms: u64,
|
||||
) {
|
||||
if !self.verbose {
|
||||
return;
|
||||
}
|
||||
|
||||
let glyph = if exit_code == 0 {
|
||||
styles::green_check(renderer.styles())
|
||||
} else {
|
||||
styles::red_cross(renderer.styles())
|
||||
};
|
||||
let msg = format!(
|
||||
"{glyph} [{}/{}] {}",
|
||||
command_index + 1,
|
||||
self.devcontainer_command_count,
|
||||
styles::truncate(command, 60)
|
||||
);
|
||||
let dur = format_duration_ms(duration_ms);
|
||||
|
||||
if renderer.is_tty() {
|
||||
let bar = match &self.devcontainer_bar {
|
||||
Some(devcontainer_bar) => renderer.insert_before(devcontainer_bar),
|
||||
None => renderer.add_spinner(),
|
||||
};
|
||||
bar.set_style(styles::style_tool_done());
|
||||
bar.set_prefix(dur);
|
||||
bar.finish_with_message(msg);
|
||||
} else {
|
||||
renderer.print_line(6, &format!("{msg} {dur}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initializing_message(provider: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ async fn main() {
|
|||
|
||||
// Capture the worker bearer token immediately and scrub it from the process
|
||||
// env before any subprocess can be spawned. Every descendant of the worker
|
||||
// (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore
|
||||
// (hooks, sandbox commands, MCP stdio, etc.) therefore
|
||||
// inherits a process env that no longer contains this credential, so an
|
||||
// unscrubbed spawn site cannot leak it. The token flows to `runner::execute`
|
||||
// through explicit function arguments instead of the environment.
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
[package]
|
||||
name = "fabro-devcontainer"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "Parse and resolve devcontainer.json into Dockerfiles and lifecycle hooks"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
fabro-static.workspace = true
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
fabro-http.workspace = true
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = "0.9"
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { workspace = true }
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tempfile = "3"
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
# Devcontainer Spec Compatibility Matrix
|
||||
|
||||
Compatibility of `fabro-devcontainer` with the [devcontainer.json reference](https://containers.dev/implementors/json_reference/).
|
||||
|
||||
**Legend**: Yes = fully supported, Partial = parsed but incomplete, No = not supported, Planned = intended for future
|
||||
|
||||
## General
|
||||
|
||||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `name` | No | Silently ignored by serde (unknown fields are skipped); not exposed in `DevcontainerConfig` |
|
||||
| `forwardPorts` | Yes | Numeric and string formats (e.g., `"8080:80"`, `"9090"`) extracted into `DevcontainerConfig::forwarded_ports`; merged with compose ports in compose mode |
|
||||
| `portsAttributes` | No | Not parsed |
|
||||
| `otherPortsAttributes` | No | Not parsed |
|
||||
| `updateRemoteUserUID` | No | Not parsed |
|
||||
| `containerEnv` | Yes | Baked into generated Dockerfile as `ENV` directives; also exposed in `DevcontainerConfig::container_env` |
|
||||
| `remoteEnv` | Yes | Merged into `DevcontainerConfig::environment` with variable substitution |
|
||||
| `containerUser` | No | Parsed but unused; not exposed in `DevcontainerConfig` |
|
||||
| `remoteUser` | Yes | Exposed as `DevcontainerConfig::remote_user` |
|
||||
| `userEnvProbe` | No | Not parsed |
|
||||
| `overrideCommand` | No | Parsed but unused |
|
||||
| `shutdownAction` | No | Not parsed |
|
||||
|
||||
## Image
|
||||
|
||||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `image` | Yes | Used as `FROM` line when no Dockerfile is specified; defaults to `mcr.microsoft.com/devcontainers/base:ubuntu` |
|
||||
|
||||
## Build (Dockerfile)
|
||||
|
||||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `build.dockerfile` | Yes | Resolved relative to devcontainer.json; content read and used as base Dockerfile |
|
||||
| `build.context` | Yes | Resolved with variable substitution; passed as `DevcontainerConfig::build_context` |
|
||||
| `build.args` | Yes | Parsed and exposed in `DevcontainerConfig::build_args` for passing to `docker build --build-arg` |
|
||||
| `build.target` | Yes | Parsed with variable substitution; exposed as `DevcontainerConfig::build_target` for passing to `docker build --target` |
|
||||
| `build.cacheFrom` | No | Not parsed |
|
||||
| `build.options` | No | Not parsed |
|
||||
|
||||
## Compose
|
||||
|
||||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `dockerComposeFile` | Yes | Single path and array of paths supported; multiple files are merged (last wins for image/build/user; ports accumulate; environment overrides) |
|
||||
| `service` | Yes | Required when `dockerComposeFile` is set; used to extract service config |
|
||||
| `runServices` | No | Not parsed; all services assumed |
|
||||
| `shutdownAction` | No | Not parsed |
|
||||
| `overrideCommand` | No | Parsed but unused |
|
||||
| `workspaceFolder` | Yes | Defaults to `/workspaces/{repo-name}` |
|
||||
| `workspaceMount` | No | Parsed but unused |
|
||||
|
||||
## Features
|
||||
|
||||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `features` | Yes | Fetched via `oras` CLI (OCI refs), local path copy (`./`/`../`), or HTTPS download. Topologically sorted by `installsAfter` and `dependsOn`. Dockerfile layers generated with options as env vars |
|
||||
| `features` (reference types) | Yes | OCI registry refs (default), local paths (`./feature`), and HTTPS URLs (`https://...feature.tgz`) |
|
||||
| Feature `dependsOn` | Yes | Hard dependencies auto-installed if missing; used for topological ordering alongside `installsAfter` |
|
||||
| Feature `containerEnv` | Yes | Collected from each feature in install order; merged with devcontainer.json `containerEnv` (devcontainer.json wins on conflicts) |
|
||||
| Feature `onCreateCommand` | Yes | Collected in install order and appended after devcontainer.json `onCreateCommand` |
|
||||
| Feature `postCreateCommand` | Yes | Collected in install order and appended after devcontainer.json `postCreateCommand` |
|
||||
| Feature `postStartCommand` | Yes | Collected in install order and appended after devcontainer.json `postStartCommand` |
|
||||
| `overrideFeatureInstallOrder` | No | Not parsed |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `initializeCommand` | Yes | All three forms supported: string, array, object (parallel). Exposed as `DevcontainerConfig::initialize_commands` |
|
||||
| `onCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::on_create_commands` |
|
||||
| `updateContentCommand` | No | Not parsed |
|
||||
| `postCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_create_commands` |
|
||||
| `postStartCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_start_commands` |
|
||||
| `postAttachCommand` | No | Not parsed |
|
||||
| `waitFor` | No | Not parsed |
|
||||
|
||||
## Host
|
||||
|
||||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `hostRequirements` | No | Not parsed |
|
||||
| `init` | No | Not parsed |
|
||||
| `privileged` | No | Not parsed |
|
||||
| `capAdd` | No | Not parsed |
|
||||
| `securityOpt` | No | Not parsed |
|
||||
| `mounts` | No | Not parsed |
|
||||
| `gpuRequest` | No | Not parsed |
|
||||
|
||||
## Customizations
|
||||
|
||||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `customizations` | No | Unknown fields are silently ignored by serde, so `customizations` is accepted but not processed |
|
||||
|
||||
## Variables
|
||||
|
||||
| Variable | Status | Notes |
|
||||
|---|---|---|
|
||||
| `${localWorkspaceFolder}` | Yes | Substituted via `VariableContext` |
|
||||
| `${localWorkspaceFolderBasename}` | Yes | Substituted via `VariableContext` |
|
||||
| `${containerWorkspaceFolder}` | Yes | Substituted via `VariableContext` |
|
||||
| `${containerWorkspaceFolderBasename}` | Yes | Derived from `containerWorkspaceFolder` by splitting on `/` |
|
||||
| `${localEnv:VAR}` | Yes | Reads from host environment; supports `:default` syntax |
|
||||
| `${containerEnv:VAR}` | No | Not implemented (requires running container) |
|
||||
| `${devcontainerId}` | No | Not implemented |
|
||||
|
||||
## JSONC Support
|
||||
|
||||
The parser supports JSONC (JSON with Comments):
|
||||
- Line comments (`//`)
|
||||
- Block comments (`/* */`)
|
||||
- Trailing commas before `}` and `]`
|
||||
|
||||
## File Discovery
|
||||
|
||||
Searched in order:
|
||||
1. `<path>/.devcontainer/devcontainer.json`
|
||||
2. `<path>/.devcontainer.json`
|
||||
3. Direct path if it ends in `devcontainer.json`
|
||||
4. Subdirectory format: `<path>/.devcontainer/<subdir>/devcontainer.json` — subdirectories sorted alphabetically, first match used
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
# Integrating DevcontainerConfig with DaytonaSandbox
|
||||
|
||||
How to wire the parsed `DevcontainerConfig` into sandbox creation.
|
||||
|
||||
## Overview
|
||||
|
||||
`DevcontainerResolver::resolve(repo_path)` reads a repository's devcontainer.json and produces a `DevcontainerConfig` containing everything needed to build and configure a sandbox:
|
||||
|
||||
```rust
|
||||
pub struct DevcontainerConfig {
|
||||
pub dockerfile: String, // Generated Dockerfile content
|
||||
pub build_context: PathBuf, // Directory for docker build
|
||||
pub build_args: HashMap<String, String>, // docker build --build-arg flags
|
||||
pub build_target: Option<String>, // docker build --target
|
||||
pub initialize_commands: Vec<Command>, // Host-side pre-build commands
|
||||
pub on_create_commands: Vec<Command>, // Container after first creation
|
||||
pub post_create_commands: Vec<Command>, // Container post-creation setup
|
||||
pub post_start_commands: Vec<Command>, // Container on-each-start commands
|
||||
pub environment: HashMap<String, String>, // remoteEnv merged
|
||||
pub container_env: HashMap<String, String>, // containerEnv (also in Dockerfile)
|
||||
pub remote_user: Option<String>, // Non-root user
|
||||
pub workspace_folder: String, // Working directory inside container
|
||||
pub forwarded_ports: Vec<u16>, // Ports to expose
|
||||
pub compose_files: Vec<PathBuf>, // Compose file paths (empty if not compose mode)
|
||||
pub compose_service: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
## Mapping Devcontainer to Daytona
|
||||
|
||||
### Dockerfile and Image Build
|
||||
|
||||
`config.dockerfile` contains the full Dockerfile content (not a path). For image-only configs, this is a single `FROM` line. For Dockerfile configs, it is the file content with feature layers appended.
|
||||
|
||||
- Build a Docker image from `config.dockerfile` using `config.build_context` as the build context directory.
|
||||
- Use this image as the Daytona sandbox snapshot/base image.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
`config.environment` contains the `remoteEnv` values (with variables already substituted). These are runtime-only environment variables, not baked into the Dockerfile. `config.container_env` contains `containerEnv` values (baked into the generated Dockerfile as `ENV` directives).
|
||||
|
||||
- Pass `config.environment` as runtime environment variables when starting the sandbox.
|
||||
- `containerEnv` values are already in the Dockerfile; `config.container_env` is available for reference.
|
||||
|
||||
### Workspace Folder
|
||||
|
||||
`config.workspace_folder` defaults to `/workspaces/{repo-name}`.
|
||||
|
||||
- Set this as the sandbox working directory.
|
||||
- Mount or clone the repository into this path.
|
||||
|
||||
### Remote User
|
||||
|
||||
`config.remote_user` specifies the non-root user for running dev tools.
|
||||
|
||||
- Use this as the sandbox exec user when running lifecycle commands and user sessions.
|
||||
- Falls back to root if not set.
|
||||
|
||||
### Forwarded Ports
|
||||
|
||||
`config.forwarded_ports` lists ports to expose (first port = default preview).
|
||||
|
||||
- Use the first port as the default preview URL for the sandbox.
|
||||
- Forward all listed ports from the sandbox to the user.
|
||||
|
||||
## Docker Compose DinD Flow
|
||||
|
||||
When `config.compose_files` is non-empty, the devcontainer uses Docker Compose mode.
|
||||
|
||||
### Strategy
|
||||
|
||||
Run Docker-in-Docker (DinD) inside the Daytona sandbox:
|
||||
|
||||
1. Create a sandbox using the extracted Dockerfile from the compose service.
|
||||
2. Install Docker daemon inside the sandbox (or use a DinD-capable base image).
|
||||
3. Copy the compose file and related context into the sandbox.
|
||||
4. Run `docker compose up` inside the sandbox to start all services.
|
||||
5. The compose service ports become available on localhost inside the sandbox.
|
||||
6. Forward those ports from the sandbox to the user.
|
||||
|
||||
### Port Forwarding
|
||||
|
||||
Ports come from the compose service's `ports` configuration (parsed by `compose::parse_compose`). The compose parser extracts container-side ports from formats like `"8080:80"`, `"3000"`, and `5432`.
|
||||
|
||||
## Lifecycle Hook Execution Order
|
||||
|
||||
The devcontainer spec defines this execution order:
|
||||
|
||||
| Hook | Where | When | `DevcontainerConfig` field |
|
||||
|---|---|---|---|
|
||||
| `initializeCommand` | Host | Before build | `initialize_commands` |
|
||||
| `onCreateCommand` | Container | After first creation | `on_create_commands` |
|
||||
| `updateContentCommand` | Container | After create/content update | Not captured (not parsed) |
|
||||
| `postCreateCommand` | Container | After create/content update | `post_create_commands` |
|
||||
| `postStartCommand` | Container | On each start | `post_start_commands` |
|
||||
| `postAttachCommand` | Container | On each attach | Not captured (not parsed) |
|
||||
|
||||
### Command Types
|
||||
|
||||
Each command is represented as a `Command` enum:
|
||||
|
||||
```rust
|
||||
pub enum Command {
|
||||
Shell(String), // "npm install"
|
||||
Args(Vec<String>), // ["npm", "install"]
|
||||
Parallel(HashMap<String, String>), // {"install": "npm install", "build": "npm run build"}
|
||||
}
|
||||
```
|
||||
|
||||
- `Shell` -- execute via `sh -c "<command>"`
|
||||
- `Args` -- execute directly as argv
|
||||
- `Parallel` -- execute all values concurrently, wait for all to complete
|
||||
|
||||
### Execution in Sandbox
|
||||
|
||||
```
|
||||
1. Run initialize_commands on HOST (before sandbox creation)
|
||||
2. Build image from config.dockerfile (pass config.build_args as --build-arg flags)
|
||||
3. Create sandbox from image
|
||||
4. Run on_create_commands in sandbox (as remote_user if set)
|
||||
5. Run post_create_commands in sandbox (as remote_user if set)
|
||||
6. Run post_start_commands in sandbox (as remote_user if set)
|
||||
```
|
||||
|
||||
## Example Integration Code
|
||||
|
||||
```rust
|
||||
use arc_devcontainer::{DevcontainerResolver, DevcontainerConfig, Command};
|
||||
|
||||
async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
|
||||
let config = DevcontainerResolver::resolve(repo_path).await?;
|
||||
|
||||
// 1. Run host-side init commands
|
||||
for cmd in &config.initialize_commands {
|
||||
run_host_command(cmd).await?;
|
||||
}
|
||||
|
||||
// 2. Build image and create sandbox
|
||||
let sandbox = if !config.compose_files.is_empty() {
|
||||
// Compose mode: build from extracted service Dockerfile, then run compose inside
|
||||
let sandbox = daytona.create_from_dockerfile(
|
||||
&config.dockerfile,
|
||||
&config.build_context,
|
||||
).await?;
|
||||
setup_dind(&sandbox).await?;
|
||||
sandbox.exec("docker compose up -d").await?;
|
||||
sandbox
|
||||
} else {
|
||||
// Image/Dockerfile mode: build directly
|
||||
daytona.create_from_dockerfile(
|
||||
&config.dockerfile,
|
||||
&config.build_context,
|
||||
).await?
|
||||
};
|
||||
|
||||
// 3. Configure environment
|
||||
for (key, value) in &config.environment {
|
||||
sandbox.set_env(key, value).await?;
|
||||
}
|
||||
|
||||
// 4. Set working directory
|
||||
sandbox.set_workdir(&config.workspace_folder).await?;
|
||||
|
||||
// 5. Run lifecycle hooks
|
||||
let user = config.remote_user.as_deref();
|
||||
for cmd in &config.on_create_commands {
|
||||
sandbox.exec_command(cmd, user).await?;
|
||||
}
|
||||
for cmd in &config.post_create_commands {
|
||||
sandbox.exec_command(cmd, user).await?;
|
||||
}
|
||||
for cmd in &config.post_start_commands {
|
||||
sandbox.exec_command(cmd, user).await?;
|
||||
}
|
||||
|
||||
// 6. Set up port forwarding
|
||||
if let Some(port) = config.forwarded_ports.first() {
|
||||
sandbox.set_preview_port(*port).await?;
|
||||
}
|
||||
|
||||
Ok(sandbox)
|
||||
}
|
||||
```
|
||||
|
||||
## Edge Cases and Limitations
|
||||
|
||||
- **Features require `oras`**: Feature resolution shells out to `oras` CLI for OCI registry pulls. The resolver attempts auto-install if `oras` is not on PATH.
|
||||
- **No `updateContentCommand`**: This lifecycle hook is not parsed.
|
||||
- **No `postAttachCommand`**: Not parsed. Attach-time hooks would need to run on each user session connection.
|
||||
- **`${containerEnv:VAR}` not supported**: Variable substitution only covers host-side variables. Container-side env vars require a running container.
|
||||
- **Port forwarding**: Both numeric and string port formats (e.g., `"8080:80"`, `"9090"`) are supported in `forwardPorts`. In compose mode, `forwardPorts` are merged with compose service ports.
|
||||
|
|
@ -1,437 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
/// Extracted configuration from a Docker Compose service.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct ComposeServiceSpec {
|
||||
pub image: Option<String>,
|
||||
pub build: Option<ComposeBuild>,
|
||||
pub ports: Vec<u16>,
|
||||
pub environment: HashMap<String, String>,
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// Build configuration from a Docker Compose service.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ComposeBuild {
|
||||
pub context: String,
|
||||
pub dockerfile: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a Docker Compose file and extract config for the named service.
|
||||
pub(crate) async fn parse_compose(
|
||||
compose_path: &Path,
|
||||
service_name: &str,
|
||||
) -> Result<ComposeServiceSpec, String> {
|
||||
let contents = fs::read_to_string(compose_path).await.map_err(|e| {
|
||||
format!(
|
||||
"failed to read compose file {}: {e}",
|
||||
compose_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let doc: serde_yaml::Value = serde_yaml::from_str(&contents)
|
||||
.map_err(|e| format!("failed to parse YAML {}: {e}", compose_path.display()))?;
|
||||
|
||||
let service = doc
|
||||
.get("services")
|
||||
.and_then(|s| s.get(service_name))
|
||||
.ok_or_else(|| format!("service '{service_name}' not found in compose file"))?;
|
||||
|
||||
let image = service
|
||||
.get("image")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let build = parse_build(service);
|
||||
let ports = parse_ports(service);
|
||||
let environment = parse_environment(service);
|
||||
|
||||
let user = service
|
||||
.get("user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
Ok(ComposeServiceSpec {
|
||||
image,
|
||||
build,
|
||||
ports,
|
||||
environment,
|
||||
user,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_build(service: &serde_yaml::Value) -> Option<ComposeBuild> {
|
||||
let build_val = service.get("build")?;
|
||||
|
||||
if let Some(context) = build_val.as_str() {
|
||||
return Some(ComposeBuild {
|
||||
context: context.to_string(),
|
||||
dockerfile: None,
|
||||
});
|
||||
}
|
||||
|
||||
if build_val.is_mapping() {
|
||||
let context = build_val
|
||||
.get("context")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(".")
|
||||
.to_string();
|
||||
let dockerfile = build_val
|
||||
.get("dockerfile")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
return Some(ComposeBuild {
|
||||
context,
|
||||
dockerfile,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_ports(service: &serde_yaml::Value) -> Vec<u16> {
|
||||
let Some(ports_val) = service.get("ports") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(ports_seq) = ports_val.as_sequence() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
ports_seq
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
if let Some(n) = entry.as_u64() {
|
||||
return u16::try_from(n).ok();
|
||||
}
|
||||
if let Some(s) = entry.as_str() {
|
||||
// Formats: "8080:80", "3000", "8080:80/tcp"
|
||||
let s = s.split('/').next().unwrap_or(s); // strip protocol
|
||||
return if let Some((_host, container)) = s.split_once(':') {
|
||||
container.parse::<u16>().ok()
|
||||
} else {
|
||||
s.parse::<u16>().ok()
|
||||
};
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_environment(service: &serde_yaml::Value) -> HashMap<String, String> {
|
||||
let Some(env_val) = service.get("environment") else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
// Array form: ["KEY=VALUE", ...]
|
||||
if let Some(seq) = env_val.as_sequence() {
|
||||
return seq
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
let s = v.as_str()?;
|
||||
let (key, value) = s.split_once('=')?;
|
||||
Some((key.to_string(), value.to_string()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Object form: { KEY: VALUE, ... }
|
||||
if let Some(mapping) = env_val.as_mapping() {
|
||||
return mapping
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let key = k.as_str()?.to_string();
|
||||
let value = match v {
|
||||
serde_yaml::Value::String(s) => s.clone(),
|
||||
serde_yaml::Value::Number(n) => n.to_string(),
|
||||
serde_yaml::Value::Bool(b) => b.to_string(),
|
||||
serde_yaml::Value::Null => String::new(),
|
||||
_ => return None,
|
||||
};
|
||||
Some((key, value))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
/// Parse multiple Docker Compose files and merge config for the named service.
|
||||
/// Later files override earlier files for image/build/user; ports accumulate
|
||||
/// (deduped); environment keys from later files override earlier ones.
|
||||
pub(crate) async fn parse_compose_multi(
|
||||
compose_paths: &[PathBuf],
|
||||
service_name: &str,
|
||||
) -> Result<ComposeServiceSpec, String> {
|
||||
let mut merged = ComposeServiceSpec::default();
|
||||
let mut found_service = false;
|
||||
|
||||
for path in compose_paths {
|
||||
let contents = fs::read_to_string(path)
|
||||
.await
|
||||
.map_err(|e| format!("failed to read compose file {}: {e}", path.display()))?;
|
||||
|
||||
let doc: serde_yaml::Value = serde_yaml::from_str(&contents)
|
||||
.map_err(|e| format!("failed to parse YAML {}: {e}", path.display()))?;
|
||||
|
||||
let Some(service) = doc.get("services").and_then(|s| s.get(service_name)) else {
|
||||
continue;
|
||||
};
|
||||
found_service = true;
|
||||
|
||||
if let Some(image) = service.get("image").and_then(|v| v.as_str()) {
|
||||
merged.image = Some(image.to_string());
|
||||
}
|
||||
|
||||
if let Some(build) = parse_build(service) {
|
||||
merged.build = Some(build);
|
||||
}
|
||||
|
||||
if let Some(user) = service.get("user").and_then(|v| v.as_str()) {
|
||||
merged.user = Some(user.to_string());
|
||||
}
|
||||
|
||||
for port in parse_ports(service) {
|
||||
if !merged.ports.contains(&port) {
|
||||
merged.ports.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
for (k, v) in parse_environment(service) {
|
||||
merged.environment.insert(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
if !found_service {
|
||||
return Err(format!(
|
||||
"service '{service_name}' not found in any compose file"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "test helpers write compose fixtures to temp files via sync std::io"
|
||||
)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn write_compose(content: &str) -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||
f.write_all(content.as_bytes()).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_with_image_only() {
|
||||
let f = write_compose(
|
||||
r"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
",
|
||||
);
|
||||
let cfg = parse_compose(f.path(), "web").await.unwrap();
|
||||
assert_eq!(cfg.image.as_deref(), Some("nginx:latest"));
|
||||
assert!(cfg.build.is_none());
|
||||
assert!(cfg.ports.is_empty());
|
||||
assert!(cfg.environment.is_empty());
|
||||
assert!(cfg.user.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_with_build_string() {
|
||||
let f = write_compose(
|
||||
r"
|
||||
services:
|
||||
app:
|
||||
build: ./src
|
||||
",
|
||||
);
|
||||
let cfg = parse_compose(f.path(), "app").await.unwrap();
|
||||
let build = cfg.build.unwrap();
|
||||
assert_eq!(build.context, "./src");
|
||||
assert!(build.dockerfile.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_with_build_object() {
|
||||
let f = write_compose(
|
||||
r"
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ./app
|
||||
dockerfile: Dockerfile.dev
|
||||
",
|
||||
);
|
||||
let cfg = parse_compose(f.path(), "app").await.unwrap();
|
||||
let build = cfg.build.unwrap();
|
||||
assert_eq!(build.context, "./app");
|
||||
assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile.dev"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ports_various_formats() {
|
||||
let f = write_compose(
|
||||
r#"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "3000"
|
||||
- 5432
|
||||
- "9090:9090/tcp"
|
||||
"#,
|
||||
);
|
||||
let cfg = parse_compose(f.path(), "web").await.unwrap();
|
||||
assert_eq!(cfg.ports, vec![80, 3000, 5432, 9090]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn environment_as_array() {
|
||||
let f = write_compose(
|
||||
r#"
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
environment:
|
||||
- "DATABASE_URL=postgres://localhost/db"
|
||||
- "DEBUG=true"
|
||||
"#,
|
||||
);
|
||||
let cfg = parse_compose(f.path(), "app").await.unwrap();
|
||||
assert_eq!(cfg.environment.len(), 2);
|
||||
assert_eq!(cfg.environment["DATABASE_URL"], "postgres://localhost/db");
|
||||
assert_eq!(cfg.environment["DEBUG"], "true");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn environment_as_object() {
|
||||
let f = write_compose(
|
||||
r"
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
environment:
|
||||
RAILS_ENV: production
|
||||
PORT: 3000
|
||||
",
|
||||
);
|
||||
let cfg = parse_compose(f.path(), "app").await.unwrap();
|
||||
assert_eq!(cfg.environment.len(), 2);
|
||||
assert_eq!(cfg.environment["RAILS_ENV"], "production");
|
||||
assert_eq!(cfg.environment["PORT"], "3000");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_not_found() {
|
||||
let f = write_compose(
|
||||
r"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
",
|
||||
);
|
||||
let err = parse_compose(f.path(), "missing").await.unwrap_err();
|
||||
assert!(err.contains("service 'missing' not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_not_found() {
|
||||
let err = parse_compose(Path::new("/nonexistent/docker-compose.yml"), "web")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("failed to read compose file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_with_user() {
|
||||
let f = write_compose(
|
||||
r#"
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
user: "1000:1000"
|
||||
"#,
|
||||
);
|
||||
let cfg = parse_compose(f.path(), "app").await.unwrap();
|
||||
assert_eq!(cfg.user.as_deref(), Some("1000:1000"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_compose_merge() {
|
||||
let base = write_compose(
|
||||
r#"
|
||||
services:
|
||||
app:
|
||||
image: node:20
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- "NODE_ENV=development"
|
||||
"#,
|
||||
);
|
||||
let over = write_compose(
|
||||
r#"
|
||||
services:
|
||||
app:
|
||||
image: node:22
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "9229:9229"
|
||||
environment:
|
||||
- "DEBUG=true"
|
||||
"#,
|
||||
);
|
||||
let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];
|
||||
let cfg = parse_compose_multi(&paths, "app").await.unwrap();
|
||||
assert_eq!(cfg.image.as_deref(), Some("node:22"));
|
||||
assert_eq!(cfg.ports, vec![3000, 9229]);
|
||||
assert_eq!(cfg.environment["NODE_ENV"], "development");
|
||||
assert_eq!(cfg.environment["DEBUG"], "true");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_compose_service_not_found() {
|
||||
let f = write_compose(
|
||||
r"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
",
|
||||
);
|
||||
let paths = vec![f.path().to_path_buf()];
|
||||
let err = parse_compose_multi(&paths, "missing").await.unwrap_err();
|
||||
assert!(err.contains("service 'missing' not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_compose_skips_file_without_service() {
|
||||
let base = write_compose(
|
||||
r"
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
",
|
||||
);
|
||||
let over = write_compose(
|
||||
r"
|
||||
services:
|
||||
app:
|
||||
image: node:22
|
||||
",
|
||||
);
|
||||
let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];
|
||||
let cfg = parse_compose_multi(&paths, "app").await.unwrap();
|
||||
assert_eq!(cfg.image.as_deref(), Some("node:22"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use crate::features::FeatureLayer;
|
||||
|
||||
/// Generate a combined Dockerfile from base + features + env + user.
|
||||
pub(crate) fn generate(
|
||||
base_dockerfile: &str,
|
||||
feature_layers: &[FeatureLayer],
|
||||
container_env: &HashMap<String, String>,
|
||||
remote_user: Option<&str>,
|
||||
) -> String {
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
|
||||
sections.push("# Generated by fabro-devcontainer".to_string());
|
||||
sections.push(base_dockerfile.to_string());
|
||||
|
||||
for layer in feature_layers {
|
||||
sections.push(layer.dockerfile_snippet.clone());
|
||||
}
|
||||
|
||||
if !container_env.is_empty() {
|
||||
let mut keys: Vec<&String> = container_env.keys().collect();
|
||||
keys.sort();
|
||||
let env_lines: Vec<String> = keys
|
||||
.iter()
|
||||
.map(|k| format!("ENV {}={}", k, container_env[*k]))
|
||||
.collect();
|
||||
sections.push(env_lines.join("\n"));
|
||||
}
|
||||
|
||||
if let Some(user) = remote_user {
|
||||
sections.push(format!("USER {user}"));
|
||||
}
|
||||
|
||||
let mut result = sections.join("\n\n");
|
||||
result.push('\n');
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_layer(id: &str, dir_name: &str, snippet: &str) -> FeatureLayer {
|
||||
FeatureLayer {
|
||||
id: id.to_string(),
|
||||
dir_name: dir_name.to_string(),
|
||||
dockerfile_snippet: snippet.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_image_only() {
|
||||
let result = generate("FROM ubuntu:22.04", &[], &HashMap::new(), None);
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM ubuntu:22.04
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_dockerfile_preserved_as_is() {
|
||||
let base = "FROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl";
|
||||
let result = generate(base, &[], &HashMap::new(), None);
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM ubuntu:22.04
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y curl
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_feature_layers() {
|
||||
let layers = vec![
|
||||
make_layer("node", "node-1", "RUN install-node.sh"),
|
||||
make_layer("python", "python-1", "RUN install-python.sh"),
|
||||
];
|
||||
let result = generate("FROM ubuntu:22.04", &layers, &HashMap::new(), None);
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM ubuntu:22.04
|
||||
|
||||
RUN install-node.sh
|
||||
|
||||
RUN install-python.sh
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_env_sorted() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("ZEBRA".to_string(), "stripes".to_string());
|
||||
env.insert("APPLE".to_string(), "red".to_string());
|
||||
env.insert("MANGO".to_string(), "yellow".to_string());
|
||||
let result = generate("FROM alpine", &[], &env, None);
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM alpine
|
||||
|
||||
ENV APPLE=red
|
||||
ENV MANGO=yellow
|
||||
ENV ZEBRA=stripes
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_remote_user() {
|
||||
let result = generate("FROM alpine", &[], &HashMap::new(), Some("vscode"));
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM alpine
|
||||
|
||||
USER vscode
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_combined() {
|
||||
let layers = vec![make_layer("node", "node-1", "RUN install-node.sh")];
|
||||
let mut env = HashMap::new();
|
||||
env.insert("PATH".to_string(), "/usr/local/bin".to_string());
|
||||
env.insert("HOME".to_string(), "/home/vscode".to_string());
|
||||
let result = generate("FROM ubuntu:22.04", &layers, &env, Some("vscode"));
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM ubuntu:22.04
|
||||
|
||||
RUN install-node.sh
|
||||
|
||||
ENV HOME=/home/vscode
|
||||
ENV PATH=/usr/local/bin
|
||||
|
||||
USER vscode
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_feature_layers_no_extra_blank_lines() {
|
||||
let result = generate("FROM alpine", &[], &HashMap::new(), Some("dev"));
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM alpine
|
||||
|
||||
USER dev
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_env_map_treated_as_none() {
|
||||
let env = HashMap::new();
|
||||
let result = generate("FROM alpine", &[], &env, None);
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM alpine
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_base_dockerfile() {
|
||||
let base = "FROM ubuntu:22.04 AS builder\n\
|
||||
RUN apt-get update && apt-get install -y build-essential\n\
|
||||
COPY . /app\n\
|
||||
RUN make\n\
|
||||
\n\
|
||||
FROM ubuntu:22.04\n\
|
||||
COPY --from=builder /app/bin /usr/local/bin";
|
||||
let result = generate(base, &[], &HashMap::new(), None);
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM ubuntu:22.04 AS builder
|
||||
RUN apt-get update && apt-get install -y build-essential
|
||||
COPY . /app
|
||||
RUN make
|
||||
|
||||
FROM ubuntu:22.04
|
||||
COPY --from=builder /app/bin /usr/local/bin
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_env_only() {
|
||||
let mut cenv = HashMap::new();
|
||||
cenv.insert("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string());
|
||||
let result = generate("FROM alpine", &[], &cenv, None);
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM alpine
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_env_with_multiple_keys() {
|
||||
let mut cenv = HashMap::new();
|
||||
cenv.insert("ALPHA".to_string(), "first".to_string());
|
||||
cenv.insert("BETA".to_string(), "second".to_string());
|
||||
cenv.insert("GAMMA".to_string(), "third".to_string());
|
||||
let result = generate("FROM alpine", &[], &cenv, None);
|
||||
insta::assert_snapshot!(result, @r"
|
||||
# Generated by fabro-devcontainer
|
||||
|
||||
FROM alpine
|
||||
|
||||
ENV ALPHA=first
|
||||
ENV BETA=second
|
||||
ENV GAMMA=third
|
||||
");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,280 +0,0 @@
|
|||
/// Strip JSONC comments and trailing commas, producing valid JSON.
|
||||
pub(crate) fn strip_jsonc(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let bytes = input.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
match bytes[i] {
|
||||
// String literal — copy verbatim (including any comment-like content)
|
||||
b'"' => {
|
||||
out.push('"');
|
||||
i += 1;
|
||||
while i < len {
|
||||
match bytes[i] {
|
||||
b'\\' => {
|
||||
// Escaped character — copy both backslash and next char
|
||||
out.push('\\');
|
||||
i += 1;
|
||||
if i < len {
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'"' => {
|
||||
out.push('"');
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Potential comment start
|
||||
b'/' if i + 1 < len => {
|
||||
match bytes[i + 1] {
|
||||
// Line comment — skip until end of line
|
||||
b'/' => {
|
||||
i += 2;
|
||||
while i < len && bytes[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// Block comment — skip until */
|
||||
b'*' => {
|
||||
i += 2;
|
||||
while i + 1 < len {
|
||||
if bytes[i] == b'*' && bytes[i + 1] == b'/' {
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// Handle unterminated block comment at end of input
|
||||
if i >= len {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
out.push('/');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comma — check if it's a trailing comma before } or ]
|
||||
b',' => {
|
||||
// Look ahead past whitespace for } or ]
|
||||
let mut j = i + 1;
|
||||
while j < len && bytes[j].is_ascii_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
// Also skip comments after the comma
|
||||
while j < len {
|
||||
if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'/' {
|
||||
j += 2;
|
||||
while j < len && bytes[j] != b'\n' {
|
||||
j += 1;
|
||||
}
|
||||
while j < len && bytes[j].is_ascii_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
} else if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'*' {
|
||||
j += 2;
|
||||
while j + 1 < len {
|
||||
if bytes[j] == b'*' && bytes[j + 1] == b'/' {
|
||||
j += 2;
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
while j < len && bytes[j].is_ascii_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if j < len && (bytes[j] == b'}' || bytes[j] == b']') {
|
||||
// Trailing comma — skip it
|
||||
i += 1;
|
||||
} else {
|
||||
out.push(',');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn passthrough_valid_json() {
|
||||
let json = r#"{"key": "value"}"#;
|
||||
assert_eq!(strip_jsonc(json), json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_line_comments() {
|
||||
let input = r#"{
|
||||
// this is a comment
|
||||
"key": "value"
|
||||
}"#;
|
||||
// Leading whitespace on the comment line remains but that's valid JSON
|
||||
let expected = "{\n \n \"key\": \"value\"\n}";
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_line_comment_at_end_of_line() {
|
||||
let input = r#"{"key": "value" // inline comment
|
||||
}"#;
|
||||
// Space before the comment remains
|
||||
let expected = "{\"key\": \"value\" \n}";
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_block_comments() {
|
||||
let input = r#"{"key": /* comment */ "value"}"#;
|
||||
let expected = r#"{"key": "value"}"#;
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_multiline_block_comment() {
|
||||
let input = r#"{
|
||||
/* this is
|
||||
a multi-line
|
||||
comment */
|
||||
"key": "value"
|
||||
}"#;
|
||||
// Leading whitespace before block comment remains
|
||||
let expected = "{\n \n \"key\": \"value\"\n}";
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_trailing_comma_before_brace() {
|
||||
let input = r#"{"a": 1, "b": 2,}"#;
|
||||
let expected = r#"{"a": 1, "b": 2}"#;
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_trailing_comma_before_bracket() {
|
||||
let input = r"[1, 2, 3,]";
|
||||
let expected = r"[1, 2, 3]";
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_comma_with_whitespace() {
|
||||
let input = r#"{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
}"#;
|
||||
let expected = r#"{
|
||||
"a": 1,
|
||||
"b": 2
|
||||
}"#;
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_inside_strings_preserved() {
|
||||
let input = r#"{"key": "value // not a comment"}"#;
|
||||
assert_eq!(strip_jsonc(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_comment_inside_string_preserved() {
|
||||
let input = r#"{"key": "value /* not a comment */ still here"}"#;
|
||||
assert_eq!(strip_jsonc(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_comments_and_trailing_commas() {
|
||||
let input = r#"{
|
||||
// first comment
|
||||
"name": "test", /* inline */
|
||||
"items": [
|
||||
1,
|
||||
2, // trailing
|
||||
],
|
||||
}"#;
|
||||
// Whitespace around stripped comments remains; trailing commas removed
|
||||
let expected = "{\n \n \"name\": \"test\", \n \"items\": [\n 1,\n 2 \n ]\n}";
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input() {
|
||||
assert_eq!(strip_jsonc(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_quote_in_string() {
|
||||
let input = r#"{"key": "val\"ue // not a comment"}"#;
|
||||
assert_eq!(strip_jsonc(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_comma_with_comment_before_close() {
|
||||
let input = r#"{"a": 1, // comment
|
||||
}"#;
|
||||
// Trailing comma removed; space before comment remains
|
||||
let expected = "{\"a\": 1 \n}";
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_comma_with_block_comment_before_close() {
|
||||
let input = r#"{"a": 1, /* comment */ }"#;
|
||||
// Trailing comma removed; spaces around stripped comment remain
|
||||
let expected = "{\"a\": 1 }";
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_comments() {
|
||||
let input = "// just a comment\n/* block */";
|
||||
let expected = "\n";
|
||||
assert_eq!(strip_jsonc(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn produces_valid_json() {
|
||||
let input = r#"{
|
||||
// devcontainer settings
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/rust:1": {},
|
||||
},
|
||||
/* forwarded ports */
|
||||
"forwardPorts": [3000, 8080,],
|
||||
"remoteEnv": {
|
||||
"EDITOR": "code", // default editor
|
||||
},
|
||||
}"#;
|
||||
let result = strip_jsonc(input);
|
||||
let parsed: serde_json::Result<serde_json::Value> = serde_json::from_str(&result);
|
||||
assert!(parsed.is_ok(), "should produce valid JSON, got: {result}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,678 +0,0 @@
|
|||
#![allow(
|
||||
dead_code,
|
||||
reason = "This crate keeps parsing helpers available while integration points are still landing."
|
||||
)]
|
||||
|
||||
mod compose;
|
||||
mod dockerfile;
|
||||
mod features;
|
||||
mod jsonc;
|
||||
mod types;
|
||||
mod variables;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_util::env::SystemEnv;
|
||||
use tokio::fs;
|
||||
pub use types::DevcontainerJson;
|
||||
|
||||
/// Lifecycle command — string, array, or object (parallel) form.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
Shell(String),
|
||||
Args(Vec<String>),
|
||||
Parallel(HashMap<String, String>),
|
||||
}
|
||||
|
||||
/// Parsed and resolved devcontainer configuration — everything needed to create
|
||||
/// a sandbox.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DevcontainerSpec {
|
||||
/// Generated Dockerfile content
|
||||
pub dockerfile: String,
|
||||
/// Directory for docker build context
|
||||
pub build_context: PathBuf,
|
||||
/// Build arguments (docker build --build-arg)
|
||||
pub build_args: HashMap<String, String>,
|
||||
/// Multi-stage build target (docker build --target)
|
||||
pub build_target: Option<String>,
|
||||
/// Run on host before build
|
||||
pub initialize_commands: Vec<Command>,
|
||||
/// Run in container after first creation (before updateContentCommand)
|
||||
pub on_create_commands: Vec<Command>,
|
||||
/// Run in container after creation
|
||||
pub post_create_commands: Vec<Command>,
|
||||
/// Run in container on each start
|
||||
pub post_start_commands: Vec<Command>,
|
||||
/// remoteEnv merged
|
||||
pub environment: HashMap<String, String>,
|
||||
/// containerEnv — baked into Dockerfile as ENV directives
|
||||
pub container_env: HashMap<String, String>,
|
||||
pub remote_user: Option<String>,
|
||||
/// default: /workspaces/{repo-name}
|
||||
pub workspace_folder: String,
|
||||
/// first = default preview port
|
||||
pub forwarded_ports: Vec<u16>,
|
||||
/// Compose file paths (empty if not in compose mode)
|
||||
pub compose_files: Vec<PathBuf>,
|
||||
pub compose_service: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DevcontainerError {
|
||||
#[error("no devcontainer.json found in {0}")]
|
||||
NotFound(PathBuf),
|
||||
|
||||
#[error("parsing devcontainer.json: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
|
||||
#[error("reading file {path}: {source}")]
|
||||
ReadFile {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("compose file error: {0}")]
|
||||
Compose(String),
|
||||
|
||||
#[error("feature error: {0}")]
|
||||
Feature(String),
|
||||
|
||||
#[error("oras not found and auto-install failed: {0}")]
|
||||
OrasInstall(String),
|
||||
|
||||
#[error("oras command failed: {0}")]
|
||||
OrasCommand(String),
|
||||
|
||||
#[error("variable substitution error: {0}")]
|
||||
Variable(String),
|
||||
|
||||
#[error(
|
||||
"base Dockerfile contains COPY or ADD instructions that reference build context files, which is not supported by Daytona snapshots: {0}"
|
||||
)]
|
||||
UnsupportedCopyAdd(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DevcontainerError>;
|
||||
|
||||
/// Check that a Dockerfile does not contain COPY or ADD instructions that
|
||||
/// reference build context files. Multi-stage `COPY --from=` and `ADD
|
||||
/// http(s)://` are allowed.
|
||||
fn check_no_build_context_copies(dockerfile: &str) -> Result<()> {
|
||||
let mut offending = Vec::new();
|
||||
let mut continuation = String::new();
|
||||
|
||||
for raw_line in dockerfile.lines() {
|
||||
let trimmed = raw_line.trim();
|
||||
|
||||
// Handle line continuations
|
||||
if !continuation.is_empty() {
|
||||
continuation.push(' ');
|
||||
continuation.push_str(trimmed);
|
||||
if trimmed.ends_with('\\') {
|
||||
continuation.truncate(continuation.len() - 1);
|
||||
continue;
|
||||
}
|
||||
let full_line = std::mem::take(&mut continuation);
|
||||
check_single_line(&full_line, &mut offending);
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.ends_with('\\') {
|
||||
continuation = trimmed.trim_end_matches('\\').to_string();
|
||||
continue;
|
||||
}
|
||||
|
||||
check_single_line(trimmed, &mut offending);
|
||||
}
|
||||
|
||||
// Handle unterminated continuation
|
||||
if !continuation.is_empty() {
|
||||
check_single_line(&continuation, &mut offending);
|
||||
}
|
||||
|
||||
if offending.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DevcontainerError::UnsupportedCopyAdd(offending.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
fn check_single_line(line: &str, offending: &mut Vec<String>) {
|
||||
let upper = line.to_ascii_uppercase();
|
||||
if upper.starts_with("COPY ") {
|
||||
// Allow COPY --from=<stage>
|
||||
let rest = line[5..].trim_start();
|
||||
if !rest.starts_with("--from=") && !rest.to_ascii_uppercase().starts_with("--FROM=") {
|
||||
offending.push(line.to_string());
|
||||
}
|
||||
} else if upper.starts_with("ADD ") {
|
||||
// Allow ADD http:// or https://
|
||||
let rest = line[4..].trim_start();
|
||||
if !rest.starts_with("http://") && !rest.starts_with("https://") {
|
||||
offending.push(line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and resolve a devcontainer config from a repo directory.
|
||||
pub struct DevcontainerResolver;
|
||||
|
||||
impl DevcontainerResolver {
|
||||
/// path: repo root (or explicit .devcontainer/ path)
|
||||
pub async fn resolve(path: &Path) -> Result<DevcontainerSpec> {
|
||||
let (json_path, devcontainer) = Self::find_and_parse(path).await?;
|
||||
let repo_root = Self::repo_root_from_json_path(&json_path, path);
|
||||
let base_dir = json_path.parent().unwrap_or(path);
|
||||
|
||||
let repo_name = repo_root
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("workspace")
|
||||
.to_string();
|
||||
|
||||
// Variable substitution — two-pass: first resolve workspace_folder itself,
|
||||
// then create final context with the resolved value.
|
||||
let raw_workspace_folder = devcontainer
|
||||
.workspace_folder
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("/workspaces/{repo_name}"));
|
||||
|
||||
let system_env = SystemEnv;
|
||||
let preliminary_vars = variables::VariableContext {
|
||||
local_workspace_folder: repo_root.to_string_lossy().to_string(),
|
||||
local_workspace_folder_basename: repo_name.clone(),
|
||||
container_workspace_folder: raw_workspace_folder.clone(),
|
||||
env: &system_env,
|
||||
};
|
||||
let workspace_folder = variables::substitute(&raw_workspace_folder, &preliminary_vars);
|
||||
|
||||
let vars = variables::VariableContext {
|
||||
local_workspace_folder: repo_root.to_string_lossy().to_string(),
|
||||
local_workspace_folder_basename: repo_name.clone(),
|
||||
container_workspace_folder: workspace_folder.clone(),
|
||||
env: &system_env,
|
||||
};
|
||||
|
||||
// Handle compose mode
|
||||
if let Some(compose_ref) = &devcontainer.docker_compose_file {
|
||||
let compose_paths: Vec<PathBuf> = compose_ref
|
||||
.paths()
|
||||
.iter()
|
||||
.map(|p| base_dir.join(variables::substitute(p, &vars)))
|
||||
.collect();
|
||||
let service_name = devcontainer
|
||||
.service
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
DevcontainerError::Compose(
|
||||
"dockerComposeFile requires 'service' field".to_string(),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let compose_config = compose::parse_compose_multi(&compose_paths, &service_name)
|
||||
.await
|
||||
.map_err(DevcontainerError::Compose)?;
|
||||
|
||||
let mut environment = HashMap::new();
|
||||
for (k, v) in compose_config.environment {
|
||||
environment.insert(k, variables::substitute(&v, &vars));
|
||||
}
|
||||
if let Some(env) = &devcontainer.remote_env {
|
||||
for (k, v) in env {
|
||||
environment.insert(k.clone(), variables::substitute(v, &vars));
|
||||
}
|
||||
}
|
||||
|
||||
// Use the first compose file's parent as build context base
|
||||
let compose_base_dir = compose_paths
|
||||
.first()
|
||||
.and_then(|p| p.parent())
|
||||
.unwrap_or(base_dir);
|
||||
|
||||
let dockerfile = if let Some(build) = &compose_config.build {
|
||||
let df_path = compose_base_dir
|
||||
.join(&build.context)
|
||||
.join(build.dockerfile.as_deref().unwrap_or("Dockerfile"));
|
||||
fs::read_to_string(&df_path).await.map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: df_path,
|
||||
source,
|
||||
}
|
||||
})?
|
||||
} else {
|
||||
format!(
|
||||
"FROM {}",
|
||||
compose_config.image.as_deref().unwrap_or("ubuntu")
|
||||
)
|
||||
};
|
||||
|
||||
check_no_build_context_copies(&dockerfile)?;
|
||||
|
||||
return Ok(DevcontainerSpec {
|
||||
dockerfile,
|
||||
build_context: compose_base_dir.to_path_buf(),
|
||||
build_args: HashMap::new(),
|
||||
build_target: None,
|
||||
initialize_commands: Self::collect_commands(
|
||||
devcontainer.initialize_command.as_ref(),
|
||||
&vars,
|
||||
),
|
||||
on_create_commands: Self::collect_commands(
|
||||
devcontainer.on_create_command.as_ref(),
|
||||
&vars,
|
||||
),
|
||||
post_create_commands: Self::collect_commands(
|
||||
devcontainer.post_create_command.as_ref(),
|
||||
&vars,
|
||||
),
|
||||
post_start_commands: Self::collect_commands(
|
||||
devcontainer.post_start_command.as_ref(),
|
||||
&vars,
|
||||
),
|
||||
environment,
|
||||
container_env: Self::collect_container_env(
|
||||
devcontainer.container_env.as_ref(),
|
||||
&vars,
|
||||
),
|
||||
remote_user: devcontainer.remote_user.clone().or(compose_config.user),
|
||||
workspace_folder,
|
||||
forwarded_ports: {
|
||||
let mut ports = compose_config.ports;
|
||||
for port in Self::parse_forward_ports(&devcontainer.forward_ports) {
|
||||
if !ports.contains(&port) {
|
||||
ports.push(port);
|
||||
}
|
||||
}
|
||||
ports
|
||||
},
|
||||
compose_files: compose_paths,
|
||||
compose_service: Some(service_name),
|
||||
});
|
||||
}
|
||||
|
||||
// Image or Dockerfile mode
|
||||
let (base_dockerfile, build_context, build_args, build_target) =
|
||||
if let Some(build) = &devcontainer.build {
|
||||
let context_dir = build.context.as_ref().map_or_else(
|
||||
|| base_dir.to_path_buf(),
|
||||
|c| base_dir.join(variables::substitute(c, &vars)),
|
||||
);
|
||||
let df_path = base_dir.join(variables::substitute(
|
||||
build.dockerfile.as_deref().unwrap_or("Dockerfile"),
|
||||
&vars,
|
||||
));
|
||||
let content = fs::read_to_string(&df_path).await.map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: df_path,
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
check_no_build_context_copies(&content)?;
|
||||
let args: HashMap<String, String> = build
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), variables::substitute(v, &vars)))
|
||||
.collect();
|
||||
let target = build
|
||||
.target
|
||||
.as_ref()
|
||||
.map(|t| variables::substitute(t, &vars));
|
||||
(content, context_dir, args, target)
|
||||
} else {
|
||||
let image = devcontainer
|
||||
.image
|
||||
.as_deref()
|
||||
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
|
||||
(
|
||||
format!("FROM {image}"),
|
||||
base_dir.to_path_buf(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
// Features
|
||||
let resolved_features = if devcontainer.features.is_empty() {
|
||||
features::ResolvedFeatures::default()
|
||||
} else {
|
||||
features::resolve_features(
|
||||
&devcontainer.features,
|
||||
base_dir,
|
||||
devcontainer.remote_user.as_deref(),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Merge feature containerEnv with devcontainer.json containerEnv
|
||||
// (devcontainer.json wins on conflicts)
|
||||
let mut merged_container_env = resolved_features.container_env;
|
||||
if let Some(env) = &devcontainer.container_env {
|
||||
for (k, v) in env {
|
||||
merged_container_env.insert(k.clone(), variables::substitute(v, &vars));
|
||||
}
|
||||
}
|
||||
// Generate final Dockerfile
|
||||
let dockerfile_content = dockerfile::generate(
|
||||
&base_dockerfile,
|
||||
&resolved_features.layers,
|
||||
&merged_container_env,
|
||||
devcontainer.remote_user.as_deref(),
|
||||
);
|
||||
|
||||
let mut environment = HashMap::new();
|
||||
if let Some(env) = &devcontainer.remote_env {
|
||||
for (k, v) in env {
|
||||
environment.insert(k.clone(), variables::substitute(v, &vars));
|
||||
}
|
||||
}
|
||||
|
||||
let forwarded_ports = Self::parse_forward_ports(&devcontainer.forward_ports);
|
||||
|
||||
// Collect devcontainer.json lifecycle commands, then append feature lifecycle
|
||||
// commands
|
||||
let mut on_create_commands =
|
||||
Self::collect_commands(devcontainer.on_create_command.as_ref(), &vars);
|
||||
let mut post_create_commands =
|
||||
Self::collect_commands(devcontainer.post_create_command.as_ref(), &vars);
|
||||
let mut post_start_commands =
|
||||
Self::collect_commands(devcontainer.post_start_command.as_ref(), &vars);
|
||||
|
||||
for cmd in &resolved_features.on_create_commands {
|
||||
on_create_commands.push(Self::convert_lifecycle_command(cmd));
|
||||
}
|
||||
for cmd in &resolved_features.post_create_commands {
|
||||
post_create_commands.push(Self::convert_lifecycle_command(cmd));
|
||||
}
|
||||
for cmd in &resolved_features.post_start_commands {
|
||||
post_start_commands.push(Self::convert_lifecycle_command(cmd));
|
||||
}
|
||||
|
||||
Ok(DevcontainerSpec {
|
||||
dockerfile: dockerfile_content,
|
||||
build_context,
|
||||
build_args,
|
||||
build_target,
|
||||
initialize_commands: Self::collect_commands(
|
||||
devcontainer.initialize_command.as_ref(),
|
||||
&vars,
|
||||
),
|
||||
on_create_commands,
|
||||
post_create_commands,
|
||||
post_start_commands,
|
||||
environment,
|
||||
container_env: merged_container_env,
|
||||
remote_user: devcontainer.remote_user.clone(),
|
||||
workspace_folder,
|
||||
forwarded_ports,
|
||||
compose_files: Vec::new(),
|
||||
compose_service: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn find_and_parse(path: &Path) -> Result<(PathBuf, DevcontainerJson)> {
|
||||
// Check standard locations
|
||||
let candidates = [
|
||||
path.join(".devcontainer/devcontainer.json"),
|
||||
path.join(".devcontainer.json"),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
let raw = fs::read_to_string(candidate).await.map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: candidate.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let stripped = jsonc::strip_jsonc(&raw);
|
||||
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
|
||||
return Ok((candidate.clone(), parsed));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if path itself is a devcontainer.json
|
||||
if path.is_file() && path.file_name().is_some_and(|n| n == "devcontainer.json") {
|
||||
let raw =
|
||||
fs::read_to_string(path)
|
||||
.await
|
||||
.map_err(|source| DevcontainerError::ReadFile {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let stripped = jsonc::strip_jsonc(&raw);
|
||||
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
|
||||
return Ok((path.to_path_buf(), parsed));
|
||||
}
|
||||
|
||||
// Subdirectory format: scan .devcontainer/ for subdirs containing
|
||||
// devcontainer.json
|
||||
let devcontainer_dir = path.join(".devcontainer");
|
||||
if devcontainer_dir.is_dir() {
|
||||
let mut entries = fs::read_dir(&devcontainer_dir).await.map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: devcontainer_dir.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let mut subdirs = Vec::new();
|
||||
|
||||
while let Some(entry) =
|
||||
entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|source| DevcontainerError::ReadFile {
|
||||
path: devcontainer_dir.clone(),
|
||||
source,
|
||||
})?
|
||||
{
|
||||
let entry_path = entry.path();
|
||||
let file_type =
|
||||
entry
|
||||
.file_type()
|
||||
.await
|
||||
.map_err(|source| DevcontainerError::ReadFile {
|
||||
path: entry_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
if file_type.is_dir() && entry_path.join("devcontainer.json").exists() {
|
||||
subdirs.push(entry_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically to get deterministic first pick
|
||||
subdirs.sort();
|
||||
|
||||
if let Some(subdir) = subdirs.first() {
|
||||
let candidate = subdir.join("devcontainer.json");
|
||||
let raw = fs::read_to_string(&candidate).await.map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: candidate.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let stripped = jsonc::strip_jsonc(&raw);
|
||||
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
|
||||
return Ok((candidate, parsed));
|
||||
}
|
||||
}
|
||||
|
||||
Err(DevcontainerError::NotFound(path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn repo_root_from_json_path<'a>(json_path: &Path, original_path: &'a Path) -> &'a Path {
|
||||
// If json_path is inside .devcontainer/<subdir>/, the repo root is two levels
|
||||
// up If json_path is inside .devcontainer/, the repo root is one level
|
||||
// up
|
||||
if let Some(parent) = json_path.parent() {
|
||||
if parent.file_name().is_some_and(|n| n == ".devcontainer") {
|
||||
if let Some(repo_root) = parent.parent() {
|
||||
let _ = repo_root;
|
||||
}
|
||||
} else if let Some(grandparent) = parent.parent() {
|
||||
if grandparent
|
||||
.file_name()
|
||||
.is_some_and(|n| n == ".devcontainer")
|
||||
{
|
||||
if let Some(repo_root) = grandparent.parent() {
|
||||
let _ = repo_root;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
original_path
|
||||
}
|
||||
|
||||
fn collect_container_env(
|
||||
env: Option<&HashMap<String, String>>,
|
||||
vars: &variables::VariableContext,
|
||||
) -> HashMap<String, String> {
|
||||
match env {
|
||||
None => HashMap::new(),
|
||||
Some(map) => map
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), variables::substitute(v, vars)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_lifecycle_command(cmd: &types::LifecycleCommand) -> Command {
|
||||
match cmd {
|
||||
types::LifecycleCommand::String(s) => Command::Shell(s.clone()),
|
||||
types::LifecycleCommand::Array(arr) => Command::Args(arr.clone()),
|
||||
types::LifecycleCommand::Object(map) => Command::Parallel(map.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_commands(
|
||||
cmd: Option<&types::LifecycleCommand>,
|
||||
vars: &variables::VariableContext,
|
||||
) -> Vec<Command> {
|
||||
match cmd {
|
||||
None => Vec::new(),
|
||||
Some(types::LifecycleCommand::String(s)) => {
|
||||
vec![Command::Shell(variables::substitute(s, vars))]
|
||||
}
|
||||
Some(types::LifecycleCommand::Array(arr)) => {
|
||||
vec![Command::Args(
|
||||
arr.iter().map(|s| variables::substitute(s, vars)).collect(),
|
||||
)]
|
||||
}
|
||||
Some(types::LifecycleCommand::Object(map)) => {
|
||||
vec![Command::Parallel(
|
||||
map.iter()
|
||||
.map(|(k, v)| (k.clone(), variables::substitute(v, vars)))
|
||||
.collect(),
|
||||
)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_forward_ports(ports: &[serde_json::Value]) -> Vec<u16> {
|
||||
ports
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
serde_json::Value::Number(n) => n.as_u64().and_then(|n| u16::try_from(n).ok()),
|
||||
serde_json::Value::String(s) => {
|
||||
let s = s.split('/').next().unwrap_or(s); // strip protocol
|
||||
if let Some((_host, container)) = s.split_once(':') {
|
||||
container.parse::<u16>().ok()
|
||||
} else {
|
||||
s.parse::<u16>().ok()
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn copy_local_file_is_rejected() {
|
||||
let dockerfile = "FROM ubuntu\nCOPY . /app\n";
|
||||
let err = check_no_build_context_copies(dockerfile).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DevcontainerError::UnsupportedCopyAdd(_)),
|
||||
"expected UnsupportedCopyAdd, got: {err:?}"
|
||||
);
|
||||
assert!(err.to_string().contains("COPY . /app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_local_file_is_rejected() {
|
||||
let dockerfile = "FROM ubuntu\nADD local.tar.gz /opt/\n";
|
||||
let err = check_no_build_context_copies(dockerfile).unwrap_err();
|
||||
assert!(err.to_string().contains("ADD local.tar.gz /opt/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_from_stage_is_allowed() {
|
||||
let dockerfile =
|
||||
"FROM builder AS build\nRUN make\nFROM ubuntu\nCOPY --from=builder /app /app\n";
|
||||
check_no_build_context_copies(dockerfile).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_url_is_allowed() {
|
||||
let dockerfile = "FROM ubuntu\nADD https://example.com/file.tar.gz /opt/\n";
|
||||
check_no_build_context_copies(dockerfile).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_http_url_is_allowed() {
|
||||
let dockerfile = "FROM ubuntu\nADD http://example.com/file.tar.gz /opt/\n";
|
||||
check_no_build_context_copies(dockerfile).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_from_and_run_is_allowed() {
|
||||
let dockerfile = "FROM ubuntu\nRUN apt-get update\nENV FOO=bar\n";
|
||||
check_no_build_context_copies(dockerfile).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_continuation_copy_is_rejected() {
|
||||
let dockerfile = "FROM ubuntu\nCOPY \\\n . /app\n";
|
||||
let err = check_no_build_context_copies(dockerfile).unwrap_err();
|
||||
assert!(matches!(err, DevcontainerError::UnsupportedCopyAdd(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_copy_is_rejected() {
|
||||
let dockerfile = "FROM ubuntu\ncopy . /app\n";
|
||||
let err = check_no_build_context_copies(dockerfile).unwrap_err();
|
||||
assert!(err.to_string().contains("copy . /app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_add_is_rejected() {
|
||||
let dockerfile = "FROM ubuntu\nadd local.tar.gz /opt/\n";
|
||||
let err = check_no_build_context_copies(dockerfile).unwrap_err();
|
||||
assert!(err.to_string().contains("add local.tar.gz /opt/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_offending_lines_reported() {
|
||||
let dockerfile = "FROM ubuntu\nCOPY . /app\nADD foo.tar /opt/\n";
|
||||
let err = check_no_build_context_copies(dockerfile).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("COPY . /app"));
|
||||
assert!(msg.contains("ADD foo.tar /opt/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_empty_lines_are_skipped() {
|
||||
let dockerfile = "FROM ubuntu\n\n# COPY . /app\n \nRUN echo hi\n";
|
||||
check_no_build_context_copies(dockerfile).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,336 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Top-level devcontainer.json schema (subset of the spec we support).
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DevcontainerJson {
|
||||
/// Base image (image mode)
|
||||
pub image: Option<String>,
|
||||
|
||||
/// Dockerfile build config
|
||||
pub build: Option<BuildSpec>,
|
||||
|
||||
/// Docker Compose file path(s) (compose mode)
|
||||
pub docker_compose_file: Option<ComposeFileRef>,
|
||||
|
||||
/// Service name for compose mode
|
||||
pub service: Option<String>,
|
||||
|
||||
/// Features to install: feature ID → options object
|
||||
#[serde(default)]
|
||||
pub features: HashMap<String, serde_json::Value>,
|
||||
|
||||
/// Ports to forward
|
||||
#[serde(default, alias = "forwardPorts")]
|
||||
pub forward_ports: Vec<serde_json::Value>,
|
||||
|
||||
/// Environment variables set in the container
|
||||
#[serde(default)]
|
||||
pub remote_env: Option<HashMap<String, String>>,
|
||||
|
||||
/// Environment variables set in the container (containerEnv)
|
||||
#[serde(default)]
|
||||
pub container_env: Option<HashMap<String, String>>,
|
||||
|
||||
/// Non-root user to run as
|
||||
pub remote_user: Option<String>,
|
||||
|
||||
/// Container user
|
||||
pub container_user: Option<String>,
|
||||
|
||||
/// Workspace folder path inside container
|
||||
pub workspace_folder: Option<String>,
|
||||
|
||||
/// Workspace mount string
|
||||
pub workspace_mount: Option<String>,
|
||||
|
||||
/// Run on host before anything else
|
||||
pub initialize_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Run in container after first creation (before updateContentCommand)
|
||||
pub on_create_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Run in container after creation
|
||||
pub post_create_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Run in container on every start
|
||||
pub post_start_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Override the default command
|
||||
pub override_command: Option<bool>,
|
||||
}
|
||||
|
||||
/// Build configuration for Dockerfile mode.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BuildSpec {
|
||||
/// Path to Dockerfile (relative to devcontainer.json)
|
||||
pub dockerfile: Option<String>,
|
||||
|
||||
/// Build context directory (relative to devcontainer.json)
|
||||
pub context: Option<String>,
|
||||
|
||||
/// Build arguments
|
||||
#[serde(default)]
|
||||
pub args: HashMap<String, String>,
|
||||
|
||||
/// Multi-stage build target
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
/// A reference to one or more Docker Compose files.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ComposeFileRef {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl ComposeFileRef {
|
||||
pub fn paths(&self) -> Vec<&str> {
|
||||
match self {
|
||||
Self::Single(s) => vec![s.as_str()],
|
||||
Self::Multiple(v) => v.iter().map(String::as_str).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A lifecycle command can be a string, array of strings, or object of named
|
||||
/// commands.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum LifecycleCommand {
|
||||
String(String),
|
||||
Array(Vec<String>),
|
||||
Object(HashMap<String, String>),
|
||||
}
|
||||
|
||||
/// Metadata from a devcontainer-feature.json file.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FeatureMetadata {
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub version: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub options: HashMap<String, FeatureOption>,
|
||||
|
||||
/// Feature IDs that this feature should be installed after
|
||||
#[serde(default)]
|
||||
pub installs_after: Vec<String>,
|
||||
|
||||
/// Hard dependencies: feature IDs that must be present (auto-installed if
|
||||
/// missing)
|
||||
#[serde(default)]
|
||||
pub depends_on: HashMap<String, serde_json::Value>,
|
||||
|
||||
/// Environment variables contributed by this feature
|
||||
#[serde(default)]
|
||||
pub container_env: HashMap<String, String>,
|
||||
|
||||
/// Lifecycle hooks contributed by this feature
|
||||
pub on_create_command: Option<LifecycleCommand>,
|
||||
pub post_create_command: Option<LifecycleCommand>,
|
||||
pub post_start_command: Option<LifecycleCommand>,
|
||||
}
|
||||
|
||||
/// A single option for a devcontainer feature.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct FeatureOption {
|
||||
#[serde(rename = "type")]
|
||||
pub option_type: Option<String>,
|
||||
pub default: Option<serde_json::Value>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_image_only() {
|
||||
let json = r#"{"image": "mcr.microsoft.com/devcontainers/base:ubuntu"}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
config.image.as_deref(),
|
||||
Some("mcr.microsoft.com/devcontainers/base:ubuntu")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_features() {
|
||||
let json = r#"{
|
||||
"image": "ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {"version": "20"},
|
||||
"ghcr.io/devcontainers/features/python:1": {}
|
||||
}
|
||||
}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.features.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lifecycle_string() {
|
||||
let json = r#"{"postCreateCommand": "npm install"}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
config.post_create_command,
|
||||
Some(LifecycleCommand::String(ref s)) if s == "npm install"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lifecycle_array() {
|
||||
let json = r#"{"postCreateCommand": ["npm", "install"]}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
config.post_create_command,
|
||||
Some(LifecycleCommand::Array(ref arr)) if arr == &["npm", "install"]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lifecycle_object() {
|
||||
let json = r#"{"postCreateCommand": {"install": "npm install", "build": "npm run build"}}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
config.post_create_command,
|
||||
Some(LifecycleCommand::Object(ref map)) if map.len() == 2
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_config() {
|
||||
let json = r#"{
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "..",
|
||||
"args": {"VARIANT": "3.9"},
|
||||
"target": "dev"
|
||||
}
|
||||
}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
let build = config.build.unwrap();
|
||||
assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile"));
|
||||
assert_eq!(build.context.as_deref(), Some(".."));
|
||||
assert_eq!(build.args.get("VARIANT").map(String::as_str), Some("3.9"));
|
||||
assert_eq!(build.target.as_deref(), Some("dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_compose_mode() {
|
||||
let json = r#"{
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace"
|
||||
}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![
|
||||
"docker-compose.yml"
|
||||
]);
|
||||
assert_eq!(config.service.as_deref(), Some("app"));
|
||||
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_compose_mode_array() {
|
||||
let json = r#"{
|
||||
"dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"],
|
||||
"service": "app"
|
||||
}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.docker_compose_file.as_ref().unwrap().paths(), vec![
|
||||
"docker-compose.yml",
|
||||
"docker-compose.override.yml"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_ignored() {
|
||||
let json = r#"{"image": "ubuntu", "unknownField": true, "customizations": {}}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.image.as_deref(), Some("ubuntu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_feature_metadata_lifecycle_hooks() {
|
||||
let json = r#"{
|
||||
"id": "python",
|
||||
"onCreateCommand": "pip install -r requirements.txt",
|
||||
"postCreateCommand": ["python", "setup.py"],
|
||||
"postStartCommand": {"server": "python app.py"}
|
||||
}"#;
|
||||
let meta: FeatureMetadata = serde_json::from_str(json).unwrap();
|
||||
assert!(
|
||||
matches!(meta.on_create_command, Some(LifecycleCommand::String(ref s)) if s == "pip install -r requirements.txt")
|
||||
);
|
||||
assert!(
|
||||
matches!(meta.post_create_command, Some(LifecycleCommand::Array(ref arr)) if arr == &["python", "setup.py"])
|
||||
);
|
||||
assert!(
|
||||
matches!(meta.post_start_command, Some(LifecycleCommand::Object(ref map)) if map.len() == 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_feature_metadata_container_env() {
|
||||
let json = r#"{
|
||||
"id": "node",
|
||||
"containerEnv": {
|
||||
"NODE_ENV": "development",
|
||||
"PATH": "/usr/local/bin:${PATH}"
|
||||
}
|
||||
}"#;
|
||||
let meta: FeatureMetadata = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(meta.container_env.len(), 2);
|
||||
assert_eq!(
|
||||
meta.container_env.get("NODE_ENV").map(String::as_str),
|
||||
Some("development")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_feature_metadata_depends_on() {
|
||||
let json = r#"{
|
||||
"id": "python",
|
||||
"dependsOn": {
|
||||
"ghcr.io/devcontainers/features/common-utils:1": {},
|
||||
"ghcr.io/devcontainers/features/node:1": {"version": "20"}
|
||||
}
|
||||
}"#;
|
||||
let meta: FeatureMetadata = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(meta.depends_on.len(), 2);
|
||||
assert!(
|
||||
meta.depends_on
|
||||
.contains_key("ghcr.io/devcontainers/features/common-utils:1")
|
||||
);
|
||||
assert_eq!(
|
||||
meta.depends_on.get("ghcr.io/devcontainers/features/node:1"),
|
||||
Some(&serde_json::json!({"version": "20"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_feature_metadata() {
|
||||
let json = r#"{
|
||||
"id": "node",
|
||||
"name": "Node.js",
|
||||
"version": "1.0.0",
|
||||
"options": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"default": "lts",
|
||||
"description": "Node.js version"
|
||||
}
|
||||
},
|
||||
"installsAfter": ["ghcr.io/devcontainers/features/common-utils"]
|
||||
}"#;
|
||||
let meta: FeatureMetadata = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(meta.id.as_deref(), Some("node"));
|
||||
assert_eq!(meta.options.len(), 1);
|
||||
assert_eq!(meta.installs_after.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
use fabro_util::env::Env;
|
||||
|
||||
/// Context for variable substitution.
|
||||
pub(crate) struct VariableContext<'a> {
|
||||
pub local_workspace_folder: String,
|
||||
pub local_workspace_folder_basename: String,
|
||||
pub container_workspace_folder: String,
|
||||
pub env: &'a dyn Env,
|
||||
}
|
||||
|
||||
/// Replace devcontainer variables in a string value.
|
||||
pub(crate) fn substitute(input: &str, ctx: &VariableContext) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
let mut rest = input;
|
||||
|
||||
while let Some(start) = rest.find("${") {
|
||||
result.push_str(&rest[..start]);
|
||||
let after_open = &rest[start + 2..];
|
||||
|
||||
if let Some(close) = after_open.find('}') {
|
||||
let expr = &after_open[..close];
|
||||
let replacement = resolve_variable(expr, ctx);
|
||||
match replacement {
|
||||
Some(val) => result.push_str(&val),
|
||||
None => {
|
||||
// Unknown variable — leave as-is
|
||||
result.push_str(&rest[start..=(start + 2 + close)]);
|
||||
}
|
||||
}
|
||||
rest = &after_open[close + 1..];
|
||||
} else {
|
||||
// No closing brace — copy literally
|
||||
result.push_str(&rest[start..]);
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
|
||||
result.push_str(rest);
|
||||
result
|
||||
}
|
||||
|
||||
fn resolve_variable(expr: &str, ctx: &VariableContext) -> Option<String> {
|
||||
match expr {
|
||||
"localWorkspaceFolder" => Some(ctx.local_workspace_folder.clone()),
|
||||
"localWorkspaceFolderBasename" => Some(ctx.local_workspace_folder_basename.clone()),
|
||||
"containerWorkspaceFolder" => Some(ctx.container_workspace_folder.clone()),
|
||||
"containerWorkspaceFolderBasename" => {
|
||||
let basename = ctx
|
||||
.container_workspace_folder
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(&ctx.container_workspace_folder);
|
||||
Some(basename.to_string())
|
||||
}
|
||||
_ if expr.starts_with("localEnv:") => {
|
||||
let var_part = &expr["localEnv:".len()..];
|
||||
// Split on first colon for default value
|
||||
if let Some(colon_pos) = var_part.find(':') {
|
||||
let var_name = &var_part[..colon_pos];
|
||||
let default = &var_part[colon_pos + 1..];
|
||||
Some(
|
||||
ctx.env
|
||||
.var(var_name)
|
||||
.unwrap_or_else(|_| default.to_string()),
|
||||
)
|
||||
} else {
|
||||
Some(ctx.env.var(var_part).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fabro_util::env::{SystemEnv, TestEnv};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_ctx() -> VariableContext<'static> {
|
||||
// Tests that don't exercise localEnv don't care about the env impl.
|
||||
// Use SystemEnv which has no lifetime/allocation concerns.
|
||||
VariableContext {
|
||||
local_workspace_folder: "/home/user/project".to_string(),
|
||||
local_workspace_folder_basename: "project".to_string(),
|
||||
container_workspace_folder: "/workspaces/project".to_string(),
|
||||
env: &SystemEnv,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_variables() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(substitute("hello", &ctx), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_workspace_folder() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(
|
||||
substitute("${localWorkspaceFolder}/src", &ctx),
|
||||
"/home/user/project/src"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_workspace_folder_basename() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(
|
||||
substitute("name: ${localWorkspaceFolderBasename}", &ctx),
|
||||
"name: project"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_workspace_folder() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(
|
||||
substitute("${containerWorkspaceFolder}/app", &ctx),
|
||||
"/workspaces/project/app"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_workspace_folder_basename() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(
|
||||
substitute("${containerWorkspaceFolderBasename}", &ctx),
|
||||
"project"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_workspace_folder_basename_nested() {
|
||||
let ctx = VariableContext {
|
||||
local_workspace_folder: "/home/user/repos/my-app".to_string(),
|
||||
local_workspace_folder_basename: "my-app".to_string(),
|
||||
container_workspace_folder: "/workspaces/repos/my-app".to_string(),
|
||||
env: &SystemEnv,
|
||||
};
|
||||
assert_eq!(
|
||||
substitute("${containerWorkspaceFolderBasename}", &ctx),
|
||||
"my-app"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_variables() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(
|
||||
substitute(
|
||||
"${localWorkspaceFolder} and ${containerWorkspaceFolder}",
|
||||
&ctx
|
||||
),
|
||||
"/home/user/project and /workspaces/project"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_variable_left_as_is() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(substitute("${unknownVariable}", &ctx), "${unknownVariable}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_env_with_set_variable() {
|
||||
let env = TestEnv(HashMap::from([(
|
||||
"FABRO_TEST_VAR_SET".into(),
|
||||
"hello".into(),
|
||||
)]));
|
||||
let ctx = VariableContext {
|
||||
env: &env,
|
||||
..test_ctx()
|
||||
};
|
||||
assert_eq!(substitute("${localEnv:FABRO_TEST_VAR_SET}", &ctx), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_env_unset_returns_empty() {
|
||||
let env = TestEnv(HashMap::new());
|
||||
let ctx = VariableContext {
|
||||
env: &env,
|
||||
..test_ctx()
|
||||
};
|
||||
assert_eq!(substitute("${localEnv:FABRO_TEST_VAR_UNSET_123}", &ctx), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_env_with_default_when_unset() {
|
||||
let env = TestEnv(HashMap::new());
|
||||
let ctx = VariableContext {
|
||||
env: &env,
|
||||
..test_ctx()
|
||||
};
|
||||
assert_eq!(
|
||||
substitute("${localEnv:FABRO_TEST_VAR_DEFAULT_456:fallback}", &ctx),
|
||||
"fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_env_with_default_when_set() {
|
||||
let env = TestEnv(HashMap::from([(
|
||||
"FABRO_TEST_VAR_DEFAULT_SET".into(),
|
||||
"actual".into(),
|
||||
)]));
|
||||
let ctx = VariableContext {
|
||||
env: &env,
|
||||
..test_ctx()
|
||||
};
|
||||
assert_eq!(
|
||||
substitute("${localEnv:FABRO_TEST_VAR_DEFAULT_SET:fallback}", &ctx),
|
||||
"actual"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closing_brace() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(
|
||||
substitute("${localWorkspaceFolder", &ctx),
|
||||
"${localWorkspaceFolder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(substitute("", &ctx), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dollar_without_brace() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(substitute("$notavar", &ctx), "$notavar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_variables() {
|
||||
let ctx = test_ctx();
|
||||
assert_eq!(
|
||||
substitute(
|
||||
"${localWorkspaceFolderBasename}${containerWorkspaceFolderBasename}",
|
||||
&ctx
|
||||
),
|
||||
"projectproject"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"image": "ubuntu:22.04",
|
||||
"initializeCommand": "echo pre-build",
|
||||
"onCreateCommand": ["make", "setup"],
|
||||
"postCreateCommand": {
|
||||
"install": "npm install",
|
||||
"build": "npm run build"
|
||||
},
|
||||
"postStartCommand": "echo started"
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"remoteUser": "node",
|
||||
"forwardPorts": [3000, 5173],
|
||||
"postCreateCommand": "npm install",
|
||||
"remoteEnv": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
services:
|
||||
app:
|
||||
image: node:20
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "9229:9229"
|
||||
environment:
|
||||
- "NODE_ENV=development"
|
||||
- "DEBUG=true"
|
||||
db:
|
||||
image: postgres:15
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
services:
|
||||
app:
|
||||
image: node:20
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"dockerComposeFile": ["base.yml", "override.yml"],
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace"
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
services:
|
||||
app:
|
||||
image: node:22
|
||||
environment:
|
||||
- "OVERRIDE_VAR=true"
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
FROM node:20
|
||||
RUN apt-get update && apt-get install -y git
|
||||
WORKDIR /workspace
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
// This is a JSONC file with comments
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "..",
|
||||
"args": {"NODE_VERSION": "20"},
|
||||
"target": "dev"
|
||||
},
|
||||
"remoteUser": "developer",
|
||||
"postCreateCommand": "npm install",
|
||||
"forwardPorts": [4000],
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"./go-feature": "1.21"
|
||||
},
|
||||
"remoteUser": "developer"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"id": "go-feature",
|
||||
"version": "1.0.0",
|
||||
"options": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"default": "latest",
|
||||
"description": "Go version"
|
||||
},
|
||||
"node-version": {
|
||||
"type": "string",
|
||||
"default": "none",
|
||||
"description": "Optional Node.js version (hyphenated option ID)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/sh
|
||||
echo "Installing go ${VERSION} with node ${NODE_VERSION}"
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"forwardPorts": [3000, "8080:80", "9090"],
|
||||
"remoteUser": "vscode",
|
||||
"remoteEnv": {
|
||||
"EDITOR": "code"
|
||||
},
|
||||
"containerEnv": {
|
||||
"DEBIAN_FRONTEND": "noninteractive"
|
||||
},
|
||||
"onCreateCommand": "setup.sh",
|
||||
"postCreateCommand": "echo hello"
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"id": "base-utils",
|
||||
"version": "1.0.0",
|
||||
"containerEnv": {
|
||||
"BASE_UTILS_INSTALLED": "true"
|
||||
},
|
||||
"onCreateCommand": "echo base-utils-setup"
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/sh
|
||||
echo "Installing base-utils"
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"./node-feature": {"version": "20"},
|
||||
"./python-feature": {}
|
||||
},
|
||||
"remoteUser": "vscode",
|
||||
"containerEnv": {
|
||||
"DEVCONTAINER": "true"
|
||||
},
|
||||
"onCreateCommand": "echo devcontainer-setup",
|
||||
"postCreateCommand": "echo devcontainer-post-create"
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"id": "node-feature",
|
||||
"version": "1.0.0",
|
||||
"options": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"default": "lts",
|
||||
"description": "Node.js version"
|
||||
}
|
||||
},
|
||||
"dependsOn": {
|
||||
"./base-utils": {}
|
||||
},
|
||||
"installsAfter": [],
|
||||
"containerEnv": {
|
||||
"NODE_INSTALLED": "true",
|
||||
"NODE_PATH": "/usr/local/lib/node_modules"
|
||||
},
|
||||
"onCreateCommand": "echo node-setup",
|
||||
"postStartCommand": "echo node-started"
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/sh
|
||||
echo "Installing node ${VERSION}"
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "python-feature",
|
||||
"version": "1.0.0",
|
||||
"installsAfter": ["./node-feature"],
|
||||
"containerEnv": {
|
||||
"PYTHON_INSTALLED": "true"
|
||||
},
|
||||
"postCreateCommand": "echo python-post-create"
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/sh
|
||||
echo "Installing python"
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"],
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"remoteUser": "node",
|
||||
"forwardPorts": [8080],
|
||||
"containerEnv": {
|
||||
"TERM": "xterm-256color",
|
||||
"EDITOR": "vim"
|
||||
},
|
||||
"remoteEnv": {
|
||||
"DATABASE_URL": "postgres://postgres:devpass@db:5432/myapp_dev",
|
||||
"REDIS_URL": "redis://redis:6379"
|
||||
},
|
||||
"onCreateCommand": "npm ci",
|
||||
"postCreateCommand": "npm run db:migrate",
|
||||
"postStartCommand": "npm run dev"
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
services:
|
||||
app:
|
||||
environment:
|
||||
- "DEBUG=true"
|
||||
- "LOG_LEVEL=verbose"
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
services:
|
||||
app:
|
||||
image: node:20-bookworm
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "9229:9229"
|
||||
environment:
|
||||
- "NODE_ENV=development"
|
||||
user: "node"
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
db:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_PASSWORD: devpass
|
||||
POSTGRES_DB: myapp_dev
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
ARG PYTHON_VERSION=3.11
|
||||
FROM python:${PYTHON_VERSION}-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -s /bin/bash developer
|
||||
WORKDIR /workspaces/app
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
// Realistic Python project devcontainer
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"PYTHON_VERSION": "3.12"
|
||||
}
|
||||
},
|
||||
"containerEnv": {
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"PIP_NO_CACHE_DIR": "1"
|
||||
},
|
||||
"remoteEnv": {
|
||||
"PYTHONPATH": "${containerWorkspaceFolder}/src",
|
||||
"PYTHONUNBUFFERED": "yes"
|
||||
},
|
||||
"remoteUser": "developer",
|
||||
"forwardPorts": [8000, 5432],
|
||||
"onCreateCommand": "pip install -r requirements.txt",
|
||||
"postCreateCommand": "python manage.py migrate",
|
||||
"postStartCommand": "python manage.py runserver 0.0.0.0:8000",
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.12",
|
||||
"remoteUser": "vscode"
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"remoteUser": "alpha-user"
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"image": "node:20",
|
||||
"remoteUser": "beta-user"
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"remoteUser": "standard-user"
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.12",
|
||||
"remoteUser": "python-user"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"image": "ubuntu:22.04",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
"remoteEnv": {
|
||||
"PROJECT_ROOT": "${containerWorkspaceFolder}",
|
||||
"PROJECT_NAME": "${containerWorkspaceFolderBasename}"
|
||||
},
|
||||
"postCreateCommand": "echo ${containerWorkspaceFolder}"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/python:1": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,594 +0,0 @@
|
|||
//! End-to-end tests exercising full resolver pipeline with realistic
|
||||
//! devcontainer configs. These tests verify the 4 critical gaps are wired
|
||||
//! correctly through the entire stack:
|
||||
//! 1. onCreateCommand
|
||||
//! 2. build.args
|
||||
//! 3. containerEnv
|
||||
//! 4. dockerComposeFile array
|
||||
|
||||
use fabro_devcontainer::{Command, DevcontainerResolver};
|
||||
|
||||
use super::helpers::fixture_path;
|
||||
|
||||
/// Realistic Python project: Dockerfile + build.args + containerEnv +
|
||||
/// onCreateCommand + remoteEnv Verifies all 4 gaps work together in a single
|
||||
/// config.
|
||||
#[tokio::test]
|
||||
async fn realistic_python_project() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("realistic-python"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Gap 2: build.args exposed for docker build --build-arg
|
||||
assert_eq!(
|
||||
config.build_args.get("PYTHON_VERSION").map(String::as_str),
|
||||
Some("3.12")
|
||||
);
|
||||
|
||||
// Gap 3: containerEnv baked into Dockerfile as ENV directives
|
||||
assert!(config.dockerfile.contains("ENV PIP_NO_CACHE_DIR=1"));
|
||||
assert!(config.dockerfile.contains("ENV PYTHONDONTWRITEBYTECODE=1"));
|
||||
assert_eq!(
|
||||
config
|
||||
.container_env
|
||||
.get("PIP_NO_CACHE_DIR")
|
||||
.map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
|
||||
// After fix: only containerEnv is baked into Dockerfile (remoteEnv is
|
||||
// runtime-only)
|
||||
assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=1"));
|
||||
// environment HashMap gets the remoteEnv value
|
||||
assert_eq!(
|
||||
config
|
||||
.environment
|
||||
.get("PYTHONUNBUFFERED")
|
||||
.map(String::as_str),
|
||||
Some("yes")
|
||||
);
|
||||
|
||||
// Gap 3: remoteEnv with variable substitution
|
||||
assert_eq!(
|
||||
config.environment.get("PYTHONPATH").map(String::as_str),
|
||||
Some("/workspaces/realistic-python/src")
|
||||
);
|
||||
|
||||
// Gap 1: onCreateCommand parsed and exposed
|
||||
assert_eq!(config.on_create_commands.len(), 1);
|
||||
assert!(
|
||||
matches!(&config.on_create_commands[0], Command::Shell(s) if s == "pip install -r requirements.txt")
|
||||
);
|
||||
|
||||
// Other lifecycle commands still work
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(
|
||||
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "python manage.py migrate")
|
||||
);
|
||||
assert_eq!(config.post_start_commands.len(), 1);
|
||||
assert!(
|
||||
matches!(&config.post_start_commands[0], Command::Shell(s) if s == "python manage.py runserver 0.0.0.0:8000")
|
||||
);
|
||||
|
||||
// Dockerfile content is the actual file (not generated FROM line)
|
||||
assert!(config.dockerfile.contains("ARG PYTHON_VERSION=3.11"));
|
||||
assert!(config.dockerfile.contains("apt-get update"));
|
||||
|
||||
// Standard fields
|
||||
assert_eq!(config.remote_user.as_deref(), Some("developer"));
|
||||
assert_eq!(config.forwarded_ports, vec![8000, 5432]);
|
||||
assert!(config.compose_files.is_empty());
|
||||
}
|
||||
|
||||
/// Realistic compose project: multi-file compose + containerEnv +
|
||||
/// onCreateCommand + remoteEnv Verifies gaps 1, 3, 4 work together in compose
|
||||
/// mode.
|
||||
#[tokio::test]
|
||||
async fn realistic_compose_project() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("realistic-compose"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Gap 4: multiple compose files resolved
|
||||
assert_eq!(config.compose_files.len(), 2);
|
||||
assert_eq!(config.compose_service.as_deref(), Some("app"));
|
||||
|
||||
// Gap 4: image from base compose file (override doesn't change image)
|
||||
assert!(config.dockerfile.contains("FROM node:20-bookworm"));
|
||||
|
||||
// Ports merged from both compose files (base: 3000, 9229; override: 4000) +
|
||||
// forwardPorts (8080)
|
||||
assert!(config.forwarded_ports.contains(&3000));
|
||||
assert!(config.forwarded_ports.contains(&9229));
|
||||
assert!(config.forwarded_ports.contains(&4000));
|
||||
assert!(config.forwarded_ports.contains(&8080));
|
||||
assert_eq!(config.forwarded_ports.len(), 4);
|
||||
|
||||
// Gap 4: environment merged from both compose files + remoteEnv
|
||||
assert_eq!(
|
||||
config.environment.get("NODE_ENV").map(String::as_str),
|
||||
Some("development")
|
||||
);
|
||||
assert_eq!(
|
||||
config.environment.get("DEBUG").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
config.environment.get("LOG_LEVEL").map(String::as_str),
|
||||
Some("verbose")
|
||||
);
|
||||
// remoteEnv values
|
||||
assert_eq!(
|
||||
config.environment.get("DATABASE_URL").map(String::as_str),
|
||||
Some("postgres://postgres:devpass@db:5432/myapp_dev")
|
||||
);
|
||||
assert_eq!(
|
||||
config.environment.get("REDIS_URL").map(String::as_str),
|
||||
Some("redis://redis:6379")
|
||||
);
|
||||
|
||||
// Gap 3: containerEnv exposed on config
|
||||
assert_eq!(
|
||||
config.container_env.get("TERM").map(String::as_str),
|
||||
Some("xterm-256color")
|
||||
);
|
||||
assert_eq!(
|
||||
config.container_env.get("EDITOR").map(String::as_str),
|
||||
Some("vim")
|
||||
);
|
||||
|
||||
// Gap 1: onCreateCommand in compose mode
|
||||
assert_eq!(config.on_create_commands.len(), 1);
|
||||
assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "npm ci"));
|
||||
|
||||
// Other lifecycle commands
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(
|
||||
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm run db:migrate")
|
||||
);
|
||||
assert_eq!(config.post_start_commands.len(), 1);
|
||||
assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "npm run dev"));
|
||||
|
||||
// User comes from compose (node) but remoteUser also set to node
|
||||
assert_eq!(config.remote_user.as_deref(), Some("node"));
|
||||
assert_eq!(config.workspace_folder, "/workspace");
|
||||
}
|
||||
|
||||
/// All lifecycle commands in different forms: string, array, object, and the
|
||||
/// new onCreateCommand.
|
||||
#[tokio::test]
|
||||
async fn all_lifecycle_command_forms() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("all-lifecycle"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// initializeCommand as string
|
||||
assert_eq!(config.initialize_commands.len(), 1);
|
||||
assert!(matches!(&config.initialize_commands[0], Command::Shell(s) if s == "echo pre-build"));
|
||||
|
||||
// Gap 1: onCreateCommand as array
|
||||
assert_eq!(config.on_create_commands.len(), 1);
|
||||
assert!(
|
||||
matches!(&config.on_create_commands[0], Command::Args(args) if args == &["make", "setup"])
|
||||
);
|
||||
|
||||
// postCreateCommand as object (parallel)
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(matches!(&config.post_create_commands[0], Command::Parallel(map) if map.len() == 2));
|
||||
|
||||
// postStartCommand as string
|
||||
assert_eq!(config.post_start_commands.len(), 1);
|
||||
assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "echo started"));
|
||||
}
|
||||
|
||||
/// Verify containerEnv doesn't pollute the environment HashMap (which is
|
||||
/// remoteEnv only).
|
||||
#[tokio::test]
|
||||
async fn container_env_separate_from_environment() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("realistic-python"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// container_env has containerEnv values
|
||||
assert!(config.container_env.contains_key("PYTHONDONTWRITEBYTECODE"));
|
||||
assert!(config.container_env.contains_key("PIP_NO_CACHE_DIR"));
|
||||
|
||||
// environment only has remoteEnv values (not containerEnv-only keys)
|
||||
assert!(!config.environment.contains_key("PYTHONDONTWRITEBYTECODE"));
|
||||
assert!(!config.environment.contains_key("PIP_NO_CACHE_DIR"));
|
||||
// PYTHONUNBUFFERED is in both - environment gets remoteEnv value
|
||||
assert_eq!(
|
||||
config
|
||||
.environment
|
||||
.get("PYTHONUNBUFFERED")
|
||||
.map(String::as_str),
|
||||
Some("yes")
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify build_args default to empty in non-dockerfile modes.
|
||||
#[tokio::test]
|
||||
async fn build_args_empty_in_image_and_compose_modes() {
|
||||
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(image_config.build_args.is_empty());
|
||||
|
||||
let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(compose_config.build_args.is_empty());
|
||||
}
|
||||
|
||||
/// Verify build_target is None for image-only and compose modes.
|
||||
#[tokio::test]
|
||||
async fn build_target_none_in_image_and_compose_modes() {
|
||||
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(image_config.build_target.is_none());
|
||||
|
||||
let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(compose_config.build_target.is_none());
|
||||
}
|
||||
|
||||
/// Gap 1: remoteEnv values must NOT appear as ENV directives in the generated
|
||||
/// Dockerfile. Only containerEnv should be baked in.
|
||||
#[tokio::test]
|
||||
async fn remote_env_excluded_from_dockerfile() {
|
||||
// image-only fixture has remoteEnv: {"EDITOR": "code"} and containerEnv:
|
||||
// {"DEBIAN_FRONTEND": "noninteractive"}
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// containerEnv IS in the Dockerfile
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("ENV DEBIAN_FRONTEND=noninteractive")
|
||||
);
|
||||
|
||||
// remoteEnv is NOT in the Dockerfile
|
||||
assert!(!config.dockerfile.contains("EDITOR=code"));
|
||||
|
||||
// remoteEnv IS in the environment HashMap (runtime-only)
|
||||
assert_eq!(
|
||||
config.environment.get("EDITOR").map(String::as_str),
|
||||
Some("code")
|
||||
);
|
||||
}
|
||||
|
||||
/// Gap 2: forwardPorts in compose mode are merged with compose service ports,
|
||||
/// with deduplication.
|
||||
#[tokio::test]
|
||||
async fn forward_ports_merged_and_deduped_in_compose() {
|
||||
// compose-mode fixture has compose ports [3000, 9229] and forwardPorts [3000,
|
||||
// 5173]
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3000 appears in both compose ports and forwardPorts — should NOT be
|
||||
// duplicated
|
||||
assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);
|
||||
}
|
||||
|
||||
/// Gap 3: build.target is parsed and exposed in dockerfile mode.
|
||||
#[tokio::test]
|
||||
async fn build_target_in_dockerfile_mode() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.build_target.as_deref(), Some("dev"));
|
||||
}
|
||||
|
||||
/// Gap 4: forwardPorts string formats ("host:container", "port") are parsed
|
||||
/// correctly.
|
||||
#[tokio::test]
|
||||
async fn forward_ports_string_formats() {
|
||||
// image-only fixture has forwardPorts: [3000, "8080:80", "9090"]
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3000 is a plain number
|
||||
assert!(config.forwarded_ports.contains(&3000));
|
||||
// "8080:80" extracts container port 80
|
||||
assert!(config.forwarded_ports.contains(&80));
|
||||
// "9090" is parsed as a plain port number
|
||||
assert!(config.forwarded_ports.contains(&9090));
|
||||
// host port 8080 should NOT appear (only container port matters)
|
||||
assert!(!config.forwarded_ports.contains(&8080));
|
||||
|
||||
assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]);
|
||||
}
|
||||
|
||||
/// Verify compose_files is empty for non-compose modes.
|
||||
#[tokio::test]
|
||||
async fn compose_files_empty_in_non_compose_modes() {
|
||||
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(image_config.compose_files.is_empty());
|
||||
|
||||
let df_config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(df_config.compose_files.is_empty());
|
||||
}
|
||||
|
||||
/// Verify on_create_commands defaults to empty when not specified.
|
||||
#[tokio::test]
|
||||
async fn on_create_commands_empty_when_not_specified() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(config.on_create_commands.is_empty());
|
||||
}
|
||||
|
||||
/// Verify container_env defaults to empty when not specified.
|
||||
#[tokio::test]
|
||||
async fn container_env_empty_when_not_specified() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(config.container_env.is_empty());
|
||||
}
|
||||
|
||||
// === Gap e2e tests: local features exercising dependsOn, containerEnv,
|
||||
// lifecycle hooks ===
|
||||
|
||||
/// Gap 5: Local path feature references are resolved through the full pipeline.
|
||||
#[tokio::test]
|
||||
async fn local_feature_refs_resolved() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Base image preserved
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")
|
||||
);
|
||||
|
||||
// Feature install.sh snippets are in the Dockerfile
|
||||
assert!(config.dockerfile.contains("node-feature"));
|
||||
assert!(config.dockerfile.contains("python-feature"));
|
||||
|
||||
// Node feature option "version=20" passed as env var
|
||||
assert!(config.dockerfile.contains("export VERSION=\"20\""));
|
||||
}
|
||||
|
||||
/// Gap 1: dependsOn auto-injects missing features through the full pipeline.
|
||||
/// node-feature dependsOn ./base-utils which is NOT listed in devcontainer.json
|
||||
/// features.
|
||||
#[tokio::test]
|
||||
async fn depends_on_auto_injects_missing_feature() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// base-utils was auto-injected and its install.sh snippet is in the Dockerfile
|
||||
assert!(config.dockerfile.contains("base-utils"));
|
||||
|
||||
// base-utils must appear before node-feature (dependency ordering)
|
||||
let base_pos = config.dockerfile.find("base-utils").unwrap();
|
||||
let node_pos = config.dockerfile.find("node-feature").unwrap();
|
||||
assert!(
|
||||
base_pos < node_pos,
|
||||
"base-utils (pos {base_pos}) should appear before node-feature (pos {node_pos})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Gap 2: Feature containerEnv is merged into the Dockerfile and config.
|
||||
#[tokio::test]
|
||||
async fn feature_container_env_merged() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Feature containerEnv values baked into Dockerfile
|
||||
assert!(config.dockerfile.contains("ENV NODE_INSTALLED=true"));
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("ENV NODE_PATH=/usr/local/lib/node_modules")
|
||||
);
|
||||
assert!(config.dockerfile.contains("ENV PYTHON_INSTALLED=true"));
|
||||
assert!(config.dockerfile.contains("ENV BASE_UTILS_INSTALLED=true"));
|
||||
|
||||
// Devcontainer.json containerEnv also present
|
||||
assert!(config.dockerfile.contains("ENV DEVCONTAINER=true"));
|
||||
|
||||
// All values in config.container_env
|
||||
assert_eq!(
|
||||
config
|
||||
.container_env
|
||||
.get("NODE_INSTALLED")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.container_env
|
||||
.get("PYTHON_INSTALLED")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.container_env
|
||||
.get("BASE_UTILS_INSTALLED")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
config.container_env.get("DEVCONTAINER").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
/// Gap 3: Feature lifecycle hooks are appended after devcontainer.json
|
||||
/// lifecycle commands.
|
||||
#[tokio::test]
|
||||
async fn feature_lifecycle_hooks_appended() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// onCreateCommand: devcontainer.json first, then features
|
||||
// devcontainer.json: "echo devcontainer-setup"
|
||||
// base-utils: "echo base-utils-setup"
|
||||
// node-feature: "echo node-setup"
|
||||
assert!(config.on_create_commands.len() >= 2);
|
||||
assert!(
|
||||
matches!(&config.on_create_commands[0], Command::Shell(s) if s == "echo devcontainer-setup")
|
||||
);
|
||||
|
||||
// Feature on_create_commands appear after devcontainer.json's
|
||||
let feature_on_create: Vec<&str> = config.on_create_commands[1..]
|
||||
.iter()
|
||||
.filter_map(|cmd| match cmd {
|
||||
Command::Shell(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(feature_on_create.contains(&"echo base-utils-setup"));
|
||||
assert!(feature_on_create.contains(&"echo node-setup"));
|
||||
|
||||
// postCreateCommand: devcontainer.json first, then python-feature
|
||||
assert!(config.post_create_commands.len() >= 2);
|
||||
assert!(
|
||||
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo devcontainer-post-create")
|
||||
);
|
||||
let feature_post_create: Vec<&str> = config.post_create_commands[1..]
|
||||
.iter()
|
||||
.filter_map(|cmd| match cmd {
|
||||
Command::Shell(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(feature_post_create.contains(&"echo python-post-create"));
|
||||
|
||||
// postStartCommand: only node-feature contributes (no devcontainer.json
|
||||
// postStartCommand)
|
||||
assert!(!config.post_start_commands.is_empty());
|
||||
let post_start: Vec<&str> = config
|
||||
.post_start_commands
|
||||
.iter()
|
||||
.filter_map(|cmd| match cmd {
|
||||
Command::Shell(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(post_start.contains(&"echo node-started"));
|
||||
}
|
||||
|
||||
/// Fix 1: Shorthand version syntax "1.21" is normalized to {"version": "1.21"}.
|
||||
#[tokio::test]
|
||||
async fn feature_shorthand_version_syntax() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("feature-options"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// "1.21" string should become version=1.21 env var
|
||||
assert!(
|
||||
config.dockerfile.contains("export VERSION=\"1.21\""),
|
||||
"shorthand string \"1.21\" should set VERSION env var, got:\n{}",
|
||||
config.dockerfile,
|
||||
);
|
||||
}
|
||||
|
||||
/// Fix 2: Hyphenated option IDs are converted to valid env var names
|
||||
/// (node-version → NODE_VERSION).
|
||||
#[tokio::test]
|
||||
async fn feature_option_id_hyphen_to_underscore() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("feature-options"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// node-version default "none" should export as NODE_VERSION (not NODE-VERSION)
|
||||
assert!(
|
||||
config.dockerfile.contains("export NODE_VERSION=\"none\""),
|
||||
"hyphenated option 'node-version' should become NODE_VERSION env var, got:\n{}",
|
||||
config.dockerfile,
|
||||
);
|
||||
assert!(
|
||||
!config.dockerfile.contains("NODE-VERSION"),
|
||||
"NODE-VERSION (with hyphen) should not appear in Dockerfile",
|
||||
);
|
||||
}
|
||||
|
||||
/// Fix 3: _REMOTE_USER and related env vars are emitted in feature install
|
||||
/// snippets.
|
||||
#[tokio::test]
|
||||
async fn feature_install_user_env_vars() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("feature-options"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// remoteUser is "developer", so _REMOTE_USER should be "developer"
|
||||
assert!(
|
||||
config.dockerfile.contains("_REMOTE_USER=\"developer\""),
|
||||
"_REMOTE_USER should be set to remoteUser value, got:\n{}",
|
||||
config.dockerfile,
|
||||
);
|
||||
assert!(
|
||||
config.dockerfile.contains("_CONTAINER_USER=\"root\""),
|
||||
"_CONTAINER_USER should always be root",
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("_REMOTE_USER_HOME=\"/home/developer\""),
|
||||
"_REMOTE_USER_HOME should be /home/developer",
|
||||
);
|
||||
assert!(
|
||||
config.dockerfile.contains("_CONTAINER_USER_HOME=\"/root\""),
|
||||
"_CONTAINER_USER_HOME should always be /root",
|
||||
);
|
||||
}
|
||||
|
||||
/// Fix 3: _REMOTE_USER defaults to root when remoteUser is not set.
|
||||
#[tokio::test]
|
||||
async fn feature_install_user_env_vars_default_root() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// local-features has remoteUser: "vscode"
|
||||
assert!(
|
||||
config.dockerfile.contains("_REMOTE_USER=\"vscode\""),
|
||||
"_REMOTE_USER should be set to vscode, got:\n{}",
|
||||
config.dockerfile,
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("_REMOTE_USER_HOME=\"/home/vscode\""),
|
||||
"_REMOTE_USER_HOME should be /home/vscode",
|
||||
);
|
||||
}
|
||||
|
||||
/// Gap 2+3: Feature ordering affects both containerEnv and lifecycle hook
|
||||
/// collection. python-feature installsAfter node-feature, so node's env/hooks
|
||||
/// come first.
|
||||
#[tokio::test]
|
||||
async fn feature_ordering_preserved_in_env_and_hooks() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// In the Dockerfile, node-feature layers come before python-feature layers
|
||||
let node_layer_pos = config.dockerfile.find("node-feature").unwrap();
|
||||
let python_layer_pos = config.dockerfile.find("python-feature").unwrap();
|
||||
assert!(
|
||||
node_layer_pos < python_layer_pos,
|
||||
"node-feature (pos {node_layer_pos}) should be installed before python-feature (pos {python_layer_pos})"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
pub(super) fn fixture_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
use fabro_devcontainer::{Command, DevcontainerResolver};
|
||||
|
||||
use super::helpers::fixture_path;
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_image_only() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")
|
||||
);
|
||||
assert_eq!(config.remote_user.as_deref(), Some("vscode"));
|
||||
assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]);
|
||||
assert_eq!(
|
||||
config.environment.get("EDITOR").map(String::as_str),
|
||||
Some("code")
|
||||
);
|
||||
assert_eq!(config.workspace_folder, "/workspaces/image-only");
|
||||
assert!(config.compose_files.is_empty());
|
||||
assert!(config.compose_service.is_none());
|
||||
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo hello"));
|
||||
|
||||
// onCreateCommand
|
||||
assert_eq!(config.on_create_commands.len(), 1);
|
||||
assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "setup.sh"));
|
||||
|
||||
// containerEnv baked into Dockerfile
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("ENV DEBIAN_FRONTEND=noninteractive")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.container_env
|
||||
.get("DEBIAN_FRONTEND")
|
||||
.map(String::as_str),
|
||||
Some("noninteractive")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_dockerfile_mode() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should read the actual Dockerfile content
|
||||
assert!(config.dockerfile.contains("FROM node:20"));
|
||||
assert!(config.dockerfile.contains("apt-get update"));
|
||||
assert_eq!(config.remote_user.as_deref(), Some("developer"));
|
||||
assert_eq!(config.forwarded_ports, vec![4000]);
|
||||
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm install"));
|
||||
|
||||
// build.args
|
||||
assert_eq!(
|
||||
config.build_args.get("NODE_VERSION").map(String::as_str),
|
||||
Some("20")
|
||||
);
|
||||
|
||||
// build.target
|
||||
assert_eq!(config.build_target.as_deref(), Some("dev"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_compose_mode() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// In compose mode, the dockerfile is derived from the compose service's image
|
||||
assert!(config.dockerfile.contains("FROM node:20"));
|
||||
assert_eq!(config.workspace_folder, "/workspace");
|
||||
assert_eq!(config.remote_user.as_deref(), Some("node"));
|
||||
assert_eq!(config.compose_files.len(), 1);
|
||||
assert_eq!(config.compose_service.as_deref(), Some("app"));
|
||||
|
||||
// Ports come from compose + forwardPorts merged
|
||||
assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);
|
||||
|
||||
// Environment merged from compose + remoteEnv
|
||||
assert_eq!(
|
||||
config.environment.get("NODE_ENV").map(String::as_str),
|
||||
Some("development")
|
||||
);
|
||||
assert_eq!(
|
||||
config.environment.get("DEBUG").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_variables() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.workspace_folder, "/workspaces/variables");
|
||||
assert_eq!(
|
||||
config.environment.get("PROJECT_ROOT").map(String::as_str),
|
||||
Some("/workspaces/variables")
|
||||
);
|
||||
assert_eq!(
|
||||
config.environment.get("PROJECT_NAME").map(String::as_str),
|
||||
Some("variables")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_compose_multi() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("compose-multi"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Override file wins for image
|
||||
assert!(config.dockerfile.contains("FROM node:22"));
|
||||
assert_eq!(config.workspace_folder, "/workspace");
|
||||
assert_eq!(config.compose_files.len(), 2);
|
||||
assert_eq!(config.compose_service.as_deref(), Some("app"));
|
||||
|
||||
// Port from base.yml
|
||||
assert_eq!(config.forwarded_ports, vec![3000]);
|
||||
|
||||
// Environment from override.yml
|
||||
assert_eq!(
|
||||
config.environment.get("OVERRIDE_VAR").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_not_found() {
|
||||
let result = DevcontainerResolver::resolve(&fixture_path("nonexistent")).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("no devcontainer.json found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_subdirectory_mode() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/python:3.12")
|
||||
);
|
||||
assert_eq!(config.remote_user.as_deref(), Some("vscode"));
|
||||
assert_eq!(config.workspace_folder, "/workspaces/subdirectory-mode");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_subdirectory_multiple_picks_alphabetical_first() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-multiple"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// "alpha" sorts before "beta", so alpha's config is used
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")
|
||||
);
|
||||
assert_eq!(config.remote_user.as_deref(), Some("alpha-user"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_subdirectory_standard_wins_over_subdirs() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-with-standard"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Standard .devcontainer/devcontainer.json takes priority over subdirectory
|
||||
// format
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")
|
||||
);
|
||||
assert_eq!(config.remote_user.as_deref(), Some("standard-user"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_dockerfile_is_well_formed() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should start with the generated header
|
||||
assert!(
|
||||
config
|
||||
.dockerfile
|
||||
.contains("# Generated by fabro-devcontainer")
|
||||
);
|
||||
// Should have the base image
|
||||
assert!(config.dockerfile.contains("FROM"));
|
||||
// Should end with a newline
|
||||
assert!(config.dockerfile.ends_with('\n'));
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
mod e2e;
|
||||
mod helpers;
|
||||
mod integration;
|
||||
|
|
@ -14,7 +14,7 @@ use fabro_types::{RunId, RunSandbox, RunSandboxRuntime, SandboxProviderKind};
|
|||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
use crate::clone_source;
|
||||
#[cfg(feature = "daytona")]
|
||||
use crate::daytona::{self, DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig};
|
||||
use crate::daytona::{self, DaytonaConfig, DaytonaSandbox};
|
||||
#[cfg(feature = "docker")]
|
||||
use crate::docker::{self, DockerSandbox, DockerSandboxOptions};
|
||||
use crate::local::LocalSandbox;
|
||||
|
|
@ -175,14 +175,6 @@ impl SandboxSpec {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply devcontainer snapshot config. Only Daytona uses this.
|
||||
#[cfg(feature = "daytona")]
|
||||
pub fn apply_devcontainer_snapshot(&mut self, snapshot: DaytonaSnapshotConfig) {
|
||||
if let Self::Daytona { config, .. } = self {
|
||||
config.snapshot = Some(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::unused_async,
|
||||
reason = "Only Daytona construction awaits; local and Docker builds share the async API."
|
||||
|
|
|
|||
|
|
@ -409,50 +409,3 @@ pub struct CliEnsureFailedProps {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exec_output_tail: Option<ExecOutputTail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DevcontainerResolvedProps {
|
||||
pub dockerfile_lines: usize,
|
||||
pub environment_count: usize,
|
||||
pub lifecycle_command_count: usize,
|
||||
pub workspace_folder: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DevcontainerLifecycleStartedProps {
|
||||
pub phase: String,
|
||||
pub command_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DevcontainerLifecycleCommandStartedProps {
|
||||
pub phase: String,
|
||||
pub command: String,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DevcontainerLifecycleCommandCompletedProps {
|
||||
pub phase: String,
|
||||
pub command: String,
|
||||
pub index: usize,
|
||||
pub exit_code: i32,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DevcontainerLifecycleCompletedProps {
|
||||
pub phase: String,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DevcontainerLifecycleFailedProps {
|
||||
pub phase: String,
|
||||
pub command: String,
|
||||
pub index: usize,
|
||||
pub exit_code: i32,
|
||||
pub stderr: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exec_output_tail: Option<ExecOutputTail>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -358,18 +358,6 @@ pub enum EventBody {
|
|||
PullRequestUnlinked(PullRequestUnlinkedProps),
|
||||
#[serde(rename = "pull_request.failed")]
|
||||
PullRequestFailed(PullRequestFailedProps),
|
||||
#[serde(rename = "devcontainer.resolved")]
|
||||
DevcontainerResolved(DevcontainerResolvedProps),
|
||||
#[serde(rename = "devcontainer.lifecycle.started")]
|
||||
DevcontainerLifecycleStarted(DevcontainerLifecycleStartedProps),
|
||||
#[serde(rename = "devcontainer.lifecycle.command.started")]
|
||||
DevcontainerLifecycleCommandStarted(DevcontainerLifecycleCommandStartedProps),
|
||||
#[serde(rename = "devcontainer.lifecycle.command.completed")]
|
||||
DevcontainerLifecycleCommandCompleted(DevcontainerLifecycleCommandCompletedProps),
|
||||
#[serde(rename = "devcontainer.lifecycle.completed")]
|
||||
DevcontainerLifecycleCompleted(DevcontainerLifecycleCompletedProps),
|
||||
#[serde(rename = "devcontainer.lifecycle.failed")]
|
||||
DevcontainerLifecycleFailed(DevcontainerLifecycleFailedProps),
|
||||
Unknown {
|
||||
name: String,
|
||||
properties: Value,
|
||||
|
|
@ -580,16 +568,6 @@ impl EventBody {
|
|||
Self::PullRequestLinked(_) => "pull_request.linked",
|
||||
Self::PullRequestUnlinked(_) => "pull_request.unlinked",
|
||||
Self::PullRequestFailed(_) => "pull_request.failed",
|
||||
Self::DevcontainerResolved(_) => "devcontainer.resolved",
|
||||
Self::DevcontainerLifecycleStarted(_) => "devcontainer.lifecycle.started",
|
||||
Self::DevcontainerLifecycleCommandStarted(_) => {
|
||||
"devcontainer.lifecycle.command.started"
|
||||
}
|
||||
Self::DevcontainerLifecycleCommandCompleted(_) => {
|
||||
"devcontainer.lifecycle.command.completed"
|
||||
}
|
||||
Self::DevcontainerLifecycleCompleted(_) => "devcontainer.lifecycle.completed",
|
||||
Self::DevcontainerLifecycleFailed(_) => "devcontainer.lifecycle.failed",
|
||||
Self::Unknown { name, .. } => name.as_str(),
|
||||
}
|
||||
}
|
||||
|
|
@ -762,12 +740,6 @@ fn is_known_event_name(event: &str) -> bool {
|
|||
| "pull_request.linked"
|
||||
| "pull_request.unlinked"
|
||||
| "pull_request.failed"
|
||||
| "devcontainer.resolved"
|
||||
| "devcontainer.lifecycle.started"
|
||||
| "devcontainer.lifecycle.command.started"
|
||||
| "devcontainer.lifecycle.command.completed"
|
||||
| "devcontainer.lifecycle.completed"
|
||||
| "devcontainer.lifecycle.failed"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ fabro-config = { path = "../fabro-config" }
|
|||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
fabro-hooks = { path = "../fabro-hooks" }
|
||||
fabro-validate = { path = "../fabro-validate" }
|
||||
fabro-devcontainer = { path = "../fabro-devcontainer" }
|
||||
fabro-dump = { path = "../fabro-dump" }
|
||||
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] }
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
|
|
|
|||
|
|
@ -1,640 +0,0 @@
|
|||
use std::time::Instant;
|
||||
|
||||
use fabro_agent::sandbox::Sandbox;
|
||||
use fabro_devcontainer::DevcontainerSpec;
|
||||
use fabro_sandbox::daytona::{DaytonaSnapshotConfig, DockerfileSource};
|
||||
use futures::future::try_join_all;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::event::{Emitter, Event};
|
||||
|
||||
/// Map a `DevcontainerSpec` to a `DaytonaSnapshotConfig`.
|
||||
pub fn devcontainer_to_snapshot_config(dc: &DevcontainerSpec) -> DaytonaSnapshotConfig {
|
||||
DaytonaSnapshotConfig {
|
||||
dockerfile: Some(DockerfileSource::Inline(dc.dockerfile.clone())),
|
||||
cpu: None,
|
||||
memory: None,
|
||||
disk: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a set of devcontainer lifecycle commands inside a sandbox.
|
||||
///
|
||||
/// Follows the same pattern as setup commands in `run.rs`.
|
||||
pub async fn run_devcontainer_lifecycle(
|
||||
sandbox: &dyn Sandbox,
|
||||
emitter: &Emitter,
|
||||
phase: &str,
|
||||
commands: &[fabro_devcontainer::Command],
|
||||
timeout_ms: u64,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<(), Error> {
|
||||
if commands.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
emitter.emit(&Event::DevcontainerLifecycleStarted {
|
||||
phase: phase.to_string(),
|
||||
command_count: commands.len(),
|
||||
});
|
||||
let phase_start = Instant::now();
|
||||
|
||||
for (index, cmd) in commands.iter().enumerate() {
|
||||
match cmd {
|
||||
fabro_devcontainer::Command::Shell(s) => {
|
||||
run_single_lifecycle_command(
|
||||
sandbox,
|
||||
emitter,
|
||||
phase,
|
||||
&format!("sh -c {}", shlex::try_quote(s).unwrap_or_else(|_| s.into())),
|
||||
index,
|
||||
timeout_ms,
|
||||
cancel_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
fabro_devcontainer::Command::Args(args) => {
|
||||
let joined = args
|
||||
.iter()
|
||||
.map(|a| shlex::try_quote(a).unwrap_or_else(|_| a.into()).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
run_single_lifecycle_command(
|
||||
sandbox,
|
||||
emitter,
|
||||
phase,
|
||||
&joined,
|
||||
index,
|
||||
timeout_ms,
|
||||
cancel_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
fabro_devcontainer::Command::Parallel(map) => {
|
||||
let futs: Vec<_> = map
|
||||
.iter()
|
||||
.map(|(name, cmd_str)| {
|
||||
let command = format!(
|
||||
"sh -c {}",
|
||||
shlex::try_quote(cmd_str).unwrap_or_else(|_| cmd_str.into())
|
||||
);
|
||||
let phase = phase.to_string();
|
||||
let name = name.clone();
|
||||
let cancel_token = cancel_token.clone();
|
||||
async move {
|
||||
let cmd_start = Instant::now();
|
||||
emitter.emit(&Event::DevcontainerLifecycleCommandStarted {
|
||||
phase: phase.clone(),
|
||||
command: name.clone(),
|
||||
index,
|
||||
});
|
||||
let child_token = cancel_token.child_token();
|
||||
let result = sandbox
|
||||
.exec_command(
|
||||
&command,
|
||||
timeout_ms,
|
||||
None,
|
||||
None,
|
||||
Some(child_token.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::engine(format!(
|
||||
"Devcontainer {phase} parallel command '{name}' failed: {e}"
|
||||
))
|
||||
})?;
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
child_token.cancel();
|
||||
let cmd_duration = crate::millis_u64(cmd_start.elapsed());
|
||||
if !result.is_success() {
|
||||
let exit_code = result.display_exit_code();
|
||||
let exec_output_tail = result.default_redacted_output_tail();
|
||||
emitter.emit(
|
||||
&Event::DevcontainerLifecycleFailed {
|
||||
phase: phase.clone(),
|
||||
command: name.clone(),
|
||||
index,
|
||||
exit_code,
|
||||
stderr: result.stderr.clone(),
|
||||
exec_output_tail,
|
||||
},
|
||||
);
|
||||
return Err(Error::engine(format!(
|
||||
"Devcontainer {phase} parallel command '{name}' failed (exit code {}): {}",
|
||||
exit_code,
|
||||
result.stderr,
|
||||
)));
|
||||
}
|
||||
let exit_code = result.exit_code.unwrap_or(0);
|
||||
emitter.emit(
|
||||
&Event::DevcontainerLifecycleCommandCompleted {
|
||||
phase: phase.clone(),
|
||||
command: name.clone(),
|
||||
index,
|
||||
exit_code,
|
||||
duration_ms: cmd_duration,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
try_join_all(futs).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let phase_duration = crate::millis_u64(phase_start.elapsed());
|
||||
emitter.emit(&Event::DevcontainerLifecycleCompleted {
|
||||
phase: phase.to_string(),
|
||||
duration_ms: phase_duration,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_single_lifecycle_command(
|
||||
sandbox: &dyn Sandbox,
|
||||
emitter: &Emitter,
|
||||
phase: &str,
|
||||
command: &str,
|
||||
index: usize,
|
||||
timeout_ms: u64,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<(), Error> {
|
||||
emitter.emit(&Event::DevcontainerLifecycleCommandStarted {
|
||||
phase: phase.to_string(),
|
||||
command: command.to_string(),
|
||||
index,
|
||||
});
|
||||
let cmd_start = Instant::now();
|
||||
let child_token = cancel_token.child_token();
|
||||
let result = sandbox
|
||||
.exec_command(command, timeout_ms, None, None, Some(child_token.clone()))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::engine_with_source(format!("Devcontainer {phase} command failed"), e)
|
||||
})?;
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
child_token.cancel();
|
||||
let cmd_duration = crate::millis_u64(cmd_start.elapsed());
|
||||
if !result.is_success() {
|
||||
let exit_code = result.display_exit_code();
|
||||
let exec_output_tail = result.default_redacted_output_tail();
|
||||
emitter.emit(&Event::DevcontainerLifecycleFailed {
|
||||
phase: phase.to_string(),
|
||||
command: command.to_string(),
|
||||
index,
|
||||
exit_code,
|
||||
stderr: result.stderr.clone(),
|
||||
exec_output_tail,
|
||||
});
|
||||
return Err(Error::engine(format!(
|
||||
"Devcontainer {phase} command failed (exit code {}): {command}\n{}",
|
||||
exit_code, result.stderr,
|
||||
)));
|
||||
}
|
||||
let exit_code = result.exit_code.unwrap_or(0);
|
||||
emitter.emit(&Event::DevcontainerLifecycleCommandCompleted {
|
||||
phase: phase.to_string(),
|
||||
command: command.to_string(),
|
||||
index,
|
||||
exit_code,
|
||||
duration_ms: cmd_duration,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_agent::sandbox::{ExecResult, GrepOptions, Sandbox};
|
||||
use fabro_types::{CommandTermination, EventBody};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Simple test sandbox that records commands and returns a fixed exit code.
|
||||
struct TestSandbox {
|
||||
commands: Mutex<Vec<String>>,
|
||||
cancel_tokens: Mutex<Vec<bool>>,
|
||||
exit_code: i32,
|
||||
wait_for_cancel: bool,
|
||||
}
|
||||
|
||||
impl TestSandbox {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
commands: Mutex::new(Vec::new()),
|
||||
cancel_tokens: Mutex::new(Vec::new()),
|
||||
exit_code: 0,
|
||||
wait_for_cancel: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_exit_code(exit_code: i32) -> Self {
|
||||
Self {
|
||||
commands: Mutex::new(Vec::new()),
|
||||
cancel_tokens: Mutex::new(Vec::new()),
|
||||
exit_code,
|
||||
wait_for_cancel: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn waiting_for_cancel() -> Self {
|
||||
Self {
|
||||
commands: Mutex::new(Vec::new()),
|
||||
cancel_tokens: Mutex::new(Vec::new()),
|
||||
exit_code: 0,
|
||||
wait_for_cancel: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_commands(&self) -> Vec<String> {
|
||||
self.commands.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn captured_cancel_tokens(&self) -> Vec<bool> {
|
||||
self.cancel_tokens.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Sandbox for TestSandbox {
|
||||
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn list_directory(
|
||||
&self,
|
||||
_path: &str,
|
||||
_depth: Option<usize>,
|
||||
) -> fabro_sandbox::Result<Vec<fabro_agent::sandbox::DirEntry>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn exec_command(
|
||||
&self,
|
||||
command: &str,
|
||||
_timeout_ms: u64,
|
||||
_working_dir: Option<&str>,
|
||||
_env_vars: Option<&std::collections::HashMap<String, String>>,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> fabro_sandbox::Result<ExecResult> {
|
||||
self.commands.lock().unwrap().push(command.to_string());
|
||||
self.cancel_tokens
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(cancel_token.is_some());
|
||||
if self.wait_for_cancel {
|
||||
let token = cancel_token
|
||||
.ok_or_else(|| fabro_sandbox::Error::message("missing cancel token"))?;
|
||||
token.cancelled().await;
|
||||
return Ok(ExecResult {
|
||||
stdout: String::new(),
|
||||
stderr: "cancelled".to_string(),
|
||||
exit_code: None,
|
||||
termination: CommandTermination::Cancelled,
|
||||
duration_ms: 10,
|
||||
});
|
||||
}
|
||||
Ok(ExecResult {
|
||||
stdout: String::new(),
|
||||
stderr: if self.exit_code != 0 {
|
||||
"command failed".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
exit_code: Some(self.exit_code),
|
||||
termination: CommandTermination::Exited,
|
||||
duration_ms: 10,
|
||||
})
|
||||
}
|
||||
async fn grep(
|
||||
&self,
|
||||
_pattern: &str,
|
||||
_path: &str,
|
||||
_options: &GrepOptions,
|
||||
) -> fabro_sandbox::Result<Vec<String>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn glob(
|
||||
&self,
|
||||
_pattern: &str,
|
||||
_path: Option<&str>,
|
||||
) -> fabro_sandbox::Result<Vec<String>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn download_file_to_local(
|
||||
&self,
|
||||
_remote_path: &str,
|
||||
_local_path: &std::path::Path,
|
||||
) -> fabro_sandbox::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn upload_file_from_local(
|
||||
&self,
|
||||
_local_path: &std::path::Path,
|
||||
_remote_path: &str,
|
||||
) -> fabro_sandbox::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn initialize(&self) -> fabro_sandbox::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn working_directory(&self) -> &str {
|
||||
"/work"
|
||||
}
|
||||
fn platform(&self) -> &str {
|
||||
"linux"
|
||||
}
|
||||
fn os_version(&self) -> String {
|
||||
"Linux 6.1.0".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_dockerfile_to_inline() {
|
||||
let dc = test_devcontainer_config("FROM rust:1.85\nRUN cargo install sccache");
|
||||
let snapshot = devcontainer_to_snapshot_config(&dc);
|
||||
assert_eq!(
|
||||
snapshot.dockerfile,
|
||||
Some(DockerfileSource::Inline(dc.dockerfile.clone()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn devcontainer_snapshot_uses_runtime_daytona_identity_path() {
|
||||
let dc = test_devcontainer_config("FROM ubuntu:22.04");
|
||||
let snapshot = devcontainer_to_snapshot_config(&dc);
|
||||
assert_eq!(snapshot.cpu, None);
|
||||
assert_eq!(snapshot.memory, None);
|
||||
assert_eq!(snapshot.disk, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_command_executed() {
|
||||
let sandbox = TestSandbox::new();
|
||||
let emitter = Emitter::default();
|
||||
let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())];
|
||||
run_devcontainer_lifecycle(
|
||||
&sandbox,
|
||||
&emitter,
|
||||
"on_create",
|
||||
&commands,
|
||||
300_000,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let captured = sandbox.captured_commands();
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert!(captured[0].contains("echo hi"), "command: {}", captured[0]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn args_command_joins() {
|
||||
let sandbox = TestSandbox::new();
|
||||
let emitter = Emitter::default();
|
||||
let commands = vec![fabro_devcontainer::Command::Args(vec![
|
||||
"echo".to_string(),
|
||||
"hi".to_string(),
|
||||
])];
|
||||
run_devcontainer_lifecycle(
|
||||
&sandbox,
|
||||
&emitter,
|
||||
"on_create",
|
||||
&commands,
|
||||
300_000,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let captured = sandbox.captured_commands();
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert!(
|
||||
captured[0].contains("echo") && captured[0].contains("hi"),
|
||||
"command: {}",
|
||||
captured[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emits_started_and_completed_events() {
|
||||
let emitter = Emitter::default();
|
||||
let events = Arc::new(Mutex::new(Vec::<fabro_types::RunEvent>::new()));
|
||||
let events_clone = Arc::clone(&events);
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(event.clone());
|
||||
});
|
||||
let sandbox = TestSandbox::new();
|
||||
let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())];
|
||||
run_devcontainer_lifecycle(
|
||||
&sandbox,
|
||||
&emitter,
|
||||
"on_create",
|
||||
&commands,
|
||||
300_000,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let events = events.lock().unwrap();
|
||||
let started = events[0].properties().unwrap();
|
||||
assert_eq!(events[0].event_name(), "devcontainer.lifecycle.started");
|
||||
assert_eq!(started["phase"], "on_create");
|
||||
assert_eq!(started["command_count"], 1);
|
||||
|
||||
assert_eq!(
|
||||
events[1].event_name(),
|
||||
"devcontainer.lifecycle.command.started"
|
||||
);
|
||||
let command_started = events[1].properties().unwrap();
|
||||
assert_eq!(command_started["phase"], "on_create");
|
||||
assert_eq!(command_started["index"], 0);
|
||||
|
||||
assert_eq!(
|
||||
events[2].event_name(),
|
||||
"devcontainer.lifecycle.command.completed"
|
||||
);
|
||||
let command_completed = events[2].properties().unwrap();
|
||||
assert_eq!(command_completed["phase"], "on_create");
|
||||
assert_eq!(command_completed["index"], 0);
|
||||
assert_eq!(command_completed["exit_code"], 0);
|
||||
|
||||
assert_eq!(events[3].event_name(), "devcontainer.lifecycle.completed");
|
||||
assert_eq!(events[3].properties().unwrap()["phase"], "on_create");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_command_emits_failed_and_returns_error() {
|
||||
let emitter = Emitter::default();
|
||||
let events = Arc::new(Mutex::new(Vec::<fabro_types::RunEvent>::new()));
|
||||
let events_clone = Arc::clone(&events);
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(event.clone());
|
||||
});
|
||||
let sandbox = TestSandbox::with_exit_code(1);
|
||||
let commands = vec![fabro_devcontainer::Command::Shell("false".to_string())];
|
||||
let result = run_devcontainer_lifecycle(
|
||||
&sandbox,
|
||||
&emitter,
|
||||
"on_create",
|
||||
&commands,
|
||||
300_000,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let events = events.lock().unwrap();
|
||||
let failed = events
|
||||
.iter()
|
||||
.find(|event| event.event_name() == "devcontainer.lifecycle.failed")
|
||||
.expect("devcontainer lifecycle failed event");
|
||||
match &failed.body {
|
||||
EventBody::DevcontainerLifecycleFailed(props) => {
|
||||
assert_eq!(props.phase, "on_create");
|
||||
assert_eq!(props.exit_code, 1);
|
||||
assert_eq!(props.stderr, "command failed");
|
||||
assert_eq!(
|
||||
props
|
||||
.exec_output_tail
|
||||
.as_ref()
|
||||
.and_then(|tail| tail.stderr.as_deref()),
|
||||
Some("command failed")
|
||||
);
|
||||
}
|
||||
other => panic!("expected devcontainer lifecycle failed body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_commands_is_noop() {
|
||||
let emitter = Emitter::default();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let events_clone = Arc::clone(&events);
|
||||
emitter.on_event(move |event| {
|
||||
events_clone.lock().unwrap().push(event.clone());
|
||||
});
|
||||
let sandbox = TestSandbox::new();
|
||||
run_devcontainer_lifecycle(
|
||||
&sandbox,
|
||||
&emitter,
|
||||
"on_create",
|
||||
&[],
|
||||
300_000,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(events.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_commands_run() {
|
||||
let sandbox = TestSandbox::new();
|
||||
let emitter = Emitter::default();
|
||||
let mut map = HashMap::new();
|
||||
map.insert("install".to_string(), "npm install".to_string());
|
||||
map.insert("build".to_string(), "npm run build".to_string());
|
||||
let commands = vec![fabro_devcontainer::Command::Parallel(map)];
|
||||
run_devcontainer_lifecycle(
|
||||
&sandbox,
|
||||
&emitter,
|
||||
"post_create",
|
||||
&commands,
|
||||
300_000,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let captured = sandbox.captured_commands();
|
||||
assert_eq!(captured.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_shell_command_returns_cancelled() {
|
||||
let sandbox = TestSandbox::waiting_for_cancel();
|
||||
let emitter = Emitter::default();
|
||||
let commands = vec![fabro_devcontainer::Command::Shell("sleep 5".to_string())];
|
||||
let cancel_token = CancellationToken::new();
|
||||
cancel_token.cancel();
|
||||
|
||||
let result = run_devcontainer_lifecycle(
|
||||
&sandbox,
|
||||
&emitter,
|
||||
"on_create",
|
||||
&commands,
|
||||
300_000,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(Error::Cancelled)));
|
||||
assert_eq!(sandbox.captured_cancel_tokens(), vec![true]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_parallel_command_returns_cancelled() {
|
||||
let sandbox = TestSandbox::waiting_for_cancel();
|
||||
let emitter = Emitter::default();
|
||||
let mut map = HashMap::new();
|
||||
map.insert("install".to_string(), "sleep 5".to_string());
|
||||
map.insert("build".to_string(), "sleep 5".to_string());
|
||||
let commands = vec![fabro_devcontainer::Command::Parallel(map)];
|
||||
let cancel_token = CancellationToken::new();
|
||||
cancel_token.cancel();
|
||||
|
||||
let result = run_devcontainer_lifecycle(
|
||||
&sandbox,
|
||||
&emitter,
|
||||
"post_create",
|
||||
&commands,
|
||||
300_000,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(Error::Cancelled)));
|
||||
let captured = sandbox.captured_cancel_tokens();
|
||||
assert!(!captured.is_empty());
|
||||
assert!(captured.iter().all(|saw_token| *saw_token));
|
||||
}
|
||||
|
||||
fn test_devcontainer_config(dockerfile: &str) -> DevcontainerSpec {
|
||||
DevcontainerSpec {
|
||||
dockerfile: dockerfile.to_string(),
|
||||
build_context: std::path::PathBuf::from("."),
|
||||
build_args: HashMap::new(),
|
||||
build_target: None,
|
||||
initialize_commands: vec![],
|
||||
on_create_commands: vec![],
|
||||
post_create_commands: vec![],
|
||||
post_start_commands: vec![],
|
||||
environment: HashMap::new(),
|
||||
container_env: HashMap::new(),
|
||||
remote_user: None,
|
||||
workspace_folder: "/workspaces/test".to_string(),
|
||||
forwarded_ports: vec![],
|
||||
compose_files: vec![],
|
||||
compose_service: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1306,77 +1306,6 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
error: error.clone(),
|
||||
})
|
||||
}
|
||||
Event::DevcontainerResolved {
|
||||
dockerfile_lines,
|
||||
environment_count,
|
||||
lifecycle_command_count,
|
||||
workspace_folder,
|
||||
} => EventBody::DevcontainerResolved(fabro_types::DevcontainerResolvedProps {
|
||||
dockerfile_lines: *dockerfile_lines,
|
||||
environment_count: *environment_count,
|
||||
lifecycle_command_count: *lifecycle_command_count,
|
||||
workspace_folder: workspace_folder.clone(),
|
||||
}),
|
||||
Event::DevcontainerLifecycleStarted {
|
||||
phase,
|
||||
command_count,
|
||||
} => EventBody::DevcontainerLifecycleStarted(
|
||||
fabro_types::DevcontainerLifecycleStartedProps {
|
||||
phase: phase.clone(),
|
||||
command_count: *command_count,
|
||||
},
|
||||
),
|
||||
Event::DevcontainerLifecycleCommandStarted {
|
||||
phase,
|
||||
command,
|
||||
index,
|
||||
} => EventBody::DevcontainerLifecycleCommandStarted(
|
||||
fabro_types::DevcontainerLifecycleCommandStartedProps {
|
||||
phase: phase.clone(),
|
||||
command: command.clone(),
|
||||
index: *index,
|
||||
},
|
||||
),
|
||||
Event::DevcontainerLifecycleCommandCompleted {
|
||||
phase,
|
||||
command,
|
||||
index,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
} => EventBody::DevcontainerLifecycleCommandCompleted(
|
||||
fabro_types::DevcontainerLifecycleCommandCompletedProps {
|
||||
phase: phase.clone(),
|
||||
command: command.clone(),
|
||||
index: *index,
|
||||
exit_code: *exit_code,
|
||||
duration_ms: *duration_ms,
|
||||
},
|
||||
),
|
||||
Event::DevcontainerLifecycleCompleted { phase, duration_ms } => {
|
||||
EventBody::DevcontainerLifecycleCompleted(
|
||||
fabro_types::DevcontainerLifecycleCompletedProps {
|
||||
phase: phase.clone(),
|
||||
duration_ms: *duration_ms,
|
||||
},
|
||||
)
|
||||
}
|
||||
Event::DevcontainerLifecycleFailed {
|
||||
phase,
|
||||
command,
|
||||
index,
|
||||
exit_code,
|
||||
stderr,
|
||||
exec_output_tail,
|
||||
} => {
|
||||
EventBody::DevcontainerLifecycleFailed(fabro_types::DevcontainerLifecycleFailedProps {
|
||||
phase: phase.clone(),
|
||||
command: command.clone(),
|
||||
index: *index,
|
||||
exit_code: *exit_code,
|
||||
stderr: stderr.clone(),
|
||||
exec_output_tail: exec_output_tail.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -737,41 +737,6 @@ pub enum Event {
|
|||
PullRequestFailed {
|
||||
error: String,
|
||||
},
|
||||
DevcontainerResolved {
|
||||
dockerfile_lines: usize,
|
||||
environment_count: usize,
|
||||
lifecycle_command_count: usize,
|
||||
workspace_folder: String,
|
||||
},
|
||||
DevcontainerLifecycleStarted {
|
||||
phase: String,
|
||||
command_count: usize,
|
||||
},
|
||||
DevcontainerLifecycleCommandStarted {
|
||||
phase: String,
|
||||
command: String,
|
||||
index: usize,
|
||||
},
|
||||
DevcontainerLifecycleCommandCompleted {
|
||||
phase: String,
|
||||
command: String,
|
||||
index: usize,
|
||||
exit_code: i32,
|
||||
duration_ms: u64,
|
||||
},
|
||||
DevcontainerLifecycleCompleted {
|
||||
phase: String,
|
||||
duration_ms: u64,
|
||||
},
|
||||
DevcontainerLifecycleFailed {
|
||||
phase: String,
|
||||
command: String,
|
||||
index: usize,
|
||||
exit_code: i32,
|
||||
stderr: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
exec_output_tail: Option<fabro_types::ExecOutputTail>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
|
|
@ -1595,77 +1560,6 @@ impl Event {
|
|||
Self::PullRequestFailed { error, .. } => {
|
||||
error!(error = %error, "Pull request creation failed");
|
||||
}
|
||||
Self::DevcontainerResolved {
|
||||
dockerfile_lines,
|
||||
environment_count,
|
||||
lifecycle_command_count,
|
||||
workspace_folder,
|
||||
} => {
|
||||
info!(
|
||||
dockerfile_lines,
|
||||
environment_count,
|
||||
lifecycle_command_count,
|
||||
workspace_folder,
|
||||
"Devcontainer resolved"
|
||||
);
|
||||
}
|
||||
Self::DevcontainerLifecycleStarted {
|
||||
phase,
|
||||
command_count,
|
||||
} => {
|
||||
info!(phase, command_count, "Devcontainer lifecycle started");
|
||||
}
|
||||
Self::DevcontainerLifecycleCommandStarted {
|
||||
phase,
|
||||
command,
|
||||
index,
|
||||
} => {
|
||||
debug!(
|
||||
phase,
|
||||
command, index, "Devcontainer lifecycle command started"
|
||||
);
|
||||
}
|
||||
Self::DevcontainerLifecycleCommandCompleted {
|
||||
phase,
|
||||
command,
|
||||
index,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
} => {
|
||||
debug!(
|
||||
phase,
|
||||
command,
|
||||
index,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
"Devcontainer lifecycle command completed"
|
||||
);
|
||||
}
|
||||
Self::DevcontainerLifecycleCompleted { phase, duration_ms } => {
|
||||
info!(phase, duration_ms, "Devcontainer lifecycle completed");
|
||||
}
|
||||
Self::DevcontainerLifecycleFailed {
|
||||
phase,
|
||||
command,
|
||||
index,
|
||||
exit_code,
|
||||
exec_output_tail,
|
||||
..
|
||||
} => {
|
||||
let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref());
|
||||
error!(
|
||||
phase,
|
||||
command,
|
||||
index,
|
||||
exit_code,
|
||||
exec_output_tail_present = tail.present,
|
||||
exec_stdout_tail_bytes = tail.stdout_bytes,
|
||||
exec_stderr_tail_bytes = tail.stderr_bytes,
|
||||
exec_stdout_truncated = tail.stdout_truncated,
|
||||
exec_stderr_truncated = tail.stderr_truncated,
|
||||
"Devcontainer lifecycle command failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,16 +155,6 @@ pub fn event_name(event: &Event) -> &'static str {
|
|||
Event::PullRequestLinked { .. } => "pull_request.linked",
|
||||
Event::PullRequestUnlinked { .. } => "pull_request.unlinked",
|
||||
Event::PullRequestFailed { .. } => "pull_request.failed",
|
||||
Event::DevcontainerResolved { .. } => "devcontainer.resolved",
|
||||
Event::DevcontainerLifecycleStarted { .. } => "devcontainer.lifecycle.started",
|
||||
Event::DevcontainerLifecycleCommandStarted { .. } => {
|
||||
"devcontainer.lifecycle.command.started"
|
||||
}
|
||||
Event::DevcontainerLifecycleCommandCompleted { .. } => {
|
||||
"devcontainer.lifecycle.command.completed"
|
||||
}
|
||||
Event::DevcontainerLifecycleCompleted { .. } => "devcontainer.lifecycle.completed",
|
||||
Event::DevcontainerLifecycleFailed { .. } => "devcontainer.lifecycle.failed",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -289,7 +289,6 @@ pub mod billing_rollup;
|
|||
pub mod command_log;
|
||||
pub(crate) mod condition;
|
||||
pub mod context;
|
||||
pub mod devcontainer_bridge;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod file_resolver;
|
||||
|
|
|
|||
|
|
@ -24,5 +24,5 @@ pub use start::{StartServices, Started, start};
|
|||
pub use timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline, timeline};
|
||||
pub use validate::{ValidateInput, validate};
|
||||
|
||||
pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec};
|
||||
pub use crate::pipeline::{LlmSpec, SandboxEnvSpec};
|
||||
pub use crate::transforms::RenderMode;
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ use crate::handler::HandlerRegistry;
|
|||
use crate::handler::llm::routing;
|
||||
use crate::outcome::{Outcome, StageOutcome};
|
||||
use crate::pipeline::{
|
||||
self, DevcontainerSpec, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted,
|
||||
PullRequestOptions, SandboxEnvSpec, build_conclusion_from_store, classify_engine_result,
|
||||
self, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, PullRequestOptions,
|
||||
SandboxEnvSpec, build_conclusion_from_store, classify_engine_result,
|
||||
};
|
||||
use crate::records::Checkpoint;
|
||||
use crate::run_control::RunControlState;
|
||||
|
|
@ -62,7 +62,6 @@ struct RunSession {
|
|||
lifecycle: LifecycleOptions,
|
||||
hooks: fabro_hooks::HookSettings,
|
||||
sandbox_env: SandboxEnvSpec,
|
||||
devcontainer: Option<DevcontainerSpec>,
|
||||
seed_context: Option<Context>,
|
||||
run_store: RunStoreHandle,
|
||||
event_sink: RunEventSink,
|
||||
|
|
@ -421,14 +420,11 @@ impl RunSession {
|
|||
let github_permissions: Option<HashMap<String, String>> =
|
||||
(!services.github_permissions.is_empty()).then(|| services.github_permissions.clone());
|
||||
let sandbox_env = SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env,
|
||||
github_permissions,
|
||||
origin_url: record.repo_origin_url().map(str::to_string),
|
||||
};
|
||||
|
||||
let devcontainer = None;
|
||||
|
||||
let interviewer: Arc<dyn Interviewer> = if resolved.execution.approval == ApprovalMode::Auto
|
||||
{
|
||||
Arc::new(AutoApproveInterviewer::engine())
|
||||
|
|
@ -458,13 +454,11 @@ impl RunSession {
|
|||
lifecycle: LifecycleOptions {
|
||||
setup_commands: resolved.prepare.commands.clone(),
|
||||
setup_command_timeout_ms: resolved.prepare.timeout_ms,
|
||||
devcontainer_phases: Vec::new(),
|
||||
},
|
||||
hooks: fabro_hooks::HookSettings {
|
||||
hooks: resolved.hooks.iter().map(runtime_hook_definition).collect(),
|
||||
},
|
||||
sandbox_env,
|
||||
devcontainer,
|
||||
seed_context: None,
|
||||
run_store: services.run_store,
|
||||
artifact_sink: services.artifact_sink,
|
||||
|
|
@ -855,7 +849,6 @@ impl RunSession {
|
|||
hooks: self.hooks,
|
||||
sandbox_env: self.sandbox_env,
|
||||
vault: self.vault,
|
||||
devcontainer: self.devcontainer,
|
||||
git: self.git,
|
||||
registry_override: self.registry_override,
|
||||
artifact_sink: self.artifact_sink,
|
||||
|
|
|
|||
|
|
@ -177,7 +177,6 @@ fn test_lifecycle(setup_commands: Vec<String>) -> LifecycleOptions {
|
|||
LifecycleOptions {
|
||||
setup_commands,
|
||||
setup_command_timeout_ms: 300_000,
|
||||
devcontainer_phases: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,20 +269,17 @@ async fn execute_test_run_with_options(
|
|||
lifecycle: LifecycleOptions {
|
||||
setup_commands: vec![],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
devcontainer_phases: vec![],
|
||||
},
|
||||
run_options,
|
||||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: HookSettings { hooks: vec![] },
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: git_options,
|
||||
run_control: None,
|
||||
registry_override,
|
||||
|
|
@ -336,20 +332,17 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
|
|||
lifecycle: LifecycleOptions {
|
||||
setup_commands: vec![],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
devcontainer_phases: vec![],
|
||||
},
|
||||
run_options,
|
||||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: HookSettings { hooks: vec![] },
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -416,13 +409,11 @@ async fn run_with_lifecycle(
|
|||
workflow_bundle: None,
|
||||
hooks: HookSettings { hooks: vec![] },
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: Some(Arc::new(registry)),
|
||||
|
|
|
|||
|
|
@ -16,15 +16,10 @@ use fabro_sandbox::{
|
|||
};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_vault::Vault;
|
||||
use futures::future::try_join_all;
|
||||
use shlex::try_quote;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
|
||||
use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec};
|
||||
use crate::devcontainer_bridge::{devcontainer_to_snapshot_config, run_devcontainer_lifecycle};
|
||||
use crate::error::Error;
|
||||
use crate::event::{Event, RunNoticeCode, RunNoticeLevel};
|
||||
use crate::git::GitAuthor;
|
||||
|
|
@ -90,8 +85,7 @@ fn build_sandbox_env(
|
|||
spec: &SandboxEnvSpec,
|
||||
github_app: Option<&fabro_github::GitHubCredentials>,
|
||||
) -> Result<BuiltSandboxEnv, Error> {
|
||||
let mut env = spec.devcontainer_env.clone();
|
||||
env.extend(spec.toml_env.clone());
|
||||
let env = spec.toml_env.clone();
|
||||
|
||||
let Some(permissions) = spec.github_permissions.as_ref().filter(|p| !p.is_empty()) else {
|
||||
return Ok((env, None));
|
||||
|
|
@ -273,104 +267,6 @@ fn build_llm_source(vault: Option<Arc<AsyncRwLock<Vault>>>) -> Arc<dyn Credentia
|
|||
}
|
||||
}
|
||||
|
||||
async fn resolve_devcontainer(options: &mut InitOptions) -> Result<(), Error> {
|
||||
let Some(devcontainer) = options.devcontainer.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
if !devcontainer.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = fabro_devcontainer::DevcontainerResolver::resolve(&devcontainer.resolve_dir)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("Failed to resolve devcontainer", e))?;
|
||||
|
||||
let lifecycle_command_count = config.on_create_commands.len()
|
||||
+ config.post_create_commands.len()
|
||||
+ config.post_start_commands.len();
|
||||
options.emitter.emit(&Event::DevcontainerResolved {
|
||||
dockerfile_lines: config.dockerfile.lines().count(),
|
||||
environment_count: config.environment.len(),
|
||||
lifecycle_command_count,
|
||||
workspace_folder: config.workspace_folder.clone(),
|
||||
});
|
||||
|
||||
options
|
||||
.sandbox
|
||||
.apply_devcontainer_snapshot(devcontainer_to_snapshot_config(&config));
|
||||
|
||||
let timeout = std::time::Duration::from_mins(5);
|
||||
let run_shell = |shell_command: String| {
|
||||
let cwd = devcontainer.resolve_dir.clone();
|
||||
async move {
|
||||
let output = tokio_timeout(
|
||||
timeout,
|
||||
TokioCommand::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&shell_command)
|
||||
.current_dir(&cwd)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Error::engine(format!(
|
||||
"Devcontainer initializeCommand timed out: {shell_command}"
|
||||
))
|
||||
})?
|
||||
.map_err(|e| {
|
||||
Error::engine_with_source(
|
||||
format!("Failed to execute devcontainer initializeCommand: {shell_command}"),
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let code = output
|
||||
.status
|
||||
.code()
|
||||
.map_or_else(|| "unknown".to_string(), |code| code.to_string());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::engine(format!(
|
||||
"Devcontainer initializeCommand failed (exit code {code}): {shell_command}\n{stderr}"
|
||||
)));
|
||||
}
|
||||
Ok::<(), Error>(())
|
||||
}
|
||||
};
|
||||
|
||||
for command in &config.initialize_commands {
|
||||
match command {
|
||||
fabro_devcontainer::Command::Shell(shell) => run_shell(shell.clone()).await?,
|
||||
fabro_devcontainer::Command::Args(args) => {
|
||||
let shell_command = args
|
||||
.iter()
|
||||
.map(|arg| try_quote(arg).unwrap_or_else(|_| arg.into()).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
run_shell(shell_command).await?;
|
||||
}
|
||||
fabro_devcontainer::Command::Parallel(commands) => {
|
||||
let futures = commands.values().cloned().map(&run_shell);
|
||||
try_join_all(futures).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
options
|
||||
.sandbox_env
|
||||
.devcontainer_env
|
||||
.clone_from(&config.environment);
|
||||
options.lifecycle.devcontainer_phases = vec![
|
||||
("on_create".to_string(), config.on_create_commands.clone()),
|
||||
(
|
||||
"post_create".to_string(),
|
||||
config.post_create_commands.clone(),
|
||||
),
|
||||
("post_start".to_string(), config.post_start_commands.clone()),
|
||||
];
|
||||
|
||||
Ok(())
|
||||
}
|
||||
/// INITIALIZE phase: prepare the sandbox, env, and handlers for execution.
|
||||
pub async fn initialize(
|
||||
persisted: Persisted,
|
||||
|
|
@ -397,8 +293,6 @@ pub async fn initialize(
|
|||
)))
|
||||
};
|
||||
|
||||
resolve_devcontainer(&mut options).await?;
|
||||
|
||||
let attach_existing = options.checkpoint.is_some();
|
||||
options.run_options.display_base_sha = options
|
||||
.run_options
|
||||
|
|
@ -681,18 +575,6 @@ pub async fn initialize(
|
|||
});
|
||||
}
|
||||
|
||||
for (phase, commands) in &options.lifecycle.devcontainer_phases {
|
||||
run_devcontainer_lifecycle(
|
||||
sandbox.as_ref(),
|
||||
&options.emitter,
|
||||
phase,
|
||||
commands,
|
||||
options.lifecycle.setup_command_timeout_ms,
|
||||
options.run_options.cancel_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let metadata_writer = match build_metadata_writer(&options.run_options) {
|
||||
Ok(writer) => writer,
|
||||
Err(err) => {
|
||||
|
|
@ -940,20 +822,17 @@ mod tests {
|
|||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec![command.to_string()],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
devcontainer_phases: vec![],
|
||||
},
|
||||
run_options: test_settings(&run_dir),
|
||||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1025,20 +904,17 @@ mod tests {
|
|||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec![],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
devcontainer_phases: vec![],
|
||||
},
|
||||
run_options: test_settings(&run_dir),
|
||||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env: HashMap::from([("TEST_KEY".to_string(), "value".to_string())]),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1226,20 +1102,17 @@ mod tests {
|
|||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: Vec::new(),
|
||||
setup_command_timeout_ms: 1_000,
|
||||
devcontainer_phases: Vec::new(),
|
||||
},
|
||||
run_options: test_settings(&run_dir),
|
||||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: Some(vault),
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1325,20 +1198,17 @@ mod tests {
|
|||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec!["true".to_string()],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
devcontainer_phases: vec![],
|
||||
},
|
||||
run_options: test_settings(&run_dir),
|
||||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1442,88 +1312,17 @@ mod tests {
|
|||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec!["sleep 5".to_string()],
|
||||
setup_command_timeout_ms: 5_000,
|
||||
devcontainer_phases: vec![],
|
||||
},
|
||||
run_options,
|
||||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
artifact_sink: None,
|
||||
checkpoint: None,
|
||||
seed_context: None,
|
||||
fabro_run_tools: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(Error::Cancelled)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_cancelled_devcontainer_phase_returns_cancelled() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let run_dir = temp.path().join("run");
|
||||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
let (graph, source) = simple_graph();
|
||||
let persisted = test_persisted(graph, source, &run_dir);
|
||||
let cancel_token = tokio_util::sync::CancellationToken::new();
|
||||
cancel_token.cancel();
|
||||
let mut run_options = test_settings(&run_dir);
|
||||
run_options.cancel_token = cancel_token;
|
||||
|
||||
let emitter = Arc::new(crate::event::Emitter::new(test_run_id()));
|
||||
let result = initialize(persisted, InitOptions {
|
||||
run_id: test_run_id(),
|
||||
run_store: {
|
||||
let store = memory_store();
|
||||
let inner = store.create_run(&test_run_id()).await.unwrap();
|
||||
inner.into()
|
||||
},
|
||||
dry_run: false,
|
||||
emitter: emitter.clone(),
|
||||
sandbox: SandboxSpec::Local {
|
||||
working_directory: std::env::current_dir().unwrap(),
|
||||
},
|
||||
llm: LlmSpec {
|
||||
model: "test-model".to_string(),
|
||||
provider_id: fabro_model::ProviderId::anthropic(),
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
model_controls: RunModelControls::default(),
|
||||
dry_run: true,
|
||||
},
|
||||
interviewer: Arc::new(AutoApproveInterviewer::engine()),
|
||||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
|
||||
catalog: test_catalog(),
|
||||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec![],
|
||||
setup_command_timeout_ms: 5_000,
|
||||
devcontainer_phases: vec![("on_create".to_string(), vec![
|
||||
fabro_devcontainer::Command::Shell("sleep 5".to_string()),
|
||||
])],
|
||||
},
|
||||
run_options,
|
||||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
devcontainer_env: HashMap::new(),
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ pub use pull_request::{
|
|||
};
|
||||
pub use transform::transform;
|
||||
pub use types::{
|
||||
Concluded, DevcontainerSpec, Executed, FinalizeOptions, Finalized, InitOptions, Initialized,
|
||||
LlmSpec, Parsed, Persisted, PullRequestOptions, SandboxEnvSpec,
|
||||
TEMPLATE_UNDEFINED_VARIABLE_RULE, TransformOptions, Transformed, Validated,
|
||||
Concluded, Executed, FinalizeOptions, Finalized, InitOptions, Initialized, LlmSpec, Parsed,
|
||||
Persisted, PullRequestOptions, SandboxEnvSpec, TEMPLATE_UNDEFINED_VARIABLE_RULE,
|
||||
TransformOptions, Transformed, Validated,
|
||||
};
|
||||
pub use validate::validate;
|
||||
|
|
|
|||
|
|
@ -239,18 +239,11 @@ pub struct LlmSpec {
|
|||
|
||||
#[derive(Clone)]
|
||||
pub struct SandboxEnvSpec {
|
||||
pub devcontainer_env: HashMap<String, String>,
|
||||
pub toml_env: HashMap<String, String>,
|
||||
pub github_permissions: Option<HashMap<String, String>>,
|
||||
pub origin_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DevcontainerSpec {
|
||||
pub enabled: bool,
|
||||
pub resolve_dir: PathBuf,
|
||||
}
|
||||
|
||||
pub struct InitOptions {
|
||||
pub run_id: RunId,
|
||||
pub run_store: RunStoreHandle,
|
||||
|
|
@ -268,7 +261,6 @@ pub struct InitOptions {
|
|||
pub hooks: fabro_hooks::HookSettings,
|
||||
pub sandbox_env: SandboxEnvSpec,
|
||||
pub vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
pub devcontainer: Option<DevcontainerSpec>,
|
||||
pub git: Option<GitCheckpointOptions>,
|
||||
pub registry_override: Option<Arc<HandlerRegistry>>,
|
||||
pub artifact_sink: Option<ArtifactSink>,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,4 @@ pub struct LifecycleOptions {
|
|||
pub setup_commands: Vec<String>,
|
||||
/// Timeout in milliseconds for each setup command.
|
||||
pub setup_command_timeout_ms: u64,
|
||||
/// Devcontainer lifecycle phases and their commands.
|
||||
pub devcontainer_phases: Vec<(String, Vec<fabro_devcontainer::Command>)>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ pub struct EngineServices {
|
|||
/// Git state for the current run. Set via `set_git_state` at the start of
|
||||
/// `execute` and read by parallel/fan-in handlers.
|
||||
pub(crate) git_state: std::sync::RwLock<Option<Arc<GitState>>>,
|
||||
/// Environment variables from devcontainer and `[sandbox.env]` config.
|
||||
/// Environment variables from `[sandbox.env]` config.
|
||||
pub base_env: HashMap<String, String>,
|
||||
/// GitHub token source used to inject `GITHUB_TOKEN` at the point of use.
|
||||
pub github_token: Option<Arc<GitHubTokenSource>>,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue