continuo-python-runtime 0.11.0__py3-none-any.whl → 0.12.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.
- continuo_python_runtime/cli.py +6 -0
- continuo_python_runtime/content_check.py +73 -0
- continuo_python_runtime/contract/kinds.py +10 -2
- continuo_python_runtime/contract/loader.py +52 -2
- continuo_python_runtime/contract/merge.py +26 -45
- continuo_python_runtime/contract/model.py +1 -0
- continuo_python_runtime/contract/paths.py +33 -0
- continuo_python_runtime/errors.py +13 -1
- continuo_python_runtime/harness.py +7 -1
- continuo_python_runtime/hash_inputs.py +63 -0
- continuo_python_runtime/hashing.py +69 -70
- continuo_python_runtime/lint.py +194 -6
- continuo_python_runtime/validation/preflight.py +410 -0
- continuo_python_runtime/validation/runner.py +20 -3
- {continuo_python_runtime-0.11.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/METADATA +129 -12
- continuo_python_runtime-0.12.0.dist-info/RECORD +35 -0
- continuo_python_runtime-0.11.0.dist-info/RECORD +0 -32
- {continuo_python_runtime-0.11.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/WHEEL +0 -0
- {continuo_python_runtime-0.11.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/entry_points.txt +0 -0
- {continuo_python_runtime-0.11.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {continuo_python_runtime-0.11.0.dist-info → continuo_python_runtime-0.12.0.dist-info}/licenses/NOTICE +0 -0
continuo_python_runtime/cli.py
CHANGED
|
@@ -4,6 +4,7 @@ import argparse
|
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
+
from importlib import metadata
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from continuo_python_runtime.contract.loader import load_contract_dir
|
|
@@ -28,6 +29,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
parser = argparse.ArgumentParser(prog="continuo-runtime")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--version",
|
|
34
|
+
action="version",
|
|
35
|
+
version=f"continuo-runtime {metadata.version('continuo-python-runtime')}",
|
|
36
|
+
)
|
|
31
37
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
32
38
|
|
|
33
39
|
dialect_help = (
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The harness's start check: the image must hold what the release hashed.
|
|
2
|
+
|
|
3
|
+
continuo sets ``CONTENT_HASH`` to the ``content_hash`` the release promoted for
|
|
4
|
+
the node. The check recomputes the node's hash from the files in the image with
|
|
5
|
+
the helpers ``merge`` uses (``gather_hash_inputs`` and ``hash_parts``), so the
|
|
6
|
+
two can never disagree, and refuses to run a node whose image reproduces a
|
|
7
|
+
different one: the image holds other code, imports, assets or contract than the
|
|
8
|
+
release.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from continuo_python_runtime.contract.model import Node
|
|
17
|
+
from continuo_python_runtime.errors import ContractError, ImageContractMismatch
|
|
18
|
+
from continuo_python_runtime.hash_inputs import gather_hash_inputs
|
|
19
|
+
from continuo_python_runtime.hashing import hash_parts
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("continuo_python_runtime.content_check")
|
|
22
|
+
|
|
23
|
+
_ENV_KEY = "CONTENT_HASH"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_image_contract(node: Node, app_root: Path, env: Mapping[str, str]) -> None:
|
|
27
|
+
"""Fail unless the image reproduces the ``content_hash`` in ``CONTENT_HASH``.
|
|
28
|
+
|
|
29
|
+
An unset or empty ``CONTENT_HASH`` logs a warning and skips the check. Any
|
|
30
|
+
other value is compared as an exact string, so a value without the
|
|
31
|
+
``sha256:`` prefix, in upper case or with surrounding whitespace is a
|
|
32
|
+
mismatch.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ImageContractMismatch: The recomputed ``content_hash`` differs, or a
|
|
36
|
+
file the release hashed is missing from the image.
|
|
37
|
+
"""
|
|
38
|
+
released = env.get(_ENV_KEY)
|
|
39
|
+
if not released:
|
|
40
|
+
logger.warning(
|
|
41
|
+
"%s is not set: skipping the check that the image holds the release's content for %s",
|
|
42
|
+
_ENV_KEY, node.relation,
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
inputs = gather_hash_inputs(node, app_root)
|
|
48
|
+
parts = hash_parts(node, inputs.primary, inputs.units)
|
|
49
|
+
except ContractError as exc:
|
|
50
|
+
raise ImageContractMismatch(
|
|
51
|
+
_message(node, f"the image cannot reproduce {_ENV_KEY} {released}: {exc}")
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
if parts["content_hash"] != released:
|
|
55
|
+
detail = (
|
|
56
|
+
f"the image's content_hash {parts['content_hash']} "
|
|
57
|
+
f"(source_hash {parts['source_hash']}, "
|
|
58
|
+
f"shared_code_hash {parts['shared_code_hash']}, "
|
|
59
|
+
f"config_hash {parts['config_hash']}, "
|
|
60
|
+
f"units {sorted(inputs.units)})"
|
|
61
|
+
)
|
|
62
|
+
raise ImageContractMismatch(_message(node, f"{_ENV_KEY} {released} differs from {detail}"))
|
|
63
|
+
|
|
64
|
+
logger.info("image holds the release's content for %s (%s)", node.relation, released)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _message(node: Node, detail: str) -> str:
|
|
68
|
+
return (
|
|
69
|
+
f"the image does not hold what the release hashed for {node.relation}: "
|
|
70
|
+
f"{detail}. "
|
|
71
|
+
f"This image runs runtime {metadata.version('continuo-python-runtime')}. "
|
|
72
|
+
"Fix: rebuild the image from the released commit, with CI on the runtime of the image's FROM tag."
|
|
73
|
+
)
|
|
@@ -112,14 +112,22 @@ class KindRules:
|
|
|
112
112
|
# The write modes a node of this kind may declare. A new kind gets replace
|
|
113
113
|
# only unless its entry opts into more.
|
|
114
114
|
mode_types: frozenset[str] = frozenset({DEFAULT_MODE_TYPE})
|
|
115
|
+
# Whether a node of this kind may list the repository files its script reads.
|
|
116
|
+
assets_allowed: bool = False
|
|
115
117
|
|
|
116
118
|
|
|
117
119
|
RULES: dict[str, KindRules] = {
|
|
118
|
-
"python-node": KindRules(
|
|
120
|
+
"python-node": KindRules(
|
|
121
|
+
script_required=True, reads=SqlReads(), mode_types=MODE_TYPES, assets_allowed=True
|
|
122
|
+
),
|
|
119
123
|
# A csv drop is a snapshot of its source: replace only (spec D2).
|
|
120
124
|
"python-csv": KindRules(script_required=False, reads=CsvRead()),
|
|
121
125
|
"python-api": KindRules(
|
|
122
|
-
script_required=True,
|
|
126
|
+
script_required=True,
|
|
127
|
+
reads=NoReads(),
|
|
128
|
+
secret_allowed=True,
|
|
129
|
+
mode_types=MODE_TYPES,
|
|
130
|
+
assets_allowed=True,
|
|
123
131
|
),
|
|
124
132
|
}
|
|
125
133
|
|
|
@@ -7,13 +7,14 @@ structural or semantic violation.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
14
|
import yaml
|
|
14
15
|
from sqlglot import Dialect
|
|
15
16
|
|
|
16
|
-
from continuo_python_runtime.contract.kinds import rules_for
|
|
17
|
+
from continuo_python_runtime.contract.kinds import KindRules, rules_for
|
|
17
18
|
from continuo_python_runtime.contract.model import (
|
|
18
19
|
CRITICALITIES,
|
|
19
20
|
DEFAULT_KIND,
|
|
@@ -45,6 +46,7 @@ _ALLOWED_KEYS = {
|
|
|
45
46
|
"mode_type",
|
|
46
47
|
"primary_key",
|
|
47
48
|
"timeout_minutes",
|
|
49
|
+
"assets",
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
# Bounds of a node's declared task timeout, in minutes (one minute to one week).
|
|
@@ -210,6 +212,52 @@ def _validate_primary_key(
|
|
|
210
212
|
return tuple(raw_key)
|
|
211
213
|
|
|
212
214
|
|
|
215
|
+
_DRIVE_PATH = re.compile(r"^[A-Za-z]:")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _validate_assets(
|
|
219
|
+
raw: dict[str, Any], rules: KindRules, kind: str, label: str
|
|
220
|
+
) -> tuple[str, ...]:
|
|
221
|
+
"""Validate ``assets``: repo-relative paths of the files a script reads.
|
|
222
|
+
|
|
223
|
+
A kind without a script has nothing that reads a file, so it refuses the
|
|
224
|
+
key. Each path is written with forward slashes, relative to the repository
|
|
225
|
+
root and without a ``..`` segment; it is normalised (``./data//ref.json``
|
|
226
|
+
is ``data/ref.json``) and keeps its declared position. A repeat after
|
|
227
|
+
normalising is refused. Whether the file exists is checked where a
|
|
228
|
+
repository root is known.
|
|
229
|
+
"""
|
|
230
|
+
if "assets" not in raw:
|
|
231
|
+
return ()
|
|
232
|
+
if not rules.assets_allowed:
|
|
233
|
+
raise ContractError(
|
|
234
|
+
f"{label}: 'assets' is not allowed for kind {kind} (it has no script to read files)"
|
|
235
|
+
)
|
|
236
|
+
raw_assets = raw["assets"]
|
|
237
|
+
if not isinstance(raw_assets, list):
|
|
238
|
+
raise ContractError(f"{label}: 'assets' must be a list of repository-relative paths")
|
|
239
|
+
assets: list[str] = []
|
|
240
|
+
for entry in raw_assets:
|
|
241
|
+
if not isinstance(entry, str) or not entry.strip():
|
|
242
|
+
raise ContractError(f"{label}: 'assets' entry {entry!r} must be a non-empty string")
|
|
243
|
+
if "\\" in entry:
|
|
244
|
+
raise ContractError(f"{label}: 'assets' entry {entry!r} must use forward slashes")
|
|
245
|
+
path = PurePosixPath(entry)
|
|
246
|
+
if path.is_absolute() or _DRIVE_PATH.match(entry):
|
|
247
|
+
raise ContractError(
|
|
248
|
+
f"{label}: 'assets' entry {entry!r} must be relative to the repository root"
|
|
249
|
+
)
|
|
250
|
+
if ".." in path.parts:
|
|
251
|
+
raise ContractError(f"{label}: 'assets' entry {entry!r} must not contain '..'")
|
|
252
|
+
normalised = path.as_posix()
|
|
253
|
+
if normalised == ".":
|
|
254
|
+
raise ContractError(f"{label}: 'assets' entry {entry!r} must name a file")
|
|
255
|
+
if normalised in assets:
|
|
256
|
+
raise ContractError(f"{label}: 'assets' names {normalised!r} more than once")
|
|
257
|
+
assets.append(normalised)
|
|
258
|
+
return tuple(assets)
|
|
259
|
+
|
|
260
|
+
|
|
213
261
|
def parse_node(
|
|
214
262
|
raw: dict[str, Any],
|
|
215
263
|
source: str,
|
|
@@ -362,6 +410,7 @@ def parse_node(
|
|
|
362
410
|
columns.append(Column(name=name, type=col_type, nullable=nullable))
|
|
363
411
|
|
|
364
412
|
primary_key = _validate_primary_key(raw, mode_type, columns, label)
|
|
413
|
+
assets = _validate_assets(raw, rules, kind, label)
|
|
365
414
|
|
|
366
415
|
description = raw.get("description", "")
|
|
367
416
|
if not isinstance(description, str):
|
|
@@ -391,6 +440,7 @@ def parse_node(
|
|
|
391
440
|
mode_type=mode_type,
|
|
392
441
|
primary_key=primary_key,
|
|
393
442
|
timeout_minutes=timeout_minutes,
|
|
443
|
+
assets=assets,
|
|
394
444
|
)
|
|
395
445
|
|
|
396
446
|
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
"""Contract v1 merger: wire contract builder."""
|
|
2
2
|
|
|
3
|
-
from collections.abc import Callable
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
|
|
6
5
|
import yaml
|
|
7
6
|
|
|
8
|
-
from continuo_python_runtime.closure import resolve_closure
|
|
9
7
|
from continuo_python_runtime.contract.loader import load_contract_dir
|
|
10
8
|
from continuo_python_runtime.contract.model import CONTRACT_VERSION, DEFAULT_MODE_TYPE, Node
|
|
11
|
-
from continuo_python_runtime.contract.paths import resolve_script_path
|
|
12
9
|
from continuo_python_runtime.errors import ContractError
|
|
10
|
+
from continuo_python_runtime.hash_inputs import gather_hash_inputs
|
|
13
11
|
from continuo_python_runtime.hashing import hash_parts
|
|
14
12
|
from continuo_python_runtime.lint import lint_source
|
|
15
13
|
|
|
@@ -21,9 +19,9 @@ def node_entry(node: Node) -> dict:
|
|
|
21
19
|
dicts. The entry carries no hash fields: build_wire_contract adds all
|
|
22
20
|
four (source_hash, shared_code_hash, config_hash, content_hash). The
|
|
23
21
|
nullable field is always present in output_columns. ``secret_ref``,
|
|
24
|
-
``mode_type`` and ``
|
|
25
|
-
default, so a node that does not use them
|
|
26
|
-
|
|
22
|
+
``mode_type``, ``primary_key`` and ``assets`` appear only when they differ
|
|
23
|
+
from their default, so the entry of a node that does not use them stays
|
|
24
|
+
minimal.
|
|
27
25
|
"""
|
|
28
26
|
entry = {
|
|
29
27
|
"schema": node.schema,
|
|
@@ -46,26 +44,24 @@ def node_entry(node: Node) -> dict:
|
|
|
46
44
|
"extra_columns": node.extra_columns,
|
|
47
45
|
"config": dict(node.config),
|
|
48
46
|
}
|
|
49
|
-
# Only a kind that may name a Secret ever carries one;
|
|
50
|
-
# only when set
|
|
47
|
+
# Only a kind that may name a Secret ever carries one; the key is emitted
|
|
48
|
+
# only when set.
|
|
51
49
|
if node.secret_ref:
|
|
52
50
|
entry["secret_ref"] = node.secret_ref
|
|
53
|
-
# Same reasoning:
|
|
54
|
-
# stay byte-identical, so contract_version stays 1.
|
|
51
|
+
# Same reasoning: a replace node's entry carries no mode_type.
|
|
55
52
|
if node.mode_type != DEFAULT_MODE_TYPE:
|
|
56
53
|
entry["mode_type"] = node.mode_type
|
|
57
54
|
if node.mode_type == "upsert":
|
|
58
55
|
entry["primary_key"] = list(node.primary_key)
|
|
56
|
+
if node.assets:
|
|
57
|
+
entry["assets"] = list(node.assets)
|
|
59
58
|
return entry
|
|
60
59
|
|
|
61
60
|
|
|
62
61
|
def _lint_node_closure(
|
|
63
62
|
node: Node,
|
|
64
63
|
repo_root: Path,
|
|
65
|
-
|
|
66
|
-
script_bytes: bytes,
|
|
67
|
-
closure: list[Path],
|
|
68
|
-
member_bytes: list[bytes],
|
|
64
|
+
code_files: tuple[tuple[Path, bytes], ...],
|
|
69
65
|
) -> None:
|
|
70
66
|
"""Lint the node's script and every resolved closure member.
|
|
71
67
|
|
|
@@ -73,7 +69,8 @@ def _lint_node_closure(
|
|
|
73
69
|
in-repo import closure — is held to the same lint rules
|
|
74
70
|
(``continuo_python_runtime.lint.lint_source``: L1 forbidden driver
|
|
75
71
|
imports, L2 SQL string literals, L3 forbidden data-access calls, L4
|
|
76
|
-
private-attribute access, L5 dynamic-import constructs
|
|
72
|
+
private-attribute access, L5 dynamic-import constructs, L6 constructs
|
|
73
|
+
that run code the hash cannot see) that CI's
|
|
77
74
|
``continuo-runtime lint`` applies to ``scripts/``. Enforcing it again
|
|
78
75
|
here, at merge time, means a helper file outside ``scripts/`` — which
|
|
79
76
|
the CI lint step never looks at — cannot become a side channel around
|
|
@@ -107,10 +104,8 @@ def _lint_node_closure(
|
|
|
107
104
|
catches only ``HarnessError``/``OSError``.
|
|
108
105
|
"""
|
|
109
106
|
repo_root_resolved = repo_root.resolve()
|
|
110
|
-
files = [(script_path, script_bytes), *zip(closure, member_bytes, strict=True)]
|
|
111
|
-
|
|
112
107
|
violations: list[str] = []
|
|
113
|
-
for path, data in
|
|
108
|
+
for path, data in code_files:
|
|
114
109
|
rel = path.relative_to(repo_root_resolved)
|
|
115
110
|
try:
|
|
116
111
|
text = data.decode("utf-8-sig")
|
|
@@ -125,30 +120,6 @@ def _lint_node_closure(
|
|
|
125
120
|
)
|
|
126
121
|
|
|
127
122
|
|
|
128
|
-
def _script_hash_inputs(node: Node, repo_root: Path) -> tuple[bytes, list[bytes]]:
|
|
129
|
-
"""A script node's source is its script plus its in-repo import closure,
|
|
130
|
-
every file of which is held to the closure lint."""
|
|
131
|
-
script_path = resolve_script_path(node.script, repo_root, context=node.relation)
|
|
132
|
-
script_bytes = script_path.read_bytes()
|
|
133
|
-
closure = resolve_closure(script_path, repo_root)
|
|
134
|
-
member_bytes = [member.read_bytes() for member in closure]
|
|
135
|
-
_lint_node_closure(node, repo_root, script_path, script_bytes, closure, member_bytes)
|
|
136
|
-
return script_bytes, member_bytes
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _csv_hash_inputs(node: Node, repo_root: Path) -> tuple[bytes, list[bytes]]:
|
|
140
|
-
"""A csv node has no script and no import closure: its source is the uri.
|
|
141
|
-
New file content at the same uri is new data, not a new node version."""
|
|
142
|
-
return node.reads["csv"].encode(), []
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
_HASH_INPUTS: dict[str, Callable[[Node, Path], tuple[bytes, list[bytes]]]] = {
|
|
146
|
-
"python-node": _script_hash_inputs,
|
|
147
|
-
"python-csv": _csv_hash_inputs,
|
|
148
|
-
"python-api": _script_hash_inputs,
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
123
|
def build_wire_contract(
|
|
153
124
|
contract_dir: Path, repo_root: Path, service: str, *, dialect: str | None = None
|
|
154
125
|
) -> dict:
|
|
@@ -160,16 +131,26 @@ def build_wire_contract(
|
|
|
160
131
|
.load_contract_dir` so every declared read is checked against that sqlglot
|
|
161
132
|
dialect (``None`` -- the default -- uses sqlglot's dialect-neutral parser).
|
|
162
133
|
|
|
163
|
-
Raises ContractError if any script file is missing
|
|
134
|
+
Raises ContractError if any script file is missing, or if the repository
|
|
135
|
+
root holds a ``sitecustomize.py`` or ``usercustomize.py``: Python imports
|
|
136
|
+
either at startup, before the harness, so nothing in a node's import
|
|
137
|
+
closure reaches it.
|
|
164
138
|
"""
|
|
165
139
|
nodes = load_contract_dir(contract_dir, dialect=dialect)
|
|
166
140
|
|
|
141
|
+
for hook in ("sitecustomize.py", "usercustomize.py"):
|
|
142
|
+
if (repo_root / hook).exists():
|
|
143
|
+
raise ContractError(
|
|
144
|
+
f"{hook} at the repository root runs before the harness and is not in the hash"
|
|
145
|
+
)
|
|
146
|
+
|
|
167
147
|
wire_nodes = []
|
|
168
148
|
for node in nodes:
|
|
169
149
|
entry = node_entry(node)
|
|
170
150
|
|
|
171
|
-
|
|
172
|
-
|
|
151
|
+
inputs = gather_hash_inputs(node, repo_root)
|
|
152
|
+
_lint_node_closure(node, repo_root, inputs.code_files)
|
|
153
|
+
entry.update(hash_parts(node, inputs.primary, inputs.units))
|
|
173
154
|
# The one wire key outside config_hash: it is written after the hashes
|
|
174
155
|
# are computed, so changing it alone never changes content_hash.
|
|
175
156
|
if node.timeout_minutes is not None:
|
|
@@ -36,3 +36,36 @@ def resolve_script_path(script: str, repo_root: Path, *, context: str) -> Path:
|
|
|
36
36
|
raise ContractError(f"{context}: script not found: {script}")
|
|
37
37
|
|
|
38
38
|
return script_path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_asset_path(asset: str, repo_root: Path, *, context: str) -> Path:
|
|
42
|
+
"""Resolve ``asset`` (relative to ``repo_root``) and validate it.
|
|
43
|
+
|
|
44
|
+
Rejects absolute paths, paths that escape ``repo_root`` once symlinks
|
|
45
|
+
resolve, paths that do not exist and paths that are not a regular file.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
asset: The asset path as declared in the contract, relative to
|
|
49
|
+
``repo_root``.
|
|
50
|
+
repo_root: The repository root the asset path is resolved against.
|
|
51
|
+
context: A label prefixed to every error message (e.g. a node's
|
|
52
|
+
relation) to identify which node's asset failed.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ContractError: If the path is absolute, escapes the repository root,
|
|
56
|
+
does not exist or is not a file.
|
|
57
|
+
"""
|
|
58
|
+
if Path(asset).is_absolute():
|
|
59
|
+
raise ContractError(
|
|
60
|
+
f"{context}: asset path {asset!r} must be relative to the repository root"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
asset_path = (repo_root / asset).resolve()
|
|
64
|
+
if not asset_path.is_relative_to(repo_root.resolve()):
|
|
65
|
+
raise ContractError(f"{context}: asset path {asset!r} escapes the repository root")
|
|
66
|
+
if not asset_path.exists():
|
|
67
|
+
raise ContractError(f"{context}: asset not found: {asset}")
|
|
68
|
+
if not asset_path.is_file():
|
|
69
|
+
raise ContractError(f"{context}: asset {asset!r} is not a file")
|
|
70
|
+
|
|
71
|
+
return asset_path
|
|
@@ -6,7 +6,13 @@ remediation classifier can key off it without parsing free text.
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class HarnessError(Exception):
|
|
9
|
-
"""Base for all runtime failures the harness converts to a sentinel block.
|
|
9
|
+
"""Base for all runtime failures the harness converts to a sentinel block.
|
|
10
|
+
|
|
11
|
+
``reason`` is a machine-readable code the block carries beside the message;
|
|
12
|
+
empty for the errors that have none.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
reason: str = ""
|
|
10
16
|
|
|
11
17
|
@property
|
|
12
18
|
def error_class(self) -> str:
|
|
@@ -20,6 +26,12 @@ class ContractError(HarnessError):
|
|
|
20
26
|
"""Contract missing, invalid, node not found, or script missing."""
|
|
21
27
|
|
|
22
28
|
|
|
29
|
+
class ImageContractMismatch(ContractError):
|
|
30
|
+
"""The image does not hold the contract, script, imports or assets the release hashed."""
|
|
31
|
+
|
|
32
|
+
reason = "image_contract_mismatch"
|
|
33
|
+
|
|
34
|
+
|
|
23
35
|
class ReadError(HarnessError):
|
|
24
36
|
"""Unknown read name, or a declared read failed at the warehouse."""
|
|
25
37
|
|
|
@@ -32,6 +32,7 @@ from continuo_engine_contract.port import ( # type: ignore[import-untyped]
|
|
|
32
32
|
from continuo_engine_contract.result import result_block # type: ignore[import-untyped]
|
|
33
33
|
|
|
34
34
|
from continuo_python_runtime.conform import conform, to_arrow
|
|
35
|
+
from continuo_python_runtime.content_check import check_image_contract
|
|
35
36
|
from continuo_python_runtime.context import RunContext
|
|
36
37
|
from continuo_python_runtime.contract.loader import load_contract_dir
|
|
37
38
|
from continuo_python_runtime.contract.model import Node
|
|
@@ -345,6 +346,7 @@ def run_node(
|
|
|
345
346
|
# Every other loader validation still runs at container start.
|
|
346
347
|
nodes = load_contract_dir(contract_dir, check_reads=False)
|
|
347
348
|
node = select_node(nodes, node_id)
|
|
349
|
+
check_image_contract(node, app_root, env)
|
|
348
350
|
|
|
349
351
|
if adapter is not None:
|
|
350
352
|
active_adapter = adapter
|
|
@@ -402,7 +404,11 @@ def run_node(
|
|
|
402
404
|
)
|
|
403
405
|
print(
|
|
404
406
|
result_block(
|
|
405
|
-
"error",
|
|
407
|
+
"error",
|
|
408
|
+
message=err.sentinel_message(),
|
|
409
|
+
failures=1,
|
|
410
|
+
unique_id=node_id,
|
|
411
|
+
reason=err.reason,
|
|
406
412
|
)
|
|
407
413
|
)
|
|
408
414
|
return 1
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The bytes each node kind hashes, gathered from the repository.
|
|
2
|
+
|
|
3
|
+
A script kind hashes its script, its in-repo import closure and the assets it
|
|
4
|
+
declares (a file that is both is one unit). A csv node has
|
|
5
|
+
no script and no closure: its source is its uri, so new file content at the same
|
|
6
|
+
uri is new data, not a new node version.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from continuo_python_runtime.closure import resolve_closure
|
|
14
|
+
from continuo_python_runtime.contract.model import Node
|
|
15
|
+
from continuo_python_runtime.contract.paths import resolve_asset_path, resolve_script_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class HashInputs:
|
|
20
|
+
"""What a node's hash parts are computed from.
|
|
21
|
+
|
|
22
|
+
``primary`` is the script's bytes (a csv node's uri, encoded). ``units`` maps
|
|
23
|
+
each closure file's and each declared asset's repo-relative POSIX path to
|
|
24
|
+
its bytes. ``code_files`` holds every file that executes for the node, the
|
|
25
|
+
script first, for the lint.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
primary: bytes
|
|
29
|
+
units: dict[str, bytes]
|
|
30
|
+
code_files: tuple[tuple[Path, bytes], ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _script_inputs(node: Node, repo_root: Path) -> HashInputs:
|
|
34
|
+
script_path = resolve_script_path(node.script, repo_root, context=node.relation)
|
|
35
|
+
script_bytes = script_path.read_bytes()
|
|
36
|
+
root = repo_root.resolve()
|
|
37
|
+
members = [(member, member.read_bytes()) for member in resolve_closure(script_path, repo_root)]
|
|
38
|
+
units = {member.relative_to(root).as_posix(): data for member, data in members}
|
|
39
|
+
# Assets are data the script reads, not code that runs, so they are hashed
|
|
40
|
+
# but not part of code_files (which the lint reads).
|
|
41
|
+
for asset in node.assets:
|
|
42
|
+
units[asset] = resolve_asset_path(asset, repo_root, context=node.relation).read_bytes()
|
|
43
|
+
return HashInputs(
|
|
44
|
+
primary=script_bytes,
|
|
45
|
+
units=units,
|
|
46
|
+
code_files=((script_path, script_bytes), *members),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _csv_inputs(node: Node, repo_root: Path) -> HashInputs:
|
|
51
|
+
return HashInputs(node.reads["csv"].encode(), {}, ())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
HASH_INPUTS: dict[str, Callable[[Node, Path], HashInputs]] = {
|
|
55
|
+
"python-node": _script_inputs,
|
|
56
|
+
"python-csv": _csv_inputs,
|
|
57
|
+
"python-api": _script_inputs,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def gather_hash_inputs(node: Node, repo_root: Path) -> HashInputs:
|
|
62
|
+
"""Read the files *node* hashes, by its kind."""
|
|
63
|
+
return HASH_INPUTS[node.kind](node, repo_root)
|