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 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
+ ]
@@ -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"]
@@ -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