freepod 0.1.1__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.
- freepod/__init__.py +86 -0
- freepod/__main__.py +10 -0
- freepod/api.py +306 -0
- freepod/archive.py +480 -0
- freepod/auth.py +589 -0
- freepod/build.py +358 -0
- freepod/cli.py +349 -0
- freepod/config.py +158 -0
- freepod/deploy.py +867 -0
- freepod/project.py +203 -0
- freepod/tos.py +228 -0
- freepod/values.py +273 -0
- freepod-0.1.1.dist-info/METADATA +118 -0
- freepod-0.1.1.dist-info/RECORD +17 -0
- freepod-0.1.1.dist-info/WHEEL +4 -0
- freepod-0.1.1.dist-info/entry_points.txt +2 -0
- freepod-0.1.1.dist-info/licenses/LICENSE +21 -0
freepod/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""freepod — the Freepod command-line client.
|
|
2
|
+
|
|
3
|
+
The package is a pure client of the public Freepod REST API. It shares no code
|
|
4
|
+
with the API server and imports nothing from it.
|
|
5
|
+
|
|
6
|
+
This module holds only the error hierarchy, because every other module raises
|
|
7
|
+
from it and a leaf module keeps the imports acyclic. Each error carries the
|
|
8
|
+
exit code its class maps onto; see `cli-distribution` § *Exit codes
|
|
9
|
+
distinguish failure classes*.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"FreepodError",
|
|
16
|
+
"UsageError",
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"PermissionError_",
|
|
19
|
+
"BuildFailed",
|
|
20
|
+
"RolloutFailed",
|
|
21
|
+
"EXIT_OK",
|
|
22
|
+
"EXIT_ERROR",
|
|
23
|
+
"EXIT_USAGE",
|
|
24
|
+
"EXIT_NOT_AUTHENTICATED",
|
|
25
|
+
"EXIT_BUILD_FAILED",
|
|
26
|
+
"EXIT_ROLLOUT_FAILED",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.1"
|
|
30
|
+
|
|
31
|
+
EXIT_OK = 0
|
|
32
|
+
EXIT_ERROR = 1
|
|
33
|
+
EXIT_USAGE = 2
|
|
34
|
+
EXIT_NOT_AUTHENTICATED = 3
|
|
35
|
+
EXIT_BUILD_FAILED = 4
|
|
36
|
+
EXIT_ROLLOUT_FAILED = 5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FreepodError(Exception):
|
|
40
|
+
"""Anything that should end the run with a readable message.
|
|
41
|
+
|
|
42
|
+
Subclasses override `exit_code` to select their row of the exit-code
|
|
43
|
+
table. The base class is the "unexpected error" row.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
exit_code = EXIT_ERROR
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UsageError(FreepodError):
|
|
50
|
+
"""The command was invoked wrongly. Nothing was attempted."""
|
|
51
|
+
|
|
52
|
+
exit_code = EXIT_USAGE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AuthenticationError(FreepodError):
|
|
56
|
+
"""No usable credential, or one the platform will not accept.
|
|
57
|
+
|
|
58
|
+
Raised for the 401 row of the status-code contract, where re-authenticating
|
|
59
|
+
would succeed and change nothing, as well as for the ordinary "you are not
|
|
60
|
+
logged in" case.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
exit_code = EXIT_NOT_AUTHENTICATED
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PermissionError_(FreepodError):
|
|
67
|
+
"""Authenticated, but not permitted to act on the resource.
|
|
68
|
+
|
|
69
|
+
Named with a trailing underscore so it cannot shadow the builtin. This is
|
|
70
|
+
the API's own 403 — a credential problem no refresh can fix — and it is
|
|
71
|
+
deliberately *not* an `AuthenticationError`, so it never triggers a login.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
exit_code = EXIT_ERROR
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BuildFailed(FreepodError):
|
|
78
|
+
"""The build reached a non-successful terminal status."""
|
|
79
|
+
|
|
80
|
+
exit_code = EXIT_BUILD_FAILED
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RolloutFailed(FreepodError):
|
|
84
|
+
"""The rollout failed, or waiting for it timed out."""
|
|
85
|
+
|
|
86
|
+
exit_code = EXIT_ROLLOUT_FAILED
|
freepod/__main__.py
ADDED
freepod/api.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""The HTTP client for the Freepod API, and the status-code contract it obeys.
|
|
2
|
+
|
|
3
|
+
The platform's authentication statuses invert the conventional reading, for
|
|
4
|
+
reasons recorded in design Appendix A. The short version:
|
|
5
|
+
|
|
6
|
+
| Status | Answered by | Client action |
|
|
7
|
+
|----------------------------------------|-------------|------------------------------------|
|
|
8
|
+
| 401 | edge | stop and explain; do not re-auth |
|
|
9
|
+
| 403, non-JSON body | edge | refresh once and retry |
|
|
10
|
+
| 403, JSON `detail` | API | stop — a permission error |
|
|
11
|
+
| 404, `{"detail": "Not authenticated"}` | API | report a platform condition |
|
|
12
|
+
|
|
13
|
+
The two 403 rows are why the rule cannot simply be "403 means refresh": the API
|
|
14
|
+
issues its own 403s from `require_self` / `require_admin`, and an unbounded
|
|
15
|
+
refresh rule would refresh, fail, re-login, and loop on a request that no
|
|
16
|
+
credential can satisfy. Hence: **refresh at most once per request**.
|
|
17
|
+
|
|
18
|
+
One asymmetry worth knowing while reading this module: most of the reads the
|
|
19
|
+
client performs later — products, plans, hostnames, domains — are on the edge's
|
|
20
|
+
`skip_auth_routes` list and are answered anonymously whatever credential the
|
|
21
|
+
request carried. They cannot return 401 or 403, so none of the machinery below
|
|
22
|
+
ever fires on them. That is exactly why `GET /api/me` must come first.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
from urllib.parse import quote
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from . import AuthenticationError, FreepodError, PermissionError_
|
|
34
|
+
from .auth import Session
|
|
35
|
+
from .config import DEFAULT_HTTP_TIMEOUT, USER_AGENT, Environment
|
|
36
|
+
|
|
37
|
+
#: Methods whose repetition cannot create or duplicate state.
|
|
38
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
39
|
+
|
|
40
|
+
#: Total attempts for a safe request, including the first.
|
|
41
|
+
MAX_ATTEMPTS = 3
|
|
42
|
+
|
|
43
|
+
#: First backoff interval; doubles per attempt.
|
|
44
|
+
BACKOFF_BASE_SECONDS = 0.5
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _json_detail(response: httpx.Response) -> Optional[str]:
|
|
48
|
+
"""The `detail` of a JSON error body, or None if the body is not one.
|
|
49
|
+
|
|
50
|
+
Identification is by *body shape*, not by `Content-Type`: the edge's
|
|
51
|
+
refusals are a bare `http.Error` with a plain-text body and no reliable
|
|
52
|
+
content type, while every refusal the API itself issues is a FastAPI JSON
|
|
53
|
+
document carrying `detail`.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
payload = response.json()
|
|
57
|
+
except ValueError:
|
|
58
|
+
return None
|
|
59
|
+
if isinstance(payload, dict) and isinstance(payload.get("detail"), str):
|
|
60
|
+
return payload["detail"]
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ApiClient:
|
|
65
|
+
"""Authenticated access to one environment's REST API."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
env: Environment,
|
|
70
|
+
session: Session,
|
|
71
|
+
*,
|
|
72
|
+
timeout: int = DEFAULT_HTTP_TIMEOUT,
|
|
73
|
+
verbose: bool = False,
|
|
74
|
+
client: Optional[httpx.Client] = None,
|
|
75
|
+
backoff_base: float = BACKOFF_BASE_SECONDS,
|
|
76
|
+
):
|
|
77
|
+
self.env = env
|
|
78
|
+
self.session = session
|
|
79
|
+
self.timeout = timeout
|
|
80
|
+
self.verbose = verbose
|
|
81
|
+
self.backoff_base = backoff_base
|
|
82
|
+
self._client = client if client is not None else httpx.Client()
|
|
83
|
+
self._owns_client = client is None
|
|
84
|
+
|
|
85
|
+
# -- lifecycle --------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
if self._owns_client:
|
|
89
|
+
self._client.close()
|
|
90
|
+
|
|
91
|
+
def __enter__(self) -> "ApiClient":
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(self, *exc_info) -> None:
|
|
95
|
+
self.close()
|
|
96
|
+
|
|
97
|
+
# -- requests ---------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
100
|
+
"""Perform a request, applying the status-code contract.
|
|
101
|
+
|
|
102
|
+
Returns the response for any status the contract does not claim, so a
|
|
103
|
+
caller that needs to distinguish, say, the two causes of a 409 still
|
|
104
|
+
can. Raises for every credential-related status.
|
|
105
|
+
"""
|
|
106
|
+
url = self.env.url(path)
|
|
107
|
+
refreshed = False
|
|
108
|
+
|
|
109
|
+
while True:
|
|
110
|
+
response = self._send(method, url, **kwargs)
|
|
111
|
+
status = response.status_code
|
|
112
|
+
|
|
113
|
+
if status == 401:
|
|
114
|
+
# Either no credential reached the edge, or — on dev only — a
|
|
115
|
+
# perfectly valid one belongs to a non-member of the gating
|
|
116
|
+
# group. Re-authenticating succeeds and changes nothing, so
|
|
117
|
+
# this never triggers a login.
|
|
118
|
+
raise AuthenticationError(self._unauthenticated_message(url))
|
|
119
|
+
|
|
120
|
+
if status == 403:
|
|
121
|
+
detail = _json_detail(response)
|
|
122
|
+
if detail is not None:
|
|
123
|
+
# The API answered: authenticated, but not permitted here.
|
|
124
|
+
raise PermissionError_(f"403 from {url} — {detail}")
|
|
125
|
+
if refreshed:
|
|
126
|
+
# Bounded refresh: a second 403 is reported, never retried.
|
|
127
|
+
raise PermissionError_(
|
|
128
|
+
f"403 from {url} even after refreshing — the token is still not "
|
|
129
|
+
f"verifiable. Run `freepod login --env {self.env.name}` to "
|
|
130
|
+
f"re-authenticate from scratch."
|
|
131
|
+
)
|
|
132
|
+
refreshed = True
|
|
133
|
+
self._renew()
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if status == 404 and _json_detail(response) == "Not authenticated":
|
|
137
|
+
# Only reachable on a route the edge skips. A bug report, not a
|
|
138
|
+
# login prompt.
|
|
139
|
+
raise FreepodError(
|
|
140
|
+
f"404 'Not authenticated' from {url} — no identity reached the API. "
|
|
141
|
+
"This is an unexpected platform condition, not a credential problem; "
|
|
142
|
+
"please report it."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return response
|
|
146
|
+
|
|
147
|
+
def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
148
|
+
return self.request("GET", path, **kwargs)
|
|
149
|
+
|
|
150
|
+
def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
151
|
+
return self.request("POST", path, **kwargs)
|
|
152
|
+
|
|
153
|
+
def put(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
154
|
+
return self.request("PUT", path, **kwargs)
|
|
155
|
+
|
|
156
|
+
def get_json(self, path: str, **kwargs: Any) -> Any:
|
|
157
|
+
"""GET and decode, raising on any non-successful status."""
|
|
158
|
+
return self._decode(self.get(path, **kwargs))
|
|
159
|
+
|
|
160
|
+
def me(self) -> dict:
|
|
161
|
+
"""`GET /api/me` — the first request that actually exercises the token.
|
|
162
|
+
|
|
163
|
+
Every command that requires authentication calls this before its public
|
|
164
|
+
reads, so a bad credential is reported here rather than surfacing later
|
|
165
|
+
as an unrelated-looking failure. See design D15.
|
|
166
|
+
"""
|
|
167
|
+
body = self.get_json("/api/me")
|
|
168
|
+
if not isinstance(body, dict) or "id" not in body:
|
|
169
|
+
raise FreepodError(f"unexpected /api/me response: {body!r}")
|
|
170
|
+
return body
|
|
171
|
+
|
|
172
|
+
# -- public reads -----------------------------------------------------
|
|
173
|
+
#
|
|
174
|
+
# Everything below is on the edge's `skip_auth_routes` list and is answered
|
|
175
|
+
# anonymously. Call `me()` before any of them, or a credential problem
|
|
176
|
+
# surfaces later as an unrelated-looking failure.
|
|
177
|
+
|
|
178
|
+
def products(self) -> list:
|
|
179
|
+
body = self.get_json("/api/products")
|
|
180
|
+
if not isinstance(body, list):
|
|
181
|
+
raise FreepodError(f"unexpected /api/products response: {body!r}")
|
|
182
|
+
return body
|
|
183
|
+
|
|
184
|
+
def find_product(self, slug: str) -> Optional[dict]:
|
|
185
|
+
"""The first product carrying `slug`, or None.
|
|
186
|
+
|
|
187
|
+
By slug, never by display name: `slug` is written only by the catalog
|
|
188
|
+
reconciler and is the product's stable identity, while the name is
|
|
189
|
+
presentation and can be retitled at any time.
|
|
190
|
+
"""
|
|
191
|
+
for product in self.products():
|
|
192
|
+
if isinstance(product, dict) and product.get("slug") == slug:
|
|
193
|
+
return product
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def domains(self) -> list:
|
|
197
|
+
"""The platform's wildcard domains, most preferred first."""
|
|
198
|
+
body = self.get_json("/api/domains")
|
|
199
|
+
if not isinstance(body, list):
|
|
200
|
+
raise FreepodError(f"unexpected /api/domains response: {body!r}")
|
|
201
|
+
return [entry for entry in body if isinstance(entry, str)]
|
|
202
|
+
|
|
203
|
+
def check_hostname(self, fqdn: str) -> dict:
|
|
204
|
+
"""`GET /api/hostnames/{fqdn}` — always 200 with `{fqdn, usable, reason}`.
|
|
205
|
+
|
|
206
|
+
Advisory only: the name is claimed when the deployment is created, so a
|
|
207
|
+
usable answer here is not a reservation. Note the check runs without
|
|
208
|
+
`exclude_deployment_id`, so re-checking a name we already hold reports
|
|
209
|
+
`in_use` against ourselves — see design D14.
|
|
210
|
+
"""
|
|
211
|
+
body = self.get_json(f"/api/hostnames/{quote(fqdn, safe='')}")
|
|
212
|
+
if not isinstance(body, dict):
|
|
213
|
+
raise FreepodError(f"unexpected hostname check response: {body!r}")
|
|
214
|
+
return body
|
|
215
|
+
|
|
216
|
+
# -- internals --------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def _headers(self) -> dict:
|
|
219
|
+
headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
|
220
|
+
if self.session.access_token:
|
|
221
|
+
headers["Authorization"] = f"Bearer {self.session.access_token}"
|
|
222
|
+
return headers
|
|
223
|
+
|
|
224
|
+
def _send(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
225
|
+
"""Send one request, retrying only what is safe to repeat.
|
|
226
|
+
|
|
227
|
+
A request that could create or mutate state gets exactly one attempt:
|
|
228
|
+
a duplicate build or deployment is worse than a reported failure.
|
|
229
|
+
"""
|
|
230
|
+
attempts = MAX_ATTEMPTS if method.upper() in SAFE_METHODS else 1
|
|
231
|
+
# Load-bearing, not incidental: caller-supplied headers are *read*,
|
|
232
|
+
# never written. The copy is what lets `request()` replay this call
|
|
233
|
+
# after a refresh with the caller's headers intact and a fresh
|
|
234
|
+
# Authorization. Merging the other way — injecting auth into the
|
|
235
|
+
# caller's dict, or dropping this `dict()` so `update` writes into a
|
|
236
|
+
# hoisted base — reintroduces two bugs at once: a stale token that the
|
|
237
|
+
# bounded-refresh rule then misreports as a permission error, and a
|
|
238
|
+
# `Range` from one request leaking into the next.
|
|
239
|
+
headers = dict(self._headers())
|
|
240
|
+
headers.update(kwargs.pop("headers", None) or {})
|
|
241
|
+
timeout = kwargs.pop("timeout", self.timeout)
|
|
242
|
+
|
|
243
|
+
last_error: Optional[Exception] = None
|
|
244
|
+
for attempt in range(1, attempts + 1):
|
|
245
|
+
try:
|
|
246
|
+
response = self._client.request(
|
|
247
|
+
method, url, headers=headers, timeout=timeout, **kwargs
|
|
248
|
+
)
|
|
249
|
+
except httpx.HTTPError as exc:
|
|
250
|
+
last_error = exc
|
|
251
|
+
if attempt >= attempts:
|
|
252
|
+
raise FreepodError(f"cannot reach {url}: {exc}") from None
|
|
253
|
+
self._backoff(attempt)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
if response.status_code >= 500 and attempt < attempts:
|
|
257
|
+
self._backoff(attempt)
|
|
258
|
+
continue
|
|
259
|
+
return response
|
|
260
|
+
|
|
261
|
+
# Unreachable: the loop either returns or raises.
|
|
262
|
+
raise FreepodError(f"cannot reach {url}: {last_error}")
|
|
263
|
+
|
|
264
|
+
def _backoff(self, attempt: int) -> None:
|
|
265
|
+
delay = self.backoff_base * (2 ** (attempt - 1))
|
|
266
|
+
if delay > 0:
|
|
267
|
+
time.sleep(delay)
|
|
268
|
+
|
|
269
|
+
def _renew(self) -> None:
|
|
270
|
+
"""Recover from an edge 403 — the token is expired or unverifiable.
|
|
271
|
+
|
|
272
|
+
A refused refresh falls back to a full login rather than failing
|
|
273
|
+
outright, which is what makes an idle-expired offline session recover
|
|
274
|
+
without the user being told to do anything.
|
|
275
|
+
"""
|
|
276
|
+
if self.session.refresh():
|
|
277
|
+
self.session.credential_source = "refreshed token"
|
|
278
|
+
return
|
|
279
|
+
self.session.login()
|
|
280
|
+
self.session.credential_source = "fresh login (after failed refresh)"
|
|
281
|
+
|
|
282
|
+
def _unauthenticated_message(self, url: str) -> str:
|
|
283
|
+
if self.env.requires_group:
|
|
284
|
+
return (
|
|
285
|
+
f"401 from {url} — no credential, or your account lacks access to "
|
|
286
|
+
f"'{self.env.name}'.\n"
|
|
287
|
+
f" {self.env.api_base} requires membership of the "
|
|
288
|
+
f"'{self.env.requires_group}' Keycloak group.\n"
|
|
289
|
+
f" Re-authenticating will succeed and change nothing — check the "
|
|
290
|
+
f"group membership first."
|
|
291
|
+
)
|
|
292
|
+
return (
|
|
293
|
+
f"401 from {url} — no credential reached the API.\n"
|
|
294
|
+
f" Run `freepod login --env {self.env.name}` to authenticate."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _decode(response: httpx.Response) -> Any:
|
|
299
|
+
if not response.is_success:
|
|
300
|
+
detail = _json_detail(response)
|
|
301
|
+
body = detail if detail is not None else response.text.strip()[:300]
|
|
302
|
+
raise FreepodError(f"HTTP {response.status_code} from {response.url}: {body}")
|
|
303
|
+
try:
|
|
304
|
+
return response.json()
|
|
305
|
+
except ValueError:
|
|
306
|
+
raise FreepodError(f"unparseable response from {response.url}") from None
|