py-serverless-kit 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.
- py_serverless_kit/__init__.py +47 -0
- py_serverless_kit/__main__.py +3 -0
- py_serverless_kit/_version.py +2 -0
- py_serverless_kit/cli/__init__.py +0 -0
- py_serverless_kit/cli/main.py +491 -0
- py_serverless_kit/cli/templates.py +212 -0
- py_serverless_kit/config/__init__.py +12 -0
- py_serverless_kit/config/loader.py +316 -0
- py_serverless_kit/config/schema.py +457 -0
- py_serverless_kit/core/__init__.py +0 -0
- py_serverless_kit/core/deployer.py +180 -0
- py_serverless_kit/core/describe.py +112 -0
- py_serverless_kit/core/project.py +147 -0
- py_serverless_kit/dashboard/__init__.py +3 -0
- py_serverless_kit/dashboard/generator.py +65 -0
- py_serverless_kit/dashboard/template.html +248 -0
- py_serverless_kit/local/__init__.py +3 -0
- py_serverless_kit/local/runner.py +234 -0
- py_serverless_kit/local/server.py +200 -0
- py_serverless_kit/packaging/__init__.py +3 -0
- py_serverless_kit/packaging/builder.py +252 -0
- py_serverless_kit/providers/__init__.py +15 -0
- py_serverless_kit/providers/alibaba.py +268 -0
- py_serverless_kit/providers/aws.py +508 -0
- py_serverless_kit/providers/azure.py +417 -0
- py_serverless_kit/providers/base.py +310 -0
- py_serverless_kit/providers/cloudflare.py +264 -0
- py_serverless_kit/providers/digitalocean.py +211 -0
- py_serverless_kit/providers/gcp.py +480 -0
- py_serverless_kit/providers/huawei.py +292 -0
- py_serverless_kit/providers/ibm.py +132 -0
- py_serverless_kit/providers/oracle.py +304 -0
- py_serverless_kit/providers/registry.py +60 -0
- py_serverless_kit/providers/schedule.py +153 -0
- py_serverless_kit/providers/tencent.py +275 -0
- py_serverless_kit/providers/terraform_doc.py +125 -0
- py_serverless_kit/runtime/__init__.py +6 -0
- py_serverless_kit/runtime/adapters/__init__.py +18 -0
- py_serverless_kit/runtime/adapters/_httpfn.py +91 -0
- py_serverless_kit/runtime/adapters/alibaba.py +147 -0
- py_serverless_kit/runtime/adapters/aws.py +201 -0
- py_serverless_kit/runtime/adapters/azure.py +185 -0
- py_serverless_kit/runtime/adapters/base.py +86 -0
- py_serverless_kit/runtime/adapters/cloudflare.py +126 -0
- py_serverless_kit/runtime/adapters/digitalocean.py +74 -0
- py_serverless_kit/runtime/adapters/gcp.py +176 -0
- py_serverless_kit/runtime/adapters/huawei.py +173 -0
- py_serverless_kit/runtime/adapters/ibm.py +91 -0
- py_serverless_kit/runtime/adapters/oracle.py +161 -0
- py_serverless_kit/runtime/adapters/tencent.py +151 -0
- py_serverless_kit/runtime/batch.py +47 -0
- py_serverless_kit/runtime/context.py +45 -0
- py_serverless_kit/runtime/dispatch.py +277 -0
- py_serverless_kit/runtime/errors.py +83 -0
- py_serverless_kit/runtime/event.py +267 -0
- py_serverless_kit/runtime/invoke.py +119 -0
- py_serverless_kit/runtime/log.py +69 -0
- py_serverless_kit/runtime/manifest.py +121 -0
- py_serverless_kit/runtime/response.py +177 -0
- py_serverless_kit/runtime/routing.py +74 -0
- py_serverless_kit/runtime/workflow.py +312 -0
- py_serverless_kit/terraform/__init__.py +3 -0
- py_serverless_kit/terraform/runner.py +104 -0
- py_serverless_kit/workflows/__init__.py +35 -0
- py_serverless_kit/workflows/asl.py +170 -0
- py_serverless_kit/workflows/gcp.py +328 -0
- py_serverless_kit-0.1.0.dist-info/METADATA +300 -0
- py_serverless_kit-0.1.0.dist-info/RECORD +71 -0
- py_serverless_kit-0.1.0.dist-info/WHEEL +4 -0
- py_serverless_kit-0.1.0.dist-info/entry_points.txt +3 -0
- py_serverless_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Provider-neutral HTTP response model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
_TEXT_PREFIXES = ("text/",)
|
|
14
|
+
_TEXT_TYPES = (
|
|
15
|
+
"application/json",
|
|
16
|
+
"application/xml",
|
|
17
|
+
"application/javascript",
|
|
18
|
+
"application/x-www-form-urlencoded",
|
|
19
|
+
"image/svg+xml",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def json_default(value: Any) -> Any:
|
|
24
|
+
if isinstance(value, (datetime, date)):
|
|
25
|
+
return value.isoformat()
|
|
26
|
+
if isinstance(value, Decimal):
|
|
27
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
28
|
+
if isinstance(value, (UUID, bytes)):
|
|
29
|
+
return value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value)
|
|
30
|
+
if isinstance(value, (set, frozenset)):
|
|
31
|
+
return sorted(value)
|
|
32
|
+
if hasattr(value, "model_dump"): # pydantic v2
|
|
33
|
+
return value.model_dump()
|
|
34
|
+
if hasattr(value, "to_dict"):
|
|
35
|
+
return value.to_dict()
|
|
36
|
+
if hasattr(value, "__dataclass_fields__"):
|
|
37
|
+
from dataclasses import asdict
|
|
38
|
+
|
|
39
|
+
return asdict(value)
|
|
40
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def dumps(value: Any) -> str:
|
|
44
|
+
return json.dumps(value, default=json_default, separators=(",", ":"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_text_content_type(content_type: str) -> bool:
|
|
48
|
+
ct = content_type.split(";")[0].strip().lower()
|
|
49
|
+
return ct.startswith(_TEXT_PREFIXES) or ct in _TEXT_TYPES or ct.endswith("+json")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Response:
|
|
54
|
+
status: int = 200
|
|
55
|
+
body: Any = None
|
|
56
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
57
|
+
cookies: List[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
# ---- constructors -------------------------------------------------
|
|
60
|
+
@classmethod
|
|
61
|
+
def json(cls, data: Any, status: int = 200, headers: Optional[Dict[str, str]] = None) -> Response:
|
|
62
|
+
return cls(status, dumps(data), {"content-type": "application/json", **(headers or {})})
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def text(cls, text: str, status: int = 200, headers: Optional[Dict[str, str]] = None) -> Response:
|
|
66
|
+
return cls(status, text, {"content-type": "text/plain; charset=utf-8", **(headers or {})})
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def html(cls, html: str, status: int = 200, headers: Optional[Dict[str, str]] = None) -> Response:
|
|
70
|
+
return cls(status, html, {"content-type": "text/html; charset=utf-8", **(headers or {})})
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def binary(
|
|
74
|
+
cls,
|
|
75
|
+
data: bytes,
|
|
76
|
+
content_type: str = "application/octet-stream",
|
|
77
|
+
status: int = 200,
|
|
78
|
+
headers: Optional[Dict[str, str]] = None,
|
|
79
|
+
) -> Response:
|
|
80
|
+
return cls(status, bytes(data), {"content-type": content_type, **(headers or {})})
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def redirect(cls, location: str, status: int = 302) -> Response:
|
|
84
|
+
return cls(status, "", {"location": location})
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def no_content(cls) -> Response:
|
|
88
|
+
return cls(204, "")
|
|
89
|
+
|
|
90
|
+
# ---- helpers ------------------------------------------------------
|
|
91
|
+
def set_cookie(
|
|
92
|
+
self,
|
|
93
|
+
name: str,
|
|
94
|
+
value: str,
|
|
95
|
+
*,
|
|
96
|
+
max_age: Optional[int] = None,
|
|
97
|
+
path: str = "/",
|
|
98
|
+
http_only: bool = True,
|
|
99
|
+
secure: bool = True,
|
|
100
|
+
same_site: str = "Lax",
|
|
101
|
+
) -> Response:
|
|
102
|
+
parts = [f"{name}={value}", f"Path={path}", f"SameSite={same_site}"]
|
|
103
|
+
if max_age is not None:
|
|
104
|
+
parts.append(f"Max-Age={max_age}")
|
|
105
|
+
if http_only:
|
|
106
|
+
parts.append("HttpOnly")
|
|
107
|
+
if secure:
|
|
108
|
+
parts.append("Secure")
|
|
109
|
+
self.cookies.append("; ".join(parts))
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def content_type(self) -> str:
|
|
114
|
+
for key, value in self.headers.items():
|
|
115
|
+
if key.lower() == "content-type":
|
|
116
|
+
return value
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
def render(self) -> Tuple[int, Dict[str, str], bytes]:
|
|
120
|
+
"""Return ``(status, headers, body_bytes)`` with lower-cased header names
|
|
121
|
+
and a content-type guaranteed to be set when there is a body."""
|
|
122
|
+
headers = {k.lower(): str(v) for k, v in self.headers.items()}
|
|
123
|
+
body = self.body
|
|
124
|
+
if body is None:
|
|
125
|
+
raw = b""
|
|
126
|
+
elif isinstance(body, bytes):
|
|
127
|
+
raw = body
|
|
128
|
+
if raw:
|
|
129
|
+
headers.setdefault("content-type", "application/octet-stream")
|
|
130
|
+
elif isinstance(body, str):
|
|
131
|
+
raw = body.encode("utf-8")
|
|
132
|
+
if raw:
|
|
133
|
+
headers.setdefault("content-type", "text/plain; charset=utf-8")
|
|
134
|
+
else:
|
|
135
|
+
raw = dumps(body).encode("utf-8")
|
|
136
|
+
headers.setdefault("content-type", "application/json")
|
|
137
|
+
return int(self.status), headers, raw
|
|
138
|
+
|
|
139
|
+
def render_for_gateway(self) -> Tuple[int, Dict[str, str], str, bool]:
|
|
140
|
+
"""Like :meth:`render` but returns a ``str`` body plus an ``is_base64`` flag,
|
|
141
|
+
which is what API-gateway style integrations expect."""
|
|
142
|
+
status, headers, raw = self.render()
|
|
143
|
+
if not raw:
|
|
144
|
+
return status, headers, "", False
|
|
145
|
+
if is_text_content_type(headers.get("content-type", "")):
|
|
146
|
+
try:
|
|
147
|
+
return status, headers, raw.decode("utf-8"), False
|
|
148
|
+
except UnicodeDecodeError:
|
|
149
|
+
pass
|
|
150
|
+
return status, headers, base64.b64encode(raw).decode("ascii"), True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def to_response(result: Any) -> Response:
|
|
154
|
+
"""Coerce whatever a handler returned into a :class:`Response`.
|
|
155
|
+
|
|
156
|
+
* ``Response`` -> unchanged
|
|
157
|
+
* ``None`` -> 204 No Content
|
|
158
|
+
* ``dict`` / ``list`` / numbers -> JSON 200
|
|
159
|
+
* ``str`` -> text/plain 200
|
|
160
|
+
* ``bytes`` -> application/octet-stream 200
|
|
161
|
+
* ``(body, status)`` / ``(body, status, headers)`` -> Flask-style tuple
|
|
162
|
+
"""
|
|
163
|
+
if isinstance(result, Response):
|
|
164
|
+
return result
|
|
165
|
+
if result is None:
|
|
166
|
+
return Response.no_content()
|
|
167
|
+
if isinstance(result, tuple) and 2 <= len(result) <= 3 and isinstance(result[1], int):
|
|
168
|
+
base = to_response(result[0])
|
|
169
|
+
base.status = result[1]
|
|
170
|
+
if len(result) == 3 and result[2]:
|
|
171
|
+
base.headers.update(result[2])
|
|
172
|
+
return base
|
|
173
|
+
if isinstance(result, bytes):
|
|
174
|
+
return Response.binary(result)
|
|
175
|
+
if isinstance(result, str):
|
|
176
|
+
return Response.text(result)
|
|
177
|
+
return Response.json(result)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Tiny route matcher used where the provider has no API gateway in front of the
|
|
2
|
+
function (GCP, IBM, DigitalOcean, Cloudflare, ...) and by the local dev server.
|
|
3
|
+
|
|
4
|
+
Patterns use ``{name}`` for one segment and ``{name+}`` for a greedy tail,
|
|
5
|
+
the same syntax as AWS API Gateway, Azure Functions and the YAML config.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def split_path(path: str) -> List[str]:
|
|
14
|
+
return [seg for seg in path.split("?")[0].split("/") if seg]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_param(seg: str) -> bool:
|
|
18
|
+
return seg.startswith("{") and seg.endswith("}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _match_segments(pattern: Sequence[str], parts: Sequence[str]) -> Optional[Dict[str, str]]:
|
|
22
|
+
params: Dict[str, str] = {}
|
|
23
|
+
for i, seg in enumerate(pattern):
|
|
24
|
+
if _is_param(seg):
|
|
25
|
+
name = seg[1:-1]
|
|
26
|
+
if name.endswith("+"):
|
|
27
|
+
if i != len(pattern) - 1 or i >= len(parts):
|
|
28
|
+
return None
|
|
29
|
+
params[name[:-1]] = "/".join(parts[i:])
|
|
30
|
+
return params
|
|
31
|
+
if i >= len(parts):
|
|
32
|
+
return None
|
|
33
|
+
params[name] = parts[i]
|
|
34
|
+
elif i >= len(parts) or seg != parts[i]:
|
|
35
|
+
return None
|
|
36
|
+
return params if len(parts) == len(pattern) else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def match_path(pattern: str, path: str, *, tolerant: bool = True) -> Optional[Dict[str, str]]:
|
|
40
|
+
"""Return path params if ``path`` matches ``pattern`` else ``None``.
|
|
41
|
+
|
|
42
|
+
With ``tolerant=True`` two platform quirks are accepted as well:
|
|
43
|
+
|
|
44
|
+
* the platform *prepended* a fixed prefix (Azure ``/api/users/7``) - the
|
|
45
|
+
whole pattern must then match the tail of the path;
|
|
46
|
+
* the platform *stripped* the pattern's leading static segments (GCP serves
|
|
47
|
+
``/users/{id}`` at ``https://.../fn-name/7``) - the remaining
|
|
48
|
+
pattern must then match the whole path.
|
|
49
|
+
"""
|
|
50
|
+
pat = split_path(pattern)
|
|
51
|
+
parts = split_path(path)
|
|
52
|
+
exact = _match_segments(pat, parts)
|
|
53
|
+
if exact is not None or not tolerant:
|
|
54
|
+
return exact
|
|
55
|
+
if any(not _is_param(seg) for seg in pat):
|
|
56
|
+
for drop in range(1, len(parts)):
|
|
57
|
+
got = _match_segments(pat, parts[drop:])
|
|
58
|
+
if got is not None:
|
|
59
|
+
return got
|
|
60
|
+
leading_static = 0
|
|
61
|
+
for seg in pat:
|
|
62
|
+
if _is_param(seg):
|
|
63
|
+
break
|
|
64
|
+
leading_static += 1
|
|
65
|
+
if leading_static:
|
|
66
|
+
rest = pat[leading_static:]
|
|
67
|
+
if rest or not parts:
|
|
68
|
+
return _match_segments(rest, parts)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def method_matches(allowed: str, method: str) -> bool:
|
|
73
|
+
allowed = allowed.upper()
|
|
74
|
+
return allowed in ("ANY", "*") or allowed == method.upper()
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Embedded workflow (state machine) engine.
|
|
2
|
+
|
|
3
|
+
The same definition the CLI compiles to AWS Step Functions (ASL) or Google
|
|
4
|
+
Cloud Workflows is interpreted here for:
|
|
5
|
+
|
|
6
|
+
* ``pskit workflow run --local`` - test workflows on your laptop;
|
|
7
|
+
* providers without a native orchestrator - the CLI deploys an extra
|
|
8
|
+
*orchestrator* function whose handler is :func:`orchestrate`, and which calls
|
|
9
|
+
task handlers in-process (they live in the same bundle).
|
|
10
|
+
|
|
11
|
+
Supported state types: task, choice, wait, pass, parallel, map, succeed, fail.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import fnmatch
|
|
18
|
+
import time
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from .context import Context
|
|
25
|
+
from .event import Event, EventType
|
|
26
|
+
from .response import Response
|
|
27
|
+
|
|
28
|
+
TaskRunner = Callable[[str, Any], Any]
|
|
29
|
+
ALL_ERRORS = ("*", "States.ALL")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WorkflowFailed(Exception):
|
|
33
|
+
def __init__(self, error: str, cause: str = "", history: Optional[List[Dict[str, Any]]] = None) -> None:
|
|
34
|
+
self.error = error
|
|
35
|
+
self.cause = cause
|
|
36
|
+
self.history = history or []
|
|
37
|
+
super().__init__(f"{error}: {cause}" if cause else error)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------- JSON path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tokens(path: str) -> List[Any]:
|
|
44
|
+
if path in ("$", "", None):
|
|
45
|
+
return []
|
|
46
|
+
if not path.startswith("$"):
|
|
47
|
+
raise ValueError(f"JSON path must start with '$': {path!r}")
|
|
48
|
+
tokens: List[Any] = []
|
|
49
|
+
for part in path[1:].replace("[", ".[").split("."):
|
|
50
|
+
if not part:
|
|
51
|
+
continue
|
|
52
|
+
if part.startswith("[") and part.endswith("]"):
|
|
53
|
+
inner = part[1:-1].strip("'\"")
|
|
54
|
+
tokens.append(int(inner) if inner.lstrip("-").isdigit() else inner)
|
|
55
|
+
else:
|
|
56
|
+
tokens.append(part)
|
|
57
|
+
return tokens
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_MISSING = object() # "no default given" marker for get_path
|
|
61
|
+
_ABSENT = object() # "path not present" value used by choice rules
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_path(data: Any, path: str, default: Any = _MISSING) -> Any:
|
|
65
|
+
current = data
|
|
66
|
+
for tok in _tokens(path):
|
|
67
|
+
try:
|
|
68
|
+
current = current[tok]
|
|
69
|
+
except (KeyError, IndexError, TypeError):
|
|
70
|
+
if default is _MISSING:
|
|
71
|
+
raise KeyError(path) from None
|
|
72
|
+
return default
|
|
73
|
+
return current
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def set_path(data: Any, path: Optional[str], value: Any) -> Any:
|
|
77
|
+
"""Return ``data`` with ``value`` written at ``path`` (``$`` replaces; ``None`` discards)."""
|
|
78
|
+
if path is None:
|
|
79
|
+
return data
|
|
80
|
+
tokens = _tokens(path)
|
|
81
|
+
if not tokens:
|
|
82
|
+
return value
|
|
83
|
+
root = copy.deepcopy(data) if isinstance(data, (dict, list)) else {}
|
|
84
|
+
current = root
|
|
85
|
+
for tok in tokens[:-1]:
|
|
86
|
+
if isinstance(current, dict):
|
|
87
|
+
current = current.setdefault(tok, {})
|
|
88
|
+
else:
|
|
89
|
+
current = current[tok]
|
|
90
|
+
current[tokens[-1]] = value
|
|
91
|
+
return root
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------- choice
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_OPS: Dict[str, Callable[[Any, Any], bool]] = {
|
|
98
|
+
"equals": lambda a, b: a == b,
|
|
99
|
+
"not_equals": lambda a, b: a != b,
|
|
100
|
+
"gt": lambda a, b: a is not None and a > b,
|
|
101
|
+
"gte": lambda a, b: a is not None and a >= b,
|
|
102
|
+
"lt": lambda a, b: a is not None and a < b,
|
|
103
|
+
"lte": lambda a, b: a is not None and a <= b,
|
|
104
|
+
"in": lambda a, b: a in b,
|
|
105
|
+
"contains": lambda a, b: a is not None and b in a,
|
|
106
|
+
"matches": lambda a, b: isinstance(a, str) and fnmatch.fnmatchcase(a, b),
|
|
107
|
+
"is_null": lambda a, b: (a is None) == bool(b),
|
|
108
|
+
"is_present": lambda a, b: (a is not _ABSENT) == bool(b),
|
|
109
|
+
}
|
|
110
|
+
CHOICE_OPERATORS = tuple(_OPS)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def evaluate_rule(rule: Dict[str, Any], data: Any) -> bool:
|
|
114
|
+
if "and" in rule:
|
|
115
|
+
return all(evaluate_rule(r, data) for r in rule["and"])
|
|
116
|
+
if "or" in rule:
|
|
117
|
+
return any(evaluate_rule(r, data) for r in rule["or"])
|
|
118
|
+
if "not" in rule:
|
|
119
|
+
return not evaluate_rule(rule["not"], data)
|
|
120
|
+
variable = rule.get("variable", "$")
|
|
121
|
+
for op, fn in _OPS.items():
|
|
122
|
+
if op in rule:
|
|
123
|
+
value = get_path(data, variable, _ABSENT)
|
|
124
|
+
if op == "is_present":
|
|
125
|
+
return fn(value, rule[op])
|
|
126
|
+
if value is _ABSENT:
|
|
127
|
+
return False
|
|
128
|
+
try:
|
|
129
|
+
return bool(fn(value, rule[op]))
|
|
130
|
+
except TypeError:
|
|
131
|
+
return False
|
|
132
|
+
raise ValueError(f"Choice rule has no operator (one of {', '.join(_OPS)}): {rule}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------- engine
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _error_name(exc: BaseException) -> str:
|
|
139
|
+
return getattr(exc, "error", None) or type(exc).__name__
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _matches(errors: List[str], name: str) -> bool:
|
|
143
|
+
return any(e in ALL_ERRORS or e == name for e in (errors or ["*"]))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class WorkflowEngine:
|
|
148
|
+
definition: Dict[str, Any]
|
|
149
|
+
task_runner: TaskRunner
|
|
150
|
+
sleep: Callable[[float], None] = time.sleep
|
|
151
|
+
max_steps: int = 10_000
|
|
152
|
+
history: List[Dict[str, Any]] = field(default_factory=list)
|
|
153
|
+
|
|
154
|
+
def run(self, data: Any = None) -> Any:
|
|
155
|
+
return self._run_machine(self.definition, data)
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
def _record(self, state: str, kind: str, **extra: Any) -> None:
|
|
159
|
+
self.history.append({"ts": datetime.now(timezone.utc).isoformat(), "state": state, "type": kind, **extra})
|
|
160
|
+
|
|
161
|
+
def _run_machine(self, machine: Dict[str, Any], data: Any) -> Any:
|
|
162
|
+
states: Dict[str, Dict[str, Any]] = machine["states"]
|
|
163
|
+
name: Optional[str] = machine["start_at"]
|
|
164
|
+
steps = 0
|
|
165
|
+
while name is not None:
|
|
166
|
+
steps += 1
|
|
167
|
+
if steps > self.max_steps:
|
|
168
|
+
raise WorkflowFailed("States.Runtime", f"exceeded {self.max_steps} steps", self.history)
|
|
169
|
+
state = states[name]
|
|
170
|
+
kind = state["type"]
|
|
171
|
+
self._record(name, kind, event="enter")
|
|
172
|
+
if kind == "succeed":
|
|
173
|
+
return data
|
|
174
|
+
if kind == "fail":
|
|
175
|
+
raise WorkflowFailed(state.get("error", "States.Fail"), state.get("cause", ""), self.history)
|
|
176
|
+
if kind == "choice":
|
|
177
|
+
name = self._choose(state, data)
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
data = self._execute(state, data)
|
|
181
|
+
except WorkflowFailed:
|
|
182
|
+
raise
|
|
183
|
+
except Exception as exc: # noqa: BLE001 - mapped onto catch rules
|
|
184
|
+
handler = next(
|
|
185
|
+
(c for c in state.get("catch") or [] if _matches(c.get("errors", ["*"]), _error_name(exc))),
|
|
186
|
+
None,
|
|
187
|
+
)
|
|
188
|
+
if handler is None:
|
|
189
|
+
raise WorkflowFailed(_error_name(exc), str(exc), self.history) from exc
|
|
190
|
+
self._record(name, kind, event="caught", error=_error_name(exc))
|
|
191
|
+
info = {"error": _error_name(exc), "cause": str(exc)}
|
|
192
|
+
data = set_path(data, handler.get("result_path", "$.error"), info)
|
|
193
|
+
name = handler["next"]
|
|
194
|
+
continue
|
|
195
|
+
if state.get("end") or not state.get("next"):
|
|
196
|
+
return data
|
|
197
|
+
name = state["next"]
|
|
198
|
+
return data
|
|
199
|
+
|
|
200
|
+
def _choose(self, state: Dict[str, Any], data: Any) -> str:
|
|
201
|
+
for rule in state.get("choices") or []:
|
|
202
|
+
if evaluate_rule(rule, data):
|
|
203
|
+
return rule["next"]
|
|
204
|
+
if state.get("default"):
|
|
205
|
+
return state["default"]
|
|
206
|
+
raise WorkflowFailed("States.NoChoiceMatched", "no choice rule matched and no default", self.history)
|
|
207
|
+
|
|
208
|
+
def _execute(self, state: Dict[str, Any], data: Any) -> Any:
|
|
209
|
+
kind = state["type"]
|
|
210
|
+
input_data = get_path(data, state["input_path"]) if state.get("input_path") else data
|
|
211
|
+
if kind == "task":
|
|
212
|
+
result = self._with_retry(state, lambda: self.task_runner(state["function"], input_data))
|
|
213
|
+
elif kind == "pass":
|
|
214
|
+
result = state.get("result", input_data)
|
|
215
|
+
elif kind == "wait":
|
|
216
|
+
if state.get("seconds_path"):
|
|
217
|
+
seconds = float(get_path(data, state["seconds_path"]))
|
|
218
|
+
else:
|
|
219
|
+
seconds = float(state.get("seconds", 0))
|
|
220
|
+
self.sleep(seconds)
|
|
221
|
+
result = input_data
|
|
222
|
+
elif kind == "parallel":
|
|
223
|
+
branches = state.get("branches") or []
|
|
224
|
+
|
|
225
|
+
def run_branch(branch: Dict[str, Any]) -> Any:
|
|
226
|
+
return self._run_machine(branch, copy.deepcopy(input_data))
|
|
227
|
+
|
|
228
|
+
with ThreadPoolExecutor(max_workers=max(1, len(branches))) as pool:
|
|
229
|
+
result = list(pool.map(run_branch, branches))
|
|
230
|
+
elif kind == "map":
|
|
231
|
+
items = get_path(input_data, state.get("items_path", "$"))
|
|
232
|
+
if not isinstance(items, list):
|
|
233
|
+
raise TypeError(f"map items_path did not resolve to a list: {type(items).__name__}")
|
|
234
|
+
iterator = state["iterator"]
|
|
235
|
+
workers = int(state.get("max_concurrency") or 0) or min(32, max(1, len(items)))
|
|
236
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
237
|
+
result = list(pool.map(lambda item: self._run_machine(iterator, item), items))
|
|
238
|
+
else:
|
|
239
|
+
raise WorkflowFailed("States.Runtime", f"unknown state type {kind!r}", self.history)
|
|
240
|
+
return set_path(data, state.get("result_path", "$"), result)
|
|
241
|
+
|
|
242
|
+
def _with_retry(self, state: Dict[str, Any], call: Callable[[], Any]) -> Any:
|
|
243
|
+
attempts: Dict[int, int] = {}
|
|
244
|
+
while True:
|
|
245
|
+
try:
|
|
246
|
+
return call()
|
|
247
|
+
except Exception as exc: # noqa: BLE001
|
|
248
|
+
name = _error_name(exc)
|
|
249
|
+
rules = state.get("retry") or []
|
|
250
|
+
# Like ASL, the first retrier whose errors match is the one used.
|
|
251
|
+
idx = next((i for i, r in enumerate(rules) if _matches(r.get("errors", ["*"]), name)), None)
|
|
252
|
+
if idx is None:
|
|
253
|
+
raise
|
|
254
|
+
rule = rules[idx]
|
|
255
|
+
count = attempts.get(idx, 0)
|
|
256
|
+
if count >= int(rule.get("max_attempts", 3)):
|
|
257
|
+
raise
|
|
258
|
+
attempts[idx] = count + 1
|
|
259
|
+
delay = float(rule.get("interval_seconds", 1)) * float(rule.get("backoff_rate", 2.0)) ** count
|
|
260
|
+
self._record(state.get("function", "?"), "task", event="retry", attempt=count + 1, error=name)
|
|
261
|
+
self.sleep(min(delay, float(rule.get("max_delay_seconds", 300))))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def in_process_runner(provider: str = "local") -> TaskRunner:
|
|
265
|
+
"""Task runner that invokes handlers from the current bundle via the dispatcher."""
|
|
266
|
+
from .dispatch import execute
|
|
267
|
+
|
|
268
|
+
def run(function: str, data: Any) -> Any:
|
|
269
|
+
event = Event(type=EventType.WORKFLOW, provider=provider, function=function, data=data)
|
|
270
|
+
result = execute(event, Context(function=function, provider=provider), function)
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
return run
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def orchestrate(event: Event, context: Context) -> Any:
|
|
277
|
+
"""Handler of the auto-generated ``<workflow>-orchestrator`` function."""
|
|
278
|
+
from .manifest import load_manifest
|
|
279
|
+
|
|
280
|
+
manifest = load_manifest()
|
|
281
|
+
target = next(
|
|
282
|
+
(
|
|
283
|
+
(name, wf)
|
|
284
|
+
for name, wf in manifest.workflows.items()
|
|
285
|
+
if wf.get("orchestrator") == (event.function or context.function)
|
|
286
|
+
),
|
|
287
|
+
None,
|
|
288
|
+
)
|
|
289
|
+
if target is None:
|
|
290
|
+
raise LookupError(f"No workflow is orchestrated by function {event.function!r}")
|
|
291
|
+
name, wf = target
|
|
292
|
+
if event.http is not None:
|
|
293
|
+
data = event.http.json(default=None) if event.http.body else dict(event.http.query)
|
|
294
|
+
else:
|
|
295
|
+
data = event.data
|
|
296
|
+
engine = WorkflowEngine(wf["definition"], in_process_runner(event.provider))
|
|
297
|
+
started = time.time()
|
|
298
|
+
try:
|
|
299
|
+
output = engine.run(data)
|
|
300
|
+
except WorkflowFailed as exc:
|
|
301
|
+
failure = {"workflow": name, "status": "FAILED", "error": exc.error, "cause": exc.cause}
|
|
302
|
+
context.logger.error("workflow failed", extra=failure)
|
|
303
|
+
if event.http is not None:
|
|
304
|
+
return Response.json(failure, 500)
|
|
305
|
+
raise
|
|
306
|
+
body: Dict[str, Any] = {
|
|
307
|
+
"workflow": name,
|
|
308
|
+
"status": "SUCCEEDED",
|
|
309
|
+
"output": output,
|
|
310
|
+
"duration_ms": round((time.time() - started) * 1000, 2),
|
|
311
|
+
}
|
|
312
|
+
return body
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Thin, testable wrapper around the ``terraform`` / ``tofu`` binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TerraformError(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_binary(preferred: Optional[str] = None) -> str:
|
|
19
|
+
candidates = [c for c in (preferred, os.environ.get("PSKIT_TERRAFORM_BIN"), "terraform", "tofu") if c]
|
|
20
|
+
for cand in candidates:
|
|
21
|
+
found = shutil.which(cand)
|
|
22
|
+
if found:
|
|
23
|
+
return found
|
|
24
|
+
raise TerraformError(
|
|
25
|
+
"Neither `terraform` nor `tofu` was found on PATH. Install Terraform >= 1.5 "
|
|
26
|
+
"(https://developer.hashicorp.com/terraform/install) or OpenTofu, "
|
|
27
|
+
"or set PSKIT_TERRAFORM_BIN."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TerraformRunner:
|
|
33
|
+
work_dir: Path
|
|
34
|
+
binary: Optional[str] = None
|
|
35
|
+
env: Optional[Dict[str, str]] = None
|
|
36
|
+
echo: Callable[[str], None] = print
|
|
37
|
+
|
|
38
|
+
def _bin(self) -> str:
|
|
39
|
+
if self.binary is None or not Path(self.binary).is_file():
|
|
40
|
+
self.binary = find_binary(self.binary)
|
|
41
|
+
return self.binary
|
|
42
|
+
|
|
43
|
+
def _run(self, args: List[str], *, capture: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
44
|
+
env = dict(os.environ)
|
|
45
|
+
env.setdefault("TF_IN_AUTOMATION", "1")
|
|
46
|
+
env.setdefault("TF_INPUT", "0")
|
|
47
|
+
env.update(self.env or {})
|
|
48
|
+
cmd = [self._bin(), *args]
|
|
49
|
+
if capture:
|
|
50
|
+
proc = subprocess.run(cmd, cwd=self.work_dir, env=env, capture_output=True, text=True)
|
|
51
|
+
else:
|
|
52
|
+
proc = subprocess.run(cmd, cwd=self.work_dir, env=env, text=True)
|
|
53
|
+
if check and proc.returncode != 0:
|
|
54
|
+
detail = (proc.stderr or proc.stdout or "")[-6000:] if capture else ""
|
|
55
|
+
raise TerraformError(f"`{' '.join(cmd[1:3])}` failed with exit code {proc.returncode}\n{detail}".rstrip())
|
|
56
|
+
return proc
|
|
57
|
+
|
|
58
|
+
def init(self, upgrade: bool = False, reconfigure: bool = False) -> None:
|
|
59
|
+
args = ["init", "-input=false", "-no-color"]
|
|
60
|
+
if upgrade:
|
|
61
|
+
args.append("-upgrade")
|
|
62
|
+
if reconfigure:
|
|
63
|
+
args.append("-reconfigure")
|
|
64
|
+
self._run(args)
|
|
65
|
+
|
|
66
|
+
def validate(self) -> None:
|
|
67
|
+
self._run(["validate", "-no-color"])
|
|
68
|
+
|
|
69
|
+
def plan(self, destroy: bool = False, out: Optional[str] = "tfplan") -> bool:
|
|
70
|
+
"""Return ``True`` when the plan has changes."""
|
|
71
|
+
args = ["plan", "-input=false", "-no-color", "-detailed-exitcode"]
|
|
72
|
+
if destroy:
|
|
73
|
+
args.append("-destroy")
|
|
74
|
+
if out:
|
|
75
|
+
args += ["-out", out]
|
|
76
|
+
proc = self._run(args, check=False)
|
|
77
|
+
if proc.returncode == 1:
|
|
78
|
+
raise TerraformError("terraform plan failed")
|
|
79
|
+
return proc.returncode == 2
|
|
80
|
+
|
|
81
|
+
def apply(self, plan_file: Optional[str] = None, auto_approve: bool = True) -> None:
|
|
82
|
+
args = ["apply", "-input=false", "-no-color"]
|
|
83
|
+
if plan_file:
|
|
84
|
+
args.append(plan_file)
|
|
85
|
+
elif auto_approve:
|
|
86
|
+
args.append("-auto-approve")
|
|
87
|
+
self._run(args)
|
|
88
|
+
|
|
89
|
+
def destroy(self, auto_approve: bool = True) -> None:
|
|
90
|
+
args = ["destroy", "-input=false", "-no-color"]
|
|
91
|
+
if auto_approve:
|
|
92
|
+
args.append("-auto-approve")
|
|
93
|
+
self._run(args)
|
|
94
|
+
|
|
95
|
+
def output(self) -> Dict[str, Any]:
|
|
96
|
+
proc = self._run(["output", "-json", "-no-color"], capture=True)
|
|
97
|
+
try:
|
|
98
|
+
raw = json.loads(proc.stdout or "{}")
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
raise TerraformError(f"could not parse terraform output: {exc}") from exc
|
|
101
|
+
return {k: v.get("value") for k, v in raw.items()}
|
|
102
|
+
|
|
103
|
+
def passthrough(self, args: List[str]) -> int:
|
|
104
|
+
return self._run(args, check=False).returncode
|