floodwall 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- floodwall-0.1.0/PKG-INFO +66 -0
- floodwall-0.1.0/README.md +52 -0
- floodwall-0.1.0/floodwall/__init__.py +379 -0
- floodwall-0.1.0/floodwall.egg-info/PKG-INFO +66 -0
- floodwall-0.1.0/floodwall.egg-info/SOURCES.txt +10 -0
- floodwall-0.1.0/floodwall.egg-info/dependency_links.txt +1 -0
- floodwall-0.1.0/floodwall.egg-info/requires.txt +3 -0
- floodwall-0.1.0/floodwall.egg-info/top_level.txt +1 -0
- floodwall-0.1.0/pyproject.toml +25 -0
- floodwall-0.1.0/setup.cfg +4 -0
- floodwall-0.1.0/tests/test_client.py +300 -0
floodwall-0.1.0/LICENSE
ADDED
|
@@ -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.
|
floodwall-0.1.0/PKG-INFO
ADDED
|
@@ -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,52 @@
|
|
|
1
|
+
# floodwall (Python)
|
|
2
|
+
|
|
3
|
+
Ask a Floodwall Cloud plane before your agent changes production, and tell it
|
|
4
|
+
how the change went. Standard library only; Python 3.9+.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install floodwall
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from floodwall import Floodwall
|
|
12
|
+
|
|
13
|
+
fw = Floodwall(api_key=os.environ["FLOODWALL_KEY"], plane="pl_...", agent="deployer")
|
|
14
|
+
|
|
15
|
+
# Runs the callable only if the plane admits the change, then reports
|
|
16
|
+
# "succeeded", or "failed" with the exception if it raises.
|
|
17
|
+
fw.guard(
|
|
18
|
+
{"action": {"kind": "scale", "resource": "web", "replicas": 5}, "priority": "normal"},
|
|
19
|
+
lambda: subprocess.run(["kubectl", "scale", "deploy/web", "--replicas=5"], check=True),
|
|
20
|
+
)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`guard` waits while the change is queued or held for a person (up to
|
|
24
|
+
`timeout_seconds`, 300 by default), and raises `NotAdmitted` if it is
|
|
25
|
+
rejected, expires or lapses. Rate limits (HTTP 429) are waited out.
|
|
26
|
+
|
|
27
|
+
- `submit`, `wait` and `report` are the steps `guard` takes.
|
|
28
|
+
- `when_unreachable="open"` applies a change unchecked when the plane cannot
|
|
29
|
+
be reached (`URLError`, timeouts, connection errors and 5xx only) and calls
|
|
30
|
+
`on_fail_open(intent, error)`. Invalid changes, signing errors and a missing
|
|
31
|
+
`cryptography` package always raise. The default, `"closed"`, refuses it.
|
|
32
|
+
The default `on_fail_open` warns with the agent, kind, resource and error,
|
|
33
|
+
never the manifest.
|
|
34
|
+
- A timeout leaves the intent **held** on the plane: `NotAdmitted` with
|
|
35
|
+
status `"held"` means it may still be released later. Do not apply the change
|
|
36
|
+
afterwards without submitting it again.
|
|
37
|
+
- `wait` never polls more than once a second, whatever the server does.
|
|
38
|
+
- If `apply` raises and reporting the failure fails too, your exception is
|
|
39
|
+
still the one raised (the report error is on its `report_error`). If `apply`
|
|
40
|
+
succeeds but the report fails, `guard` returns the result and calls
|
|
41
|
+
`on_report_error(error, key)` (default: `warnings.warn`). Failure reasons are
|
|
42
|
+
sent with whitespace collapsed, at most 1024 characters.
|
|
43
|
+
- `base_url` must be `https://`, except for `localhost`, `127.0.0.1` and
|
|
44
|
+
`[::1]`. Redirects are never followed, and the key is not in `repr()`.
|
|
45
|
+
- `Retry-After` may be seconds or an HTTP date.
|
|
46
|
+
- `signing_key` signs each intent, for planes whose keyring requires it:
|
|
47
|
+
`pip install floodwall[signing]`.
|
|
48
|
+
|
|
49
|
+
Tests: `python3 -m unittest discover -s tests -t .`
|
|
50
|
+
|
|
51
|
+
Releases: bump `version` in `pyproject.toml`, then push a tag `sdk-python-v<version>`;
|
|
52
|
+
the `publish-python` workflow publishes it to PyPI (Trusted Publishing, no token).
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
floodwall
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "floodwall"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Ask a Floodwall Cloud plane before your agent changes production."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
keywords = ["floodwall", "agents", "devops", "policy", "change-management"]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
# Only for planes that require signed intents.
|
|
18
|
+
signing = ["cryptography>=41"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
# Only the client; tests/ is not part of the package.
|
|
22
|
+
packages = ["floodwall"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Repository = "https://github.com/erphq/floodwall"
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import http.server
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import unittest
|
|
8
|
+
import urllib.error
|
|
9
|
+
import warnings
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
from floodwall import Floodwall, FloodwallError, NotAdmitted, intent_bytes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Answer(io.BytesIO):
|
|
16
|
+
def __enter__(self):
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
def __exit__(self, *exc):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def stub(handler):
|
|
24
|
+
calls = []
|
|
25
|
+
|
|
26
|
+
def opener(request, timeout):
|
|
27
|
+
body = json.loads(request.data) if request.data else None
|
|
28
|
+
call = {"method": request.get_method(), "url": request.full_url, "headers": dict(request.header_items()), "body": body}
|
|
29
|
+
calls.append(call)
|
|
30
|
+
status, answer = handler(call)
|
|
31
|
+
data = json.dumps(answer).encode()
|
|
32
|
+
if status >= 400:
|
|
33
|
+
raise urllib.error.HTTPError(request.full_url, status, "error", {"Retry-After": "0"}, io.BytesIO(data))
|
|
34
|
+
return Answer(data)
|
|
35
|
+
|
|
36
|
+
return calls, opener
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def client(opener, **extra):
|
|
40
|
+
return Floodwall(api_key="fw_test_x", plane="p1", agent="deployer", opener=opener, **extra)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
SCALE = {"action": {"kind": "scale", "resource": "web", "replicas": 3}}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ClientTest(unittest.TestCase):
|
|
47
|
+
def test_guard_applies_an_admitted_change_and_reports_it(self):
|
|
48
|
+
calls, opener = stub(lambda c: (200, {"verdict": "admit"}))
|
|
49
|
+
self.assertEqual(client(opener).guard(SCALE, lambda: "applied"), "applied")
|
|
50
|
+
self.assertEqual(calls[0]["headers"]["Authorization"], "Bearer fw_test_x")
|
|
51
|
+
self.assertEqual(list(calls[0]["body"]), ["id", "agent", "action", "priority", "blast_radius", "signature"])
|
|
52
|
+
self.assertTrue(calls[1]["url"].endswith("/outcome"))
|
|
53
|
+
self.assertEqual(calls[1]["body"], {"outcome": "succeeded", "reason": None})
|
|
54
|
+
|
|
55
|
+
def test_a_held_change_is_applied_once_released(self):
|
|
56
|
+
polls = []
|
|
57
|
+
|
|
58
|
+
def handler(c):
|
|
59
|
+
if c["method"] == "POST" and c["url"].endswith("/intents"):
|
|
60
|
+
return 200, {"verdict": "defer", "reason": "off the allowlist"}
|
|
61
|
+
if c["method"] == "GET":
|
|
62
|
+
polls.append(1)
|
|
63
|
+
return 200, {"status": "held" if len(polls) < 2 else "in_flight"}
|
|
64
|
+
return 200, {}
|
|
65
|
+
|
|
66
|
+
calls, opener = stub(handler)
|
|
67
|
+
client(opener).guard(SCALE, lambda: None)
|
|
68
|
+
self.assertEqual(len(polls), 2)
|
|
69
|
+
|
|
70
|
+
def test_a_rejected_change_is_never_applied(self):
|
|
71
|
+
calls, opener = stub(lambda c: (200, {"verdict": "reject", "reason": "no"}))
|
|
72
|
+
with self.assertRaises(NotAdmitted):
|
|
73
|
+
client(opener).guard(SCALE, lambda: self.fail("applied"))
|
|
74
|
+
self.assertEqual(len(calls), 1)
|
|
75
|
+
|
|
76
|
+
def test_a_failing_change_is_reported_failed(self):
|
|
77
|
+
calls, opener = stub(lambda c: (200, {"verdict": "admit"}))
|
|
78
|
+
|
|
79
|
+
def boom():
|
|
80
|
+
raise RuntimeError("kubectl exploded")
|
|
81
|
+
|
|
82
|
+
with self.assertRaises(RuntimeError):
|
|
83
|
+
client(opener).guard(SCALE, boom)
|
|
84
|
+
self.assertEqual(calls[-1]["body"], {"outcome": "failed", "reason": "kubectl exploded"})
|
|
85
|
+
|
|
86
|
+
def test_rate_limits_are_waited_out(self):
|
|
87
|
+
tries = []
|
|
88
|
+
|
|
89
|
+
def handler(c):
|
|
90
|
+
tries.append(1)
|
|
91
|
+
return (429, {"error": "slow"}) if len(tries) < 2 else (200, {"verdict": "admit"})
|
|
92
|
+
|
|
93
|
+
calls, opener = stub(handler)
|
|
94
|
+
self.assertEqual(client(opener).submit(SCALE)["status"], "admit")
|
|
95
|
+
|
|
96
|
+
def test_unreachable_closed_refuses_and_open_applies(self):
|
|
97
|
+
def down(request, timeout):
|
|
98
|
+
raise urllib.error.URLError("no route")
|
|
99
|
+
|
|
100
|
+
with self.assertRaises(urllib.error.URLError):
|
|
101
|
+
client(down).guard(SCALE, lambda: self.fail())
|
|
102
|
+
unchecked = []
|
|
103
|
+
out = client(down, when_unreachable="open", on_fail_open=lambda c, e: unchecked.append(c)).guard(SCALE, lambda: "ran")
|
|
104
|
+
self.assertEqual(out, "ran")
|
|
105
|
+
self.assertEqual(len(unchecked), 1)
|
|
106
|
+
calls, opener = stub(lambda c: (401, {"error": "bad key"}))
|
|
107
|
+
with self.assertRaises(FloodwallError):
|
|
108
|
+
client(opener, when_unreachable="open").guard(SCALE, lambda: self.fail())
|
|
109
|
+
|
|
110
|
+
def test_signed_bytes_match_the_crate(self):
|
|
111
|
+
import struct
|
|
112
|
+
|
|
113
|
+
def field(s):
|
|
114
|
+
return struct.pack("<Q", len(s)) + s
|
|
115
|
+
|
|
116
|
+
got = intent_bytes({"id": "7", "agent": "a", "action": {"kind": "scale", "resource": "w", "replicas": 2},
|
|
117
|
+
"priority": "urgent", "blast_radius": "region", "signature": None})
|
|
118
|
+
want = field(b"floodwall/intent/v1") + field(b"a") + struct.pack("<Q", 7) + b"\x01" + field(b"w") + struct.pack("<I", 2) + b"\x02\x02"
|
|
119
|
+
self.assertEqual(got, want)
|
|
120
|
+
|
|
121
|
+
def test_fail_open_only_for_transport_failures(self):
|
|
122
|
+
seen = []
|
|
123
|
+
asked = []
|
|
124
|
+
|
|
125
|
+
def down(request, timeout):
|
|
126
|
+
asked.append(1)
|
|
127
|
+
raise urllib.error.URLError("no route")
|
|
128
|
+
|
|
129
|
+
def open_(opener, **extra):
|
|
130
|
+
return client(opener, when_unreachable="open", on_fail_open=lambda i, e: seen.append(i), **extra)
|
|
131
|
+
|
|
132
|
+
# Validation errors raise, with no request and nothing applied.
|
|
133
|
+
with self.assertRaises(ValueError):
|
|
134
|
+
open_(down).guard({**SCALE, "priority": "nope"}, lambda: self.fail("applied"))
|
|
135
|
+
with self.assertRaises(ValueError):
|
|
136
|
+
open_(down).guard({"action": {"kind": "scale", "resource": "w", "replicas": -1}}, lambda: self.fail("applied"))
|
|
137
|
+
# A missing `cryptography` package raises, never fails open.
|
|
138
|
+
with mock.patch.dict(sys.modules, {"cryptography": None, "cryptography.hazmat.primitives.asymmetric.ed25519": None}):
|
|
139
|
+
with self.assertRaises(RuntimeError):
|
|
140
|
+
open_(down, signing_key=b"k" * 32).guard(SCALE, lambda: self.fail("applied"))
|
|
141
|
+
self.assertEqual((asked, seen), ([], []))
|
|
142
|
+
|
|
143
|
+
def raising(exc):
|
|
144
|
+
def opener(request, timeout):
|
|
145
|
+
raise exc
|
|
146
|
+
|
|
147
|
+
return opener
|
|
148
|
+
|
|
149
|
+
with self.assertRaises(KeyError):
|
|
150
|
+
open_(raising(KeyError("odd"))).guard(SCALE, lambda: self.fail("applied"))
|
|
151
|
+
for exc in (urllib.error.URLError("x"), socket.timeout("t"), ConnectionResetError("r"), TimeoutError("t")):
|
|
152
|
+
self.assertEqual(open_(raising(exc)).guard(SCALE, lambda: "ran"), "ran")
|
|
153
|
+
_, opener = stub(lambda c: (503, {"error": "down"}))
|
|
154
|
+
self.assertEqual(open_(opener).guard(SCALE, lambda: "ran"), "ran")
|
|
155
|
+
_, opener = stub(lambda c: (400, {"error": "bad"}))
|
|
156
|
+
with self.assertRaises(FloodwallError):
|
|
157
|
+
open_(opener).guard(SCALE, lambda: self.fail("applied"))
|
|
158
|
+
self.assertEqual(len(seen), 5)
|
|
159
|
+
|
|
160
|
+
def test_default_fail_open_warning_leaves_the_manifest_out(self):
|
|
161
|
+
def down(request, timeout):
|
|
162
|
+
raise urllib.error.URLError("no route")
|
|
163
|
+
|
|
164
|
+
change = {"action": {"kind": "apply", "resource": "web", "manifest": "SECRET: hunter2"}}
|
|
165
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
166
|
+
warnings.simplefilter("always", UserWarning)
|
|
167
|
+
warnings.simplefilter("ignore", ResourceWarning)
|
|
168
|
+
client(down, when_unreachable="open").guard(change, lambda: 1)
|
|
169
|
+
self.assertEqual(len(caught), 1)
|
|
170
|
+
text = str(caught[0].message)
|
|
171
|
+
self.assertIn("deployer", text)
|
|
172
|
+
self.assertIn("web", text)
|
|
173
|
+
self.assertNotIn("hunter2", text)
|
|
174
|
+
|
|
175
|
+
def test_wait_never_polls_faster_than_once_a_second(self):
|
|
176
|
+
polls = []
|
|
177
|
+
calls, opener = stub(lambda c: (200, {"status": "held" if len(polls.append(1) or polls) < 4 else "in_flight"}))
|
|
178
|
+
fw = client(opener)
|
|
179
|
+
sleeps = []
|
|
180
|
+
fw._sleep = sleeps.append
|
|
181
|
+
self.assertEqual(fw.wait({"agent": "deployer", "id": "1"})["status"], "in_flight")
|
|
182
|
+
self.assertEqual(len(sleeps), 3)
|
|
183
|
+
for pause in sleeps:
|
|
184
|
+
self.assertTrue(0.9 < pause <= 1.0, pause)
|
|
185
|
+
|
|
186
|
+
def test_a_failed_report_never_masks_the_changes_error(self):
|
|
187
|
+
calls, opener = stub(lambda c: (200, {"verdict": "admit"}) if c["url"].endswith("/intents") else (500, {"error": "down"}))
|
|
188
|
+
error = RuntimeError("kubectl exploded")
|
|
189
|
+
|
|
190
|
+
def boom():
|
|
191
|
+
raise error
|
|
192
|
+
|
|
193
|
+
with self.assertRaises(RuntimeError) as raised:
|
|
194
|
+
client(opener).guard(SCALE, boom)
|
|
195
|
+
self.assertIs(raised.exception, error)
|
|
196
|
+
self.assertEqual(error.report_error.status, 500)
|
|
197
|
+
|
|
198
|
+
def test_a_failed_report_after_success_returns_the_result(self):
|
|
199
|
+
calls, opener = stub(lambda c: (200, {"verdict": "admit"}) if c["url"].endswith("/intents") else (500, {"error": "down"}))
|
|
200
|
+
told = []
|
|
201
|
+
out = client(opener, on_report_error=lambda e, k: told.append((e, k))).guard(SCALE, lambda: "applied")
|
|
202
|
+
self.assertEqual(out, "applied")
|
|
203
|
+
self.assertEqual(told[0][0].status, 500)
|
|
204
|
+
self.assertEqual(told[0][1]["agent"], "deployer")
|
|
205
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
206
|
+
warnings.simplefilter("always", UserWarning)
|
|
207
|
+
warnings.simplefilter("ignore", ResourceWarning)
|
|
208
|
+
self.assertEqual(client(opener).guard(SCALE, lambda: "ok"), "ok")
|
|
209
|
+
self.assertEqual(len(caught), 1)
|
|
210
|
+
|
|
211
|
+
def test_failure_reasons_are_collapsed_and_capped(self):
|
|
212
|
+
calls, opener = stub(lambda c: (200, {"verdict": "admit"}))
|
|
213
|
+
with self.assertRaises(RuntimeError):
|
|
214
|
+
client(opener).guard(SCALE, lambda: (_ for _ in ()).throw(RuntimeError("a\n\n b\tc " + "x" * 3000)))
|
|
215
|
+
reason = calls[-1]["body"]["reason"]
|
|
216
|
+
self.assertTrue(reason.startswith("a b c xxx"))
|
|
217
|
+
self.assertEqual(len(reason), 1024)
|
|
218
|
+
|
|
219
|
+
def test_ids_are_not_burned_when_given(self):
|
|
220
|
+
fw = client(lambda r, t: None)
|
|
221
|
+
self.assertEqual(fw.intent({**SCALE, "id": 5})["id"], "5")
|
|
222
|
+
self.assertEqual(fw._last_id, 0)
|
|
223
|
+
|
|
224
|
+
def test_retry_after_is_waited_out_for_a_minute_at_most(self):
|
|
225
|
+
for header, expected in (("3600", 60.0), ("Wed, 21 Oct 2099 07:28:00 GMT", 60.0), ("2", 2.0)):
|
|
226
|
+
tries = []
|
|
227
|
+
slept = []
|
|
228
|
+
|
|
229
|
+
def opener(request, timeout, header=header):
|
|
230
|
+
tries.append(1)
|
|
231
|
+
if len(tries) < 2:
|
|
232
|
+
raise urllib.error.HTTPError(request.full_url, 429, "slow", {"Retry-After": header}, io.BytesIO(b"{}"))
|
|
233
|
+
return Answer(b'{"verdict":"admit"}')
|
|
234
|
+
|
|
235
|
+
fw = client(opener)
|
|
236
|
+
fw._sleep = slept.append
|
|
237
|
+
fw.submit(SCALE)
|
|
238
|
+
self.assertEqual(slept, [expected], header)
|
|
239
|
+
|
|
240
|
+
def test_retry_after_may_be_a_date_or_garbage(self):
|
|
241
|
+
for header in ("Wed, 21 Oct 2099 07:28:00 GMT", "soon", "", "nan", "-5"):
|
|
242
|
+
tries = []
|
|
243
|
+
|
|
244
|
+
def opener(request, timeout, header=header):
|
|
245
|
+
tries.append(1)
|
|
246
|
+
if len(tries) < 2:
|
|
247
|
+
raise urllib.error.HTTPError(request.full_url, 429, "slow", {"Retry-After": header}, io.BytesIO(b"{}"))
|
|
248
|
+
return Answer(b'{"verdict":"admit"}')
|
|
249
|
+
|
|
250
|
+
fw = client(opener)
|
|
251
|
+
fw._sleep = lambda s: None
|
|
252
|
+
self.assertEqual(fw.submit(SCALE)["status"], "admit", header)
|
|
253
|
+
|
|
254
|
+
def test_base_url_must_be_https_unless_local_and_the_key_stays_out_of_repr(self):
|
|
255
|
+
for url in ("http://api.example.com", "ftp://x", "api.example.com"):
|
|
256
|
+
with self.assertRaises(ValueError):
|
|
257
|
+
client(None, base_url=url)
|
|
258
|
+
for url in ("https://api.example.com/", "http://localhost:8787", "http://127.0.0.1:1", "http://[::1]:3"):
|
|
259
|
+
client(None, base_url=url)
|
|
260
|
+
self.assertNotIn("fw_test_x", repr(client(None)))
|
|
261
|
+
|
|
262
|
+
def test_redirects_are_refused_and_the_key_is_not_forwarded(self):
|
|
263
|
+
hits = []
|
|
264
|
+
|
|
265
|
+
class Target(http.server.BaseHTTPRequestHandler):
|
|
266
|
+
def do_POST(self):
|
|
267
|
+
hits.append(dict(self.headers))
|
|
268
|
+
self.send_response(200)
|
|
269
|
+
self.end_headers()
|
|
270
|
+
self.wfile.write(b"{}")
|
|
271
|
+
|
|
272
|
+
log_message = lambda *a: None # noqa: E731
|
|
273
|
+
|
|
274
|
+
target = http.server.HTTPServer(("127.0.0.1", 0), Target)
|
|
275
|
+
|
|
276
|
+
class Redirect(http.server.BaseHTTPRequestHandler):
|
|
277
|
+
def do_POST(self):
|
|
278
|
+
self.send_response(307)
|
|
279
|
+
self.send_header("Location", f"http://127.0.0.1:{target.server_port}/x")
|
|
280
|
+
self.end_headers()
|
|
281
|
+
|
|
282
|
+
log_message = lambda *a: None # noqa: E731
|
|
283
|
+
|
|
284
|
+
front = http.server.HTTPServer(("127.0.0.1", 0), Redirect)
|
|
285
|
+
for server in (target, front):
|
|
286
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
287
|
+
try:
|
|
288
|
+
fw = Floodwall(api_key="fw_test_x", plane="p1", agent="deployer", base_url=f"http://127.0.0.1:{front.server_port}")
|
|
289
|
+
with self.assertRaises(FloodwallError) as raised:
|
|
290
|
+
fw.submit(SCALE)
|
|
291
|
+
self.assertEqual(raised.exception.status, 307)
|
|
292
|
+
self.assertEqual(hits, [])
|
|
293
|
+
finally:
|
|
294
|
+
for server in (target, front):
|
|
295
|
+
server.shutdown()
|
|
296
|
+
server.server_close()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
if __name__ == "__main__":
|
|
300
|
+
unittest.main()
|