bg-coolify-migrate 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. bg_coolify_migrate/__init__.py +86 -0
  2. bg_coolify_migrate/api/__init__.py +1 -0
  3. bg_coolify_migrate/api/client.py +491 -0
  4. bg_coolify_migrate/api/fields.py +382 -0
  5. bg_coolify_migrate/api/resources.py +466 -0
  6. bg_coolify_migrate/cli.py +689 -0
  7. bg_coolify_migrate/discovery/__init__.py +1 -0
  8. bg_coolify_migrate/discovery/docker.py +282 -0
  9. bg_coolify_migrate/discovery/quiesce.py +305 -0
  10. bg_coolify_migrate/dns/__init__.py +1 -0
  11. bg_coolify_migrate/dns/extract.py +253 -0
  12. bg_coolify_migrate/dns/gate.py +229 -0
  13. bg_coolify_migrate/dns/resolve.py +160 -0
  14. bg_coolify_migrate/domain/__init__.py +10 -0
  15. bg_coolify_migrate/domain/compose.py +281 -0
  16. bg_coolify_migrate/domain/drift.py +479 -0
  17. bg_coolify_migrate/domain/images.py +219 -0
  18. bg_coolify_migrate/domain/kinds.py +260 -0
  19. bg_coolify_migrate/domain/manifest.py +290 -0
  20. bg_coolify_migrate/domain/naming.py +318 -0
  21. bg_coolify_migrate/domain/plan.py +255 -0
  22. bg_coolify_migrate/domain/statemachine.py +256 -0
  23. bg_coolify_migrate/engine/__init__.py +1 -0
  24. bg_coolify_migrate/engine/compensations.py +178 -0
  25. bg_coolify_migrate/engine/context.py +141 -0
  26. bg_coolify_migrate/engine/executor.py +276 -0
  27. bg_coolify_migrate/engine/keys.py +157 -0
  28. bg_coolify_migrate/engine/planner.py +628 -0
  29. bg_coolify_migrate/engine/runner.py +308 -0
  30. bg_coolify_migrate/engine/steps.py +741 -0
  31. bg_coolify_migrate/errors.py +168 -0
  32. bg_coolify_migrate/journal/__init__.py +1 -0
  33. bg_coolify_migrate/journal/store.py +302 -0
  34. bg_coolify_migrate/observability/__init__.py +1 -0
  35. bg_coolify_migrate/observability/logging_setup.py +173 -0
  36. bg_coolify_migrate/py.typed +0 -0
  37. bg_coolify_migrate/server/__init__.py +1 -0
  38. bg_coolify_migrate/server/appkey.py +234 -0
  39. bg_coolify_migrate/server/fencing.py +141 -0
  40. bg_coolify_migrate/server/inventory.py +205 -0
  41. bg_coolify_migrate/server/runner.py +140 -0
  42. bg_coolify_migrate/server/statemachine.py +94 -0
  43. bg_coolify_migrate/server/steps.py +362 -0
  44. bg_coolify_migrate/settings/__init__.py +1 -0
  45. bg_coolify_migrate/settings/base.py +188 -0
  46. bg_coolify_migrate/transfer/__init__.py +1 -0
  47. bg_coolify_migrate/transfer/partition.py +153 -0
  48. bg_coolify_migrate/transfer/rsync.py +279 -0
  49. bg_coolify_migrate/transfer/ssh.py +313 -0
  50. bg_coolify_migrate/transfer/verify.py +269 -0
  51. bg_coolify_migrate/ui/__init__.py +1 -0
  52. bg_coolify_migrate/ui/console.py +87 -0
  53. bg_coolify_migrate/ui/dashboard.py +189 -0
  54. bg_coolify_migrate/ui/report.py +224 -0
  55. bg_coolify_migrate/ui/run_report.py +150 -0
  56. bg_coolify_migrate/ui/server_report.py +91 -0
  57. bg_coolify_migrate/ui/wizard.py +215 -0
  58. bg_coolify_migrate-1.0.0.dist-info/METADATA +157 -0
  59. bg_coolify_migrate-1.0.0.dist-info/RECORD +62 -0
  60. bg_coolify_migrate-1.0.0.dist-info/WHEEL +4 -0
  61. bg_coolify_migrate-1.0.0.dist-info/entry_points.txt +2 -0
  62. bg_coolify_migrate-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,86 @@
