Add workspace service lifecycle with typed readiness
Make persistent workspaces capable of running long-lived background processes instead of forcing everything through one-shot exec calls. Add workspace service start/list/status/logs/stop across the CLI, Python SDK, and MCP server, with multiple named services per workspace, typed readiness probes (file, tcp, http, and command), and aggregate service counts on workspace status. Keep service state and logs outside /workspace so diff and export semantics stay workspace-scoped, and extend the guest agent plus backends to persist service records and logs across separate calls. Update the 2.7.0 docs, examples, changelog, and roadmap milestone to reflect the shipped surface. Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke for workspace create, two service starts, list/status/logs, diff unaffected, stop, and delete.
This commit is contained in:
parent
84a7e18d4d
commit
f504f0a331
28 changed files with 4098 additions and 124 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
||||
|
||||
## 2.7.0
|
||||
|
||||
- Added first-class workspace services across the CLI, Python SDK, and MCP server with
|
||||
`pyro workspace service *`, `Pyro.start_service()` / `list_services()` / `status_service()` /
|
||||
`logs_service()` / `stop_service()`, and the matching `service_*` MCP tools.
|
||||
- Added typed readiness probes for workspace services with file, TCP, HTTP, and command checks so
|
||||
long-running processes can be started and inspected without relying on shell-fragile flows.
|
||||
- Kept service state and logs outside `/workspace`, and surfaced aggregate service counts from
|
||||
`workspace status` without polluting workspace diff or export semantics.
|
||||
|
||||
## 2.6.0
|
||||
|
||||
- Added explicit host-out workspace operations across the CLI, Python SDK, and MCP server with
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -20,7 +20,7 @@ It exposes the same runtime in three public forms:
|
|||
- First run transcript: [docs/first-run.md](docs/first-run.md)
|
||||
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
|
||||
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
||||
- What's new in 2.6.0: [CHANGELOG.md#260](CHANGELOG.md#260)
|
||||
- What's new in 2.7.0: [CHANGELOG.md#270](CHANGELOG.md#270)
|
||||
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
||||
- Integration targets: [docs/integrations.md](docs/integrations.md)
|
||||
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
||||
|
|
@ -57,7 +57,7 @@ What success looks like:
|
|||
```bash
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
Catalog version: 2.6.0
|
||||
Catalog version: 2.7.0
|
||||
...
|
||||
[pull] phase=install environment=debian:12
|
||||
[pull] phase=ready environment=debian:12
|
||||
|
|
@ -81,6 +81,7 @@ After the quickstart works:
|
|||
- diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID`
|
||||
- export a changed file or directory with `uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
|
||||
- open a persistent interactive shell with `uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID`
|
||||
- start long-running workspace services with `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'`
|
||||
- move to Python or MCP via [docs/integrations.md](docs/integrations.md)
|
||||
|
||||
## Supported Hosts
|
||||
|
|
@ -134,7 +135,7 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 2.6.0
|
||||
Catalog version: 2.7.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
|
|
@ -218,6 +219,13 @@ pyro workspace shell open WORKSPACE_ID
|
|||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
|
||||
pyro workspace service list WORKSPACE_ID
|
||||
pyro workspace service status WORKSPACE_ID web
|
||||
pyro workspace service logs WORKSPACE_ID web --tail-lines 50
|
||||
pyro workspace service stop WORKSPACE_ID web
|
||||
pyro workspace service stop WORKSPACE_ID worker
|
||||
pyro workspace logs WORKSPACE_ID
|
||||
pyro workspace delete WORKSPACE_ID
|
||||
```
|
||||
|
|
@ -226,12 +234,16 @@ Persistent workspaces start in `/workspace` and keep command history until you d
|
|||
machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when
|
||||
you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
|
||||
archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import
|
||||
later host-side changes into a started workspace. Sync is non-atomic in `2.6.0`; if it fails
|
||||
later host-side changes into a started workspace. Sync is non-atomic in `2.7.0`; if it fails
|
||||
partway through, delete and recreate the workspace from its seed. Use `pyro workspace diff` to
|
||||
compare the live `/workspace` tree to its immutable create-time baseline, and `pyro workspace export`
|
||||
to copy one changed file or directory back to the host. Use `pyro workspace exec` for one-shot
|
||||
non-interactive commands inside a live workspace, and `pyro workspace shell *` when you need a
|
||||
persistent PTY session that keeps interactive shell state between calls.
|
||||
persistent PTY session that keeps interactive shell state between calls. Use
|
||||
`pyro workspace service *` when the workspace needs one or more long-running background processes.
|
||||
Typed readiness checks prefer `--ready-file`, `--ready-tcp`, or `--ready-http`; keep
|
||||
`--ready-command` as the escape hatch. Service metadata and logs live outside `/workspace`, so the
|
||||
internal service state does not appear in `pyro workspace diff` or `pyro workspace export`.
|
||||
|
||||
## Public Interfaces
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
|
|||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro env list
|
||||
Catalog version: 2.6.0
|
||||
Catalog version: 2.7.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
|
|
@ -75,6 +75,7 @@ $ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes
|
|||
$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
|
||||
$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
|
||||
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
$ uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
|
|
@ -118,16 +119,52 @@ $ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input '
|
|||
$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID
|
||||
/workspace
|
||||
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
[workspace-service-start] workspace_id=... service=web state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
|
||||
[workspace-service-start] workspace_id=... service=worker state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service list WORKSPACE_ID
|
||||
Workspace: ...
|
||||
Services: 2 total, 2 running
|
||||
- web [running] cwd=/workspace readiness=file
|
||||
- worker [running] cwd=/workspace readiness=file
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service status WORKSPACE_ID web
|
||||
Workspace: ...
|
||||
Service: web
|
||||
State: running
|
||||
Command: sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
Cwd: /workspace
|
||||
Readiness: file /workspace/.web-ready
|
||||
Execution mode: guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service logs WORKSPACE_ID web --tail-lines 50
|
||||
Workspace: ...
|
||||
Service: web
|
||||
State: running
|
||||
...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID web
|
||||
[workspace-service-stop] workspace_id=... service=web state=stopped execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID worker
|
||||
[workspace-service-stop] workspace_id=... service=worker state=stopped execution_mode=guest_vsock
|
||||
```
|
||||
|
||||
Use `--seed-path` when the workspace should start from a host directory or a local
|
||||
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use
|
||||
`pyro workspace sync push` when you need to import later host-side changes into a started
|
||||
workspace. Sync is non-atomic in `2.6.0`; if it fails partway through, delete and recreate the
|
||||
workspace. Sync is non-atomic in `2.7.0`; if it fails partway through, delete and recreate the
|
||||
workspace. Use `pyro workspace diff` to compare the current `/workspace` tree to its immutable
|
||||
create-time baseline, and `pyro workspace export` to copy one changed file or directory back to
|
||||
the host. Use `pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you
|
||||
need a persistent interactive PTY session in that same workspace.
|
||||
need a persistent interactive PTY session in that same workspace. Use `pyro workspace service *`
|
||||
when the workspace needs long-running background processes with typed readiness checks. Internal
|
||||
service state and logs stay outside `/workspace`, so service runtime data does not appear in
|
||||
workspace diff or export results.
|
||||
|
||||
Example output:
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 2.6.0
|
||||
Catalog version: 2.7.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
|
|
@ -179,6 +179,7 @@ After the CLI path works, you can move on to:
|
|||
- baseline diff: `pyro workspace diff WORKSPACE_ID`
|
||||
- host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
|
||||
- interactive shells: `pyro workspace shell open WORKSPACE_ID`
|
||||
- long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'`
|
||||
- MCP: `pyro mcp serve`
|
||||
- Python SDK: `from pyro_mcp import Pyro`
|
||||
- Demos: `pyro demo` or `pyro demo --network`
|
||||
|
|
@ -197,6 +198,13 @@ pyro workspace shell open WORKSPACE_ID
|
|||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
|
||||
pyro workspace service list WORKSPACE_ID
|
||||
pyro workspace service status WORKSPACE_ID web
|
||||
pyro workspace service logs WORKSPACE_ID web --tail-lines 50
|
||||
pyro workspace service stop WORKSPACE_ID web
|
||||
pyro workspace service stop WORKSPACE_ID worker
|
||||
pyro workspace logs WORKSPACE_ID
|
||||
pyro workspace delete WORKSPACE_ID
|
||||
```
|
||||
|
|
@ -205,11 +213,14 @@ Workspace commands default to the persistent `/workspace` directory inside the g
|
|||
the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path`
|
||||
when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
|
||||
archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync
|
||||
is non-atomic in `2.6.0`; if it fails partway through, delete and recreate the workspace from its
|
||||
is non-atomic in `2.7.0`; if it fails partway through, delete and recreate the workspace from its
|
||||
seed. Use `pyro workspace diff` to compare the current workspace tree to its immutable create-time
|
||||
baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use
|
||||
`pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you need an
|
||||
interactive PTY that survives across separate calls.
|
||||
interactive PTY that survives across separate calls. Use `pyro workspace service *` when the
|
||||
workspace needs long-running background processes with typed readiness probes. Service metadata and
|
||||
logs stay outside `/workspace`, so the service runtime itself does not show up in workspace diff or
|
||||
export results.
|
||||
|
||||
## Contributor Clone
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ Recommended surface:
|
|||
- `vm_run`
|
||||
- `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state
|
||||
- `workspace_diff` + `workspace_export` when the agent needs explicit baseline comparison or host-out file transfer
|
||||
- `start_service` / `list_services` / `status_service` / `logs_service` / `stop_service` when the agent needs long-running processes inside that workspace
|
||||
- `open_shell` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace
|
||||
|
||||
Canonical example:
|
||||
|
|
@ -69,6 +70,7 @@ Recommended default:
|
|||
- `Pyro.run_in_vm(...)`
|
||||
- `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required
|
||||
- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer
|
||||
- `Pyro.start_service(...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace
|
||||
- `Pyro.open_shell(...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace
|
||||
|
||||
Lifecycle note:
|
||||
|
|
@ -83,6 +85,8 @@ Lifecycle note:
|
|||
- use `diff_workspace(...)` when the agent needs a structured comparison against the immutable
|
||||
create-time baseline
|
||||
- use `export_workspace(...)` when the agent needs one file or directory copied back to the host
|
||||
- use `start_service(...)` when the agent needs long-running processes and typed readiness inside
|
||||
one workspace
|
||||
- use `open_shell(...)` when the agent needs interactive shell state instead of one-shot execs
|
||||
|
||||
Examples:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ Top-level commands:
|
|||
- `pyro workspace exec`
|
||||
- `pyro workspace export`
|
||||
- `pyro workspace diff`
|
||||
- `pyro workspace service start`
|
||||
- `pyro workspace service list`
|
||||
- `pyro workspace service status`
|
||||
- `pyro workspace service logs`
|
||||
- `pyro workspace service stop`
|
||||
- `pyro workspace shell open`
|
||||
- `pyro workspace shell read`
|
||||
- `pyro workspace shell write`
|
||||
|
|
@ -58,10 +63,12 @@ Behavioral guarantees:
|
|||
- `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace.
|
||||
- `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host.
|
||||
- `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline.
|
||||
- `pyro workspace service *` manages long-running named services inside one started workspace with typed readiness probes.
|
||||
- `pyro workspace exec` runs in the persistent `/workspace` for that workspace and does not auto-clean.
|
||||
- `pyro workspace shell *` manages persistent PTY sessions inside a started workspace.
|
||||
- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`.
|
||||
- Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized.
|
||||
- `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields.
|
||||
|
||||
## Python SDK Contract
|
||||
|
||||
|
|
@ -82,6 +89,11 @@ Supported public entrypoints:
|
|||
- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
|
||||
- `Pyro.export_workspace(workspace_id, path, *, output_path)`
|
||||
- `Pyro.diff_workspace(workspace_id)`
|
||||
- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)`
|
||||
- `Pyro.list_services(workspace_id)`
|
||||
- `Pyro.status_service(workspace_id, service_name)`
|
||||
- `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
|
||||
- `Pyro.stop_service(workspace_id, service_name)`
|
||||
- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)`
|
||||
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
|
||||
- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)`
|
||||
|
|
@ -112,6 +124,11 @@ Stable public method names:
|
|||
- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
|
||||
- `export_workspace(workspace_id, path, *, output_path)`
|
||||
- `diff_workspace(workspace_id)`
|
||||
- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)`
|
||||
- `list_services(workspace_id)`
|
||||
- `status_service(workspace_id, service_name)`
|
||||
- `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
|
||||
- `stop_service(workspace_id, service_name)`
|
||||
- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)`
|
||||
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
|
||||
- `write_shell(workspace_id, shell_id, *, input, append_newline=True)`
|
||||
|
|
@ -140,6 +157,8 @@ Behavioral defaults:
|
|||
- `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace.
|
||||
- `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path.
|
||||
- `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline.
|
||||
- `Pyro.start_service(...)` starts one named long-running process in a started workspace and waits for its typed readiness probe when configured.
|
||||
- `Pyro.list_services(...)`, `Pyro.status_service(...)`, `Pyro.logs_service(...)`, and `Pyro.stop_service(...)` manage those persisted workspace services.
|
||||
- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes.
|
||||
- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive.
|
||||
- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace.
|
||||
|
|
@ -171,6 +190,11 @@ Persistent workspace tools:
|
|||
- `workspace_exec`
|
||||
- `workspace_export`
|
||||
- `workspace_diff`
|
||||
- `service_start`
|
||||
- `service_list`
|
||||
- `service_status`
|
||||
- `service_logs`
|
||||
- `service_stop`
|
||||
- `shell_open`
|
||||
- `shell_read`
|
||||
- `shell_write`
|
||||
|
|
@ -190,6 +214,7 @@ Behavioral defaults:
|
|||
- `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`.
|
||||
- `workspace_export` exports one file or directory from `/workspace` to an explicit host path.
|
||||
- `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline.
|
||||
- `service_start`, `service_list`, `service_status`, `service_logs`, and `service_stop` manage persistent named services inside a started workspace.
|
||||
- `vm_exec` runs one command and auto-cleans that VM after the exec completes.
|
||||
- `workspace_exec` runs one command in a persistent `/workspace` and leaves the workspace alive.
|
||||
- `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace.
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
This roadmap turns the agent-workspace vision into release-sized milestones.
|
||||
|
||||
Current baseline is `2.6.0`:
|
||||
Current baseline is `2.7.0`:
|
||||
|
||||
- workspace persistence exists and the public surface is now workspace-first
|
||||
- host crossing currently covers create-time seeding, later sync push, and explicit export
|
||||
- persistent PTY shell sessions exist alongside one-shot `workspace exec`
|
||||
- immutable create-time baselines now power whole-workspace diff
|
||||
- no service, snapshot, reset, or secrets contract exists yet
|
||||
- multi-service lifecycle exists with typed readiness and aggregate workspace status counts
|
||||
- no snapshot, reset, or secrets contract exists yet
|
||||
|
||||
Locked roadmap decisions:
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ also expected to update:
|
|||
1. [`2.4.0` Workspace Contract Pivot](task-workspace-ga/2.4.0-workspace-contract-pivot.md) - Done
|
||||
2. [`2.5.0` PTY Shell Sessions](task-workspace-ga/2.5.0-pty-shell-sessions.md) - Done
|
||||
3. [`2.6.0` Structured Export And Baseline Diff](task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md) - Done
|
||||
4. [`2.7.0` Service Lifecycle And Typed Readiness](task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md)
|
||||
4. [`2.7.0` Service Lifecycle And Typed Readiness](task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md) - Done
|
||||
5. [`2.8.0` Named Snapshots And Reset](task-workspace-ga/2.8.0-named-snapshots-and-reset.md)
|
||||
6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md)
|
||||
7. [`2.10.0` Network Policy And Host Port Publication](task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# `2.7.0` Service Lifecycle And Typed Readiness
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make app-style workspaces practical by adding first-class services and typed
|
||||
|
|
|
|||
|
|
@ -26,6 +26,19 @@ def main() -> None:
|
|||
exported_path = Path(export_dir, "note.txt")
|
||||
pyro.export_workspace(workspace_id, "note.txt", output_path=exported_path)
|
||||
print(exported_path.read_text(encoding="utf-8"), end="")
|
||||
pyro.start_service(
|
||||
workspace_id,
|
||||
"web",
|
||||
command="touch .web-ready && while true; do sleep 60; done",
|
||||
readiness={"type": "file", "path": ".web-ready"},
|
||||
)
|
||||
services = pyro.list_services(workspace_id)
|
||||
print(f"services={services['count']} running={services['running_count']}")
|
||||
service_status = pyro.status_service(workspace_id, "web")
|
||||
print(f"service_state={service_status['state']} ready_at={service_status['ready_at']}")
|
||||
service_logs = pyro.logs_service(workspace_id, "web", tail_lines=20)
|
||||
print(f"service_stdout_len={len(service_logs['stdout'])}")
|
||||
pyro.stop_service(workspace_id, "web")
|
||||
logs = pyro.logs_workspace(workspace_id)
|
||||
print(f"workspace_id={workspace_id} command_count={logs['count']}")
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "pyro-mcp"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
description = "Ephemeral Firecracker sandboxes with curated environments, persistent workspaces, and MCP tools."
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import fcntl
|
|||
import io
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
|
|
@ -18,6 +19,8 @@ import tempfile
|
|||
import termios
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -25,6 +28,8 @@ PORT = 5005
|
|||
BUFFER_SIZE = 65536
|
||||
WORKSPACE_ROOT = PurePosixPath("/workspace")
|
||||
SHELL_ROOT = Path("/run/pyro-shells")
|
||||
SERVICE_ROOT = Path("/run/pyro-services")
|
||||
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
SHELL_SIGNAL_MAP = {
|
||||
"HUP": signal.SIGHUP,
|
||||
"INT": signal.SIGINT,
|
||||
|
|
@ -105,6 +110,35 @@ def _normalize_shell_cwd(cwd: str) -> tuple[str, Path]:
|
|||
return str(normalized), host_path
|
||||
|
||||
|
||||
def _normalize_service_name(service_name: str) -> str:
|
||||
normalized = service_name.strip()
|
||||
if normalized == "":
|
||||
raise RuntimeError("service_name is required")
|
||||
if SERVICE_NAME_RE.fullmatch(normalized) is None:
|
||||
raise RuntimeError("service_name is invalid")
|
||||
return normalized
|
||||
|
||||
|
||||
def _service_stdout_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.stdout"
|
||||
|
||||
|
||||
def _service_stderr_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.stderr"
|
||||
|
||||
|
||||
def _service_status_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.status"
|
||||
|
||||
|
||||
def _service_runner_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.runner.sh"
|
||||
|
||||
|
||||
def _service_metadata_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.json"
|
||||
|
||||
|
||||
def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
|
||||
target = link_target.strip()
|
||||
if target == "":
|
||||
|
|
@ -286,7 +320,7 @@ class GuestShellSession:
|
|||
self._log_path = SHELL_ROOT / f"{shell_id}.log"
|
||||
self._master_fd: int | None = None
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
master_fd, slave_fd = os.openpty()
|
||||
try:
|
||||
_set_pty_size(slave_fd, rows, cols)
|
||||
env = os.environ.copy()
|
||||
|
|
@ -512,6 +546,268 @@ def _remove_shell(shell_id: str) -> GuestShellSession:
|
|||
raise RuntimeError(f"shell {shell_id!r} does not exist") from exc
|
||||
|
||||
|
||||
def _read_service_metadata(service_name: str) -> dict[str, Any]:
|
||||
metadata_path = _service_metadata_path(service_name)
|
||||
if not metadata_path.exists():
|
||||
raise RuntimeError(f"service {service_name!r} does not exist")
|
||||
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"service record for {service_name!r} is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _write_service_metadata(service_name: str, payload: dict[str, Any]) -> None:
|
||||
_service_metadata_path(service_name).write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _service_exit_code(service_name: str) -> int | None:
|
||||
status_path = _service_status_path(service_name)
|
||||
if not status_path.exists():
|
||||
return None
|
||||
raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
if raw_value == "":
|
||||
return None
|
||||
return int(raw_value)
|
||||
|
||||
|
||||
def _service_pid_running(pid: int | None) -> bool:
|
||||
if pid is None:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _tail_service_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]:
|
||||
if not path.exists():
|
||||
return "", False
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if tail_lines is None:
|
||||
return text, False
|
||||
lines = text.splitlines(keepends=True)
|
||||
if len(lines) <= tail_lines:
|
||||
return text, False
|
||||
return "".join(lines[-tail_lines:]), True
|
||||
|
||||
|
||||
def _stop_service_process(pid: int) -> tuple[bool, bool]:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return False, False
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if not _service_pid_running(pid):
|
||||
return True, False
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.killpg(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return True, False
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if not _service_pid_running(pid):
|
||||
return True, True
|
||||
time.sleep(0.1)
|
||||
return True, True
|
||||
|
||||
|
||||
def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if str(payload.get("state", "stopped")) != "running":
|
||||
return payload
|
||||
pid = payload.get("pid")
|
||||
normalized_pid = None if pid is None else int(pid)
|
||||
if _service_pid_running(normalized_pid):
|
||||
return payload
|
||||
refreshed = dict(payload)
|
||||
refreshed["state"] = "exited"
|
||||
refreshed["ended_at"] = refreshed.get("ended_at") or time.time()
|
||||
refreshed["exit_code"] = _service_exit_code(service_name)
|
||||
_write_service_metadata(service_name, refreshed)
|
||||
return refreshed
|
||||
|
||||
|
||||
def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool:
|
||||
if readiness is None:
|
||||
return True
|
||||
readiness_type = str(readiness["type"])
|
||||
if readiness_type == "file":
|
||||
_, ready_path = _normalize_destination(str(readiness["path"]))
|
||||
return ready_path.exists()
|
||||
if readiness_type == "tcp":
|
||||
host, raw_port = str(readiness["address"]).rsplit(":", 1)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(1)
|
||||
try:
|
||||
sock.connect((host, int(raw_port)))
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
if readiness_type == "http":
|
||||
request = urllib.request.Request(str(readiness["url"]), method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310
|
||||
return 200 <= int(response.status) < 400
|
||||
except (urllib.error.URLError, TimeoutError, ValueError):
|
||||
return False
|
||||
if readiness_type == "command":
|
||||
proc = subprocess.run( # noqa: S603
|
||||
["/bin/sh", "-lc", str(readiness["command"])],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
raise RuntimeError(f"unsupported readiness type: {readiness_type}")
|
||||
|
||||
|
||||
def _start_service(
|
||||
*,
|
||||
service_name: str,
|
||||
command: str,
|
||||
cwd_text: str,
|
||||
readiness: dict[str, Any] | None,
|
||||
ready_timeout_seconds: int,
|
||||
ready_interval_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
|
||||
existing = None
|
||||
metadata_path = _service_metadata_path(normalized_service_name)
|
||||
if metadata_path.exists():
|
||||
existing = _refresh_service_payload(
|
||||
normalized_service_name,
|
||||
_read_service_metadata(normalized_service_name),
|
||||
)
|
||||
if existing is not None and str(existing.get("state", "stopped")) == "running":
|
||||
raise RuntimeError(f"service {normalized_service_name!r} is already running")
|
||||
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
stdout_path = _service_stdout_path(normalized_service_name)
|
||||
stderr_path = _service_stderr_path(normalized_service_name)
|
||||
status_path = _service_status_path(normalized_service_name)
|
||||
runner_path = _service_runner_path(normalized_service_name)
|
||||
stdout_path.write_text("", encoding="utf-8")
|
||||
stderr_path.write_text("", encoding="utf-8")
|
||||
status_path.unlink(missing_ok=True)
|
||||
runner_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"set +e",
|
||||
f"cd {shlex.quote(str(cwd_path))}",
|
||||
(
|
||||
f"/bin/sh -lc {shlex.quote(command)}"
|
||||
f" >> {shlex.quote(str(stdout_path))}"
|
||||
f" 2>> {shlex.quote(str(stderr_path))}"
|
||||
),
|
||||
"status=$?",
|
||||
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
|
||||
"exit \"$status\"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner_path.chmod(0o700)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
[str(runner_path)],
|
||||
cwd=str(cwd_path),
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"service_name": normalized_service_name,
|
||||
"command": command,
|
||||
"cwd": normalized_cwd,
|
||||
"state": "running",
|
||||
"started_at": time.time(),
|
||||
"readiness": readiness,
|
||||
"ready_at": None,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"pid": process.pid,
|
||||
"stop_reason": None,
|
||||
}
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
deadline = time.monotonic() + ready_timeout_seconds
|
||||
while True:
|
||||
payload = _refresh_service_payload(normalized_service_name, payload)
|
||||
if str(payload.get("state", "stopped")) != "running":
|
||||
payload["state"] = "failed"
|
||||
payload["stop_reason"] = "process_exited_before_ready"
|
||||
payload["ended_at"] = payload.get("ended_at") or time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
if _run_readiness_probe(readiness, cwd=cwd_path):
|
||||
payload["ready_at"] = time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
if time.monotonic() >= deadline:
|
||||
_stop_service_process(process.pid)
|
||||
payload = _refresh_service_payload(normalized_service_name, payload)
|
||||
payload["state"] = "failed"
|
||||
payload["stop_reason"] = "readiness_timeout"
|
||||
payload["ended_at"] = payload.get("ended_at") or time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
time.sleep(max(ready_interval_ms, 1) / 1000)
|
||||
|
||||
|
||||
def _status_service(service_name: str) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
return _refresh_service_payload(
|
||||
normalized_service_name,
|
||||
_read_service_metadata(normalized_service_name),
|
||||
)
|
||||
|
||||
|
||||
def _logs_service(service_name: str, *, tail_lines: int | None) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
payload = _status_service(normalized_service_name)
|
||||
stdout, stdout_truncated = _tail_service_text(
|
||||
_service_stdout_path(normalized_service_name),
|
||||
tail_lines=tail_lines,
|
||||
)
|
||||
stderr, stderr_truncated = _tail_service_text(
|
||||
_service_stderr_path(normalized_service_name),
|
||||
tail_lines=tail_lines,
|
||||
)
|
||||
payload.update(
|
||||
{
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"tail_lines": tail_lines,
|
||||
"truncated": stdout_truncated or stderr_truncated,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _stop_service(service_name: str) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
payload = _status_service(normalized_service_name)
|
||||
pid = payload.get("pid")
|
||||
if pid is None:
|
||||
return payload
|
||||
if str(payload.get("state", "stopped")) == "running":
|
||||
_, killed = _stop_service_process(int(pid))
|
||||
payload = _status_service(normalized_service_name)
|
||||
payload["state"] = "stopped"
|
||||
payload["stop_reason"] = "sigkill" if killed else "sigterm"
|
||||
payload["ended_at"] = payload.get("ended_at") or time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
||||
action = str(request.get("action", "exec"))
|
||||
if action == "extract_archive":
|
||||
|
|
@ -564,6 +860,31 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
if shell_id == "":
|
||||
raise RuntimeError("shell_id is required")
|
||||
return _remove_shell(shell_id).close()
|
||||
if action == "start_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
command = str(request.get("command", ""))
|
||||
cwd_text = str(request.get("cwd", "/workspace"))
|
||||
readiness = request.get("readiness")
|
||||
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None
|
||||
return _start_service(
|
||||
service_name=service_name,
|
||||
command=command,
|
||||
cwd_text=cwd_text,
|
||||
readiness=readiness_payload,
|
||||
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
|
||||
ready_interval_ms=int(request.get("ready_interval_ms", 500)),
|
||||
)
|
||||
if action == "status_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
return _status_service(service_name)
|
||||
if action == "logs_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
tail_lines = request.get("tail_lines")
|
||||
normalized_tail_lines = None if tail_lines is None else int(tail_lines)
|
||||
return _logs_service(service_name, tail_lines=normalized_tail_lines)
|
||||
if action == "stop_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
return _stop_service(service_name)
|
||||
command = str(request.get("command", ""))
|
||||
timeout_seconds = int(request.get("timeout_seconds", 30))
|
||||
return _run_command(command, timeout_seconds)
|
||||
|
|
@ -571,6 +892,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
|
||||
def main() -> None:
|
||||
SHELL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
family = getattr(socket, "AF_VSOCK", None)
|
||||
if family is None:
|
||||
raise SystemExit("AF_VSOCK is unavailable")
|
||||
|
|
|
|||
|
|
@ -207,6 +207,50 @@ class Pyro:
|
|||
def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]:
|
||||
return self._manager.close_shell(workspace_id, shell_id)
|
||||
|
||||
def start_service(
|
||||
self,
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
*,
|
||||
command: str,
|
||||
cwd: str = "/workspace",
|
||||
readiness: dict[str, Any] | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.start_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
)
|
||||
|
||||
def list_services(self, workspace_id: str) -> dict[str, Any]:
|
||||
return self._manager.list_services(workspace_id)
|
||||
|
||||
def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]:
|
||||
return self._manager.status_service(workspace_id, service_name)
|
||||
|
||||
def logs_service(
|
||||
self,
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
*,
|
||||
tail_lines: int = 200,
|
||||
all: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.logs_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
tail_lines=None if all else tail_lines,
|
||||
)
|
||||
|
||||
def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]:
|
||||
return self._manager.stop_service(workspace_id, service_name)
|
||||
|
||||
def delete_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
return self._manager.delete_workspace(workspace_id)
|
||||
|
||||
|
|
@ -458,6 +502,69 @@ class Pyro:
|
|||
"""Close a persistent workspace shell."""
|
||||
return self.close_shell(workspace_id, shell_id)
|
||||
|
||||
@server.tool()
|
||||
async def service_start(
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
command: str,
|
||||
cwd: str = "/workspace",
|
||||
ready_file: str | None = None,
|
||||
ready_tcp: str | None = None,
|
||||
ready_http: str | None = None,
|
||||
ready_command: str | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a named long-running service inside a workspace."""
|
||||
readiness: dict[str, Any] | None = None
|
||||
if ready_file is not None:
|
||||
readiness = {"type": "file", "path": ready_file}
|
||||
elif ready_tcp is not None:
|
||||
readiness = {"type": "tcp", "address": ready_tcp}
|
||||
elif ready_http is not None:
|
||||
readiness = {"type": "http", "url": ready_http}
|
||||
elif ready_command is not None:
|
||||
readiness = {"type": "command", "command": ready_command}
|
||||
return self.start_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
async def service_list(workspace_id: str) -> dict[str, Any]:
|
||||
"""List named services in one workspace."""
|
||||
return self.list_services(workspace_id)
|
||||
|
||||
@server.tool()
|
||||
async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]:
|
||||
"""Inspect one named workspace service."""
|
||||
return self.status_service(workspace_id, service_name)
|
||||
|
||||
@server.tool()
|
||||
async def service_logs(
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
tail_lines: int = 200,
|
||||
all: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Read persisted stdout/stderr for one workspace service."""
|
||||
return self.logs_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
tail_lines=tail_lines,
|
||||
all=all,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]:
|
||||
"""Stop one running service in a workspace."""
|
||||
return self.stop_service(workspace_id, service_name)
|
||||
|
||||
@server.tool()
|
||||
async def workspace_delete(workspace_id: str) -> dict[str, Any]:
|
||||
"""Delete a persistent workspace and its backing sandbox."""
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
|||
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
|
||||
from pyro_mcp.vm_manager import (
|
||||
DEFAULT_MEM_MIB,
|
||||
DEFAULT_SERVICE_LOG_TAIL_LINES,
|
||||
DEFAULT_SERVICE_READY_INTERVAL_MS,
|
||||
DEFAULT_SERVICE_READY_TIMEOUT_SECONDS,
|
||||
DEFAULT_VCPU_COUNT,
|
||||
WORKSPACE_GUEST_PATH,
|
||||
WORKSPACE_SHELL_SIGNAL_NAMES,
|
||||
|
|
@ -171,6 +174,11 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
|
|||
f"{int(payload.get('mem_mib', 0))} MiB"
|
||||
)
|
||||
print(f"Command count: {int(payload.get('command_count', 0))}")
|
||||
print(
|
||||
"Services: "
|
||||
f"{int(payload.get('running_service_count', 0))}/"
|
||||
f"{int(payload.get('service_count', 0))} running"
|
||||
)
|
||||
last_command = payload.get("last_command")
|
||||
if isinstance(last_command, dict):
|
||||
print(
|
||||
|
|
@ -304,6 +312,42 @@ def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _print_workspace_service_summary_human(payload: dict[str, Any], *, prefix: str) -> None:
|
||||
print(
|
||||
f"[{prefix}] "
|
||||
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
|
||||
f"service_name={str(payload.get('service_name', 'unknown'))} "
|
||||
f"state={str(payload.get('state', 'unknown'))} "
|
||||
f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} "
|
||||
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _print_workspace_service_list_human(payload: dict[str, Any]) -> None:
|
||||
services = payload.get("services")
|
||||
if not isinstance(services, list) or not services:
|
||||
print("No workspace services found.")
|
||||
return
|
||||
for service in services:
|
||||
if not isinstance(service, dict):
|
||||
continue
|
||||
print(
|
||||
f"{str(service.get('service_name', 'unknown'))} "
|
||||
f"[{str(service.get('state', 'unknown'))}] "
|
||||
f"cwd={str(service.get('cwd', WORKSPACE_GUEST_PATH))}"
|
||||
)
|
||||
|
||||
|
||||
def _print_workspace_service_logs_human(payload: dict[str, Any]) -> None:
|
||||
stdout = str(payload.get("stdout", ""))
|
||||
stderr = str(payload.get("stderr", ""))
|
||||
_write_stream(stdout, stream=sys.stdout)
|
||||
_write_stream(stderr, stream=sys.stderr)
|
||||
_print_workspace_service_summary_human(payload, prefix="workspace-service-logs")
|
||||
|
||||
|
||||
class _HelpFormatter(
|
||||
argparse.RawDescriptionHelpFormatter,
|
||||
argparse.ArgumentDefaultsHelpFormatter,
|
||||
|
|
@ -339,6 +383,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
|
||||
Use `pyro mcp serve` only after the CLI validation path works.
|
||||
"""
|
||||
|
|
@ -549,6 +595,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace logs WORKSPACE_ID
|
||||
"""
|
||||
),
|
||||
|
|
@ -570,6 +618,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace sync push WORKSPACE_ID ./changes
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
|
|
@ -943,6 +993,160 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_parser = workspace_subparsers.add_parser(
|
||||
"service",
|
||||
help="Manage long-running services inside a workspace.",
|
||||
description=(
|
||||
"Start, inspect, and stop named long-running services inside one started workspace."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace service list WORKSPACE_ID
|
||||
pyro workspace service status WORKSPACE_ID app
|
||||
pyro workspace service logs WORKSPACE_ID app --tail-lines 50
|
||||
pyro workspace service stop WORKSPACE_ID app
|
||||
|
||||
Use `--ready-file` by default in the curated Debian environments. `--ready-command`
|
||||
remains available as an escape hatch.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_subparsers = workspace_service_parser.add_subparsers(
|
||||
dest="workspace_service_command",
|
||||
required=True,
|
||||
metavar="SERVICE",
|
||||
)
|
||||
workspace_service_start_parser = workspace_service_subparsers.add_parser(
|
||||
"start",
|
||||
help="Start one named long-running service.",
|
||||
description="Start a named service inside a started workspace with optional readiness.",
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_start_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_start_parser.add_argument("service_name", metavar="SERVICE_NAME")
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--cwd",
|
||||
default=WORKSPACE_GUEST_PATH,
|
||||
help="Service working directory. Relative values resolve inside `/workspace`.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-file",
|
||||
help="Mark the service ready once this workspace path exists.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-tcp",
|
||||
help="Mark the service ready once this HOST:PORT accepts guest-local TCP connections.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-http",
|
||||
help="Mark the service ready once this guest-local URL returns 2xx or 3xx.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-command",
|
||||
help="Escape hatch readiness probe command. Use typed readiness when possible.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-timeout-seconds",
|
||||
type=int,
|
||||
default=DEFAULT_SERVICE_READY_TIMEOUT_SECONDS,
|
||||
help="Maximum time to wait for readiness before failing the service start.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-interval-ms",
|
||||
type=int,
|
||||
default=DEFAULT_SERVICE_READY_INTERVAL_MS,
|
||||
help="Polling interval between readiness checks.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"command_args",
|
||||
nargs="*",
|
||||
metavar="ARG",
|
||||
help="Service command and arguments. Prefix them with `--`.",
|
||||
)
|
||||
workspace_service_list_parser = workspace_service_subparsers.add_parser(
|
||||
"list",
|
||||
help="List named services in one workspace.",
|
||||
description="List named services and their current states for one workspace.",
|
||||
epilog="Example:\n pyro workspace service list WORKSPACE_ID",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_list_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_status_parser = workspace_service_subparsers.add_parser(
|
||||
"status",
|
||||
help="Inspect one service.",
|
||||
description="Show state and readiness metadata for one named workspace service.",
|
||||
epilog="Example:\n pyro workspace service status WORKSPACE_ID app",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_status_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_status_parser.add_argument("service_name", metavar="SERVICE_NAME")
|
||||
workspace_service_status_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_logs_parser = workspace_service_subparsers.add_parser(
|
||||
"logs",
|
||||
help="Read persisted service stdout and stderr.",
|
||||
description="Read service stdout and stderr without using `workspace logs`.",
|
||||
epilog="Example:\n pyro workspace service logs WORKSPACE_ID app --tail-lines 50",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_logs_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_logs_parser.add_argument("service_name", metavar="SERVICE_NAME")
|
||||
workspace_service_logs_parser.add_argument(
|
||||
"--tail-lines",
|
||||
type=int,
|
||||
default=DEFAULT_SERVICE_LOG_TAIL_LINES,
|
||||
help="Maximum number of trailing lines to return from each service log stream.",
|
||||
)
|
||||
workspace_service_logs_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Return full stdout and stderr instead of tailing them.",
|
||||
)
|
||||
workspace_service_logs_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_stop_parser = workspace_service_subparsers.add_parser(
|
||||
"stop",
|
||||
help="Stop one running service.",
|
||||
description="Stop one named workspace service with TERM then KILL fallback.",
|
||||
epilog="Example:\n pyro workspace service stop WORKSPACE_ID app",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_stop_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_stop_parser.add_argument("service_name", metavar="SERVICE_NAME")
|
||||
workspace_service_stop_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_status_parser = workspace_subparsers.add_parser(
|
||||
"status",
|
||||
help="Inspect one workspace.",
|
||||
|
|
@ -1372,6 +1576,128 @@ def main() -> None:
|
|||
else:
|
||||
_print_workspace_shell_summary_human(payload, prefix="workspace-shell-close")
|
||||
return
|
||||
if args.workspace_command == "service":
|
||||
if args.workspace_service_command == "start":
|
||||
readiness_count = sum(
|
||||
value is not None
|
||||
for value in (
|
||||
args.ready_file,
|
||||
args.ready_tcp,
|
||||
args.ready_http,
|
||||
args.ready_command,
|
||||
)
|
||||
)
|
||||
if readiness_count > 1:
|
||||
error = (
|
||||
"choose at most one of --ready-file, --ready-tcp, "
|
||||
"--ready-http, or --ready-command"
|
||||
)
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": error})
|
||||
else:
|
||||
print(f"[error] {error}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1)
|
||||
readiness: dict[str, Any] | None = None
|
||||
if args.ready_file is not None:
|
||||
readiness = {"type": "file", "path": args.ready_file}
|
||||
elif args.ready_tcp is not None:
|
||||
readiness = {"type": "tcp", "address": args.ready_tcp}
|
||||
elif args.ready_http is not None:
|
||||
readiness = {"type": "http", "url": args.ready_http}
|
||||
elif args.ready_command is not None:
|
||||
readiness = {"type": "command", "command": args.ready_command}
|
||||
command = _require_command(args.command_args)
|
||||
try:
|
||||
payload = pyro.start_service(
|
||||
args.workspace_id,
|
||||
args.service_name,
|
||||
command=command,
|
||||
cwd=args.cwd,
|
||||
readiness=readiness,
|
||||
ready_timeout_seconds=args.ready_timeout_seconds,
|
||||
ready_interval_ms=args.ready_interval_ms,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_summary_human(
|
||||
payload,
|
||||
prefix="workspace-service-start",
|
||||
)
|
||||
return
|
||||
if args.workspace_service_command == "list":
|
||||
try:
|
||||
payload = pyro.list_services(args.workspace_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_list_human(payload)
|
||||
return
|
||||
if args.workspace_service_command == "status":
|
||||
try:
|
||||
payload = pyro.status_service(args.workspace_id, args.service_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_summary_human(
|
||||
payload,
|
||||
prefix="workspace-service-status",
|
||||
)
|
||||
return
|
||||
if args.workspace_service_command == "logs":
|
||||
try:
|
||||
payload = pyro.logs_service(
|
||||
args.workspace_id,
|
||||
args.service_name,
|
||||
tail_lines=args.tail_lines,
|
||||
all=bool(args.all),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_logs_human(payload)
|
||||
return
|
||||
if args.workspace_service_command == "stop":
|
||||
try:
|
||||
payload = pyro.stop_service(args.workspace_id, args.service_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_summary_human(
|
||||
payload,
|
||||
prefix="workspace-service-stop",
|
||||
)
|
||||
return
|
||||
if args.workspace_command == "status":
|
||||
payload = pyro.status_workspace(args.workspace_id)
|
||||
if bool(args.json):
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
|||
"exec",
|
||||
"export",
|
||||
"logs",
|
||||
"service",
|
||||
"shell",
|
||||
"status",
|
||||
"sync",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS = ("list", "logs", "start", "status", "stop")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_SUBCOMMANDS = ("close", "open", "read", "signal", "write")
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS = ("push",)
|
||||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
|
||||
|
|
@ -29,6 +31,20 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
|
|||
)
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS = ("--tail-lines", "--all", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS = (
|
||||
"--cwd",
|
||||
"--ready-file",
|
||||
"--ready-tcp",
|
||||
"--ready-http",
|
||||
"--ready-command",
|
||||
"--ready-timeout-seconds",
|
||||
"--ready-interval-ms",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = ("--cwd", "--cols", "--rows", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
|
||||
|
|
@ -58,6 +74,8 @@ PUBLIC_SDK_METHODS = (
|
|||
"export_workspace",
|
||||
"inspect_environment",
|
||||
"list_environments",
|
||||
"list_services",
|
||||
"logs_service",
|
||||
"logs_workspace",
|
||||
"network_info_vm",
|
||||
"open_shell",
|
||||
|
|
@ -68,14 +86,22 @@ PUBLIC_SDK_METHODS = (
|
|||
"reap_expired",
|
||||
"run_in_vm",
|
||||
"signal_shell",
|
||||
"start_service",
|
||||
"start_vm",
|
||||
"status_service",
|
||||
"status_vm",
|
||||
"status_workspace",
|
||||
"stop_service",
|
||||
"stop_vm",
|
||||
"write_shell",
|
||||
)
|
||||
|
||||
PUBLIC_MCP_TOOLS = (
|
||||
"service_list",
|
||||
"service_logs",
|
||||
"service_start",
|
||||
"service_status",
|
||||
"service_stop",
|
||||
"shell_close",
|
||||
"shell_open",
|
||||
"shell_read",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import fcntl
|
|||
import io
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
|
|
@ -18,6 +19,8 @@ import tempfile
|
|||
import termios
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -25,6 +28,8 @@ PORT = 5005
|
|||
BUFFER_SIZE = 65536
|
||||
WORKSPACE_ROOT = PurePosixPath("/workspace")
|
||||
SHELL_ROOT = Path("/run/pyro-shells")
|
||||
SERVICE_ROOT = Path("/run/pyro-services")
|
||||
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
SHELL_SIGNAL_MAP = {
|
||||
"HUP": signal.SIGHUP,
|
||||
"INT": signal.SIGINT,
|
||||
|
|
@ -105,6 +110,35 @@ def _normalize_shell_cwd(cwd: str) -> tuple[str, Path]:
|
|||
return str(normalized), host_path
|
||||
|
||||
|
||||
def _normalize_service_name(service_name: str) -> str:
|
||||
normalized = service_name.strip()
|
||||
if normalized == "":
|
||||
raise RuntimeError("service_name is required")
|
||||
if SERVICE_NAME_RE.fullmatch(normalized) is None:
|
||||
raise RuntimeError("service_name is invalid")
|
||||
return normalized
|
||||
|
||||
|
||||
def _service_stdout_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.stdout"
|
||||
|
||||
|
||||
def _service_stderr_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.stderr"
|
||||
|
||||
|
||||
def _service_status_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.status"
|
||||
|
||||
|
||||
def _service_runner_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.runner.sh"
|
||||
|
||||
|
||||
def _service_metadata_path(service_name: str) -> Path:
|
||||
return SERVICE_ROOT / f"{service_name}.json"
|
||||
|
||||
|
||||
def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
|
||||
target = link_target.strip()
|
||||
if target == "":
|
||||
|
|
@ -286,7 +320,7 @@ class GuestShellSession:
|
|||
self._log_path = SHELL_ROOT / f"{shell_id}.log"
|
||||
self._master_fd: int | None = None
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
master_fd, slave_fd = os.openpty()
|
||||
try:
|
||||
_set_pty_size(slave_fd, rows, cols)
|
||||
env = os.environ.copy()
|
||||
|
|
@ -512,6 +546,268 @@ def _remove_shell(shell_id: str) -> GuestShellSession:
|
|||
raise RuntimeError(f"shell {shell_id!r} does not exist") from exc
|
||||
|
||||
|
||||
def _read_service_metadata(service_name: str) -> dict[str, Any]:
|
||||
metadata_path = _service_metadata_path(service_name)
|
||||
if not metadata_path.exists():
|
||||
raise RuntimeError(f"service {service_name!r} does not exist")
|
||||
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"service record for {service_name!r} is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _write_service_metadata(service_name: str, payload: dict[str, Any]) -> None:
|
||||
_service_metadata_path(service_name).write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _service_exit_code(service_name: str) -> int | None:
|
||||
status_path = _service_status_path(service_name)
|
||||
if not status_path.exists():
|
||||
return None
|
||||
raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
if raw_value == "":
|
||||
return None
|
||||
return int(raw_value)
|
||||
|
||||
|
||||
def _service_pid_running(pid: int | None) -> bool:
|
||||
if pid is None:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _tail_service_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]:
|
||||
if not path.exists():
|
||||
return "", False
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if tail_lines is None:
|
||||
return text, False
|
||||
lines = text.splitlines(keepends=True)
|
||||
if len(lines) <= tail_lines:
|
||||
return text, False
|
||||
return "".join(lines[-tail_lines:]), True
|
||||
|
||||
|
||||
def _stop_service_process(pid: int) -> tuple[bool, bool]:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return False, False
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if not _service_pid_running(pid):
|
||||
return True, False
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.killpg(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return True, False
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if not _service_pid_running(pid):
|
||||
return True, True
|
||||
time.sleep(0.1)
|
||||
return True, True
|
||||
|
||||
|
||||
def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if str(payload.get("state", "stopped")) != "running":
|
||||
return payload
|
||||
pid = payload.get("pid")
|
||||
normalized_pid = None if pid is None else int(pid)
|
||||
if _service_pid_running(normalized_pid):
|
||||
return payload
|
||||
refreshed = dict(payload)
|
||||
refreshed["state"] = "exited"
|
||||
refreshed["ended_at"] = refreshed.get("ended_at") or time.time()
|
||||
refreshed["exit_code"] = _service_exit_code(service_name)
|
||||
_write_service_metadata(service_name, refreshed)
|
||||
return refreshed
|
||||
|
||||
|
||||
def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool:
|
||||
if readiness is None:
|
||||
return True
|
||||
readiness_type = str(readiness["type"])
|
||||
if readiness_type == "file":
|
||||
_, ready_path = _normalize_destination(str(readiness["path"]))
|
||||
return ready_path.exists()
|
||||
if readiness_type == "tcp":
|
||||
host, raw_port = str(readiness["address"]).rsplit(":", 1)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(1)
|
||||
try:
|
||||
sock.connect((host, int(raw_port)))
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
if readiness_type == "http":
|
||||
request = urllib.request.Request(str(readiness["url"]), method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310
|
||||
return 200 <= int(response.status) < 400
|
||||
except (urllib.error.URLError, TimeoutError, ValueError):
|
||||
return False
|
||||
if readiness_type == "command":
|
||||
proc = subprocess.run( # noqa: S603
|
||||
["/bin/sh", "-lc", str(readiness["command"])],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
raise RuntimeError(f"unsupported readiness type: {readiness_type}")
|
||||
|
||||
|
||||
def _start_service(
|
||||
*,
|
||||
service_name: str,
|
||||
command: str,
|
||||
cwd_text: str,
|
||||
readiness: dict[str, Any] | None,
|
||||
ready_timeout_seconds: int,
|
||||
ready_interval_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
|
||||
existing = None
|
||||
metadata_path = _service_metadata_path(normalized_service_name)
|
||||
if metadata_path.exists():
|
||||
existing = _refresh_service_payload(
|
||||
normalized_service_name,
|
||||
_read_service_metadata(normalized_service_name),
|
||||
)
|
||||
if existing is not None and str(existing.get("state", "stopped")) == "running":
|
||||
raise RuntimeError(f"service {normalized_service_name!r} is already running")
|
||||
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
stdout_path = _service_stdout_path(normalized_service_name)
|
||||
stderr_path = _service_stderr_path(normalized_service_name)
|
||||
status_path = _service_status_path(normalized_service_name)
|
||||
runner_path = _service_runner_path(normalized_service_name)
|
||||
stdout_path.write_text("", encoding="utf-8")
|
||||
stderr_path.write_text("", encoding="utf-8")
|
||||
status_path.unlink(missing_ok=True)
|
||||
runner_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"set +e",
|
||||
f"cd {shlex.quote(str(cwd_path))}",
|
||||
(
|
||||
f"/bin/sh -lc {shlex.quote(command)}"
|
||||
f" >> {shlex.quote(str(stdout_path))}"
|
||||
f" 2>> {shlex.quote(str(stderr_path))}"
|
||||
),
|
||||
"status=$?",
|
||||
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
|
||||
"exit \"$status\"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner_path.chmod(0o700)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
[str(runner_path)],
|
||||
cwd=str(cwd_path),
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"service_name": normalized_service_name,
|
||||
"command": command,
|
||||
"cwd": normalized_cwd,
|
||||
"state": "running",
|
||||
"started_at": time.time(),
|
||||
"readiness": readiness,
|
||||
"ready_at": None,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"pid": process.pid,
|
||||
"stop_reason": None,
|
||||
}
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
deadline = time.monotonic() + ready_timeout_seconds
|
||||
while True:
|
||||
payload = _refresh_service_payload(normalized_service_name, payload)
|
||||
if str(payload.get("state", "stopped")) != "running":
|
||||
payload["state"] = "failed"
|
||||
payload["stop_reason"] = "process_exited_before_ready"
|
||||
payload["ended_at"] = payload.get("ended_at") or time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
if _run_readiness_probe(readiness, cwd=cwd_path):
|
||||
payload["ready_at"] = time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
if time.monotonic() >= deadline:
|
||||
_stop_service_process(process.pid)
|
||||
payload = _refresh_service_payload(normalized_service_name, payload)
|
||||
payload["state"] = "failed"
|
||||
payload["stop_reason"] = "readiness_timeout"
|
||||
payload["ended_at"] = payload.get("ended_at") or time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
time.sleep(max(ready_interval_ms, 1) / 1000)
|
||||
|
||||
|
||||
def _status_service(service_name: str) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
return _refresh_service_payload(
|
||||
normalized_service_name,
|
||||
_read_service_metadata(normalized_service_name),
|
||||
)
|
||||
|
||||
|
||||
def _logs_service(service_name: str, *, tail_lines: int | None) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
payload = _status_service(normalized_service_name)
|
||||
stdout, stdout_truncated = _tail_service_text(
|
||||
_service_stdout_path(normalized_service_name),
|
||||
tail_lines=tail_lines,
|
||||
)
|
||||
stderr, stderr_truncated = _tail_service_text(
|
||||
_service_stderr_path(normalized_service_name),
|
||||
tail_lines=tail_lines,
|
||||
)
|
||||
payload.update(
|
||||
{
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"tail_lines": tail_lines,
|
||||
"truncated": stdout_truncated or stderr_truncated,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _stop_service(service_name: str) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
payload = _status_service(normalized_service_name)
|
||||
pid = payload.get("pid")
|
||||
if pid is None:
|
||||
return payload
|
||||
if str(payload.get("state", "stopped")) == "running":
|
||||
_, killed = _stop_service_process(int(pid))
|
||||
payload = _status_service(normalized_service_name)
|
||||
payload["state"] = "stopped"
|
||||
payload["stop_reason"] = "sigkill" if killed else "sigterm"
|
||||
payload["ended_at"] = payload.get("ended_at") or time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
||||
action = str(request.get("action", "exec"))
|
||||
if action == "extract_archive":
|
||||
|
|
@ -564,6 +860,31 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
if shell_id == "":
|
||||
raise RuntimeError("shell_id is required")
|
||||
return _remove_shell(shell_id).close()
|
||||
if action == "start_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
command = str(request.get("command", ""))
|
||||
cwd_text = str(request.get("cwd", "/workspace"))
|
||||
readiness = request.get("readiness")
|
||||
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None
|
||||
return _start_service(
|
||||
service_name=service_name,
|
||||
command=command,
|
||||
cwd_text=cwd_text,
|
||||
readiness=readiness_payload,
|
||||
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
|
||||
ready_interval_ms=int(request.get("ready_interval_ms", 500)),
|
||||
)
|
||||
if action == "status_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
return _status_service(service_name)
|
||||
if action == "logs_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
tail_lines = request.get("tail_lines")
|
||||
normalized_tail_lines = None if tail_lines is None else int(tail_lines)
|
||||
return _logs_service(service_name, tail_lines=normalized_tail_lines)
|
||||
if action == "stop_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
return _stop_service(service_name)
|
||||
command = str(request.get("command", ""))
|
||||
timeout_seconds = int(request.get("timeout_seconds", 30))
|
||||
return _run_command(command, timeout_seconds)
|
||||
|
|
@ -571,6 +892,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
|
||||
def main() -> None:
|
||||
SHELL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
family = getattr(socket, "AF_VSOCK", None)
|
||||
if family is None:
|
||||
raise SystemExit("AF_VSOCK is unavailable")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
"guest": {
|
||||
"agent": {
|
||||
"path": "guest/pyro_guest_agent.py",
|
||||
"sha256": "4118589ccd8f4ac8200d9cedf25d13ff515d77c28094bbbdb208310247688b40"
|
||||
"sha256": "58dd2e09d05538228540d8c667b1acb42c2e6c579f7883b70d483072570f2499"
|
||||
}
|
||||
},
|
||||
"platform": "linux-x86_64",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||
|
||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "2.6.0"
|
||||
DEFAULT_CATALOG_VERSION = "2.7.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
|
|||
|
|
@ -325,6 +325,102 @@ class VsockExecClient:
|
|||
self._shell_summary_from_payload(payload)
|
||||
return payload
|
||||
|
||||
def start_service(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
service_name: str,
|
||||
command: str,
|
||||
cwd: str,
|
||||
readiness: dict[str, Any] | None,
|
||||
ready_timeout_seconds: int,
|
||||
ready_interval_ms: int,
|
||||
timeout_seconds: int = 60,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "start_service",
|
||||
"service_name": service_name,
|
||||
"command": command,
|
||||
"cwd": cwd,
|
||||
"readiness": readiness,
|
||||
"ready_timeout_seconds": ready_timeout_seconds,
|
||||
"ready_interval_ms": ready_interval_ms,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest service start response must be a JSON object",
|
||||
)
|
||||
|
||||
def status_service(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
service_name: str,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "status_service",
|
||||
"service_name": service_name,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest service status response must be a JSON object",
|
||||
)
|
||||
|
||||
def logs_service(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
service_name: str,
|
||||
tail_lines: int | None,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "logs_service",
|
||||
"service_name": service_name,
|
||||
"tail_lines": tail_lines,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest service logs response must be a JSON object",
|
||||
)
|
||||
|
||||
def stop_service(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
service_name: str,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "stop_service",
|
||||
"service_name": service_name,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest service stop response must be a JSON object",
|
||||
)
|
||||
|
||||
def _request_json(
|
||||
self,
|
||||
guest_cid: int,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,6 @@ from __future__ import annotations
|
|||
import codecs
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import shlex
|
||||
import signal
|
||||
import struct
|
||||
|
|
@ -14,7 +13,7 @@ import termios
|
|||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import IO, Literal
|
||||
|
||||
ShellState = Literal["running", "stopped"]
|
||||
|
||||
|
|
@ -59,41 +58,60 @@ class LocalShellSession:
|
|||
self._lock = threading.RLock()
|
||||
self._output = ""
|
||||
self._master_fd: int | None = None
|
||||
self._input_pipe: IO[bytes] | None = None
|
||||
self._output_pipe: IO[bytes] | None = None
|
||||
self._reader: threading.Thread | None = None
|
||||
self._waiter: threading.Thread | None = None
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"TERM": env.get("TERM", "xterm-256color"),
|
||||
"PS1": "pyro$ ",
|
||||
"PROMPT_COMMAND": "",
|
||||
}
|
||||
)
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
process: subprocess.Popen[bytes]
|
||||
try:
|
||||
_set_pty_size(slave_fd, rows, cols)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"TERM": env.get("TERM", "xterm-256color"),
|
||||
"PS1": "pyro$ ",
|
||||
"PROMPT_COMMAND": "",
|
||||
}
|
||||
)
|
||||
master_fd, slave_fd = os.openpty()
|
||||
except OSError:
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
["/bin/bash", "--noprofile", "--norc", "-i"],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
["/bin/bash", "--noprofile", "--norc"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
text=False,
|
||||
close_fds=True,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
except Exception:
|
||||
os.close(master_fd)
|
||||
raise
|
||||
finally:
|
||||
os.close(slave_fd)
|
||||
self._input_pipe = process.stdin
|
||||
self._output_pipe = process.stdout
|
||||
else:
|
||||
try:
|
||||
_set_pty_size(slave_fd, rows, cols)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
["/bin/bash", "--noprofile", "--norc", "-i"],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
text=False,
|
||||
close_fds=True,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
except Exception:
|
||||
os.close(master_fd)
|
||||
raise
|
||||
finally:
|
||||
os.close(slave_fd)
|
||||
self._master_fd = master_fd
|
||||
|
||||
self._process = process
|
||||
self.pid = process.pid
|
||||
self._master_fd = master_fd
|
||||
self._reader = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._waiter = threading.Thread(target=self._waiter_loop, daemon=True)
|
||||
self._reader.start()
|
||||
|
|
@ -136,11 +154,16 @@ class LocalShellSession:
|
|||
if self.state != "running":
|
||||
raise RuntimeError(f"shell {self.shell_id} is not running")
|
||||
master_fd = self._master_fd
|
||||
if master_fd is None:
|
||||
raise RuntimeError(f"shell {self.shell_id} transport is unavailable")
|
||||
input_pipe = self._input_pipe
|
||||
payload = text + ("\n" if append_newline else "")
|
||||
try:
|
||||
os.write(master_fd, payload.encode("utf-8"))
|
||||
if master_fd is not None:
|
||||
os.write(master_fd, payload.encode("utf-8"))
|
||||
else:
|
||||
if input_pipe is None:
|
||||
raise RuntimeError(f"shell {self.shell_id} transport is unavailable")
|
||||
input_pipe.write(payload.encode("utf-8"))
|
||||
input_pipe.flush()
|
||||
except OSError as exc:
|
||||
self._refresh_process_state()
|
||||
raise RuntimeError(f"failed to write to shell {self.shell_id}: {exc}") from exc
|
||||
|
|
@ -195,11 +218,17 @@ class LocalShellSession:
|
|||
|
||||
def _reader_loop(self) -> None:
|
||||
master_fd = self._master_fd
|
||||
if master_fd is None:
|
||||
output_pipe = self._output_pipe
|
||||
if master_fd is None and output_pipe is None:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(master_fd, 65536)
|
||||
if master_fd is not None:
|
||||
chunk = os.read(master_fd, 65536)
|
||||
else:
|
||||
if output_pipe is None:
|
||||
break
|
||||
chunk = os.read(output_pipe.fileno(), 65536)
|
||||
except OSError:
|
||||
break
|
||||
if chunk == b"":
|
||||
|
|
@ -234,6 +263,14 @@ class LocalShellSession:
|
|||
with self._lock:
|
||||
master_fd = self._master_fd
|
||||
self._master_fd = None
|
||||
input_pipe = self._input_pipe
|
||||
self._input_pipe = None
|
||||
output_pipe = self._output_pipe
|
||||
self._output_pipe = None
|
||||
if input_pipe is not None:
|
||||
input_pipe.close()
|
||||
if output_pipe is not None:
|
||||
output_pipe.close()
|
||||
if master_fd is None:
|
||||
return
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -58,6 +57,11 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
|||
assert "shell_write" in tool_names
|
||||
assert "shell_signal" in tool_names
|
||||
assert "shell_close" in tool_names
|
||||
assert "service_start" in tool_names
|
||||
assert "service_list" in tool_names
|
||||
assert "service_status" in tool_names
|
||||
assert "service_logs" in tool_names
|
||||
assert "service_stop" in tool_names
|
||||
|
||||
|
||||
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
|
||||
|
|
@ -141,16 +145,16 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
diff_payload = pyro.diff_workspace(workspace_id)
|
||||
export_path = tmp_path / "exported-note.txt"
|
||||
exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path)
|
||||
opened = pyro.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
written = pyro.write_shell(workspace_id, shell_id, input="pwd")
|
||||
read = pyro.read_shell(workspace_id, shell_id)
|
||||
deadline = time.time() + 5
|
||||
while "/workspace" not in str(read["output"]) and time.time() < deadline:
|
||||
read = pyro.read_shell(workspace_id, shell_id, cursor=0)
|
||||
time.sleep(0.05)
|
||||
signaled = pyro.signal_shell(workspace_id, shell_id)
|
||||
closed = pyro.close_shell(workspace_id, shell_id)
|
||||
service = pyro.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
services = pyro.list_services(workspace_id)
|
||||
service_status = pyro.status_service(workspace_id, "app")
|
||||
service_logs = pyro.logs_service(workspace_id, "app", all=True)
|
||||
service_stopped = pyro.stop_service(workspace_id, "app")
|
||||
status = pyro.status_workspace(workspace_id)
|
||||
logs = pyro.logs_workspace(workspace_id)
|
||||
deleted = pyro.delete_workspace(workspace_id)
|
||||
|
|
@ -158,13 +162,15 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
assert executed["stdout"] == "ok\n"
|
||||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||
assert written["input_length"] == 3
|
||||
assert diff_payload["changed"] is True
|
||||
assert exported["output_path"] == str(export_path)
|
||||
assert export_path.read_text(encoding="utf-8") == "ok\n"
|
||||
assert "/workspace" in read["output"]
|
||||
assert signaled["signal"] == "INT"
|
||||
assert closed["closed"] is True
|
||||
assert service["state"] == "running"
|
||||
assert services["count"] == 1
|
||||
assert service_status["state"] == "running"
|
||||
assert service_logs["tail_lines"] is None
|
||||
assert service_stopped["state"] == "stopped"
|
||||
assert status["command_count"] == 1
|
||||
assert status["service_count"] == 1
|
||||
assert logs["count"] == 1
|
||||
assert deleted["deleted"] is True
|
||||
|
|
|
|||
1054
tests/test_cli.py
1054
tests/test_cli.py
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,12 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS,
|
||||
|
|
@ -135,6 +141,37 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS:
|
||||
assert flag in workspace_shell_close_help_text
|
||||
workspace_service_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"service",
|
||||
).format_help()
|
||||
for subcommand_name in PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS:
|
||||
assert subcommand_name in workspace_service_help_text
|
||||
workspace_service_start_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "start"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS:
|
||||
assert flag in workspace_service_start_help_text
|
||||
workspace_service_list_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "list"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS:
|
||||
assert flag in workspace_service_list_help_text
|
||||
workspace_service_status_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "status"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS:
|
||||
assert flag in workspace_service_status_help_text
|
||||
workspace_service_logs_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS:
|
||||
assert flag in workspace_service_logs_help_text
|
||||
workspace_service_stop_help_text = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "stop"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS:
|
||||
assert flag in workspace_service_stop_help_text
|
||||
|
||||
demo_help_text = _subparser_choice(parser, "demo").format_help()
|
||||
for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -42,6 +41,11 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
|||
assert "shell_write" in tool_names
|
||||
assert "shell_signal" in tool_names
|
||||
assert "shell_close" in tool_names
|
||||
assert "service_start" in tool_names
|
||||
assert "service_list" in tool_names
|
||||
assert "service_status" in tool_names
|
||||
assert "service_logs" in tool_names
|
||||
assert "service_stop" in tool_names
|
||||
|
||||
|
||||
def test_vm_run_round_trip(tmp_path: Path) -> None:
|
||||
|
|
@ -192,20 +196,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
raise TypeError("expected structured dictionary result")
|
||||
return cast(dict[str, Any], structured)
|
||||
|
||||
async def _run() -> tuple[
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
]:
|
||||
async def _run() -> tuple[dict[str, Any], ...]:
|
||||
server = create_server(manager=manager)
|
||||
created = _extract_structured(
|
||||
await server.call_tool(
|
||||
|
|
@ -254,57 +245,45 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
},
|
||||
)
|
||||
)
|
||||
opened = _extract_structured(
|
||||
await server.call_tool("shell_open", {"workspace_id": workspace_id})
|
||||
)
|
||||
shell_id = str(opened["shell_id"])
|
||||
written = _extract_structured(
|
||||
service = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_write",
|
||||
"service_start",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"input": "pwd",
|
||||
"service_name": "app",
|
||||
"command": "sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
"ready_file": ".ready",
|
||||
},
|
||||
)
|
||||
)
|
||||
read = _extract_structured(
|
||||
services = _extract_structured(
|
||||
await server.call_tool("service_list", {"workspace_id": workspace_id})
|
||||
)
|
||||
service_status = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_read",
|
||||
"service_status",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"service_name": "app",
|
||||
},
|
||||
)
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while "/workspace" not in str(read["output"]) and time.time() < deadline:
|
||||
read = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_read",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"cursor": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
signaled = _extract_structured(
|
||||
service_logs = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_signal",
|
||||
"service_logs",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"service_name": "app",
|
||||
"all": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
closed = _extract_structured(
|
||||
service_stopped = _extract_structured(
|
||||
await server.call_tool(
|
||||
"shell_close",
|
||||
"service_stop",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
"service_name": "app",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -320,11 +299,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
executed,
|
||||
diffed,
|
||||
exported,
|
||||
opened,
|
||||
written,
|
||||
read,
|
||||
signaled,
|
||||
closed,
|
||||
service,
|
||||
services,
|
||||
service_status,
|
||||
service_logs,
|
||||
service_stopped,
|
||||
logs,
|
||||
deleted,
|
||||
)
|
||||
|
|
@ -335,11 +314,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
executed,
|
||||
diffed,
|
||||
exported,
|
||||
opened,
|
||||
written,
|
||||
read,
|
||||
signaled,
|
||||
closed,
|
||||
service,
|
||||
services,
|
||||
service_status,
|
||||
service_logs,
|
||||
service_stopped,
|
||||
logs,
|
||||
deleted,
|
||||
) = asyncio.run(_run())
|
||||
|
|
@ -350,10 +329,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
assert diffed["changed"] is True
|
||||
assert exported["artifact_type"] == "file"
|
||||
assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "more\n"
|
||||
assert opened["state"] == "running"
|
||||
assert written["input_length"] == 3
|
||||
assert "/workspace" in read["output"]
|
||||
assert signaled["signal"] == "INT"
|
||||
assert closed["closed"] is True
|
||||
assert service["state"] == "running"
|
||||
assert services["count"] == 1
|
||||
assert service_status["state"] == "running"
|
||||
assert service_logs["tail_lines"] is None
|
||||
assert service_stopped["state"] == "stopped"
|
||||
assert logs["count"] == 1
|
||||
assert deleted["deleted"] is True
|
||||
|
|
|
|||
|
|
@ -262,6 +262,105 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) ->
|
|||
assert open_request["shell_id"] == "shell-1"
|
||||
|
||||
|
||||
def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
|
||||
responses = [
|
||||
json.dumps(
|
||||
{
|
||||
"service_name": "app",
|
||||
"command": "echo ok",
|
||||
"cwd": "/workspace",
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ready_at": 2.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"pid": 42,
|
||||
"readiness": {"type": "file", "path": "/workspace/.ready"},
|
||||
"stop_reason": None,
|
||||
}
|
||||
).encode("utf-8"),
|
||||
json.dumps(
|
||||
{
|
||||
"service_name": "app",
|
||||
"command": "echo ok",
|
||||
"cwd": "/workspace",
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ready_at": 2.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"pid": 42,
|
||||
"readiness": {"type": "file", "path": "/workspace/.ready"},
|
||||
"stop_reason": None,
|
||||
}
|
||||
).encode("utf-8"),
|
||||
json.dumps(
|
||||
{
|
||||
"service_name": "app",
|
||||
"command": "echo ok",
|
||||
"cwd": "/workspace",
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ready_at": 2.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"pid": 42,
|
||||
"readiness": {"type": "file", "path": "/workspace/.ready"},
|
||||
"stop_reason": None,
|
||||
"stdout": "ok\n",
|
||||
"stderr": "",
|
||||
"tail_lines": 200,
|
||||
"truncated": False,
|
||||
}
|
||||
).encode("utf-8"),
|
||||
json.dumps(
|
||||
{
|
||||
"service_name": "app",
|
||||
"command": "echo ok",
|
||||
"cwd": "/workspace",
|
||||
"state": "stopped",
|
||||
"started_at": 1.0,
|
||||
"ready_at": 2.0,
|
||||
"ended_at": 3.0,
|
||||
"exit_code": 0,
|
||||
"pid": 42,
|
||||
"readiness": {"type": "file", "path": "/workspace/.ready"},
|
||||
"stop_reason": "sigterm",
|
||||
}
|
||||
).encode("utf-8"),
|
||||
]
|
||||
stubs = [StubSocket(response) for response in responses]
|
||||
remaining = list(stubs)
|
||||
|
||||
def socket_factory(family: int, sock_type: int) -> StubSocket:
|
||||
assert family == socket.AF_VSOCK
|
||||
assert sock_type == socket.SOCK_STREAM
|
||||
return remaining.pop(0)
|
||||
|
||||
client = VsockExecClient(socket_factory=socket_factory)
|
||||
started = client.start_service(
|
||||
1234,
|
||||
5005,
|
||||
service_name="app",
|
||||
command="echo ok",
|
||||
cwd="/workspace",
|
||||
readiness={"type": "file", "path": "/workspace/.ready"},
|
||||
ready_timeout_seconds=30,
|
||||
ready_interval_ms=500,
|
||||
)
|
||||
assert started["service_name"] == "app"
|
||||
status = client.status_service(1234, 5005, service_name="app")
|
||||
assert status["state"] == "running"
|
||||
logs = client.logs_service(1234, 5005, service_name="app", tail_lines=200)
|
||||
assert logs["stdout"] == "ok\n"
|
||||
stopped = client.stop_service(1234, 5005, service_name="app")
|
||||
assert stopped["state"] == "stopped"
|
||||
start_request = json.loads(stubs[0].sent.decode("utf-8").strip())
|
||||
assert start_request["action"] == "start_service"
|
||||
assert start_request["service_name"] == "app"
|
||||
|
||||
|
||||
def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
|
||||
stub = StubSocket(b'{"error":"shell is unavailable"}')
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
|
|
@ -1144,3 +1145,369 @@ def test_reap_expired_workspaces_removes_invalid_and_expired_records(tmp_path: P
|
|||
|
||||
assert not invalid_dir.exists()
|
||||
assert not (tmp_path / "vms" / "workspaces" / workspace_id).exists()
|
||||
|
||||
|
||||
def test_workspace_service_lifecycle_and_status_counts(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
started = manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'printf \"service ready\\n\"; touch .ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
assert started["state"] == "running"
|
||||
|
||||
listed = manager.list_services(workspace_id)
|
||||
assert listed["count"] == 1
|
||||
assert listed["running_count"] == 1
|
||||
|
||||
status = manager.status_service(workspace_id, "app")
|
||||
assert status["state"] == "running"
|
||||
assert status["ready_at"] is not None
|
||||
|
||||
logs = manager.logs_service(workspace_id, "app")
|
||||
assert "service ready" in str(logs["stdout"])
|
||||
|
||||
workspace_status = manager.status_workspace(workspace_id)
|
||||
assert workspace_status["service_count"] == 1
|
||||
assert workspace_status["running_service_count"] == 1
|
||||
|
||||
stopped = manager.stop_service(workspace_id, "app")
|
||||
assert stopped["state"] == "stopped"
|
||||
assert stopped["stop_reason"] in {"sigterm", "sigkill"}
|
||||
|
||||
deleted = manager.delete_workspace(workspace_id)
|
||||
assert deleted["deleted"] is True
|
||||
|
||||
|
||||
def test_workspace_service_start_replaces_non_running_record(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
failed = manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'exit 2'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
ready_timeout_seconds=1,
|
||||
ready_interval_ms=50,
|
||||
)
|
||||
assert failed["state"] == "failed"
|
||||
|
||||
started = manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
assert started["state"] == "running"
|
||||
manager.delete_workspace(workspace_id)
|
||||
|
||||
|
||||
def test_workspace_service_supports_command_readiness_and_helper_probes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
command_started = manager.start_service(
|
||||
workspace_id,
|
||||
"command-ready",
|
||||
command="sh -lc 'touch command.ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "command", "command": "test -f command.ready"},
|
||||
)
|
||||
assert command_started["state"] == "running"
|
||||
|
||||
listed = manager.list_services(workspace_id)
|
||||
assert listed["count"] == 1
|
||||
assert listed["running_count"] == 1
|
||||
|
||||
status = manager.status_workspace(workspace_id)
|
||||
assert status["service_count"] == 1
|
||||
assert status["running_service_count"] == 1
|
||||
|
||||
assert manager.stop_service(workspace_id, "command-ready")["state"] == "stopped"
|
||||
|
||||
workspace_dir = tmp_path / "vms" / "workspaces" / workspace_id / "workspace"
|
||||
ready_file = workspace_dir / "probe.ready"
|
||||
ready_file.write_text("ok\n", encoding="utf-8")
|
||||
assert vm_manager_module._service_ready_on_host( # noqa: SLF001
|
||||
readiness={"type": "file", "path": "/workspace/probe.ready"},
|
||||
workspace_dir=workspace_dir,
|
||||
cwd=workspace_dir,
|
||||
)
|
||||
|
||||
class StubSocket:
|
||||
def __enter__(self) -> StubSocket:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
del args
|
||||
|
||||
def settimeout(self, timeout: int) -> None:
|
||||
assert timeout == 1
|
||||
|
||||
def connect(self, address: tuple[str, int]) -> None:
|
||||
assert address == ("127.0.0.1", 8080)
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.socket.socket", lambda *args: StubSocket())
|
||||
assert vm_manager_module._service_ready_on_host( # noqa: SLF001
|
||||
readiness={"type": "tcp", "address": "127.0.0.1:8080"},
|
||||
workspace_dir=workspace_dir,
|
||||
cwd=workspace_dir,
|
||||
)
|
||||
|
||||
class StubResponse:
|
||||
status = 204
|
||||
|
||||
def __enter__(self) -> StubResponse:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
del args
|
||||
|
||||
def _urlopen(request: object, timeout: int) -> StubResponse:
|
||||
del request
|
||||
assert timeout == 2
|
||||
return StubResponse()
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.urllib.request.urlopen", _urlopen)
|
||||
assert vm_manager_module._service_ready_on_host( # noqa: SLF001
|
||||
readiness={"type": "http", "url": "http://127.0.0.1:8080/"},
|
||||
workspace_dir=workspace_dir,
|
||||
cwd=workspace_dir,
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_service_logs_tail_and_delete_cleanup(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
manager.start_service(
|
||||
workspace_id,
|
||||
"logger",
|
||||
command=(
|
||||
"sh -lc 'printf \"one\\n\"; printf \"two\\n\"; "
|
||||
"touch .ready; while true; do sleep 60; done'"
|
||||
),
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
|
||||
logs = manager.logs_service(workspace_id, "logger", tail_lines=1)
|
||||
assert logs["stdout"] == "two\n"
|
||||
assert logs["truncated"] is True
|
||||
|
||||
services_dir = tmp_path / "vms" / "workspaces" / workspace_id / "services"
|
||||
assert services_dir.exists()
|
||||
deleted = manager.delete_workspace(workspace_id)
|
||||
assert deleted["deleted"] is True
|
||||
assert not services_dir.exists()
|
||||
|
||||
|
||||
def test_workspace_status_stops_service_counts_when_workspace_is_stopped(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["workspace_id"]
|
||||
)
|
||||
manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
service_path = tmp_path / "vms" / "workspaces" / workspace_id / "services" / "app.json"
|
||||
live_service_payload = json.loads(service_path.read_text(encoding="utf-8"))
|
||||
live_pid = int(live_service_payload["pid"])
|
||||
|
||||
try:
|
||||
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
|
||||
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
|
||||
payload["state"] = "stopped"
|
||||
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
status = manager.status_workspace(workspace_id)
|
||||
assert status["state"] == "stopped"
|
||||
assert status["service_count"] == 1
|
||||
assert status["running_service_count"] == 0
|
||||
|
||||
service_payload = json.loads(service_path.read_text(encoding="utf-8"))
|
||||
assert service_payload["state"] == "stopped"
|
||||
assert service_payload["stop_reason"] == "workspace_stopped"
|
||||
finally:
|
||||
vm_manager_module._stop_process_group(live_pid) # noqa: SLF001
|
||||
|
||||
|
||||
def test_workspace_service_readiness_validation_helpers() -> None:
|
||||
assert vm_manager_module._normalize_workspace_service_name("app-1") == "app-1" # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="service_name must not be empty"):
|
||||
vm_manager_module._normalize_workspace_service_name(" ") # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="service_name must match"):
|
||||
vm_manager_module._normalize_workspace_service_name("bad name") # noqa: SLF001
|
||||
|
||||
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "file", "path": "subdir/.ready"}
|
||||
) == {"type": "file", "path": "/workspace/subdir/.ready"}
|
||||
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "tcp", "address": "127.0.0.1:8080"}
|
||||
) == {"type": "tcp", "address": "127.0.0.1:8080"}
|
||||
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "http", "url": "http://127.0.0.1:8080/"}
|
||||
) == {"type": "http", "url": "http://127.0.0.1:8080/"}
|
||||
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "command", "command": "test -f .ready"}
|
||||
) == {"type": "command", "command": "test -f .ready"}
|
||||
|
||||
with pytest.raises(ValueError, match="one of: file, tcp, http, command"):
|
||||
vm_manager_module._normalize_workspace_service_readiness({"type": "bogus"}) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="required for file readiness"):
|
||||
vm_manager_module._normalize_workspace_service_readiness({"type": "file"}) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="HOST:PORT format"):
|
||||
vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
|
||||
{"type": "tcp", "address": "127.0.0.1"}
|
||||
)
|
||||
with pytest.raises(ValueError, match="required for http readiness"):
|
||||
vm_manager_module._normalize_workspace_service_readiness({"type": "http"}) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="required for command readiness"):
|
||||
vm_manager_module._normalize_workspace_service_readiness({"type": "command"}) # noqa: SLF001
|
||||
|
||||
|
||||
def test_workspace_service_text_and_exit_code_helpers(tmp_path: Path) -> None:
|
||||
status_path = tmp_path / "service.status"
|
||||
assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001
|
||||
status_path.write_text("", encoding="utf-8")
|
||||
assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001
|
||||
status_path.write_text("7\n", encoding="utf-8")
|
||||
assert vm_manager_module._read_service_exit_code(status_path) == 7 # noqa: SLF001
|
||||
|
||||
log_path = tmp_path / "service.log"
|
||||
assert vm_manager_module._tail_text(log_path, tail_lines=10) == ("", False) # noqa: SLF001
|
||||
log_path.write_text("one\ntwo\nthree\n", encoding="utf-8")
|
||||
assert vm_manager_module._tail_text(log_path, tail_lines=None) == ( # noqa: SLF001
|
||||
"one\ntwo\nthree\n",
|
||||
False,
|
||||
)
|
||||
assert vm_manager_module._tail_text(log_path, tail_lines=5) == ( # noqa: SLF001
|
||||
"one\ntwo\nthree\n",
|
||||
False,
|
||||
)
|
||||
assert vm_manager_module._tail_text(log_path, tail_lines=1) == ("three\n", True) # noqa: SLF001
|
||||
|
||||
|
||||
def test_workspace_service_process_group_helpers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _missing(_pid: int, _signal: int) -> None:
|
||||
raise ProcessLookupError()
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _missing)
|
||||
assert vm_manager_module._stop_process_group(123) == (False, False) # noqa: SLF001
|
||||
|
||||
kill_calls: list[int] = []
|
||||
monotonic_values = iter([0.0, 0.0, 5.0, 5.0, 10.0])
|
||||
running_states = iter([True, True, False])
|
||||
|
||||
def _killpg(_pid: int, signum: int) -> None:
|
||||
kill_calls.append(signum)
|
||||
|
||||
def _monotonic() -> float:
|
||||
return next(monotonic_values)
|
||||
|
||||
def _is_running(_pid: int | None) -> bool:
|
||||
return next(running_states)
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _killpg)
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.time.monotonic", _monotonic)
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager.time.sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", _is_running)
|
||||
|
||||
stopped, killed = vm_manager_module._stop_process_group(456, wait_seconds=5) # noqa: SLF001
|
||||
assert (stopped, killed) == (True, True)
|
||||
assert kill_calls == [signal.SIGTERM, signal.SIGKILL]
|
||||
|
||||
|
||||
def test_workspace_service_probe_and_refresh_helpers(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
assert vm_manager_module._run_service_probe_command(tmp_path, "exit 3") == 3 # noqa: SLF001
|
||||
|
||||
services_dir = tmp_path / "services"
|
||||
services_dir.mkdir()
|
||||
status_path = services_dir / "app.status"
|
||||
status_path.write_text("9\n", encoding="utf-8")
|
||||
running = vm_manager_module.WorkspaceServiceRecord( # noqa: SLF001
|
||||
workspace_id="workspace-1",
|
||||
service_name="app",
|
||||
command="sleep 60",
|
||||
cwd="/workspace",
|
||||
state="running",
|
||||
started_at=time.time(),
|
||||
readiness=None,
|
||||
ready_at=None,
|
||||
ended_at=None,
|
||||
exit_code=None,
|
||||
pid=1234,
|
||||
execution_mode="host_compat",
|
||||
stop_reason=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", lambda _pid: False)
|
||||
refreshed = vm_manager_module._refresh_local_service_record( # noqa: SLF001
|
||||
running,
|
||||
services_dir=services_dir,
|
||||
)
|
||||
assert refreshed.state == "exited"
|
||||
assert refreshed.exit_code == 9
|
||||
|
||||
monkeypatch.setattr(
|
||||
"pyro_mcp.vm_manager._stop_process_group",
|
||||
lambda _pid: (True, False),
|
||||
)
|
||||
stopped = vm_manager_module._stop_local_service( # noqa: SLF001
|
||||
refreshed,
|
||||
services_dir=services_dir,
|
||||
)
|
||||
assert stopped.state == "stopped"
|
||||
assert stopped.stop_reason == "sigterm"
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ def test_workspace_shells_write_and_signal_runtime_errors(
|
|||
try:
|
||||
with session._lock: # noqa: SLF001
|
||||
session._master_fd = None # noqa: SLF001
|
||||
session._input_pipe = None # noqa: SLF001
|
||||
with pytest.raises(RuntimeError, match="transport is unavailable"):
|
||||
session.write("pwd", append_newline=True)
|
||||
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -706,7 +706,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pyro-mcp"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue