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,457 @@
|
|
|
1
|
+
"""Typed model of ``pskit.yml``.
|
|
2
|
+
|
|
3
|
+
The model is intentionally provider-neutral. Anything that only makes sense
|
|
4
|
+
for one cloud goes under an ``options:`` mapping, which the matching provider
|
|
5
|
+
renderer reads and the others ignore.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, List, Literal, Optional, Type, Union
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
PROVIDERS = (
|
|
16
|
+
"aws",
|
|
17
|
+
"azure",
|
|
18
|
+
"gcp",
|
|
19
|
+
"alibaba",
|
|
20
|
+
"oracle",
|
|
21
|
+
"ibm",
|
|
22
|
+
"digitalocean",
|
|
23
|
+
"tencent",
|
|
24
|
+
"huawei",
|
|
25
|
+
"cloudflare",
|
|
26
|
+
)
|
|
27
|
+
EVENT_TYPES = ("http", "schedule", "queue", "storage", "topic")
|
|
28
|
+
HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "ANY")
|
|
29
|
+
_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,62}$")
|
|
30
|
+
_HANDLER_RE = re.compile(r"^[A-Za-z_][\w]*(\.[A-Za-z_][\w]*)*[.:][A-Za-z_][\w]*$")
|
|
31
|
+
_RATE_RE = re.compile(r"^\s*(\d+)\s+(minute|minutes|hour|hours|day|days)\s*$", re.I)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Model(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------ events
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CorsConfig(_Model):
|
|
42
|
+
origins: List[str] = Field(default_factory=lambda: ["*"])
|
|
43
|
+
methods: Optional[List[str]] = None
|
|
44
|
+
headers: Optional[List[str]] = None
|
|
45
|
+
expose_headers: List[str] = Field(default_factory=list)
|
|
46
|
+
allow_credentials: bool = False
|
|
47
|
+
max_age: int = 600
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HttpEvent(_Model):
|
|
51
|
+
path: str = "/"
|
|
52
|
+
method: str = "GET"
|
|
53
|
+
cors: Union[bool, CorsConfig] = False
|
|
54
|
+
auth: Literal["none", "iam", "jwt", "key"] = "none"
|
|
55
|
+
authorizer: Dict[str, Any] = Field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
@field_validator("method")
|
|
58
|
+
@classmethod
|
|
59
|
+
def _method(cls, v: str) -> str:
|
|
60
|
+
v = v.upper()
|
|
61
|
+
if v == "*":
|
|
62
|
+
v = "ANY"
|
|
63
|
+
if v not in HTTP_METHODS:
|
|
64
|
+
raise ValueError(f"method must be one of {', '.join(HTTP_METHODS)}")
|
|
65
|
+
return v
|
|
66
|
+
|
|
67
|
+
@field_validator("path")
|
|
68
|
+
@classmethod
|
|
69
|
+
def _path(cls, v: str) -> str:
|
|
70
|
+
v = "/" + v.strip().lstrip("/")
|
|
71
|
+
if "{" in v and not re.fullmatch(r"(/([\w.~-]+|\{[A-Za-z_]\w*\}))*(/\{[A-Za-z_]\w*\+\})?/?", v):
|
|
72
|
+
raise ValueError(f"invalid path template {v!r}; use /users/{{id}} or /files/{{path+}}")
|
|
73
|
+
return v
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def cors_config(self) -> Optional[CorsConfig]:
|
|
77
|
+
if self.cors is True:
|
|
78
|
+
return CorsConfig()
|
|
79
|
+
if isinstance(self.cors, CorsConfig):
|
|
80
|
+
return self.cors
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ScheduleEvent(_Model):
|
|
85
|
+
rate: Optional[str] = None
|
|
86
|
+
cron: Optional[str] = None
|
|
87
|
+
input: Any = None
|
|
88
|
+
enabled: bool = True
|
|
89
|
+
timezone: str = "UTC"
|
|
90
|
+
name: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
@model_validator(mode="after")
|
|
93
|
+
def _one_of(self) -> ScheduleEvent:
|
|
94
|
+
if bool(self.rate) == bool(self.cron):
|
|
95
|
+
raise ValueError("schedule needs exactly one of `rate` (e.g. '5 minutes') or `cron` (5 fields)")
|
|
96
|
+
if self.rate and not _RATE_RE.match(self.rate):
|
|
97
|
+
raise ValueError("rate must look like '<n> minutes|hours|days'")
|
|
98
|
+
if self.cron and len(self.cron.split()) != 5:
|
|
99
|
+
raise ValueError("cron must be a standard 5-field expression: 'min hour dom month dow'")
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class QueueEvent(_Model):
|
|
104
|
+
name: str
|
|
105
|
+
existing: bool = False
|
|
106
|
+
batch_size: int = Field(10, ge=1, le=10000)
|
|
107
|
+
batch_window: int = Field(0, ge=0, le=300, description="seconds to wait to fill a batch")
|
|
108
|
+
visibility_timeout: Optional[int] = None
|
|
109
|
+
dead_letter: Optional[str] = Field(None, description="queue name receiving messages after max_receive")
|
|
110
|
+
max_receive: int = 5
|
|
111
|
+
fifo: bool = False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class StorageEvent(_Model):
|
|
115
|
+
bucket: str
|
|
116
|
+
existing: bool = False
|
|
117
|
+
events: List[Literal["created", "deleted"]] = Field(default=["created"])
|
|
118
|
+
prefix: Optional[str] = None
|
|
119
|
+
suffix: Optional[str] = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TopicEvent(_Model):
|
|
123
|
+
name: str
|
|
124
|
+
existing: bool = False
|
|
125
|
+
filter: Optional[Dict[str, Any]] = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
EventConfig = Union[HttpEvent, ScheduleEvent, QueueEvent, StorageEvent, TopicEvent]
|
|
129
|
+
_EVENT_MODELS: Dict[str, Type[BaseModel]] = {
|
|
130
|
+
"http": HttpEvent,
|
|
131
|
+
"schedule": ScheduleEvent,
|
|
132
|
+
"queue": QueueEvent,
|
|
133
|
+
"storage": StorageEvent,
|
|
134
|
+
"topic": TopicEvent,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class EventSpec(_Model):
|
|
139
|
+
"""One trigger. Written in YAML as a single-key mapping, e.g. ``- http: {path: /x}``."""
|
|
140
|
+
|
|
141
|
+
type: Literal["http", "schedule", "queue", "storage", "topic"]
|
|
142
|
+
config: Any
|
|
143
|
+
|
|
144
|
+
@model_validator(mode="before")
|
|
145
|
+
@classmethod
|
|
146
|
+
def _from_yaml(cls, raw: Any) -> Any:
|
|
147
|
+
if isinstance(raw, dict) and set(raw) == {"type", "config"}:
|
|
148
|
+
return raw
|
|
149
|
+
if not isinstance(raw, dict) or len(raw) != 1:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
"each event must be a single-key mapping such as `- http: {path: /hello}`; got " + repr(raw)
|
|
152
|
+
)
|
|
153
|
+
((kind, body),) = raw.items()
|
|
154
|
+
if kind not in _EVENT_MODELS:
|
|
155
|
+
raise ValueError(f"unknown event type {kind!r}; expected one of {', '.join(EVENT_TYPES)}")
|
|
156
|
+
if kind == "http" and isinstance(body, str): # shorthand: `- http: GET /users/{id}`
|
|
157
|
+
method, _, path = body.strip().partition(" ")
|
|
158
|
+
body = {"method": method, "path": path or "/"} if path else {"path": method}
|
|
159
|
+
if kind == "schedule" and isinstance(body, str): # shorthand: `- schedule: 5 minutes`
|
|
160
|
+
body = {"rate": body} if _RATE_RE.match(body) else {"cron": body}
|
|
161
|
+
if kind in ("queue", "topic") and isinstance(body, str):
|
|
162
|
+
body = {"name": body}
|
|
163
|
+
if kind == "storage" and isinstance(body, str):
|
|
164
|
+
body = {"bucket": body}
|
|
165
|
+
return {"type": kind, "config": _EVENT_MODELS[kind].model_validate(body or {})}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------ function / provider
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class IamStatement(_Model):
|
|
172
|
+
effect: Literal["Allow", "Deny"] = "Allow"
|
|
173
|
+
actions: List[str]
|
|
174
|
+
resources: List[str] = Field(default_factory=lambda: ["*"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class IamConfig(_Model):
|
|
178
|
+
statements: List[IamStatement] = Field(default_factory=list)
|
|
179
|
+
role: Optional[str] = Field(None, description="use an existing role / service account instead of creating one")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class FunctionConfig(_Model):
|
|
183
|
+
handler: str
|
|
184
|
+
description: str = ""
|
|
185
|
+
runtime: Optional[str] = None
|
|
186
|
+
memory: Optional[int] = Field(None, ge=64, le=32768)
|
|
187
|
+
timeout: Optional[int] = Field(None, ge=1, le=3600)
|
|
188
|
+
environment: Dict[str, str] = Field(default_factory=dict)
|
|
189
|
+
tags: Dict[str, str] = Field(default_factory=dict)
|
|
190
|
+
events: List[EventSpec] = Field(default_factory=list)
|
|
191
|
+
concurrency: Optional[int] = Field(None, ge=0, description="reserved / max instances")
|
|
192
|
+
architecture: Optional[Literal["x86_64", "arm64"]] = None
|
|
193
|
+
iam: IamConfig = Field(default_factory=IamConfig)
|
|
194
|
+
options: Dict[str, Any] = Field(default_factory=dict)
|
|
195
|
+
|
|
196
|
+
@field_validator("handler")
|
|
197
|
+
@classmethod
|
|
198
|
+
def _handler(cls, v: str) -> str:
|
|
199
|
+
if not _HANDLER_RE.match(v):
|
|
200
|
+
raise ValueError(f"handler {v!r} must be 'package.module.function' (or 'module:function')")
|
|
201
|
+
return v
|
|
202
|
+
|
|
203
|
+
@field_validator("environment", mode="before")
|
|
204
|
+
@classmethod
|
|
205
|
+
def _env(cls, v: Any) -> Any:
|
|
206
|
+
return {str(k): "" if val is None else str(val) for k, val in (v or {}).items()}
|
|
207
|
+
|
|
208
|
+
def events_of(self, kind: str) -> List[Any]:
|
|
209
|
+
return [e.config for e in self.events if e.type == kind]
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def kind(self) -> str:
|
|
213
|
+
"""Human label used by the dashboard: http / scheduled / event-driven / invoke-only."""
|
|
214
|
+
types = {e.type for e in self.events}
|
|
215
|
+
if not types:
|
|
216
|
+
return "invoke-only"
|
|
217
|
+
if types == {"http"}:
|
|
218
|
+
return "http"
|
|
219
|
+
if types == {"schedule"}:
|
|
220
|
+
return "scheduled"
|
|
221
|
+
if "http" in types:
|
|
222
|
+
return "http+event"
|
|
223
|
+
return "event-driven"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class StateBackend(_Model):
|
|
227
|
+
backend: Optional[str] = Field(None, description="terraform backend type, e.g. s3, gcs, azurerm, http")
|
|
228
|
+
config: Dict[str, Any] = Field(default_factory=dict)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TerraformSettings(_Model):
|
|
232
|
+
binary: Optional[str] = Field(None, description="terraform or tofu; default autodetect")
|
|
233
|
+
required_version: str = ">= 1.5.0"
|
|
234
|
+
provider_version: Optional[str] = None
|
|
235
|
+
extra: Dict[str, Any] = Field(
|
|
236
|
+
default_factory=dict, description="raw Terraform JSON deep-merged into the generated config"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class ProviderConfig(_Model):
|
|
241
|
+
# Built-in names are listed in PROVIDERS; third-party providers registered through the
|
|
242
|
+
# `py_serverless_kit.providers` entry point are accepted too (checked by the loader).
|
|
243
|
+
name: str = Field(pattern=r"^[a-z][a-z0-9_-]{1,31}$")
|
|
244
|
+
region: Optional[str] = None
|
|
245
|
+
stage: str = "dev"
|
|
246
|
+
runtime: str = "python3.12"
|
|
247
|
+
memory: int = Field(256, ge=64, le=32768)
|
|
248
|
+
timeout: int = Field(30, ge=1, le=3600)
|
|
249
|
+
architecture: Literal["x86_64", "arm64"] = "x86_64"
|
|
250
|
+
environment: Dict[str, str] = Field(default_factory=dict)
|
|
251
|
+
tags: Dict[str, str] = Field(default_factory=dict)
|
|
252
|
+
iam: IamConfig = Field(default_factory=IamConfig)
|
|
253
|
+
log_retention_days: Optional[int] = 14
|
|
254
|
+
options: Dict[str, Any] = Field(default_factory=dict)
|
|
255
|
+
state: StateBackend = Field(default_factory=StateBackend)
|
|
256
|
+
terraform: TerraformSettings = Field(default_factory=TerraformSettings)
|
|
257
|
+
|
|
258
|
+
@field_validator("environment", mode="before")
|
|
259
|
+
@classmethod
|
|
260
|
+
def _env(cls, v: Any) -> Any:
|
|
261
|
+
return {str(k): "" if val is None else str(val) for k, val in (v or {}).items()}
|
|
262
|
+
|
|
263
|
+
@field_validator("runtime")
|
|
264
|
+
@classmethod
|
|
265
|
+
def _runtime(cls, v: str) -> str:
|
|
266
|
+
if not re.fullmatch(r"python3\.(9|1[0-4])", v):
|
|
267
|
+
raise ValueError("runtime must be python3.9 .. python3.14 (provider support varies)")
|
|
268
|
+
return v
|
|
269
|
+
|
|
270
|
+
@field_validator("stage")
|
|
271
|
+
@classmethod
|
|
272
|
+
def _stage(cls, v: str) -> str:
|
|
273
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]{0,15}", v):
|
|
274
|
+
raise ValueError("stage must be lowercase alphanumeric/dash, max 16 chars, e.g. dev, prod")
|
|
275
|
+
return v
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class PackageConfig(_Model):
|
|
279
|
+
source: str = "."
|
|
280
|
+
include: List[str] = Field(default_factory=lambda: ["**/*"])
|
|
281
|
+
exclude: List[str] = Field(default_factory=list)
|
|
282
|
+
requirements: Optional[str] = "requirements.txt"
|
|
283
|
+
pip_args: List[str] = Field(default_factory=list)
|
|
284
|
+
vendor: Optional[bool] = Field(None, description="force (true) or skip (false) pip install into the bundle")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------------------------------ workflows
|
|
288
|
+
|
|
289
|
+
StateType = Literal["task", "choice", "wait", "pass", "parallel", "map", "succeed", "fail"]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class RetryRule(_Model):
|
|
293
|
+
errors: List[str] = Field(default_factory=lambda: ["*"])
|
|
294
|
+
max_attempts: int = Field(3, ge=0, le=99)
|
|
295
|
+
interval_seconds: float = Field(1, ge=0)
|
|
296
|
+
backoff_rate: float = Field(2.0, ge=1)
|
|
297
|
+
max_delay_seconds: Optional[float] = None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class CatchRule(_Model):
|
|
301
|
+
errors: List[str] = Field(default_factory=lambda: ["*"])
|
|
302
|
+
next: str
|
|
303
|
+
result_path: Optional[str] = "$.error"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class WorkflowState(_Model):
|
|
307
|
+
model_config = ConfigDict(extra="forbid")
|
|
308
|
+
|
|
309
|
+
type: StateType
|
|
310
|
+
function: Optional[str] = None
|
|
311
|
+
next: Optional[str] = None
|
|
312
|
+
end: bool = False
|
|
313
|
+
comment: Optional[str] = None
|
|
314
|
+
input_path: Optional[str] = None
|
|
315
|
+
result_path: Optional[str] = "$"
|
|
316
|
+
retry: List[RetryRule] = Field(default_factory=list)
|
|
317
|
+
catch: List[CatchRule] = Field(default_factory=list)
|
|
318
|
+
timeout_seconds: Optional[int] = None
|
|
319
|
+
# choice
|
|
320
|
+
choices: List[Dict[str, Any]] = Field(default_factory=list)
|
|
321
|
+
default: Optional[str] = None
|
|
322
|
+
# wait
|
|
323
|
+
seconds: Optional[float] = None
|
|
324
|
+
seconds_path: Optional[str] = None
|
|
325
|
+
# pass
|
|
326
|
+
result: Any = None
|
|
327
|
+
# parallel / map
|
|
328
|
+
branches: List[WorkflowDefinition] = Field(default_factory=list)
|
|
329
|
+
items_path: Optional[str] = None
|
|
330
|
+
iterator: Optional[WorkflowDefinition] = None
|
|
331
|
+
max_concurrency: Optional[int] = None
|
|
332
|
+
# fail
|
|
333
|
+
error: Optional[str] = None
|
|
334
|
+
cause: Optional[str] = None
|
|
335
|
+
|
|
336
|
+
@model_validator(mode="after")
|
|
337
|
+
def _shape(self) -> WorkflowState:
|
|
338
|
+
t = self.type
|
|
339
|
+
if t == "task" and not self.function:
|
|
340
|
+
raise ValueError("task state needs `function`")
|
|
341
|
+
if t == "choice" and not self.choices:
|
|
342
|
+
raise ValueError("choice state needs `choices`")
|
|
343
|
+
if t == "wait" and self.seconds is None and not self.seconds_path:
|
|
344
|
+
raise ValueError("wait state needs `seconds` or `seconds_path`")
|
|
345
|
+
if t == "parallel" and not self.branches:
|
|
346
|
+
raise ValueError("parallel state needs `branches`")
|
|
347
|
+
if t == "map" and self.iterator is None:
|
|
348
|
+
raise ValueError("map state needs `iterator`")
|
|
349
|
+
if t in ("task", "wait", "pass", "parallel", "map") and not (self.next or self.end):
|
|
350
|
+
self.end = True
|
|
351
|
+
return self
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class WorkflowDefinition(_Model):
|
|
355
|
+
start_at: str
|
|
356
|
+
states: Dict[str, WorkflowState]
|
|
357
|
+
|
|
358
|
+
@model_validator(mode="after")
|
|
359
|
+
def _graph(self) -> WorkflowDefinition:
|
|
360
|
+
names = set(self.states)
|
|
361
|
+
if self.start_at not in names:
|
|
362
|
+
raise ValueError(f"start_at {self.start_at!r} is not a defined state")
|
|
363
|
+
for name, st in self.states.items():
|
|
364
|
+
targets = [st.next, st.default] + [c.next for c in st.catch] + [r.get("next") for r in st.choices]
|
|
365
|
+
for target in targets:
|
|
366
|
+
if target and target not in names:
|
|
367
|
+
raise ValueError(f"state {name!r} points to unknown state {target!r}")
|
|
368
|
+
for rule in st.choices:
|
|
369
|
+
if "next" not in rule:
|
|
370
|
+
raise ValueError(f"choice rule in {name!r} has no `next`: {rule}")
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
def task_functions(self) -> List[str]:
|
|
374
|
+
found: List[str] = []
|
|
375
|
+
for st in self.states.values():
|
|
376
|
+
if st.function:
|
|
377
|
+
found.append(st.function)
|
|
378
|
+
for branch in st.branches:
|
|
379
|
+
found.extend(branch.task_functions())
|
|
380
|
+
if st.iterator is not None:
|
|
381
|
+
found.extend(st.iterator.task_functions())
|
|
382
|
+
return found
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class WorkflowConfig(WorkflowDefinition):
|
|
386
|
+
description: str = ""
|
|
387
|
+
engine: Literal["auto", "native", "embedded"] = "auto"
|
|
388
|
+
timeout: Optional[int] = None
|
|
389
|
+
options: Dict[str, Any] = Field(default_factory=dict)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
WorkflowState.model_rebuild()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ------------------------------------------------------------------ root
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class ServiceConfig(_Model):
|
|
399
|
+
service: str
|
|
400
|
+
provider: ProviderConfig
|
|
401
|
+
package: PackageConfig = Field(default_factory=PackageConfig)
|
|
402
|
+
functions: Dict[str, FunctionConfig] = Field(default_factory=dict)
|
|
403
|
+
workflows: Dict[str, WorkflowConfig] = Field(default_factory=dict)
|
|
404
|
+
hooks: Dict[str, List[str]] = Field(default_factory=dict)
|
|
405
|
+
custom: Dict[str, Any] = Field(default_factory=dict, description="free-form values for ${self:custom.x}")
|
|
406
|
+
|
|
407
|
+
@field_validator("service")
|
|
408
|
+
@classmethod
|
|
409
|
+
def _service(cls, v: str) -> str:
|
|
410
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]{1,31}", v):
|
|
411
|
+
raise ValueError("service must be 2-32 chars of lowercase letters, digits and dashes")
|
|
412
|
+
return v
|
|
413
|
+
|
|
414
|
+
@field_validator("functions", "workflows")
|
|
415
|
+
@classmethod
|
|
416
|
+
def _names(cls, v: Dict[str, Any]) -> Dict[str, Any]:
|
|
417
|
+
for name in v:
|
|
418
|
+
if not _NAME_RE.match(name):
|
|
419
|
+
raise ValueError(f"name {name!r} must start with a letter and use letters, digits, - or _")
|
|
420
|
+
return v
|
|
421
|
+
|
|
422
|
+
@field_validator("hooks")
|
|
423
|
+
@classmethod
|
|
424
|
+
def _hooks(cls, v: Dict[str, List[str]]) -> Dict[str, List[str]]:
|
|
425
|
+
allowed = {"before_package", "after_package", "before_deploy", "after_deploy", "before_remove", "after_remove"}
|
|
426
|
+
unknown = set(v) - allowed
|
|
427
|
+
if unknown:
|
|
428
|
+
raise ValueError(f"unknown hooks {sorted(unknown)}; allowed: {sorted(allowed)}")
|
|
429
|
+
return v
|
|
430
|
+
|
|
431
|
+
@model_validator(mode="after")
|
|
432
|
+
def _cross_refs(self) -> ServiceConfig:
|
|
433
|
+
for wf_name, wf in self.workflows.items():
|
|
434
|
+
for fn in wf.task_functions():
|
|
435
|
+
if fn not in self.functions:
|
|
436
|
+
raise ValueError(f"workflow {wf_name!r} references unknown function {fn!r}")
|
|
437
|
+
seen: Dict[str, str] = {}
|
|
438
|
+
for fn_name, fn_cfg in self.functions.items():
|
|
439
|
+
for ev in fn_cfg.events_of("http"):
|
|
440
|
+
key = f"{ev.method} {ev.path.rstrip('/') or '/'}"
|
|
441
|
+
if key in seen:
|
|
442
|
+
raise ValueError(f"route {key} is used by both {seen[key]!r} and {fn_name!r}")
|
|
443
|
+
seen[key] = fn_name
|
|
444
|
+
return self
|
|
445
|
+
|
|
446
|
+
# convenience -----------------------------------------------------
|
|
447
|
+
def memory_of(self, fn: FunctionConfig) -> int:
|
|
448
|
+
return fn.memory or self.provider.memory
|
|
449
|
+
|
|
450
|
+
def timeout_of(self, fn: FunctionConfig) -> int:
|
|
451
|
+
return fn.timeout or self.provider.timeout
|
|
452
|
+
|
|
453
|
+
def runtime_of(self, fn: FunctionConfig) -> str:
|
|
454
|
+
return fn.runtime or self.provider.runtime
|
|
455
|
+
|
|
456
|
+
def architecture_of(self, fn: FunctionConfig) -> str:
|
|
457
|
+
return fn.architecture or self.provider.architecture
|
|
File without changes
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""High level package / deploy / remove orchestration used by the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from ..packaging.builder import build_artifact
|
|
13
|
+
from ..providers.base import Artifact, Issue, RenderContext
|
|
14
|
+
from ..terraform.runner import TerraformRunner
|
|
15
|
+
from .describe import RECORD_FILE, build_record
|
|
16
|
+
from .project import Project
|
|
17
|
+
|
|
18
|
+
Echo = Callable[[str], None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeployError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PackageResult:
|
|
27
|
+
artifact: Artifact
|
|
28
|
+
tf_file: Path
|
|
29
|
+
resource_count: int
|
|
30
|
+
warnings: List[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Deployer:
|
|
34
|
+
def __init__(self, project: Project, echo: Echo = print, runner: Optional[TerraformRunner] = None) -> None:
|
|
35
|
+
self.project = project
|
|
36
|
+
self.echo = echo
|
|
37
|
+
self._runner = runner
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------- helpers
|
|
40
|
+
@property
|
|
41
|
+
def runner(self) -> TerraformRunner:
|
|
42
|
+
if self._runner is None:
|
|
43
|
+
tf = self.project.config.provider.terraform
|
|
44
|
+
self._runner = TerraformRunner(self.project.work_dir, binary=tf.binary, echo=self.echo)
|
|
45
|
+
return self._runner
|
|
46
|
+
|
|
47
|
+
def check(self) -> List[Issue]:
|
|
48
|
+
issues = self.project.validate()
|
|
49
|
+
errors = [i for i in issues if i.level == "error"]
|
|
50
|
+
for issue in issues:
|
|
51
|
+
self.echo(f" {issue}")
|
|
52
|
+
if errors:
|
|
53
|
+
raise DeployError(f"{len(errors)} configuration error(s); fix them and retry")
|
|
54
|
+
return issues
|
|
55
|
+
|
|
56
|
+
def run_hook(self, name: str) -> None:
|
|
57
|
+
commands = self.project.config.hooks.get(name) or []
|
|
58
|
+
for cmd in commands:
|
|
59
|
+
self.echo(f" hook {name}: {cmd}")
|
|
60
|
+
env = dict(os.environ)
|
|
61
|
+
env.update(
|
|
62
|
+
{
|
|
63
|
+
"PSKIT_SERVICE": self.project.config.service,
|
|
64
|
+
"PSKIT_STAGE": self.project.config.provider.stage,
|
|
65
|
+
"PSKIT_PROVIDER": self.project.config.provider.name,
|
|
66
|
+
"PSKIT_WORK_DIR": str(self.project.work_dir),
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
proc = subprocess.run(cmd, shell=True, cwd=self.project.root, env=env) # noqa: S602 - user hook
|
|
70
|
+
if proc.returncode != 0:
|
|
71
|
+
raise DeployError(f"hook {name} failed ({proc.returncode}): {cmd}")
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------- steps
|
|
74
|
+
def package(self, *, skip_deps: bool = False) -> PackageResult:
|
|
75
|
+
project = self.project
|
|
76
|
+
self.check()
|
|
77
|
+
project.work_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
_write_gitignore(project.root / ".pskit")
|
|
79
|
+
self.run_hook("before_package")
|
|
80
|
+
self.echo(
|
|
81
|
+
f"Packaging {project.config.service} for {project.provider.display_name} ({project.config.provider.stage})"
|
|
82
|
+
)
|
|
83
|
+
artifact = build_artifact(project, skip_deps=skip_deps, log=self.echo)
|
|
84
|
+
ctx = RenderContext(
|
|
85
|
+
config=project.config,
|
|
86
|
+
provider=project.provider,
|
|
87
|
+
root=project.root,
|
|
88
|
+
work_dir=project.work_dir,
|
|
89
|
+
artifact=artifact,
|
|
90
|
+
region=project.region,
|
|
91
|
+
workflow_engines=dict(project.engines),
|
|
92
|
+
)
|
|
93
|
+
doc = project.provider.render(ctx)
|
|
94
|
+
tf_file = project.work_dir / "main.tf.json"
|
|
95
|
+
tf_file.write_text(doc.to_json(), "utf-8")
|
|
96
|
+
warnings = list(getattr(doc, "warnings", []))
|
|
97
|
+
for w in warnings:
|
|
98
|
+
self.echo(f" WARNING: {w}")
|
|
99
|
+
self.echo(f" terraform: {doc.count()} resource(s) -> {tf_file.relative_to(project.root)}")
|
|
100
|
+
self.run_hook("after_package")
|
|
101
|
+
return PackageResult(artifact=artifact, tf_file=tf_file, resource_count=doc.count(), warnings=warnings)
|
|
102
|
+
|
|
103
|
+
def plan(self, *, skip_deps: bool = False, destroy: bool = False) -> bool:
|
|
104
|
+
self.package(skip_deps=skip_deps)
|
|
105
|
+
self.runner.init()
|
|
106
|
+
return self.runner.plan(destroy=destroy, out=None)
|
|
107
|
+
|
|
108
|
+
def deploy(self, *, skip_deps: bool = False, auto_approve: bool = True, upgrade: bool = False) -> Dict[str, Any]:
|
|
109
|
+
result = self.package(skip_deps=skip_deps)
|
|
110
|
+
self.run_hook("before_deploy")
|
|
111
|
+
self.echo("Running terraform init")
|
|
112
|
+
self.runner.init(upgrade=upgrade)
|
|
113
|
+
if auto_approve:
|
|
114
|
+
self.echo("Running terraform apply")
|
|
115
|
+
self.runner.apply(auto_approve=True)
|
|
116
|
+
else:
|
|
117
|
+
changed = self.runner.plan(out="tfplan")
|
|
118
|
+
if not changed:
|
|
119
|
+
self.echo("No changes.")
|
|
120
|
+
else:
|
|
121
|
+
self.runner.apply(plan_file="tfplan")
|
|
122
|
+
outputs = self.runner.output()
|
|
123
|
+
record = build_record(
|
|
124
|
+
self.project,
|
|
125
|
+
outputs,
|
|
126
|
+
artifact={
|
|
127
|
+
"sha256": result.artifact.sha256,
|
|
128
|
+
"size": result.artifact.size,
|
|
129
|
+
"files": result.artifact.file_count,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
self.save_record(record)
|
|
133
|
+
self.run_hook("after_deploy")
|
|
134
|
+
return record
|
|
135
|
+
|
|
136
|
+
def remove(self, *, auto_approve: bool = True) -> None:
|
|
137
|
+
project = self.project
|
|
138
|
+
if not (project.work_dir / "main.tf.json").is_file():
|
|
139
|
+
# Re-render so terraform knows the provider configuration.
|
|
140
|
+
self.package(skip_deps=True)
|
|
141
|
+
self.run_hook("before_remove")
|
|
142
|
+
self.runner.init()
|
|
143
|
+
self.runner.destroy(auto_approve=auto_approve)
|
|
144
|
+
record_path = project.work_dir / RECORD_FILE
|
|
145
|
+
if record_path.is_file():
|
|
146
|
+
record = json.loads(record_path.read_text("utf-8"))
|
|
147
|
+
record["status"] = "removed"
|
|
148
|
+
record_path.write_text(json.dumps(record, indent=2), "utf-8")
|
|
149
|
+
self.run_hook("after_remove")
|
|
150
|
+
|
|
151
|
+
def refresh_info(self) -> Dict[str, Any]:
|
|
152
|
+
outputs = self.runner.output()
|
|
153
|
+
previous = self.load_record() or {}
|
|
154
|
+
record = build_record(self.project, outputs, artifact=previous.get("artifact"))
|
|
155
|
+
self.save_record(record)
|
|
156
|
+
return record
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------- record
|
|
159
|
+
def save_record(self, record: Dict[str, Any]) -> Path:
|
|
160
|
+
path = self.project.work_dir / RECORD_FILE
|
|
161
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
path.write_text(json.dumps(record, indent=2, default=str), "utf-8")
|
|
163
|
+
return path
|
|
164
|
+
|
|
165
|
+
def load_record(self) -> Optional[Dict[str, Any]]:
|
|
166
|
+
path = self.project.work_dir / RECORD_FILE
|
|
167
|
+
if path.is_file():
|
|
168
|
+
return json.loads(path.read_text("utf-8"))
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _write_gitignore(pskit_dir: Path) -> None:
|
|
173
|
+
pskit_dir.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
gi = pskit_dir / ".gitignore"
|
|
175
|
+
if not gi.exists():
|
|
176
|
+
gi.write_text(
|
|
177
|
+
"# Generated by py-serverless-kit. Keep terraform state out of git -\n"
|
|
178
|
+
"# configure a remote backend (provider.state) for team use.\n*\n",
|
|
179
|
+
"utf-8",
|
|
180
|
+
)
|