Compare commits
18 Commits
e17a70aecf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bb9b00dcc | |||
| 036429e9bf | |||
| 18fe063691 | |||
| f27a5456f6 | |||
| 82e8167f67 | |||
| 4030b8d5ee | |||
| 24b97338ba | |||
| 12d532da7b | |||
| 8adcf93b6a | |||
| 3f73ed0aef | |||
| 801dbe15dc | |||
| 13e10fa52f | |||
| 6ba4c7ca92 | |||
| 8878eda0b2 | |||
| 4b8b1a0a6e | |||
| 661460bfd9 | |||
| a1a9388d88 | |||
| 4caac3a6c7 |
+417
@@ -2,6 +2,423 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.7.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
**`inspect_container`** — new tool that returns the full configuration
|
||||
of a single container, intended as the foundation for a future
|
||||
GUI-container → Compose migration workflow.
|
||||
|
||||
- API: `SYNO.Docker.Container/get` with `name` JSON-encoded (the same
|
||||
endpoint already used by `get_container_status` and `delete_container`,
|
||||
but called directly with a focus on the migration-relevant fields).
|
||||
The DSM action `inspect` (no `get`) does not exist and returns DSM
|
||||
code 103 — use `get`.
|
||||
- Output covers: image, status / running, restart policy, network
|
||||
mode and per-network IP addresses, port bindings, volume mounts
|
||||
(with FULL `/volume1/...` host path — see DSM quirk below),
|
||||
environment variables, labels, entrypoint / command, links, and
|
||||
capabilities / privileged.
|
||||
- Hash-prefixed container names (`abcdef012345_myservice`) are resolved
|
||||
to the actual DSM name for the API call and stripped from the
|
||||
display header — same convention as the other container tools.
|
||||
|
||||
### Documented (DSM quirk)
|
||||
|
||||
**`profile.volume_bindings[].host_volume_file` is share-relative**, not
|
||||
the full host path. A live capture against a container with a
|
||||
`/volume1/docker/homeassistant` bind mount returned
|
||||
`host_volume_file = "/docker/homeassistant"` in `profile`, while
|
||||
`details.Mounts[].Source` carried the full
|
||||
`/volume1/docker/homeassistant`. For a Compose-rebuild use case the
|
||||
full path is required — `inspect_container` therefore reads mount
|
||||
sources from `details.Mounts[].Source`, not from
|
||||
`profile.volume_bindings[].host_volume_file`. Recorded in CLAUDE.md.
|
||||
|
||||
## [0.6.0] - 2026-05-18
|
||||
|
||||
### Changed
|
||||
|
||||
**`build_stream` is no longer fire-and-forget (#2).** A live stream
|
||||
capture confirmed that DSM emits a readable plaintext build log over
|
||||
the HTTP body — one short status line per step — and closes the
|
||||
connection when the build is done. The 0.2.5 implementation sent the
|
||||
request and dropped the body unread, which meant a failed image pull
|
||||
left users with nothing more than a `BUILD_FAILED` polling status and
|
||||
no actionable diagnostic.
|
||||
|
||||
- `DsmClient.trigger_build_stream` now consumes the body line by line
|
||||
and returns the collected log as a string instead of `None`. A wall-
|
||||
clock budget of 210 s (`BUILD_STREAM_BUDGET`, kept under the Claude
|
||||
Desktop ~4 min tool-call ceiling) caps the read; on timeout the
|
||||
partial log is returned with a `[build_stream: timeout — stream
|
||||
still open server-side]` marker appended so the caller knows the
|
||||
build is still going server-side. Per-chunk `ReadTimeout` is treated
|
||||
the same way — return what we have plus the marker.
|
||||
- `redeploy_project` and `create_project` now parse the returned log
|
||||
via `_parse_build_stream_log`, which classifies any line containing
|
||||
`Error response from daemon:` or ending in ` Error` as a failure.
|
||||
When the log contains errors the tools abort immediately, surface
|
||||
the daemon line(s) in the result (e.g. `Error response from daemon:
|
||||
manifest for nginx:9.9.9-nonexistent not found: manifest unknown`),
|
||||
and skip the polling step entirely — DSM has already told us the
|
||||
build is dead. The existing `BUILD_FAILED` / `ERROR` polling guard
|
||||
(M-5) stays as a second safety net for late failures where the
|
||||
stream log was clean but the container exited after start.
|
||||
- No new MCP tool: the build log is a live stream and cannot be
|
||||
re-fetched after the build ends, so it is surfaced during
|
||||
`redeploy_project` / `create_project` rather than exposed as a
|
||||
standalone `get_project_build_log` call.
|
||||
|
||||
JSON error envelope handling, transport-error mapping (M-4), and the
|
||||
SID-scrubbed HTTP-error formatting are unchanged. Closes #2.
|
||||
|
||||
Minor version bump because `redeploy_project` and `create_project`
|
||||
return materially different strings on a failed build and exit
|
||||
earlier in the failure path; signatures unchanged.
|
||||
|
||||
## [0.5.1] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- `pull_image` (#3): the 0.5.0 implementation called
|
||||
`SYNO.Docker.Registry/pull_start`, which DSM rejects with
|
||||
"Method does not exist". A live DSM API capture confirmed that
|
||||
`pull_start` actually lives on `SYNO.Docker.Image`, not
|
||||
`SYNO.Docker.Registry`. The Registry API only exposes the
|
||||
synchronous read-only methods (`search`, `tags`, `get/set/create/
|
||||
delete`, `using`). Parameters are unchanged — both `repository`
|
||||
and `tag` are still JSON-encoded — and the `Image/list` polling
|
||||
for completion detection works as before. Note: #3 is already
|
||||
closed; this is the follow-up fix.
|
||||
|
||||
## [0.5.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
**Welle B Teil 1 — Registry tools (#3, #5).** Three new `SYNO.Docker.Registry`
|
||||
tools, reverse-engineered from a live DSM API capture (the n4s4 reference
|
||||
disagrees on parameter names and methods; the live capture wins):
|
||||
|
||||
- `search_registry` (#5) — search the active Docker registry by query string.
|
||||
Uses `SYNO.Docker.Registry/search` (version 1) with the query JSON-encoded
|
||||
as `q`, plus `offset`, `limit`, and `page_size`. Renders stars, downloads,
|
||||
the official-image flag, and a truncated description per hit, and shows
|
||||
the total match count so the caller knows when to raise `limit`. No
|
||||
confirmation gate (read-only).
|
||||
- `list_image_tags` — list available tags for a repository (bonus tool).
|
||||
Uses `SYNO.Docker.Registry/tags` (version 1) with the repository
|
||||
JSON-encoded as `repo` — note the parameter name diverges from the n4s4
|
||||
reference which uses `name`. The response shape is unusual: DSM returns
|
||||
the tag list as the envelope's `data` field directly (not wrapped in a
|
||||
sub-key), so the tool accepts both shapes defensively. Output is capped
|
||||
by `limit` (default 50) because popular images like `alpine` ship 200+
|
||||
tags. No confirmation gate (read-only).
|
||||
- `pull_image` (#3) — pull an image into the local cache via
|
||||
`SYNO.Docker.Registry/pull_start` (version 1) with both `repository` and
|
||||
`tag` JSON-encoded. Requires `confirmed=True`. DSM exposes no confirmed
|
||||
`pull_status` method, so completion is detected by polling
|
||||
`SYNO.Docker.Image/list` for the new `repository:tag` pair with a 2–10 s
|
||||
backoff schedule and a 240 s overall budget (kept under the Claude
|
||||
Desktop ~4 min tool-call ceiling). A timeout returns a non-fatal "still
|
||||
running" hint pointing at `list_images` instead of raising — DSM keeps
|
||||
pulling server-side regardless. Short-circuits when the image is already
|
||||
present locally so a repeated call is cheap. Closes #3 and #5.
|
||||
|
||||
Tool count rises from 31 to 34.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `inspect_image` rendering polish (follow-up to #4 after the 0.4.2
|
||||
parameter-contract fix). Three live-NAS observations that the
|
||||
initial implementation got wrong:
|
||||
- **Header showed `?:?`** — DSM `SYNO.Docker.Image/get` returns
|
||||
`image=""` and `tag=""` when the lookup is by name:tag, so the
|
||||
response fields are unreliable. The header now echoes the user-
|
||||
supplied `image_id` (e.g. `gitea/gitea:1.26.1`), falling back to
|
||||
the sha256 `id` only if `image_id` itself were empty.
|
||||
- **Ports rendered as raw Python dicts** like
|
||||
`{'port': '22', 'protocol': 'tcp'}`. The `ports` array is actually
|
||||
a list of `{"port", "protocol"}` objects; each entry is now
|
||||
formatted as `22/tcp`.
|
||||
- **Environment rendered as raw Python dicts** like
|
||||
`{'key': 'PATH', 'value': '...'}`. The `env` array is actually a
|
||||
list of `{"key", "value"}` objects; each entry is now formatted
|
||||
as `PATH=/usr/local/...`.
|
||||
`cmd`, `entrypoint`, and `volumes` (plain string arrays) were
|
||||
already correct and are unchanged. Referencing #4 (already closed).
|
||||
|
||||
## [0.4.2] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- `inspect_image` (#4): the 0.4.0 implementation called
|
||||
`SYNO.Docker.Image/get` with `name` + `tag` + `id` parameters, which
|
||||
DSM rejected with error 114 ("invalid parameter"). Reverse-
|
||||
engineering the live API capture revealed the correct contract:
|
||||
one JSON-encoded parameter named `identity` that accepts either form:
|
||||
- `name:tag` (e.g. `"gitea/gitea:1.26.1"`)
|
||||
- `sha256:<hash>` (e.g. `"sha256:cd21e54e..."`)
|
||||
The pre-resolution lookup against `list_images` is no longer needed —
|
||||
the user input is JSON-encoded and passed straight through.
|
||||
- `inspect_image` response parsing: the endpoint does NOT return
|
||||
`details.Config.*` / `RootFS.Layers` (the Docker-engine inspect shape
|
||||
the previous code assumed). The actual top-level fields are `image`,
|
||||
`tag`, `id`, `digest`, `size`, `virtual_size`, `author`,
|
||||
`docker_version`, `cmd`, `entrypoint`, `env`, `ports`, `volumes`.
|
||||
Layer rendering is removed (the endpoint does not surface layers);
|
||||
digest, author, docker version, and volumes are now displayed.
|
||||
|
||||
## [0.4.1] - 2026-05-18
|
||||
|
||||
### Removed
|
||||
|
||||
- `pause_container` and `unpause_container` (added in 0.4.0) — live
|
||||
test against DSM revealed that Container Manager on this firmware
|
||||
does not expose pause/resume at all. The GUI action menu only offers
|
||||
Start / Stop / Force-Stop / Restart / Reset, and direct calls to
|
||||
`SYNO.Docker.Container/pause` and `/unpause` return "Method does not
|
||||
exist". Both tools have been removed rather than left as a broken
|
||||
surface. The remaining three lifecycle tools (`start_container`,
|
||||
`stop_container`, `restart_container`) are unaffected. Tool count
|
||||
drops from 33 to 31. Closes #7 (won't fix — DSM-side limitation).
|
||||
|
||||
## [0.4.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
**Container lifecycle (#1, #7)** — five new tools fill the gap between
|
||||
"project" and "exec" operations. Until now the only way to stop or
|
||||
restart a single container was to stop the whole project. All five
|
||||
use `SYNO.Docker.Container` (version=1) with the same JSON-encoded
|
||||
`name` parameter pattern as `delete_container`, and route through
|
||||
`_resolve_container_name` so a clean name like `jenkins` resolves
|
||||
to DSM's hash-prefixed `f93cb8b504f7_jenkins`:
|
||||
|
||||
- `start_container` — start a stopped container (no confirmation).
|
||||
- `stop_container` — stop a running container (confirmation required).
|
||||
- `restart_container` — stop + start in one call (confirmation required).
|
||||
- `pause_container` — SIGSTOP the container's processes (confirmation
|
||||
required).
|
||||
- `unpause_container` — SIGCONT the container's processes (no
|
||||
confirmation; reversing a pause is non-destructive).
|
||||
|
||||
`stop` is live-verified against DSM; `start`, `restart`, `pause`, and
|
||||
`unpause` are implemented by symmetry on the same API surface. If any
|
||||
of them turns out to need a different parameter shape in production,
|
||||
it will be reverse-engineered via DevTools capture in a follow-up.
|
||||
|
||||
**Image inspection (#4)** — `inspect_image` returns the full image
|
||||
detail blob (layers + sizes, environment variables, exposed ports,
|
||||
entrypoint/cmd, working directory, labels) via
|
||||
`SYNO.Docker.Image/get`. Accepts the same identifier forms as
|
||||
`delete_image` — `name:tag`, registry-prefixed `ghcr.io/foo/bar:v1`,
|
||||
and bare hash — and resolves the user input against `list_images`
|
||||
first so typos produce a clean "not found" rather than an opaque DSM
|
||||
error. The response parser is defensive about wrapper shape
|
||||
(`details.<docker fields>` vs. flat) so the tool keeps working if DSM
|
||||
varies the envelope between firmware versions.
|
||||
|
||||
**System overview (#6)** — `system_overview` aggregates CPU %, RAM
|
||||
used/limit, network I/O, and block I/O across all running containers
|
||||
plus running/stopped counts. No new DSM endpoint: composed from
|
||||
`SYNO.Docker.Container/list` + `SYNO.Docker.Container/stats`,
|
||||
re-using the same Docker-formula CPU calculation as
|
||||
`container_stats`. Errors from either upstream call are non-fatal —
|
||||
the available section is rendered and the failure is surfaced under a
|
||||
`Warnings:` block.
|
||||
|
||||
## [0.3.3] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- `delete_container`: `SYNO.Docker.Container/delete` requires three
|
||||
parameters — `name` (JSON-encoded), `force=false`, and
|
||||
`preserve_profile=false`. Previously only `name` was sent (without
|
||||
JSON-encoding), causing DSM to reject the call with error 114.
|
||||
- `delete_project`: DSM does **not** reject `Project/delete` on a
|
||||
running project — it silently removes the registration and leaves
|
||||
the containers running as orphans. The connector now blocks the call
|
||||
itself when the project status is `RUNNING`, before issuing any DSM
|
||||
request, and tells the user to `stop_project` first. The previous
|
||||
implementation relied on a DSM-level rejection that never occurs in
|
||||
practice; the corresponding unit test was a false positive (mocked
|
||||
an error that real DSM never returns) and has been replaced with a
|
||||
test that asserts `Project/delete` is never called for a `RUNNING`
|
||||
project.
|
||||
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- `delete_project` — remove a Container Manager project's
|
||||
registration via `SYNO.Docker.Project/delete` with the UUID
|
||||
JSON-encoded as the `id` parameter (per DSM convention).
|
||||
Mirrors the "Delete project" action in Container Manager: only the
|
||||
registration is removed; the project folder and compose file remain
|
||||
on the NAS. The success message explicitly states the folder was
|
||||
preserved so the user is not surprised. Closes the project
|
||||
lifecycle (create → start/stop/redeploy → delete).
|
||||
Safety:
|
||||
- Project-name validation runs before any I/O.
|
||||
- A `_find_project` pre-flight returns "not found" with a clear
|
||||
message rather than letting DSM reject an unknown UUID.
|
||||
- The tool deliberately does NOT auto-stop a running project. If
|
||||
DSM rejects the delete on a `RUNNING` project, the response tells
|
||||
the user to `stop_project` first rather than silently halting
|
||||
containers under the guise of a "delete" call.
|
||||
- Requires `confirmed=True`; the preview shows name, UUID, status,
|
||||
full path, and share path so the user can verify before deleting.
|
||||
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- `create_project` — register a new Container Manager project from a
|
||||
compose YAML string. Three-step flow:
|
||||
1. Create the target folder via `SYNO.FileStation.CreateFolder` with
|
||||
`force_parent=true` (idempotent — does not fail if the folder
|
||||
already exists, and creates missing intermediate directories).
|
||||
Without this step, `SYNO.Docker.Project/create` fails with DSM
|
||||
error code 2100.
|
||||
2. `SYNO.Docker.Project/create` (form-encoded POST, JSON-encoded
|
||||
string parameters per DSM convention) returns the new project's
|
||||
UUID.
|
||||
3. `trigger_build_stream` + `_wait_for_project_running` — reuses the
|
||||
existing image-pull / start / poll machinery (including the
|
||||
`BUILD_FAILED` early-exit from welle 2).
|
||||
Defaults: `share_path` is derived from `compose_base_path` (e.g.
|
||||
`/volume1/docker` + `myapp` → `/docker/myapp`). The compose content
|
||||
is validated as YAML before any side effects. A pre-flight
|
||||
`list_projects` check rejects duplicate names with a clear message
|
||||
rather than leaving an orphaned folder on the NAS. Requires
|
||||
`confirmed=True`; the preview shows the resolved share path and the
|
||||
service count parsed from the compose content.
|
||||
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- `DsmClient.trigger_build_stream`: broaden transport-error handling
|
||||
(M-4). `httpx.ReadTimeout` is still treated as a success signal (the
|
||||
request was sent; DSM is processing it server-side), but all other
|
||||
`httpx.HTTPError` subclasses (`ConnectError`, `ConnectTimeout`,
|
||||
`WriteError`, `RemoteProtocolError`, …) are now converted into a
|
||||
`SynologyError` with a clear message. Previously these propagated as
|
||||
raw httpx exceptions and left callers with a stack trace.
|
||||
- `redeploy_project`: when build_stream (or the polling step) fails
|
||||
after the project has already been stopped, the response now
|
||||
explicitly tells the user that the project is in `STOPPED` state and
|
||||
must be recovered with `start_project` or another `redeploy_project`
|
||||
call. Previously the response suggested "use stop + start
|
||||
separately", which was misleading because stop had already happened.
|
||||
- `_wait_for_project_running`: exit the polling loop early on
|
||||
`BUILD_FAILED` / `ERROR` (M-5). DSM signals these statuses within
|
||||
seconds of a failed image pull; the old polling loop kept waiting up
|
||||
to 5 minutes for `RUNNING`. `redeploy_project` surfaces the terminal
|
||||
status with a hint to `update_image_tag` and retry when the cause is
|
||||
`BUILD_FAILED`.
|
||||
- `system_prune` preview: count unused user-created networks alongside
|
||||
dangling images and stopped containers (M-6). Built-in networks
|
||||
(`bridge`, `host`, `none`) are excluded because Docker never prunes
|
||||
them. Previously the preview noted "Unused networks: (not counted)",
|
||||
even though the underlying `SYNO.Docker.Utils/prune` call deletes
|
||||
them — users could lose networks they had not been warned about.
|
||||
|
||||
### Changed
|
||||
|
||||
- Minor version bump because `redeploy_project` and `system_prune`
|
||||
return different strings (and `redeploy_project` returns earlier on
|
||||
`BUILD_FAILED`). No tool signatures changed.
|
||||
|
||||
## [0.2.9] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- `__version__` is now derived from package metadata via
|
||||
`importlib.metadata.version()`, eliminating the drift between
|
||||
`pyproject.toml` and `src/mcp_synology_container/__init__.py` (was stuck
|
||||
at `0.1.0` since the initial release).
|
||||
- `compose.py`: reject project names that contain path separators or other
|
||||
unsafe characters before they reach `_find_compose_path`. Previously a
|
||||
name like `"../../etc"` could traverse out of `compose_base_path` when
|
||||
the project was not yet registered with Container Manager. The new
|
||||
`_validate_project_name` helper enforces `^[a-zA-Z0-9_-]+$` and is
|
||||
applied to `read_compose`, `update_compose`, `update_image_tag`, and
|
||||
`update_env_var`. Addresses M-3 from the v0.2.8 review.
|
||||
|
||||
### Docs
|
||||
|
||||
- `CHANGELOG.md` backfilled for releases 0.2.7 and 0.2.8 (entries had been
|
||||
missed during those releases).
|
||||
|
||||
## [0.2.8] - 2026-04-21
|
||||
|
||||
### Added
|
||||
|
||||
- `tests/test_dsm_client.py` — comprehensive offline test suite for
|
||||
`DsmClient`:
|
||||
- `_scrub_url` and `_error_message` pure helpers.
|
||||
- `request()` happy-path, API-not-cached, `_sid` scrubbing in
|
||||
`HTTPStatusError`, sensitive-param log masking.
|
||||
- Session re-auth retry: single-retry semantics, auth-manager-absent
|
||||
path, re-auth failure path, thundering-herd (login called once under
|
||||
concurrent 106 responses).
|
||||
- `trigger_build_stream`: SSE fire-and-forget, JSON error detection,
|
||||
`ReadTimeout` swallowing, HTTP-error scrubbing.
|
||||
- `upload_text` and `download_text` happy-path + error-response branches.
|
||||
- `_ensure_initialized` double-checked locking and negative-cache
|
||||
cooldown behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `DsmClient._ensure_initialized`: cache failed init outcomes for 60 s so
|
||||
that repeated tool calls during a credential outage (wrong password,
|
||||
IP-blocked 407, DNS failure) do not keep hammering DSM. Each caller
|
||||
receives the cached exception until the cooldown window expires, after
|
||||
which a fresh attempt is made. Adds `INIT_ERROR_COOLDOWN` module
|
||||
constant and `_init_error` / `_init_error_until` state. Addresses M4
|
||||
from the 0.2.7 review.
|
||||
|
||||
## [0.2.7] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- `DsmClient`: scrub `_sid` query-parameter values from URLs embedded in
|
||||
`httpx.HTTPStatusError` messages so the raw DSM session ID never reaches
|
||||
log output or MCP tool responses (C1).
|
||||
- `DsmClient`: re-auth lock now snapshots `_sid` before entering the lock
|
||||
and skips the redundant login if another task has already refreshed the
|
||||
session, eliminating duplicate logins on concurrent 106/107/119
|
||||
responses (M3).
|
||||
- `DsmClient.trigger_build_stream`: re-instates immediate JSON-error
|
||||
detection (regression from 0.2.6). Inspects the `Content-Type` header
|
||||
and reads a small capped prefix of the body for `application/json`
|
||||
responses to surface DSM error codes without forcing the caller into a
|
||||
multi-minute polling timeout. SSE responses remain fire-and-forget
|
||||
(C2/M8).
|
||||
- `compose.update_env_var`: parenthesise the apply-branch match condition
|
||||
so `(isinstance AND startswith) OR (entry == var_name)` no longer
|
||||
evaluates the equality branch for non-string entries — aligns the apply
|
||||
side with the preview-side detection logic (M1).
|
||||
|
||||
### Changed
|
||||
|
||||
- All 23 `@mcp.tool()` functions: strip `-> str` return annotations and
|
||||
trim docstrings to a single line (≤100 chars). FastMCP generates an
|
||||
`outputSchema` entry for every annotated tool, which roughly doubles
|
||||
the `tools/list` payload size; multi-line docstrings with
|
||||
`Args:`/`Returns:` sections add further bulk that Claude Desktop must
|
||||
parse on every connection.
|
||||
|
||||
### Chore
|
||||
|
||||
- `uv.lock` resynced (was stale at `0.2.2`).
|
||||
- `.gitignore`: exclude `.claude/` per-user Claude Code settings.
|
||||
- Mechanical `ruff check --fix` + `ruff format` cleanup (import sorting,
|
||||
unused-import removal). No functional change.
|
||||
|
||||
## [0.2.6] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -26,18 +26,24 @@ containers, images, compose files, networks, and system housekeeping.
|
||||
3. Restart Claude Desktop (tray icon → Quit → relaunch)
|
||||
```
|
||||
|
||||
**Push retry:** the Gitea remote (`gitea.gecheckt.de`) occasionally
|
||||
returns `Unauthorized` on the first push attempt. If `git push` fails
|
||||
with an auth error, wait 1 s and retry once before reporting back.
|
||||
Only a second consecutive failure is treated as a real auth problem.
|
||||
|
||||
---
|
||||
|
||||
## Implemented tools (23)
|
||||
## Implemented tools (35)
|
||||
|
||||
| Category | Tools |
|
||||
|---|---|
|
||||
| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project` |
|
||||
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container` |
|
||||
| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project`, `delete_project` |
|
||||
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `inspect_container`, `delete_container`, `start_container`, `stop_container`, `restart_container` |
|
||||
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` |
|
||||
| Images | `check_image_updates`, `list_images`, `delete_image` |
|
||||
| Images | `check_image_updates`, `list_images`, `delete_image`, `inspect_image` |
|
||||
| Registry | `search_registry`, `list_image_tags`, `pull_image` |
|
||||
| Networks | `list_networks`, `create_network`, `delete_network` |
|
||||
| System | `system_df`, `system_prune` |
|
||||
| System | `system_df`, `system_prune`, `system_overview` |
|
||||
|
||||
---
|
||||
|
||||
@@ -50,28 +56,88 @@ containers, images, compose files, networks, and system housekeeping.
|
||||
- **Async project start** — `SYNO.Docker.Project/start` returns immediately
|
||||
while containers are still initialising. `redeploy_project` polls
|
||||
`SYNO.Docker.Project/list` every 2 s for up to 30 s after issuing start.
|
||||
- **`SYNO.Docker.Project/build_stream`** — returns a streamed plaintext
|
||||
build log (content-type `text/html`), one short line per step:
|
||||
`Container <name> Running` on success, `<svc> Error` followed by
|
||||
`Error response from daemon: <cause>` on failure. The stream closes
|
||||
when the build is done. `DsmClient.trigger_build_stream` consumes the
|
||||
body line-by-line with a 210 s wall-clock budget (under the Claude
|
||||
Desktop ~4 min ceiling) and returns the log as a string; on timeout
|
||||
the partial log is returned with a marker appended so callers know
|
||||
the build is still running server-side. `redeploy_project` and
|
||||
`create_project` grep the returned log for daemon errors and abort
|
||||
early — these errors are much more actionable than the eventual
|
||||
`BUILD_FAILED` polling status. The log is **live-only**: it cannot
|
||||
be re-fetched after the build ends, which is why no standalone
|
||||
`get_project_build_log` tool exists.
|
||||
- **Image delete** — requires a form-encoded POST with a JSON `images` array
|
||||
(confirmed via browser DevTools); uses `DsmClient.post_request()`.
|
||||
- **`SYNO.Docker.Image/pull`** — API method exists but behaviour varies by
|
||||
DSM version; not exposed as a standalone tool.
|
||||
- **`SYNO.Docker.Image/pull` vs. `pull_start`** — the legacy `pull` method
|
||||
exists but behaviour varies by DSM version; not exposed as a standalone
|
||||
tool. `pull_image` uses `SYNO.Docker.Image/pull_start` (asynchronous
|
||||
pull entry point) with both `repository` and `tag` JSON-encoded. Note
|
||||
that `pull_start` lives on **`SYNO.Docker.Image`**, NOT on
|
||||
`SYNO.Docker.Registry` — the Registry API only exposes the synchronous
|
||||
read-only methods (`search`, `tags`, `get/set/create/delete`, `using`);
|
||||
calling `Registry/pull_start` returns "Method does not exist". No
|
||||
matching `pull_status` method is confirmed on either API, so completion
|
||||
is detected by polling `SYNO.Docker.Image/list` until `repository:tag`
|
||||
appears (2–10 s backoff, 240 s budget). Timeout returns a "still
|
||||
running" hint instead of raising — DSM keeps pulling server-side
|
||||
regardless of the HTTP response.
|
||||
- **`SYNO.Docker.Registry/tags`** — uses `repo` (JSON-encoded) as the
|
||||
parameter name; the n4s4 reference's `name` does not work on this DSM
|
||||
version. Returns the tag list as the envelope's `data` field directly,
|
||||
not wrapped in a sub-key.
|
||||
- **`SYNO.Docker.Volume`** — endpoint does not exist; volume management is
|
||||
not available via the DSM WebAPI.
|
||||
- **`SYNO.Docker.Registry/get`** — does not behave as documented; registry
|
||||
listing omitted.
|
||||
- **`SYNO.Docker.Container/pause` and `/unpause`** — not implemented in
|
||||
DSM Container Manager on this firmware. The action menu only offers
|
||||
start/stop/force-stop/restart/reset; calls to `pause`/`unpause` return
|
||||
"Method does not exist". `pause_container` and `unpause_container`
|
||||
were briefly shipped in 0.4.0 and removed in 0.4.1.
|
||||
- **`SYNO.Docker.Container/get` response — `profile.volume_bindings[].host_volume_file`
|
||||
is share-relative, not the full host path.** Live capture against a
|
||||
container with bind mount `/volume1/docker/homeassistant:/config`
|
||||
returned `host_volume_file = "/docker/homeassistant"` (21 chars,
|
||||
share-relative) in `profile`, while `details.Mounts[].Source` carried
|
||||
the full `/volume1/docker/homeassistant` and `details.HostConfig.Binds`
|
||||
the full `/volume1/docker/homeassistant:/config:rw`. For
|
||||
Compose-rebuild use cases the full path is required — `inspect_container`
|
||||
reads mount sources from `details.Mounts[].Source`, not from
|
||||
`profile.volume_bindings[].host_volume_file`. The DSM action `inspect`
|
||||
(no `get`) does not exist (code 103 "Method does not exist"); use `get`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation rules
|
||||
|
||||
- Confirmation required before destructive operations: `stop_project`,
|
||||
`redeploy_project`, `exec_in_container`, `update_image_tag`,
|
||||
`update_env_var`, `update_compose`, `delete_container`
|
||||
`redeploy_project`, `create_project`, `delete_project`,
|
||||
`exec_in_container`, `update_image_tag`, `update_env_var`,
|
||||
`update_compose`, `delete_container`, `stop_container`,
|
||||
`restart_container`, `pull_image`
|
||||
- After compose changes: suggest `redeploy_project`
|
||||
- DSM errors → human-readable message, no stack traces
|
||||
- No secrets in stderr output
|
||||
- Type hints and docstrings everywhere
|
||||
- Formatter: `ruff format` · Linter: `ruff check` · Tests: `pytest`
|
||||
- All text (docstrings, comments, README): English
|
||||
- **CHANGELOG.md:** every user-visible change (bug fix, new/changed
|
||||
tool, behavior change, security fix, dependency bump) gets a
|
||||
`CHANGELOG.md` entry in the same commit — under a `## [Unreleased]`
|
||||
heading between releases, which becomes `## [X.Y.Z] - YYYY-MM-DD` on
|
||||
version bump. Pure internal cleanup (renames without external callers,
|
||||
comment-only edits, ruff autofix) needs no entry. Don't ship a release
|
||||
with a stale changelog (this was the C-2 gap that caused 0.2.7 and
|
||||
0.2.8 to ship undocumented).
|
||||
- **Version consistency:** the package version lives in `pyproject.toml`
|
||||
and must stay in sync with `uv.lock` and the `[X.Y.Z]` heading in
|
||||
`CHANGELOG.md`. `src/mcp_synology_container/__init__.py` derives
|
||||
`__version__` from `importlib.metadata` and is never hand-edited.
|
||||
Every version bump touches all three files in the same commit.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcp-synology-container"
|
||||
version = "0.2.7"
|
||||
version = "0.7.0"
|
||||
description = "MCP server for Synology Container Manager"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
"""MCP server for Synology Container Manager."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
try:
|
||||
__version__ = version("mcp-synology-container")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0+unknown"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
@@ -144,10 +145,8 @@ async def _run_setup() -> None:
|
||||
client.sid = sid
|
||||
click.echo(click.style("Login successful!", fg="green"))
|
||||
# Logout cleanly
|
||||
try:
|
||||
with contextlib.suppress(SynologyError):
|
||||
await client.request("SYNO.API.Auth", "logout", version=6, params={})
|
||||
except SynologyError:
|
||||
pass
|
||||
else:
|
||||
click.echo(click.style("Login failed: no session ID returned.", fg="red"), err=True)
|
||||
sys.exit(1)
|
||||
@@ -291,6 +290,7 @@ def serve(config_path: str | None) -> None:
|
||||
# and anyio.run() ensures the correct backend context is set up.
|
||||
# asyncio.run() can cause issues on Windows (ProactorEventLoop + anyio).
|
||||
import anyio
|
||||
|
||||
anyio.run(_run_serve, config_path)
|
||||
|
||||
|
||||
|
||||
@@ -132,10 +132,7 @@ def _validate_config(raw: dict[str, Any]) -> AppConfig:
|
||||
"""
|
||||
schema_version = raw.get("schema_version")
|
||||
if schema_version != CURRENT_SCHEMA_VERSION:
|
||||
msg = (
|
||||
f"Config schema_version is {schema_version!r}, "
|
||||
f"expected {CURRENT_SCHEMA_VERSION}."
|
||||
)
|
||||
msg = f"Config schema_version is {schema_version!r}, expected {CURRENT_SCHEMA_VERSION}."
|
||||
raise ValueError(msg)
|
||||
|
||||
conn_raw = raw.get("connection", {})
|
||||
|
||||
@@ -27,6 +27,10 @@ logger = logging.getLogger(__name__)
|
||||
# Session error codes that trigger transparent re-auth
|
||||
_SESSION_ERROR_CODES = frozenset({106, 107, 119})
|
||||
|
||||
# Cooldown after a failed init so repeated tool calls don't hammer DSM
|
||||
# (and don't escalate 407 "IP blocked" lockouts).
|
||||
INIT_ERROR_COOLDOWN = 60.0
|
||||
|
||||
# Parameters to mask in debug logging
|
||||
_SENSITIVE_PARAMS = frozenset({"passwd", "_sid", "device_id", "otp_code", "device_token"})
|
||||
|
||||
@@ -111,6 +115,8 @@ class DsmClient:
|
||||
self._init_lock = asyncio.Lock()
|
||||
self._initialized = False
|
||||
self._initializing = False # True while inside _ensure_initialized
|
||||
self._init_error: Exception | None = None
|
||||
self._init_error_until: float = 0.0
|
||||
logger.debug(
|
||||
"DsmClient: base_url=%s verify_ssl=%s timeout=%d",
|
||||
self._base_url,
|
||||
@@ -141,6 +147,10 @@ class DsmClient:
|
||||
async with self._init_lock:
|
||||
if self._initialized: # re-check inside lock
|
||||
return
|
||||
# Negative cache: re-raise cached error during cooldown window.
|
||||
now = asyncio.get_event_loop().time()
|
||||
if self._init_error is not None and now < self._init_error_until:
|
||||
raise self._init_error
|
||||
self._initializing = True
|
||||
try:
|
||||
sys.stderr.write(f"[dsm] Connecting to {self._base_url}...\n")
|
||||
@@ -157,7 +167,18 @@ class DsmClient:
|
||||
sys.stderr.write("[dsm] Auth OK\n")
|
||||
sys.stderr.flush()
|
||||
self._initialized = True
|
||||
self._init_error = None
|
||||
self._init_error_until = 0.0
|
||||
logger.debug("Lazy init complete")
|
||||
except Exception as e:
|
||||
self._init_error = e
|
||||
self._init_error_until = now + INIT_ERROR_COOLDOWN
|
||||
logger.warning(
|
||||
"Lazy init failed (%s); will retry after %.0fs cooldown",
|
||||
type(e).__name__,
|
||||
INIT_ERROR_COOLDOWN,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self._initializing = False
|
||||
|
||||
@@ -398,27 +419,53 @@ class DsmClient:
|
||||
logger.debug("DSM POST response: %s/%s — error code %d", api, method, code)
|
||||
raise SynologyError(_error_message(code, api), code=code)
|
||||
|
||||
async def trigger_build_stream(self, project_id: str) -> None:
|
||||
"""Trigger SYNO.Docker.Project/build_stream — the "Erstellen" button equivalent.
|
||||
# Wall-clock budget for consuming the build_stream body. DSM keeps the
|
||||
# connection open until the build is done (success or failure), so we
|
||||
# need to give it enough time for an image pull while staying under the
|
||||
# Claude Desktop ~4 min tool-call ceiling. When the budget is exhausted
|
||||
# the partial log is returned with a "still running" marker appended.
|
||||
BUILD_STREAM_BUDGET = 210.0
|
||||
|
||||
# Marker appended to the returned log when the stream did not finish
|
||||
# within BUILD_STREAM_BUDGET. Callers can grep for this to decide
|
||||
# whether the log they got is complete.
|
||||
BUILD_STREAM_TIMEOUT_MARKER = "[build_stream: timeout — stream still open server-side]"
|
||||
|
||||
async def trigger_build_stream(self, project_id: str) -> str:
|
||||
"""Trigger SYNO.Docker.Project/build_stream and return the full log text.
|
||||
|
||||
This is the proper way to force an image pull and project restart in DSM
|
||||
Container Manager (confirmed via browser DevTools). The endpoint is a
|
||||
Server-Sent Events (SSE) stream on success; we send the request, check
|
||||
the response headers, and close without consuming the SSE body. DSM
|
||||
starts the build upon receiving the request and continues server-side
|
||||
regardless of whether the HTTP connection stays open. Callers should
|
||||
poll SYNO.Docker.Project/list for the resulting RUNNING status.
|
||||
Container Manager (confirmed via browser DevTools). The endpoint
|
||||
returns a streamed plaintext response (one short status line per step,
|
||||
e.g. ``Container <name> Running`` on success or ``<svc> Error`` followed
|
||||
by ``Error response from daemon: <cause>`` on failure). DSM closes the
|
||||
stream when the build is done.
|
||||
|
||||
Error detection: DSM signals application-level rejection (e.g. project
|
||||
locked, invalid id) as an HTTP-200 JSON body `{"success": false, ...}`
|
||||
rather than as an SSE stream. We inspect the `Content-Type` header and,
|
||||
when it is `application/json`, read a small capped prefix of the body
|
||||
to surface the DSM error code immediately instead of forcing the caller
|
||||
into a multi-minute polling timeout. SSE responses are not read.
|
||||
The body is consumed line-by-line and returned as a single string so
|
||||
callers (redeploy_project, create_project) can surface the real cause
|
||||
of a failed build instead of waiting for the polling step to report
|
||||
``BUILD_FAILED`` with no context.
|
||||
|
||||
Error detection precedence:
|
||||
1. HTTP status (4xx/5xx) → SynologyError, body not consumed.
|
||||
2. JSON content-type → DSM rejected the request before streaming
|
||||
(e.g. project locked, invalid id); a ``success: false`` envelope is
|
||||
raised as SynologyError. Malformed JSON is treated as accepted.
|
||||
3. Streamed plaintext → returned verbatim; per-line failure parsing
|
||||
is the caller's responsibility (look for ``Error response from
|
||||
daemon:`` or ``<svc> Error``).
|
||||
|
||||
Args:
|
||||
project_id: Project UUID from SYNO.Docker.Project/list.
|
||||
|
||||
Returns:
|
||||
The collected build log as a single string (may be empty if DSM
|
||||
returned a non-JSON empty body). Appended with
|
||||
:attr:`BUILD_STREAM_TIMEOUT_MARKER` when the wall-clock budget
|
||||
ran out before DSM closed the stream — in that case the build is
|
||||
still running server-side and the caller's polling step is the
|
||||
authoritative status source.
|
||||
|
||||
Raises:
|
||||
SynologyError: If DSM rejects the build with a JSON error body, or
|
||||
if the HTTP response status indicates a transport-level error.
|
||||
@@ -445,16 +492,23 @@ class DsmClient:
|
||||
sys.stderr.flush()
|
||||
logger.debug("build_stream: project_id=%s", project_id)
|
||||
|
||||
# Fire-and-forget for the SSE body, but detect immediate JSON errors.
|
||||
# The read timeout only applies to waiting for response *headers* and
|
||||
# for the (small, capped) JSON error body we read; we never consume SSE
|
||||
# events, so DSM's streaming cannot block this call indefinitely.
|
||||
# Wall-clock deadline for the streamed body. Individual chunks get a
|
||||
# generous per-read timeout because DSM may pause between status lines
|
||||
# during a slow image pull.
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + self.BUILD_STREAM_BUDGET
|
||||
|
||||
try:
|
||||
async with http.stream(
|
||||
"GET",
|
||||
url,
|
||||
params=params,
|
||||
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=5.0),
|
||||
timeout=httpx.Timeout(
|
||||
connect=10.0,
|
||||
read=60.0,
|
||||
write=10.0,
|
||||
pool=5.0,
|
||||
),
|
||||
) as resp:
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
@@ -467,8 +521,8 @@ class DsmClient:
|
||||
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
# DSM rejected the build — read the JSON error body (capped
|
||||
# at ~4 KB; DSM error envelopes are tiny).
|
||||
# DSM rejected the build before streaming — read the JSON
|
||||
# error body (capped at ~4 KB; DSM error envelopes are tiny).
|
||||
body = b""
|
||||
async for chunk in resp.aiter_bytes():
|
||||
body += chunk
|
||||
@@ -479,17 +533,58 @@ class DsmClient:
|
||||
except json.JSONDecodeError:
|
||||
# Malformed response — treat as accepted and let the
|
||||
# caller's polling surface any real failure.
|
||||
return
|
||||
return ""
|
||||
if not parsed.get("success", True):
|
||||
code = parsed.get("error", {}).get("code", 0)
|
||||
raise SynologyError(_error_message(code, api), code=code)
|
||||
# success=true with JSON content-type: odd, treat as accepted.
|
||||
return
|
||||
# SSE or anything else → fire-and-forget, close without reading.
|
||||
# success=true with JSON content-type: no streamed log.
|
||||
return ""
|
||||
|
||||
# Streamed plaintext (DSM uses text/html for build_stream).
|
||||
# Collect line-by-line until DSM closes the stream or the
|
||||
# wall-clock budget runs out.
|
||||
lines: list[str] = []
|
||||
timed_out = False
|
||||
try:
|
||||
async for raw_line in resp.aiter_lines():
|
||||
# Strip Windows line endings; aiter_lines already
|
||||
# splits on \n but preserves trailing \r on some httpx
|
||||
# versions.
|
||||
line = raw_line.rstrip("\r\n")
|
||||
if line:
|
||||
lines.append(line)
|
||||
if loop.time() >= deadline:
|
||||
timed_out = True
|
||||
break
|
||||
except httpx.ReadTimeout:
|
||||
# A chunk didn't arrive within the per-read window. DSM is
|
||||
# still building server-side; surface what we have.
|
||||
timed_out = True
|
||||
|
||||
log_text = "\n".join(lines)
|
||||
if timed_out:
|
||||
log_text = (
|
||||
f"{log_text}\n{self.BUILD_STREAM_TIMEOUT_MARKER}"
|
||||
if log_text
|
||||
else self.BUILD_STREAM_TIMEOUT_MARKER
|
||||
)
|
||||
return log_text
|
||||
except httpx.ReadTimeout:
|
||||
# Headers not received within 10 s, but the GET request was already
|
||||
# sent. DSM received it and started the build. Proceed to polling.
|
||||
pass
|
||||
# Headers not received within the connect/read window, but the GET
|
||||
# was already sent. DSM received it and started the build; return
|
||||
# the timeout marker so the caller knows there's no log yet.
|
||||
return self.BUILD_STREAM_TIMEOUT_MARKER
|
||||
except httpx.HTTPError as e:
|
||||
# Other transport-level failures (ConnectError, ConnectTimeout,
|
||||
# WriteError, RemoteProtocolError, …) mean DSM never received the
|
||||
# build request. Surface a clear SynologyError instead of letting
|
||||
# the raw httpx exception bubble up — the caller (redeploy_project)
|
||||
# has typically already stopped the project and needs to know that
|
||||
# the build did not start.
|
||||
raise SynologyError(
|
||||
f"build_stream transport error: {type(e).__name__}: {e}",
|
||||
code=0,
|
||||
) from None
|
||||
|
||||
async def upload_text(
|
||||
self,
|
||||
|
||||
@@ -25,6 +25,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_VOLUME_PREFIX_RE = re.compile(r"^/volume\d+")
|
||||
|
||||
# Project names are used as path components for FileStation lookups when the
|
||||
# project is not yet registered with Container Manager. Restrict to a safe
|
||||
# subset so a malicious name like "../../etc" cannot escape compose_base_path.
|
||||
_PROJECT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||||
|
||||
|
||||
def _to_filestation_path(path: str) -> str:
|
||||
"""Strip /volumeN prefix so paths work with the FileStation API.
|
||||
@@ -35,6 +40,22 @@ def _to_filestation_path(path: str) -> str:
|
||||
return _VOLUME_PREFIX_RE.sub("", path)
|
||||
|
||||
|
||||
def _validate_project_name(project_name: str) -> str | None:
|
||||
"""Return an error message if project_name is unsafe, else None.
|
||||
|
||||
Allowed characters: letters, digits, underscore, hyphen. Anything else
|
||||
(including '/', '\\', '..', whitespace, empty string) is rejected because
|
||||
the name flows into FileStation path construction when the project is
|
||||
not yet known to Container Manager.
|
||||
"""
|
||||
if not project_name or not _PROJECT_NAME_RE.match(project_name):
|
||||
return (
|
||||
f"Error: invalid project name {project_name!r}. "
|
||||
"Allowed: letters, digits, '_' and '-' (no slashes, dots, or spaces)."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Recognized compose file names (in priority order)
|
||||
_COMPOSE_FILENAMES = [
|
||||
"docker-compose.yml",
|
||||
@@ -68,6 +89,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
@mcp.tool()
|
||||
async def read_compose(project_name: str):
|
||||
"""Read the compose file (YAML) for a project."""
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
path = await _find_compose_path(client, config, project_name)
|
||||
if path is None:
|
||||
project = await _find_project(client, project_name)
|
||||
@@ -97,6 +120,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
confirmed: bool = False,
|
||||
):
|
||||
"""Update a service's image tag in the compose file. Requires confirmed=True."""
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
path = await _find_compose_path(client, config, project_name)
|
||||
if path is None:
|
||||
return f"No compose file found for project '{project_name}'."
|
||||
@@ -216,6 +241,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
confirmed: bool = False,
|
||||
):
|
||||
"""Add or update an env var in a service's compose definition. Requires confirmed=True."""
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
path = await _find_compose_path(client, config, project_name)
|
||||
if path is None:
|
||||
return f"No compose file found for project '{project_name}'."
|
||||
@@ -241,7 +268,7 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
# Determine previous value and build description
|
||||
old_value: str | None = None
|
||||
if isinstance(env_list, list):
|
||||
for i, entry in enumerate(env_list):
|
||||
for entry in env_list:
|
||||
if isinstance(entry, str) and entry.startswith(f"{var_name}="):
|
||||
old_value = entry.split("=", 1)[1]
|
||||
break
|
||||
@@ -298,8 +325,9 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
except Exception as e:
|
||||
return f"Error writing compose file: {e}"
|
||||
|
||||
verb = "Updated" if action == "update" else "Added"
|
||||
return (
|
||||
f"{'Updated' if action == 'update' else 'Added'} env var in '{service_name}' ({project_name}):\n"
|
||||
f"{verb} env var in '{service_name}' ({project_name}):\n"
|
||||
f" {var_name}={var_value}\n\n"
|
||||
f"Tip: Run redeploy_project('{project_name}', confirmed=True) to apply the change."
|
||||
)
|
||||
@@ -311,6 +339,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
confirmed: bool = False,
|
||||
):
|
||||
"""Replace the entire compose file with new YAML content. Requires confirmed=True."""
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
# Validate YAML before anything else
|
||||
try:
|
||||
parsed = yaml.safe_load(new_content)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -319,12 +320,95 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"delete",
|
||||
params={"name": resolved_name},
|
||||
params={
|
||||
"name": json.dumps(resolved_name),
|
||||
"force": "false",
|
||||
"preserve_profile": "false",
|
||||
},
|
||||
)
|
||||
return f"Deleted container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error deleting '{display_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def start_container(container_name: str):
|
||||
"""Start a stopped container."""
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"start",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Started container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error starting container '{container_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def stop_container(container_name: str, confirmed: bool = False):
|
||||
"""Stop a running container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would stop container '{container_name}'.\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"stop",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Stopped container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error stopping container '{container_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def inspect_container(container_name: str):
|
||||
"""Inspect a container — image, ports, mounts (full host paths), env, labels, network."""
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"get",
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting container '{container_name}': {e}"
|
||||
|
||||
if not data:
|
||||
return f"Container '{container_name}' not found."
|
||||
|
||||
return _format_container_inspect(display_name, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def restart_container(container_name: str, confirmed: bool = False):
|
||||
"""Restart a container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would restart container '{container_name}'.\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"restart",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Restarted container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error restarting container '{container_name}': {e}"
|
||||
|
||||
|
||||
def _container_in_project(container: dict[str, Any], project_name: str) -> bool:
|
||||
"""Check if a container belongs to a project based on its labels."""
|
||||
@@ -392,3 +476,144 @@ def _format_container_detail(name: str, data: dict[str, Any]) -> str:
|
||||
lines.append(f" {src} → {dst}{type_tag}{rw}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_container_inspect(name: str, data: dict[str, Any]) -> str:
|
||||
"""Format SYNO.Docker.Container/get for the migration-oriented inspect view.
|
||||
|
||||
Reads the volume host path from ``details.Mounts[].Source`` (full
|
||||
``/volume1/...`` path), NOT from ``profile.volume_bindings[].host_volume_file``
|
||||
— the latter is share-relative (e.g. ``/docker/foo`` for ``/volume1/docker/foo``)
|
||||
and is not directly Compose-usable. See CLAUDE.md DSM quirks.
|
||||
"""
|
||||
details: dict[str, Any] = data.get("details", {}) or {}
|
||||
profile: dict[str, Any] = data.get("profile", {}) or {}
|
||||
|
||||
state: dict[str, Any] = details.get("State", {}) or {}
|
||||
host_config: dict[str, Any] = details.get("HostConfig", {}) or {}
|
||||
|
||||
image_str = profile.get("image") or details.get("Config", {}).get("Image", "?")
|
||||
status_str = state.get("Status", "?")
|
||||
running = state.get("Running", False)
|
||||
restart_count = details.get("RestartCount", 0)
|
||||
|
||||
lines = [
|
||||
f"Container: {name}",
|
||||
f" Image: {image_str}",
|
||||
f" Status: {status_str} (running={running})",
|
||||
]
|
||||
if restart_count:
|
||||
lines.append(f" Restarts: {restart_count}")
|
||||
|
||||
# ── Restart policy ──────────────────────────────────────────────────────
|
||||
restart_policy = host_config.get("RestartPolicy", {}) or {}
|
||||
policy_name = restart_policy.get("Name", "")
|
||||
if policy_name:
|
||||
max_retry = restart_policy.get("MaximumRetryCount", 0)
|
||||
suffix = f" (max {max_retry})" if policy_name == "on-failure" and max_retry else ""
|
||||
lines.append(f" Restart: {policy_name}{suffix}")
|
||||
elif profile.get("enable_restart_policy"):
|
||||
lines.append(" Restart: enabled")
|
||||
|
||||
# ── Network ─────────────────────────────────────────────────────────────
|
||||
net_mode = profile.get("network_mode") or host_config.get("NetworkMode", "")
|
||||
use_host = profile.get("use_host_network", False)
|
||||
if use_host:
|
||||
lines.append(" Network: host")
|
||||
elif net_mode:
|
||||
lines.append(f" Network: {net_mode}")
|
||||
|
||||
networks: dict[str, Any] = (details.get("NetworkSettings", {}) or {}).get("Networks", {}) or {}
|
||||
for net_name, net in networks.items():
|
||||
ip = (net or {}).get("IPAddress", "")
|
||||
if ip:
|
||||
lines.append(f" {net_name}: {ip}")
|
||||
|
||||
# ── Ports ───────────────────────────────────────────────────────────────
|
||||
port_bindings: list[dict[str, Any]] = profile.get("port_bindings", []) or []
|
||||
if port_bindings:
|
||||
lines.append("")
|
||||
lines.append(f"Ports ({len(port_bindings)}):")
|
||||
for pb in port_bindings:
|
||||
host = pb.get("host_port", "?")
|
||||
ctr = pb.get("container_port", "?")
|
||||
proto = pb.get("type", "tcp")
|
||||
lines.append(f" {host} → {ctr}/{proto}")
|
||||
|
||||
# ── Mounts ──────────────────────────────────────────────────────────────
|
||||
# Use details.Mounts[].Source — it's the full /volume1/... path.
|
||||
# profile.volume_bindings[].host_volume_file is share-relative and not
|
||||
# Compose-usable (DSM quirk; see CLAUDE.md).
|
||||
mounts: list[dict[str, Any]] = details.get("Mounts", []) or []
|
||||
if mounts:
|
||||
lines.append("")
|
||||
lines.append(f"Mounts ({len(mounts)}):")
|
||||
for m in mounts:
|
||||
src = m.get("Source", "?")
|
||||
dst = m.get("Destination", "?")
|
||||
mtype = m.get("Type", "")
|
||||
rw = "" if m.get("RW", True) else " [ro]"
|
||||
type_tag = f" ({mtype})" if mtype else ""
|
||||
lines.append(f" {src} → {dst}{type_tag}{rw}")
|
||||
|
||||
# ── Environment ─────────────────────────────────────────────────────────
|
||||
env_vars: list[Any] = profile.get("env_variables") or []
|
||||
if env_vars:
|
||||
lines.append("")
|
||||
lines.append(f"Environment ({len(env_vars)}):")
|
||||
for var in env_vars:
|
||||
if isinstance(var, dict):
|
||||
lines.append(f" {var.get('key', '?')}={var.get('value', '')}")
|
||||
else:
|
||||
lines.append(f" {var}")
|
||||
|
||||
# ── Labels ──────────────────────────────────────────────────────────────
|
||||
labels: dict[str, Any] = profile.get("labels") or {}
|
||||
if labels:
|
||||
lines.append("")
|
||||
lines.append(f"Labels ({len(labels)}):")
|
||||
for key in sorted(labels):
|
||||
lines.append(f" {key}={labels[key]}")
|
||||
|
||||
# ── Command / Entrypoint ────────────────────────────────────────────────
|
||||
cfg = details.get("Config", {}) or {}
|
||||
entrypoint = cfg.get("Entrypoint") or []
|
||||
cmd = cfg.get("Cmd") or profile.get("cmd_v2") or profile.get("cmd") or ""
|
||||
|
||||
if entrypoint:
|
||||
ep_str = (
|
||||
" ".join(str(x) for x in entrypoint)
|
||||
if isinstance(entrypoint, list)
|
||||
else str(entrypoint)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"Entrypoint: {ep_str}")
|
||||
if cmd:
|
||||
cmd_str = " ".join(str(x) for x in cmd) if isinstance(cmd, list) else str(cmd)
|
||||
if not entrypoint:
|
||||
lines.append("")
|
||||
lines.append(f"Cmd: {cmd_str}")
|
||||
|
||||
# ── Links ───────────────────────────────────────────────────────────────
|
||||
links: list[Any] = profile.get("links") or []
|
||||
if links:
|
||||
lines.append("")
|
||||
lines.append(f"Links ({len(links)}):")
|
||||
for link in links:
|
||||
lines.append(f" {link}")
|
||||
|
||||
# ── Capabilities / Privileged ───────────────────────────────────────────
|
||||
cap_add = profile.get("CapAdd") or host_config.get("CapAdd") or []
|
||||
cap_drop = profile.get("CapDrop") or host_config.get("CapDrop") or []
|
||||
privileged = profile.get("privileged") or host_config.get("Privileged", False)
|
||||
if cap_add or cap_drop or privileged:
|
||||
lines.append("")
|
||||
lines.append("Security:")
|
||||
if privileged:
|
||||
lines.append(" privileged=true")
|
||||
if cap_add:
|
||||
lines.append(f" CapAdd: {', '.join(str(c) for c in cap_add)}")
|
||||
if cap_drop:
|
||||
lines.append(f" CapDrop: {', '.join(str(c) for c in cap_drop)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -233,8 +233,10 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
)
|
||||
|
||||
if in_use_stopped:
|
||||
stopped_name = in_use_stopped[0]
|
||||
return (
|
||||
f"Cannot delete '{display_name}': image is used by stopped container '{in_use_stopped[0]}'.\n"
|
||||
f"Cannot delete '{display_name}': image is used by stopped container "
|
||||
f"'{stopped_name}'.\n"
|
||||
f"Delete the container first or run system_prune to clean up stopped containers."
|
||||
)
|
||||
|
||||
@@ -269,6 +271,102 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
|
||||
return f"Deleted {display_name} — {size_str} freed."
|
||||
|
||||
@mcp.tool()
|
||||
async def inspect_image(image_id: str):
|
||||
"""Inspect a local image by name:tag or sha256 hash — shows config, env, ports."""
|
||||
# SYNO.Docker.Image/get version=1 expects the parameter "identity"
|
||||
# (JSON-encoded). It accepts both `name:tag` and `sha256:<hash>` —
|
||||
# no pre-resolution needed. Confirmed via DSM API capture.
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"get",
|
||||
version=1,
|
||||
params={"identity": json.dumps(image_id)},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting image '{image_id}': {e}"
|
||||
|
||||
if not isinstance(data, dict) or not data:
|
||||
return f"Image '{image_id}' not found."
|
||||
|
||||
img_hash = data.get("id") or ""
|
||||
# The DSM Image/get response leaves `image` and `tag` empty when the
|
||||
# lookup is by name:tag (live-confirmed against the NAS), so the
|
||||
# response fields are unreliable for the header. Prefer the user-
|
||||
# supplied image_id, then fall back to the id hash.
|
||||
display_name = image_id or img_hash or "?"
|
||||
digest = data.get("digest") or ""
|
||||
size_val = data.get("size") or 0
|
||||
virtual_size_val = data.get("virtual_size") or 0
|
||||
author = data.get("author") or ""
|
||||
docker_version = data.get("docker_version") or ""
|
||||
cmd = data.get("cmd") or []
|
||||
entrypoint = data.get("entrypoint") or []
|
||||
env_list: list[Any] = data.get("env") or []
|
||||
ports: list[Any] = data.get("ports") or []
|
||||
volumes: list[Any] = data.get("volumes") or []
|
||||
|
||||
lines = [f"Image: {display_name}"]
|
||||
if img_hash:
|
||||
lines.append(f" Hash: {img_hash}")
|
||||
if digest:
|
||||
lines.append(f" Digest: {digest}")
|
||||
if size_val:
|
||||
lines.append(f" Size: {_human_size(size_val)}")
|
||||
if virtual_size_val and virtual_size_val != size_val:
|
||||
lines.append(f" Virtual: {_human_size(virtual_size_val)}")
|
||||
if author:
|
||||
lines.append(f" Author: {author}")
|
||||
if docker_version:
|
||||
lines.append(f" Docker: {docker_version}")
|
||||
|
||||
if entrypoint:
|
||||
ep_str = (
|
||||
" ".join(str(x) for x in entrypoint)
|
||||
if isinstance(entrypoint, list)
|
||||
else str(entrypoint)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f" Entrypoint: {ep_str}")
|
||||
if cmd:
|
||||
cmd_str = " ".join(str(x) for x in cmd) if isinstance(cmd, list) else str(cmd)
|
||||
if not entrypoint:
|
||||
lines.append("")
|
||||
lines.append(f" Cmd: {cmd_str}")
|
||||
|
||||
if ports:
|
||||
lines.append("")
|
||||
lines.append(f"Exposed ports ({len(ports)}):")
|
||||
for port in ports:
|
||||
# Live shape: {"port": "22", "protocol": "tcp"}.
|
||||
if isinstance(port, dict):
|
||||
port_num = port.get("port", "?")
|
||||
proto = port.get("protocol", "tcp")
|
||||
lines.append(f" {port_num}/{proto}")
|
||||
else:
|
||||
lines.append(f" {port}")
|
||||
|
||||
if volumes:
|
||||
lines.append("")
|
||||
lines.append(f"Volumes ({len(volumes)}):")
|
||||
for vol in volumes:
|
||||
lines.append(f" {vol}")
|
||||
|
||||
if env_list:
|
||||
lines.append("")
|
||||
lines.append(f"Environment ({len(env_list)}):")
|
||||
for var in env_list:
|
||||
# Live shape: {"key": "PATH", "value": "/usr/local/sbin"}.
|
||||
if isinstance(var, dict):
|
||||
key = var.get("key", "?")
|
||||
value = var.get("value", "")
|
||||
lines.append(f" {key}={value}")
|
||||
else:
|
||||
lines.append(f" {var}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def check_image_updates(project_name: str | None = None):
|
||||
"""Check which local images have updates available (upgradable flag from NAS registry)."""
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy."""
|
||||
"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy, create."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
@@ -19,6 +22,46 @@ _POLL_INTERVAL = 2 # seconds between status checks
|
||||
_POLL_TIMEOUT = 30 # seconds for ordinary start polling
|
||||
_BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be slow)
|
||||
|
||||
# Statuses that mean "stop polling now — this redeploy is not coming back."
|
||||
# DSM signals these typically within seconds of build_stream when the image
|
||||
# pull or container start fails; without an early exit the caller would wait
|
||||
# the full _BUILD_POLL_TIMEOUT for nothing.
|
||||
_TERMINAL_FAILURE_STATUSES = frozenset({"BUILD_FAILED", "ERROR"})
|
||||
|
||||
# Substrings that mark a line in the build_stream log as a failure. Live
|
||||
# DSM capture: image-pull or container-start errors surface as either a
|
||||
# bare "<svc> Error" status line OR a more verbose "Error response from
|
||||
# daemon: <cause>" line emitted right after it. Both forms are treated as
|
||||
# build failures by `_parse_build_stream_log`.
|
||||
_BUILD_DAEMON_ERROR = "Error response from daemon:"
|
||||
|
||||
|
||||
def _parse_build_stream_log(log: str) -> tuple[list[str], list[str]]:
|
||||
"""Split a build_stream log into (error_lines, info_lines).
|
||||
|
||||
A line is classified as an error when it contains "Error response from
|
||||
daemon:" or ends with " Error" (the per-service status DSM emits when
|
||||
a container fails to start). Everything else — including success
|
||||
markers like "Container <name> Running" — is treated as info.
|
||||
|
||||
Args:
|
||||
log: Raw log text returned by ``DsmClient.trigger_build_stream``.
|
||||
|
||||
Returns:
|
||||
Tuple of (errors, info) lines with empty lines stripped.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
info: list[str] = []
|
||||
for raw in log.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if _BUILD_DAEMON_ERROR in line or line.endswith(" Error"):
|
||||
errors.append(line)
|
||||
else:
|
||||
info.append(line)
|
||||
return errors, info
|
||||
|
||||
|
||||
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all project management tools with the MCP server."""
|
||||
@@ -124,6 +167,10 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
)
|
||||
|
||||
results: list[str] = []
|
||||
# Track whether we issued a stop that DSM accepted. Used to give the
|
||||
# caller an accurate recovery hint if a later step (build_stream)
|
||||
# fails — the project would be left in STOPPED state.
|
||||
stop_was_issued = False
|
||||
|
||||
try:
|
||||
# ── Step 1: Stop ──────────────────────────────────────────────────
|
||||
@@ -133,15 +180,36 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
results.append("Step 1/3: Stopping failed build...")
|
||||
with contextlib.suppress(Exception):
|
||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||
stop_was_issued = True
|
||||
results.append(" Stopped.")
|
||||
else: # RUNNING
|
||||
results.append("Step 1/3: Stopping project...")
|
||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||
stop_was_issued = True
|
||||
results.append(" Stopped.")
|
||||
|
||||
# ── Step 2: build_stream (pull images + start) ────────────────────
|
||||
results.append("Step 2/3: Triggering image pull and project start (build_stream)...")
|
||||
await client.trigger_build_stream(project_id)
|
||||
build_log = await client.trigger_build_stream(project_id)
|
||||
build_errors, _ = _parse_build_stream_log(build_log)
|
||||
if build_errors:
|
||||
# Live build_stream reported a daemon error (e.g. manifest
|
||||
# not found, container exited). Surface the cause now —
|
||||
# no need to wait for polling to flip the project to
|
||||
# BUILD_FAILED, and the daemon line is much more
|
||||
# actionable than the bare status.
|
||||
results.append(" Build failed — DSM reported:")
|
||||
results.extend(f" {line}" for line in build_errors)
|
||||
results.append(
|
||||
f"\nProject '{project_name}' redeploy aborted (build_stream errors)."
|
||||
)
|
||||
if stop_was_issued:
|
||||
results.append(
|
||||
f"Note: project '{project_name}' was stopped before this error and is "
|
||||
f"now in STOPPED state. Run start_project('{project_name}') or retry "
|
||||
f"redeploy_project to recover."
|
||||
)
|
||||
return "\n".join(results)
|
||||
results.append(" Build request accepted by DSM.")
|
||||
|
||||
# ── Step 3: Poll ──────────────────────────────────────────────────
|
||||
@@ -155,6 +223,19 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
if final_status == "RUNNING":
|
||||
results.append(" Project is RUNNING.")
|
||||
results.append(f"\nProject '{project_name}' redeployed successfully.")
|
||||
elif final_status in _TERMINAL_FAILURE_STATUSES:
|
||||
# M-5: DSM signalled a hard failure during polling. The
|
||||
# build_stream log above was clean, so this is a late
|
||||
# failure (e.g. container exited after start).
|
||||
results.append(f" Redeploy failed — project status is '{final_status}'.")
|
||||
if final_status == "BUILD_FAILED":
|
||||
results.append(
|
||||
" Check the image tag in the compose file "
|
||||
"(update_image_tag) and retry redeploy_project."
|
||||
)
|
||||
results.append(
|
||||
f"\nProject '{project_name}' redeploy aborted (status: {final_status})."
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
f" Warning: project status is '{final_status}' after "
|
||||
@@ -165,10 +246,252 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
|
||||
except Exception as e:
|
||||
results.append(f"Error during redeploy: {e}")
|
||||
results.append("Workaround: use stop_project + start_project separately.")
|
||||
if stop_was_issued:
|
||||
# M-4: build_stream (or polling) failed AFTER we stopped the
|
||||
# project. The project is now in STOPPED state and the caller
|
||||
# needs to know that — the previous "use stop + start"
|
||||
# workaround was misleading because stop already happened.
|
||||
results.append(
|
||||
f"Note: project '{project_name}' was stopped before this error and is "
|
||||
f"now in STOPPED state. Run start_project('{project_name}') or retry "
|
||||
f"redeploy_project to recover."
|
||||
)
|
||||
else:
|
||||
results.append("Workaround: use stop_project + start_project separately.")
|
||||
|
||||
return "\n".join(results)
|
||||
|
||||
@mcp.tool()
|
||||
async def create_project(
|
||||
project_name: str,
|
||||
compose_content: str,
|
||||
share_path: str | None = None,
|
||||
confirmed: bool = False,
|
||||
):
|
||||
"""Create a new Container Manager project from compose YAML. Requires confirmed=True."""
|
||||
# Lazy import avoids a circular dependency between projects.py and compose.py.
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.compose import (
|
||||
_to_filestation_path,
|
||||
_validate_project_name,
|
||||
)
|
||||
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
|
||||
# Parse compose YAML up-front so a malformed input is rejected before
|
||||
# any side effects (folder creation, project registration).
|
||||
try:
|
||||
parsed = yaml.safe_load(compose_content)
|
||||
except yaml.YAMLError as e:
|
||||
return f"Invalid YAML content: {e}"
|
||||
if not isinstance(parsed, dict) or "services" not in parsed:
|
||||
return "Invalid compose file: must be a YAML document with a 'services' key."
|
||||
services = parsed.get("services") or {}
|
||||
service_count = len(services) if isinstance(services, dict) else 0
|
||||
|
||||
# Resolve share_path. When the caller omits it, derive it from
|
||||
# compose_base_path (e.g. "/volume1/docker" + "myapp" → "/docker/myapp").
|
||||
if share_path is None:
|
||||
parent_share = _to_filestation_path(config.compose_base_path).rstrip("/")
|
||||
resolved_share_path = f"{parent_share}/{project_name}"
|
||||
folder_name = project_name
|
||||
else:
|
||||
resolved_share_path = share_path.rstrip("/")
|
||||
parent_share, _, folder_name = resolved_share_path.rpartition("/")
|
||||
if not parent_share:
|
||||
parent_share = "/"
|
||||
if not folder_name:
|
||||
return f"Invalid share_path '{share_path}': missing folder name."
|
||||
|
||||
# Check for an existing project with the same name BEFORE creating
|
||||
# the folder — avoids leaving an orphaned directory on the NAS.
|
||||
existing = await _find_project(client, project_name)
|
||||
if existing is not None:
|
||||
return (
|
||||
f"Project '{project_name}' already exists "
|
||||
f"(status: {existing.get('status', '?')}, path: {existing.get('path', '?')})."
|
||||
)
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
f"About to create new project '{project_name}':\n"
|
||||
f" Share path: {resolved_share_path}\n"
|
||||
f" Services: {service_count}\n\n"
|
||||
f"Call this tool again with confirmed=True to apply."
|
||||
)
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
# ── Step 1: Create the target folder via FileStation ──────────────────
|
||||
# force_parent=true makes the call idempotent: it does not fail if the
|
||||
# folder already exists, and it creates any missing intermediate
|
||||
# directories. Without this step Docker.Project/create fails with
|
||||
# error code 2100 ("target folder issue").
|
||||
results.append("Step 1/3: Creating target folder...")
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.FileStation.CreateFolder",
|
||||
"create",
|
||||
version=2,
|
||||
params={
|
||||
"folder_path": json.dumps(parent_share),
|
||||
"name": json.dumps(folder_name),
|
||||
"force_parent": "true",
|
||||
},
|
||||
)
|
||||
results.append(f" Folder ready: {resolved_share_path}")
|
||||
except SynologyError as e:
|
||||
return (
|
||||
f"Error creating folder for project '{project_name}': {e}\n"
|
||||
f" Attempted path: {parent_share}/{folder_name}"
|
||||
)
|
||||
|
||||
# ── Step 2: Register the project with Container Manager ───────────────
|
||||
results.append("Step 2/3: Registering project with Container Manager...")
|
||||
try:
|
||||
data = await client.post_request(
|
||||
"SYNO.Docker.Project",
|
||||
"create",
|
||||
version=1,
|
||||
params={
|
||||
"name": json.dumps(project_name),
|
||||
"share_path": json.dumps(resolved_share_path),
|
||||
"content": json.dumps(compose_content),
|
||||
"enable_service_portal": json.dumps(False),
|
||||
"service_portal_name": json.dumps(""),
|
||||
"service_portal_port": 0,
|
||||
"service_portal_protocol": json.dumps("http"),
|
||||
},
|
||||
)
|
||||
except SynologyError as e:
|
||||
if e.code == 2100:
|
||||
return (
|
||||
f"Project creation failed — target folder issue (DSM error 2100).\n"
|
||||
f" Share path: {resolved_share_path}\n"
|
||||
f" Folder was created in step 1 but DSM rejected it. "
|
||||
f"Verify the share exists and the user has write access."
|
||||
)
|
||||
return f"Error registering project '{project_name}': {e}"
|
||||
|
||||
project_id = (data.get("id") if isinstance(data, dict) else "") or ""
|
||||
if not project_id:
|
||||
return (
|
||||
f"Project registered but DSM returned no project ID. "
|
||||
f"Check list_projects to confirm — response was: {data!r}"
|
||||
)
|
||||
results.append(f" Registered (id={project_id}).")
|
||||
|
||||
# ── Step 3: Trigger the build (pull images + start containers) ────────
|
||||
results.append("Step 3/3: Triggering build_stream (image pull and start)...")
|
||||
try:
|
||||
build_log = await client.trigger_build_stream(project_id)
|
||||
except Exception as e:
|
||||
results.append(f" Error triggering build: {e}")
|
||||
results.append(
|
||||
f"\nProject '{project_name}' is registered but was not started. "
|
||||
f"Run redeploy_project('{project_name}', confirmed=True) to retry."
|
||||
)
|
||||
return "\n".join(results)
|
||||
|
||||
build_errors, _ = _parse_build_stream_log(build_log)
|
||||
if build_errors:
|
||||
results.append(" Build failed — DSM reported:")
|
||||
results.extend(f" {line}" for line in build_errors)
|
||||
results.append(
|
||||
f"\nProject '{project_name}' is registered but failed to build. "
|
||||
f"Fix the compose content (e.g. update_image_tag) and run "
|
||||
f"redeploy_project('{project_name}', confirmed=True) to retry."
|
||||
)
|
||||
return "\n".join(results)
|
||||
results.append(" Build request accepted by DSM.")
|
||||
|
||||
results.append(
|
||||
f"Waiting for project to reach RUNNING state (up to {_BUILD_POLL_TIMEOUT}s)..."
|
||||
)
|
||||
final_status = await _wait_for_project_running(
|
||||
client, project_name, timeout=_BUILD_POLL_TIMEOUT
|
||||
)
|
||||
if final_status == "RUNNING":
|
||||
results.append(" Project is RUNNING.")
|
||||
results.append(f"\nProject '{project_name}' created and started successfully.")
|
||||
elif final_status in _TERMINAL_FAILURE_STATUSES:
|
||||
results.append(f" Build failed — project status is '{final_status}'.")
|
||||
if final_status == "BUILD_FAILED":
|
||||
results.append(
|
||||
" Check the image tag(s) in the compose content "
|
||||
"(update_image_tag) and retry redeploy_project."
|
||||
)
|
||||
results.append(
|
||||
f"\nProject '{project_name}' is registered but failed to start "
|
||||
f"(status: {final_status})."
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
f" Warning: project status is '{final_status}' after "
|
||||
f"{_BUILD_POLL_TIMEOUT}s. "
|
||||
f"Containers may still be starting — check with get_project_status."
|
||||
)
|
||||
results.append(f"\nProject '{project_name}' created (final status: {final_status}).")
|
||||
|
||||
return "\n".join(results)
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_project(project_name: str, confirmed: bool = False):
|
||||
"""Remove a project registration from Container Manager. Requires confirmed=True."""
|
||||
# Lazy import: see create_project for why this avoids a circular dep.
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.compose import _validate_project_name
|
||||
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
|
||||
project = await _find_project(client, project_name)
|
||||
if project is None:
|
||||
return f"Project '{project_name}' not found."
|
||||
|
||||
project_id = project.get("id", "")
|
||||
status = (project.get("status") or "?").upper()
|
||||
path = project.get("path", "?")
|
||||
share_path = project.get("share_path", "?")
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
f"About to delete project registration '{project_name}':\n"
|
||||
f" UUID: {project_id}\n"
|
||||
f" Status: {status}\n"
|
||||
f" Path: {path}\n"
|
||||
f" Share path: {share_path}\n\n"
|
||||
f"Note: only the Container Manager registration is removed — "
|
||||
f"the folder and compose file will remain on the NAS.\n\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
# Guard: DSM does NOT reject a delete call on a running project — it
|
||||
# removes the registration silently and leaves the containers running
|
||||
# as orphans. Reject here so we never put the NAS into that state.
|
||||
if status == "RUNNING":
|
||||
return (
|
||||
f"Error: project '{project_name}' is RUNNING. "
|
||||
f"Stop it first with stop_project, then delete_project."
|
||||
)
|
||||
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Project",
|
||||
"delete",
|
||||
version=1,
|
||||
params={"id": json.dumps(project_id)},
|
||||
)
|
||||
except SynologyError as e:
|
||||
return f"Error deleting project '{project_name}': {e}"
|
||||
|
||||
return (
|
||||
f"Project '{project_name}' deleted (registration removed).\n"
|
||||
f"Note: the project folder {share_path} was NOT deleted — "
|
||||
f"its files remain on the NAS."
|
||||
)
|
||||
|
||||
|
||||
async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
|
||||
"""Find a project by name from the list.
|
||||
@@ -220,6 +543,12 @@ async def _wait_for_project_running(
|
||||
logger.debug("Polling '%s': status=%s elapsed=%ds", name, current, elapsed)
|
||||
if current == "RUNNING":
|
||||
return current
|
||||
if current in _TERMINAL_FAILURE_STATUSES:
|
||||
# DSM has reported a hard failure (e.g. image pull failed,
|
||||
# container exited immediately). Returning early lets the
|
||||
# caller surface the real cause instead of waiting out the
|
||||
# full timeout.
|
||||
return current
|
||||
# Return whatever status we last saw (or UNKNOWN on repeated failures)
|
||||
project = await _find_project(client, name)
|
||||
return (project.get("status") or "UNKNOWN").upper() if project else "UNKNOWN"
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""MCP tools for SYNO.Docker.Registry: search, tags, pull."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_container.config import AppConfig
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pull polling: backoff schedule capped at 10 s between checks. Total budget
|
||||
# stays under the Claude Desktop ~4 min tool-call ceiling.
|
||||
_PULL_POLL_TIMEOUT = 240.0
|
||||
_PULL_POLL_INTERVALS: tuple[float, ...] = (2.0, 3.0, 5.0, 8.0, 10.0)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 80) -> str:
|
||||
"""Truncate text to limit with an ellipsis."""
|
||||
text = (text or "").replace("\n", " ").replace("\r", " ").strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
async def _image_present(client: DsmClient, repository: str, tag: str) -> bool:
|
||||
"""Return True if repository:tag is in the local image list."""
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Pull polling: image list failed: %s", e)
|
||||
return False
|
||||
|
||||
images: list[dict[str, Any]] = data.get("images", []) if isinstance(data, dict) else []
|
||||
for img in images:
|
||||
repo = img.get("repository", "")
|
||||
tags = img.get("tags") or []
|
||||
if repo == repository and tag in tags:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all registry management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def search_registry(query: str, limit: int = 20):
|
||||
"""Search the active Docker registry for images matching query."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
elif limit > 100:
|
||||
limit = 100
|
||||
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Registry",
|
||||
"search",
|
||||
version=1,
|
||||
params={
|
||||
"q": json.dumps(query),
|
||||
"offset": "0",
|
||||
"limit": str(limit),
|
||||
"page_size": str(limit),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error searching registry for '{query}': {e}"
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
if isinstance(data, dict):
|
||||
results = data.get("data", []) or []
|
||||
total = int(data.get("total", 0) or 0)
|
||||
|
||||
if not results:
|
||||
return f"No results found for '{query}'."
|
||||
|
||||
lines = [f"Search results for '{query}' ({len(results)} of {total} total):", ""]
|
||||
lines.append(" Stars Downloads Name (official) Description")
|
||||
for hit in results:
|
||||
name = hit.get("name", "?")
|
||||
description = _truncate(hit.get("description", ""))
|
||||
stars = int(hit.get("star_count", 0) or 0)
|
||||
downloads = int(hit.get("downloads", 0) or 0)
|
||||
official = " [official]" if hit.get("is_official") else ""
|
||||
lines.append(f" {stars:>5} {downloads:>9} {name}{official}")
|
||||
if description:
|
||||
lines.append(f" {description}")
|
||||
|
||||
if total > len(results):
|
||||
lines.append("")
|
||||
lines.append(f"Showing {len(results)} of {total}; raise limit to see more.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def list_image_tags(repository: str, limit: int = 50):
|
||||
"""List available tags for a registry image (e.g. 'nginx', 'grafana/grafana')."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Registry",
|
||||
"tags",
|
||||
version=1,
|
||||
params={"repo": json.dumps(repository)},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error fetching tags for '{repository}': {e}"
|
||||
|
||||
# DSM returns the tag list as the envelope's `data` field directly.
|
||||
# When empty, DsmClient.request() coerces to {} via `or {}`, so we
|
||||
# accept both shapes here.
|
||||
if isinstance(data, list):
|
||||
entries: list[Any] = data
|
||||
elif isinstance(data, dict):
|
||||
# Defensive: some DSM versions wrap as {"tags": [...]}.
|
||||
entries = data.get("tags") or data.get("data") or []
|
||||
else:
|
||||
entries = []
|
||||
|
||||
tags: list[str] = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict):
|
||||
tag = entry.get("tag")
|
||||
if isinstance(tag, str) and tag:
|
||||
tags.append(tag)
|
||||
elif isinstance(entry, str):
|
||||
tags.append(entry)
|
||||
|
||||
if not tags:
|
||||
return f"No tags found for '{repository}'."
|
||||
|
||||
total = len(tags)
|
||||
shown = tags[:limit]
|
||||
lines = [f"Tags for '{repository}' ({total} total):", ""]
|
||||
lines.extend(f" {t}" for t in shown)
|
||||
|
||||
if total > limit:
|
||||
lines.append("")
|
||||
lines.append(f"Showing first {limit} of {total}; raise limit to see more.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def pull_image(repository: str, tag: str = "latest", confirmed: bool = False):
|
||||
"""Pull image from the active registry. Requires confirmed=True; polls for completion."""
|
||||
target = f"{repository}:{tag}"
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would pull {target} from the active registry.\n"
|
||||
f"Call pull_image(repository={repository!r}, tag={tag!r}, "
|
||||
"confirmed=True) to proceed."
|
||||
)
|
||||
|
||||
# Short-circuit: if the image already exists locally, no pull needed.
|
||||
if await _image_present(client, repository, tag):
|
||||
return f"{target} is already present locally — nothing to pull."
|
||||
|
||||
# `pull_start` lives on SYNO.Docker.Image, NOT SYNO.Docker.Registry
|
||||
# (live DSM capture, confirmed). The Registry API only exposes the
|
||||
# synchronous read-only methods (search, tags, get/set/create/delete,
|
||||
# using). Calling Registry/pull_start returns "Method does not exist".
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"pull_start",
|
||||
version=1,
|
||||
params={
|
||||
"repository": json.dumps(repository),
|
||||
"tag": json.dumps(tag),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error starting pull for '{target}': {e}"
|
||||
|
||||
# Async pull — DSM exposes no confirmed pull_status method, so we poll
|
||||
# Image/list for the new tag. Backoff schedule capped at 10 s; total
|
||||
# budget under the Claude Desktop ~4 min tool-call ceiling.
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + _PULL_POLL_TIMEOUT
|
||||
attempt = 0
|
||||
while loop.time() < deadline:
|
||||
interval = _PULL_POLL_INTERVALS[min(attempt, len(_PULL_POLL_INTERVALS) - 1)]
|
||||
remaining = deadline - loop.time()
|
||||
await asyncio.sleep(min(interval, max(remaining, 0.0)))
|
||||
if await _image_present(client, repository, tag):
|
||||
return f"Pulled {target} successfully."
|
||||
attempt += 1
|
||||
|
||||
return (
|
||||
f"Pull of {target} started, still running — "
|
||||
f"verify later with list_images or check_image_updates."
|
||||
)
|
||||
@@ -15,6 +15,11 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Built-in Docker networks are never removed by `docker network prune`
|
||||
# regardless of attached-container count. Skip them when counting what
|
||||
# the prune will actually delete.
|
||||
_BUILTIN_NETWORKS = frozenset({"bridge", "host", "none"})
|
||||
|
||||
|
||||
def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all system-level tools with the MCP server."""
|
||||
@@ -88,6 +93,114 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def system_overview():
|
||||
"""Aggregated CPU, memory, network, and block I/O across all running containers."""
|
||||
errors: list[str] = []
|
||||
|
||||
# ── Container list (for running/stopped counts) ──────────────────────
|
||||
containers: list[dict[str, Any]] = []
|
||||
try:
|
||||
ctr_data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "type": "all"},
|
||||
)
|
||||
containers = ctr_data.get("containers", []) or []
|
||||
except Exception as e:
|
||||
errors.append(f"list: {e}")
|
||||
|
||||
running = sum(1 for c in containers if c.get("status") in ("running", "up"))
|
||||
stopped = len(containers) - running
|
||||
|
||||
# ── Stats (for aggregation) ──────────────────────────────────────────
|
||||
stats_data: dict[str, Any] = {}
|
||||
try:
|
||||
stats_data = await client.request("SYNO.Docker.Container", "stats") or {}
|
||||
except Exception as e:
|
||||
errors.append(f"stats: {e}")
|
||||
|
||||
total_cpu_pct = 0.0
|
||||
total_mem_usage = 0
|
||||
total_mem_limit = 0
|
||||
total_net_rx = 0
|
||||
total_net_tx = 0
|
||||
total_blk_read = 0
|
||||
total_blk_write = 0
|
||||
aggregated_count = 0
|
||||
|
||||
for entry in stats_data.values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
aggregated_count += 1
|
||||
|
||||
# CPU % — Docker formula, mirrors container_stats logic
|
||||
cpu_stats = entry.get("cpu_stats", {}) or {}
|
||||
precpu_stats = entry.get("precpu_stats", {}) or {}
|
||||
cpu_usage = cpu_stats.get("cpu_usage", {}) or {}
|
||||
precpu_usage = precpu_stats.get("cpu_usage", {}) or {}
|
||||
|
||||
cpu_delta = cpu_usage.get("total_usage", 0) - precpu_usage.get("total_usage", 0)
|
||||
system_delta = cpu_stats.get("system_cpu_usage", 0) - precpu_stats.get(
|
||||
"system_cpu_usage", 0
|
||||
)
|
||||
online_cpus = cpu_stats.get("online_cpus") or len(cpu_usage.get("percpu_usage") or [1])
|
||||
if system_delta > 0 and cpu_delta >= 0:
|
||||
total_cpu_pct += (cpu_delta / system_delta) * online_cpus * 100.0
|
||||
|
||||
# Memory
|
||||
mem_stats = entry.get("memory_stats", {}) or {}
|
||||
total_mem_usage += mem_stats.get("usage", 0)
|
||||
total_mem_limit += mem_stats.get("limit", 0)
|
||||
|
||||
# Network I/O
|
||||
for iface in (entry.get("networks") or {}).values():
|
||||
if not isinstance(iface, dict):
|
||||
continue
|
||||
total_net_rx += iface.get("rx_bytes", 0)
|
||||
total_net_tx += iface.get("tx_bytes", 0)
|
||||
|
||||
# Block I/O
|
||||
for entry_io in (entry.get("blkio_stats", {}) or {}).get(
|
||||
"io_service_bytes_recursive"
|
||||
) or []:
|
||||
if not isinstance(entry_io, dict):
|
||||
continue
|
||||
op = (entry_io.get("op") or "").lower()
|
||||
val = entry_io.get("value", 0)
|
||||
if op == "read":
|
||||
total_blk_read += val
|
||||
elif op == "write":
|
||||
total_blk_write += val
|
||||
|
||||
# ── Format output ────────────────────────────────────────────────────
|
||||
lines = ["Docker System Overview", ""]
|
||||
lines.append(" Containers:")
|
||||
lines.append(f" {running:>3} running")
|
||||
lines.append(f" {stopped:>3} stopped")
|
||||
|
||||
if aggregated_count > 0:
|
||||
mem_limit_str = _human_size(total_mem_limit) if total_mem_limit else "—"
|
||||
lines.append("")
|
||||
lines.append(" Aggregated (running containers):")
|
||||
lines.append(f" CPU: {total_cpu_pct:.2f}%")
|
||||
lines.append(f" Memory: {_human_size(total_mem_usage)} / {mem_limit_str}")
|
||||
lines.append(
|
||||
f" Net I/O: rx {_human_size(total_net_rx)} / tx {_human_size(total_net_tx)}"
|
||||
)
|
||||
lines.append(
|
||||
f" Block I/O: read {_human_size(total_blk_read)}"
|
||||
f" / write {_human_size(total_blk_write)}"
|
||||
)
|
||||
|
||||
if errors:
|
||||
lines.append("")
|
||||
lines.append(" Warnings:")
|
||||
for err in errors:
|
||||
lines.append(f" {err}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def system_prune(confirmed: bool = False):
|
||||
"""Remove unused Docker resources (images, stopped containers). Requires confirmed=True."""
|
||||
@@ -132,6 +245,21 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
dangling_size = sum(img.get("size", 0) for img in dangling_images)
|
||||
|
||||
if not confirmed:
|
||||
# M-6: also enumerate networks that would be removed so the
|
||||
# preview matches the actual prune scope. Networks are only
|
||||
# fetched in preview mode — the prune call itself doesn't
|
||||
# need them.
|
||||
unused_networks: list[dict[str, Any]] = []
|
||||
try:
|
||||
net_data = await client.request("SYNO.Docker.Network", "list")
|
||||
for net in net_data.get("network", []) or []:
|
||||
name = net.get("name", "")
|
||||
attached = net.get("containers") or []
|
||||
if not attached and name not in _BUILTIN_NETWORKS:
|
||||
unused_networks.append(net)
|
||||
except Exception as e:
|
||||
logger.debug("Could not fetch networks for prune preview: %s", e)
|
||||
|
||||
lines = ["system_prune — preview (nothing deleted yet):", ""]
|
||||
lines.append(
|
||||
f" Dangling/unused images: {len(dangling_images)} ({_human_size(dangling_size)})"
|
||||
@@ -149,7 +277,12 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
if len(stopped_containers) > 10:
|
||||
lines.append(f" … and {len(stopped_containers) - 10} more")
|
||||
|
||||
lines.append(" Unused networks: (not counted — run prune to remove)")
|
||||
lines.append(f" Unused networks: {len(unused_networks)}")
|
||||
for net in unused_networks[:10]:
|
||||
driver = net.get("driver", "?")
|
||||
lines.append(f" - {net.get('name', '?')} ({driver})")
|
||||
if len(unused_networks) > 10:
|
||||
lines.append(f" … and {len(unused_networks) - 10} more")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Call system_prune(confirmed=True) to free ~{_human_size(dangling_size)}."
|
||||
|
||||
@@ -31,6 +31,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
register_projects(mcp, config, client)
|
||||
@@ -39,6 +40,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
|
||||
register_images(mcp, config, client)
|
||||
register_system(mcp, config, client)
|
||||
register_networks(mcp, config, client)
|
||||
register_registry(mcp, config, client)
|
||||
|
||||
logger.info("MCP server configured with all tool modules")
|
||||
return mcp
|
||||
|
||||
+15
-9
@@ -35,9 +35,11 @@ def test_resolve_credentials_no_credentials(monkeypatch):
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
with patch("keyring.get_password", return_value=None):
|
||||
with pytest.raises(AuthenticationError, match="No credentials found"):
|
||||
auth.resolve_credentials()
|
||||
with (
|
||||
patch("keyring.get_password", return_value=None),
|
||||
pytest.raises(AuthenticationError, match="No credentials found"),
|
||||
):
|
||||
auth.resolve_credentials()
|
||||
|
||||
|
||||
def test_resolve_credentials_from_keyring(monkeypatch):
|
||||
@@ -124,9 +126,11 @@ async def test_login_2fa_required():
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.side_effect = SynologyError("2FA required", code=403)
|
||||
|
||||
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)):
|
||||
with pytest.raises(AuthenticationError, match="2FA is required"):
|
||||
await auth.login(mock_client)
|
||||
with (
|
||||
patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)),
|
||||
pytest.raises(AuthenticationError, match="2FA is required"),
|
||||
):
|
||||
await auth.login(mock_client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -137,9 +141,11 @@ async def test_login_no_sid_returned():
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = {} # No 'sid' key
|
||||
|
||||
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)):
|
||||
with pytest.raises(AuthenticationError, match="no session ID"):
|
||||
await auth.login(mock_client)
|
||||
with (
|
||||
patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)),
|
||||
pytest.raises(AuthenticationError, match="no session ID"),
|
||||
):
|
||||
await auth.login(mock_client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for config.py."""
|
||||
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from mcp_synology_container.modules.compose import _validate_project_name
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
@@ -345,3 +347,121 @@ def test_extract_version_prefix():
|
||||
assert _extract_version_prefix("1.24") is None # no suffix
|
||||
assert _extract_version_prefix("") is None
|
||||
assert _extract_version_prefix("v2.0-rc1") is None # starts with 'v'
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# _validate_project_name — path-traversal guard
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"myapp",
|
||||
"MyApp",
|
||||
"my-app",
|
||||
"my_app",
|
||||
"app123",
|
||||
"A",
|
||||
"1",
|
||||
"snake_case-and-dash_42",
|
||||
],
|
||||
)
|
||||
def test_validate_project_name_accepts_safe_names(name: str) -> None:
|
||||
assert _validate_project_name(name) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"", # empty
|
||||
"../etc", # parent traversal
|
||||
"../../etc/passwd", # multi-level traversal
|
||||
"foo/../bar", # embedded traversal
|
||||
"foo/bar", # forward slash
|
||||
"foo\\bar", # backslash
|
||||
".", # bare dot
|
||||
"..", # bare dotdot
|
||||
".hidden", # leading dot
|
||||
"foo.bar", # dot inside (.yaml extensions, etc.)
|
||||
"foo bar", # whitespace
|
||||
" foo", # leading space
|
||||
"foo ", # trailing space
|
||||
"foo\tbar", # tab
|
||||
"foo\nbar", # newline
|
||||
"foo;rm", # shell metachar
|
||||
"foo|bar",
|
||||
"foo&bar",
|
||||
"foo*bar",
|
||||
"foo?bar",
|
||||
"foo:bar",
|
||||
"foo'bar",
|
||||
'foo"bar',
|
||||
"foo$bar",
|
||||
"foo`bar",
|
||||
"café", # non-ASCII letter
|
||||
],
|
||||
)
|
||||
def test_validate_project_name_rejects_unsafe_names(name: str) -> None:
|
||||
msg = _validate_project_name(name)
|
||||
assert msg is not None
|
||||
assert "invalid project name" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_compose_rejects_traversal_name() -> None:
|
||||
"""Traversal name must be rejected before any DSM call."""
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["read_compose"]("../../etc")
|
||||
assert "invalid project name" in result
|
||||
client.request.assert_not_called()
|
||||
client.download_text.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_compose_rejects_traversal_name() -> None:
|
||||
"""update_compose with a traversal name must not validate YAML or upload."""
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_compose"](
|
||||
"foo/../bar", "services:\n web:\n image: nginx\n", confirmed=True
|
||||
)
|
||||
assert "invalid project name" in result
|
||||
client.upload_text.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_tag_rejects_traversal_name() -> None:
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_image_tag"]("foo/bar", "web", "1.25", confirmed=True)
|
||||
assert "invalid project name" in result
|
||||
client.upload_text.assert_not_called()
|
||||
client.download_text.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_env_var_rejects_traversal_name() -> None:
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_env_var"]("..", "web", "FOO", "bar", confirmed=True)
|
||||
assert "invalid project name" in result
|
||||
client.upload_text.assert_not_called()
|
||||
client.download_text.assert_not_called()
|
||||
|
||||
@@ -317,6 +317,32 @@ async def test_delete_container_stopped_confirmed():
|
||||
assert "myapp_web" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_container_sends_required_params():
|
||||
"""SYNO.Docker.Container/delete must include name (json-encoded), force=false,
|
||||
and preserve_profile=false — without them DSM returns error 114."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {
|
||||
"details": {"State": {"Running": False, "Status": "exited"}},
|
||||
"profile": {"image": "nginx:latest"},
|
||||
}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
await tools["delete_container"]("myapp_web", confirmed=True)
|
||||
|
||||
# Find the delete call (second call — first is Container/get)
|
||||
delete_calls = [c for c in client.request.call_args_list if c.args[1] == "delete"]
|
||||
assert len(delete_calls) == 1
|
||||
params = delete_calls[0].kwargs.get("params") or {}
|
||||
assert params["name"] == '"myapp_web"'
|
||||
assert params["force"] == "false"
|
||||
assert params["preserve_profile"] == "false"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_stats_cpu_calculation():
|
||||
"""CPU% is computed via the standard Docker formula."""
|
||||
@@ -597,6 +623,230 @@ async def test_get_container_status_shows_mounts():
|
||||
assert "/var/jenkins_home" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# inspect_container
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Live-captured-style response (homeassistant) — proves that
|
||||
# details.Mounts[].Source carries the full /volume1/... path while
|
||||
# profile.volume_bindings[].host_volume_file is share-relative.
|
||||
INSPECT_RESPONSE = {
|
||||
"details": {
|
||||
"Config": {
|
||||
"Image": "homeassistant/home-assistant:stable",
|
||||
"Entrypoint": ["/init"],
|
||||
"Cmd": None,
|
||||
"Env": ["TZ=Europe/Berlin"],
|
||||
},
|
||||
"HostConfig": {
|
||||
"RestartPolicy": {"Name": "always", "MaximumRetryCount": 0},
|
||||
"NetworkMode": "frostiq_net",
|
||||
"Privileged": False,
|
||||
"CapAdd": None,
|
||||
"CapDrop": None,
|
||||
"Binds": ["/volume1/docker/homeassistant:/config:rw"],
|
||||
},
|
||||
"Mounts": [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/volume1/docker/homeassistant",
|
||||
"Destination": "/config",
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
"NetworkSettings": {
|
||||
"Networks": {
|
||||
"frostiq_net": {"IPAddress": "172.18.0.5"},
|
||||
}
|
||||
},
|
||||
"RestartCount": 0,
|
||||
"State": {"Status": "running", "Running": True},
|
||||
},
|
||||
"profile": {
|
||||
"image": "homeassistant/home-assistant:stable",
|
||||
"name": "homeassistant",
|
||||
"enable_restart_policy": True,
|
||||
"network_mode": "frostiq_net",
|
||||
"use_host_network": False,
|
||||
"port_bindings": [{"container_port": 8123, "host_port": 8123, "type": "tcp"}],
|
||||
# Share-relative — must NOT be the path the tool reports.
|
||||
"volume_bindings": [
|
||||
{
|
||||
"host_volume_file": "/docker/homeassistant",
|
||||
"mount_point": "/config",
|
||||
"type": "rw",
|
||||
"is_directory": True,
|
||||
}
|
||||
],
|
||||
"env_variables": [
|
||||
{"key": "TZ", "value": "Europe/Berlin"},
|
||||
{"key": "LANG", "value": "C.UTF-8"},
|
||||
],
|
||||
"labels": {"io.hass.type": "core", "io.hass.version": "2026.2.3"},
|
||||
"links": [],
|
||||
"cmd": "",
|
||||
"cmd_v2": "",
|
||||
"privileged": False,
|
||||
"CapAdd": None,
|
||||
"CapDrop": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_uses_full_host_path():
|
||||
"""inspect_container must report /volume1/docker/... (full) — not
|
||||
/docker/... (share-relative) — for volume mounts. The compose-rebuild
|
||||
workflow depends on the full host path."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": [{"name": "homeassistant"}]}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
return INSPECT_RESPONSE
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("homeassistant")
|
||||
assert "/volume1/docker/homeassistant" in result
|
||||
# The share-relative shortcut must not appear as a mount source.
|
||||
assert " /docker/homeassistant " not in result # not as standalone path
|
||||
assert "→ /config" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_shows_core_fields():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = INSPECT_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("homeassistant")
|
||||
# Header
|
||||
assert "Container: homeassistant" in result
|
||||
assert "homeassistant/home-assistant:stable" in result
|
||||
assert "running" in result
|
||||
# Restart policy
|
||||
assert "always" in result
|
||||
# Network
|
||||
assert "frostiq_net" in result
|
||||
assert "172.18.0.5" in result
|
||||
# Ports
|
||||
assert "8123" in result
|
||||
# Env
|
||||
assert "TZ=Europe/Berlin" in result
|
||||
# Labels
|
||||
assert "io.hass.version=2026.2.3" in result
|
||||
# Entrypoint
|
||||
assert "/init" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_calls_get_with_json_name():
|
||||
"""inspect_container must send name= as a JSON-encoded string (DSM
|
||||
Container/get is documented to accept both but json.dumps keeps the
|
||||
convention shared with start/stop/restart/delete)."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": [{"name": "homeassistant"}]}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
seen["params"] = kwargs.get("params")
|
||||
return INSPECT_RESPONSE
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
await tools["inspect_container"]("homeassistant")
|
||||
assert seen["params"] == {"name": '"homeassistant"'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_resolves_hash_prefix():
|
||||
"""If DSM stores the container as 'abcdef012345_homeassistant', a user
|
||||
request for 'homeassistant' must resolve to the prefixed name and the
|
||||
displayed header must show the clean name."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": [{"name": "abcdef012345_homeassistant"}]}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
assert kwargs["params"]["name"] == '"abcdef012345_homeassistant"'
|
||||
return INSPECT_RESPONSE
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("homeassistant")
|
||||
assert "Container: homeassistant" in result
|
||||
assert "abcdef012345" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_not_found():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": []}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
return None
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("ghost")
|
||||
assert "not found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": [{"name": "homeassistant"}]}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
raise SynologyError("API error", code=102)
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("homeassistant")
|
||||
assert "Error" in result
|
||||
assert "homeassistant" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_logs_resolves_hash_prefix():
|
||||
"""get_container_logs resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call."""
|
||||
@@ -678,3 +928,151 @@ async def test_container_stats_no_precpu_graceful():
|
||||
|
||||
result = await tools["container_stats"]("myapp")
|
||||
assert "0.00%" in result # graceful fallback to 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Lifecycle: start / stop / restart
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Tools that don't have a confirmation gate
|
||||
_NO_CONFIRM_LIFECYCLE = [
|
||||
("start_container", "start", "Started"),
|
||||
]
|
||||
|
||||
# Tools that require confirmed=True
|
||||
_CONFIRM_LIFECYCLE = [
|
||||
("stop_container", "stop", "Stopped", "stop"),
|
||||
("restart_container", "restart", "Restarted", "restart"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name, dsm_method, success_word", _NO_CONFIRM_LIFECYCLE)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_no_confirm_calls_dsm(tool_name, dsm_method, success_word):
|
||||
"""start_container calls DSM directly with json-encoded name."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools[tool_name]("myapp_web")
|
||||
|
||||
# Find the matching lifecycle call (a list() may also occur via _resolve_container_name)
|
||||
calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method]
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.args[0] == "SYNO.Docker.Container"
|
||||
assert call.kwargs["version"] == 1
|
||||
assert call.kwargs["params"] == {"name": '"myapp_web"'}
|
||||
assert success_word in result
|
||||
assert "myapp_web" in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name, dsm_method, success_word, _action", _CONFIRM_LIFECYCLE)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_confirmed_calls_dsm(tool_name, dsm_method, success_word, _action):
|
||||
"""stop/restart call DSM with confirmed=True using json-encoded name."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools[tool_name]("myapp_web", confirmed=True)
|
||||
|
||||
calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method]
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.args[0] == "SYNO.Docker.Container"
|
||||
assert call.kwargs["version"] == 1
|
||||
assert call.kwargs["params"] == {"name": '"myapp_web"'}
|
||||
assert success_word in result
|
||||
assert "myapp_web" in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name, _dsm_method, _success_word, action", _CONFIRM_LIFECYCLE)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_preview_without_confirmation(
|
||||
tool_name, _dsm_method, _success_word, action
|
||||
):
|
||||
"""stop/restart without confirmed=True must NOT call DSM."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools[tool_name]("myapp_web")
|
||||
assert "Preview" in result
|
||||
assert action in result
|
||||
assert "myapp_web" in result
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name, dsm_method, kwargs",
|
||||
[
|
||||
("start_container", "start", {}),
|
||||
("stop_container", "stop", {"confirmed": True}),
|
||||
("restart_container", "restart", {"confirmed": True}),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_resolves_hash_prefix(tool_name, dsm_method, kwargs):
|
||||
"""All lifecycle tools resolve 'jenkins' to 'f93cb8b504f7_jenkins' for the DSM call."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kw):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return HASH_PREFIXED_CONTAINERS_DATA
|
||||
if api == "SYNO.Docker.Container" and method == dsm_method:
|
||||
assert kw["params"]["name"] == '"f93cb8b504f7_jenkins"'
|
||||
assert kw["version"] == 1
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
# User passes the clean name; resolved to the hash-prefixed name for DSM,
|
||||
# but the display name in the success message must be hash-stripped.
|
||||
result = await tools[tool_name]("jenkins", **kwargs)
|
||||
assert "jenkins" in result
|
||||
assert "f93cb8b504f7" not in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name, kwargs, error_verb",
|
||||
[
|
||||
("start_container", {}, "starting"),
|
||||
("stop_container", {"confirmed": True}, "stopping"),
|
||||
("restart_container", {"confirmed": True}, "restarting"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_api_error(tool_name, kwargs, error_verb):
|
||||
"""API errors during lifecycle ops return a human-readable error message."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("API error", code=102)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools[tool_name]("myapp_web", **kwargs)
|
||||
assert "Error" in result
|
||||
assert error_verb in result
|
||||
assert "myapp_web" in result
|
||||
|
||||
@@ -411,6 +411,296 @@ async def test_delete_image_api_error():
|
||||
assert "114" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# inspect_image
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Authoritative DSM SYNO.Docker.Image/get response shape (v1).
|
||||
# Live-captured peculiarities:
|
||||
# - `image` and `tag` come back as empty strings when the lookup is
|
||||
# by name:tag — the header therefore has to fall back to image_id.
|
||||
# - `ports` is an array of {"port", "protocol"} dicts, NOT strings.
|
||||
# - `env` is an array of {"key", "value"} dicts, NOT strings.
|
||||
# - `volumes` and `cmd`/`entrypoint` are plain string arrays.
|
||||
# - There is no `layers` field on this endpoint.
|
||||
SAMPLE_INSPECT = {
|
||||
"image": "",
|
||||
"tag": "",
|
||||
"id": "sha256:aaaa1234567890abcdef",
|
||||
"digest": "sha256:digestabcdef",
|
||||
"size": 50 * 1024 * 1024,
|
||||
"virtual_size": 50 * 1024 * 1024,
|
||||
"author": "NGINX Team",
|
||||
"docker_version": "20.10.0",
|
||||
"cmd": ["nginx", "-g", "daemon off;"],
|
||||
"entrypoint": ["/docker-entrypoint.sh"],
|
||||
"env": [
|
||||
{"key": "NGINX_VERSION", "value": "1.24"},
|
||||
{"key": "PATH", "value": "/usr/local/sbin"},
|
||||
],
|
||||
"ports": [
|
||||
{"port": "80", "protocol": "tcp"},
|
||||
{"port": "443", "protocol": "tcp"},
|
||||
],
|
||||
"volumes": ["/var/cache/nginx"],
|
||||
}
|
||||
|
||||
|
||||
def _make_inspect_client(inspect_payload=None):
|
||||
"""Build a mock DsmClient that returns inspect_payload for SYNO.Docker.Image/get."""
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "get":
|
||||
return inspect_payload if inspect_payload is not None else SAMPLE_INSPECT
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_by_name_tag():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "nginx" in result
|
||||
assert "1.24" in result
|
||||
assert "MiB" in result # size formatted via _human_size
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_by_hash():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
# Inspect data shaped for redis returned by hash lookup.
|
||||
# image/tag come back empty (matches live DSM behavior) — display
|
||||
# falls back to image_id, then to the id hash.
|
||||
redis_inspect = {
|
||||
"image": "",
|
||||
"tag": "",
|
||||
"id": "sha256:ccccredis",
|
||||
"digest": "sha256:rdigest",
|
||||
"size": 30 * 1024 * 1024,
|
||||
"virtual_size": 30 * 1024 * 1024,
|
||||
"cmd": ["redis-server"],
|
||||
"entrypoint": [],
|
||||
"env": [],
|
||||
"ports": [{"port": "6379", "protocol": "tcp"}],
|
||||
"volumes": [],
|
||||
}
|
||||
client = _make_inspect_client(inspect_payload=redis_inspect)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="sha256:cccc")
|
||||
# The header echoes the user-supplied image_id; "redis" surfaces via the cmd line.
|
||||
assert "sha256:cccc" in result
|
||||
assert "redis" in result
|
||||
assert "6379/tcp" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_header_uses_image_id_when_fields_empty():
|
||||
"""DSM Image/get returns image='' and tag='' for name:tag lookups — the header
|
||||
must fall back to the user-supplied image_id, NOT render '?:?'."""
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client() # SAMPLE_INSPECT has image='' / tag=''
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="gitea/gitea:1.26.1")
|
||||
assert "Image: gitea/gitea:1.26.1" in result
|
||||
assert "?:?" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_header_falls_back_to_id_when_image_id_empty():
|
||||
"""If image_id were empty (defensive — should not happen in practice), the
|
||||
header falls back to the sha256 id field."""
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client() # SAMPLE_INSPECT.id == sha256:aaaa1234567890abcdef
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="")
|
||||
assert "Image: sha256:aaaa1234567890abcdef" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_uses_identity_param():
|
||||
"""DSM SYNO.Docker.Image/get expects parameter 'identity' (JSON-encoded), version=1.
|
||||
|
||||
Using name/tag/id (the old shape) returned DSM error 114 — this test guards
|
||||
against regressing to that contract.
|
||||
"""
|
||||
import json
|
||||
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
await tools["inspect_image"](image_id="nginx:1.24")
|
||||
|
||||
get_calls = [
|
||||
c for c in client.request.call_args_list if c.args[:2] == ("SYNO.Docker.Image", "get")
|
||||
]
|
||||
assert len(get_calls) == 1
|
||||
call = get_calls[0]
|
||||
assert call.kwargs.get("version") == 1
|
||||
params = call.kwargs.get("params") or {}
|
||||
assert params == {"identity": json.dumps("nginx:1.24")}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_empty_response_treated_as_not_found():
|
||||
"""An empty response dict surfaces as a clean 'not found' message."""
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client(inspect_payload={})
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="bogus:latest")
|
||||
assert "not found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_env_vars():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "NGINX_VERSION=1.24" in result
|
||||
assert "PATH=/usr/local/sbin" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_exposed_ports():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "80/tcp" in result
|
||||
assert "443/tcp" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_volumes():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "/var/cache/nginx" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_entrypoint_cmd():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "/docker-entrypoint.sh" in result
|
||||
assert "nginx" in result
|
||||
assert "daemon off;" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_digest_author_docker_version():
|
||||
"""Identity-block fields specific to the DSM Image/get response are surfaced."""
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "sha256:digestabcdef" in result
|
||||
assert "NGINX Team" in result
|
||||
assert "20.10.0" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_registry_prefixed():
|
||||
"""A registry-prefixed identifier like 'ghcr.io/foo/bar:v1' is passed through verbatim
|
||||
in the JSON-encoded identity parameter."""
|
||||
import json
|
||||
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
registry_inspect = {
|
||||
# image / tag come back empty for name:tag lookups (live DSM behavior).
|
||||
"image": "",
|
||||
"tag": "",
|
||||
"id": "sha256:dddd",
|
||||
"digest": "sha256:gdigest",
|
||||
"size": 100 * 1024 * 1024,
|
||||
"virtual_size": 100 * 1024 * 1024,
|
||||
"cmd": ["/app"],
|
||||
"entrypoint": [],
|
||||
"env": [],
|
||||
"ports": [],
|
||||
"volumes": [],
|
||||
}
|
||||
client = _make_inspect_client(inspect_payload=registry_inspect)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="ghcr.io/foo/bar:v1")
|
||||
assert "ghcr.io/foo/bar" in result
|
||||
assert "v1" in result
|
||||
|
||||
# The full identifier (with ':') is JSON-encoded into a single string —
|
||||
# registry-prefixed names are not split into name + tag.
|
||||
get_calls = [
|
||||
c for c in client.request.call_args_list if c.args[:2] == ("SYNO.Docker.Image", "get")
|
||||
]
|
||||
assert get_calls
|
||||
params = get_calls[0].kwargs.get("params") or {}
|
||||
assert params.get("identity") == json.dumps("ghcr.io/foo/bar:v1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "get":
|
||||
raise SynologyError("inspect failed", code=120)
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# check_image_updates (existing tests preserved)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -210,6 +210,7 @@ def make_stateful_redeploy_mock(
|
||||
initial_status: str,
|
||||
stop_raises=None,
|
||||
build_stream_raises=None,
|
||||
build_stream_log: str = "",
|
||||
):
|
||||
"""Create a stateful client mock for redeploy tests.
|
||||
|
||||
@@ -235,6 +236,7 @@ def make_stateful_redeploy_mock(
|
||||
if build_stream_raises:
|
||||
raise build_stream_raises
|
||||
build_done = True # After build_stream, polling returns RUNNING
|
||||
return build_stream_log
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.trigger_build_stream = AsyncMock(side_effect=mock_trigger_build_stream)
|
||||
@@ -346,6 +348,7 @@ async def test_redeploy_poll_timeout():
|
||||
async def mock_build_stream(project_id):
|
||||
nonlocal build_done
|
||||
build_done = True
|
||||
return ""
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
||||
@@ -374,10 +377,649 @@ async def test_redeploy_unknown_status_returns_error():
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.trigger_build_stream = AsyncMock()
|
||||
client.trigger_build_stream = AsyncMock(return_value="")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "UPDATING" in result
|
||||
assert "Workaround" in result or "stop_project" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# M-4: clear recovery hint when build_stream fails after stop succeeded
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_stream_transport_error_shows_stopped_recovery_hint():
|
||||
"""M-4: build_stream transport error after RUNNING-stop must tell the user the
|
||||
project is now STOPPED and recommend start_project / retry."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"RUNNING",
|
||||
build_stream_raises=SynologyError(
|
||||
"build_stream transport error: ConnectError: nas offline", code=0
|
||||
),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
# No raw stack trace — clean message
|
||||
assert "transport error" in result
|
||||
assert "ConnectError" in result
|
||||
# The recovery hint must point at the actual situation
|
||||
assert "STOPPED" in result
|
||||
assert "start_project" in result
|
||||
# Old misleading workaround text must NOT appear
|
||||
assert "stop_project + start_project separately" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_stream_error_on_stopped_project_keeps_old_workaround():
|
||||
"""If the project was STOPPED to begin with, no stop was issued, so the
|
||||
'STOPPED recovery' hint is NOT appropriate — keep the original workaround."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"STOPPED",
|
||||
build_stream_raises=SynologyError("build failed", code=114),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "build failed" in result or "Error during redeploy" in result
|
||||
# Stop was never issued; new recovery hint should not appear
|
||||
assert "was stopped before this error" not in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Issue #2: surface build_stream daemon errors directly
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_build_stream_log_extracts_errors_and_info():
|
||||
"""The helper splits a streamed build log into error and info lines."""
|
||||
from mcp_synology_container.modules.projects import _parse_build_stream_log
|
||||
|
||||
log = (
|
||||
"Container vault Pulling\n"
|
||||
"nginx Error\n"
|
||||
"Error response from daemon: manifest for nginx:9.9.9 not found: "
|
||||
"manifest unknown\n"
|
||||
"\n"
|
||||
"Container vault Running\n"
|
||||
)
|
||||
|
||||
errors, info = _parse_build_stream_log(log)
|
||||
|
||||
assert errors == [
|
||||
"nginx Error",
|
||||
("Error response from daemon: manifest for nginx:9.9.9 not found: manifest unknown"),
|
||||
]
|
||||
assert info == ["Container vault Pulling", "Container vault Running"]
|
||||
|
||||
|
||||
def test_parse_build_stream_log_clean_log_no_errors():
|
||||
from mcp_synology_container.modules.projects import _parse_build_stream_log
|
||||
|
||||
log = "Container vault Running\nContainer vault-db Running\n"
|
||||
errors, info = _parse_build_stream_log(log)
|
||||
|
||||
assert errors == []
|
||||
assert info == ["Container vault Running", "Container vault-db Running"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_surfaces_build_stream_daemon_error():
|
||||
"""build_stream log containing 'Error response from daemon:' aborts redeploy
|
||||
with that line visible — and skips the polling step entirely."""
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"RUNNING",
|
||||
build_stream_log=(
|
||||
"nginx Error\n"
|
||||
"Error response from daemon: manifest for nginx:9.9.9-nonexistent "
|
||||
"not found: manifest unknown\n"
|
||||
),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "Build failed" in result
|
||||
assert "Error response from daemon: manifest for nginx:9.9.9-nonexistent" in result
|
||||
assert "redeploy aborted" in result
|
||||
assert "redeployed successfully" not in result
|
||||
|
||||
# Recovery hint must appear because the project was stopped first.
|
||||
assert "was stopped before this error" in result
|
||||
|
||||
# The polling step must NOT run — build_stream errors short-circuit.
|
||||
methods = [m for _, m in calls]
|
||||
list_calls = [m for m in methods if m == "list"]
|
||||
assert len(list_calls) == 1 # only the initial _find_project lookup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_clean_log_proceeds_to_polling():
|
||||
"""A clean build_stream log keeps the original happy-path behavior."""
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"RUNNING",
|
||||
build_stream_log="Container myapp Pulling\nContainer myapp Running\n",
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "redeployed successfully" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_surfaces_build_stream_daemon_error():
|
||||
"""build_stream log with daemon error → registered-but-failed-to-build hint."""
|
||||
client, calls = make_create_project_client(
|
||||
build_stream_log=(
|
||||
"web Error\n"
|
||||
"Error response from daemon: manifest for nginx:0.0.0-bad not found: "
|
||||
"manifest unknown\n"
|
||||
),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "Build failed" in result
|
||||
assert "Error response from daemon: manifest for nginx:0.0.0-bad" in result
|
||||
assert "is registered but failed to build" in result
|
||||
assert "redeploy_project" in result
|
||||
assert "created and started successfully" not in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# M-5: polling exits early on BUILD_FAILED / ERROR
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_project_running_returns_early_on_build_failed():
|
||||
"""_wait_for_project_running must exit as soon as DSM reports BUILD_FAILED,
|
||||
not wait the full timeout."""
|
||||
from mcp_synology_container.modules.projects import _wait_for_project_running
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if method == "list":
|
||||
return project_list("BUILD_FAILED")
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
# 100s timeout, 2s interval — if the early-exit isn't there the test
|
||||
# would still terminate quickly because sleep is mocked, but the call
|
||||
# count assertion below catches a non-exiting loop.
|
||||
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
|
||||
|
||||
assert result == "BUILD_FAILED"
|
||||
# Only a few list() calls — exit was on the first poll iteration.
|
||||
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
|
||||
assert len(list_calls) <= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_project_running_returns_early_on_error():
|
||||
from mcp_synology_container.modules.projects import _wait_for_project_running
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if method == "list":
|
||||
return project_list("ERROR")
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
|
||||
|
||||
assert result == "ERROR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_surfaces_build_failed_with_hint():
|
||||
"""When polling reports BUILD_FAILED, redeploy_project must include a clear
|
||||
hint to inspect the image tag and retry."""
|
||||
client = AsyncMock()
|
||||
build_done = False
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if method == "list":
|
||||
return project_list("BUILD_FAILED") if build_done else project_list("RUNNING")
|
||||
return {}
|
||||
|
||||
async def mock_build_stream(project_id):
|
||||
nonlocal build_done
|
||||
build_done = True
|
||||
return "" # Clean stream log — failure surfaces via polling.
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "Redeploy failed" in result
|
||||
assert "BUILD_FAILED" in result
|
||||
assert "update_image_tag" in result
|
||||
assert "redeployed successfully" not in result
|
||||
# Polling must have exited early, not run to the full timeout.
|
||||
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
|
||||
# Generous upper bound — early exit means handful of polls, not hundreds.
|
||||
assert len(list_calls) <= 5
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# create_project
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
SIMPLE_COMPOSE = """
|
||||
services:
|
||||
web:
|
||||
image: nginx:1.25
|
||||
worker:
|
||||
image: redis:7
|
||||
"""
|
||||
|
||||
|
||||
def make_create_project_client(
|
||||
*,
|
||||
existing_projects: dict | None = None,
|
||||
create_folder_raises: Exception | None = None,
|
||||
create_project_raises: Exception | None = None,
|
||||
build_stream_raises: Exception | None = None,
|
||||
build_stream_log: str = "",
|
||||
project_id: str = "uuid-new",
|
||||
final_status: str = "RUNNING",
|
||||
):
|
||||
"""Build a stateful mock client for create_project tests.
|
||||
|
||||
Tracks:
|
||||
- whether Docker.Project/create has been called (so post_create_calls
|
||||
to /list return the newly-registered project at `final_status`)
|
||||
- which API/method/version each call used
|
||||
"""
|
||||
client = AsyncMock()
|
||||
calls: list[tuple[str, str, dict]] = []
|
||||
project_created = False
|
||||
|
||||
async def mock_request(api, method, version=None, params=None, **kwargs):
|
||||
calls.append((api, method, dict(params or {})))
|
||||
if api == "SYNO.Docker.Project" and method == "list":
|
||||
if project_created:
|
||||
return {
|
||||
project_id: {
|
||||
"id": project_id,
|
||||
"name": "newapp",
|
||||
"status": final_status,
|
||||
"path": "/volume1/docker/newapp",
|
||||
"containerIds": [],
|
||||
"services": [],
|
||||
}
|
||||
}
|
||||
return existing_projects or {}
|
||||
if api == "SYNO.FileStation.CreateFolder":
|
||||
if create_folder_raises:
|
||||
raise create_folder_raises
|
||||
return {}
|
||||
return {}
|
||||
|
||||
async def mock_post_request(api, method, version=None, params=None, **kwargs):
|
||||
nonlocal project_created
|
||||
calls.append((api, f"POST:{method}", dict(params or {})))
|
||||
if api == "SYNO.Docker.Project" and method == "create":
|
||||
if create_project_raises:
|
||||
raise create_project_raises
|
||||
project_created = True
|
||||
return {"id": project_id}
|
||||
return {}
|
||||
|
||||
async def mock_build_stream(pid):
|
||||
calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid}))
|
||||
if build_stream_raises:
|
||||
raise build_stream_raises
|
||||
return build_stream_log
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.post_request.side_effect = mock_post_request
|
||||
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
||||
return client, calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_preview_only():
|
||||
"""Without confirmed=True, no side effects — return a preview with service count."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE)
|
||||
|
||||
assert "confirmed=True" in result
|
||||
assert "newapp" in result
|
||||
assert "Services: 2" in result
|
||||
assert "/docker/newapp" in result
|
||||
# No CreateFolder, no Project/create, no build_stream
|
||||
methods = [m for _, m, _ in calls]
|
||||
assert "create" not in methods # FileStation.CreateFolder
|
||||
assert "POST:create" not in methods
|
||||
assert "build_stream" not in methods
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_invalid_name():
|
||||
"""Path-traversal-style names are rejected before any I/O."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("../escape", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "invalid project name" in result.lower()
|
||||
# No API calls at all
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_invalid_yaml():
|
||||
"""Malformed compose content is rejected before any I/O."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("newapp", "this: is: not: yaml: [", confirmed=True)
|
||||
|
||||
assert "Invalid YAML" in result or "Invalid compose" in result
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_compose_without_services():
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("newapp", "version: '3'\n", confirmed=True)
|
||||
|
||||
assert "services" in result.lower()
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_already_exists():
|
||||
"""If a project with the given name already exists, abort without creating anything."""
|
||||
existing = {
|
||||
"uuid-1": {
|
||||
"id": "uuid-1",
|
||||
"name": "newapp",
|
||||
"status": "RUNNING",
|
||||
"path": "/volume1/docker/newapp",
|
||||
"containerIds": [],
|
||||
"services": [],
|
||||
}
|
||||
}
|
||||
client, calls = make_create_project_client(existing_projects=existing)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "already exists" in result
|
||||
assert "RUNNING" in result
|
||||
# Only the list call should have happened
|
||||
methods = [m for _, m, _ in calls]
|
||||
assert "create" not in methods
|
||||
assert "POST:create" not in methods
|
||||
client.trigger_build_stream.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_happy_path():
|
||||
"""confirmed=True with no existing project: folder → create → build_stream → RUNNING."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "created and started successfully" in result
|
||||
|
||||
# Verify all three steps fired in the correct order
|
||||
summarised = [(api, method) for api, method, _ in calls]
|
||||
assert ("SYNO.FileStation.CreateFolder", "create") in summarised
|
||||
assert ("SYNO.Docker.Project", "POST:create") in summarised
|
||||
assert ("SYNO.Docker.Project", "build_stream") in summarised
|
||||
cf_idx = summarised.index(("SYNO.FileStation.CreateFolder", "create"))
|
||||
cp_idx = summarised.index(("SYNO.Docker.Project", "POST:create"))
|
||||
bs_idx = summarised.index(("SYNO.Docker.Project", "build_stream"))
|
||||
assert cf_idx < cp_idx < bs_idx
|
||||
|
||||
# Verify JSON-encoding of CreateFolder params
|
||||
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
|
||||
assert cf_params["folder_path"] == '"/docker"'
|
||||
assert cf_params["name"] == '"newapp"'
|
||||
assert cf_params["force_parent"] == "true"
|
||||
|
||||
# Verify JSON-encoding of Docker.Project/create params
|
||||
cp_params = next(
|
||||
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
|
||||
)
|
||||
assert cp_params["name"] == '"newapp"'
|
||||
assert cp_params["share_path"] == '"/docker/newapp"'
|
||||
assert cp_params["enable_service_portal"] == "false"
|
||||
assert cp_params["service_portal_port"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_explicit_share_path():
|
||||
"""Caller-supplied share_path overrides the derived default."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"](
|
||||
"newapp",
|
||||
SIMPLE_COMPOSE,
|
||||
share_path="/projects/custom/newapp",
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
assert "created and started successfully" in result
|
||||
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
|
||||
assert cf_params["folder_path"] == '"/projects/custom"'
|
||||
assert cf_params["name"] == '"newapp"'
|
||||
cp_params = next(
|
||||
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
|
||||
)
|
||||
assert cp_params["share_path"] == '"/projects/custom/newapp"'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_error_2100_surfaces_hint():
|
||||
"""DSM error 2100 on Project/create returns a clear 'target folder' message."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_create_project_client(
|
||||
create_project_raises=SynologyError("Folder issue", code=2100),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "2100" in result
|
||||
assert "target folder" in result.lower()
|
||||
# build_stream must NOT have been called after a failed Project/create
|
||||
client.trigger_build_stream.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_build_stream_failure_keeps_registration():
|
||||
"""If build_stream fails AFTER successful Project/create, the user is told the
|
||||
project is registered-but-not-started and pointed at redeploy_project."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_create_project_client(
|
||||
build_stream_raises=SynologyError("transport error", code=0),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "registered but was not started" in result
|
||||
assert "redeploy_project" in result
|
||||
assert "created and started successfully" not in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# delete_project
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_delete_project_client(
|
||||
*,
|
||||
project: dict | None = None,
|
||||
delete_raises: Exception | None = None,
|
||||
):
|
||||
"""Stateful mock client for delete_project tests.
|
||||
|
||||
- `project`: the project dict returned by Project/list. None → no
|
||||
project registered (simulates the "not found" case).
|
||||
- `delete_raises`: optional exception raised when Project/delete is
|
||||
called (used to simulate DSM refusing to delete a running project).
|
||||
"""
|
||||
client = AsyncMock()
|
||||
calls: list[tuple[str, str, dict]] = []
|
||||
project_deleted = False
|
||||
|
||||
async def mock_request(api, method, version=None, params=None, **kwargs):
|
||||
nonlocal project_deleted
|
||||
calls.append((api, method, dict(params or {})))
|
||||
if api == "SYNO.Docker.Project" and method == "list":
|
||||
if project is None or project_deleted:
|
||||
return {}
|
||||
return {project["id"]: project}
|
||||
if api == "SYNO.Docker.Project" and method == "delete":
|
||||
if delete_raises:
|
||||
raise delete_raises
|
||||
project_deleted = True
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
return client, calls
|
||||
|
||||
|
||||
SAMPLE_PROJECT_RUNNING = {
|
||||
"id": "uuid-abc",
|
||||
"name": "myapp",
|
||||
"status": "RUNNING",
|
||||
"path": "/volume1/docker/myapp",
|
||||
"share_path": "/docker/myapp",
|
||||
"containerIds": ["c1"],
|
||||
"services": [],
|
||||
}
|
||||
|
||||
SAMPLE_PROJECT_STOPPED = {
|
||||
**SAMPLE_PROJECT_RUNNING,
|
||||
"status": "STOPPED",
|
||||
"containerIds": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_preview_only():
|
||||
"""confirmed=False: no Project/delete call; preview shows UUID and warns about folder."""
|
||||
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("myapp")
|
||||
|
||||
assert "confirmed=True" in result
|
||||
assert "uuid-abc" in result
|
||||
assert "myapp" in result
|
||||
assert "/docker/myapp" in result
|
||||
# No delete call
|
||||
methods = [m for _, m, _ in calls]
|
||||
assert "delete" not in methods
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_not_found():
|
||||
"""If the project isn't registered, return a clear 'not found' message — no delete."""
|
||||
client, calls = make_delete_project_client(project=None)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("ghost", confirmed=True)
|
||||
|
||||
assert "not found" in result
|
||||
assert "ghost" in result
|
||||
methods = [m for _, m, _ in calls]
|
||||
assert "delete" not in methods
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_rejects_invalid_name():
|
||||
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("../escape", confirmed=True)
|
||||
|
||||
assert "invalid project name" in result.lower()
|
||||
# Not even a list call
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_happy_path():
|
||||
"""confirmed=True with a stopped project: UUID is json.dumps'd; success message
|
||||
mentions both 'deleted' and the surviving folder path."""
|
||||
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "deleted" in result
|
||||
assert "registration removed" in result
|
||||
assert "/docker/myapp" in result
|
||||
assert "NOT deleted" in result
|
||||
|
||||
delete_call = next(
|
||||
(a, m, p) for a, m, p in calls if a == "SYNO.Docker.Project" and m == "delete"
|
||||
)
|
||||
_api, _method, params = delete_call
|
||||
# The UUID must arrive JSON-encoded per the reverse-engineered DSM convention.
|
||||
assert params["id"] == '"uuid-abc"'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_running_blocked_connector_side():
|
||||
"""Live test showed that DSM does NOT reject Project/delete on a running project —
|
||||
it silently orphans the containers. The connector must therefore block the call
|
||||
itself when the project is RUNNING, without ever calling client.request(delete)."""
|
||||
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_RUNNING)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "RUNNING" in result
|
||||
assert "stop_project" in result
|
||||
# The delete endpoint must NOT have been called — no orphaned containers.
|
||||
delete_calls = [m for _, m, _ in calls if m == "delete"]
|
||||
assert delete_calls == []
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Tests for modules/registry.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# search_registry — Issue #5
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SEARCH_RESPONSE = {
|
||||
"data": [
|
||||
{
|
||||
"name": "caddy",
|
||||
"description": (
|
||||
"Caddy 2 is a powerful, enterprise-ready, open source web "
|
||||
"server with automatic HTTPS written in Go."
|
||||
),
|
||||
"downloads": 650989718,
|
||||
"is_automated": False,
|
||||
"is_official": True,
|
||||
"star_count": 881,
|
||||
"registry": "https://registry.hub.docker.com",
|
||||
},
|
||||
{
|
||||
"name": "abiosoft/caddy",
|
||||
"description": "Caddy is a lightweight, general-purpose web server.",
|
||||
"downloads": 111974910,
|
||||
"is_automated": True,
|
||||
"is_official": False,
|
||||
"star_count": 289,
|
||||
"registry": "https://registry.hub.docker.com",
|
||||
},
|
||||
],
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"page_size": 20,
|
||||
"total": 6647,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_renders_hits():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SEARCH_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["search_registry"](query="caddy")
|
||||
|
||||
assert "caddy" in result
|
||||
assert "abiosoft/caddy" in result
|
||||
assert "[official]" in result
|
||||
assert "881" in result # stars
|
||||
assert "650989718" in result # downloads
|
||||
assert "2 of 6647" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_uses_json_encoded_query():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SEARCH_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
await tools["search_registry"](query="nginx", limit=10)
|
||||
|
||||
call = client.request.call_args
|
||||
assert call.args[0] == "SYNO.Docker.Registry"
|
||||
assert call.args[1] == "search"
|
||||
assert call.kwargs.get("version") == 1
|
||||
params = call.kwargs.get("params", {})
|
||||
assert json.loads(params["q"]) == "nginx"
|
||||
assert params["offset"] == "0"
|
||||
assert params["limit"] == "10"
|
||||
assert params["page_size"] == "10"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_empty():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {"data": [], "total": 0}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["search_registry"](query="does-not-exist")
|
||||
assert "No results" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("Auth failure", code=119)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["search_registry"](query="caddy")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# list_image_tags — bonus
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_array_response():
|
||||
"""DSM returns the envelope's data field directly as a list."""
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = [
|
||||
{"tag": "latest"},
|
||||
{"tag": "3.20"},
|
||||
{"tag": "3.19"},
|
||||
]
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="alpine")
|
||||
|
||||
assert "alpine" in result
|
||||
assert "latest" in result
|
||||
assert "3.20" in result
|
||||
assert "3.19" in result
|
||||
assert "3 total" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_uses_json_repo_param():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = [{"tag": "latest"}]
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
await tools["list_image_tags"](repository="grafana/grafana")
|
||||
|
||||
call = client.request.call_args
|
||||
assert call.args[0] == "SYNO.Docker.Registry"
|
||||
assert call.args[1] == "tags"
|
||||
assert call.kwargs.get("version") == 1
|
||||
params = call.kwargs.get("params", {})
|
||||
assert json.loads(params["repo"]) == "grafana/grafana"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_limit_truncates():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
tags = [{"tag": f"v{i}"} for i in range(120)]
|
||||
client = AsyncMock()
|
||||
client.request.return_value = tags
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="alpine", limit=10)
|
||||
assert "120 total" in result
|
||||
assert "Showing first 10 of 120" in result
|
||||
# Tag #10 must not appear (only v0..v9)
|
||||
assert " v9" in result
|
||||
assert " v10" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_empty():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
# Empty list → DsmClient.request coerces to {} via `or {}`
|
||||
client.request.return_value = {}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="nonexistent/image")
|
||||
assert "No tags found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("Boom", code=100)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="alpine")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# pull_image — Issue #3
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_preview():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24")
|
||||
assert "Preview" in result
|
||||
assert "nginx:1.24" in result
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_already_present():
|
||||
"""If image is already in Image/list, no pull_start call is made."""
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
|
||||
]
|
||||
}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "already present" in result
|
||||
|
||||
# Only the Image/list pre-check was called; no pull_start of any kind.
|
||||
calls = client.request.call_args_list
|
||||
assert all(c.args[1] != "pull_start" for c in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_confirmed_success(monkeypatch):
|
||||
"""pull_start succeeds, then Image/list shows the new tag on first poll."""
|
||||
import mcp_synology_container.modules.registry as registry_mod
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
# Make polling instant
|
||||
async def fake_sleep(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||
|
||||
state = {"pulled": False}
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
if state["pulled"]:
|
||||
return {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
|
||||
]
|
||||
}
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Image" and method == "pull_start":
|
||||
state["pulled"] = True
|
||||
return {}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "Pulled" in result
|
||||
assert "nginx:1.24" in result
|
||||
|
||||
# Verify pull_start was invoked on SYNO.Docker.Image (not Registry)
|
||||
# with JSON-encoded params.
|
||||
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
|
||||
assert len(pull_calls) == 1
|
||||
assert pull_calls[0].args[0] == "SYNO.Docker.Image"
|
||||
params = pull_calls[0].kwargs.get("params", {})
|
||||
assert json.loads(params["repository"]) == "nginx"
|
||||
assert json.loads(params["tag"]) == "1.24"
|
||||
assert pull_calls[0].kwargs.get("version") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_timeout(monkeypatch):
|
||||
"""If the image never appears, the tool returns a still-running hint."""
|
||||
import mcp_synology_container.modules.registry as registry_mod
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
# Make polling instant
|
||||
async def fake_sleep(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||
# Shrink the budget so the loop exits quickly
|
||||
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Image" and method == "pull_start":
|
||||
return {}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "still running" in result
|
||||
assert "nginx:1.24" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_start_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Image" and method == "pull_start":
|
||||
raise SynologyError("Permission denied", code=105)
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "Error starting pull" in result
|
||||
@@ -305,3 +305,298 @@ async def test_system_prune_api_error():
|
||||
|
||||
result = await tools["system_prune"](confirmed=True)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# M-6: system_prune preview now counts unused networks
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
SAMPLE_NETWORKS_FOR_PRUNE = {
|
||||
"network": [
|
||||
# User-created, no containers attached → will be pruned
|
||||
{"name": "orphan_net", "driver": "bridge", "containers": []},
|
||||
# User-created, in use → must NOT be counted
|
||||
{
|
||||
"name": "myapp_default",
|
||||
"driver": "bridge",
|
||||
"containers": ["web", "db"],
|
||||
},
|
||||
# Built-in networks: Docker never prunes these even if empty
|
||||
{"name": "bridge", "driver": "bridge", "containers": []},
|
||||
{"name": "host", "driver": "host", "containers": []},
|
||||
{"name": "none", "driver": "null", "containers": []},
|
||||
# Another user-created empty network
|
||||
{"name": "legacy_net", "driver": "bridge", "containers": []},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prune_preview_counts_unused_networks() -> None:
|
||||
"""M-6: preview must enumerate user-created networks with no containers,
|
||||
skipping the three built-in networks (bridge/host/none)."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Network":
|
||||
return SAMPLE_NETWORKS_FOR_PRUNE
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_prune"]()
|
||||
|
||||
# Two unused user-created networks; the three built-ins must not appear.
|
||||
assert "Unused networks: 2" in result
|
||||
assert "orphan_net" in result
|
||||
assert "legacy_net" in result
|
||||
# Built-in network names must not appear in the prune preview.
|
||||
assert " - bridge " not in result
|
||||
assert " - host " not in result
|
||||
assert " - none " not in result
|
||||
# Network with containers must not be listed.
|
||||
assert "myapp_default" not in result
|
||||
# Old "not counted" placeholder must be gone.
|
||||
assert "not counted" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prune_preview_network_fetch_failure_is_nonfatal() -> None:
|
||||
"""If the network list fetch fails, the preview still works (0 networks)."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Network":
|
||||
raise SynologyError("network list failed", code=100)
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_prune"]()
|
||||
# Preview still renders; networks count falls back to 0.
|
||||
assert "preview" in result.lower()
|
||||
assert "Unused networks: 0" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# system_overview
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Two-container stats snapshot used to verify aggregation arithmetic.
|
||||
SAMPLE_STATS_OVERVIEW = {
|
||||
"aaa111": {
|
||||
"id": "aaa111",
|
||||
"name": "/web",
|
||||
"cpu_stats": {
|
||||
"cpu_usage": {"total_usage": 2_000_000_000, "percpu_usage": [1, 2]},
|
||||
"system_cpu_usage": 1_000_000_000_000,
|
||||
"online_cpus": 2,
|
||||
},
|
||||
"precpu_stats": {
|
||||
"cpu_usage": {"total_usage": 1_000_000_000},
|
||||
"system_cpu_usage": 999_000_000_000,
|
||||
},
|
||||
"memory_stats": {"usage": 100 * 1024 * 1024, "limit": 1024 * 1024 * 1024},
|
||||
"networks": {
|
||||
"eth0": {"rx_bytes": 1_000_000, "tx_bytes": 500_000},
|
||||
},
|
||||
"blkio_stats": {
|
||||
"io_service_bytes_recursive": [
|
||||
{"op": "Read", "value": 10 * 1024 * 1024},
|
||||
{"op": "Write", "value": 5 * 1024 * 1024},
|
||||
]
|
||||
},
|
||||
},
|
||||
"bbb222": {
|
||||
"id": "bbb222",
|
||||
"name": "/db",
|
||||
"cpu_stats": {
|
||||
"cpu_usage": {"total_usage": 3_000_000_000, "percpu_usage": [1, 2]},
|
||||
"system_cpu_usage": 1_000_000_000_000,
|
||||
"online_cpus": 2,
|
||||
},
|
||||
"precpu_stats": {
|
||||
"cpu_usage": {"total_usage": 2_000_000_000},
|
||||
"system_cpu_usage": 999_000_000_000,
|
||||
},
|
||||
"memory_stats": {"usage": 200 * 1024 * 1024, "limit": 2 * 1024 * 1024 * 1024},
|
||||
"networks": {
|
||||
"eth0": {"rx_bytes": 2_000_000, "tx_bytes": 1_000_000},
|
||||
},
|
||||
"blkio_stats": {
|
||||
"io_service_bytes_recursive": [
|
||||
{"op": "Read", "value": 20 * 1024 * 1024},
|
||||
{"op": "Write", "value": 7 * 1024 * 1024},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_aggregates_stats():
|
||||
"""Sums CPU%, memory, net I/O, and block I/O across all containers in stats."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
return SAMPLE_STATS_OVERVIEW
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
|
||||
# Headline section exists
|
||||
assert "Docker System Overview" in result
|
||||
assert "Aggregated" in result
|
||||
|
||||
# CPU per container: (1e9 / 1e9) * 2 * 100 = 200% each → sum ≈ 400%
|
||||
assert "400.00%" in result
|
||||
|
||||
# Memory total usage = 100 MiB + 200 MiB = 300 MiB; limit = 1 GiB + 2 GiB = 3 GiB
|
||||
assert "300 MiB" in result
|
||||
assert "3.0 GiB" in result
|
||||
|
||||
# Net I/O present (rx/tx sums non-zero)
|
||||
assert "Net I/O" in result
|
||||
assert "rx " in result
|
||||
assert "tx " in result
|
||||
|
||||
# Block I/O: read = 30 MiB, write = 12 MiB
|
||||
assert "30 MiB" in result
|
||||
assert "12 MiB" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_running_vs_stopped_count():
|
||||
"""Counts running and stopped containers from the list response."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
return SAMPLE_STATS_OVERVIEW
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
|
||||
# SAMPLE_CONTAINERS has 2 running ("web", "db") and 1 stopped ("old")
|
||||
assert "running" in result
|
||||
assert "stopped" in result
|
||||
assert " 2 running" in result
|
||||
assert " 1 stopped" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_no_containers():
|
||||
"""Empty container list and empty stats — shows zero counts, no aggregation block."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": []}
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
assert " 0 running" in result
|
||||
assert " 0 stopped" in result
|
||||
# No aggregation block when there are no stats entries
|
||||
assert "Aggregated" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_container_list_error_graceful():
|
||||
"""list fails, stats works → still shows aggregated stats and a warning."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
raise SynologyError("list failed", code=102)
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
return SAMPLE_STATS_OVERVIEW
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
# Counts default to 0 (list failed), but aggregation still runs
|
||||
assert "Warnings" in result
|
||||
assert "list" in result
|
||||
assert "Aggregated" in result
|
||||
assert "400.00%" in result # stats still aggregated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_stats_error_graceful():
|
||||
"""stats fails, list works → still shows counts and a warning."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
raise SynologyError("stats failed", code=102)
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
# Counts still displayed
|
||||
assert " 2 running" in result
|
||||
assert " 1 stopped" in result
|
||||
# Warning surfaced for the stats failure
|
||||
assert "Warnings" in result
|
||||
assert "stats" in result
|
||||
# No aggregation block — stats unavailable
|
||||
assert "Aggregated" not in result
|
||||
|
||||
Reference in New Issue
Block a user