csp-lab 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ node_modules/
2
+ dist/
3
+ .csp-lab/
4
+ .agents/skills/
5
+ skills-lock.json
6
+ /.local/
7
+ .venv/
8
+ __pycache__/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ *.egg-info/
csp_lab-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lackim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
csp_lab-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: csp-lab
3
+ Version: 0.1.0
4
+ Summary: A local CubeSat Space Protocol lab powered by libcsp
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: mcp<2,>=1.20
9
+ Requires-Dist: pydantic<3,>=2.10
10
+ Requires-Dist: typer<1,>=0.15
11
+ Description-Content-Type: text/markdown
12
+
13
+ # csp-lab
14
+
15
+ A local CubeSat Space Protocol lab powered by [libcsp](https://github.com/libcsp/libcsp). It runs two simulated nodes and a ZMQ hub in Docker. The Python CLI and MCP server provide the same diagnostic operations.
16
+
17
+ ## Requirements
18
+
19
+ - Python 3.10 or newer and [uv](https://docs.astral.sh/uv/)
20
+ - Docker with Compose
21
+
22
+ The native code is built against the pinned `libcsp` v2.1 submodule. That library supports CSP protocol v1 and v2 at runtime. The Docker image uses Linux because recent libcsp releases do not provide maintained native macOS support. See [versioning policy](docs/versioning.md) for the supported combinations.
23
+
24
+ ## Local development
25
+
26
+ ```sh
27
+ git submodule update --init --recursive
28
+ uv sync --locked
29
+ uv run --locked csp-lab up --protocol 2 --build
30
+ uv run --locked csp-lab ping 2 --json
31
+ uv run --locked csp-lab doctor --json
32
+ uv run --locked csp-lab down
33
+ ```
34
+
35
+ `--build` compiles the native image from this checkout, so it needs the libcsp submodule. Without `--build`, the CLI uses the image matching its own version from GHCR. That image becomes available after the first release.
36
+
37
+ Once released, the package can run without a clone or local compilation:
38
+
39
+ ```sh
40
+ uvx csp-lab up --protocol 2
41
+ uvx csp-lab ping 2
42
+ uvx csp-lab down
43
+ ```
44
+
45
+ You can also install it with `uv tool install csp-lab` and then use `csp-lab` directly. Lab state is stored in `~/.local/state/csp-lab` or under `XDG_STATE_HOME` when set, so commands work from any directory.
46
+
47
+ If a lab was started by the earlier checkout-based CLI, run `csp-lab down` from that checkout once before switching to `uvx` from another directory. The CLI recognizes and removes the earlier path-based Docker project there.
48
+
49
+ To test CSP v1, start a fresh lab with `up --protocol 1`. `up` refuses to change the protocol of an existing lab; run `down` first. The two versions never share a running network.
50
+
51
+ The CLI commands are `up`, `down`, `status`, `topology`, `ping <address>`, and `doctor`. `status` checks whether the hub and both nodes are running in Docker. Use `--json` for machine-readable output. `ping` and `doctor` return a nonzero exit code when a node does not respond. Addresses must be 1–31 for CSP v1 or 1–16383 for CSP v2. The demo nodes use addresses 2 and 3; the probe uses address 4.
52
+
53
+ ## MCP
54
+
55
+ Start the lab first, then configure an MCP client to launch `csp-lab mcp` over stdio. After the package is published, the entry is:
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "csp-lab": {
61
+ "command": "uvx",
62
+ "args": ["csp-lab", "mcp"]
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ With `uv tool install`, set the command to `csp-lab` and the argument to `mcp`. GUI clients may not see the shell's `PATH`; use the full path to `uvx` or the installed `csp-lab` executable when needed. For a local clone after `uv sync`, use `/absolute/path/to/csp-lab/.venv/bin/csp-lab` as the command. Keep the lab running in a separate terminal while using the MCP tools.
69
+
70
+ The stdio server provides `csp_topology`, `csp_ping`, and `csp_diagnose`. It does not expose commands that reboot, shut down, or read or write node memory. The simulated server binds only the CSP ping service port.
71
+
72
+ ## What is tested
73
+
74
+ `uv run --locked pytest` checks protocol validation, unavailable Docker, cleanup after a failed start, and unresponsive nodes without requiring Docker. Run `uv run --locked ruff check src/csp_lab tests` and `uv run --locked mypy src/csp_lab` for lint and types. The Docker integration flow exercises real libcsp, ZMQ, CLI diagnostics, and MCP for both protocol versions:
75
+
76
+ ```sh
77
+ bash test/integration.sh
78
+ ```
79
+
80
+ The GitHub Actions workflow is configured to run both checks on Ubuntu with Docker Compose. The local integration script stops the lab on exit, including after a failure.
81
+
82
+ ## Scope and license
83
+
84
+ This lab uses the pinned libcsp release as a build dependency and selects CSP v1 or v2 as a runtime protocol. It does not build against historical libcsp 1.x releases or operate mixed-version networks. ZMQ is the only transport in this lab. The project is licensed under [MIT](LICENSE); the libcsp submodule retains its own license. Image component notices are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,72 @@
1
+ # csp-lab
2
+
3
+ A local CubeSat Space Protocol lab powered by [libcsp](https://github.com/libcsp/libcsp). It runs two simulated nodes and a ZMQ hub in Docker. The Python CLI and MCP server provide the same diagnostic operations.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.10 or newer and [uv](https://docs.astral.sh/uv/)
8
+ - Docker with Compose
9
+
10
+ The native code is built against the pinned `libcsp` v2.1 submodule. That library supports CSP protocol v1 and v2 at runtime. The Docker image uses Linux because recent libcsp releases do not provide maintained native macOS support. See [versioning policy](docs/versioning.md) for the supported combinations.
11
+
12
+ ## Local development
13
+
14
+ ```sh
15
+ git submodule update --init --recursive
16
+ uv sync --locked
17
+ uv run --locked csp-lab up --protocol 2 --build
18
+ uv run --locked csp-lab ping 2 --json
19
+ uv run --locked csp-lab doctor --json
20
+ uv run --locked csp-lab down
21
+ ```
22
+
23
+ `--build` compiles the native image from this checkout, so it needs the libcsp submodule. Without `--build`, the CLI uses the image matching its own version from GHCR. That image becomes available after the first release.
24
+
25
+ Once released, the package can run without a clone or local compilation:
26
+
27
+ ```sh
28
+ uvx csp-lab up --protocol 2
29
+ uvx csp-lab ping 2
30
+ uvx csp-lab down
31
+ ```
32
+
33
+ You can also install it with `uv tool install csp-lab` and then use `csp-lab` directly. Lab state is stored in `~/.local/state/csp-lab` or under `XDG_STATE_HOME` when set, so commands work from any directory.
34
+
35
+ If a lab was started by the earlier checkout-based CLI, run `csp-lab down` from that checkout once before switching to `uvx` from another directory. The CLI recognizes and removes the earlier path-based Docker project there.
36
+
37
+ To test CSP v1, start a fresh lab with `up --protocol 1`. `up` refuses to change the protocol of an existing lab; run `down` first. The two versions never share a running network.
38
+
39
+ The CLI commands are `up`, `down`, `status`, `topology`, `ping <address>`, and `doctor`. `status` checks whether the hub and both nodes are running in Docker. Use `--json` for machine-readable output. `ping` and `doctor` return a nonzero exit code when a node does not respond. Addresses must be 1–31 for CSP v1 or 1–16383 for CSP v2. The demo nodes use addresses 2 and 3; the probe uses address 4.
40
+
41
+ ## MCP
42
+
43
+ Start the lab first, then configure an MCP client to launch `csp-lab mcp` over stdio. After the package is published, the entry is:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "csp-lab": {
49
+ "command": "uvx",
50
+ "args": ["csp-lab", "mcp"]
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ With `uv tool install`, set the command to `csp-lab` and the argument to `mcp`. GUI clients may not see the shell's `PATH`; use the full path to `uvx` or the installed `csp-lab` executable when needed. For a local clone after `uv sync`, use `/absolute/path/to/csp-lab/.venv/bin/csp-lab` as the command. Keep the lab running in a separate terminal while using the MCP tools.
57
+
58
+ The stdio server provides `csp_topology`, `csp_ping`, and `csp_diagnose`. It does not expose commands that reboot, shut down, or read or write node memory. The simulated server binds only the CSP ping service port.
59
+
60
+ ## What is tested
61
+
62
+ `uv run --locked pytest` checks protocol validation, unavailable Docker, cleanup after a failed start, and unresponsive nodes without requiring Docker. Run `uv run --locked ruff check src/csp_lab tests` and `uv run --locked mypy src/csp_lab` for lint and types. The Docker integration flow exercises real libcsp, ZMQ, CLI diagnostics, and MCP for both protocol versions:
63
+
64
+ ```sh
65
+ bash test/integration.sh
66
+ ```
67
+
68
+ The GitHub Actions workflow is configured to run both checks on Ubuntu with Docker Compose. The local integration script stops the lab on exit, including after a failure.
69
+
70
+ ## Scope and license
71
+
72
+ This lab uses the pinned libcsp release as a build dependency and selects CSP v1 or v2 as a runtime protocol. It does not build against historical libcsp 1.x releases or operate mixed-version networks. ZMQ is the only transport in this lab. The project is licensed under [MIT](LICENSE); the libcsp submodule retains its own license. Image component notices are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,14 @@
1
+ # Third party notices
2
+
3
+ The container image includes these components. Their original notices and license
4
+ terms remain in the image.
5
+
6
+ | Component | Source | License and notice location in the image |
7
+ | --- | --- | --- |
8
+ | libcsp v2.1 | [libcsp](https://github.com/libcsp/libcsp/tree/v2.1) | MIT; `/usr/share/licenses/libcsp/LICENSE` and `AUTHORS` |
9
+ | ZeroMQ (`libzmq5`, Debian bookworm package) | [Debian package](https://packages.debian.org/bookworm/libzmq5) | LGPL-3.0+ with a special exception and package-specific terms; `/usr/share/doc/libzmq5/copyright` |
10
+ | Debian base image and runtime packages | [Debian](https://www.debian.org/) | Individual package notices under `/usr/share/doc/` |
11
+
12
+ The csp-lab source and its own MIT license are at
13
+ `/usr/share/licenses/csp-lab/LICENSE` in the image. The installed package versions
14
+ can be inspected with `dpkg-query -W` inside a container.
@@ -0,0 +1,28 @@
1
+ # Versioning policy
2
+
3
+ Three version numbers matter here and they have different meanings:
4
+
5
+ | Layer | Current support | How it is selected |
6
+ | --- | --- | --- |
7
+ | libcsp library and API | v2.1, pinned as a Git submodule commit | Build time |
8
+ | CSP wire protocol | v1 or v2 | `csp-lab up --protocol 1` or `--protocol 2` |
9
+ | csp-lab CLI and MCP API | 0.1.x prototype | Project release |
10
+
11
+ The current image always builds against libcsp v2.1. Selecting `--protocol 1` changes the wire format used by that library; it does **not** build libcsp 1.x. The two simulated nodes and the probe always use the same protocol setting. A running lab must be stopped before switching protocols.
12
+
13
+ ## Updating libcsp 2.x
14
+
15
+ Update the pinned submodule in a dedicated change. Check the C API and ZMQ build, then run `uv run --locked pytest` and `bash test/integration.sh` for both wire protocols. Review the generated Docker image and record the new libcsp commit in the change. Keep the CLI and MCP output compatible within a csp-lab release unless a breaking change is intentional.
16
+
17
+ ## Historical libcsp 1.x
18
+
19
+ libcsp 1.x has materially different build and API surfaces. If support becomes useful, add a separate legacy native build and adapter, with its own pinned revision and CI job. Do not switch native library generations implicitly based on `--protocol`; that option is reserved for the wire protocol. A mixed v1/v2 network is outside this lab's scope.
20
+
21
+ ## Support matrix
22
+
23
+ | Native library | Wire protocol | Status |
24
+ | --- | --- | --- |
25
+ | libcsp v2.1 | CSP v1 | Tested locally; covered by the CI workflow |
26
+ | libcsp v2.1 | CSP v2 | Tested locally; covered by the CI workflow |
27
+ | libcsp 1.x | CSP v1 | Unsupported |
28
+ | Any library | Mixed CSP v1/v2 in one lab | Unsupported |
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "csp-lab"
7
+ version = "0.1.0"
8
+ description = "A local CubeSat Space Protocol lab powered by libcsp"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = [
13
+ "mcp>=1.20,<2",
14
+ "pydantic>=2.10,<3",
15
+ "typer>=0.15,<1",
16
+ ]
17
+
18
+ [project.scripts]
19
+ csp-lab = "csp_lab.cli:run_cli"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "mypy>=1.15,<2",
24
+ "pytest>=8,<10",
25
+ "ruff>=0.11,<1",
26
+ ]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/csp_lab"]
30
+
31
+ [tool.hatch.build.targets.sdist]
32
+ only-include = ["src/csp_lab", "THIRD_PARTY_NOTICES.md", "docs/versioning.md"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+
37
+ [tool.ruff]
38
+ target-version = "py310"
39
+ line-length = 100
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "S602", "S605"]
43
+
44
+ [tool.mypy]
45
+ python_version = "3.10"
46
+ strict = true
47
+ files = ["src/csp_lab"]
@@ -0,0 +1,6 @@
1
+ """Public models and lab operations for the local CSP lab."""
2
+
3
+ from csp_lab.docker import diagnose, down, ping, status, topology, up
4
+ from csp_lab.models import LabState, PingResult
5
+
6
+ __all__ = ["LabState", "PingResult", "diagnose", "down", "ping", "status", "topology", "up"]
@@ -0,0 +1,191 @@
1
+ """Command-line interface for the local CSP lab."""
2
+
3
+ import json
4
+ import sys
5
+ from collections.abc import Callable
6
+ from importlib.metadata import version
7
+ from typing import TypeVar
8
+
9
+ import typer
10
+ from pydantic import BaseModel
11
+
12
+ from csp_lab import docker
13
+ from csp_lab.models import PingResult
14
+
15
+ ResultT = TypeVar("ResultT")
16
+
17
+ app = typer.Typer(
18
+ name="csp-lab",
19
+ help="Local CubeSat Space Protocol lab",
20
+ add_completion=False,
21
+ no_args_is_help=True,
22
+ context_settings={"help_option_names": ["-h", "--help"]},
23
+ )
24
+
25
+
26
+ def _show_version(value: bool) -> bool:
27
+ if value:
28
+ print(version("csp-lab"))
29
+ raise typer.Exit()
30
+ return value
31
+
32
+
33
+ @app.callback()
34
+ def main(
35
+ ctx: typer.Context,
36
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
37
+ show_version: bool = typer.Option(
38
+ False, "-V", "--version", callback=_show_version, is_eager=True
39
+ ),
40
+ ) -> None:
41
+ """Global output options."""
42
+ ctx.obj = json_output
43
+
44
+
45
+ def _json(ctx: typer.Context, local: bool) -> bool:
46
+ return bool(ctx.obj) or local
47
+
48
+
49
+ def _emit(value: BaseModel | dict[str, object], json_output: bool) -> None:
50
+ data = value.model_dump(by_alias=True) if isinstance(value, BaseModel) else value
51
+ print(
52
+ json.dumps(
53
+ data,
54
+ indent=None if json_output else 2,
55
+ separators=(",", ":") if json_output else None,
56
+ ensure_ascii=False,
57
+ )
58
+ )
59
+
60
+
61
+ def _action(operation: Callable[[], ResultT], json_output: bool) -> ResultT:
62
+ try:
63
+ return operation()
64
+ except Exception as exc:
65
+ message = str(exc)
66
+ if json_output:
67
+ print(json.dumps({"error": message}, separators=(",", ":"), ensure_ascii=False))
68
+ else:
69
+ print(f"csp-lab: {message}", file=sys.stderr)
70
+ raise typer.Exit(code=1) from exc
71
+
72
+
73
+ @app.command()
74
+ def up(
75
+ ctx: typer.Context,
76
+ protocol: str = typer.Option("2", "--protocol", help="CSP protocol version (1 or 2)"),
77
+ build: bool = typer.Option(False, "--build", help="Build the native image from this checkout"),
78
+ json_output: bool = typer.Option(False, "--json", help="Print JSON output"),
79
+ show_version: bool = typer.Option(
80
+ False, "-V", "--version", callback=_show_version, is_eager=True, hidden=True
81
+ ),
82
+ ) -> None:
83
+ """Start two libcsp nodes and a ZMQ hub."""
84
+ json_output = _json(ctx, json_output)
85
+ state = _action(lambda: docker.up(docker.parse_protocol(protocol), build), json_output)
86
+ data: dict[str, object] = {"started": True}
87
+ data.update(state.as_json())
88
+ _emit(data, json_output)
89
+
90
+
91
+ @app.command()
92
+ def down(
93
+ ctx: typer.Context,
94
+ json_output: bool = typer.Option(False, "--json", help="Print JSON output"),
95
+ show_version: bool = typer.Option(
96
+ False, "-V", "--version", callback=_show_version, is_eager=True, hidden=True
97
+ ),
98
+ ) -> None:
99
+ """Stop the lab."""
100
+ json_output = _json(ctx, json_output)
101
+ _action(docker.down, json_output)
102
+ _emit({"stopped": True}, json_output)
103
+
104
+
105
+ @app.command()
106
+ def ping(
107
+ ctx: typer.Context,
108
+ address: str = typer.Argument(..., help="CSP address to ping"),
109
+ json_output: bool = typer.Option(False, "--json", help="Print JSON output"),
110
+ show_version: bool = typer.Option(
111
+ False, "-V", "--version", callback=_show_version, is_eager=True, hidden=True
112
+ ),
113
+ ) -> None:
114
+ """Ping a node using libcsp."""
115
+ json_output = _json(ctx, json_output)
116
+
117
+ def probe() -> PingResult:
118
+ state = docker.require_state()
119
+ return docker.ping(docker.parse_address(address, state.protocol))
120
+
121
+ result = _action(probe, json_output)
122
+ _emit(result, json_output)
123
+ if not result.reachable:
124
+ raise typer.Exit(code=1)
125
+
126
+
127
+ @app.command()
128
+ def topology(
129
+ ctx: typer.Context,
130
+ json_output: bool = typer.Option(False, "--json", help="Print JSON output"),
131
+ show_version: bool = typer.Option(
132
+ False, "-V", "--version", callback=_show_version, is_eager=True, hidden=True
133
+ ),
134
+ ) -> None:
135
+ """Show the configured network."""
136
+ json_output = _json(ctx, json_output)
137
+ _emit(_action(docker.topology, json_output), json_output)
138
+
139
+
140
+ @app.command()
141
+ def doctor(
142
+ ctx: typer.Context,
143
+ json_output: bool = typer.Option(False, "--json", help="Print JSON output"),
144
+ show_version: bool = typer.Option(
145
+ False, "-V", "--version", callback=_show_version, is_eager=True, hidden=True
146
+ ),
147
+ ) -> None:
148
+ """Test both nodes."""
149
+ json_output = _json(ctx, json_output)
150
+ result = _action(docker.diagnose, json_output)
151
+ _emit(result, json_output)
152
+ if not result.healthy:
153
+ raise typer.Exit(code=1)
154
+
155
+
156
+ @app.command()
157
+ def status(
158
+ ctx: typer.Context,
159
+ json_output: bool = typer.Option(False, "--json", help="Print JSON output"),
160
+ show_version: bool = typer.Option(
161
+ False, "-V", "--version", callback=_show_version, is_eager=True, hidden=True
162
+ ),
163
+ ) -> None:
164
+ """Show local lab state."""
165
+ json_output = _json(ctx, json_output)
166
+ _emit(_action(docker.status, json_output), json_output)
167
+
168
+
169
+ @app.command()
170
+ def mcp() -> None:
171
+ """Serve read-only CSP diagnostics over MCP stdio."""
172
+ from csp_lab.mcp_server import server
173
+
174
+ server.run(transport="stdio")
175
+
176
+
177
+ def run_cli() -> None:
178
+ """Map legacy help syntax and parser exit codes to the original CLI."""
179
+ args = sys.argv[1:]
180
+ if args and args[0] == "help":
181
+ args = [*args[1:], "--help"]
182
+ try:
183
+ app(args=args)
184
+ except SystemExit as exc:
185
+ if exc.code == 2:
186
+ raise SystemExit(1) from exc
187
+ raise
188
+
189
+
190
+ if __name__ == "__main__":
191
+ run_cli()
@@ -0,0 +1,11 @@
1
+ services:
2
+ hub:
3
+ build:
4
+ context: ${CSP_LAB_BUILD_CONTEXT:?}
5
+ pull_policy: build
6
+ node2:
7
+ pull_policy: never
8
+ node3:
9
+ pull_policy: never
10
+ probe:
11
+ pull_policy: never
@@ -0,0 +1,16 @@
1
+ services:
2
+ hub:
3
+ image: ${CSP_LAB_IMAGE:?}
4
+ entrypoint: ["/usr/local/bin/zmqproxy"]
5
+ command: ["-v", "${CSP_VERSION:-2}"]
6
+ node2:
7
+ image: ${CSP_LAB_IMAGE:?}
8
+ depends_on: [hub]
9
+ command: ["server", "${CSP_VERSION:-2}", "2", "hub"]
10
+ node3:
11
+ image: ${CSP_LAB_IMAGE:?}
12
+ depends_on: [hub]
13
+ command: ["server", "${CSP_VERSION:-2}", "3", "hub"]
14
+ probe:
15
+ image: ${CSP_LAB_IMAGE:?}
16
+ profiles: ["tools"]
@@ -0,0 +1,270 @@
1
+ """Docker Compose orchestration and CSP probe operations."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from contextlib import ExitStack
10
+ from importlib.metadata import version
11
+ from importlib.resources import as_file, files
12
+ from pathlib import Path
13
+
14
+ from pydantic import ValidationError
15
+
16
+ from csp_lab.models import (
17
+ DiagnoseResult,
18
+ LabState,
19
+ PingResult,
20
+ Protocol,
21
+ StatusResult,
22
+ TopologyResult,
23
+ )
24
+
25
+ ROOT = Path(__file__).resolve().parents[2]
26
+ STATE_HOME = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
27
+ STATE_DIR = STATE_HOME / "csp-lab"
28
+ STATE_FILE = STATE_DIR / "state.json"
29
+ PROJECT = "csp-lab"
30
+ IMAGE = "ghcr.io/lackim/csp-lab"
31
+
32
+
33
+ def selected_image(build: bool) -> str:
34
+ return "csp-lab-native:local" if build else f"{IMAGE}:v{version('csp-lab')}"
35
+
36
+
37
+ def state_image(state: LabState) -> str:
38
+ return state.image or selected_image(state.build)
39
+
40
+
41
+ def state_project(state: LabState) -> str:
42
+ return state.project or PROJECT
43
+
44
+
45
+ def parse_protocol(value: str) -> Protocol:
46
+ if value == "1":
47
+ return 1
48
+ if value == "2":
49
+ return 2
50
+ raise ValueError("Protocol must be 1 or 2.")
51
+
52
+
53
+ def parse_address(value: str, protocol: Protocol) -> int:
54
+ if re.fullmatch(r"[0-9]+", value) is None:
55
+ raise ValueError("Address must be a decimal integer.")
56
+ address = int(value)
57
+ maximum = 31 if protocol == 1 else 16383
58
+ if address < 1 or address > maximum:
59
+ raise ValueError(f"CSP v{protocol} address must be between 1 and {maximum}.")
60
+ return address
61
+
62
+
63
+ def _read_state(path: Path) -> LabState | None:
64
+ try:
65
+ content = path.read_text(encoding="utf-8")
66
+ except FileNotFoundError:
67
+ return None
68
+ try:
69
+ return LabState.model_validate_json(content)
70
+ except ValidationError as exc:
71
+ raise ValueError("Unsupported lab state. Run 'csp-lab down' and start again.") from exc
72
+
73
+
74
+ def get_state() -> LabState | None:
75
+ state = _read_state(STATE_FILE)
76
+ if state is not None:
77
+ return state
78
+ for root in dict.fromkeys((ROOT, Path.cwd().resolve())):
79
+ legacy = _read_state(root / ".csp-lab" / "state.json")
80
+ if legacy is not None:
81
+ project = "csp-lab-" + hashlib.sha256(str(root).encode()).hexdigest()[:8]
82
+ return legacy.model_copy(
83
+ update={
84
+ "build": True,
85
+ "image": "csp-lab-native:local",
86
+ "project": project,
87
+ "legacy_root": root,
88
+ }
89
+ )
90
+ return None
91
+
92
+
93
+ def require_state() -> LabState:
94
+ state = get_state()
95
+ if state is None:
96
+ raise RuntimeError("Lab is not started. Run 'csp-lab up --protocol 2' first.")
97
+ return state
98
+
99
+
100
+ def run(
101
+ command: str, args: list[str], env: dict[str, str] | None = None, timeout: int = 120
102
+ ) -> str:
103
+ try:
104
+ result = subprocess.run(
105
+ [command, *args],
106
+ env={**os.environ, **(env or {})},
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=timeout,
110
+ check=False,
111
+ )
112
+ except FileNotFoundError as exc:
113
+ raise RuntimeError(f"{command} ENOENT: {exc}") from exc
114
+ if result.returncode != 0:
115
+ detail = (result.stderr or result.stdout).strip()
116
+ raise RuntimeError(f"{command} exited {result.returncode}: {detail}")
117
+ return result.stdout
118
+
119
+
120
+ def compose(
121
+ args: list[str],
122
+ protocol: Protocol,
123
+ image: str | None = None,
124
+ timeout: int = 120,
125
+ build_overlay: bool = False,
126
+ project: str = PROJECT,
127
+ ) -> str:
128
+ env = {"CSP_VERSION": str(protocol), "CSP_LAB_IMAGE": image or selected_image(False)}
129
+ with ExitStack() as stack:
130
+ compose_file = stack.enter_context(as_file(files("csp_lab") / "data" / "compose.yaml"))
131
+ file_args = ["-f", str(compose_file)]
132
+ if build_overlay:
133
+ if not (ROOT / "Dockerfile").is_file() or not (ROOT / "vendor" / "libcsp").is_dir():
134
+ raise RuntimeError(
135
+ "Local build requires a source checkout with the libcsp submodule."
136
+ )
137
+ build_file = stack.enter_context(
138
+ as_file(files("csp_lab") / "data" / "compose.build.yaml")
139
+ )
140
+ file_args.extend(["-f", str(build_file)])
141
+ env["CSP_LAB_BUILD_CONTEXT"] = str(ROOT)
142
+ return run("docker", ["compose", "-p", project, *file_args, *args], env, timeout)
143
+
144
+
145
+ def up(protocol: Protocol, build: bool = False) -> LabState:
146
+ if get_state() is not None:
147
+ raise RuntimeError("Lab is already started. Run 'csp-lab down' before changing protocol.")
148
+ if build and (
149
+ not (ROOT / "Dockerfile").is_file() or not (ROOT / "vendor" / "libcsp").is_dir()
150
+ ):
151
+ raise RuntimeError("Local build requires a source checkout with the libcsp submodule.")
152
+ image = selected_image(build)
153
+ try:
154
+ pull = ["--build"] if build else ["--pull", "always"]
155
+ compose(
156
+ ["up", "-d", *pull, "hub", "node2", "node3"],
157
+ protocol,
158
+ image,
159
+ 600,
160
+ build_overlay=build,
161
+ )
162
+ for target in (2, 3):
163
+ if not ping_with_protocol(target, protocol, image).reachable:
164
+ raise RuntimeError(f"Node {target} did not answer a CSP v{protocol} ping.")
165
+ state = LabState(
166
+ protocol=protocol,
167
+ libcspVersion="2.1",
168
+ build=build,
169
+ image=image,
170
+ project=PROJECT,
171
+ )
172
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
173
+ STATE_FILE.write_text(
174
+ json.dumps(
175
+ {**state.as_json(), "build": build, "image": image, "project": PROJECT},
176
+ indent=2,
177
+ )
178
+ + "\n",
179
+ encoding="utf-8",
180
+ )
181
+ return state
182
+ except Exception:
183
+ try:
184
+ compose(["down", "--remove-orphans"], protocol, image)
185
+ except Exception:
186
+ pass
187
+ shutil.rmtree(STATE_DIR, ignore_errors=True)
188
+ raise
189
+
190
+
191
+ def down() -> None:
192
+ state = get_state()
193
+ compose(
194
+ ["down", "--remove-orphans"],
195
+ state.protocol if state else 2,
196
+ state_image(state) if state else None,
197
+ project=state_project(state) if state else PROJECT,
198
+ )
199
+ state_dir = state.legacy_root / ".csp-lab" if state and state.legacy_root else STATE_DIR
200
+ shutil.rmtree(state_dir, ignore_errors=True)
201
+
202
+
203
+ def ping_with_protocol(
204
+ target: int, protocol: Protocol, image: str | None = None, project: str = PROJECT
205
+ ) -> PingResult:
206
+ parse_address(str(target), protocol)
207
+ output = compose(
208
+ ["run", "--rm", "--no-deps", "probe", "probe", str(protocol), "4", str(target)],
209
+ protocol,
210
+ image,
211
+ 30,
212
+ project=project,
213
+ )
214
+ line = next(
215
+ (part for part in reversed(output.splitlines()) if part.startswith('{"target":')),
216
+ None,
217
+ )
218
+ if line is None:
219
+ raise ValueError(f"Probe returned no JSON result: {output.strip()}")
220
+ try:
221
+ result = PingResult.model_validate_json(line)
222
+ except ValidationError as exc:
223
+ raise ValueError("Probe returned an invalid result.") from exc
224
+ if result.target != target:
225
+ raise ValueError("Probe returned an invalid result.")
226
+ return result
227
+
228
+
229
+ def ping(target: int) -> PingResult:
230
+ state = require_state()
231
+ return ping_with_protocol(target, state.protocol, state_image(state), state_project(state))
232
+
233
+
234
+ def topology() -> TopologyResult:
235
+ state = require_state()
236
+ return TopologyResult(
237
+ protocol=state.protocol,
238
+ libcspVersion=state.libcsp_version,
239
+ nodes=[2, 3],
240
+ probeAddress=4,
241
+ transport="ZMQ",
242
+ )
243
+
244
+
245
+ def diagnose() -> DiagnoseResult:
246
+ state = require_state()
247
+ nodes = [
248
+ ping_with_protocol(target, state.protocol, state_image(state), state_project(state))
249
+ for target in (2, 3)
250
+ ]
251
+ return DiagnoseResult(
252
+ protocol=state.protocol,
253
+ libcspVersion=state.libcsp_version,
254
+ healthy=all(node.reachable for node in nodes),
255
+ nodes=nodes,
256
+ )
257
+
258
+
259
+ def status() -> StatusResult:
260
+ state = get_state()
261
+ if state is None:
262
+ return StatusResult(running=False, state=None)
263
+ services = compose(
264
+ ["ps", "--services", "--status", "running"],
265
+ state.protocol,
266
+ state_image(state),
267
+ project=state_project(state),
268
+ ).splitlines()
269
+ running = all(name in services for name in ("hub", "node2", "node3"))
270
+ return StatusResult(running=running, state=state)
@@ -0,0 +1,42 @@
1
+ """Read-only MCP tools for inspecting a running CSP lab."""
2
+
3
+ import json
4
+ from typing import Annotated
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+ from mcp.types import ToolAnnotations
8
+ from pydantic import Field
9
+
10
+ from csp_lab import docker
11
+
12
+ server = FastMCP("csp-lab")
13
+ READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
14
+
15
+
16
+ @server.tool(
17
+ name="csp_topology",
18
+ description="Read the configured CSP protocol version and simulated nodes",
19
+ annotations=READ_ONLY,
20
+ )
21
+ def csp_topology() -> str:
22
+ return json.dumps(docker.topology().as_json())
23
+
24
+
25
+ @server.tool(
26
+ name="csp_ping",
27
+ description="Ping a simulated node through libcsp",
28
+ annotations=READ_ONLY,
29
+ )
30
+ def csp_ping(address: Annotated[int, Field(strict=True, gt=0)]) -> str:
31
+ state = docker.require_state()
32
+ target = docker.parse_address(str(address), state.protocol)
33
+ return json.dumps(docker.ping(target).as_json())
34
+
35
+
36
+ @server.tool(
37
+ name="csp_diagnose",
38
+ description="Ping both nodes and summarize lab health",
39
+ annotations=READ_ONLY,
40
+ )
41
+ def csp_diagnose() -> str:
42
+ return json.dumps(docker.diagnose().as_json())
@@ -0,0 +1,50 @@
1
+ """Validated data returned by the CLI and MCP server."""
2
+
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, StrictBool
7
+
8
+ Protocol = Literal[1, 2]
9
+
10
+
11
+ class LabModel(BaseModel):
12
+ model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
13
+
14
+ def as_json(self) -> dict[str, object]:
15
+ return self.model_dump(by_alias=True)
16
+
17
+
18
+ class LabState(LabModel):
19
+ protocol: Protocol
20
+ libcsp_version: Literal["2.1"] = Field(alias="libcspVersion")
21
+ build: bool = Field(default=False, exclude=True)
22
+ image: str | None = Field(default=None, exclude=True)
23
+ project: str | None = Field(default=None, exclude=True)
24
+ legacy_root: Path | None = Field(default=None, exclude=True)
25
+
26
+
27
+ class PingResult(LabModel):
28
+ target: int
29
+ reachable: StrictBool
30
+ rtt_ms: int = Field(alias="rttMs")
31
+
32
+
33
+ class StatusResult(LabModel):
34
+ running: bool
35
+ state: LabState | None
36
+
37
+
38
+ class TopologyResult(LabModel):
39
+ protocol: Protocol
40
+ libcsp_version: Literal["2.1"] = Field(alias="libcspVersion")
41
+ nodes: list[int]
42
+ probe_address: int = Field(alias="probeAddress")
43
+ transport: Literal["ZMQ"]
44
+
45
+
46
+ class DiagnoseResult(LabModel):
47
+ protocol: Protocol
48
+ libcsp_version: Literal["2.1"] = Field(alias="libcspVersion")
49
+ healthy: bool
50
+ nodes: list[PingResult]