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.
Files changed (71) hide show
  1. py_serverless_kit/__init__.py +47 -0
  2. py_serverless_kit/__main__.py +3 -0
  3. py_serverless_kit/_version.py +2 -0
  4. py_serverless_kit/cli/__init__.py +0 -0
  5. py_serverless_kit/cli/main.py +491 -0
  6. py_serverless_kit/cli/templates.py +212 -0
  7. py_serverless_kit/config/__init__.py +12 -0
  8. py_serverless_kit/config/loader.py +316 -0
  9. py_serverless_kit/config/schema.py +457 -0
  10. py_serverless_kit/core/__init__.py +0 -0
  11. py_serverless_kit/core/deployer.py +180 -0
  12. py_serverless_kit/core/describe.py +112 -0
  13. py_serverless_kit/core/project.py +147 -0
  14. py_serverless_kit/dashboard/__init__.py +3 -0
  15. py_serverless_kit/dashboard/generator.py +65 -0
  16. py_serverless_kit/dashboard/template.html +248 -0
  17. py_serverless_kit/local/__init__.py +3 -0
  18. py_serverless_kit/local/runner.py +234 -0
  19. py_serverless_kit/local/server.py +200 -0
  20. py_serverless_kit/packaging/__init__.py +3 -0
  21. py_serverless_kit/packaging/builder.py +252 -0
  22. py_serverless_kit/providers/__init__.py +15 -0
  23. py_serverless_kit/providers/alibaba.py +268 -0
  24. py_serverless_kit/providers/aws.py +508 -0
  25. py_serverless_kit/providers/azure.py +417 -0
  26. py_serverless_kit/providers/base.py +310 -0
  27. py_serverless_kit/providers/cloudflare.py +264 -0
  28. py_serverless_kit/providers/digitalocean.py +211 -0
  29. py_serverless_kit/providers/gcp.py +480 -0
  30. py_serverless_kit/providers/huawei.py +292 -0
  31. py_serverless_kit/providers/ibm.py +132 -0
  32. py_serverless_kit/providers/oracle.py +304 -0
  33. py_serverless_kit/providers/registry.py +60 -0
  34. py_serverless_kit/providers/schedule.py +153 -0
  35. py_serverless_kit/providers/tencent.py +275 -0
  36. py_serverless_kit/providers/terraform_doc.py +125 -0
  37. py_serverless_kit/runtime/__init__.py +6 -0
  38. py_serverless_kit/runtime/adapters/__init__.py +18 -0
  39. py_serverless_kit/runtime/adapters/_httpfn.py +91 -0
  40. py_serverless_kit/runtime/adapters/alibaba.py +147 -0
  41. py_serverless_kit/runtime/adapters/aws.py +201 -0
  42. py_serverless_kit/runtime/adapters/azure.py +185 -0
  43. py_serverless_kit/runtime/adapters/base.py +86 -0
  44. py_serverless_kit/runtime/adapters/cloudflare.py +126 -0
  45. py_serverless_kit/runtime/adapters/digitalocean.py +74 -0
  46. py_serverless_kit/runtime/adapters/gcp.py +176 -0
  47. py_serverless_kit/runtime/adapters/huawei.py +173 -0
  48. py_serverless_kit/runtime/adapters/ibm.py +91 -0
  49. py_serverless_kit/runtime/adapters/oracle.py +161 -0
  50. py_serverless_kit/runtime/adapters/tencent.py +151 -0
  51. py_serverless_kit/runtime/batch.py +47 -0
  52. py_serverless_kit/runtime/context.py +45 -0
  53. py_serverless_kit/runtime/dispatch.py +277 -0
  54. py_serverless_kit/runtime/errors.py +83 -0
  55. py_serverless_kit/runtime/event.py +267 -0
  56. py_serverless_kit/runtime/invoke.py +119 -0
  57. py_serverless_kit/runtime/log.py +69 -0
  58. py_serverless_kit/runtime/manifest.py +121 -0
  59. py_serverless_kit/runtime/response.py +177 -0
  60. py_serverless_kit/runtime/routing.py +74 -0
  61. py_serverless_kit/runtime/workflow.py +312 -0
  62. py_serverless_kit/terraform/__init__.py +3 -0
  63. py_serverless_kit/terraform/runner.py +104 -0
  64. py_serverless_kit/workflows/__init__.py +35 -0
  65. py_serverless_kit/workflows/asl.py +170 -0
  66. py_serverless_kit/workflows/gcp.py +328 -0
  67. py_serverless_kit-0.1.0.dist-info/METADATA +300 -0
  68. py_serverless_kit-0.1.0.dist-info/RECORD +71 -0
  69. py_serverless_kit-0.1.0.dist-info/WHEEL +4 -0
  70. py_serverless_kit-0.1.0.dist-info/entry_points.txt +3 -0
  71. 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
+ )