DSM emits a readable plaintext build log over the build_stream 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, leaving 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. Wall-clock budget of 210 s
(under the Claude Desktop ~4 min ceiling); on timeout the partial log
is returned with a "[build_stream: timeout — stream still open
server-side]" marker so callers know the build continues server-side.
Per-chunk ReadTimeout is treated the same way. JSON error envelope,
transport-error mapping (M-4), and SID-scrubbed HTTP-error formatting
are unchanged.
redeploy_project and create_project now parse the returned log via
_parse_build_stream_log (any line containing "Error response from
daemon:" or ending in " Error" counts as a failure). On a failed log
the tools abort immediately, surface the daemon line(s) in the result
(e.g. "Error response from daemon: manifest for nginx:9.9.9 not
found: manifest unknown"), and skip the polling step. The BUILD_FAILED
polling guard (M-5) stays as a second safety net for late failures
where the stream 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.
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.
Tests: streamed-log collection, daemon-error log, header ReadTimeout
marker, per-chunk ReadTimeout partial log, wall-clock budget
truncation, _parse_build_stream_log unit tests, redeploy/create end-
to-end behavior with a failing log.
Closes#2
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 0.5.0 prompt mis-attributed the API: pull_start lives on
SYNO.Docker.Image, not SYNO.Docker.Registry (live DSM capture).
search and tags ARE correctly on Registry; only pull_start belongs
to Image. Registry/pull_start returns "Method does not exist".
Parameters are unchanged (repository + tag both JSON-encoded), and
the Image/list polling for completion detection is untouched.
Tests updated to assert SYNO.Docker.Image/pull_start. CLAUDE.md
DSM-quirks section consolidates the Image vs. Registry split so
this trap is documented for future surface additions. References
#3 (already closed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three new SYNO.Docker.Registry tools, reverse-engineered from a live
DSM API capture (n4s4 reference disagrees on param names and methods).
- search_registry (#5): SYNO.Docker.Registry/search v1 with JSON-encoded
q, plus offset/limit/page_size. Renders stars, downloads, official
flag, truncated description, and total match count. Read-only.
- list_image_tags: SYNO.Docker.Registry/tags v1 with JSON-encoded repo
(not name — DSM live capture diverges from n4s4). Response shape is
unusual: tag list comes back as the envelope's data field directly.
Output capped by limit (default 50); accepts both list and dict
response shapes defensively. Read-only.
- pull_image (#3): SYNO.Docker.Registry/pull_start v1 with both
repository and tag JSON-encoded. Async pull — no pull_status method
confirmed on this DSM, so completion is detected by polling
SYNO.Docker.Image/list (2–10 s backoff, 240 s budget under the
Claude Desktop ~4 min tool-call ceiling). Timeout returns a
non-fatal "still running" hint. Short-circuits when the image is
already present locally. Confirmation gate required.
Tool count: 31 → 34. CLAUDE.md confirmation list updated. New DSM
quirks documented for pull_start (no pull_status) and tags (repo
param name, top-level data array).
Closes#3Closes#5
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three live-NAS observations that the 0.4.2 implementation got wrong:
1. Header showed "?:?" — DSM Image/get returns image="" and tag=""
for name:tag lookups; the response fields are unreliable. The
header now echoes the user-supplied image_id, falling back to the
sha256 id only when image_id itself is empty.
2. Ports printed as raw Python dicts ({'port':'22','protocol':'tcp'}).
The ports array is a list of {port, protocol} objects — each entry
now renders as "22/tcp".
3. Environment printed as raw Python dicts ({'key':'PATH','value':...}).
The env array is a list of {key, value} objects — each entry now
renders as "PATH=/usr/local/...".
cmd, entrypoint, and volumes (plain string arrays) were already
correct and are untouched. Tests updated to match the live shape;
two new tests guard the header fallback. References #4 (already
closed by 0.4.2 — no need to reopen for a rendering polish).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Live DSM API capture revealed the actual SYNO.Docker.Image/get
contract: a single JSON-encoded parameter named `identity` that
accepts both `name:tag` and `sha256:<hash>` forms. The 0.4.0 code
passed `name` + `tag` + `id` and was rejected by DSM with error 114.
Response shape also corrected — the endpoint returns flat top-level
fields (image, tag, id, digest, size, virtual_size, author,
docker_version, cmd, entrypoint, env, ports, volumes), NOT the
Docker-engine inspect shape with details.Config.* + RootFS.Layers
that the previous implementation assumed. Layer rendering removed;
digest / author / docker_version / volumes are now displayed.
Pre-resolution against list_images is no longer needed — the user
input goes straight into `identity` JSON-encoded.
Closes#4.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Live test on this DSM firmware: SYNO.Docker.Container has no pause/
unpause method ("Method does not exist"). The Container Manager GUI
action menu only exposes Start / Stop / Force-Stop / Restart / Reset —
pause/resume simply isn't a feature here.
The two tools were briefly shipped in 0.4.0 (implemented by symmetry
with the verified stop call) and have now been removed rather than
left as a broken surface. The remaining lifecycle tools
(start_container, stop_container, restart_container) are unaffected.
Tool count: 33 → 31. Closes#7 (won't fix — DSM limitation).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes#1, #4, #6, #7.
Container lifecycle (#1, #7):
- start_container, stop_container, restart_container, pause_container,
unpause_container — all via SYNO.Docker.Container with JSON-encoded
name parameter, routed through _resolve_container_name for hash-
prefix resolution. stop is live-verified; the other four are
implemented by symmetry on the same API surface.
inspect_image (#4):
- Returns full image detail (layers, env, ports, entrypoint/cmd,
labels) via SYNO.Docker.Image/get. Accepts name:tag, registry-
prefixed names, and bare hashes. Defensive response parsing
handles both wrapped (details.*) and flat envelopes.
system_overview (#6):
- Aggregates CPU %, RAM, network and block I/O across all running
containers plus running/stopped counts. No new DSM endpoint —
composed from list + stats, reusing the container_stats CPU
formula. Per-source errors are non-fatal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug 1 — delete_container (DSM error 114):
SYNO.Docker.Container/delete requires three parameters: name
(JSON-encoded), force=false, and preserve_profile=false. Previously
only a bare `name` string was sent, causing DSM to reject the call
with error 114. Added the two missing fields and JSON-encode name to
match the DSM convention. The connector-side running-container guard
is unchanged; force stays hard-coded to false.
Bug 2 — delete_project orphan containers:
Production test revealed that DSM does NOT reject Project/delete on a
running project — it silently removes the registration and leaves the
containers running without any project context. The previous
implementation tried to handle this via a caught SynologyError that
never actually fires. Fix: check the project status from _find_project
connector-side before issuing any DSM call; if RUNNING, return an
error pointing at stop_project. The delete request is never sent for
a running project.
The corresponding unit test (test_delete_project_running_returns_stop_hint)
was a false positive — it mocked a DSM rejection that real DSM never
produces. Replaced with test_delete_project_running_blocked_connector_side
which asserts that client.request("delete") is never called when the
project is RUNNING.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the project lifecycle (create → start/stop/redeploy → delete).
The tool calls SYNO.Docker.Project/delete with the UUID JSON-encoded
as the `id` parameter (per DSM convention) and removes only the
Container Manager registration — the project folder and compose
file remain on the NAS. This mirrors DSM's own "Delete project"
behaviour, not a bug; the success message states the folder was
preserved so the user is not surprised.
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.
- No automatic stop. If the project is RUNNING and DSM rejects
the delete, the response tells the user to `stop_project` first
rather than silently halting containers under the guise of a
"delete" call.
- Requires confirmed=True; preview shows name, UUID, status, full
path, and share path so the user can verify before deleting.
Tests cover preview-only, not-found, invalid-name, happy path
(verifies the UUID is JSON-encoded in the delete call), and the
running-project rejection path that surfaces the stop_project hint.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds `create_project` for registering a new Container Manager project
from a compose YAML string. Three-step flow that mirrors the DSM
"Create Project" wizard:
1. SYNO.FileStation.CreateFolder with force_parent=true (idempotent
— does not fail if the folder already exists, and creates missing
intermediate directories). Without this step, Docker.Project/create
fails with DSM error 2100.
2. SYNO.Docker.Project/create (form-encoded POST; JSON-encoded string
parameters per DSM convention) returns the new project UUID.
3. trigger_build_stream + _wait_for_project_running, reusing the
existing image-pull / start / poll machinery (including the
BUILD_FAILED early-exit from welle 2).
Safety:
- Project-name validation (Welle-1 regex) runs before any I/O.
- Compose content is YAML-parsed and must contain a top-level
`services` key 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.
- share_path defaults to compose_base_path + project_name (e.g.
/volume1/docker + myapp → /docker/myapp); a caller-supplied value
overrides it.
- Requires confirmed=True; the preview shows the resolved share path
and the service count parsed from the compose content.
- DSM error 2100 surfaces as "target folder issue" with the attempted
path. A build_stream failure after a successful Project/create tells
the user the project is registered-but-not-started and points at
redeploy_project for recovery.
Tests cover preview-only, already-exists, happy path (with parameter
JSON-encoding assertions), explicit share_path, malformed YAML,
missing services key, invalid project name, error 2100, and
build_stream failure after registration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three resilience and honesty fixes from the v0.2.8 review. Minor
version bump because redeploy_project and system_prune return
different strings.
M-4: trigger_build_stream now converts every non-ReadTimeout
httpx.HTTPError (ConnectError, ConnectTimeout, WriteError,
RemoteProtocolError, ...) into a SynologyError with a clear
message. Previously only ReadTimeout was handled; everything else
propagated as a raw httpx exception. redeploy_project now tracks
whether stop was actually issued and, when build_stream fails after
a successful stop, tells the user the project is in STOPPED state
and recommends start_project / retry rather than the misleading
"use stop + start separately" workaround.
M-5: _wait_for_project_running exits early on BUILD_FAILED / ERROR
(new _TERMINAL_FAILURE_STATUSES frozenset). 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
now surfaces the terminal status with a BUILD_FAILED-specific hint
to update_image_tag.
M-6: system_prune preview now enumerates user-created networks
that have no containers attached (excluding the three built-in
networks bridge/host/none, which Docker never prunes). Previously
the preview noted "Unused networks: (not counted)" even though
SYNO.Docker.Utils/prune does delete them — users could lose
networks they had not been warned about.
Tests:
- 2 new dsm_client tests: ConnectError and RemoteProtocolError
both raise SynologyError, not raw httpx exceptions.
- 2 new project tests: recovery hint after stop+build_stream
failure (RUNNING case); old workaround retained for the
STOPPED case where no stop was issued.
- 3 new polling tests: BUILD_FAILED and ERROR each trigger early
exit; redeploy_project surfaces BUILD_FAILED with update_image_tag
hint.
- 2 new system_prune preview tests: counts unused networks
correctly, excludes built-ins; network-fetch failure is non-fatal.
245 tests pass. ruff check + ruff format clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
C-1: __version__ now derived from package metadata via
importlib.metadata.version() so pyproject.toml is the single source of
truth. Previously stuck at "0.1.0" since the initial release.
C-2: Backfill CHANGELOG entries for 0.2.7 and 0.2.8 (both releases had
shipped without changelog updates) and add a 0.2.9 entry covering this
welle.
M-3: Reject project names containing 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. Adds _validate_project_name
(regex ^[a-zA-Z0-9_-]+$, applied in read_compose, update_compose,
update_image_tag, update_env_var) plus parametrized tests for valid and
unsafe names and one rejection test per tool. 236 tests pass.
Also: ruff format autofix on three pre-existing files (cli.py,
config.py, test_config.py) — cosmetic only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the delete-before-start workaround with the real mechanism:
SYNO.Docker.Project/build_stream is what the DSM "Erstellen" button calls
(confirmed via DevTools). It pulls updated images and starts the project.
DsmClient.trigger_build_stream(project_id): fires a streaming GET to
build_stream, reads the first SSE chunk to confirm DSM accepted the
request, then closes. ReadTimeout is swallowed (build running server-side).
Immediate JSON error responses are parsed and raised as SynologyError.
redeploy_project simplified from 4 steps to 3:
1. Stop (skip for STOPPED, suppress for BUILD_FAILED)
2. trigger_build_stream — DSM pulls images + starts project
3. Poll for RUNNING (timeout raised from 30s → 5min for large pulls)
build_stream errors are now fatal (abort with clear message).
Removes _read_compose_images_for_project, _try_delete_image and their
json/yaml/re imports — no longer needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
redeploy_project: replace broken SYNO.Docker.Image/pull with a unified
4-step delete-before-start flow for all project states (RUNNING, STOPPED,
BUILD_FAILED). Reads image tags from the project's compose.yaml via
FileStation before stopping, deletes each cached image (non-fatal), then
starts the project so DSM auto-pulls the latest version. Polls for RUNNING
as before.
update_image_tag: auto-update env vars whose value equals the numeric
version prefix of the old tag when the new tag shares the same
<digits>-<suffix> pattern (e.g. JENKINS_VERSION=2.558 → 2.560 when tag
changes 2.558-jdk21 → 2.560-jdk21). Preview mode lists the pending
auto-updates. Only triggers when the var exists and the pattern matches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove contextlib.suppress from the image pull step in the BUILD_FAILED
redeploy path. A failed pull (e.g. non-existent tag) now immediately
returns an actionable error pointing to update_image_tag instead of
silently continuing and starting the project with stale/missing image.
Also bumps version 0.2.1 → 0.2.2 and adds CHANGELOG entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DSM starts containers asynchronously - start_project returns immediately
while containers are still initialising. Adds _wait_for_project_running:
polls SYNO.Docker.Project/list every 2s up to 30s after issuing start.
Reports RUNNING on success; emits a warning instead of failure on timeout
so callers can still verify with get_project_status.
Applies to all three redeploy paths (RUNNING, STOPPED, BUILD_FAILED).
Also bumps version 0.2.0 → 0.2.1 and adds CHANGELOG entry.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Bug fixes from product test:
1. redeploy_project: BUILD_FAILED now includes explicit image pull (stop → pull → start)
2. delete_image: Distinguishes running vs stopped containers, suggests system_prune for stopped refs
3. New tool delete_container: Verify stopped state before deletion, confirmation required
Tests added for all three paths plus stopped-container edge cases.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
DSM response has two top-level keys:
details → Docker Engine inspect (State, NetworkSettings, Mounts)
profile → DSM format (image, port_bindings)
_format_container_detail now reads:
Status/Running/StartedAt from details.State
Image from profile.image
IP addresses from details.NetworkSettings.Networks
Port bindings from profile.port_bindings
Mounts from details.Mounts
Also: debug_container_response tool removed, json/sys imports cleaned up.
27 container tests all passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- get_container_status now strips hash prefix from user input and calls
SYNO.Docker.Container/get with the clean name (e.g. 'jenkins'), not the
hash-prefixed form — the get endpoint accepts only the clean name
- _format_container_detail: unwraps 'container' wrapper key if present
(DSM may return {"container": {State, Config, ...}} at the data level)
- Flat-format fallback: reads lowercase 'status'/'image' fields when
Docker Engine nested format (State/Config) is absent
- Diagnostic stderr logging for data_keys, unwrap, status, image
- 25 container tests all passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1: Container name hash-prefix (e.g. f93cb8b504f7_jenkins)
- _strip_hash_prefix(): strips 12-char hex prefix and leading slash
- _resolve_container_name(): looks up actual DSM name from container list
- Applied in list_containers (display), container_stats (matching),
get_container_status/get_container_logs/exec_in_container (lookup)
Bug 2: redeploy_project DSM 2101/1202 on wrong project state
- Fetch project status before acting
- RUNNING → stop then start
- STOPPED → start directly (nothing to stop)
- BUILD_FAILED → suppress stop error, then start
- Other → return error with workaround hint
36 tests all passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DSM methods for SYNO.Docker.Image/pull and SYNO.Docker.Registry/get
did not behave as expected in production testing against the NAS.
Tools deregistered, modules deleted, tests removed, CLAUDE.md updated.
Tool count: 19 → 17.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- pull_image: SYNO.Docker.Image/pull with repository+tag split via
rpartition; polls image list every 3 s until image appears, 120 s timeout
- list_registries: SYNO.Docker.Registry/get; shows name, URL, active marker
- Gruppe 5 (Volumes) removed from roadmap — SYNO.Docker.Volume does not exist
- CLAUDE.md: tool count 17 → 19, Volumes section removed
- 28 tests all passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
create_network:
- Switch from GET request() to post_request()
- name, subnet, gateway, iprange → json.dumps(value) per DSM convention
- disable_masquerade=json.dumps(False) added as required fixed param
delete_network:
- Switch from GET request() to post_request() with method "remove"
(was "delete" — wrong method name)
- Send full network object from list response as
networks=json.dumps([{...}]) including all DSM fields plus _key=id
(ipv6_gateway, ipv6_subnet, ipv6_iprange, disable_masquerade with
safe defaults for fields absent from the list response)
Tests updated: mock post_request, assert correct method/params structure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
list_networks:
SYNO.Docker.Network/list → shows name, driver, subnet, gateway,
attached containers. Response key is "network" (not "networks").
create_network(name, driver, subnet, gateway, ip_range, enable_ipv6, confirmed):
Dry-run preview without confirmed=True. Passes enable_ipv6 as
json.dumps(bool) per N4S4 reference. Optional params (subnet,
gateway, iprange) omitted from request when not provided.
delete_network(name, confirmed):
Validates network exists and has no attached containers before
deleting. Clear error listing attached container names if blocked.
15 unit tests covering all paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
system_df:
Assembles disk-usage report from SYNO.Docker.Image/list and
SYNO.Docker.Container/list. Reports image count/size/reclaimable
(images not referenced by any container), container running/stopped.
Gracefully degrades when one API is unavailable.
system_prune:
Without confirmed=True: lists dangling/unused images and stopped
containers with sizes (dry-run preview).
With confirmed=True: calls SYNO.Docker.Utils/prune and reports
reclaimed space from the response (SpaceReclaimed field).
10 unit tests: stats counts, reclaimable detection, preview content,
confirmed execution, missing-response-field graceful handling, API error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DSM Container Manager rejects name+tag and sha256 id params (error 114).
Browser DevTools capture shows the correct call is:
POST SYNO.Docker.Image / delete / version=1
images=[{"repository":"nouchka/sqlite3","tags":["latest"]}]
Changes:
- Add DsmClient.post_request() for form-encoded POST requests
- delete_image now calls post_request with version=1 and the images
JSON array built from the resolved repository name and tag
- Remove unused docker error-code dict from _error_message()
- Tests mock post_request and assert the images param content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DSM SYNO.Docker.Image/delete returns error 114 when called with name+tag.
The API expects the sha256 hash from the image list (field "id") as the
"id" parameter.
- Look up the sha256 hash from SYNO.Docker.Image/list (already fetched
for the in-use check), then pass params={"id": img_hash}
- Guard against missing hash with a clear error message
- Updated tests to assert id param is sent, name/tag are absent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
partition(":") split at the first colon, so registry-prefixed images
like "ghcr.io/open-webui/open-webui:v0.8.10" produced name="ghcr"
instead of the correct repository name, causing DSM error 114.
rpartition(":") always splits at the last colon, correctly handling:
- plain images: "nginx:1.24" → name="nginx", tag="1.24"
- namespaced: "nouchka/sqlite3:latest" → correct split
- registry URLs: "ghcr.io/foo/bar:v1" → name="ghcr.io/foo/bar", tag="v1"
- no tag: "nginx" → name="nginx", tag="latest" (fallback)
Added regression test verifying correct params sent to DSM delete API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- list_images: lists all local Docker images sorted by size desc,
shows size (human-readable), creation date, in-use marker, and
update-available marker; gracefully handles container list failure
- delete_image: accepts name:tag or image hash, blocks deletion when
image is in use by a container, requires confirmed=True to execute;
default shows a dry-run preview
- 16 unit tests covering all paths (mock DSM client)
- ruff format + check clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>