mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
feat(server): list sandbox services
This commit is contained in:
parent
1f6965386c
commit
9c08653228
17 changed files with 703 additions and 11 deletions
|
|
@ -2653,6 +2653,40 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/sandbox/services:
|
||||
get:
|
||||
operationId: listSandboxServices
|
||||
tags: [Human-in-the-Loop]
|
||||
summary: List Sandbox Services
|
||||
description: Lists listening TCP services discovered inside the run sandbox.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
responses:
|
||||
"200":
|
||||
description: Listening TCP services
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SandboxServiceListResponse"
|
||||
"404":
|
||||
description: Run not found
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"409":
|
||||
description: Run has no active sandbox or service discovery failed
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/sandbox/vnc:
|
||||
post:
|
||||
operationId: createSandboxVncPreview
|
||||
|
|
@ -7953,6 +7987,49 @@ components:
|
|||
items:
|
||||
$ref: "#/components/schemas/SandboxFileEntry"
|
||||
|
||||
SandboxService:
|
||||
description: A listening TCP service discovered inside a run sandbox.
|
||||
type: object
|
||||
required:
|
||||
- port
|
||||
- addresses
|
||||
- processes
|
||||
- preview_supported
|
||||
properties:
|
||||
port:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 65535
|
||||
description: Listening TCP port.
|
||||
example: 3000
|
||||
addresses:
|
||||
type: array
|
||||
description: Local bind addresses reported by `ss`.
|
||||
items:
|
||||
type: string
|
||||
example: ["127.0.0.1:3000", "[::]:3000"]
|
||||
processes:
|
||||
type: array
|
||||
description: Visible process summaries reported by `ss`.
|
||||
items:
|
||||
type: string
|
||||
example: ['users:(("node",pid=42,fd=23))']
|
||||
preview_supported:
|
||||
type: boolean
|
||||
description: Whether the provider supports an external preview URL for this port.
|
||||
example: true
|
||||
|
||||
SandboxServiceListResponse:
|
||||
description: Non-paginated list of listening TCP services in a run sandbox.
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SandboxService"
|
||||
|
||||
VncPreviewResponse:
|
||||
description: Response containing a signed noVNC preview URL for a Daytona sandbox.
|
||||
type: object
|
||||
|
|
|
|||
135
docs/superpowers/plans/2026-05-10-sandbox-services-backend.md
Normal file
135
docs/superpowers/plans/2026-05-10-sandbox-services-backend.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Sandbox Services Backend Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a backend API that lists listening TCP services inside a run sandbox and marks Daytona-previewable ports.
|
||||
|
||||
**Architecture:** The server reconnects to the run-owned sandbox, starts it if needed, runs `ss -H -ltnp`, parses the raw output outside the sandbox command, groups services by port, and returns a provider-neutral JSON response. This phase owns the API contract, server implementation, parser tests, and generated clients; the frontend is implemented separately.
|
||||
|
||||
**Tech Stack:** Rust, Axum, OpenAPI, progenitor, fabro-sandbox `Sandbox::exec_command`, generated TypeScript Axios client.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
- Add `GET /api/v1/runs/{id}/sandbox/services`.
|
||||
- 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`.
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `docs/public/api-reference/fabro-api.yaml`
|
||||
- Modify: `lib/crates/fabro-types/src/lib.rs`
|
||||
- Create or modify: `lib/crates/fabro-types/src/sandbox_services.rs`
|
||||
- Modify: `lib/crates/fabro-api/src/lib.rs`
|
||||
- Modify: `lib/crates/fabro-api/build.rs`
|
||||
- Create: `lib/crates/fabro-api/tests/sandbox_services_round_trip.rs`
|
||||
- Modify: `lib/crates/fabro-server/src/server/handler/sandbox.rs`
|
||||
- Modify: `lib/crates/fabro-server/src/server/handler/mod.rs`
|
||||
- Modify: `lib/crates/fabro-server/src/demo/mod.rs`
|
||||
- Regenerate: generated Rust API code via `cargo build -p fabro-api`
|
||||
- Regenerate: `lib/packages/fabro-api-client/src/**` via `cd lib/packages/fabro-api-client && bun run generate`
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Define the API Contract and Shared Types
|
||||
|
||||
- [x] Add schemas to `docs/public/api-reference/fabro-api.yaml`:
|
||||
- `SandboxService` with required fields `port`, `addresses`, `processes`, `preview_supported`.
|
||||
- `SandboxServiceListResponse` with required field `data`.
|
||||
- `port` is an integer with `minimum: 1`, `maximum: 65535`.
|
||||
- `addresses` is an array of strings, preserving bind addresses from `ss`.
|
||||
- `processes` is an array of strings, preserving visible process summaries from `ss`.
|
||||
- `preview_supported` is a boolean.
|
||||
- [x] Add `GET /api/v1/runs/{id}/sandbox/services` under the Human-in-the-Loop tag:
|
||||
- Summary: `List Sandbox Services`.
|
||||
- Description: lists listening TCP services discovered inside the run sandbox.
|
||||
- `200` returns `SandboxServiceListResponse`.
|
||||
- `404` returns `ErrorResponse` for missing run.
|
||||
- `409` returns `ErrorResponse` when the run has no active sandbox or service discovery fails.
|
||||
- [x] Add shared Rust types in `fabro-types`:
|
||||
- `SandboxService { port: u16, addresses: Vec<String>, processes: Vec<String>, preview_supported: bool }`
|
||||
- `SandboxServiceListResponse { data: Vec<SandboxService> }`
|
||||
- [x] Re-export the shared types from `fabro-types/src/lib.rs`.
|
||||
- [x] Reuse the shared types from `fabro-api` with `with_replacement(...)` entries in `build.rs`.
|
||||
- [x] Add `fabro-api/tests/sandbox_services_round_trip.rs` proving:
|
||||
- `fabro_api::types::SandboxService` is the same type as `fabro_types::SandboxService`.
|
||||
- JSON shape matches the OpenAPI contract.
|
||||
- Minimal response with an empty `data` array deserializes.
|
||||
|
||||
### Task 2: Implement Service Discovery in the Server
|
||||
|
||||
- [x] Add a route in `lib/crates/fabro-server/src/server/handler/sandbox.rs`:
|
||||
- `.route("/runs/{id}/sandbox/services", get(list_sandbox_services))`
|
||||
- [x] Implement `list_sandbox_services` with the same auth and run-id parsing style as `list_sandbox_files`.
|
||||
- [x] Use `reconnect_run_sandbox(&state, &id).await` so the sandbox is reconnected and started before service discovery.
|
||||
- [x] Execute `ss -H -ltnp` with:
|
||||
- timeout `5_000`.
|
||||
- no custom working directory.
|
||||
- no custom environment variables.
|
||||
- no cancellation token.
|
||||
- [x] If `exec_command` returns an error, return `409` with the cause chain.
|
||||
- [x] If the command exits non-zero, return `409` with stderr when present, otherwise stdout, otherwise `ss -H -ltnp failed`.
|
||||
- [x] Parse only stdout on success.
|
||||
- [x] Determine provider from the loaded sandbox record, not from parsed command output.
|
||||
- [x] Sort response rows by ascending port.
|
||||
|
||||
### Task 3: Add a Focused Parser
|
||||
|
||||
- [x] Add private parser helpers in `sandbox.rs`, near the route handler or in a small private module inside the file:
|
||||
- `parse_ss_listening_services(output: &str, provider: &str) -> Vec<SandboxService>`
|
||||
- `preview_supported(provider: &str, port: u16) -> bool`
|
||||
- [x] Parser rules:
|
||||
- Input lines are from `ss -H -ltnp`.
|
||||
- Ignore blank lines and malformed lines.
|
||||
- The local address field is the fourth whitespace-delimited field for standard `ss -H -ltnp` output.
|
||||
- Extract the port from the last colon-separated segment.
|
||||
- Handle IPv4, IPv6 bracketed addresses, wildcard binds, and loopback binds.
|
||||
- Keep the full local address string in `addresses`.
|
||||
- Keep the process field and any remaining trailing text in `processes` when present.
|
||||
- Group duplicate rows by port, deduplicating `addresses` and `processes`.
|
||||
- [x] Parser tests should cover:
|
||||
- `127.0.0.1:3000`
|
||||
- `0.0.0.0:5173`
|
||||
- `[::]:8080`
|
||||
- `[::1]:2500`
|
||||
- malformed and non-numeric ports ignored.
|
||||
- same port with multiple addresses grouped.
|
||||
- Daytona `2500` is not previewable.
|
||||
- Daytona `3000` and `9999` are previewable.
|
||||
- Docker `3000` is not previewable.
|
||||
|
||||
### Task 4: Demo and Auth Coverage
|
||||
|
||||
- [x] Add `/runs/{id}/sandbox/services` to `demo_routes`.
|
||||
- [x] Add `list_sandbox_services_stub` returning at least:
|
||||
- port `3000`, preview-supported `true`.
|
||||
- port `2500`, preview-supported `false`.
|
||||
- [x] Add the route to the server user-only route auth test so worker tokens cannot call it.
|
||||
- [x] Add a server test for non-zero `ss` output if an existing fake sandbox setup can cover it cleanly; otherwise keep the failure behavior covered by parser/unit tests and the handler implementation review.
|
||||
|
||||
### Task 5: Generate and Verify
|
||||
|
||||
- [x] Run `cargo build -p fabro-api`.
|
||||
- [x] Run `cd lib/packages/fabro-api-client && bun run generate`.
|
||||
- [x] Run `cargo nextest run -p fabro-api`.
|
||||
- [x] Run `cargo nextest run -p fabro-server`.
|
||||
- [x] Run `cargo +nightly-2026-04-14 fmt --check --all`.
|
||||
- [x] Run `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `GET /api/v1/runs/{id}/sandbox/services` returns all parsed listening TCP ports.
|
||||
- Preview support is true only for Daytona ports `3000..=9999`.
|
||||
- The sandbox command performs no filtering beyond `ss -H -ltnp`.
|
||||
- Non-Daytona providers can list services but never mark rows previewable.
|
||||
- Missing `ss` or a failing `ss` command returns a clear `409`.
|
||||
- Generated Rust and TypeScript clients expose the new endpoint and response types.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The intended preview range is `3000..=9999`.
|
||||
- `ss` is available often enough for v1; fallback commands are deferred.
|
||||
- Process names from `ss` are useful display hints only and are not parsed into structured PID/name fields in this phase.
|
||||
|
|
@ -385,6 +385,12 @@ fn main() {
|
|||
("DirtyStatus", "fabro_types::DirtyStatus", &[]),
|
||||
("GitContext", "fabro_types::GitContext", &[]),
|
||||
("SandboxDetails", "fabro_types::SandboxDetails", &[]),
|
||||
("SandboxService", "fabro_types::SandboxService", &[]),
|
||||
(
|
||||
"SandboxServiceListResponse",
|
||||
"fabro_types::SandboxServiceListResponse",
|
||||
&[],
|
||||
),
|
||||
("SandboxState", "fabro_types::SandboxState", &[]),
|
||||
("SandboxResources", "fabro_types::SandboxResources", &[]),
|
||||
("SandboxTimestamps", "fabro_types::SandboxTimestamps", &[]),
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ pub mod types {
|
|||
EventEnvelope, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord,
|
||||
PendingInterviewRecord, PreRunPushOutcome, Principal, QuestionType, RepositoryReference,
|
||||
RunClientProvenance, RunEvent, RunProjection, RunProvenance, RunServerProvenance,
|
||||
RunSummary, SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps,
|
||||
SecretMetadata, SecretType, ServerSettings, StageCompletion, StageHandler, StageOutcome,
|
||||
StageProjection, StageState, SystemActorKind, UserPrincipal, WorkflowSettings,
|
||||
RunSummary, SandboxDetails, SandboxResources, SandboxService, SandboxServiceListResponse,
|
||||
SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings,
|
||||
StageCompletion, StageHandler, StageOutcome, StageProjection, StageState, SystemActorKind,
|
||||
UserPrincipal, WorkflowSettings,
|
||||
};
|
||||
|
||||
pub use crate::generated::types::*;
|
||||
|
|
|
|||
62
lib/crates/fabro-api/tests/sandbox_services_round_trip.rs
Normal file
62
lib/crates/fabro-api/tests/sandbox_services_round_trip.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::{
|
||||
SandboxService as ApiSandboxService,
|
||||
SandboxServiceListResponse as ApiSandboxServiceListResponse,
|
||||
};
|
||||
use fabro_types::{SandboxService, SandboxServiceListResponse};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn sandbox_services_reuse_domain_types() {
|
||||
assert_same_type::<ApiSandboxService, SandboxService>();
|
||||
assert_same_type::<ApiSandboxServiceListResponse, SandboxServiceListResponse>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_services_json_matches_openapi_shape() {
|
||||
let response = SandboxServiceListResponse {
|
||||
data: vec![SandboxService {
|
||||
port: 3000,
|
||||
addresses: vec!["127.0.0.1:3000".to_string(), "[::]:3000".to_string()],
|
||||
processes: vec![
|
||||
r#"users:(("node",pid=42,fd=23))"#.to_string(),
|
||||
r#"users:(("vite",pid=84,fd=19))"#.to_string(),
|
||||
],
|
||||
preview_supported: true,
|
||||
}],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&response).unwrap(),
|
||||
json!({
|
||||
"data": [{
|
||||
"port": 3000,
|
||||
"addresses": ["127.0.0.1:3000", "[::]:3000"],
|
||||
"processes": [
|
||||
r#"users:(("node",pid=42,fd=23))"#,
|
||||
r#"users:(("vite",pid=84,fd=19))"#,
|
||||
],
|
||||
"preview_supported": true
|
||||
}]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_services_deserializes_empty_response() {
|
||||
let response: SandboxServiceListResponse = serde_json::from_value(json!({ "data": [] }))
|
||||
.expect("empty service response should deserialize");
|
||||
|
||||
assert!(response.data.is_empty());
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,8 @@ use fabro_api::types::{
|
|||
PaginationMeta, RunArtifactListResponse, RunCommit, RunCommitParent, RunCommitParentSha,
|
||||
RunCommitParentShortSha, RunCommitPerson, RunCommitSha, RunCommitShortSha, RunCommitTreeSha,
|
||||
RunCommitsMeta, RunCommitsMetaBaseSha, RunCommitsMetaHeadSha, RunCommitsMetaSource,
|
||||
RunFilesMeta, RunFilesMetaScope, RunFilesMetaSource,
|
||||
RunFilesMeta, RunFilesMetaScope, RunFilesMetaSource, SandboxService,
|
||||
SandboxServiceListResponse,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -400,6 +401,33 @@ pub(crate) async fn list_sandbox_files_stub(
|
|||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn list_sandbox_services_stub(
|
||||
_auth: RequiredUser,
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Path(_id): Path<String>,
|
||||
) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(SandboxServiceListResponse {
|
||||
data: vec![
|
||||
SandboxService {
|
||||
port: 3000,
|
||||
addresses: vec!["0.0.0.0:3000".to_string()],
|
||||
processes: vec![r#"users:(("node",pid=42,fd=23))"#.to_string()],
|
||||
preview_supported: true,
|
||||
},
|
||||
SandboxService {
|
||||
port: 2500,
|
||||
addresses: vec!["127.0.0.1:2500".to_string()],
|
||||
processes: vec![r#"users:(("debug",pid=84,fd=19))"#.to_string()],
|
||||
preview_supported: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_sandbox_file_stub(
|
||||
_auth: RequiredUser,
|
||||
State(_state): State<Arc<AppState>>,
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ pub use fabro_api::types::{
|
|||
RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RewindRequest, RewindResponse,
|
||||
RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals,
|
||||
RunError, RunManifest, RunStage, RunStatusResponse, SandboxDetails, SandboxFileEntry,
|
||||
SandboxFileListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState,
|
||||
StartRunRequest, SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRepairRunIssue,
|
||||
SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, VncPreviewResponse,
|
||||
WriteBlobResponse,
|
||||
SandboxFileListResponse, SandboxService, SandboxServiceListResponse, SshAccessRequest,
|
||||
SshAccessResponse, StageHandler, StageState, StartRunRequest, SubmitAnswerRequest,
|
||||
SystemFeatures, SystemInfoResponse, SystemRepairRunIssue, SystemRepairRunsResponse,
|
||||
SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse,
|
||||
};
|
||||
use fabro_auth::{
|
||||
CredentialSource, VaultCredentialSource, auth_issue_message, parse_credential_secret,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
|
|||
"/runs/{id}/sandbox/files",
|
||||
get(demo::list_sandbox_files_stub),
|
||||
)
|
||||
.route(
|
||||
"/runs/{id}/sandbox/services",
|
||||
get(demo::list_sandbox_services_stub),
|
||||
)
|
||||
.route(
|
||||
"/runs/{id}/sandbox/file",
|
||||
get(demo::get_sandbox_file_stub).put(demo::put_sandbox_file_stub),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -10,14 +11,17 @@ use super::super::{
|
|||
ApiError, AppState, Bytes, DaytonaSandbox, EnvVars, HeaderMap, IntoResponse, Json,
|
||||
NamedTempFile, Path, PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response,
|
||||
Router, RunId, Sandbox, SandboxDetails, SandboxFileEntry, SandboxFileListResponse,
|
||||
SandboxProvider, SshAccessRequest, SshAccessResponse, State, StatusCode, VncPreviewResponse,
|
||||
collect_causes, fs, get, octet_stream_response, parse_run_id_path, post, reconnect_for_run,
|
||||
reject_if_archived, render_with_causes, sandbox_details,
|
||||
SandboxProvider, SandboxService, SandboxServiceListResponse, SshAccessRequest,
|
||||
SshAccessResponse, State, StatusCode, VncPreviewResponse, collect_causes, fs, get,
|
||||
octet_stream_response, parse_run_id_path, post, reconnect_for_run, reject_if_archived,
|
||||
render_with_causes, sandbox_details,
|
||||
};
|
||||
|
||||
const MAX_TERMINAL_CONTROL_BYTES: usize = 4096;
|
||||
const DEFAULT_VNC_NO_VNC_PORT: u16 = 6080;
|
||||
const DEFAULT_VNC_TTL_SECS: i32 = 3600;
|
||||
const LIST_SANDBOX_SERVICES_COMMAND: &str = "ss -H -ltnp";
|
||||
const LIST_SANDBOX_SERVICES_TIMEOUT_MS: u64 = 5_000;
|
||||
// Daytona's signed preview points at the noVNC service root, which serves a
|
||||
// directory listing. Force the iframe to the actual viewer page with
|
||||
// autoconnect+scale so the user lands on the desktop, not a file index.
|
||||
|
|
@ -68,6 +72,7 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
.route("/runs/{id}/terminal", get(run_terminal))
|
||||
.route("/runs/{id}/sandbox", get(retrieve_run_sandbox))
|
||||
.route("/runs/{id}/sandbox/vnc", post(create_sandbox_vnc_preview))
|
||||
.route("/runs/{id}/sandbox/services", get(list_sandbox_services))
|
||||
.route("/runs/{id}/sandbox/files", get(list_sandbox_files))
|
||||
.route(
|
||||
"/runs/{id}/sandbox/file",
|
||||
|
|
@ -526,6 +531,109 @@ async fn list_sandbox_files(
|
|||
}
|
||||
}
|
||||
|
||||
async fn list_sandbox_services(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let record = match load_run_sandbox_record(&state, &id).await {
|
||||
Ok(record) => record,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let provider = record.provider;
|
||||
let sandbox = match reconnect_run_sandbox(&state, &id).await {
|
||||
Ok(sandbox) => sandbox,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let result = match sandbox
|
||||
.exec_command(
|
||||
LIST_SANDBOX_SERVICES_COMMAND,
|
||||
LIST_SANDBOX_SERVICES_TIMEOUT_MS,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response();
|
||||
}
|
||||
};
|
||||
if !result.is_success() {
|
||||
return ApiError::new(
|
||||
StatusCode::CONFLICT,
|
||||
sandbox_service_command_failure_detail(&result),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
Json(SandboxServiceListResponse {
|
||||
data: parse_ss_listening_services(&result.stdout, &provider),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn sandbox_service_command_failure_detail(result: &fabro_sandbox::ExecResult) -> String {
|
||||
let stderr = result.stderr.trim();
|
||||
if !stderr.is_empty() {
|
||||
return stderr.to_string();
|
||||
}
|
||||
let stdout = result.stdout.trim();
|
||||
if !stdout.is_empty() {
|
||||
return stdout.to_string();
|
||||
}
|
||||
format!("{LIST_SANDBOX_SERVICES_COMMAND} failed")
|
||||
}
|
||||
|
||||
fn parse_ss_listening_services(output: &str, provider: &str) -> Vec<SandboxService> {
|
||||
let mut services = BTreeMap::<u16, SandboxService>::new();
|
||||
for line in output
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
let fields = line.split_whitespace().collect::<Vec<_>>();
|
||||
let Some(address) = fields.get(3).copied() else {
|
||||
continue;
|
||||
};
|
||||
let Some(port) = parse_ss_local_port(address) else {
|
||||
continue;
|
||||
};
|
||||
let process = (fields.len() > 5).then(|| fields[5..].join(" "));
|
||||
let service = services.entry(port).or_insert_with(|| SandboxService {
|
||||
port,
|
||||
addresses: Vec::new(),
|
||||
processes: Vec::new(),
|
||||
preview_supported: preview_supported(provider, port),
|
||||
});
|
||||
push_unique(&mut service.addresses, address.to_string());
|
||||
if let Some(process) = process {
|
||||
push_unique(&mut service.processes, process);
|
||||
}
|
||||
}
|
||||
services.into_values().collect()
|
||||
}
|
||||
|
||||
fn parse_ss_local_port(address: &str) -> Option<u16> {
|
||||
let port = address.rsplit_once(':')?.1.parse::<u16>().ok()?;
|
||||
(port > 0).then_some(port)
|
||||
}
|
||||
|
||||
fn preview_supported(provider: &str, port: u16) -> bool {
|
||||
provider == SandboxProvider::Daytona.to_string() && (3000..=9999).contains(&port)
|
||||
}
|
||||
|
||||
fn push_unique(values: &mut Vec<String>, value: String) {
|
||||
if !values.contains(&value) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_sandbox_file(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
|
@ -747,6 +855,118 @@ mod tests {
|
|||
assert!(!origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ss_parser_extracts_addresses_processes_and_preview_support() {
|
||||
let services = parse_ss_listening_services(
|
||||
r#"
|
||||
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
|
||||
LISTEN 0 4096 0.0.0.0:5173 0.0.0.0:* users:(("vite",pid=84,fd=19))
|
||||
LISTEN 0 4096 [::]:8080 [::]:* users:(("server",pid=126,fd=9))
|
||||
LISTEN 0 4096 [::1]:2500 [::]:* users:(("debug",pid=168,fd=7))
|
||||
"#,
|
||||
"daytona",
|
||||
);
|
||||
|
||||
assert_eq!(services.len(), 4);
|
||||
assert_eq!(services[0].port, 2500);
|
||||
assert_eq!(services[0].addresses, vec!["[::1]:2500"]);
|
||||
assert_eq!(services[0].processes, vec![
|
||||
r#"users:(("debug",pid=168,fd=7))"#
|
||||
]);
|
||||
assert!(!services[0].preview_supported);
|
||||
assert_eq!(services[1].port, 3000);
|
||||
assert_eq!(services[1].addresses, vec!["127.0.0.1:3000"]);
|
||||
assert_eq!(services[1].processes, vec![
|
||||
r#"users:(("node",pid=42,fd=23))"#
|
||||
]);
|
||||
assert!(services[1].preview_supported);
|
||||
assert_eq!(services[2].port, 5173);
|
||||
assert_eq!(services[2].addresses, vec!["0.0.0.0:5173"]);
|
||||
assert!(services[2].preview_supported);
|
||||
assert_eq!(services[3].port, 8080);
|
||||
assert_eq!(services[3].addresses, vec!["[::]:8080"]);
|
||||
assert!(services[3].preview_supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ss_parser_ignores_malformed_and_non_numeric_ports() {
|
||||
let services = parse_ss_listening_services(
|
||||
r#"
|
||||
LISTEN 0 4096 127.0.0.1:not-a-port 0.0.0.0:* users:(("node",pid=42,fd=23))
|
||||
LISTEN 0 4096 missing-peer
|
||||
not enough fields
|
||||
LISTEN 0 4096 127.0.0.1:0 0.0.0.0:* users:(("zero",pid=1,fd=2))
|
||||
LISTEN 0 4096 127.0.0.1:65536 0.0.0.0:* users:(("large",pid=1,fd=2))
|
||||
"#,
|
||||
"daytona",
|
||||
);
|
||||
|
||||
assert!(services.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ss_parser_groups_duplicate_ports_and_deduplicates_values() {
|
||||
let services = parse_ss_listening_services(
|
||||
r#"
|
||||
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
|
||||
LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
|
||||
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
|
||||
LISTEN 0 4096 [::]:3000 [::]:* users:(("vite",pid=84,fd=19))
|
||||
"#,
|
||||
"daytona",
|
||||
);
|
||||
|
||||
assert_eq!(services, vec![SandboxService {
|
||||
port: 3000,
|
||||
addresses: vec![
|
||||
"127.0.0.1:3000".to_string(),
|
||||
"0.0.0.0:3000".to_string(),
|
||||
"[::]:3000".to_string(),
|
||||
],
|
||||
processes: vec![
|
||||
r#"users:(("node",pid=42,fd=23))"#.to_string(),
|
||||
r#"users:(("vite",pid=84,fd=19))"#.to_string(),
|
||||
],
|
||||
preview_supported: true,
|
||||
}]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_support_is_daytona_only_for_documented_range() {
|
||||
assert!(!preview_supported("daytona", 2500));
|
||||
assert!(preview_supported("daytona", 3000));
|
||||
assert!(preview_supported("daytona", 9999));
|
||||
assert!(!preview_supported("daytona", 10000));
|
||||
assert!(!preview_supported("docker", 3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_service_command_failure_prefers_stderr_then_stdout() {
|
||||
let mut result = fabro_sandbox::ExecResult {
|
||||
stdout: "stdout detail".to_string(),
|
||||
stderr: "stderr detail".to_string(),
|
||||
exit_code: Some(127),
|
||||
termination: fabro_types::CommandTermination::Exited,
|
||||
duration_ms: 10,
|
||||
};
|
||||
assert_eq!(
|
||||
sandbox_service_command_failure_detail(&result),
|
||||
"stderr detail"
|
||||
);
|
||||
|
||||
result.stderr.clear();
|
||||
assert_eq!(
|
||||
sandbox_service_command_failure_detail(&result),
|
||||
"stdout detail"
|
||||
);
|
||||
|
||||
result.stdout.clear();
|
||||
assert_eq!(
|
||||
sandbox_service_command_failure_detail(&result),
|
||||
"ss -H -ltnp failed"
|
||||
);
|
||||
}
|
||||
|
||||
struct FakeVncSandbox {
|
||||
start_error: Option<&'static str>,
|
||||
signed_url_error: Option<&'static str>,
|
||||
|
|
|
|||
|
|
@ -6321,6 +6321,7 @@ async fn worker_token_is_rejected_on_user_only_routes() {
|
|||
(Method::POST, format!("/runs/{run_id}/preview")),
|
||||
(Method::POST, format!("/runs/{run_id}/ssh")),
|
||||
(Method::GET, format!("/runs/{run_id}/sandbox/files")),
|
||||
(Method::GET, format!("/runs/{run_id}/sandbox/services")),
|
||||
(Method::GET, format!("/runs/{run_id}/sandbox/file")),
|
||||
(Method::PUT, format!("/runs/{run_id}/sandbox/file")),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub mod run_summary;
|
|||
pub mod run_title;
|
||||
pub mod sandbox_details;
|
||||
pub mod sandbox_record;
|
||||
pub mod sandbox_services;
|
||||
pub mod secret;
|
||||
pub mod settings;
|
||||
pub mod stage_completion;
|
||||
|
|
@ -80,6 +81,7 @@ pub use run_summary::RunSummary;
|
|||
pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title};
|
||||
pub use sandbox_details::{SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps};
|
||||
pub use sandbox_record::SandboxRecord;
|
||||
pub use sandbox_services::{SandboxService, SandboxServiceListResponse};
|
||||
pub use secret::{SecretMetadata, SecretType};
|
||||
pub use stage_completion::StageCompletion;
|
||||
pub use stage_handler::StageHandler;
|
||||
|
|
|
|||
12
lib/crates/fabro-types/src/sandbox_services.rs
Normal file
12
lib/crates/fabro-types/src/sandbox_services.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SandboxService {
|
||||
pub port: u16,
|
||||
pub addresses: Vec<String>,
|
||||
pub processes: Vec<String>,
|
||||
pub preview_supported: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SandboxServiceListResponse {
|
||||
pub data: Vec<SandboxService>,
|
||||
}
|
||||
|
|
@ -290,6 +290,8 @@ models/sandbox-details.ts
|
|||
models/sandbox-file-entry.ts
|
||||
models/sandbox-file-list-response.ts
|
||||
models/sandbox-resources.ts
|
||||
models/sandbox-service-list-response.ts
|
||||
models/sandbox-service.ts
|
||||
models/sandbox-state.ts
|
||||
models/sandbox-timestamps.ts
|
||||
models/save-query-request.ts
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import type { SandboxDetails } from '../models';
|
|||
// @ts-ignore
|
||||
import type { SandboxFileListResponse } from '../models';
|
||||
// @ts-ignore
|
||||
import type { SandboxServiceListResponse } from '../models';
|
||||
// @ts-ignore
|
||||
import type { SshAccessRequest } from '../models';
|
||||
// @ts-ignore
|
||||
import type { SshAccessResponse } from '../models';
|
||||
|
|
@ -367,6 +369,46 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf
|
|||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Lists listening TCP services discovered inside the run sandbox.
|
||||
* @summary List Sandbox Services
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listSandboxServices: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('listSandboxServices', 'id', id)
|
||||
const localVarPath = `/api/v1/runs/{id}/sandbox/services`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Uploads a file into the run\'s sandbox environment.
|
||||
* @summary Upload Sandbox File
|
||||
|
|
@ -660,6 +702,19 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) {
|
|||
const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.listSandboxFiles']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Lists listening TCP services discovered inside the run sandbox.
|
||||
* @summary List Sandbox Services
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async listSandboxServices(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SandboxServiceListResponse>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listSandboxServices(id, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.listSandboxServices']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Uploads a file into the run\'s sandbox environment.
|
||||
* @summary Upload Sandbox File
|
||||
|
|
@ -803,6 +858,16 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration,
|
|||
listSandboxFiles(id: string, path: string, depth?: number, options?: RawAxiosRequestConfig): AxiosPromise<SandboxFileListResponse> {
|
||||
return localVarFp.listSandboxFiles(id, path, depth, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Lists listening TCP services discovered inside the run sandbox.
|
||||
* @summary List Sandbox Services
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listSandboxServices(id: string, options?: RawAxiosRequestConfig): AxiosPromise<SandboxServiceListResponse> {
|
||||
return localVarFp.listSandboxServices(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Uploads a file into the run\'s sandbox environment.
|
||||
* @summary Upload Sandbox File
|
||||
|
|
@ -939,6 +1004,17 @@ export class HumanInTheLoopApi extends BaseAPI {
|
|||
return HumanInTheLoopApiFp(this.configuration).listSandboxFiles(id, path, depth, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists listening TCP services discovered inside the run sandbox.
|
||||
* @summary List Sandbox Services
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public listSandboxServices(id: string, options?: RawAxiosRequestConfig) {
|
||||
return HumanInTheLoopApiFp(this.configuration).listSandboxServices(id, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file into the run\'s sandbox environment.
|
||||
* @summary Upload Sandbox File
|
||||
|
|
|
|||
|
|
@ -267,6 +267,8 @@ export * from './sandbox-details';
|
|||
export * from './sandbox-file-entry';
|
||||
export * from './sandbox-file-list-response';
|
||||
export * from './sandbox-resources';
|
||||
export * from './sandbox-service';
|
||||
export * from './sandbox-service-list-response';
|
||||
export * from './sandbox-state';
|
||||
export * from './sandbox-timestamps';
|
||||
export * from './save-query-request';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SandboxService } from './sandbox-service';
|
||||
|
||||
/**
|
||||
* Non-paginated list of listening TCP services in a run sandbox.
|
||||
*/
|
||||
export interface SandboxServiceListResponse {
|
||||
'data': Array<SandboxService>;
|
||||
}
|
||||
|
||||
38
lib/packages/fabro-api-client/src/models/sandbox-service.ts
Normal file
38
lib/packages/fabro-api-client/src/models/sandbox-service.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* A listening TCP service discovered inside a run sandbox.
|
||||
*/
|
||||
export interface SandboxService {
|
||||
/**
|
||||
* Listening TCP port.
|
||||
*/
|
||||
'port': number;
|
||||
/**
|
||||
* Local bind addresses reported by `ss`.
|
||||
*/
|
||||
'addresses': Array<string>;
|
||||
/**
|
||||
* Visible process summaries reported by `ss`.
|
||||
*/
|
||||
'processes': Array<string>;
|
||||
/**
|
||||
* Whether the provider supports an external preview URL for this port.
|
||||
*/
|
||||
'preview_supported': boolean;
|
||||
}
|
||||
|
||||
Loading…
Add table
Reference in a new issue