microburst 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.
microburst/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """microburst — AWS failure injection proxy."""
2
+
3
+ __version__ = "0.1.0"
microburst/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from microburst.cli import main
4
+
5
+ sys.exit(main())
microburst/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """microburst CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+
9
+ from aiohttp import web
10
+
11
+ from microburst.proxy import Microburst, load_config, make_app
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ prog="microburst",
17
+ description="AWS failure injection proxy — inject realistic AWS "
18
+ "errors, latency, and connection faults between your app and any "
19
+ "AWS endpoint (MiniStack, moto, LocalStack, or real AWS).",
20
+ )
21
+ parser.add_argument(
22
+ "--upstream", "-u", default=None,
23
+ help="upstream AWS endpoint (default: config file or http://localhost:4566)",
24
+ )
25
+ parser.add_argument(
26
+ "--port", "-p", type=int, default=None,
27
+ help="listen port (default: 9999)",
28
+ )
29
+ parser.add_argument(
30
+ "--host", default=None, help="listen host (default: 127.0.0.1)",
31
+ )
32
+ parser.add_argument(
33
+ "--config", "-c", default=None,
34
+ help="YAML config file (upstream, port, rules)",
35
+ )
36
+ parser.add_argument(
37
+ "--resign", action="store_true", default=None,
38
+ help="re-sign requests with AWS_* env credentials (auto for "
39
+ "amazonaws.com upstreams)",
40
+ )
41
+ parser.add_argument(
42
+ "--no-resign", action="store_true",
43
+ help="never re-sign, even for amazonaws.com upstreams",
44
+ )
45
+ parser.add_argument(
46
+ "--verbose", "-v", action="store_true", help="debug logging",
47
+ )
48
+ return parser
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ args = build_parser().parse_args(argv)
53
+
54
+ logging.basicConfig(
55
+ level=logging.DEBUG if args.verbose else logging.INFO,
56
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
57
+ )
58
+
59
+ config = {}
60
+ if args.config:
61
+ config = load_config(args.config)
62
+
63
+ upstream = args.upstream or config.get("upstream") or "http://localhost:4566"
64
+ port = args.port or config.get("port") or 9999
65
+ host = args.host or config.get("host") or "127.0.0.1"
66
+ rules = config.get("rules") or []
67
+
68
+ if args.no_resign:
69
+ resign = False
70
+ elif args.resign:
71
+ resign = True
72
+ else:
73
+ resign = None # auto
74
+
75
+ microburst = Microburst(upstream, rules=rules, resign=resign)
76
+ app = make_app(microburst)
77
+
78
+ print(f"microburst listening on http://{host}:{port} → {upstream}", flush=True)
79
+ print(f"control API: http://{host}:{port}/_microburst/health", flush=True)
80
+ print(f"point your app: AWS_ENDPOINT_URL=http://{host}:{port}", flush=True)
81
+
82
+ web.run_app(app, host=host, port=port, print=None)
83
+ return 0
84
+
85
+
86
+ if __name__ == "__main__":
87
+ sys.exit(main())
microburst/detect.py ADDED
@@ -0,0 +1,208 @@
1
+ """Request detection: resolve (service, operation, region, resource) from an
2
+ incoming AWS API request.
3
+
4
+ Mirrors how AWS itself — and MiniStack's router — identify a request:
5
+ SigV4 credential scope names the service and region; ``X-Amz-Target`` or the
6
+ query-protocol ``Action`` parameter names the operation; REST services are
7
+ matched on method + requestUri patterns from the botocore service model.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from urllib.parse import parse_qsl
16
+
17
+ from microburst.models import get_protocol, get_service_model, service_for_scope
18
+
19
+ _CRED_RE = re.compile(
20
+ r"Credential=(?P<key>[^/,]+)/(?P<date>\d{8})/(?P<region>[^/]+)/"
21
+ r"(?P<service>[^/]+)/aws4_request"
22
+ )
23
+
24
+ # Operations that carry large payloads in either direction. For these the body
25
+ # is streamed through untouched — detection never needs them.
26
+ _STREAMING_OPS = {
27
+ ("s3", "PutObject"),
28
+ ("s3", "GetObject"),
29
+ ("s3", "UploadPart"),
30
+ ("lambda", "Invoke"), # response payloads can be large; body still small
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class RequestInfo:
36
+ service: str | None
37
+ operation: str | None
38
+ region: str | None
39
+ resource: str | None
40
+ access_key: str | None = None
41
+
42
+
43
+ def _parse_credential_scope(headers) -> tuple[str | None, str | None, str | None]:
44
+ auth = headers.get("Authorization", "")
45
+ match = _CRED_RE.search(auth)
46
+ if not match:
47
+ return None, None, None
48
+ return (
49
+ service_for_scope(match.group("service")),
50
+ match.group("region"),
51
+ match.group("key"),
52
+ )
53
+
54
+
55
+ _REST_OP_CACHE: dict[str, list[dict]] = {}
56
+
57
+
58
+ def _uri_to_regex(uri: str) -> re.Pattern:
59
+ """Convert a Smithy requestUri (``/x/{Label}/{Greedy+}``) to a regex."""
60
+ out = ""
61
+ i = 0
62
+ for match in re.finditer(r"\{[A-Za-z0-9_]+\+?\}", uri):
63
+ out += re.escape(uri[i:match.start()])
64
+ out += ".*" if match.group().endswith("+}") else "[^/]+"
65
+ i = match.end()
66
+ out += re.escape(uri[i:])
67
+ return re.compile(f"^{out}$")
68
+
69
+
70
+ def _rest_operations(service: str) -> list[dict]:
71
+ """Compile candidate operations for a REST-protocol service.
72
+
73
+ Several operations share the same method+path (S3 ``PutObject`` vs
74
+ ``CopyObject``); disambiguation happens at match time via query-string
75
+ markers and headers declared in the service model.
76
+ """
77
+ if service in _REST_OP_CACHE:
78
+ return _REST_OP_CACHE[service]
79
+ entries: list[dict] = []
80
+ model = get_service_model(service)
81
+ if model is not None:
82
+ for op_name in model.operation_names:
83
+ op = model.operation_model(op_name)
84
+ http = op.http
85
+ uri = http.get("requestUri", "/")
86
+ path_uri, _, literal_query = uri.partition("?")
87
+ query_markers = {
88
+ part.split("=", 1)[0] for part in literal_query.split("&") if part
89
+ }
90
+ required_headers = set()
91
+ input_shape = getattr(op, "input_shape", None)
92
+ if input_shape is not None:
93
+ for name, member in input_shape.members.items():
94
+ if (
95
+ member.serialization.get("location") == "header"
96
+ and name in input_shape.required_members
97
+ ):
98
+ required_headers.add(
99
+ member.serialization.get("locationName", name).lower()
100
+ )
101
+ entries.append(
102
+ {
103
+ "method": http.get("method", "GET"),
104
+ "regex": _uri_to_regex(path_uri),
105
+ "op": op_name,
106
+ "query_markers": query_markers,
107
+ "required_headers": required_headers,
108
+ }
109
+ )
110
+ _REST_OP_CACHE[service] = entries
111
+ return entries
112
+
113
+
114
+ def _match_rest_operation(service: str, method: str, path: str, query,
115
+ headers) -> str | None:
116
+ candidates = []
117
+ for entry in _rest_operations(service):
118
+ if entry["method"] != method or not entry["regex"].match(path):
119
+ continue
120
+ score = 0
121
+ if entry["query_markers"]:
122
+ if entry["query_markers"] <= set(query):
123
+ score += 2 * len(entry["query_markers"])
124
+ else:
125
+ score -= 10
126
+ lower_headers = {k.lower() for k in headers}
127
+ for h in entry["required_headers"]:
128
+ score += 2 if h in lower_headers else -10
129
+ candidates.append((score, entry["op"]))
130
+ if not candidates:
131
+ return None
132
+ candidates.sort(key=lambda item: item[0], reverse=True)
133
+ return candidates[0][1]
134
+
135
+
136
+ def _operation_for(service: str | None, headers, method: str, path: str,
137
+ query: dict, body: bytes | None) -> str | None:
138
+ if service is None:
139
+ return None
140
+
141
+ target = headers.get("X-Amz-Target", "")
142
+ if "." in target:
143
+ return target.rsplit(".", 1)[-1]
144
+
145
+ # Query-protocol style `Action` — some services migrated models to `json`
146
+ # but clients may still speak query; check both query string and body.
147
+ action = query.get("Action")
148
+ if action:
149
+ return action
150
+ if body and b"Action=" in body:
151
+ params = dict(parse_qsl(body.decode("utf-8", "replace")))
152
+ action = params.get("Action")
153
+ if action:
154
+ return action
155
+
156
+ protocol = get_protocol(service)
157
+ if protocol in ("rest-xml", "rest-json"):
158
+ return _match_rest_operation(service, method, path, query, headers)
159
+
160
+ return None
161
+
162
+
163
+ def _resource_hint(service: str | None, operation: str | None, path: str,
164
+ body: bytes | None) -> str | None:
165
+ if service is None:
166
+ return None
167
+ if service == "s3":
168
+ segments = [s for s in path.split("/") if s]
169
+ return segments[0] if segments else None
170
+ if body:
171
+ if service == "sqs":
172
+ segments = [s for s in path.split("/") if s]
173
+ return segments[-1] if segments else None
174
+ try:
175
+ payload = json.loads(body)
176
+ except (ValueError, UnicodeDecodeError):
177
+ return None
178
+ if service == "dynamodb":
179
+ return payload.get("TableName")
180
+ for key in ("TopicArn", "TargetArn", "QueueUrl", "FunctionName",
181
+ "Name", "StackName", "Bucket"):
182
+ if isinstance(payload.get(key), str):
183
+ return payload[key].rsplit(":", 1)[-1].rsplit("/", 1)[-1]
184
+ return None
185
+
186
+
187
+ def should_buffer(service: str | None, operation: str | None,
188
+ content_length: int | None) -> bool:
189
+ """Whether the body is needed for detection/rule matching."""
190
+ if (service, operation) in _STREAMING_OPS:
191
+ return False
192
+ if content_length is None:
193
+ return True
194
+ return content_length <= 4 * 1024 * 1024
195
+
196
+
197
+ def detect(headers, method: str, path: str, query: dict,
198
+ body: bytes | None) -> RequestInfo:
199
+ service, region, access_key = _parse_credential_scope(headers)
200
+ operation = _operation_for(service, headers, method, path, query, body)
201
+ resource = _resource_hint(service, operation, path, body)
202
+ return RequestInfo(
203
+ service=service,
204
+ operation=operation,
205
+ region=region,
206
+ resource=resource,
207
+ access_key=access_key,
208
+ )
microburst/errors.py ADDED
@@ -0,0 +1,102 @@
1
+ """Error serialization per AWS wire protocol.
2
+
3
+ The SDKs classify failures by the *error code parsed from the body* (plus
4
+ status code), not the status alone. To make injected faults exercise the real
5
+ retry machinery — throttling backoff, adaptive rate limiting, modeled
6
+ retryable exceptions — the body must be shaped the way the service actually
7
+ shapes it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import uuid
14
+ from xml.sax.saxutils import escape
15
+
16
+ from microburst.models import error_http_status, get_protocol
17
+
18
+
19
+ def _request_id() -> str:
20
+ return str(uuid.uuid4())
21
+
22
+
23
+ def _json_body(code: str, message: str) -> bytes:
24
+ return json.dumps({"__type": code, "message": message}).encode()
25
+
26
+
27
+ def render_error(
28
+ service: str | None,
29
+ code: str,
30
+ message: str = "",
31
+ status: int | None = None,
32
+ ) -> tuple[int, dict[str, str], bytes]:
33
+ """Serialize an AWS-looking error. Returns (status, headers, body)."""
34
+ protocol = get_protocol(service) if service else None
35
+ if status is None:
36
+ if service is None:
37
+ status = 503
38
+ else:
39
+ # json/query services conventionally serve client faults at 400;
40
+ # rest protocols lean on 5xx for transient errors.
41
+ default = 400 if protocol in ("json", "query", "ec2") else 503
42
+ status = error_http_status(service, code, default=default)
43
+ if not message:
44
+ message = code
45
+
46
+ rid = _request_id()
47
+
48
+ if protocol == "json":
49
+ headers = {
50
+ "Content-Type": "application/x-amz-json-1.0",
51
+ "x-amzn-RequestId": rid,
52
+ "x-amzn-ErrorType": code,
53
+ }
54
+ return status, headers, _json_body(code, message)
55
+
56
+ if protocol in ("query", "ec2"):
57
+ headers = {
58
+ "Content-Type": "text/xml",
59
+ "x-amzn-RequestId": rid,
60
+ }
61
+ body = (
62
+ "<ErrorResponse>"
63
+ "<Error>"
64
+ f"<Code>{escape(code)}</Code>"
65
+ f"<Message>{escape(message)}</Message>"
66
+ "<Type>Sender</Type>"
67
+ "</Error>"
68
+ f"<RequestId>{rid}</RequestId>"
69
+ "</ErrorResponse>"
70
+ )
71
+ return status, headers, body.encode()
72
+
73
+ if protocol == "rest-xml":
74
+ headers = {
75
+ "Content-Type": "application/xml",
76
+ "x-amz-request-id": rid,
77
+ "x-amz-id-2": uuid.uuid4().hex * 2,
78
+ }
79
+ body = (
80
+ "<Error>"
81
+ f"<Code>{escape(code)}</Code>"
82
+ f"<Message>{escape(message)}</Message>"
83
+ f"<RequestId>{rid}</RequestId>"
84
+ "</Error>"
85
+ )
86
+ return status, headers, body.encode()
87
+
88
+ if protocol == "rest-json":
89
+ headers = {
90
+ "Content-Type": "application/json",
91
+ "x-amzn-RequestId": rid,
92
+ "x-amzn-errortype": code,
93
+ }
94
+ return status, headers, json.dumps({"message": message}).encode()
95
+
96
+ # Unknown service/protocol: closest AWS-looking generic envelope.
97
+ headers = {
98
+ "Content-Type": "application/x-amz-json-1.0",
99
+ "x-amzn-RequestId": rid,
100
+ "x-amzn-ErrorType": code,
101
+ }
102
+ return status, headers, _json_body(code, message)
microburst/models.py ADDED
@@ -0,0 +1,174 @@
1
+ """Service model access via botocore.
2
+
3
+ Wraps botocore's loader to resolve AWS service models (protocol, operations,
4
+ error shapes) by the names requests actually carry: SigV4 credential scope and
5
+ endpoint prefixes. Everything is lazy and cached.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import botocore.session
11
+
12
+ # SigV4 credential scopes that differ from the botocore service name.
13
+ _SCOPE_ALIASES = {
14
+ "monitoring": "cloudwatch",
15
+ "email": "ses",
16
+ "iotdata": "iot-data",
17
+ "iot-jobs-data": "iot-jobs-data",
18
+ "iotwireless": "iotwireless",
19
+ "elasticfilesystem": "elasticfilesystem",
20
+ "api.ecr": "ecr",
21
+ "aoss": "opensearchserverless",
22
+ "cloudfront-keyvaluestore": "cloudfront-keyvaluestore",
23
+ "cognito-idp": "cognito-idp",
24
+ "cognito-identity": "cognito-identity",
25
+ }
26
+
27
+ _session = botocore.session.get_session()
28
+ _model_cache: dict[str, object] = {}
29
+
30
+
31
+ def service_for_scope(scope: str) -> str:
32
+ """Map a SigV4 credential scope to a botocore service name."""
33
+ return _SCOPE_ALIASES.get(scope, scope)
34
+
35
+
36
+ def get_service_model(service: str):
37
+ """Return the botocore ServiceModel for a service name, or None."""
38
+ if service in _model_cache:
39
+ return _model_cache[service]
40
+ try:
41
+ model = _session.get_service_model(service)
42
+ except Exception: # noqa: BLE001 — unknown service names / broken data loaders
43
+ model = None
44
+ _model_cache[service] = model
45
+ return model
46
+
47
+
48
+ def get_protocol(service: str) -> str | None:
49
+ model = get_service_model(service)
50
+ if model is None:
51
+ return None
52
+ return model.protocol
53
+
54
+
55
+ def error_shape(service: str, code: str):
56
+ """Find the shape for an error code/name in a service model, or None.
57
+
58
+ Accepts the bare shape name or a Smithy-style ``prefix#Name``.
59
+ """
60
+ model = get_service_model(service)
61
+ if model is None:
62
+ return None
63
+ name = code.rsplit("#", 1)[-1]
64
+ shape = _shape_or_none(model, name)
65
+ if shape is not None:
66
+ return shape
67
+ for candidate in model.shape_names:
68
+ if candidate == name or candidate.rsplit("#", 1)[-1] == name:
69
+ return _shape_or_none(model, candidate)
70
+ return None
71
+
72
+
73
+ def _shape_or_none(model, name: str):
74
+ try:
75
+ return model.shape_for(name)
76
+ except Exception: # noqa: BLE001 — unmodeled codes raise assorted errors
77
+ return None
78
+
79
+
80
+ # AWS's real status for common codes when the service model doesn't carry an
81
+ # ``httpStatusCode`` trait (most don't). Verified behavior that matters: the
82
+ # SDK's retry decision depends on the status — S3 SlowDown at 400 is treated
83
+ # as a terminal client error, at 503 it retries.
84
+ _KNOWN_STATUS = {
85
+ # throttling family (botocore's retryable throttled codes)
86
+ "Throttling": 400,
87
+ "ThrottlingException": 400,
88
+ "ThrottledException": 400,
89
+ "RequestThrottledException": 400,
90
+ "RequestThrottled": 400,
91
+ "EC2ThrottledException": 400,
92
+ "TooManyRequestsException": 429,
93
+ "ProvisionedThroughputExceededException": 400,
94
+ "TransactionInProgressException": 400,
95
+ "RequestLimitExceeded": 503,
96
+ "BandwidthLimitExceeded": 400,
97
+ "LimitExceededException": 400,
98
+ "PriorRequestNotComplete": 400,
99
+ "SlowDown": 503,
100
+ # transient server-side
101
+ "InternalError": 500,
102
+ "InternalServerError": 500,
103
+ "InternalFailure": 500,
104
+ "InternalServiceError": 500,
105
+ "ServiceUnavailable": 503,
106
+ "ServiceUnavailableException": 503,
107
+ "ServiceUnavailableError": 503,
108
+ "RequestTimeout": 408,
109
+ "RequestTimeoutException": 408,
110
+ # common terminal errors — keep these non-retryable
111
+ "AccessDeniedException": 403,
112
+ "AccessDenied": 403,
113
+ "InvalidAccessKeyId": 403,
114
+ "SignatureDoesNotMatch": 403,
115
+ "ExpiredTokenException": 400,
116
+ "UnauthorizedException": 401,
117
+ "NotAuthorizedException": 400,
118
+ "ValidationException": 400,
119
+ "ValidationError": 400,
120
+ "InvalidParameterValue": 400,
121
+ "InvalidParameterException": 400,
122
+ "InvalidParameterValueException": 400,
123
+ "MissingParameter": 400,
124
+ "NoSuchBucket": 404,
125
+ "NoSuchKey": 404,
126
+ "NotFound": 404,
127
+ "NotFoundException": 404,
128
+ "ResourceNotFoundException": 404,
129
+ "ResourceNotFound": 404,
130
+ "ResourceInUseException": 400,
131
+ "ResourceAlreadyExistsException": 400,
132
+ "ConditionalCheckFailedException": 400,
133
+ "InvalidClientTokenId": 403,
134
+ }
135
+
136
+
137
+ def _modeled_status(service: str, name: str) -> int | None:
138
+ """httpStatusCode from the raw service model, when modeled."""
139
+ try:
140
+ loader = _session.get_component("data_loader")
141
+ model = loader.load_service_model(service, "service-2")
142
+ shape = model.get("shapes", {}).get(name)
143
+ if shape:
144
+ status = shape.get("error", {}).get("httpStatusCode")
145
+ return status if isinstance(status, int) else None
146
+ except Exception: # noqa: BLE001 — service model may be absent/partial
147
+ return None
148
+ return None
149
+
150
+
151
+ def error_http_status(service: str, code: str, default: int = 503) -> int:
152
+ """HTTP status for an error code.
153
+
154
+ Order: modeled ``httpStatusCode`` (rare but authoritative when present) →
155
+ curated AWS-observed map → ``default`` (callers pick per protocol; AWS
156
+ json/query services conventionally serve client errors at 400).
157
+ """
158
+ name = code.rsplit("#", 1)[-1]
159
+ status = _modeled_status(service, name)
160
+ if status is not None:
161
+ return status
162
+ return _KNOWN_STATUS.get(name, default)
163
+
164
+
165
+ def operation_error_names(service: str, operation: str) -> list[str]:
166
+ """Error shape names modeled for an operation (for plausible sampling)."""
167
+ model = get_service_model(service)
168
+ if model is None:
169
+ return []
170
+ try:
171
+ op = model.operation_model(operation)
172
+ except Exception: # noqa: BLE001 — OperationNotFoundError and friends
173
+ return []
174
+ return [shape.name for shape in op.error_shapes]