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,267 @@
|
|
|
1
|
+
"""Provider-neutral event model.
|
|
2
|
+
|
|
3
|
+
Every provider adapter converts its native payload into an :class:`Event`, so a
|
|
4
|
+
handler written against this module runs unchanged on any supported cloud.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Dict, Iterator, List, Mapping, Optional, Tuple, Union
|
|
14
|
+
|
|
15
|
+
from .errors import BadRequest
|
|
16
|
+
|
|
17
|
+
_MISSING = object()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EventType:
|
|
21
|
+
HTTP = "http"
|
|
22
|
+
SCHEDULE = "schedule"
|
|
23
|
+
QUEUE = "queue"
|
|
24
|
+
STORAGE = "storage"
|
|
25
|
+
TOPIC = "topic"
|
|
26
|
+
INVOKE = "invoke"
|
|
27
|
+
WORKFLOW = "workflow"
|
|
28
|
+
|
|
29
|
+
ALL = (HTTP, SCHEDULE, QUEUE, STORAGE, TOPIC, INVOKE, WORKFLOW)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Headers(Mapping[str, str]):
|
|
33
|
+
"""Case-insensitive, read-only header mapping.
|
|
34
|
+
|
|
35
|
+
Multi-value headers are joined with ``", "`` (RFC 9110 section 5.3).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("_data",)
|
|
39
|
+
|
|
40
|
+
def __init__(self, raw: Union[Mapping[str, Any], List[Tuple[str, Any]], None] = None) -> None:
|
|
41
|
+
self._data: Dict[str, str] = {}
|
|
42
|
+
items = raw.items() if isinstance(raw, Mapping) else (raw or [])
|
|
43
|
+
for key, value in items:
|
|
44
|
+
if value is None:
|
|
45
|
+
continue
|
|
46
|
+
if isinstance(value, (list, tuple)):
|
|
47
|
+
value = ", ".join(str(v) for v in value)
|
|
48
|
+
k = str(key).lower()
|
|
49
|
+
self._data[k] = f"{self._data[k]}, {value}" if k in self._data else str(value)
|
|
50
|
+
|
|
51
|
+
def __getitem__(self, key: str) -> str:
|
|
52
|
+
return self._data[key.lower()]
|
|
53
|
+
|
|
54
|
+
def __contains__(self, key: object) -> bool:
|
|
55
|
+
return isinstance(key, str) and key.lower() in self._data
|
|
56
|
+
|
|
57
|
+
def __iter__(self) -> Iterator[str]:
|
|
58
|
+
return iter(self._data)
|
|
59
|
+
|
|
60
|
+
def __len__(self) -> int:
|
|
61
|
+
return len(self._data)
|
|
62
|
+
|
|
63
|
+
def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
|
|
64
|
+
return self._data.get(key.lower(), default)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> Dict[str, str]:
|
|
67
|
+
return dict(self._data)
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return f"Headers({self._data!r})"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _decode_json(raw: Union[str, bytes], what: str) -> Any:
|
|
74
|
+
if isinstance(raw, bytes):
|
|
75
|
+
raw = raw.decode("utf-8")
|
|
76
|
+
if raw == "":
|
|
77
|
+
raise BadRequest(f"{what} is empty, expected JSON")
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(raw)
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise BadRequest(f"{what} is not valid JSON", details=str(exc)) from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_cookies(header: Optional[str]) -> Dict[str, str]:
|
|
85
|
+
cookies: Dict[str, str] = {}
|
|
86
|
+
if not header:
|
|
87
|
+
return cookies
|
|
88
|
+
for part in header.split(";"):
|
|
89
|
+
if "=" in part:
|
|
90
|
+
name, _, value = part.strip().partition("=")
|
|
91
|
+
cookies[name] = value
|
|
92
|
+
return cookies
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class HttpRequest:
|
|
97
|
+
method: str = "GET"
|
|
98
|
+
path: str = "/"
|
|
99
|
+
headers: Headers = field(default_factory=Headers)
|
|
100
|
+
query: Dict[str, str] = field(default_factory=dict)
|
|
101
|
+
multi_query: Dict[str, List[str]] = field(default_factory=dict)
|
|
102
|
+
path_params: Dict[str, str] = field(default_factory=dict)
|
|
103
|
+
body: bytes = b""
|
|
104
|
+
source_ip: Optional[str] = None
|
|
105
|
+
route: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
def __post_init__(self) -> None:
|
|
108
|
+
self.method = self.method.upper()
|
|
109
|
+
if not isinstance(self.headers, Headers):
|
|
110
|
+
self.headers = Headers(self.headers)
|
|
111
|
+
if isinstance(self.body, str):
|
|
112
|
+
self.body = self.body.encode("utf-8")
|
|
113
|
+
elif self.body is None:
|
|
114
|
+
self.body = b""
|
|
115
|
+
if self.query and not self.multi_query:
|
|
116
|
+
self.multi_query = {k: [v] for k, v in self.query.items()}
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def content_type(self) -> str:
|
|
120
|
+
return self.headers.get("content-type", "").split(";")[0].strip().lower()
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def cookies(self) -> Dict[str, str]:
|
|
124
|
+
return parse_cookies(self.headers.get("cookie"))
|
|
125
|
+
|
|
126
|
+
def text(self, encoding: str = "utf-8") -> str:
|
|
127
|
+
return self.body.decode(encoding)
|
|
128
|
+
|
|
129
|
+
def json(self, default: Any = _MISSING) -> Any:
|
|
130
|
+
"""Parse the body as JSON. Raises :class:`BadRequest` (HTTP 400) on bad input
|
|
131
|
+
unless ``default`` is supplied."""
|
|
132
|
+
try:
|
|
133
|
+
return _decode_json(self.body, "Request body")
|
|
134
|
+
except BadRequest:
|
|
135
|
+
if default is not _MISSING:
|
|
136
|
+
return default
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
def form(self) -> Dict[str, str]:
|
|
140
|
+
from urllib.parse import parse_qsl
|
|
141
|
+
|
|
142
|
+
return dict(parse_qsl(self.text(), keep_blank_values=True))
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def decode_body(body: Any, is_base64: bool = False) -> bytes:
|
|
146
|
+
if body is None:
|
|
147
|
+
return b""
|
|
148
|
+
if isinstance(body, (dict, list)):
|
|
149
|
+
return json.dumps(body).encode("utf-8")
|
|
150
|
+
if isinstance(body, bytes):
|
|
151
|
+
return base64.b64decode(body) if is_base64 else body
|
|
152
|
+
if is_base64:
|
|
153
|
+
return base64.b64decode(body)
|
|
154
|
+
return str(body).encode("utf-8")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Record:
|
|
159
|
+
"""One message / object notification inside a batch event."""
|
|
160
|
+
|
|
161
|
+
id: Optional[str] = None
|
|
162
|
+
body: Any = None
|
|
163
|
+
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
164
|
+
source: Optional[str] = None
|
|
165
|
+
# Storage-only fields.
|
|
166
|
+
bucket: Optional[str] = None
|
|
167
|
+
key: Optional[str] = None
|
|
168
|
+
size: Optional[int] = None
|
|
169
|
+
action: Optional[str] = None # "created" | "deleted"
|
|
170
|
+
raw: Any = None
|
|
171
|
+
|
|
172
|
+
def text(self) -> str:
|
|
173
|
+
if isinstance(self.body, bytes):
|
|
174
|
+
return self.body.decode("utf-8")
|
|
175
|
+
if isinstance(self.body, (dict, list)):
|
|
176
|
+
return json.dumps(self.body)
|
|
177
|
+
return "" if self.body is None else str(self.body)
|
|
178
|
+
|
|
179
|
+
def json(self, default: Any = _MISSING) -> Any:
|
|
180
|
+
if isinstance(self.body, (dict, list)):
|
|
181
|
+
return self.body
|
|
182
|
+
try:
|
|
183
|
+
return _decode_json(self.text(), f"Record {self.id or ''} body".strip())
|
|
184
|
+
except BadRequest:
|
|
185
|
+
if default is not _MISSING:
|
|
186
|
+
return default
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class Event:
|
|
192
|
+
type: str
|
|
193
|
+
provider: str = "local"
|
|
194
|
+
function: str = ""
|
|
195
|
+
http: Optional[HttpRequest] = None
|
|
196
|
+
records: List[Record] = field(default_factory=list)
|
|
197
|
+
data: Any = None
|
|
198
|
+
time: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
199
|
+
source: Optional[str] = None
|
|
200
|
+
raw: Any = None
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def is_http(self) -> bool:
|
|
204
|
+
return self.type == EventType.HTTP
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def is_batch(self) -> bool:
|
|
208
|
+
return self.type in (EventType.QUEUE, EventType.TOPIC, EventType.STORAGE)
|
|
209
|
+
|
|
210
|
+
def json(self, default: Any = _MISSING) -> Any:
|
|
211
|
+
"""Best-effort JSON payload regardless of event type.
|
|
212
|
+
|
|
213
|
+
* HTTP: the parsed request body
|
|
214
|
+
* queue/topic/storage: the first record's parsed body
|
|
215
|
+
* schedule/invoke/workflow: ``data``
|
|
216
|
+
"""
|
|
217
|
+
if self.http is not None:
|
|
218
|
+
return self.http.json(default) if default is not _MISSING else self.http.json()
|
|
219
|
+
if self.records:
|
|
220
|
+
return self.records[0].json(default) if default is not _MISSING else self.records[0].json()
|
|
221
|
+
return self.data
|
|
222
|
+
|
|
223
|
+
def __iter__(self) -> Iterator[Record]:
|
|
224
|
+
return iter(self.records)
|
|
225
|
+
|
|
226
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
227
|
+
"""JSON-friendly summary, handy for logging."""
|
|
228
|
+
out: Dict[str, Any] = {
|
|
229
|
+
"type": self.type,
|
|
230
|
+
"provider": self.provider,
|
|
231
|
+
"function": self.function,
|
|
232
|
+
"time": self.time.isoformat(),
|
|
233
|
+
"source": self.source,
|
|
234
|
+
}
|
|
235
|
+
if self.http is not None:
|
|
236
|
+
out["http"] = {
|
|
237
|
+
"method": self.http.method,
|
|
238
|
+
"path": self.http.path,
|
|
239
|
+
"query": self.http.query,
|
|
240
|
+
"path_params": self.http.path_params,
|
|
241
|
+
}
|
|
242
|
+
if self.records:
|
|
243
|
+
out["records"] = len(self.records)
|
|
244
|
+
if self.data is not None:
|
|
245
|
+
out["data"] = self.data
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def parse_time(value: Any) -> datetime:
|
|
250
|
+
"""Parse the many timestamp shapes providers emit; fall back to *now*."""
|
|
251
|
+
if isinstance(value, datetime):
|
|
252
|
+
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
253
|
+
if isinstance(value, (int, float)):
|
|
254
|
+
seconds = value / 1000.0 if value > 1e11 else float(value)
|
|
255
|
+
return datetime.fromtimestamp(seconds, tz=timezone.utc)
|
|
256
|
+
if isinstance(value, str) and value:
|
|
257
|
+
text = value.strip()
|
|
258
|
+
if text.isdigit():
|
|
259
|
+
return parse_time(int(text))
|
|
260
|
+
if text.endswith("Z"):
|
|
261
|
+
text = text[:-1] + "+00:00"
|
|
262
|
+
try:
|
|
263
|
+
parsed = datetime.fromisoformat(text)
|
|
264
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
265
|
+
except ValueError:
|
|
266
|
+
pass
|
|
267
|
+
return datetime.now(timezone.utc)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Portable function-to-function invocation.
|
|
2
|
+
|
|
3
|
+
``invoke("billing", {"order": 1})`` calls another function of the *same
|
|
4
|
+
service* regardless of the cloud it runs on:
|
|
5
|
+
|
|
6
|
+
* ``PSKIT_ENDPOINT_<NAME>`` env var set -> HTTP POST to that URL (any provider)
|
|
7
|
+
* local (CLI / tests) -> in-process call through the dispatcher
|
|
8
|
+
* aws -> ``lambda:InvokeFunction`` via boto3
|
|
9
|
+
* gcp -> authenticated HTTPS call (ID token from metadata server)
|
|
10
|
+
|
|
11
|
+
Other providers need ``PSKIT_ENDPOINT_<NAME>`` in the caller's ``environment``
|
|
12
|
+
(for example the target's HTTP URL from ``pskit info``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import urllib.request
|
|
21
|
+
from typing import Any, Dict, Optional
|
|
22
|
+
|
|
23
|
+
from .adapters.base import envelope
|
|
24
|
+
from .manifest import load_manifest
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InvokeError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _env_key(name: str) -> str:
|
|
32
|
+
return "PSKIT_ENDPOINT_" + re.sub(r"[^A-Za-z0-9]", "_", name).upper()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def physical_name(function: str) -> str:
|
|
36
|
+
manifest = load_manifest()
|
|
37
|
+
spec = manifest.functions.get(function)
|
|
38
|
+
if spec is not None and spec.resource_name:
|
|
39
|
+
return spec.resource_name
|
|
40
|
+
service = manifest.service or os.environ.get("PSKIT_SERVICE", "")
|
|
41
|
+
stage = manifest.stage or os.environ.get("PSKIT_STAGE", "")
|
|
42
|
+
return "-".join(p for p in (service, stage, function) if p)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _http_post(url: str, payload: Any, timeout: float, headers: Optional[Dict[str, str]] = None) -> Any:
|
|
46
|
+
if not url.lower().startswith(("https://", "http://")):
|
|
47
|
+
raise InvokeError(f"invoke endpoint must be an http(s) URL, got {url!r}")
|
|
48
|
+
data = json.dumps(envelope("invoke", payload)).encode("utf-8")
|
|
49
|
+
req = urllib.request.Request(url, data=data, method="POST") # noqa: S310 - scheme checked above
|
|
50
|
+
req.add_header("content-type", "application/json")
|
|
51
|
+
for key, value in (headers or {}).items():
|
|
52
|
+
req.add_header(key, value)
|
|
53
|
+
try:
|
|
54
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - URL is operator supplied
|
|
55
|
+
raw = resp.read()
|
|
56
|
+
except Exception as exc: # pragma: no cover - network
|
|
57
|
+
raise InvokeError(f"HTTP invoke of {url} failed: {exc}") from exc
|
|
58
|
+
if not raw:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(raw)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return raw.decode("utf-8", "replace")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _gcp_id_token(audience: str) -> str: # pragma: no cover - needs GCP metadata server
|
|
67
|
+
url = (
|
|
68
|
+
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity"
|
|
69
|
+
f"?audience={audience}"
|
|
70
|
+
)
|
|
71
|
+
req = urllib.request.Request(url, headers={"Metadata-Flavor": "Google"}) # noqa: S310 - fixed http URL
|
|
72
|
+
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
|
|
73
|
+
return resp.read().decode("utf-8")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def invoke(function: str, payload: Any = None, *, asynchronous: bool = False, timeout: float = 30.0) -> Any:
|
|
77
|
+
"""Invoke another function of this service and return its result."""
|
|
78
|
+
override = os.environ.get(_env_key(function))
|
|
79
|
+
if override:
|
|
80
|
+
return _http_post(override, payload, timeout)
|
|
81
|
+
|
|
82
|
+
provider = os.environ.get("PSKIT_PROVIDER", "local")
|
|
83
|
+
if provider == "local":
|
|
84
|
+
from .context import Context
|
|
85
|
+
from .dispatch import execute
|
|
86
|
+
from .event import Event, EventType
|
|
87
|
+
|
|
88
|
+
event = Event(type=EventType.INVOKE, provider="local", function=function, data=payload)
|
|
89
|
+
return execute(event, Context(function=function), function)
|
|
90
|
+
|
|
91
|
+
if provider == "aws":
|
|
92
|
+
try:
|
|
93
|
+
import boto3 # type: ignore[import-not-found]
|
|
94
|
+
except ImportError as exc: # pragma: no cover
|
|
95
|
+
raise InvokeError("boto3 is required for invoke() on AWS") from exc
|
|
96
|
+
client = boto3.client("lambda")
|
|
97
|
+
resp = client.invoke(
|
|
98
|
+
FunctionName=physical_name(function),
|
|
99
|
+
InvocationType="Event" if asynchronous else "RequestResponse",
|
|
100
|
+
Payload=json.dumps(envelope("invoke", payload)).encode("utf-8"),
|
|
101
|
+
)
|
|
102
|
+
if asynchronous:
|
|
103
|
+
return None
|
|
104
|
+
body = resp["Payload"].read()
|
|
105
|
+
if resp.get("FunctionError"):
|
|
106
|
+
raise InvokeError(f"{function} failed: {body.decode('utf-8', 'replace')}")
|
|
107
|
+
return json.loads(body) if body else None
|
|
108
|
+
|
|
109
|
+
if provider == "gcp": # pragma: no cover - needs GCP
|
|
110
|
+
project = os.environ.get("PSKIT_GCP_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT", "")
|
|
111
|
+
region = os.environ.get("PSKIT_REGION", "")
|
|
112
|
+
url = f"https://{region}-{project}.cloudfunctions.net/{physical_name(function)}"
|
|
113
|
+
token = _gcp_id_token(url)
|
|
114
|
+
return _http_post(url, payload, timeout, {"authorization": f"Bearer {token}"})
|
|
115
|
+
|
|
116
|
+
raise InvokeError(
|
|
117
|
+
f"invoke() on provider {provider!r} needs the target URL in ${_env_key(function)}. "
|
|
118
|
+
"Give the target function an http event and set that URL in the caller's environment."
|
|
119
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Structured JSON logging that every provider's log pipeline can ingest.
|
|
2
|
+
|
|
3
|
+
Set ``PSKIT_LOG_FORMAT=text`` for human readable output (the local CLI does this)
|
|
4
|
+
and ``LOG_LEVEL`` / ``PSKIT_LOG_LEVEL`` to control verbosity.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Dict
|
|
15
|
+
|
|
16
|
+
_CONFIGURED = False
|
|
17
|
+
_RESERVED = set(vars(logging.LogRecord("", 0, "", 0, "", (), None))) | {"message", "asctime"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JsonFormatter(logging.Formatter):
|
|
21
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
22
|
+
payload: Dict[str, Any] = {
|
|
23
|
+
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
24
|
+
"level": record.levelname,
|
|
25
|
+
"logger": record.name,
|
|
26
|
+
"message": record.getMessage(),
|
|
27
|
+
}
|
|
28
|
+
for key, value in record.__dict__.items():
|
|
29
|
+
if key not in _RESERVED and not key.startswith("_"):
|
|
30
|
+
payload[key] = value
|
|
31
|
+
if record.exc_info:
|
|
32
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
33
|
+
return json.dumps(payload, default=str)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _configure() -> None:
|
|
37
|
+
global _CONFIGURED
|
|
38
|
+
if _CONFIGURED:
|
|
39
|
+
return
|
|
40
|
+
_CONFIGURED = True
|
|
41
|
+
logger = logging.getLogger("pskit")
|
|
42
|
+
level = os.environ.get("PSKIT_LOG_LEVEL") or os.environ.get("LOG_LEVEL") or "INFO"
|
|
43
|
+
logger.setLevel(level.upper() if isinstance(level, str) else level)
|
|
44
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
45
|
+
if os.environ.get("PSKIT_LOG_FORMAT", "json").lower() == "text":
|
|
46
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s"))
|
|
47
|
+
else:
|
|
48
|
+
handler.setFormatter(JsonFormatter())
|
|
49
|
+
logger.addHandler(handler)
|
|
50
|
+
logger.propagate = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _FieldsAdapter(logging.LoggerAdapter):
|
|
54
|
+
"""Like LoggerAdapter, but merges call-site ``extra=`` with the bound fields
|
|
55
|
+
(the stdlib adapter replaces them before Python 3.13)."""
|
|
56
|
+
|
|
57
|
+
def process(self, msg: Any, kwargs: Any) -> Any:
|
|
58
|
+
merged = {**(self.extra or {}), **(kwargs.get("extra") or {})}
|
|
59
|
+
# Keys such as "name" or "message" are reserved by LogRecord and would make
|
|
60
|
+
# logging raise KeyError - a log call must never crash a handler.
|
|
61
|
+
kwargs["extra"] = {(f"{k}_" if k in _RESERVED else k): v for k, v in merged.items()}
|
|
62
|
+
return msg, kwargs
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_logger(name: str = "pskit.function", **fields: Any) -> logging.LoggerAdapter:
|
|
66
|
+
_configure()
|
|
67
|
+
if not name.startswith("pskit"):
|
|
68
|
+
name = f"pskit.{name}"
|
|
69
|
+
return _FieldsAdapter(logging.getLogger(name), fields)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Reads ``pskit_manifest.json`` - the per-bundle description of every function.
|
|
2
|
+
|
|
3
|
+
The CLI writes this file into the root of each deployment artifact. It lets a
|
|
4
|
+
single shared bundle serve many functions (Azure Function Apps, the embedded
|
|
5
|
+
workflow engine, ...) without depending on per-function environment variables.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
MANIFEST_FILE = "pskit_manifest.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Route:
|
|
21
|
+
method: str
|
|
22
|
+
path: str
|
|
23
|
+
cors: Optional[Dict[str, Any]] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FunctionSpec:
|
|
28
|
+
name: str
|
|
29
|
+
handler: str
|
|
30
|
+
routes: List[Route] = field(default_factory=list)
|
|
31
|
+
timeout: Optional[int] = None
|
|
32
|
+
memory: Optional[int] = None
|
|
33
|
+
resource_name: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BundleManifest:
|
|
38
|
+
service: str = ""
|
|
39
|
+
stage: str = ""
|
|
40
|
+
provider: str = "local"
|
|
41
|
+
region: str = ""
|
|
42
|
+
functions: Dict[str, FunctionSpec] = field(default_factory=dict)
|
|
43
|
+
workflows: Dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, raw: Dict[str, Any]) -> BundleManifest:
|
|
47
|
+
functions = {}
|
|
48
|
+
for name, spec in (raw.get("functions") or {}).items():
|
|
49
|
+
routes = [Route(r["method"], r["path"], r.get("cors")) for r in spec.get("routes", [])]
|
|
50
|
+
functions[name] = FunctionSpec(
|
|
51
|
+
name=name,
|
|
52
|
+
handler=spec["handler"],
|
|
53
|
+
routes=routes,
|
|
54
|
+
timeout=spec.get("timeout"),
|
|
55
|
+
memory=spec.get("memory"),
|
|
56
|
+
resource_name=spec.get("resource_name"),
|
|
57
|
+
)
|
|
58
|
+
return cls(
|
|
59
|
+
service=raw.get("service", ""),
|
|
60
|
+
stage=raw.get("stage", ""),
|
|
61
|
+
provider=raw.get("provider", "local"),
|
|
62
|
+
region=raw.get("region", ""),
|
|
63
|
+
functions=functions,
|
|
64
|
+
workflows=raw.get("workflows") or {},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_CACHE: Optional[BundleManifest] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _candidates() -> List[Path]:
|
|
72
|
+
here = Path(__file__).resolve()
|
|
73
|
+
paths = []
|
|
74
|
+
if os.environ.get("PSKIT_MANIFEST"):
|
|
75
|
+
paths.append(Path(os.environ["PSKIT_MANIFEST"]))
|
|
76
|
+
paths.append(here.parents[2] / MANIFEST_FILE) # <bundle>/py_serverless_kit/runtime/manifest.py
|
|
77
|
+
for env in ("LAMBDA_TASK_ROOT", "FC_FUNC_CODE_PATH", "RUNTIME_CODE_ROOT"):
|
|
78
|
+
if os.environ.get(env):
|
|
79
|
+
paths.append(Path(os.environ[env]) / MANIFEST_FILE)
|
|
80
|
+
paths.append(Path.cwd() / MANIFEST_FILE)
|
|
81
|
+
return paths
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_manifest(reload: bool = False) -> BundleManifest:
|
|
85
|
+
global _CACHE
|
|
86
|
+
if _CACHE is not None and not reload:
|
|
87
|
+
return _CACHE
|
|
88
|
+
for path in _candidates():
|
|
89
|
+
if path.is_file():
|
|
90
|
+
_CACHE = BundleManifest.from_dict(json.loads(path.read_text("utf-8")))
|
|
91
|
+
return _CACHE
|
|
92
|
+
_CACHE = BundleManifest(
|
|
93
|
+
service=os.environ.get("PSKIT_SERVICE", ""),
|
|
94
|
+
stage=os.environ.get("PSKIT_STAGE", ""),
|
|
95
|
+
provider=os.environ.get("PSKIT_PROVIDER", "local"),
|
|
96
|
+
)
|
|
97
|
+
return _CACHE
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def set_manifest(manifest: Optional[BundleManifest]) -> None:
|
|
101
|
+
"""Install a manifest programmatically (local runner and tests)."""
|
|
102
|
+
global _CACHE
|
|
103
|
+
_CACHE = manifest
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_function(name: Optional[str] = None) -> FunctionSpec:
|
|
107
|
+
"""Find the spec for ``name`` (default: ``$PSKIT_FUNCTION``)."""
|
|
108
|
+
manifest = load_manifest()
|
|
109
|
+
name = name or os.environ.get("PSKIT_FUNCTION", "")
|
|
110
|
+
if name and name in manifest.functions:
|
|
111
|
+
return manifest.functions[name]
|
|
112
|
+
handler = os.environ.get("PSKIT_HANDLER")
|
|
113
|
+
if handler:
|
|
114
|
+
return FunctionSpec(name=name or handler, handler=handler)
|
|
115
|
+
if len(manifest.functions) == 1 and not name:
|
|
116
|
+
return next(iter(manifest.functions.values()))
|
|
117
|
+
known = ", ".join(sorted(manifest.functions)) or "<none>"
|
|
118
|
+
raise LookupError(
|
|
119
|
+
f"Cannot resolve function {name!r}. Set PSKIT_FUNCTION/PSKIT_HANDLER or check "
|
|
120
|
+
f"{MANIFEST_FILE}. Known functions: {known}"
|
|
121
|
+
)
|