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 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
@@ -0,0 +1,10 @@
1
+ """Support `python -m freepod` alongside the installed `freepod` script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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