floodwall 0.1.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.
floodwall/__init__.py ADDED
@@ -0,0 +1,379 @@
1
+ """Ask a Floodwall Cloud plane before an agent changes production.
2
+
3
+ Standard library only. Signing intents (for planes with a keyring) needs the
4
+ optional ``cryptography`` package: ``pip install floodwall[signing]``.
5
+
6
+ fw = Floodwall(api_key="fw_live_...", plane="pl_...", agent="deployer")
7
+ fw.guard({"action": {"kind": "scale", "resource": "web", "replicas": 5}},
8
+ lambda: scale_web(5))
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import datetime
15
+ import email.utils
16
+ import json
17
+ import math
18
+ import socket
19
+ import struct
20
+ import threading
21
+ import time
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ import warnings
26
+ from typing import Any, Callable, Dict, Optional, TypeVar
27
+
28
+ __all__ = ["Floodwall", "FloodwallError", "NotAdmitted", "intent_bytes", "intent_digest"]
29
+
30
+ DEFAULT_BASE_URL = "https://api.floodwall.ai"
31
+ PRIORITIES = ("bulk", "normal", "urgent", "pager")
32
+ BLAST_RADII = ("cell", "service", "region", "global")
33
+ WAITING = {"queued", "held"}
34
+ #: Polls of a waiting intent are never closer together than this, whatever the server does.
35
+ MIN_POLL_SECONDS = 1.0
36
+ MAX_REASON = 1024
37
+ LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
38
+
39
+ T = TypeVar("T")
40
+
41
+
42
+ class FloodwallError(Exception):
43
+ """The plane answered with an error the caller should not retry blindly."""
44
+
45
+ def __init__(self, status: int, message: str, body: Any = None):
46
+ super().__init__(message)
47
+ self.status = status
48
+ self.body = body
49
+
50
+
51
+ class NotAdmitted(Exception):
52
+ """The change was rejected, expired or lapsed, or still waited when time ran out.
53
+
54
+ When ``decision["status"]`` is ``"held"`` (or ``"queued"``) after a timeout,
55
+ the intent is still held on the plane and may be released later. The agent
56
+ must not apply the change afterwards without submitting it again.
57
+ """
58
+
59
+ def __init__(self, decision: Dict[str, Any]):
60
+ key = decision["key"]
61
+ why = f": {decision['reason']}" if decision.get("reason") else ""
62
+ super().__init__(f"floodwall did not admit {key['agent']}/{key['id']}: {decision['status']}{why}")
63
+ self.decision = decision
64
+
65
+
66
+ # A 429's Retry-After is waited out up to this, however long it asks for.
67
+ _MAX_RETRY_AFTER_SECONDS = 60.0
68
+
69
+
70
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
71
+ """A redirect is an error: the API key must not follow a response to another place."""
72
+
73
+ def redirect_request(self, *args, **kwargs):
74
+ return None
75
+
76
+
77
+ class Floodwall:
78
+ """A connection to one plane, as one agent.
79
+
80
+ ``when_unreachable`` is ``"closed"`` (the default: a change the plane
81
+ cannot be asked about is refused) or ``"open"`` (it is applied unchecked
82
+ and ``on_fail_open`` is called). ``signing_key`` is the agent's Ed25519
83
+ private key: 32 seed bytes, or a ``cryptography`` Ed25519PrivateKey.
84
+
85
+ ``base_url`` must be ``https://`` unless the host is localhost, 127.0.0.1
86
+ or ``[::1]``. ``on_report_error(error, key)`` is told when reporting a
87
+ successful change failed (the result is still returned); the default warns
88
+ without the manifest. Redirects are never followed.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ api_key: str,
94
+ plane: str,
95
+ agent: str,
96
+ base_url: str = DEFAULT_BASE_URL,
97
+ when_unreachable: str = "closed",
98
+ on_fail_open: Optional[Callable[[Any, BaseException], None]] = None,
99
+ signing_key: Any = None,
100
+ on_report_error: Optional[Callable[[BaseException, Dict[str, str]], None]] = None,
101
+ opener: Optional[Callable[[urllib.request.Request, float], Any]] = None,
102
+ timeout: float = 30.0,
103
+ ):
104
+ for name, value in (("api_key", api_key), ("plane", plane), ("agent", agent)):
105
+ if not isinstance(value, str) or not value:
106
+ raise TypeError(f"{name} is required")
107
+ if when_unreachable not in ("closed", "open"):
108
+ raise ValueError('when_unreachable must be "closed" or "open"')
109
+ _check_base_url(base_url)
110
+ self.api_key = api_key
111
+ self.plane = plane
112
+ self.agent = agent
113
+ self.base_url = base_url.rstrip("/")
114
+ self.when_unreachable = when_unreachable
115
+ self.on_fail_open = on_fail_open or _warn_fail_open
116
+ self.on_report_error = on_report_error or _warn_report_error
117
+ self.signing_key = signing_key
118
+ self.timeout = timeout
119
+ if opener is None:
120
+ built = urllib.request.build_opener(_NoRedirect)
121
+ opener = lambda request, timeout: built.open(request, timeout=timeout) # noqa: E731
122
+ self._open = opener
123
+ self._sleep = time.sleep
124
+ self._last_id = 0
125
+ self._lock = threading.Lock()
126
+
127
+ def __repr__(self) -> str:
128
+ return f"Floodwall(plane={self.plane!r}, agent={self.agent!r}, base_url={self.base_url!r})"
129
+
130
+ def next_id(self) -> int:
131
+ """An id above every id this client has used: the time in microseconds, or one more."""
132
+ with self._lock:
133
+ self._last_id = max(self._last_id + 1, int(time.time() * 1_000_000))
134
+ return self._last_id
135
+
136
+ def intent(self, change: Dict[str, Any]) -> Dict[str, Any]:
137
+ """The change in the plane's form (keys in the order the plane reads them)."""
138
+ action = change.get("action") or {}
139
+ priority = change.get("priority", "normal")
140
+ blast = change.get("blast_radius", change.get("blastRadius", "service"))
141
+ if priority not in PRIORITIES:
142
+ raise ValueError(f"priority must be one of {', '.join(PRIORITIES)}")
143
+ if blast not in BLAST_RADII:
144
+ raise ValueError(f"blast_radius must be one of {', '.join(BLAST_RADII)}")
145
+ kind = action.get("kind")
146
+ if kind == "apply":
147
+ normalized = {"kind": "apply", "resource": str(action["resource"]), "manifest": str(action.get("manifest", ""))}
148
+ elif kind == "scale":
149
+ replicas = action.get("replicas")
150
+ if not isinstance(replicas, int) or isinstance(replicas, bool) or not 0 <= replicas <= 0xFFFFFFFF:
151
+ raise ValueError("scale replicas must be a whole number from 0 to 4294967295")
152
+ normalized = {"kind": "scale", "resource": str(action["resource"]), "replicas": replicas}
153
+ elif kind == "destroy":
154
+ normalized = {"kind": "destroy", "resource": str(action["resource"])}
155
+ else:
156
+ raise ValueError('action.kind must be "apply", "scale" or "destroy"')
157
+ return {
158
+ "id": str(change["id"] if "id" in change else self.next_id()),
159
+ "agent": self.agent,
160
+ "action": normalized,
161
+ "priority": priority,
162
+ "blast_radius": blast,
163
+ "signature": None,
164
+ }
165
+
166
+ def prepare(self, change: Dict[str, Any]) -> Dict[str, Any]:
167
+ """Validate (and sign, when there is a key) a change: errors here are the caller's, never "unreachable"."""
168
+ intent = change if "agent" in change and "blast_radius" in change else self.intent(change)
169
+ if self.signing_key is not None:
170
+ intent["signature"] = sign(intent, self.signing_key)
171
+ return intent
172
+
173
+ def submit(self, change: Dict[str, Any]) -> Dict[str, Any]:
174
+ """Offer a change; returns the plane's first answer (admit, held, rejected or queued)."""
175
+ return self._post(self.prepare(change))
176
+
177
+ def _post(self, intent: Dict[str, Any]) -> Dict[str, Any]:
178
+ answer = self._request("POST", f"/v1/planes/{_q(self.plane)}/intents", intent)
179
+ return _decision(answer, intent["agent"], intent["id"])
180
+
181
+ def wait(self, key: Dict[str, str], timeout_seconds: float = 300) -> Dict[str, Any]:
182
+ """Wait until the intent leaves the queue and the hold queue, or time runs out.
183
+
184
+ A timeout leaves the intent held. Polls are at least a second apart even
185
+ if the server answers at once.
186
+ """
187
+ until = time.monotonic() + timeout_seconds
188
+ while True:
189
+ started = time.monotonic()
190
+ left = until - started
191
+ wait = max(0, min(20, int(left + 0.999)))
192
+ answer = self._request(
193
+ "GET", f"/v1/planes/{_q(self.plane)}/intents/{_q(key['agent'])}/{_q(key['id'])}?wait={wait}"
194
+ )
195
+ status = _decision(answer, key["agent"], key["id"])
196
+ if status["status"] not in WAITING or left <= 0:
197
+ return status
198
+ pause = min(MIN_POLL_SECONDS - (time.monotonic() - started), until - time.monotonic())
199
+ if pause > 0:
200
+ self._sleep(pause)
201
+
202
+ def report(self, key: Dict[str, str], outcome: str, reason: Optional[str] = None) -> None:
203
+ """Report how an admitted change went, which frees its resource."""
204
+ if outcome not in ("succeeded", "failed"):
205
+ raise ValueError('outcome must be "succeeded" or "failed"')
206
+ self._request(
207
+ "POST",
208
+ f"/v1/planes/{_q(self.plane)}/intents/{_q(key['agent'])}/{_q(key['id'])}/outcome",
209
+ {"outcome": outcome, "reason": None if reason is None else " ".join(str(reason).split())[:MAX_REASON]},
210
+ )
211
+
212
+ def guard(self, change: Dict[str, Any], apply: Callable[[], T], timeout_seconds: float = 300) -> T:
213
+ """Run ``apply`` only if the plane admits the change, and report how it went.
214
+
215
+ Raises ``NotAdmitted`` when it is not admitted (a ``"held"`` intent after a
216
+ timeout may still be released later: do not apply the change then without
217
+ submitting it again). If ``apply`` raises and the failure report fails too,
218
+ the apply error is still the one raised, with the report error on its
219
+ ``report_error`` attribute. If ``apply`` succeeds but the report fails, the
220
+ result is returned and ``on_report_error`` is told. Only transport failures
221
+ and 5xx count as unreachable; invalid changes and signing errors always raise.
222
+ """
223
+ intent = self.prepare(change)
224
+ try:
225
+ first = self._post(intent)
226
+ except Exception as error: # noqa: BLE001 - classified below
227
+ if self.when_unreachable == "open" and _unreachable(error):
228
+ self.on_fail_open(intent, error)
229
+ return apply()
230
+ raise
231
+ settled = self.wait(first["key"], timeout_seconds) if first["status"] in WAITING else first
232
+ if settled["status"] not in ("admit", "in_flight"):
233
+ raise NotAdmitted(settled)
234
+ try:
235
+ result = apply()
236
+ except BaseException as error:
237
+ try:
238
+ self.report(first["key"], "failed", str(error))
239
+ except Exception as report_error: # noqa: BLE001 - the change's error wins
240
+ try:
241
+ error.report_error = report_error
242
+ except Exception: # noqa: BLE001
243
+ pass
244
+ raise
245
+ try:
246
+ self.report(first["key"], "succeeded")
247
+ except Exception as report_error: # noqa: BLE001
248
+ self.on_report_error(report_error, first["key"])
249
+ return result
250
+
251
+ def _request(self, method: str, path: str, body: Any = None) -> Dict[str, Any]:
252
+ data = None if body is None else json.dumps(body, separators=(",", ":")).encode()
253
+ headers = {"User-Agent": "floodwall-python/0.1"}
254
+ if data is not None:
255
+ headers["Content-Type"] = "application/json"
256
+ for attempt in range(6):
257
+ request = urllib.request.Request(self.base_url + path, data=data, method=method, headers=headers)
258
+ request.add_unredirected_header("Authorization", f"Bearer {self.api_key}")
259
+ try:
260
+ with self._open(request, self.timeout) as response:
261
+ text = response.read().decode()
262
+ return json.loads(text) if text else {}
263
+ except urllib.error.HTTPError as error:
264
+ text = error.read().decode(errors="replace")
265
+ try:
266
+ parsed = json.loads(text) if text else {}
267
+ except ValueError:
268
+ parsed = {"error": text}
269
+ if error.code == 429 and attempt < 5:
270
+ self._sleep(_retry_after(error.headers.get("Retry-After")))
271
+ continue
272
+ raise FloodwallError(error.code, parsed.get("error") or f"floodwall answered {error.code}", parsed) from None
273
+ raise AssertionError("unreachable")
274
+
275
+
276
+ def _q(value: Any) -> str:
277
+ return urllib.parse.quote(str(value), safe="")
278
+
279
+
280
+ def _unreachable(error: BaseException) -> bool:
281
+ """Only a transport failure (no connection, a timeout) or a 5xx means the plane is unreachable."""
282
+ if isinstance(error, FloodwallError):
283
+ return error.status >= 500
284
+ if isinstance(error, urllib.error.HTTPError):
285
+ return False
286
+ return isinstance(error, (urllib.error.URLError, socket.timeout, ConnectionError, TimeoutError))
287
+
288
+
289
+ def _retry_after(value: Any) -> float:
290
+ """Seconds to wait from a Retry-After header, given as seconds or an HTTP date; 1 s if unusable, 60 s at most."""
291
+ try:
292
+ text = str(value).strip()
293
+ try:
294
+ seconds = float(text)
295
+ except ValueError:
296
+ when = email.utils.parsedate_to_datetime(text)
297
+ if when.tzinfo is None:
298
+ when = when.replace(tzinfo=datetime.timezone.utc)
299
+ seconds = (when - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
300
+ return min(max(0.0, seconds), _MAX_RETRY_AFTER_SECONDS) if math.isfinite(seconds) else 1.0
301
+ except Exception: # noqa: BLE001 - a bad header must not crash the client
302
+ return 1.0
303
+
304
+
305
+ def _check_base_url(base_url: str) -> None:
306
+ parts = urllib.parse.urlsplit(base_url)
307
+ if parts.scheme == "https" and parts.hostname:
308
+ return
309
+ if parts.scheme == "http" and parts.hostname in LOCAL_HOSTS:
310
+ return
311
+ raise ValueError("base_url must be https:// unless the host is localhost, 127.0.0.1 or [::1]")
312
+
313
+
314
+ def _warn_fail_open(intent: Any, error: BaseException) -> None:
315
+ action = intent.get("action") or {}
316
+ warnings.warn(
317
+ f"floodwall unreachable; applied unchecked: agent={intent.get('agent')} kind={action.get('kind')} "
318
+ f"resource={action.get('resource')}: {error}",
319
+ stacklevel=2,
320
+ )
321
+
322
+
323
+ def _warn_report_error(error: BaseException, key: Dict[str, str]) -> None:
324
+ warnings.warn(f"floodwall could not report the outcome of {key['agent']}/{key['id']}: {error}", stacklevel=2)
325
+
326
+
327
+ def _decision(answer: Dict[str, Any], agent: str, intent_id: str) -> Dict[str, Any]:
328
+ key = answer.get("key") or {"agent": agent, "id": intent_id}
329
+ status = answer.get("status") or answer.get("verdict") or "queued"
330
+ status = {"defer": "held", "reject": "rejected"}.get(status, status)
331
+ return {
332
+ "key": {"agent": key["agent"], "id": str(key["id"])},
333
+ "status": status,
334
+ "verdict": answer.get("verdict"),
335
+ "reason": answer.get("reason"),
336
+ "policies": answer.get("policies", []),
337
+ }
338
+
339
+
340
+ def _field(parts: list, data: bytes) -> None:
341
+ parts.append(struct.pack("<Q", len(data)))
342
+ parts.append(data)
343
+
344
+
345
+ def intent_bytes(intent: Dict[str, Any]) -> bytes:
346
+ """The bytes whose SHA-256 an agent signs (the crate's Intent::digest)."""
347
+ parts: list = []
348
+ _field(parts, b"floodwall/intent/v1")
349
+ _field(parts, intent["agent"].encode())
350
+ parts.append(struct.pack("<Q", int(intent["id"])))
351
+ action = intent["action"]
352
+ if action["kind"] == "apply":
353
+ parts.append(b"\x00")
354
+ _field(parts, action["resource"].encode())
355
+ _field(parts, action["manifest"].encode())
356
+ elif action["kind"] == "scale":
357
+ parts.append(b"\x01")
358
+ _field(parts, action["resource"].encode())
359
+ parts.append(struct.pack("<I", action["replicas"]))
360
+ else:
361
+ parts.append(b"\x02")
362
+ _field(parts, action["resource"].encode())
363
+ parts.append(bytes([PRIORITIES.index(intent["priority"]), BLAST_RADII.index(intent["blast_radius"])]))
364
+ return b"".join(parts)
365
+
366
+
367
+ def intent_digest(intent: Dict[str, Any]) -> bytes:
368
+ return hashlib.sha256(intent_bytes(intent)).digest()
369
+
370
+
371
+ def sign(intent: Dict[str, Any], key: Any) -> str:
372
+ """The intent's Ed25519 signature as hex. Needs the ``cryptography`` package."""
373
+ try:
374
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
375
+ except ImportError as error: # pragma: no cover - depends on the environment
376
+ raise RuntimeError("signing intents needs `pip install floodwall[signing]`") from error
377
+ if isinstance(key, (bytes, bytearray)):
378
+ key = Ed25519PrivateKey.from_private_bytes(bytes(key))
379
+ return key.sign(intent_digest(intent)).hex()
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: floodwall
3
+ Version: 0.1.0
4
+ Summary: Ask a Floodwall Cloud plane before your agent changes production.
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/erphq/floodwall
7
+ Keywords: floodwall,agents,devops,policy,change-management
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: signing
12
+ Requires-Dist: cryptography>=41; extra == "signing"
13
+ Dynamic: license-file
14
+
15
+ # floodwall (Python)
16
+
17
+ Ask a Floodwall Cloud plane before your agent changes production, and tell it
18
+ how the change went. Standard library only; Python 3.9+.
19
+
20
+ ```sh
21
+ pip install floodwall
22
+ ```
23
+
24
+ ```python
25
+ from floodwall import Floodwall
26
+
27
+ fw = Floodwall(api_key=os.environ["FLOODWALL_KEY"], plane="pl_...", agent="deployer")
28
+
29
+ # Runs the callable only if the plane admits the change, then reports
30
+ # "succeeded", or "failed" with the exception if it raises.
31
+ fw.guard(
32
+ {"action": {"kind": "scale", "resource": "web", "replicas": 5}, "priority": "normal"},
33
+ lambda: subprocess.run(["kubectl", "scale", "deploy/web", "--replicas=5"], check=True),
34
+ )
35
+ ```
36
+
37
+ `guard` waits while the change is queued or held for a person (up to
38
+ `timeout_seconds`, 300 by default), and raises `NotAdmitted` if it is
39
+ rejected, expires or lapses. Rate limits (HTTP 429) are waited out.
40
+
41
+ - `submit`, `wait` and `report` are the steps `guard` takes.
42
+ - `when_unreachable="open"` applies a change unchecked when the plane cannot
43
+ be reached (`URLError`, timeouts, connection errors and 5xx only) and calls
44
+ `on_fail_open(intent, error)`. Invalid changes, signing errors and a missing
45
+ `cryptography` package always raise. The default, `"closed"`, refuses it.
46
+ The default `on_fail_open` warns with the agent, kind, resource and error,
47
+ never the manifest.
48
+ - A timeout leaves the intent **held** on the plane: `NotAdmitted` with
49
+ status `"held"` means it may still be released later. Do not apply the change
50
+ afterwards without submitting it again.
51
+ - `wait` never polls more than once a second, whatever the server does.
52
+ - If `apply` raises and reporting the failure fails too, your exception is
53
+ still the one raised (the report error is on its `report_error`). If `apply`
54
+ succeeds but the report fails, `guard` returns the result and calls
55
+ `on_report_error(error, key)` (default: `warnings.warn`). Failure reasons are
56
+ sent with whitespace collapsed, at most 1024 characters.
57
+ - `base_url` must be `https://`, except for `localhost`, `127.0.0.1` and
58
+ `[::1]`. Redirects are never followed, and the key is not in `repr()`.
59
+ - `Retry-After` may be seconds or an HTTP date.
60
+ - `signing_key` signs each intent, for planes whose keyring requires it:
61
+ `pip install floodwall[signing]`.
62
+
63
+ Tests: `python3 -m unittest discover -s tests -t .`
64
+
65
+ Releases: bump `version` in `pyproject.toml`, then push a tag `sdk-python-v<version>`;
66
+ the `publish-python` workflow publishes it to PyPI (Trusted Publishing, no token).
@@ -0,0 +1,6 @@
1
+ floodwall/__init__.py,sha256=jKlw_U5L0tjZPokVOiIZK7aoaw6cimlV4WWPUomRxMQ,16615
2
+ floodwall-0.1.0.dist-info/licenses/LICENSE,sha256=-GXQIgJi46JCaz6F9JQAGPwKgCHmLqjYWaLkdySfPrk,1068
3
+ floodwall-0.1.0.dist-info/METADATA,sha256=bFlmx-X_xDQE_N5qMgRUxeqHz-Q17rMJ5DgmXBfj-YQ,2940
4
+ floodwall-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ floodwall-0.1.0.dist-info/top_level.txt,sha256=Qd7Axq3VaBbHCyrQp2wqgNtFbYGG8Dm3mBhXufBavJo,10
6
+ floodwall-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 protosphinx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ floodwall