1
+ """BAUER GROUP Coolify migration toolkit.
2
+
3
+ Moves a Coolify project — with its data — between servers of one Coolify
4
+ instance (F1), and relocates a whole Coolify instance to a new host (F2).
5
+
6
+ Why this exists
7
+ ---------------
8
+ Coolify can clone a resource to another server but deliberately will not move
9
+ the data: ``VolumeCloneJob`` and ``CloneMe``'s ``cloneVolumeData`` flag exist,
10
+ but upstream PR #4777 shipped them disabled. The maintainer's stated blockers —
11
+ permission damage, job-queue spam at 50+ resources, no progress tracking, and
12
+ large-volume failures — are all consequences of running inside Coolify's Laravel
13
+ queue. An external orchestrator has none of those constraints.
14
+
15
+ Design invariants (see AGENTS.md; do not weaken without an explicit decision)
16
+ ----------------------------------------------------------------------------
17
+ * Application-unaware: a cleanly stopped stack makes a volume just bytes. No
18
+ per-engine logic, no engine allowlist.
19
+ * Byte-exact: ``rsync -aHAXS --numeric-ids``. Never ``chown`` — Coolify's own
20
+ clone hardcodes ``chown -R 1000:1000`` and that is precisely what corrupts
21
+ postgres/mysql/redis (uid 999) and clickhouse (uid 101) volumes.
22
+ * REST API only. No SQL writes against ``coolify-db``.
23
+ * The source is never destroyed until an explicit, confirmed finalize step —
24
+ which is what makes rollback cheap.
25
+
26
+ The public surface is imported lazily (PEP 562) so that ``import
27
+ bg_coolify_migrate`` to read ``__version__`` does not drag in asyncssh, httpx
28
+ and the whole Rich UI.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ __version__ = "1.0.0"
36
+
37
+ # Name -> submodule, resolved on first attribute access.
38
+ _LAZY: dict[str, str] = {
39
+ "MigrationError": "bg_coolify_migrate.errors",
40
+ "PreflightError": "bg_coolify_migrate.errors",
41
+ "QuiesceError": "bg_coolify_migrate.errors",
42
+ "TransferError": "bg_coolify_migrate.errors",
43
+ "VerificationError": "bg_coolify_migrate.errors",
44
+ "DnsGateBlocked": "bg_coolify_migrate.errors",
45
+ "RebuildDriftBlocked": "bg_coolify_migrate.errors",
46
+ "CoolifyApiError": "bg_coolify_migrate.errors",
47
+ "InsufficientTokenScope": "bg_coolify_migrate.errors",
48
+ }
49
+
50
+ if TYPE_CHECKING:
51
+ from bg_coolify_migrate.errors import CoolifyApiError as CoolifyApiError
52
+ from bg_coolify_migrate.errors import DnsGateBlocked as DnsGateBlocked
53
+ from bg_coolify_migrate.errors import InsufficientTokenScope as InsufficientTokenScope
54
+ from bg_coolify_migrate.errors import MigrationError as MigrationError
55
+ from bg_coolify_migrate.errors import PreflightError as PreflightError
56
+ from bg_coolify_migrate.errors import QuiesceError as QuiesceError
57
+ from bg_coolify_migrate.errors import RebuildDriftBlocked as RebuildDriftBlocked
58
+ from bg_coolify_migrate.errors import TransferError as TransferError
59
+ from bg_coolify_migrate.errors import VerificationError as VerificationError
60
+
61
+
62
+ def __getattr__(name: str) -> Any:
63
+ module_name = _LAZY.get(name)
64
+ if module_name is None:
65
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
66
+ import importlib
67
+
68
+ return getattr(importlib.import_module(module_name), name)
69
+
70
+
71
+ def __dir__() -> list[str]:
72
+ return sorted([*_LAZY, "__version__"])
73
+
74
+
75
+ __all__ = [
76
+ "CoolifyApiError",
77
+ "DnsGateBlocked",
78
+ "InsufficientTokenScope",
79
+ "MigrationError",
80
+ "PreflightError",
81
+ "QuiesceError",
82
+ "RebuildDriftBlocked",
83
+ "TransferError",
84
+ "VerificationError",
85
+ "__version__",
86
+ ]
@@ -0,0 +1 @@
1
+ """api layer."""
@@ -0,0 +1,491 @@
1
+ """Coolify REST API client.
2
+
3
+ IO shell: keep logic out of here; it belongs in ``domain/``.
4
+
5
+ Three non-obvious behaviours this client exists to handle:
6
+
7
+ 1. **The token scope trap.** Coolify's ``ApiSensitiveData`` middleware computes
8
+ ``can_read_sensitive = token->can('root') || token->can('read:sensitive')``.
9
+ Without it, controllers call ``makeHidden(['value', 'real_value', ...])`` and
10
+ the keys simply **vanish** from the JSON — no error, no redaction marker, HTTP
11
+ 200. A migration driven by a plain ``read`` token would happily recreate every
12
+ environment variable with no value at all. So we probe eagerly at startup and
13
+ fail closed. This is the single most important thing in this module.
14
+
15
+ 2. **``$allowedFields`` is enforced.** Unknown fields are a 422 per field. Every
16
+ write goes through a whitelist from :mod:`.fields`; we never round-trip a GET
17
+ into a POST.
18
+
19
+ 3. **Deploy endpoints rate-limit.** 429 is expected under normal operation, not
20
+ exceptional, so it is retried with backoff rather than surfaced.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import random
27
+ from types import TracebackType
28
+ from typing import Any, Self
29
+
30
+ import httpx
31
+ import structlog
32
+
33
+ from bg_coolify_migrate.errors import CoolifyApiError, InsufficientTokenScope
34
+
35
+ log = structlog.get_logger(__name__)
36
+
37
+ #: Methods that are safe to retry blindly. POST is deliberately absent: a
38
+ #: retried create can produce two resources, and cleaning that up by hand is
39
+ #: exactly the kind of half-state this tool exists to avoid.
40
+ _IDEMPOTENT = frozenset({"GET", "HEAD", "PUT", "PATCH", "DELETE"})
41
+
42
+ _RETRY_STATUS = frozenset({429, 502, 503, 504})
43
+
44
+
45
+ class CoolifyClient:
46
+ """Async client for one Coolify instance.
47
+
48
+ Usage::
49
+
50
+ async with CoolifyClient(base_url, token) as api:
51
+ await api.assert_can_read_sensitive()
52
+ servers = await api.list_servers()
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ base_url: str,
58
+ token: str,
59
+ *,
60
+ timeout: float = 30.0,
61
+ max_retries: int = 4,
62
+ verify: bool = True,
63
+ ) -> None:
64
+ self._base = base_url.rstrip("/")
65
+ if not self._base.endswith("/api/v1"):
66
+ self._base = f"{self._base}/api/v1"
67
+ self._token = token
68
+ self._max_retries = max_retries
69
+ self._client = httpx.AsyncClient(
70
+ base_url=self._base,
71
+ timeout=httpx.Timeout(timeout, connect=10.0),
72
+ headers={
73
+ "Authorization": f"Bearer {token}",
74
+ "Accept": "application/json",
75
+ "Content-Type": "application/json",
76
+ },
77
+ verify=verify,
78
+ follow_redirects=False,
79
+ )
80
+ self._can_read_sensitive: bool | None = None
81
+ self._probe_reason: str = "unprobed"
82
+
83
+ async def __aenter__(self) -> Self:
84
+ return self
85
+
86
+ async def __aexit__(
87
+ self,
88
+ exc_type: type[BaseException] | None,
89
+ exc: BaseException | None,
90
+ tb: TracebackType | None,
91
+ ) -> None:
92
+ await self.aclose()
93
+
94
+ async def aclose(self) -> None:
95
+ await self._client.aclose()
96
+
97
+ # ── transport ────────────────────────────────────────────────────────────
98
+
99
+ async def _raw(
100
+ self,
101
+ method: str,
102
+ path: str,
103
+ *,
104
+ json: dict[str, Any] | None = None,
105
+ params: dict[str, Any] | None = None,
106
+ ) -> httpx.Response:
107
+ """Perform the request with retries; return the response undecoded.
108
+
109
+ Exists because ``/version`` answers in plain text while everything else
110
+ answers in JSON.
111
+ """
112
+ attempt = 0
113
+ while True:
114
+ attempt += 1
115
+ try:
116
+ response = await self._client.request(method, path, json=json, params=params)
117
+ except httpx.TimeoutException as exc:
118
+ if method in _IDEMPOTENT and attempt <= self._max_retries:
119
+ await self._backoff(attempt, reason="timeout")
120
+ continue
121
+ raise CoolifyApiError(
122
+ f"{method} {path} timed out after {attempt} attempt(s)",
123
+ hint="Check COOLIFY_URL and that the instance is reachable.",
124
+ ) from exc
125
+ except httpx.HTTPError as exc:
126
+ raise CoolifyApiError(f"{method} {path} failed: {exc}") from exc
127
+
128
+ if response.status_code in _RETRY_STATUS and attempt <= self._max_retries:
129
+ retry_after = _parse_retry_after(response)
130
+ await self._backoff(attempt, reason=str(response.status_code), floor=retry_after)
131
+ continue
132
+
133
+ if response.status_code >= 400:
134
+ raise _to_error(method, path, response)
135
+
136
+ return response
137
+
138
+ async def _request(
139
+ self,
140
+ method: str,
141
+ path: str,
142
+ *,
143
+ json: dict[str, Any] | None = None,
144
+ params: dict[str, Any] | None = None,
145
+ ) -> Any:
146
+ """Perform the request and decode its JSON body."""
147
+ response = await self._raw(method, path, json=json, params=params)
148
+ if not response.content:
149
+ return None
150
+ try:
151
+ return response.json()
152
+ except ValueError as exc:
153
+ raise CoolifyApiError(
154
+ f"{method} {path} returned non-JSON body",
155
+ status_code=response.status_code,
156
+ body=response.text[:200],
157
+ ) from exc
158
+
159
+ async def _backoff(self, attempt: int, *, reason: str, floor: float | None = None) -> None:
160
+ # Full jitter: a fleet of retries must not synchronise into a thundering
161
+ # herd against an instance that is already struggling.
162
+ delay = min(2 ** (attempt - 1), 30.0)
163
+ delay = random.uniform(0, delay)
164
+ if floor is not None:
165
+ delay = max(delay, floor)
166
+ log.debug("api.retry", attempt=attempt, reason=reason, delay=round(delay, 2))
167
+ await asyncio.sleep(delay)
168
+
169
+ async def get(self, path: str, **params: Any) -> Any:
170
+ return await self._request("GET", path, params=params or None)
171
+
172
+ async def post(self, path: str, body: dict[str, Any] | None = None) -> Any:
173
+ return await self._request("POST", path, json=body)
174
+
175
+ async def patch(self, path: str, body: dict[str, Any]) -> Any:
176
+ return await self._request("PATCH", path, json=body)
177
+
178
+ async def delete(self, path: str, **params: Any) -> Any:
179
+ return await self._request("DELETE", path, params=params or None)
180
+
181
+ # ── capability probe ─────────────────────────────────────────────────────
182
+
183
+ async def probe_sensitive(self) -> tuple[bool, str]:
184
+ """Probe the token's ability to read secrets. Returns ``(ok, reason)``.
185
+
186
+ We cannot introspect a token's abilities through the API, so we observe
187
+ the middleware's behaviour instead: ask for security keys and see whether
188
+ ``private_key`` survives the sanitizer.
189
+
190
+ THREE outcomes, not two — and conflating the last two is a lie that costs
191
+ someone half an hour:
192
+
193
+ * a key with ``private_key`` -> the token can read secrets
194
+ * a key without it -> the token definitely cannot
195
+ * no keys at all -> we could not tell
196
+
197
+ The last is still fail-closed, because an indeterminate probe must never
198
+ read as a pass. But the *reason* has to be the true one, or the operator
199
+ goes and re-issues a token that was never the problem.
200
+ """
201
+ if self._can_read_sensitive is not None:
202
+ return self._can_read_sensitive, self._probe_reason
203
+
204
+ keys = await self.get("/security/keys")
205
+ if not isinstance(keys, list) or not keys:
206
+ self._can_read_sensitive = False
207
+ self._probe_reason = "indeterminate"
208
+ log.warning("api.probe.indeterminate", reason="no ssh keys exist to probe against")
209
+ return False, "indeterminate"
210
+
211
+ ok = any("private_key" in k for k in keys if isinstance(k, dict))
212
+ self._can_read_sensitive = ok
213
+ self._probe_reason = "confirmed" if ok else "denied"
214
+ return ok, self._probe_reason
215
+
216
+ async def can_read_sensitive(self) -> bool:
217
+ """Whether the token carries ``root`` or ``read:sensitive``.
218
+
219
+ Fails closed on an indeterminate probe. See :meth:`probe_sensitive` for
220
+ why the distinction matters.
221
+ """
222
+ ok, _ = await self.probe_sensitive()
223
+ return ok
224
+
225
+ async def assert_can_read_sensitive(self) -> None:
226
+ """Fail closed unless the token can read secrets.
227
+
228
+ Raises:
229
+ InsufficientTokenScope: Always, if the probe fails. Never a warning:
230
+ without this scope the API returns HTTP 200 with the secret keys
231
+ simply absent, so the migration would silently produce a target
232
+ whose environment variables are all empty and whose compose is
233
+ missing entirely.
234
+ """
235
+ if await self.can_read_sensitive():
236
+ return
237
+ raise InsufficientTokenScope(
238
+ "the Coolify API token cannot read sensitive data",
239
+ hint=(
240
+ "Create a token with `root` or `read:sensitive` in Coolify under "
241
+ "Keys & Tokens > API tokens.\n"
242
+ "Without it Coolify silently OMITS environment variable values, "
243
+ "docker_compose_raw and database passwords from its responses — "
244
+ "HTTP 200, no error, keys simply absent. Migrating with such a "
245
+ "token would recreate every resource with empty secrets."
246
+ ),
247
+ )
248
+
249
+ # ── reads ────────────────────────────────────────────────────────────────
250
+
251
+ async def version(self) -> str:
252
+ """The instance's Coolify version.
253
+
254
+ Special-cased because ``GET /v1/version`` returns a BARE STRING —
255
+ ``4.1.2``, not ``{"version": "4.1.2"}`` and not even a quoted JSON
256
+ string. Every other endpoint returns JSON, so this one cannot go through
257
+ the normal decode path.
258
+
259
+ (This was found by the e2e rig against a real instance. The unit test
260
+ mocked it as JSON — an assumption checked against itself.)
261
+ """
262
+ response = await self._raw("GET", "/version")
263
+ text = response.text.strip().strip('"')
264
+ if text.startswith("{"):
265
+ # Tolerate a future version that returns a JSON object.
266
+ try:
267
+ parsed = response.json()
268
+ except ValueError:
269
+ return text
270
+ if isinstance(parsed, dict):
271
+ return str(parsed.get("version", text))
272
+ return text
273
+
274
+ async def list_servers(self) -> list[dict[str, Any]]:
275
+ return _as_list(await self.get("/servers"))
276
+
277
+ async def get_server(self, uuid: str) -> dict[str, Any]:
278
+ return _as_dict(await self.get(f"/servers/{uuid}"))
279
+
280
+ @staticmethod
281
+ def server_is_reachable(server: dict[str, Any]) -> bool | None:
282
+ """Whether Coolify can SSH to this server.
283
+
284
+ NOT a top-level field: it lives under ``settings.is_reachable``. Servers
285
+ are the one endpoint that eager-loads its settings relation
286
+ (``server_by_uuid`` does ``$server->load(['settings'])``), and reading it
287
+ from the top level silently yields None forever.
288
+
289
+ Returns None when the key is absent — "we do not know" is not "no".
290
+ """
291
+ settings = server.get("settings")
292
+ if isinstance(settings, dict) and "is_reachable" in settings:
293
+ return bool(settings["is_reachable"])
294
+ return None
295
+
296
+ @staticmethod
297
+ def server_is_coolify_host(server: dict[str, Any]) -> bool:
298
+ """Whether Coolify itself runs on this server. Used by F2."""
299
+ return bool(server.get("is_coolify_host"))
300
+
301
+ async def list_projects(self) -> list[dict[str, Any]]:
302
+ return _as_list(await self.get("/projects"))
303
+
304
+ async def get_project(self, uuid: str) -> dict[str, Any]:
305
+ return _as_dict(await self.get(f"/projects/{uuid}"))
306
+
307
+ async def list_resources(self) -> list[dict[str, Any]]:
308
+ return _as_list(await self.get("/resources"))
309
+
310
+ async def get_resource(self, collection: str, uuid: str) -> dict[str, Any]:
311
+ return _as_dict(await self.get(f"/{collection}/{uuid}"))
312
+
313
+ async def get_storages(self, collection: str, uuid: str) -> dict[str, Any]:
314
+ """``{"persistent_storages": [...], "file_storages": [...]}``.
315
+
316
+ Note the shape: a two-key object, not a flat array.
317
+ """
318
+ return _as_dict(await self.get(f"/{collection}/{uuid}/storages"))
319
+
320
+ async def get_envs(self, collection: str, uuid: str) -> list[dict[str, Any]]:
321
+ """Environment variables — **requires** ``read:sensitive``.
322
+
323
+ With a plain ``read`` token this returns entries whose ``value`` key is
324
+ absent rather than empty. Callers must have called
325
+ :meth:`assert_can_read_sensitive` first.
326
+ """
327
+ return _as_list(await self.get(f"/{collection}/{uuid}/envs"))
328
+
329
+ # ── writes ───────────────────────────────────────────────────────────────
330
+
331
+ async def set_envs_bulk(self, collection: str, uuid: str, entries: list[dict[str, Any]]) -> Any:
332
+ """Upsert environment variables, matched by ``key``.
333
+
334
+ The body key is literally ``data`` (``$request->get('data')``); anything
335
+ else is a 400 "Bulk data is required.".
336
+ """
337
+ return await self.patch(f"/{collection}/{uuid}/envs/bulk", {"data": entries})
338
+
339
+ async def create_storage(self, collection: str, uuid: str, body: dict[str, Any]) -> Any:
340
+ return await self.post(f"/{collection}/{uuid}/storages", body)
341
+
342
+ async def start(self, collection: str, uuid: str) -> Any:
343
+ return await self.post(f"/{collection}/{uuid}/start")
344
+
345
+ async def restart(self, collection: str, uuid: str) -> Any:
346
+ """Bring a resource up, whatever Coolify believes its state is.
347
+
348
+ Use this, not ``start``, to recover a source after a failed migration.
349
+ ``/start`` guards on the status column::
350
+
351
+ if (str($database->status)->contains('running')) {
352
+ return response()->json(['message' => 'Database is already running.'], 400);
353
+ }
354
+
355
+ and that column is advanced by a background job, so it lags the daemon.
356
+ After QUIESCE — which is ``docker stop`` then ``docker rm -f`` — the
357
+ container is gone but the column can still read "running", and ``/start``
358
+ then refuses to act while the source is in fact down. Swallowing that 400
359
+ would be the worst possible move: it reports the source recovered while
360
+ leaving it dead.
361
+
362
+ ``/restart`` (``action_restart``) carries no such guard — it dispatches
363
+ Restart* unconditionally, which for a removed container simply deploys
364
+ it. This is why the rollback path that ends the outage uses restart.
365
+ """
366
+ return await self.post(f"/{collection}/{uuid}/restart")
367
+
368
+ async def stop(self, collection: str, uuid: str) -> bool:
369
+ """Request a stop. Returns whether Coolify actually dispatched one.
370
+
371
+ A False return is not a failure — it is Coolify declining to act because
372
+ it believes the resource is already down. The caller must decide whether
373
+ to believe it (see the note below); it is never grounds to proceed as if
374
+ the stack were quiesced.
375
+
376
+ **This returns before anything has stopped** — every stop endpoint is a
377
+ `dispatch(...)`. Worse, for applications it does NOT stop preview
378
+ containers (``StopApplication`` filters ``pullRequestId=0``), and for
379
+ services it stops containers named from DB records rather than by label,
380
+ so a compose container Coolify never parsed is never stopped.
381
+
382
+ Callers MUST verify with the label-based quiesce gate in
383
+ ``discovery.quiesce``; never trust this call's return.
384
+
385
+ The False case is a 400, and it deserves precision because treating it as
386
+ an error and treating it as success are both wrong. Coolify decides it
387
+ from a database column::
388
+
389
+ if (str($database->status)->contains('stopped') ||
390
+ str($database->status)->contains('exited')) {
391
+ return response()->json(['message' => 'Database is already stopped.'], 400);
392
+ }
393
+ StopDatabase::dispatch($database, $dockerCleanup); // never reached
394
+
395
+ The column defaults to 'exited' and is advanced by a background job, so
396
+ it lags the daemon — right after a deploy it reads "exited" about a
397
+ container that is serving traffic. Raising here aborts a migration over a
398
+ stale row.
399
+
400
+ But note where the `return` sits: **no stop is dispatched**. So a caller
401
+ that shrugs this off and waits for the containers to exit waits for
402
+ something nobody asked for, and burns its whole gate timeout doing it.
403
+ Hence bool rather than a swallowed exception — the caller has to look.
404
+ """
405
+ try:
406
+ await self.post(f"/{collection}/{uuid}/stop")
407
+ return True
408
+ except CoolifyApiError as exc:
409
+ # Matched on the body, not the message: the wording differs per kind
410
+ # ("Database is already stopped.", "Service is already stopped.", ...)
411
+ # but all of them carry this phrase.
412
+ if exc.status_code == 400 and "already stopped" in str(exc.body).lower():
413
+ log.debug("api.stop.refused_as_stopped", collection=collection, uuid=uuid)
414
+ return False
415
+ raise
416
+
417
+ async def delete_resource(
418
+ self, collection: str, uuid: str, *, delete_volumes: bool = False
419
+ ) -> Any:
420
+ return await self.delete(
421
+ f"/{collection}/{uuid}",
422
+ deleteVolumes="true" if delete_volumes else "false",
423
+ )
424
+
425
+ async def update_resource(self, collection: str, uuid: str, body: dict[str, Any]) -> Any:
426
+ return await self.patch(f"/{collection}/{uuid}", body)
427
+
428
+
429
+ def _parse_retry_after(response: httpx.Response) -> float | None:
430
+ raw = response.headers.get("Retry-After")
431
+ if not raw:
432
+ return None
433
+ try:
434
+ return float(raw)
435
+ except ValueError:
436
+ return None
437
+
438
+
439
+ def _to_error(method: str, path: str, response: httpx.Response) -> CoolifyApiError:
440
+ try:
441
+ body = response.json()
442
+ except ValueError:
443
+ body = response.text[:400]
444
+
445
+ hint: str | None = None
446
+ message = str(body.get("message", "")) if isinstance(body, dict) else str(body)
447
+
448
+ if response.status_code == 401:
449
+ hint = "Check COOLIFY_TOKEN. Coolify tokens are instance-specific."
450
+ elif response.status_code == 403:
451
+ # Coolify returns 403 for BOTH "your token cannot do this" and "the API
452
+ # is switched off instance-wide", and the two need opposite fixes.
453
+ # Guessing sends the operator to re-issue a token that was never the
454
+ # problem. It tells us which in the body — read it.
455
+ if "API is disabled" in message:
456
+ hint = (
457
+ "The instance has its API switched off. Enable it in Coolify under "
458
+ "Settings > API, or call GET /api/v1/enable with a write token.\n"
459
+ "This is instance-wide and off by default; your token is fine."
460
+ )
461
+ else:
462
+ hint = "The token lacks the ability for this call (needs write/deploy, or root)."
463
+ elif response.status_code == 422:
464
+ # Coolify names the offending field, which is the single most useful
465
+ # thing when a whitelist in api/fields.py has drifted from upstream.
466
+ hint = (
467
+ "Coolify rejected a field. If this says 'This field is not allowed.', "
468
+ "our request whitelist in api/fields.py has drifted from upstream — "
469
+ "please open an issue with the field name."
470
+ )
471
+ elif response.status_code == 404:
472
+ hint = "Resource not found, or the API is disabled for this instance."
473
+
474
+ return CoolifyApiError(
475
+ f"{method} {path} failed",
476
+ status_code=response.status_code,
477
+ body=body,
478
+ hint=hint,
479
+ )
480
+
481
+
482
+ def _as_list(value: Any) -> list[dict[str, Any]]:
483
+ if isinstance(value, list):
484
+ return [v for v in value if isinstance(v, dict)]
485
+ raise CoolifyApiError(f"expected a JSON array, got {type(value).__name__}")
486
+
487
+
488
+ def _as_dict(value: Any) -> dict[str, Any]:
489
+ if isinstance(value, dict):
490
+ return value
491
+ raise CoolifyApiError(f"expected a JSON object, got {type(value).__name__}")