patchshuttle 0.1.0a2__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.
- patchshuttle/__init__.py +98 -0
- patchshuttle/_diff.py +317 -0
- patchshuttle/_process.py +198 -0
- patchshuttle/_version.py +3 -0
- patchshuttle/actions/__init__.py +80 -0
- patchshuttle/actions/constructors.py +211 -0
- patchshuttle/actions/create.py +155 -0
- patchshuttle/actions/modify.py +174 -0
- patchshuttle/audit.py +588 -0
- patchshuttle/backup.py +712 -0
- patchshuttle/checks/__init__.py +37 -0
- patchshuttle/checks/constructors.py +67 -0
- patchshuttle/checks/runner.py +233 -0
- patchshuttle/cli.py +766 -0
- patchshuttle/config.py +247 -0
- patchshuttle/context.py +370 -0
- patchshuttle/errors.py +291 -0
- patchshuttle/execution.py +651 -0
- patchshuttle/formatters/__init__.py +25 -0
- patchshuttle/formatters/runner.py +240 -0
- patchshuttle/identifiers.py +20 -0
- patchshuttle/inventory.py +331 -0
- patchshuttle/logging.py +741 -0
- patchshuttle/models.py +496 -0
- patchshuttle/operations.py +292 -0
- patchshuttle/parser.py +243 -0
- patchshuttle/planner.py +1144 -0
- patchshuttle/policy.py +377 -0
- patchshuttle/py.typed +1 -0
- patchshuttle/registry.py +275 -0
- patchshuttle/resources/AI_GUIDE.md +163 -0
- patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
- patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
- patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
- patchshuttle/resources/__init__.py +1 -0
- patchshuttle/rollback.py +306 -0
- patchshuttle/runner.py +880 -0
- patchshuttle/verification.py +107 -0
- patchshuttle/workspace.py +382 -0
- patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
- patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
- patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
- patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
- patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Project-level manual recovery operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
from patchshuttle.backup import (
|
|
10
|
+
BackupStatus,
|
|
11
|
+
load_completed_backup,
|
|
12
|
+
update_loaded_backup,
|
|
13
|
+
)
|
|
14
|
+
from patchshuttle.errors import ExecutionError, ExecutionErrorCode, JobError
|
|
15
|
+
from patchshuttle.logging import (
|
|
16
|
+
ManualRollbackLogRecord,
|
|
17
|
+
RunLogData,
|
|
18
|
+
current_run_clock,
|
|
19
|
+
write_run_log,
|
|
20
|
+
)
|
|
21
|
+
from patchshuttle.models import Job, JobKind
|
|
22
|
+
from patchshuttle.parser import load_job
|
|
23
|
+
from patchshuttle.planner import normalized_job_hash
|
|
24
|
+
from patchshuttle.registry import get_job, load_registry, update_registry
|
|
25
|
+
from patchshuttle.rollback import RollbackResult, rollback_completed_backup
|
|
26
|
+
from patchshuttle.runner import acquire_workspace_lock
|
|
27
|
+
from patchshuttle.workspace import Workspace
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ManualRollbackResult:
|
|
32
|
+
"""A verified successful user-requested rollback."""
|
|
33
|
+
|
|
34
|
+
job_id: str
|
|
35
|
+
job_hash: str
|
|
36
|
+
backup_path: Path
|
|
37
|
+
restored_files: tuple[PurePosixPath, ...]
|
|
38
|
+
removed_files: tuple[PurePosixPath, ...]
|
|
39
|
+
removed_directories: tuple[PurePosixPath, ...]
|
|
40
|
+
log_path: Path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def rollback_job(
|
|
44
|
+
workspace: Workspace,
|
|
45
|
+
job_id: str,
|
|
46
|
+
*,
|
|
47
|
+
approved: bool = False,
|
|
48
|
+
) -> ManualRollbackResult:
|
|
49
|
+
"""Safely restore one completed patch from its retained manifest."""
|
|
50
|
+
|
|
51
|
+
if not approved:
|
|
52
|
+
raise ExecutionError(
|
|
53
|
+
ExecutionErrorCode.APPROVAL_REQUIRED,
|
|
54
|
+
"explicit approval is required before a completed job is rolled back",
|
|
55
|
+
item_id=job_id,
|
|
56
|
+
)
|
|
57
|
+
clock = current_run_clock(workspace)
|
|
58
|
+
with acquire_workspace_lock(workspace):
|
|
59
|
+
registry = load_registry(workspace)
|
|
60
|
+
record = get_job(registry, job_id)
|
|
61
|
+
if record.kind != JobKind.PATCH.value:
|
|
62
|
+
raise ExecutionError(
|
|
63
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
64
|
+
"only a completed patch job can be rolled back",
|
|
65
|
+
item_id=job_id,
|
|
66
|
+
rollback_succeeded=False,
|
|
67
|
+
)
|
|
68
|
+
if not record.completed or record.backup_reference is None:
|
|
69
|
+
raise ExecutionError(
|
|
70
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
71
|
+
"job does not have a completed backup available for manual rollback",
|
|
72
|
+
item_id=job_id,
|
|
73
|
+
rollback_succeeded=False,
|
|
74
|
+
)
|
|
75
|
+
job, archived = _find_archived_job(
|
|
76
|
+
workspace,
|
|
77
|
+
job_id=job_id,
|
|
78
|
+
job_hash=record.job_hash,
|
|
79
|
+
preferred=record.archived_job_copy,
|
|
80
|
+
)
|
|
81
|
+
backup = load_completed_backup(
|
|
82
|
+
workspace,
|
|
83
|
+
record.backup_reference,
|
|
84
|
+
job_id=job_id,
|
|
85
|
+
job_hash=record.job_hash,
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
rollback = rollback_completed_backup(workspace, backup)
|
|
89
|
+
if not rollback.success:
|
|
90
|
+
update_loaded_backup(
|
|
91
|
+
backup,
|
|
92
|
+
BackupStatus.ROLLBACK_FAILED,
|
|
93
|
+
failure_code=ExecutionErrorCode.ROLLBACK_FAILED,
|
|
94
|
+
)
|
|
95
|
+
error = ExecutionError(
|
|
96
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
97
|
+
"manual rollback left one or more transaction paths unresolved",
|
|
98
|
+
item_id=job_id,
|
|
99
|
+
path=rollback.unresolved[0].as_posix(),
|
|
100
|
+
backup_path=backup.path,
|
|
101
|
+
rollback_succeeded=False,
|
|
102
|
+
)
|
|
103
|
+
_record_failed_rollback(
|
|
104
|
+
workspace,
|
|
105
|
+
registry,
|
|
106
|
+
job,
|
|
107
|
+
record.job_hash,
|
|
108
|
+
archived,
|
|
109
|
+
clock,
|
|
110
|
+
backup.path,
|
|
111
|
+
rollback,
|
|
112
|
+
error,
|
|
113
|
+
)
|
|
114
|
+
raise error
|
|
115
|
+
except ExecutionError as error:
|
|
116
|
+
if error.log_path is None:
|
|
117
|
+
_record_failed_rollback(
|
|
118
|
+
workspace,
|
|
119
|
+
registry,
|
|
120
|
+
job,
|
|
121
|
+
record.job_hash,
|
|
122
|
+
archived,
|
|
123
|
+
clock,
|
|
124
|
+
backup.path,
|
|
125
|
+
RollbackResult((), (), ()),
|
|
126
|
+
error,
|
|
127
|
+
)
|
|
128
|
+
raise
|
|
129
|
+
|
|
130
|
+
update_loaded_backup(backup, BackupStatus.ROLLED_BACK)
|
|
131
|
+
log_record = ManualRollbackLogRecord(
|
|
132
|
+
status="SUCCESS",
|
|
133
|
+
backup_path=backup.path,
|
|
134
|
+
restored_files=rollback.restored_files,
|
|
135
|
+
removed_files=rollback.removed_files,
|
|
136
|
+
removed_directories=rollback.removed_directories,
|
|
137
|
+
)
|
|
138
|
+
log_path = write_run_log(
|
|
139
|
+
RunLogData(
|
|
140
|
+
workspace=workspace,
|
|
141
|
+
job=job,
|
|
142
|
+
job_hash=record.job_hash,
|
|
143
|
+
clock=clock,
|
|
144
|
+
result="ROLLED_BACK",
|
|
145
|
+
exit_code=0,
|
|
146
|
+
failure_stage=None,
|
|
147
|
+
failure_code=None,
|
|
148
|
+
archived_job_path=archived,
|
|
149
|
+
manual_rollback=log_record,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
update_registry(
|
|
153
|
+
workspace,
|
|
154
|
+
registry,
|
|
155
|
+
job_id=job_id,
|
|
156
|
+
job_hash=record.job_hash,
|
|
157
|
+
kind=JobKind.PATCH,
|
|
158
|
+
occurred_at=clock.iso_timestamp,
|
|
159
|
+
result="ROLLED_BACK",
|
|
160
|
+
backup_path=backup.path,
|
|
161
|
+
rollback_state="SUCCESS",
|
|
162
|
+
archived_job_path=archived,
|
|
163
|
+
completed=False,
|
|
164
|
+
reset_completed=True,
|
|
165
|
+
)
|
|
166
|
+
return ManualRollbackResult(
|
|
167
|
+
job_id=job_id,
|
|
168
|
+
job_hash=record.job_hash,
|
|
169
|
+
backup_path=backup.path,
|
|
170
|
+
restored_files=rollback.restored_files,
|
|
171
|
+
removed_files=rollback.removed_files,
|
|
172
|
+
removed_directories=rollback.removed_directories,
|
|
173
|
+
log_path=log_path,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _record_failed_rollback(
|
|
178
|
+
workspace: Workspace,
|
|
179
|
+
registry,
|
|
180
|
+
job: Job,
|
|
181
|
+
job_hash: str,
|
|
182
|
+
archived: Path,
|
|
183
|
+
clock,
|
|
184
|
+
backup_path: Path,
|
|
185
|
+
rollback: RollbackResult,
|
|
186
|
+
error: ExecutionError,
|
|
187
|
+
) -> None:
|
|
188
|
+
log_path = write_run_log(
|
|
189
|
+
RunLogData(
|
|
190
|
+
workspace=workspace,
|
|
191
|
+
job=job,
|
|
192
|
+
job_hash=job_hash,
|
|
193
|
+
clock=clock,
|
|
194
|
+
result="ROLLBACK_FAILED",
|
|
195
|
+
exit_code=8,
|
|
196
|
+
failure_stage="ROLLBACK",
|
|
197
|
+
failure_code=(error.cause_code or error.code).value,
|
|
198
|
+
archived_job_path=archived,
|
|
199
|
+
error=error,
|
|
200
|
+
manual_rollback=ManualRollbackLogRecord(
|
|
201
|
+
status="FAILED",
|
|
202
|
+
backup_path=backup_path,
|
|
203
|
+
restored_files=rollback.restored_files,
|
|
204
|
+
removed_files=rollback.removed_files,
|
|
205
|
+
removed_directories=rollback.removed_directories,
|
|
206
|
+
unresolved=rollback.unresolved,
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
update_registry(
|
|
211
|
+
workspace,
|
|
212
|
+
registry,
|
|
213
|
+
job_id=job.id,
|
|
214
|
+
job_hash=job_hash,
|
|
215
|
+
kind=JobKind.PATCH,
|
|
216
|
+
occurred_at=clock.iso_timestamp,
|
|
217
|
+
result="ROLLBACK_FAILED",
|
|
218
|
+
backup_path=backup_path,
|
|
219
|
+
rollback_state="FAILED",
|
|
220
|
+
archived_job_path=archived,
|
|
221
|
+
completed=False,
|
|
222
|
+
)
|
|
223
|
+
error.log_path = log_path
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _find_archived_job(
|
|
227
|
+
workspace: Workspace,
|
|
228
|
+
*,
|
|
229
|
+
job_id: str,
|
|
230
|
+
job_hash: str,
|
|
231
|
+
preferred: str,
|
|
232
|
+
) -> tuple[Job, Path]:
|
|
233
|
+
candidates: list[Path] = []
|
|
234
|
+
preferred_path = _safe_archive_path(workspace, preferred)
|
|
235
|
+
if preferred_path is not None:
|
|
236
|
+
candidates.append(preferred_path)
|
|
237
|
+
for directory_name in ("applied", "failed"):
|
|
238
|
+
directory = workspace.patches_dir / directory_name
|
|
239
|
+
try:
|
|
240
|
+
paths = sorted(
|
|
241
|
+
directory.glob(f"{job_id}_*_{job_hash[:8]}.psh.yaml"),
|
|
242
|
+
reverse=True,
|
|
243
|
+
)
|
|
244
|
+
except OSError:
|
|
245
|
+
paths = []
|
|
246
|
+
candidates.extend(path for path in paths if path not in candidates)
|
|
247
|
+
for path in candidates:
|
|
248
|
+
try:
|
|
249
|
+
metadata = path.lstat()
|
|
250
|
+
if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():
|
|
251
|
+
continue
|
|
252
|
+
job = load_job(
|
|
253
|
+
path,
|
|
254
|
+
max_bytes=workspace.config.execution.max_job_bytes,
|
|
255
|
+
)
|
|
256
|
+
except (OSError, JobError, ValueError):
|
|
257
|
+
continue
|
|
258
|
+
if (
|
|
259
|
+
job.id == job_id
|
|
260
|
+
and job.kind is JobKind.PATCH
|
|
261
|
+
and normalized_job_hash(job) == job_hash
|
|
262
|
+
):
|
|
263
|
+
return job, path
|
|
264
|
+
raise ExecutionError(
|
|
265
|
+
ExecutionErrorCode.ROLLBACK_FAILED,
|
|
266
|
+
"an intact archived copy of the completed job was not found",
|
|
267
|
+
item_id=job_id,
|
|
268
|
+
rollback_succeeded=False,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _safe_archive_path(workspace: Workspace, value: str) -> Path | None:
|
|
273
|
+
relative = PurePosixPath(value)
|
|
274
|
+
if (
|
|
275
|
+
relative.is_absolute()
|
|
276
|
+
or "\\" in value
|
|
277
|
+
or len(relative.parts) != 3
|
|
278
|
+
or relative.parts[0] != "patches"
|
|
279
|
+
or relative.parts[1] not in {"applied", "failed"}
|
|
280
|
+
or any(part in {"", ".", ".."} for part in relative.parts)
|
|
281
|
+
):
|
|
282
|
+
return None
|
|
283
|
+
path = workspace.root.joinpath(*relative.parts)
|
|
284
|
+
try:
|
|
285
|
+
if path.resolve() != path.absolute():
|
|
286
|
+
return None
|
|
287
|
+
except OSError:
|
|
288
|
+
return None
|
|
289
|
+
return path
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
__all__ = ["ManualRollbackResult", "rollback_job"]
|
patchshuttle/parser.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Safe loading and structural validation for ``.psh.yaml`` jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
|
|
16
|
+
from yaml.tokens import AliasToken, AnchorToken
|
|
17
|
+
|
|
18
|
+
from patchshuttle.errors import JobError, JobErrorCode
|
|
19
|
+
from patchshuttle.models import Job
|
|
20
|
+
|
|
21
|
+
DEFAULT_MAX_JOB_BYTES = 2_000_000
|
|
22
|
+
_CANONICAL_EXTENSION = ".psh.yaml"
|
|
23
|
+
_STRING_TAG = "tag:yaml.org,2002:str"
|
|
24
|
+
_SAFE_STANDARD_TAGS = frozenset(
|
|
25
|
+
tag for tag in yaml.SafeLoader.yaml_constructors if isinstance(tag, str)
|
|
26
|
+
)
|
|
27
|
+
_PLAIN_PATH_SEGMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_job(
|
|
31
|
+
path: str | PathLike[str],
|
|
32
|
+
*,
|
|
33
|
+
max_bytes: int = DEFAULT_MAX_JOB_BYTES,
|
|
34
|
+
) -> Job:
|
|
35
|
+
"""Load one canonical UTF-8 YAML file and return a validated immutable job."""
|
|
36
|
+
|
|
37
|
+
if type(max_bytes) is not int or max_bytes <= 0:
|
|
38
|
+
raise ValueError("max_bytes must be a positive integer")
|
|
39
|
+
|
|
40
|
+
job_path = Path(path)
|
|
41
|
+
if not job_path.name.endswith(_CANONICAL_EXTENSION):
|
|
42
|
+
raise JobError(
|
|
43
|
+
JobErrorCode.JOB_EXTENSION_INVALID,
|
|
44
|
+
f"job filename must end with {_CANONICAL_EXTENSION}",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
file_stat = job_path.stat()
|
|
49
|
+
except FileNotFoundError as exc:
|
|
50
|
+
raise JobError(
|
|
51
|
+
JobErrorCode.JOB_FILE_NOT_FOUND,
|
|
52
|
+
"job file was not found",
|
|
53
|
+
) from exc
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
raise JobError(
|
|
56
|
+
JobErrorCode.JOB_FILE_READ_FAILED,
|
|
57
|
+
"job file metadata could not be read",
|
|
58
|
+
) from exc
|
|
59
|
+
|
|
60
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
61
|
+
raise JobError(
|
|
62
|
+
JobErrorCode.JOB_FILE_NOT_REGULAR,
|
|
63
|
+
"job path must identify a regular file",
|
|
64
|
+
)
|
|
65
|
+
if file_stat.st_size > max_bytes:
|
|
66
|
+
raise JobError(
|
|
67
|
+
JobErrorCode.JOB_SIZE_LIMIT_EXCEEDED,
|
|
68
|
+
f"job file exceeds the {max_bytes}-byte input limit",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
raw = job_path.read_bytes()
|
|
73
|
+
except FileNotFoundError as exc:
|
|
74
|
+
raise JobError(
|
|
75
|
+
JobErrorCode.JOB_FILE_NOT_FOUND,
|
|
76
|
+
"job file was not found",
|
|
77
|
+
) from exc
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
raise JobError(
|
|
80
|
+
JobErrorCode.JOB_FILE_READ_FAILED,
|
|
81
|
+
"job file could not be read",
|
|
82
|
+
) from exc
|
|
83
|
+
|
|
84
|
+
if len(raw) > max_bytes:
|
|
85
|
+
raise JobError(
|
|
86
|
+
JobErrorCode.JOB_SIZE_LIMIT_EXCEEDED,
|
|
87
|
+
f"job file exceeds the {max_bytes}-byte input limit",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
text = raw.decode("utf-8")
|
|
92
|
+
except UnicodeDecodeError as exc:
|
|
93
|
+
raise JobError(
|
|
94
|
+
JobErrorCode.JOB_ENCODING_INVALID,
|
|
95
|
+
"job file must be valid UTF-8 text",
|
|
96
|
+
) from exc
|
|
97
|
+
|
|
98
|
+
return validate_job(_load_yaml(text))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_job(value: object) -> Job:
|
|
102
|
+
"""Validate mapping-like data with the same models used by YAML jobs."""
|
|
103
|
+
|
|
104
|
+
if isinstance(value, Job):
|
|
105
|
+
return value
|
|
106
|
+
if not isinstance(value, Mapping):
|
|
107
|
+
raise JobError(
|
|
108
|
+
JobErrorCode.JOB_ROOT_INVALID,
|
|
109
|
+
"job document root must be a mapping",
|
|
110
|
+
field_path="$",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
return Job.model_validate(value)
|
|
115
|
+
except ValidationError as exc:
|
|
116
|
+
first_error = exc.errors(include_url=False)[0]
|
|
117
|
+
raise JobError(
|
|
118
|
+
JobErrorCode.JOB_SCHEMA_INVALID,
|
|
119
|
+
str(first_error["msg"]),
|
|
120
|
+
field_path=_format_validation_path(first_error["loc"]),
|
|
121
|
+
) from exc
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _load_yaml(text: str) -> Mapping[str, Any]:
|
|
125
|
+
try:
|
|
126
|
+
_reject_references(text)
|
|
127
|
+
node = yaml.compose(text, Loader=yaml.SafeLoader)
|
|
128
|
+
except JobError:
|
|
129
|
+
raise
|
|
130
|
+
except (yaml.YAMLError, RecursionError) as exc:
|
|
131
|
+
raise _yaml_syntax_error(exc) from exc
|
|
132
|
+
|
|
133
|
+
if node is None:
|
|
134
|
+
raise JobError(
|
|
135
|
+
JobErrorCode.JOB_ROOT_INVALID,
|
|
136
|
+
"job document root must be a mapping",
|
|
137
|
+
field_path="$",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
_validate_node(node, path="$")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
data = yaml.safe_load(text)
|
|
144
|
+
except (yaml.YAMLError, RecursionError) as exc:
|
|
145
|
+
raise _yaml_syntax_error(exc) from exc
|
|
146
|
+
|
|
147
|
+
if not isinstance(data, Mapping):
|
|
148
|
+
raise JobError(
|
|
149
|
+
JobErrorCode.JOB_ROOT_INVALID,
|
|
150
|
+
"job document root must be a mapping",
|
|
151
|
+
field_path="$",
|
|
152
|
+
)
|
|
153
|
+
return data
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _reject_references(text: str) -> None:
|
|
157
|
+
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
|
158
|
+
if isinstance(token, AnchorToken):
|
|
159
|
+
raise JobError(
|
|
160
|
+
JobErrorCode.YAML_ANCHOR_FORBIDDEN,
|
|
161
|
+
"YAML anchors are not allowed",
|
|
162
|
+
field_path="$",
|
|
163
|
+
line=token.start_mark.line + 1,
|
|
164
|
+
column=token.start_mark.column + 1,
|
|
165
|
+
)
|
|
166
|
+
if isinstance(token, AliasToken):
|
|
167
|
+
raise JobError(
|
|
168
|
+
JobErrorCode.YAML_ALIAS_FORBIDDEN,
|
|
169
|
+
"YAML aliases are not allowed",
|
|
170
|
+
field_path="$",
|
|
171
|
+
line=token.start_mark.line + 1,
|
|
172
|
+
column=token.start_mark.column + 1,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _validate_node(node: Node, *, path: str) -> None:
|
|
177
|
+
if node.tag not in _SAFE_STANDARD_TAGS:
|
|
178
|
+
raise JobError(
|
|
179
|
+
JobErrorCode.YAML_TAG_FORBIDDEN,
|
|
180
|
+
"custom YAML tags are not allowed",
|
|
181
|
+
field_path=path,
|
|
182
|
+
line=node.start_mark.line + 1,
|
|
183
|
+
column=node.start_mark.column + 1,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if isinstance(node, MappingNode):
|
|
187
|
+
seen: set[str] = set()
|
|
188
|
+
for key_node, value_node in node.value:
|
|
189
|
+
if not isinstance(key_node, ScalarNode) or key_node.tag != _STRING_TAG:
|
|
190
|
+
raise JobError(
|
|
191
|
+
JobErrorCode.YAML_MAPPING_KEY_INVALID,
|
|
192
|
+
"YAML mapping keys must be strings",
|
|
193
|
+
field_path=path,
|
|
194
|
+
line=key_node.start_mark.line + 1,
|
|
195
|
+
column=key_node.start_mark.column + 1,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
key = key_node.value
|
|
199
|
+
child_path = _append_field(path, key)
|
|
200
|
+
if key in seen:
|
|
201
|
+
raise JobError(
|
|
202
|
+
JobErrorCode.YAML_DUPLICATE_KEY,
|
|
203
|
+
f"duplicate mapping key {key!r}",
|
|
204
|
+
field_path=child_path,
|
|
205
|
+
line=key_node.start_mark.line + 1,
|
|
206
|
+
column=key_node.start_mark.column + 1,
|
|
207
|
+
)
|
|
208
|
+
seen.add(key)
|
|
209
|
+
_validate_node(value_node, path=child_path)
|
|
210
|
+
|
|
211
|
+
elif isinstance(node, SequenceNode):
|
|
212
|
+
for index, item in enumerate(node.value):
|
|
213
|
+
_validate_node(item, path=f"{path}[{index}]")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _yaml_syntax_error(exc: BaseException) -> JobError:
|
|
217
|
+
mark = getattr(exc, "problem_mark", None) or getattr(exc, "context_mark", None)
|
|
218
|
+
return JobError(
|
|
219
|
+
JobErrorCode.YAML_INVALID,
|
|
220
|
+
"job file contains invalid YAML syntax",
|
|
221
|
+
field_path="$",
|
|
222
|
+
line=mark.line + 1 if mark is not None else 1,
|
|
223
|
+
column=mark.column + 1 if mark is not None else 1,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _format_validation_path(location: tuple[int | str, ...]) -> str:
|
|
228
|
+
path = "$"
|
|
229
|
+
for part in location:
|
|
230
|
+
if isinstance(part, int):
|
|
231
|
+
path = f"{path}[{part}]"
|
|
232
|
+
else:
|
|
233
|
+
path = _append_field(path, str(part))
|
|
234
|
+
return path
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _append_field(path: str, field: str) -> str:
|
|
238
|
+
if _PLAIN_PATH_SEGMENT.fullmatch(field):
|
|
239
|
+
return f"{path}.{field}"
|
|
240
|
+
return f"{path}[{json.dumps(field, ensure_ascii=False)}]"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = ["DEFAULT_MAX_JOB_BYTES", "load_job", "validate_job"]
|