sfnkit 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.
- sfnkit/__init__.py +67 -0
- sfnkit/app.py +118 -0
- sfnkit/blocks.py +250 -0
- sfnkit/build/__init__.py +5 -0
- sfnkit/build/context.py +139 -0
- sfnkit/build/docker.py +75 -0
- sfnkit/build/dockerfile.py +56 -0
- sfnkit/cli.py +170 -0
- sfnkit/compile/__init__.py +1 -0
- sfnkit/compile/app.py +164 -0
- sfnkit/compile/asl.py +991 -0
- sfnkit/compile/cfn.py +302 -0
- sfnkit/compute/__init__.py +12 -0
- sfnkit/compute/base.py +113 -0
- sfnkit/compute/lambda_.py +125 -0
- sfnkit/deploy/__init__.py +14 -0
- sfnkit/deploy/client.py +81 -0
- sfnkit/deploy/deployer.py +251 -0
- sfnkit/errors.py +18 -0
- sfnkit/expr.py +492 -0
- sfnkit/iam.py +105 -0
- sfnkit/ir.py +241 -0
- sfnkit/local.py +220 -0
- sfnkit/runtime/__init__.py +1 -0
- sfnkit/runtime/container.py +50 -0
- sfnkit/runtime/execute.py +51 -0
- sfnkit/runtime/lambda_handler.py +11 -0
- sfnkit/runtime/protocol.py +68 -0
- sfnkit/runtime/results.py +268 -0
- sfnkit/step.py +185 -0
- sfnkit/trace/__init__.py +0 -0
- sfnkit/trace/lint.py +241 -0
- sfnkit/trace/merge.py +488 -0
- sfnkit/trace/tracer.py +395 -0
- sfnkit/workflow.py +146 -0
- sfnkit-0.1.0.dist-info/METADATA +219 -0
- sfnkit-0.1.0.dist-info/RECORD +40 -0
- sfnkit-0.1.0.dist-info/WHEEL +4 -0
- sfnkit-0.1.0.dist-info/entry_points.txt +5 -0
- sfnkit-0.1.0.dist-info/licenses/LICENSE +202 -0
sfnkit/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""sfnkit: author AWS Step Functions workflows as decorated Python functions."""
|
|
2
|
+
|
|
3
|
+
from sfnkit.blocks import (
|
|
4
|
+
Err,
|
|
5
|
+
Ok,
|
|
6
|
+
S3File,
|
|
7
|
+
S3Objects,
|
|
8
|
+
States,
|
|
9
|
+
attempt,
|
|
10
|
+
each,
|
|
11
|
+
fail,
|
|
12
|
+
is_null,
|
|
13
|
+
length,
|
|
14
|
+
one_of,
|
|
15
|
+
subworkflow,
|
|
16
|
+
wait,
|
|
17
|
+
)
|
|
18
|
+
from sfnkit.compute import ComputeBackend, ComputeSpec, Lambda, register_backend
|
|
19
|
+
from sfnkit.errors import CompileError, DeployError, LintError, SfnkitError, TraceError
|
|
20
|
+
from sfnkit.iam import Allow, Deny, Policy
|
|
21
|
+
from sfnkit.local import WorkflowFailed
|
|
22
|
+
from sfnkit.step import Retry, Step, step
|
|
23
|
+
from sfnkit.workflow import Workflow, workflow
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Allow",
|
|
29
|
+
"CompileError",
|
|
30
|
+
"ComputeBackend",
|
|
31
|
+
"ComputeSpec",
|
|
32
|
+
"Deny",
|
|
33
|
+
"DeployError",
|
|
34
|
+
"Err",
|
|
35
|
+
"Lambda",
|
|
36
|
+
"LintError",
|
|
37
|
+
"Ok",
|
|
38
|
+
"Policy",
|
|
39
|
+
"Retry",
|
|
40
|
+
"S3File",
|
|
41
|
+
"S3Objects",
|
|
42
|
+
"States",
|
|
43
|
+
"Step",
|
|
44
|
+
"SfnkitError",
|
|
45
|
+
"TraceError",
|
|
46
|
+
"Workflow",
|
|
47
|
+
"WorkflowFailed",
|
|
48
|
+
"attempt",
|
|
49
|
+
"each",
|
|
50
|
+
"fail",
|
|
51
|
+
"is_null",
|
|
52
|
+
"length",
|
|
53
|
+
"one_of",
|
|
54
|
+
"register_backend",
|
|
55
|
+
"step",
|
|
56
|
+
"subworkflow",
|
|
57
|
+
"wait",
|
|
58
|
+
"workflow",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def deploy(workflow, **kwargs):
|
|
63
|
+
"""Build, push and deploy a workflow. See ``sfnkit.deploy.deployer.deploy``."""
|
|
64
|
+
from sfnkit.compile.app import build_app
|
|
65
|
+
from sfnkit.deploy.deployer import deploy as _deploy
|
|
66
|
+
|
|
67
|
+
return _deploy(build_app(workflow), **kwargs)
|
sfnkit/app.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The compiled form of a workflow: everything CloudFormation assembly and the deployer need.
|
|
2
|
+
|
|
3
|
+
``sfnkit.compile.build_app(workflow)`` produces an ``App``. Downstream code (``compile/cfn.py``,
|
|
4
|
+
``build/``, ``deploy/``) reads only these types, never the tracer or IR.
|
|
5
|
+
|
|
6
|
+
Conventions shared with CloudFormation assembly:
|
|
7
|
+
* State machine definitions use ``DefinitionSubstitutions`` placeholders. ``StateMachineSpec
|
|
8
|
+
.substitutions`` already maps every placeholder it uses to a CFN intrinsic, except that the
|
|
9
|
+
intrinsics may reference the template parameter ``ArtifactsBucket`` (the assets stack's bucket name)
|
|
10
|
+
and logical ids of functions in ``App.functions``.
|
|
11
|
+
* A state machine's full name is ``${AWS::StackName}-<StateMachineSpec.name>`` so ARNs can be built
|
|
12
|
+
without a CloudFormation dependency cycle (``sm_arn``).
|
|
13
|
+
* Each ``ImageVariant`` becomes a template parameter ``ImageVariant.param_name`` holding the image URI.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
21
|
+
|
|
22
|
+
from sfnkit.compute.base import ComputeSpec
|
|
23
|
+
from sfnkit.iam import Policy
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from sfnkit.step import Step
|
|
27
|
+
from sfnkit.workflow import Workflow
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(eq=False)
|
|
31
|
+
class ImageVariant:
|
|
32
|
+
key: str # short content hash of the dependency set
|
|
33
|
+
deps: tuple[str, ...]
|
|
34
|
+
system_packages: tuple[str, ...]
|
|
35
|
+
python: str # e.g. "3.14"
|
|
36
|
+
architecture: str # "x86_64" | "arm64"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def param_name(self) -> str:
|
|
40
|
+
return f"Image{self.key}Uri"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(eq=False)
|
|
44
|
+
class FunctionSpec:
|
|
45
|
+
"""One deployed unit of compute: a step with its resolved compute, IAM policy and image."""
|
|
46
|
+
|
|
47
|
+
logical_id: str
|
|
48
|
+
step: Step
|
|
49
|
+
compute: ComputeSpec
|
|
50
|
+
policy: Policy # the user's effective policy (base set or the step's own); baseline added at synth
|
|
51
|
+
image: ImageVariant
|
|
52
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(eq=False)
|
|
56
|
+
class SmNeed:
|
|
57
|
+
"""Something a state machine role must be allowed to do. CFN assembly turns these into statements."""
|
|
58
|
+
|
|
59
|
+
kind: Literal["invoke", "start_child", "map_run", "bucket", "s3_read"]
|
|
60
|
+
fn: FunctionSpec | None = None # invoke
|
|
61
|
+
child: str | None = None # start_child: StateMachineSpec.name of the child
|
|
62
|
+
bucket: str | None = None # s3_read: a user bucket name
|
|
63
|
+
prefix: str | None = None # s3_read: key prefix ("" for the whole bucket)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(eq=False)
|
|
67
|
+
class StateMachineSpec:
|
|
68
|
+
logical_id: str
|
|
69
|
+
name: str # suffix of the full name (see module docstring)
|
|
70
|
+
definition: dict[str, Any]
|
|
71
|
+
substitutions: dict[str, Any]
|
|
72
|
+
type: Literal["STANDARD", "EXPRESS"]
|
|
73
|
+
needs: list[SmNeed]
|
|
74
|
+
root: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(eq=False)
|
|
78
|
+
class AppSettings:
|
|
79
|
+
offload_threshold: int = 32 * 1024
|
|
80
|
+
log_retention_days: int = 14
|
|
81
|
+
run_retention_days: int = 30
|
|
82
|
+
python: str = "3.14"
|
|
83
|
+
architecture: str = "x86_64"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(eq=False)
|
|
87
|
+
class App:
|
|
88
|
+
name: str # sanitized root workflow name; the default stack name is f"sfnkit-{name}"
|
|
89
|
+
module: str # importable module that defines the root workflow (and registers its steps)
|
|
90
|
+
workflow: Workflow
|
|
91
|
+
functions: list[FunctionSpec]
|
|
92
|
+
state_machines: list[StateMachineSpec] # root first
|
|
93
|
+
images: list[ImageVariant]
|
|
94
|
+
settings: AppSettings
|
|
95
|
+
project_dir: Path
|
|
96
|
+
exclude: tuple[str, ...] = ()
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def root(self) -> StateMachineSpec:
|
|
100
|
+
return self.state_machines[0]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sm_full_name(name: str) -> dict:
|
|
104
|
+
return {"Fn::Sub": f"${{AWS::StackName}}-{name}"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def sm_arn(name: str) -> dict:
|
|
108
|
+
return {
|
|
109
|
+
"Fn::Sub": "arn:${AWS::Partition}:states:${AWS::Region}:${AWS::AccountId}:stateMachine:"
|
|
110
|
+
f"${{AWS::StackName}}-{name}"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def sm_execution_arn(name: str) -> dict:
|
|
115
|
+
return {
|
|
116
|
+
"Fn::Sub": "arn:${AWS::Partition}:states:${AWS::Region}:${AWS::AccountId}:execution:"
|
|
117
|
+
f"${{AWS::StackName}}-{name}:*"
|
|
118
|
+
}
|
sfnkit/blocks.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Control-flow building blocks used inside a ``@workflow`` body.
|
|
2
|
+
|
|
3
|
+
* ``match attempt(fn, ...)`` with ``case Ok(value)`` / ``case Err(error, cause)``: error handling.
|
|
4
|
+
* ``with each(items) as it:`` ... ``it.result(x)`` ... ``it.value``: a Map with a multi-step body.
|
|
5
|
+
* ``with subworkflow("name"):``: run the block as a child state machine.
|
|
6
|
+
* ``wait(seconds=...)`` / ``wait(until=...)``, ``fail(error, cause)``.
|
|
7
|
+
* ``is_null``, ``one_of``, ``length``: conditions that Python syntax can't express on a traced value.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from sfnkit.errors import TraceError
|
|
17
|
+
from sfnkit.expr import Expr, Get, IsNull, IsOk, Length, Lit, Not, OneOf, OutcomeGet, _decide, lift
|
|
18
|
+
from sfnkit.iam import Policy
|
|
19
|
+
from sfnkit.trace.tracer import TNode, require_tracer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class States:
|
|
23
|
+
"""Step Functions' built-in error names, for ``Retry(on=[...])`` and ``Err(...)`` patterns."""
|
|
24
|
+
|
|
25
|
+
ALL = "States.ALL"
|
|
26
|
+
TIMEOUT = "States.Timeout"
|
|
27
|
+
TASK_FAILED = "States.TaskFailed"
|
|
28
|
+
PERMISSIONS = "States.Permissions"
|
|
29
|
+
HEARTBEAT_TIMEOUT = "States.HeartbeatTimeout"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# attempt / Ok / Err
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _OutcomeMeta(type):
|
|
36
|
+
def __instancecheck__(cls, inst):
|
|
37
|
+
if isinstance(inst, OutcomeGet):
|
|
38
|
+
cond = IsOk(Get(inst.src)) if cls is Ok else Not(IsOk(Get(inst.src)))
|
|
39
|
+
return _decide(cond)
|
|
40
|
+
if isinstance(inst, Expr):
|
|
41
|
+
raise TraceError("Ok(...)/Err(...) patterns only match the outcome of attempt(...)")
|
|
42
|
+
return type.__instancecheck__(cls, inst)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Ok(metaclass=_OutcomeMeta):
|
|
46
|
+
"""``case Ok(value):`` the attempted work succeeded with ``value``."""
|
|
47
|
+
|
|
48
|
+
__match_args__ = ("value",)
|
|
49
|
+
|
|
50
|
+
def __init__(self, value: Any = None):
|
|
51
|
+
self.value = value
|
|
52
|
+
|
|
53
|
+
def __repr__(self):
|
|
54
|
+
return f"Ok({self.value!r})"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Err(metaclass=_OutcomeMeta):
|
|
58
|
+
"""``case Err(error, cause):`` the attempted work failed. ``error`` is the error name (a Python
|
|
59
|
+
exception's class name), ``cause`` its details (a dict for Python exceptions)."""
|
|
60
|
+
|
|
61
|
+
__match_args__ = ("error", "cause")
|
|
62
|
+
|
|
63
|
+
def __init__(self, error: str = "", cause: Any = None):
|
|
64
|
+
self.error, self.cause = error, cause
|
|
65
|
+
|
|
66
|
+
def __repr__(self):
|
|
67
|
+
return f"Err({self.error!r}, {self.cause!r})"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def attempt(fn, *args, **kwargs) -> OutcomeGet:
|
|
71
|
+
"""Run a step, a workflow or a plain function (a multi-step block) and catch its errors.
|
|
72
|
+
|
|
73
|
+
Must be the subject of a ``match`` directly in the workflow body. Errors that no ``Err`` case
|
|
74
|
+
matches are not caught and fail the workflow as they would without ``attempt``.
|
|
75
|
+
"""
|
|
76
|
+
t = require_tracer("attempt()")
|
|
77
|
+
return t.attempt(fn, args, kwargs, sys._getframe(1))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# each / map
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class each:
|
|
84
|
+
"""``with each(items, distributed=False, max_concurrency=None) as it:`` run the block per item.
|
|
85
|
+
|
|
86
|
+
Inside, ``it.item`` is the current item; call ``it.result(x)`` with the per-item value. After the
|
|
87
|
+
block, ``it.value`` is the list of results in item order.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
items,
|
|
93
|
+
*,
|
|
94
|
+
distributed: bool = False,
|
|
95
|
+
max_concurrency: int | None = None,
|
|
96
|
+
tolerated_failure_percentage: float | None = None,
|
|
97
|
+
tolerated_failure_count: int | None = None,
|
|
98
|
+
child_type: str = "STANDARD",
|
|
99
|
+
):
|
|
100
|
+
self._frame = sys._getframe(1)
|
|
101
|
+
self._items = items
|
|
102
|
+
self._opts = dict(
|
|
103
|
+
distributed=distributed,
|
|
104
|
+
max_concurrency=max_concurrency,
|
|
105
|
+
tolerated_failure_percentage=tolerated_failure_percentage,
|
|
106
|
+
tolerated_failure_count=tolerated_failure_count,
|
|
107
|
+
child_type=child_type,
|
|
108
|
+
)
|
|
109
|
+
self._node: TNode | None = None
|
|
110
|
+
self._result: Expr | None = None
|
|
111
|
+
|
|
112
|
+
def __enter__(self) -> each:
|
|
113
|
+
t = require_tracer("each()")
|
|
114
|
+
self._node = t.each_enter(self._items, self._opts, self._frame)
|
|
115
|
+
del self._frame
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, exc_type, exc, tb):
|
|
119
|
+
if exc_type is None:
|
|
120
|
+
require_tracer("each()").each_exit(self._node, self._result) # type: ignore[arg-type]
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def item(self) -> Get:
|
|
125
|
+
if self._node is None or self._node.closed:
|
|
126
|
+
raise TraceError("it.item is only available inside the `with each(...)` block")
|
|
127
|
+
return Get(self._node.item)
|
|
128
|
+
|
|
129
|
+
def result(self, value) -> None:
|
|
130
|
+
if self._node is None or self._node.closed:
|
|
131
|
+
raise TraceError("it.result() must be called inside the `with each(...)` block")
|
|
132
|
+
self._result = lift(value)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def value(self) -> Get:
|
|
136
|
+
if self._node is None or not self._node.closed:
|
|
137
|
+
raise TraceError("it.value is only available after the `with each(...)` block")
|
|
138
|
+
return Get(self._node)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _map_call(target, items, kwargs: dict, opts: dict, frame) -> Get:
|
|
142
|
+
"""``step.map(items, **kwargs)`` / ``workflow.map(...)``: a Map whose body is one call."""
|
|
143
|
+
from sfnkit.workflow import Workflow
|
|
144
|
+
|
|
145
|
+
t = require_tracer(f"{target.name}.map()")
|
|
146
|
+
n = t.each_enter(items, opts, frame)
|
|
147
|
+
param = target.item_param(kwargs)
|
|
148
|
+
bound = target.bind((), {**kwargs, param: Get(n.item)})
|
|
149
|
+
r = t.call(target, bound, frame) if isinstance(target, Workflow) else t.task(target, bound, frame)
|
|
150
|
+
t.each_exit(n, r)
|
|
151
|
+
return Get(n)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class S3Objects:
|
|
156
|
+
"""Distributed Map items: the objects under ``s3://bucket/prefix`` (each item is a listing entry
|
|
157
|
+
with ``Key``, ``Size``, ``ETag``, ``LastModified``)."""
|
|
158
|
+
|
|
159
|
+
bucket: str
|
|
160
|
+
prefix: str = ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class S3File:
|
|
165
|
+
"""Distributed Map items read from one S3 object: a JSON array, JSON Lines, or CSV with a header."""
|
|
166
|
+
|
|
167
|
+
bucket: str
|
|
168
|
+
key: str
|
|
169
|
+
format: str = "json"
|
|
170
|
+
|
|
171
|
+
def __post_init__(self):
|
|
172
|
+
if self.format not in ("json", "jsonl", "csv"):
|
|
173
|
+
raise ValueError("S3File format must be json, jsonl or csv")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# subworkflow
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class subworkflow:
|
|
180
|
+
"""``with subworkflow("name", iam=None, type="STANDARD"):`` run the block as a child state machine.
|
|
181
|
+
|
|
182
|
+
Values created inside can be used after the block. Steps inside without their own ``iam=``
|
|
183
|
+
inherit this block's ``iam`` if given, otherwise the enclosing workflow's.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, name: str, *, iam: Policy | None = None, type: str = "STANDARD"):
|
|
187
|
+
if type not in ("STANDARD", "EXPRESS"):
|
|
188
|
+
raise ValueError("type must be STANDARD or EXPRESS")
|
|
189
|
+
self._frame = sys._getframe(1)
|
|
190
|
+
self._opts = {"name": name, "iam": iam, "type": type}
|
|
191
|
+
self._node: TNode | None = None
|
|
192
|
+
|
|
193
|
+
def __enter__(self):
|
|
194
|
+
self._node = require_tracer("subworkflow()").sub_enter(self._opts, self._frame)
|
|
195
|
+
del self._frame
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def __exit__(self, exc_type, exc, tb):
|
|
199
|
+
if exc_type is None:
|
|
200
|
+
require_tracer("subworkflow()").sub_exit(self._node) # type: ignore[arg-type]
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# wait / fail
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def wait(*, seconds: int | Expr | None = None, until: str | Expr | None = None) -> None:
|
|
208
|
+
"""Pause the workflow. Everything traced before runs first; everything after waits."""
|
|
209
|
+
require_tracer("wait()").wait(seconds, until, sys._getframe(1))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def fail(error: str | Expr = "Failed", cause: str | Expr = "") -> None:
|
|
213
|
+
"""End the workflow (or the enclosing attempt) with an error."""
|
|
214
|
+
require_tracer("fail()").fail(error, cause, sys._getframe(1))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# condition helpers
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def is_null(value) -> IsNull:
|
|
221
|
+
return IsNull(lift(value))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def one_of(value, values) -> OneOf:
|
|
225
|
+
vals = tuple(values)
|
|
226
|
+
for v in vals:
|
|
227
|
+
lift(v)
|
|
228
|
+
return OneOf(lift(value), vals)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def length(value) -> Length:
|
|
232
|
+
return Length(lift(value))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = [
|
|
236
|
+
"Err",
|
|
237
|
+
"Lit",
|
|
238
|
+
"Ok",
|
|
239
|
+
"S3File",
|
|
240
|
+
"S3Objects",
|
|
241
|
+
"States",
|
|
242
|
+
"attempt",
|
|
243
|
+
"each",
|
|
244
|
+
"fail",
|
|
245
|
+
"is_null",
|
|
246
|
+
"length",
|
|
247
|
+
"one_of",
|
|
248
|
+
"subworkflow",
|
|
249
|
+
"wait",
|
|
250
|
+
]
|
sfnkit/build/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from sfnkit.build.context import BuildContext, prepare_context
|
|
2
|
+
from sfnkit.build.docker import Builder, DockerBuilder
|
|
3
|
+
from sfnkit.build.dockerfile import UV_VERSION, render_dockerfile
|
|
4
|
+
|
|
5
|
+
__all__ = ["UV_VERSION", "BuildContext", "Builder", "DockerBuilder", "prepare_context", "render_dockerfile"]
|
sfnkit/build/context.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Assemble a Docker build context for one image variant.
|
|
2
|
+
|
|
3
|
+
Layout under ``<project>/.sfnkit/build/<variant key>/`` (never /tmp: contexts can be large and /tmp is
|
|
4
|
+
RAM on some hosts)::
|
|
5
|
+
|
|
6
|
+
Dockerfile
|
|
7
|
+
requirements.txt
|
|
8
|
+
sfnkit/ the installed sfnkit package (works for PyPI and editable installs alike)
|
|
9
|
+
project/ the import roots of the workflow module and its steps, merged
|
|
10
|
+
|
|
11
|
+
The tag is a hash of everything in the context, so an unchanged project rebuilds to the same tag and the
|
|
12
|
+
push is skipped.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
import hashlib
|
|
19
|
+
import importlib
|
|
20
|
+
import importlib.metadata
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from sfnkit.app import App, ImageVariant
|
|
28
|
+
from sfnkit.build.dockerfile import docker_platform, render_dockerfile, render_requirements
|
|
29
|
+
|
|
30
|
+
DEFAULT_EXCLUDE_NAMES = frozenset(
|
|
31
|
+
{
|
|
32
|
+
".git",
|
|
33
|
+
".venv",
|
|
34
|
+
"venv",
|
|
35
|
+
"__pycache__",
|
|
36
|
+
".sfnkit",
|
|
37
|
+
"node_modules",
|
|
38
|
+
".pytest_cache",
|
|
39
|
+
".mypy_cache",
|
|
40
|
+
".ruff_cache",
|
|
41
|
+
".tox",
|
|
42
|
+
".idea",
|
|
43
|
+
".vscode",
|
|
44
|
+
".DS_Store",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
DEFAULT_EXCLUDE_GLOBS = ("*.pyc", "*.pyo")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class BuildContext:
|
|
52
|
+
image: ImageVariant
|
|
53
|
+
path: Path
|
|
54
|
+
tag: str
|
|
55
|
+
platform: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sfnkit_version() -> str:
|
|
59
|
+
try:
|
|
60
|
+
return importlib.metadata.version("sfnkit")
|
|
61
|
+
except importlib.metadata.PackageNotFoundError:
|
|
62
|
+
return "0+unknown"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sfnkit_package_dir() -> Path:
|
|
66
|
+
mod = importlib.import_module("sfnkit")
|
|
67
|
+
return Path(next(iter(mod.__path__))).resolve()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ignore(root: Path, user_globs: tuple[str, ...]):
|
|
71
|
+
def ignore(directory: str, names: list[str]) -> set[str]:
|
|
72
|
+
rel_dir = Path(directory).resolve().relative_to(root)
|
|
73
|
+
out = set()
|
|
74
|
+
for name in names:
|
|
75
|
+
rel = (rel_dir / name).as_posix()
|
|
76
|
+
if (
|
|
77
|
+
name in DEFAULT_EXCLUDE_NAMES
|
|
78
|
+
or any(fnmatch.fnmatch(name, g) for g in DEFAULT_EXCLUDE_GLOBS)
|
|
79
|
+
or any(fnmatch.fnmatch(rel, g) or fnmatch.fnmatch(name, g) for g in user_globs)
|
|
80
|
+
):
|
|
81
|
+
out.add(name)
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
return ignore
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def import_roots(app: App, image: ImageVariant) -> list[Path]:
|
|
88
|
+
"""Directories that must sit on sys.path for the workflow module and these steps to import."""
|
|
89
|
+
project = app.project_dir.resolve()
|
|
90
|
+
names = {app.module} | {fn.step.fn.__module__ for fn in app.functions if fn.image is image}
|
|
91
|
+
roots: list[Path] = []
|
|
92
|
+
for name in sorted(names):
|
|
93
|
+
if name == "__main__":
|
|
94
|
+
continue
|
|
95
|
+
mod = sys.modules.get(name) or importlib.import_module(name)
|
|
96
|
+
file = getattr(mod, "__file__", None)
|
|
97
|
+
if not file:
|
|
98
|
+
continue
|
|
99
|
+
p = Path(file).resolve()
|
|
100
|
+
depth = name.count(".") + (1 if p.name == "__init__.py" else 0)
|
|
101
|
+
root = p.parents[depth]
|
|
102
|
+
if root.is_relative_to(project) and root not in roots:
|
|
103
|
+
roots.append(root)
|
|
104
|
+
return roots or [project]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def content_hash(path: Path, extra: str = "") -> str:
|
|
108
|
+
h = hashlib.sha256(extra.encode())
|
|
109
|
+
for f in sorted(p for p in path.rglob("*") if p.is_file()):
|
|
110
|
+
h.update(f.relative_to(path).as_posix().encode() + b"\0")
|
|
111
|
+
h.update(f.read_bytes())
|
|
112
|
+
h.update(b"\0")
|
|
113
|
+
return h.hexdigest()[:16]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def prepare_context(app: App, image: ImageVariant) -> BuildContext:
|
|
117
|
+
ctx_dir = app.project_dir.resolve() / ".sfnkit" / "build" / image.key
|
|
118
|
+
if ctx_dir.exists():
|
|
119
|
+
shutil.rmtree(ctx_dir)
|
|
120
|
+
ctx_dir.mkdir(parents=True)
|
|
121
|
+
(ctx_dir / "Dockerfile").write_text(render_dockerfile(image))
|
|
122
|
+
(ctx_dir / "requirements.txt").write_text(render_requirements(image))
|
|
123
|
+
|
|
124
|
+
pkg = sfnkit_package_dir()
|
|
125
|
+
shutil.copytree(pkg, ctx_dir / "sfnkit", ignore=_ignore(pkg, ()))
|
|
126
|
+
|
|
127
|
+
project_out = ctx_dir / "project"
|
|
128
|
+
project_out.mkdir()
|
|
129
|
+
user_globs = tuple(app.exclude)
|
|
130
|
+
for root in import_roots(app, image):
|
|
131
|
+
shutil.copytree(root, project_out, dirs_exist_ok=True, ignore=_ignore(root, user_globs))
|
|
132
|
+
|
|
133
|
+
extra = os.pathsep.join([sfnkit_version(), image.python, image.architecture])
|
|
134
|
+
return BuildContext(
|
|
135
|
+
image=image,
|
|
136
|
+
path=ctx_dir,
|
|
137
|
+
tag=content_hash(ctx_dir, extra),
|
|
138
|
+
platform=docker_platform(image.architecture),
|
|
139
|
+
)
|
sfnkit/build/docker.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Build an image variant and push it to ECR. ``Builder`` is the seam for other builders later."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from sfnkit.build.context import BuildContext
|
|
11
|
+
from sfnkit.errors import DeployError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Builder(Protocol):
|
|
15
|
+
def ensure_image(self, context: BuildContext, repository_uri: str, ecr) -> str:
|
|
16
|
+
"""Make ``<repository_uri>:<context.tag>`` exist in ECR and return that URI."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def image_exists(ecr, repository_name: str, tag: str) -> bool:
|
|
21
|
+
try:
|
|
22
|
+
resp = ecr.describe_images(repositoryName=repository_name, imageIds=[{"imageTag": tag}])
|
|
23
|
+
except ecr.exceptions.ImageNotFoundException:
|
|
24
|
+
return False
|
|
25
|
+
return bool(resp.get("imageDetails"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DockerBuilder:
|
|
29
|
+
def __init__(self, docker: str = "docker", run: Callable = subprocess.run, log: Callable = print):
|
|
30
|
+
self.docker = docker
|
|
31
|
+
self._run_fn = run
|
|
32
|
+
self.log = log
|
|
33
|
+
self._logged_in: set[str] = set()
|
|
34
|
+
|
|
35
|
+
def _run(self, args: list[str], *, input: bytes | None = None) -> None:
|
|
36
|
+
proc = self._run_fn(args, input=input, capture_output=True)
|
|
37
|
+
if proc.returncode != 0:
|
|
38
|
+
err = (proc.stderr or b"").decode(errors="replace").strip()[-4000:]
|
|
39
|
+
raise DeployError(f"`{' '.join(args[:3])} ...` failed ({proc.returncode}):\n{err}")
|
|
40
|
+
|
|
41
|
+
def login(self, ecr) -> None:
|
|
42
|
+
data = ecr.get_authorization_token()["authorizationData"][0]
|
|
43
|
+
endpoint = data["proxyEndpoint"]
|
|
44
|
+
if endpoint in self._logged_in:
|
|
45
|
+
return
|
|
46
|
+
user, _, password = base64.b64decode(data["authorizationToken"]).decode().partition(":")
|
|
47
|
+
self._run(
|
|
48
|
+
[self.docker, "login", "--username", user, "--password-stdin", endpoint], input=password.encode()
|
|
49
|
+
)
|
|
50
|
+
self._logged_in.add(endpoint)
|
|
51
|
+
|
|
52
|
+
def ensure_image(self, context: BuildContext, repository_uri: str, ecr) -> str:
|
|
53
|
+
uri = f"{repository_uri}:{context.tag}"
|
|
54
|
+
repo_name = repository_uri.split("/", 1)[1]
|
|
55
|
+
if image_exists(ecr, repo_name, context.tag):
|
|
56
|
+
self.log(f"image {context.image.key}: {context.tag} already in ECR, skipping build")
|
|
57
|
+
return uri
|
|
58
|
+
self.login(ecr)
|
|
59
|
+
self.log(f"image {context.image.key}: building {context.tag} ({context.platform})")
|
|
60
|
+
# --provenance=false: Lambda rejects the image index that build attestations create.
|
|
61
|
+
self._run(
|
|
62
|
+
[
|
|
63
|
+
self.docker,
|
|
64
|
+
"build",
|
|
65
|
+
"--platform",
|
|
66
|
+
context.platform,
|
|
67
|
+
"--provenance=false",
|
|
68
|
+
"-t",
|
|
69
|
+
uri,
|
|
70
|
+
str(context.path),
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
self.log(f"image {context.image.key}: pushing")
|
|
74
|
+
self._run([self.docker, "push", uri])
|
|
75
|
+
return uri
|