hector-cli 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.
- hector/__init__.py +4 -0
- hector/__main__.py +7 -0
- hector/commands/__init__.py +35 -0
- hector/commands/base.py +111 -0
- hector/commands/export.py +26 -0
- hector/commands/init.py +15 -0
- hector/commands/run.py +27 -0
- hector/commands/test.py +52 -0
- hector/commands/validate.py +15 -0
- hector/connections.py +130 -0
- hector/core.py +259 -0
- hector/dependencies.py +41 -0
- hector/docker.py +191 -0
- hector/generator.py +292 -0
- hector/hubs.py +196 -0
- hector/mappings.py +167 -0
- hector/modules.py +201 -0
- hector/peripherals.py +168 -0
- hector/pipeline.py +693 -0
- hector/reporters.py +111 -0
- hector/runners.py +380 -0
- hector/scaffold.py +75 -0
- hector/validator.py +414 -0
- hector_cli-0.1.0.dist-info/METADATA +1401 -0
- hector_cli-0.1.0.dist-info/RECORD +29 -0
- hector_cli-0.1.0.dist-info/WHEEL +5 -0
- hector_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hector_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
- hector_cli-0.1.0.dist-info/top_level.txt +1 -0
hector/validator.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Config validation. Collects all errors in one pass so the user sees everything at once
|
|
4
|
+
rather than fixing one typo at a time.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from .validator import validate_config
|
|
8
|
+
errors, warnings = validate_config(raw_yaml_dict)
|
|
9
|
+
for e in errors: print(f" ERROR {e.path}: {e.message}")
|
|
10
|
+
for w in warnings: print(f" WARNING {w.path}: {w.message}")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import List, Tuple
|
|
16
|
+
|
|
17
|
+
from .core import SUPPORTED_SCHEMA_VERSIONS, as_lines
|
|
18
|
+
from .hubs import HUBS
|
|
19
|
+
from .mappings import MAPPING_BACKENDS
|
|
20
|
+
from .modules import MODULE_KINDS
|
|
21
|
+
from .runners import TEST_RUNNERS
|
|
22
|
+
|
|
23
|
+
# Top-level keys defined by this schema version; anything else is flagged.
|
|
24
|
+
_KNOWN_TOP_KEYS = {
|
|
25
|
+
"version", "renode_version", "arguments", "matrix",
|
|
26
|
+
"modules", "hubs", "machines", "connections", "mappings", "build", "tests", "quantum",
|
|
27
|
+
"artifacts", "ci",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Keys renamed in this schema version → new name (for a helpful migration hint).
|
|
31
|
+
_RENAMED_TOP_KEYS = {"nodes": "machines", "setup": "build", "prepare": "build"}
|
|
32
|
+
|
|
33
|
+
# Test types renamed this schema version → current name (old configs still run).
|
|
34
|
+
_RENAMED_TEST_TYPES = {"bash": "shell", "docker": "shell"}
|
|
35
|
+
|
|
36
|
+
# CI pipeline schema (top-level `ci:` block — when/how the CI server runs this config).
|
|
37
|
+
_CI_EVENTS = {"push", "pull_request", "tag", "manual", "cron"}
|
|
38
|
+
_CI_PIPELINE_KEYS = {"when", "arguments", "jobs", "reporters", "fail_fast", "timeout", "cron"}
|
|
39
|
+
_CI_WHEN_KEYS = {"branch", "event", "path"}
|
|
40
|
+
_CI_TIMEOUT_RE = re.compile(r"^\d+[smh]$")
|
|
41
|
+
|
|
42
|
+
_KNOWN_NODE_KEYS = {
|
|
43
|
+
"backend", "platform", "firmware",
|
|
44
|
+
"peripherals", "init", "connections", "mappings", "commands",
|
|
45
|
+
# deprecated at machine level (now global); kept here to emit a targeted warning
|
|
46
|
+
# instead of a generic "unknown key" one.
|
|
47
|
+
"artifacts", "prepare", "build",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_KNOWN_MODULE_KEYS = {"kind", "type", "source", "cmake_flags"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Issue:
|
|
55
|
+
path: str
|
|
56
|
+
message: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_config(config) -> Tuple[List[Issue], List[Issue]]:
|
|
60
|
+
"""Return (errors, warnings). errors are blockers; warnings are advisory."""
|
|
61
|
+
if not isinstance(config, dict):
|
|
62
|
+
return [Issue("(root)", "Config must be a YAML mapping, got "
|
|
63
|
+
f"{type(config).__name__}.")], []
|
|
64
|
+
|
|
65
|
+
errors: List[Issue] = []
|
|
66
|
+
warnings: List[Issue] = []
|
|
67
|
+
|
|
68
|
+
def err(path, msg):
|
|
69
|
+
errors.append(Issue(path, msg))
|
|
70
|
+
|
|
71
|
+
def warn(path, msg):
|
|
72
|
+
warnings.append(Issue(path, msg))
|
|
73
|
+
|
|
74
|
+
# ---- version ----
|
|
75
|
+
schema_ver = config.get("version")
|
|
76
|
+
if schema_ver is not None:
|
|
77
|
+
sv = str(schema_ver)
|
|
78
|
+
if sv not in SUPPORTED_SCHEMA_VERSIONS:
|
|
79
|
+
warn("version", f"'{sv}' is not in supported schema versions "
|
|
80
|
+
f"({', '.join(sorted(SUPPORTED_SCHEMA_VERSIONS))}). "
|
|
81
|
+
"Some fields may not be recognised.")
|
|
82
|
+
|
|
83
|
+
# ---- renode_version (required) ----
|
|
84
|
+
rv = config.get("renode_version")
|
|
85
|
+
if not rv:
|
|
86
|
+
err("renode_version", "Required field missing (e.g. '1.16.1').")
|
|
87
|
+
elif not isinstance(rv, (str, int, float)):
|
|
88
|
+
err("renode_version", f"Must be a string like '1.16.1', got {type(rv).__name__}.")
|
|
89
|
+
|
|
90
|
+
# ---- unknown / renamed top-level keys ----
|
|
91
|
+
for k in config:
|
|
92
|
+
if k in _RENAMED_TOP_KEYS:
|
|
93
|
+
warn(k, f"'{k}:' was renamed to '{_RENAMED_TOP_KEYS[k]}:' — rename the "
|
|
94
|
+
"top-level key. This entry is ignored.")
|
|
95
|
+
elif k not in _KNOWN_TOP_KEYS:
|
|
96
|
+
warn(k, f"Unknown top-level key '{k}' — check for a typo. "
|
|
97
|
+
f"Known keys: {', '.join(sorted(_KNOWN_TOP_KEYS))}.")
|
|
98
|
+
|
|
99
|
+
# ---- arguments ----
|
|
100
|
+
args_cfg = config.get("arguments")
|
|
101
|
+
if args_cfg is not None:
|
|
102
|
+
if not isinstance(args_cfg, dict):
|
|
103
|
+
err("arguments", f"Must be a mapping of name: default, got {type(args_cfg).__name__}.")
|
|
104
|
+
else:
|
|
105
|
+
for k, v in args_cfg.items():
|
|
106
|
+
if not isinstance(v, (str, int, float, bool)):
|
|
107
|
+
err(f"arguments.{k}", f"Argument default must be a scalar, got {type(v).__name__}.")
|
|
108
|
+
|
|
109
|
+
# ---- matrix ----
|
|
110
|
+
matrix_cfg = config.get("matrix")
|
|
111
|
+
if matrix_cfg is not None:
|
|
112
|
+
if not isinstance(matrix_cfg, dict):
|
|
113
|
+
err("matrix", f"Must be a mapping, got {type(matrix_cfg).__name__}.")
|
|
114
|
+
else:
|
|
115
|
+
variables = matrix_cfg.get("variables")
|
|
116
|
+
if variables is None:
|
|
117
|
+
err("matrix", "Missing 'variables' sub-key.")
|
|
118
|
+
elif not isinstance(variables, dict):
|
|
119
|
+
err("matrix.variables", f"Must be a mapping of name: [values], got {type(variables).__name__}.")
|
|
120
|
+
else:
|
|
121
|
+
for k, v in variables.items():
|
|
122
|
+
if not isinstance(v, list) or not v:
|
|
123
|
+
err(f"matrix.variables.{k}", "Must be a non-empty list of values.")
|
|
124
|
+
excludes = matrix_cfg.get("exclude", [])
|
|
125
|
+
if not isinstance(excludes, list):
|
|
126
|
+
err("matrix.exclude", f"Must be a list of dicts, got {type(excludes).__name__}.")
|
|
127
|
+
|
|
128
|
+
# ---- modules ----
|
|
129
|
+
modules_cfg = config.get("modules") or {}
|
|
130
|
+
if not isinstance(modules_cfg, dict):
|
|
131
|
+
err("modules", f"Must be a mapping, got {type(modules_cfg).__name__}.")
|
|
132
|
+
modules_cfg = {}
|
|
133
|
+
for mod_name, mod_spec in modules_cfg.items():
|
|
134
|
+
p = f"modules.{mod_name}"
|
|
135
|
+
if not isinstance(mod_spec, dict):
|
|
136
|
+
err(p, f"Must be a mapping, got {type(mod_spec).__name__}."); continue
|
|
137
|
+
kind = mod_spec.get("kind", "renode-verilator")
|
|
138
|
+
if kind not in MODULE_KINDS:
|
|
139
|
+
err(f"{p}.kind", f"Unknown module kind '{kind}'. "
|
|
140
|
+
f"Available: {', '.join(sorted(MODULE_KINDS.keys()))}.")
|
|
141
|
+
if not mod_spec.get("type"):
|
|
142
|
+
err(f"{p}.type", "Required: the Renode class this module exposes "
|
|
143
|
+
"(e.g. 'CoSimulated.CoSimulatedUART').")
|
|
144
|
+
if kind != "csharp" and not mod_spec.get("source"):
|
|
145
|
+
err(f"{p}.source", f"Required for kind '{kind}': path to the CMake/C# project.")
|
|
146
|
+
elif kind == "csharp" and not mod_spec.get("source"):
|
|
147
|
+
err(f"{p}.source", "Required: path to a .csproj directory or a pre-built .dll.")
|
|
148
|
+
for k in mod_spec:
|
|
149
|
+
if k not in _KNOWN_MODULE_KEYS:
|
|
150
|
+
warn(f"{p}.{k}", f"Unknown module key '{k}'.")
|
|
151
|
+
|
|
152
|
+
# ---- hubs ----
|
|
153
|
+
hubs_cfg = config.get("hubs") or {}
|
|
154
|
+
if not isinstance(hubs_cfg, dict):
|
|
155
|
+
err("hubs", f"Must be a mapping, got {type(hubs_cfg).__name__}.")
|
|
156
|
+
hubs_cfg = {}
|
|
157
|
+
for hub_name, hub_spec in hubs_cfg.items():
|
|
158
|
+
p = f"hubs.{hub_name}"
|
|
159
|
+
hub_spec = hub_spec or {}
|
|
160
|
+
if not isinstance(hub_spec, dict):
|
|
161
|
+
err(p, f"Must be a mapping, got {type(hub_spec).__name__}."); continue
|
|
162
|
+
htype = hub_spec.get("type")
|
|
163
|
+
if not htype:
|
|
164
|
+
err(f"{p}.type", f"Required. Available types: {', '.join(sorted(HUBS.keys()))}.")
|
|
165
|
+
elif htype not in HUBS:
|
|
166
|
+
err(f"{p}.type", f"Unknown hub type '{htype}'. "
|
|
167
|
+
f"Available: {', '.join(sorted(HUBS.keys()))}.")
|
|
168
|
+
|
|
169
|
+
# ---- machines (optional: a config may be build-only or run sim-independent
|
|
170
|
+
# shell tests; `run`/`export` and sim-backed tests check for one at runtime) ----
|
|
171
|
+
nodes_cfg = config.get("machines")
|
|
172
|
+
if nodes_cfg is None:
|
|
173
|
+
nodes_cfg = {}
|
|
174
|
+
elif not isinstance(nodes_cfg, dict):
|
|
175
|
+
err("machines", f"Must be a mapping, got {type(nodes_cfg).__name__}.")
|
|
176
|
+
nodes_cfg = {}
|
|
177
|
+
for node_name, node_spec in nodes_cfg.items():
|
|
178
|
+
p = f"machines.{node_name}"
|
|
179
|
+
if not isinstance(node_spec, dict):
|
|
180
|
+
err(p, f"Must be a mapping, got {type(node_spec).__name__}."); continue
|
|
181
|
+
backend = node_spec.get("backend", "renode")
|
|
182
|
+
if backend != "renode":
|
|
183
|
+
err(f"{p}.backend", f"Unknown backend '{backend}'. Only 'renode' is supported.")
|
|
184
|
+
hw = node_spec.get("platform")
|
|
185
|
+
if hw is not None and not isinstance(hw, list):
|
|
186
|
+
err(f"{p}.platform", f"Must be a list of .repl paths, got {type(hw).__name__}.")
|
|
187
|
+
fw = node_spec.get("firmware")
|
|
188
|
+
if fw is not None and not isinstance(fw, str):
|
|
189
|
+
err(f"{p}.firmware", f"Must be a string path or URL, got {type(fw).__name__}.")
|
|
190
|
+
for k in node_spec:
|
|
191
|
+
if k not in _KNOWN_NODE_KEYS:
|
|
192
|
+
warn(f"{p}.{k}", f"Unknown machine key '{k}'.")
|
|
193
|
+
# peripherals
|
|
194
|
+
perifs = node_spec.get("peripherals") or {}
|
|
195
|
+
if not isinstance(perifs, dict):
|
|
196
|
+
err(f"{p}.peripherals", f"Must be a mapping, got {type(perifs).__name__}.")
|
|
197
|
+
else:
|
|
198
|
+
for inst_name, inst_spec in perifs.items():
|
|
199
|
+
inst_spec = inst_spec or {}
|
|
200
|
+
pp = f"{p}.peripherals.{inst_name}"
|
|
201
|
+
if not isinstance(inst_spec, dict):
|
|
202
|
+
err(pp, f"Must be a mapping, got {type(inst_spec).__name__}."); continue
|
|
203
|
+
if not inst_spec.get("type"):
|
|
204
|
+
err(f"{pp}.type", "Required: either a module name or a Renode class "
|
|
205
|
+
"(e.g. 'Miscellaneous.Button').")
|
|
206
|
+
# artifacts is a top-level (global) key; per-machine entries are ignored.
|
|
207
|
+
if node_spec.get("artifacts") is not None:
|
|
208
|
+
warn(f"{p}.artifacts", "Machine-level 'artifacts:' is not supported; "
|
|
209
|
+
"move it to the top-level 'artifacts:' section. This entry is ignored.")
|
|
210
|
+
# build is a top-level (global) key; per-machine entries are ignored.
|
|
211
|
+
if node_spec.get("prepare") is not None or node_spec.get("build") is not None:
|
|
212
|
+
key = "build" if node_spec.get("build") is not None else "prepare"
|
|
213
|
+
warn(f"{p}.{key}", "Machine-level build/prepare is not supported; "
|
|
214
|
+
"move it to the top-level 'build:' section. This entry is ignored.")
|
|
215
|
+
|
|
216
|
+
# ---- connections / mappings: parse each line for obvious errors ----
|
|
217
|
+
_validate_connection_lines(config.get("connections", ""), "connections", err, warn)
|
|
218
|
+
for node_name, node_spec in nodes_cfg.items():
|
|
219
|
+
if isinstance(node_spec, dict):
|
|
220
|
+
_validate_connection_lines(
|
|
221
|
+
node_spec.get("connections", ""), f"machines.{node_name}.connections", err, warn)
|
|
222
|
+
_validate_mapping_lines(config.get("mappings", ""), "mappings", nodes_cfg, err)
|
|
223
|
+
for node_name, node_spec in nodes_cfg.items():
|
|
224
|
+
if isinstance(node_spec, dict):
|
|
225
|
+
_validate_mapping_lines(
|
|
226
|
+
node_spec.get("mappings", ""), f"machines.{node_name}.mappings", nodes_cfg, err)
|
|
227
|
+
|
|
228
|
+
# ---- artifacts (top-level, global) ----
|
|
229
|
+
artifacts_cfg = config.get("artifacts")
|
|
230
|
+
if artifacts_cfg is not None and not isinstance(artifacts_cfg, (list, str)):
|
|
231
|
+
err("artifacts", "Must be a list of glob patterns or a block string, got "
|
|
232
|
+
f"{type(artifacts_cfg).__name__}.")
|
|
233
|
+
|
|
234
|
+
# ---- build (top-level, global): pre-sim shell steps, same shape as shell tests ----
|
|
235
|
+
build_cfg = config.get("build")
|
|
236
|
+
if build_cfg is not None:
|
|
237
|
+
if not isinstance(build_cfg, list):
|
|
238
|
+
err("build", "Must be a list of build steps "
|
|
239
|
+
f"(each a mapping with image:/script:/steps:), got {type(build_cfg).__name__}.")
|
|
240
|
+
else:
|
|
241
|
+
for idx, block in enumerate(build_cfg):
|
|
242
|
+
bp = f"build[{idx}]"
|
|
243
|
+
if not isinstance(block, dict):
|
|
244
|
+
err(bp, f"Each build step must be a mapping, got {type(block).__name__}.")
|
|
245
|
+
continue
|
|
246
|
+
if block.get("image") is not None and not isinstance(block.get("image"), str):
|
|
247
|
+
err(f"{bp}.image", "If set, 'image' must be a string (e.g. 'arm-gcc:13').")
|
|
248
|
+
_validate_shell_steps(block, bp, err, warn)
|
|
249
|
+
|
|
250
|
+
# ---- tests ----
|
|
251
|
+
tests_cfg = config.get("tests") or []
|
|
252
|
+
if not isinstance(tests_cfg, list):
|
|
253
|
+
err("tests", f"Must be a list of test dicts, got {type(tests_cfg).__name__}.")
|
|
254
|
+
else:
|
|
255
|
+
for idx, test in enumerate(tests_cfg):
|
|
256
|
+
p = f"tests[{idx}]"
|
|
257
|
+
if not isinstance(test, dict):
|
|
258
|
+
err(p, f"Each test must be a mapping, got {type(test).__name__}."); continue
|
|
259
|
+
if not test.get("name"):
|
|
260
|
+
warn(p, "Test has no 'name' — consider adding one for readable output.")
|
|
261
|
+
rtype = test.get("type", "robot")
|
|
262
|
+
if rtype in _RENAMED_TEST_TYPES:
|
|
263
|
+
warn(f"{p}.type", f"Test type '{rtype}' was renamed to "
|
|
264
|
+
f"'{_RENAMED_TEST_TYPES[rtype]}' — update 'type:'.")
|
|
265
|
+
rtype = _RENAMED_TEST_TYPES[rtype]
|
|
266
|
+
if rtype not in TEST_RUNNERS:
|
|
267
|
+
err(f"{p}.type", f"Unknown test type '{rtype}'. "
|
|
268
|
+
f"Available: {', '.join(sorted(TEST_RUNNERS.keys()))}.")
|
|
269
|
+
if "requires_sim" in test and not isinstance(test["requires_sim"], bool):
|
|
270
|
+
err(f"{p}.requires_sim", "Must be true or false.")
|
|
271
|
+
if rtype == "robot" and test.get("requires_sim") is False:
|
|
272
|
+
warn(f"{p}.requires_sim", "robot tests always need the simulation; "
|
|
273
|
+
"'requires_sim: false' is ignored.")
|
|
274
|
+
_validate_shell_steps(test, p, err, warn)
|
|
275
|
+
|
|
276
|
+
# ---- quantum ----
|
|
277
|
+
quantum = config.get("quantum")
|
|
278
|
+
if quantum is not None:
|
|
279
|
+
try:
|
|
280
|
+
q = float(quantum)
|
|
281
|
+
if q <= 0:
|
|
282
|
+
err("quantum", f"Must be a positive number, got {quantum}.")
|
|
283
|
+
except (TypeError, ValueError):
|
|
284
|
+
err("quantum", f"Must be a positive number (seconds), got '{quantum}'.")
|
|
285
|
+
|
|
286
|
+
# ---- ci (CI pipelines) ----
|
|
287
|
+
_validate_ci(config.get("ci"), err, warn)
|
|
288
|
+
|
|
289
|
+
return errors, warnings
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---- helpers ----
|
|
293
|
+
|
|
294
|
+
def _validate_shell_steps(item, p, err, warn):
|
|
295
|
+
"""Validate the step structure shared by tests and build blocks: either a `steps:`
|
|
296
|
+
list, or a single-step `script:`/`file:` shorthand at the item level."""
|
|
297
|
+
if "steps" in item:
|
|
298
|
+
steps = item["steps"]
|
|
299
|
+
if not isinstance(steps, list):
|
|
300
|
+
err(f"{p}.steps", f"Must be a list, got {type(steps).__name__}.")
|
|
301
|
+
elif not steps:
|
|
302
|
+
warn(p, "Empty 'steps' list — it will do nothing.")
|
|
303
|
+
else:
|
|
304
|
+
for sidx, step in enumerate(steps):
|
|
305
|
+
sp = f"{p}.steps[{sidx}]"
|
|
306
|
+
if not isinstance(step, dict):
|
|
307
|
+
err(sp, f"Each step must be a mapping, got {type(step).__name__}.")
|
|
308
|
+
continue
|
|
309
|
+
if not step.get("name"):
|
|
310
|
+
warn(sp, "Step has no 'name' — consider adding one.")
|
|
311
|
+
if not step.get("script") and not step.get("file"):
|
|
312
|
+
warn(sp, "Step has no 'script' or 'file' — it will do nothing.")
|
|
313
|
+
if step.get("file") and step.get("script"):
|
|
314
|
+
warn(sp, "Step has both 'file' and 'script' — 'file' takes precedence.")
|
|
315
|
+
else:
|
|
316
|
+
# Backward compat: script/file at the item level (single-step shorthand).
|
|
317
|
+
if not item.get("script") and not item.get("file"):
|
|
318
|
+
warn(p, "No 'steps', 'script', or 'file' — it will do nothing.")
|
|
319
|
+
if item.get("file") and item.get("script"):
|
|
320
|
+
warn(p, "Both 'file' and 'script' — 'file' takes precedence.")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _validate_ci(ci_cfg, err, warn):
|
|
324
|
+
"""Validate the top-level `ci:` block (pipeline-name -> CI settings)."""
|
|
325
|
+
if ci_cfg is None:
|
|
326
|
+
return
|
|
327
|
+
if not isinstance(ci_cfg, dict):
|
|
328
|
+
err("ci", f"Must be a mapping of pipeline-name: settings, got {type(ci_cfg).__name__}.")
|
|
329
|
+
return
|
|
330
|
+
for name, pipe in ci_cfg.items():
|
|
331
|
+
p = f"ci.{name}"
|
|
332
|
+
if not isinstance(pipe, dict):
|
|
333
|
+
err(p, f"Pipeline must be a mapping, got {type(pipe).__name__}."); continue
|
|
334
|
+
for k in pipe:
|
|
335
|
+
if k not in _CI_PIPELINE_KEYS:
|
|
336
|
+
warn(f"{p}.{k}", f"Unknown pipeline key '{k}'. "
|
|
337
|
+
f"Known keys: {', '.join(sorted(_CI_PIPELINE_KEYS))}.")
|
|
338
|
+
|
|
339
|
+
# when: { branch, event, path }
|
|
340
|
+
when = pipe.get("when")
|
|
341
|
+
events = []
|
|
342
|
+
if when is not None:
|
|
343
|
+
if not isinstance(when, dict):
|
|
344
|
+
err(f"{p}.when", f"Must be a mapping, got {type(when).__name__}.")
|
|
345
|
+
else:
|
|
346
|
+
for k in when:
|
|
347
|
+
if k not in _CI_WHEN_KEYS:
|
|
348
|
+
warn(f"{p}.when.{k}", f"Unknown 'when' key '{k}'.")
|
|
349
|
+
raw_event = when.get("event")
|
|
350
|
+
if raw_event is None:
|
|
351
|
+
events = []
|
|
352
|
+
elif not isinstance(raw_event, list):
|
|
353
|
+
err(f"{p}.when.event", f"Must be a list, got {type(raw_event).__name__}.")
|
|
354
|
+
events = []
|
|
355
|
+
else:
|
|
356
|
+
events = raw_event
|
|
357
|
+
for ev in events:
|
|
358
|
+
if not isinstance(ev, str) or ev not in _CI_EVENTS:
|
|
359
|
+
err(f"{p}.when.event", f"Unknown event {ev!r}. "
|
|
360
|
+
f"Allowed: {', '.join(sorted(_CI_EVENTS))}.")
|
|
361
|
+
|
|
362
|
+
# jobs / fail_fast
|
|
363
|
+
jobs = pipe.get("jobs")
|
|
364
|
+
if jobs is not None and (not isinstance(jobs, int) or isinstance(jobs, bool) or jobs < 1):
|
|
365
|
+
err(f"{p}.jobs", f"Must be a positive integer, got {jobs!r}.")
|
|
366
|
+
if pipe.get("fail_fast") is True and (jobs or 1) != 1:
|
|
367
|
+
err(f"{p}.fail_fast", "Only valid with jobs: 1.")
|
|
368
|
+
|
|
369
|
+
# cron (required iff event includes 'cron')
|
|
370
|
+
has_cron_event = "cron" in events
|
|
371
|
+
if has_cron_event and not isinstance(pipe.get("cron"), str):
|
|
372
|
+
err(f"{p}.cron", "A cron expression is required when event includes 'cron'.")
|
|
373
|
+
if isinstance(pipe.get("cron"), str) and not has_cron_event:
|
|
374
|
+
warn(f"{p}.cron", "cron is set but when.event does not include 'cron'.")
|
|
375
|
+
|
|
376
|
+
# timeout / reporters / arguments
|
|
377
|
+
timeout = pipe.get("timeout")
|
|
378
|
+
if timeout is not None and (not isinstance(timeout, str) or not _CI_TIMEOUT_RE.match(timeout)):
|
|
379
|
+
err(f"{p}.timeout", "Must look like '30m', '90s' or '2h'.")
|
|
380
|
+
reporters = pipe.get("reporters")
|
|
381
|
+
if reporters is not None and not isinstance(reporters, list):
|
|
382
|
+
err(f"{p}.reporters", f"Must be a list, got {type(reporters).__name__}.")
|
|
383
|
+
arguments = pipe.get("arguments")
|
|
384
|
+
if arguments is not None and not isinstance(arguments, dict):
|
|
385
|
+
err(f"{p}.arguments", f"Must be a mapping of name: value, got {type(arguments).__name__}.")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _validate_connection_lines(section, path, err, warn):
|
|
389
|
+
for line in as_lines(section):
|
|
390
|
+
if "->" not in line and "<->" not in line:
|
|
391
|
+
err(path, f"Line '{line}' has no '->' or '<->' operator.")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _validate_mapping_lines(section, path, nodes_cfg, err):
|
|
395
|
+
for line in as_lines(section):
|
|
396
|
+
if "->" not in line:
|
|
397
|
+
err(path, f"Mapping '{line}' has no '->' operator.")
|
|
398
|
+
continue
|
|
399
|
+
_, rhs = line.split("->", 1)
|
|
400
|
+
backend = rhs.strip().split(":", 1)[0].strip()
|
|
401
|
+
if backend not in MAPPING_BACKENDS:
|
|
402
|
+
err(path, f"Mapping '{line}': unknown backend '{backend}'. "
|
|
403
|
+
f"Available: {', '.join(sorted(MAPPING_BACKENDS.keys()))}.")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def print_issues(errors, warnings):
|
|
407
|
+
"""Pretty-print validation results. Returns True if there are errors."""
|
|
408
|
+
if warnings:
|
|
409
|
+
for w in warnings:
|
|
410
|
+
print(f" WARN {w.path}: {w.message}")
|
|
411
|
+
if errors:
|
|
412
|
+
for e in errors:
|
|
413
|
+
print(f" ERROR {e.path}: {e.message}")
|
|
414
|
+
return bool(errors)
|