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.
- bg_coolify_migrate/__init__.py +86 -0
- bg_coolify_migrate/api/__init__.py +1 -0
- bg_coolify_migrate/api/client.py +491 -0
- bg_coolify_migrate/api/fields.py +382 -0
- bg_coolify_migrate/api/resources.py +466 -0
- bg_coolify_migrate/cli.py +689 -0
- bg_coolify_migrate/discovery/__init__.py +1 -0
- bg_coolify_migrate/discovery/docker.py +282 -0
- bg_coolify_migrate/discovery/quiesce.py +305 -0
- bg_coolify_migrate/dns/__init__.py +1 -0
- bg_coolify_migrate/dns/extract.py +253 -0
- bg_coolify_migrate/dns/gate.py +229 -0
- bg_coolify_migrate/dns/resolve.py +160 -0
- bg_coolify_migrate/domain/__init__.py +10 -0
- bg_coolify_migrate/domain/compose.py +281 -0
- bg_coolify_migrate/domain/drift.py +479 -0
- bg_coolify_migrate/domain/images.py +219 -0
- bg_coolify_migrate/domain/kinds.py +260 -0
- bg_coolify_migrate/domain/manifest.py +290 -0
- bg_coolify_migrate/domain/naming.py +318 -0
- bg_coolify_migrate/domain/plan.py +255 -0
- bg_coolify_migrate/domain/statemachine.py +256 -0
- bg_coolify_migrate/engine/__init__.py +1 -0
- bg_coolify_migrate/engine/compensations.py +178 -0
- bg_coolify_migrate/engine/context.py +141 -0
- bg_coolify_migrate/engine/executor.py +276 -0
- bg_coolify_migrate/engine/keys.py +157 -0
- bg_coolify_migrate/engine/planner.py +628 -0
- bg_coolify_migrate/engine/runner.py +308 -0
- bg_coolify_migrate/engine/steps.py +741 -0
- bg_coolify_migrate/errors.py +168 -0
- bg_coolify_migrate/journal/__init__.py +1 -0
- bg_coolify_migrate/journal/store.py +302 -0
- bg_coolify_migrate/observability/__init__.py +1 -0
- bg_coolify_migrate/observability/logging_setup.py +173 -0
- bg_coolify_migrate/py.typed +0 -0
- bg_coolify_migrate/server/__init__.py +1 -0
- bg_coolify_migrate/server/appkey.py +234 -0
- bg_coolify_migrate/server/fencing.py +141 -0
- bg_coolify_migrate/server/inventory.py +205 -0
- bg_coolify_migrate/server/runner.py +140 -0
- bg_coolify_migrate/server/statemachine.py +94 -0
- bg_coolify_migrate/server/steps.py +362 -0
- bg_coolify_migrate/settings/__init__.py +1 -0
- bg_coolify_migrate/settings/base.py +188 -0
- bg_coolify_migrate/transfer/__init__.py +1 -0
- bg_coolify_migrate/transfer/partition.py +153 -0
- bg_coolify_migrate/transfer/rsync.py +279 -0
- bg_coolify_migrate/transfer/ssh.py +313 -0
- bg_coolify_migrate/transfer/verify.py +269 -0
- bg_coolify_migrate/ui/__init__.py +1 -0
- bg_coolify_migrate/ui/console.py +87 -0
- bg_coolify_migrate/ui/dashboard.py +189 -0
- bg_coolify_migrate/ui/report.py +224 -0
- bg_coolify_migrate/ui/run_report.py +150 -0
- bg_coolify_migrate/ui/server_report.py +91 -0
- bg_coolify_migrate/ui/wizard.py +215 -0
- bg_coolify_migrate-1.0.0.dist-info/METADATA +157 -0
- bg_coolify_migrate-1.0.0.dist-info/RECORD +62 -0
- bg_coolify_migrate-1.0.0.dist-info/WHEEL +4 -0
- bg_coolify_migrate-1.0.0.dist-info/entry_points.txt +2 -0
- 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__}")
|