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/core.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Core plumbing: the extension Registry, shared constants, host<->container path
|
|
4
|
+
translation, YAML/interpolation/matrix helpers, repl emitters, and the endpoint parser.
|
|
5
|
+
|
|
6
|
+
You rarely need to touch this file. The interesting, extensible parts live in:
|
|
7
|
+
hubs.py - add a cross-machine connection medium (uart, can, gpio, ...)
|
|
8
|
+
runners.py - add a test runner (bash, robot, renode-test, ...)
|
|
9
|
+
mappings.py - add a peripheral->host backend (file, ...)
|
|
10
|
+
modules.py - add a module build kind (renode-verilator, ...)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import itertools
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ==========================================================================
|
|
23
|
+
# REGISTRY (the extension mechanism used across the codebase)
|
|
24
|
+
# ==========================================================================
|
|
25
|
+
class Registry:
|
|
26
|
+
"""A named lookup table you extend with a decorator.
|
|
27
|
+
|
|
28
|
+
WIDGETS = Registry("widget")
|
|
29
|
+
|
|
30
|
+
@WIDGETS.register("foo")
|
|
31
|
+
class Foo: ...
|
|
32
|
+
|
|
33
|
+
WIDGETS.get("foo") # -> Foo
|
|
34
|
+
"foo" in WIDGETS # -> True
|
|
35
|
+
list(WIDGETS.keys()) # -> ["foo", ...]
|
|
36
|
+
|
|
37
|
+
Unknown keys raise a helpful error listing what *is* available, so a typo in
|
|
38
|
+
the YAML produces a clear message instead of a KeyError.
|
|
39
|
+
"""
|
|
40
|
+
def __init__(self, name):
|
|
41
|
+
self.name = name
|
|
42
|
+
self._items = {}
|
|
43
|
+
|
|
44
|
+
def register(self, key):
|
|
45
|
+
def deco(obj):
|
|
46
|
+
if key in self._items:
|
|
47
|
+
raise ValueError(f"{self.name} '{key}' is already registered.")
|
|
48
|
+
self._items[key] = obj
|
|
49
|
+
return obj
|
|
50
|
+
return deco
|
|
51
|
+
|
|
52
|
+
def get(self, key):
|
|
53
|
+
if key not in self._items:
|
|
54
|
+
raise KeyError(
|
|
55
|
+
f"Unknown {self.name} '{key}'. Available: {', '.join(sorted(self._items)) or '(none)'}."
|
|
56
|
+
)
|
|
57
|
+
return self._items[key]
|
|
58
|
+
|
|
59
|
+
def __contains__(self, key):
|
|
60
|
+
return key in self._items
|
|
61
|
+
|
|
62
|
+
def keys(self):
|
|
63
|
+
return self._items.keys()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ==========================================================================
|
|
67
|
+
# CONSTANTS
|
|
68
|
+
# ==========================================================================
|
|
69
|
+
TOOL_VERSION = "0.1.0"
|
|
70
|
+
SUPPORTED_SCHEMA_VERSIONS = {"0.1"}
|
|
71
|
+
|
|
72
|
+
RENODE_REPO_URL = "https://github.com/renode/renode.git"
|
|
73
|
+
INTEGRATION_REPO_URL = "https://github.com/antmicro/renode-verilator-integration.git"
|
|
74
|
+
|
|
75
|
+
ARTIFACTS_DIR = ".hector" # everything we generate/clone lives here (gitignore it)
|
|
76
|
+
WORKSPACE_MOUNT = os.getcwd() # cwd is bind-mounted at the same path inside the container
|
|
77
|
+
NO_DOCKER = False # when True, call renode/renode-test directly on host
|
|
78
|
+
EXTRA_MOUNTS = [] # dep dirs outside the workspace, bind-mounted 1:1 into every container
|
|
79
|
+
LIBOPENLIBM_NAME = "libopenlibm-Linux-x86_64.a"
|
|
80
|
+
|
|
81
|
+
DEFAULT_QUANTUM = "0.00001" # 10 us; applied when any hub is present
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ==========================================================================
|
|
85
|
+
# RUNTIME OPTIONS (execution-environment needs declared by components)
|
|
86
|
+
# ==========================================================================
|
|
87
|
+
@dataclass
|
|
88
|
+
class RuntimeOptions:
|
|
89
|
+
"""Execution-environment requirements a component (mapping / hub / module) needs
|
|
90
|
+
from the Renode run. Components declare these at parse time; the pipeline merges
|
|
91
|
+
them across all components and the docker layer maps them to `docker run` flags at
|
|
92
|
+
execution time. Add fields here as new needs arise (ports, mounts, ...)."""
|
|
93
|
+
capabilities: set = field(default_factory=set) # -> docker --cap-add
|
|
94
|
+
devices: set = field(default_factory=set) # -> docker --device
|
|
95
|
+
root: bool = False # -> docker -u 0:0 (run as root)
|
|
96
|
+
|
|
97
|
+
def merge(self, other):
|
|
98
|
+
"""Fold another RuntimeOptions (or None) into this one."""
|
|
99
|
+
if other:
|
|
100
|
+
self.capabilities |= other.capabilities
|
|
101
|
+
self.devices |= other.devices
|
|
102
|
+
self.root = self.root or other.root
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ==========================================================================
|
|
107
|
+
# PATH TRANSLATION (host <-> container)
|
|
108
|
+
# ==========================================================================
|
|
109
|
+
def to_container_path(host_path):
|
|
110
|
+
abs_path = os.path.realpath(host_path)
|
|
111
|
+
root = os.path.realpath(os.getcwd())
|
|
112
|
+
if os.path.commonpath([abs_path, root]) != root:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"Path '{host_path}' resolves outside the project root '{root}'. Everything used "
|
|
115
|
+
f"at runtime must live under the project folder (mounted at '{WORKSPACE_MOUNT}')."
|
|
116
|
+
)
|
|
117
|
+
return os.path.join(WORKSPACE_MOUNT, os.path.relpath(abs_path, root))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def register_dependency_mount(host_dir):
|
|
121
|
+
"""Return the container path for a dependency checkout (Renode source / integration),
|
|
122
|
+
bind-mounting it 1:1 into every container if it lives OUTSIDE the workspace.
|
|
123
|
+
|
|
124
|
+
Dirs under the workspace are already covered by the workspace mount, so they map via
|
|
125
|
+
to_container_path. An out-of-tree dir (e.g. a shared cache) is registered in
|
|
126
|
+
EXTRA_MOUNTS and used at its own absolute path inside the container."""
|
|
127
|
+
abs_dir = os.path.realpath(host_dir)
|
|
128
|
+
root = os.path.realpath(os.getcwd())
|
|
129
|
+
if os.path.commonpath([abs_dir, root]) == root:
|
|
130
|
+
return to_container_path(host_dir)
|
|
131
|
+
if abs_dir not in EXTRA_MOUNTS:
|
|
132
|
+
EXTRA_MOUNTS.append(abs_dir)
|
|
133
|
+
return abs_dir
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def container_path_for(host_path):
|
|
137
|
+
"""Container path for a host path that may live under an out-of-tree dependency mount
|
|
138
|
+
(EXTRA_MOUNTS, bind-mounted 1:1); otherwise the normal workspace mapping."""
|
|
139
|
+
abs_p = os.path.realpath(host_path)
|
|
140
|
+
for m in EXTRA_MOUNTS:
|
|
141
|
+
m_abs = os.path.realpath(m)
|
|
142
|
+
if abs_p == m_abs or abs_p.startswith(m_abs + os.sep):
|
|
143
|
+
return abs_p
|
|
144
|
+
return to_container_path(host_path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def container_to_host(container_path):
|
|
148
|
+
if container_path != WORKSPACE_MOUNT and not container_path.startswith(WORKSPACE_MOUNT + os.sep):
|
|
149
|
+
raise ValueError(f"'{container_path}' is not under {WORKSPACE_MOUNT}.")
|
|
150
|
+
rel = os.path.relpath(container_path, WORKSPACE_MOUNT)
|
|
151
|
+
host = os.path.realpath(os.path.join(os.getcwd(), rel))
|
|
152
|
+
root = os.path.realpath(os.getcwd())
|
|
153
|
+
if os.path.commonpath([host, root]) != root:
|
|
154
|
+
raise ValueError(f"'{container_path}' maps outside the project root.")
|
|
155
|
+
return host
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def split_host_container(path):
|
|
159
|
+
if path == WORKSPACE_MOUNT or path.startswith(WORKSPACE_MOUNT + os.sep):
|
|
160
|
+
return container_to_host(path), path
|
|
161
|
+
host = os.path.realpath(path)
|
|
162
|
+
return host, to_container_path(host)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ==========================================================================
|
|
166
|
+
# YAML / TEXT HELPERS
|
|
167
|
+
# ==========================================================================
|
|
168
|
+
def load_yaml(filepath):
|
|
169
|
+
with open(filepath, "r") as f:
|
|
170
|
+
return yaml.safe_load(f)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_shell(cmd, cwd=None, shell="/bin/bash"):
|
|
174
|
+
subprocess.run(cmd, shell=True, check=True, executable=shell, cwd=cwd)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def as_lines(section):
|
|
178
|
+
"""Normalize a YAML block-string or list into meaningful lines (drop blanks and
|
|
179
|
+
full-line '#' comments)."""
|
|
180
|
+
if not section:
|
|
181
|
+
return []
|
|
182
|
+
raw = section.splitlines() if isinstance(section, str) else list(section)
|
|
183
|
+
return [s.strip() for s in (str(x) for x in raw)
|
|
184
|
+
if s.strip() and not s.strip().startswith("#")]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ==========================================================================
|
|
188
|
+
# MATRIX & INTERPOLATION
|
|
189
|
+
# ==========================================================================
|
|
190
|
+
def expand_matrix(matrix_block):
|
|
191
|
+
if not matrix_block or "variables" not in matrix_block:
|
|
192
|
+
return [{}]
|
|
193
|
+
variables = matrix_block["variables"]
|
|
194
|
+
excludes = matrix_block.get("exclude", [])
|
|
195
|
+
all_jobs = [dict(zip(variables.keys(), combo))
|
|
196
|
+
for combo in itertools.product(*variables.values())]
|
|
197
|
+
return [job for job in all_jobs
|
|
198
|
+
if not any(all(job.get(k) == v for k, v in rule.items()) for rule in excludes)]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def resolve_arguments(arguments, overrides=None):
|
|
202
|
+
"""name -> value. Precedence: --set flag > env var > config default."""
|
|
203
|
+
overrides = overrides or {}
|
|
204
|
+
resolved = {}
|
|
205
|
+
for name, default in (arguments or {}).items():
|
|
206
|
+
if name in overrides:
|
|
207
|
+
resolved[name] = overrides[name]
|
|
208
|
+
source = "cli"
|
|
209
|
+
elif name in os.environ:
|
|
210
|
+
resolved[name] = os.environ[name]
|
|
211
|
+
source = "env"
|
|
212
|
+
else:
|
|
213
|
+
resolved[name] = default
|
|
214
|
+
source = "default"
|
|
215
|
+
print(f"[ARG] {name} = {resolved[name]} ({source})")
|
|
216
|
+
# --set can also introduce keys not declared in arguments:
|
|
217
|
+
for name, value in overrides.items():
|
|
218
|
+
if name not in resolved:
|
|
219
|
+
resolved[name] = value
|
|
220
|
+
print(f"[ARG] {name} = {value} (cli, undeclared)")
|
|
221
|
+
return resolved
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def interpolate(data, context):
|
|
225
|
+
if isinstance(data, str):
|
|
226
|
+
return re.sub(r"\$\{([^}]+)\}",
|
|
227
|
+
lambda m: str(context.get(m.group(1), m.group(0))), data)
|
|
228
|
+
if isinstance(data, dict):
|
|
229
|
+
return {k: interpolate(v, context) for k, v in data.items()}
|
|
230
|
+
if isinstance(data, list):
|
|
231
|
+
return [interpolate(item, context) for item in data]
|
|
232
|
+
return data
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ==========================================================================
|
|
236
|
+
# ENDPOINT PARSER (connections + mappings)
|
|
237
|
+
# node.name -> kind='plain'
|
|
238
|
+
# node.name@pin -> kind='pin', pin=N (Renode-style '@' pin / line number)
|
|
239
|
+
# name -> kind='self' (bare; typically a hub reference)
|
|
240
|
+
# ==========================================================================
|
|
241
|
+
def parse_endpoint(token):
|
|
242
|
+
token = token.strip()
|
|
243
|
+
if "." not in token:
|
|
244
|
+
return {"node": token, "name": token, "kind": "self", "raw": token}
|
|
245
|
+
node, rest = token.split(".", 1)
|
|
246
|
+
ep = {"node": node, "raw": rest}
|
|
247
|
+
m = re.match(r"^([A-Za-z0-9_]+)@(0x[0-9A-Fa-f]+|\d+)$", rest)
|
|
248
|
+
if m:
|
|
249
|
+
ep.update(kind="pin", name=m.group(1), pin=int(m.group(2), 0))
|
|
250
|
+
return ep
|
|
251
|
+
if re.match(r"^[A-Za-z0-9_]+$", rest):
|
|
252
|
+
ep.update(kind="plain", name=rest)
|
|
253
|
+
return ep
|
|
254
|
+
# node.periph.signal (e.g. mcu.nvic.IRQ)
|
|
255
|
+
m2 = re.match(r"^([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)$", rest)
|
|
256
|
+
if m2:
|
|
257
|
+
ep.update(kind="signal", periph=m2.group(1), name=m2.group(1), signal=m2.group(2))
|
|
258
|
+
return ep
|
|
259
|
+
raise ValueError(f"Could not parse endpoint '{token}'")
|
hector/dependencies.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Managed clones of Renode and the verilator integration, pinned to renode_version."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from .core import (ARTIFACTS_DIR, INTEGRATION_REPO_URL, RENODE_REPO_URL,
|
|
7
|
+
register_dependency_mount, run_shell)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def prepare_dependencies(git_tag, renode_dir=None, integration_dir=None):
|
|
11
|
+
"""Ensure the Renode source and verilator-integration checkouts exist, returning
|
|
12
|
+
their paths. Each location defaults to .hector/ but can be overridden (e.g. a cached
|
|
13
|
+
or shared checkout): an existing directory is reused as-is (no clone), a missing one
|
|
14
|
+
is cloned into. Only called when the run actually needs the source (see callers)."""
|
|
15
|
+
os.makedirs(ARTIFACTS_DIR, exist_ok=True)
|
|
16
|
+
renode_dir = renode_dir or os.path.join(ARTIFACTS_DIR, "renode")
|
|
17
|
+
integration_dir = integration_dir or os.path.join(ARTIFACTS_DIR, "renode-verilator-integration")
|
|
18
|
+
|
|
19
|
+
if not os.path.isdir(renode_dir):
|
|
20
|
+
print(f"[DEPS] Cloning Renode @ {git_tag} → {renode_dir} ...")
|
|
21
|
+
run_shell(f'git clone --depth 1 --branch "{git_tag}" --recurse-submodules '
|
|
22
|
+
f'--shallow-submodules "{RENODE_REPO_URL}" "{renode_dir}"')
|
|
23
|
+
else:
|
|
24
|
+
print(f"[DEPS] Renode present at {renode_dir} (delete to refresh).")
|
|
25
|
+
|
|
26
|
+
if not os.path.isdir(integration_dir):
|
|
27
|
+
print(f"[DEPS] Cloning renode-verilator-integration → {integration_dir} ...")
|
|
28
|
+
run_shell(f'git clone --recurse-submodules "{INTEGRATION_REPO_URL}" "{integration_dir}"')
|
|
29
|
+
else:
|
|
30
|
+
print(f"[DEPS] Integration present at {integration_dir} (delete to refresh).")
|
|
31
|
+
|
|
32
|
+
return renode_dir, integration_dir
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def framework_vars(renode_dir, integration_dir):
|
|
36
|
+
"""Interpolation variables exposing the checkouts as container paths, bind-mounting
|
|
37
|
+
any that live outside the workspace so containers reach them at the same path."""
|
|
38
|
+
return {
|
|
39
|
+
"RENODE_DIR": register_dependency_mount(renode_dir),
|
|
40
|
+
"INTEGRATION_DIR": register_dependency_mount(integration_dir),
|
|
41
|
+
}
|
hector/docker.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Thin wrappers around `docker run` for the things we launch in the container:
|
|
4
|
+
module builds, interactive Renode simulation, renode-test runs, and the
|
|
5
|
+
interactive robot REPL. Centralising them here keeps the mount/flag
|
|
6
|
+
boilerplate in one place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
from . import core
|
|
13
|
+
from .core import to_container_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _xdisplay_args():
|
|
17
|
+
xauth = os.environ.get("XAUTHORITY", f"{os.environ.get('HOME')}/.Xauthority")
|
|
18
|
+
display = os.environ.get("DISPLAY", ":0")
|
|
19
|
+
# Pin XAUTHORITY explicitly so X auth still resolves when the container runs as the
|
|
20
|
+
# host UID (see _user_flags) rather than the image's baked-in user with HOME set.
|
|
21
|
+
return ["-e", f"DISPLAY={display}", "-e", "XAUTHORITY=/home/developer/.Xauthority",
|
|
22
|
+
"-v", f"{xauth}:/home/developer/.Xauthority"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _user_flags():
|
|
26
|
+
"""Run the container as the host user, so files it writes into the bind-mounted
|
|
27
|
+
workspace are owned by them on the host — not root. Used by the shell/build and
|
|
28
|
+
renode-test containers.
|
|
29
|
+
|
|
30
|
+
Placed BEFORE _runtime_flags at the renode call sites: docker honours the last
|
|
31
|
+
`-u`, so a mapping that legitimately needs root (e.g. a tap device, via
|
|
32
|
+
runtime.root) still wins. The module-build container (run_build) is the one
|
|
33
|
+
exception — it needs root for apt-get and chowns its artifacts back itself."""
|
|
34
|
+
return ["-u", f"{os.getuid()}:{os.getgid()}"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _workspace_mount():
|
|
38
|
+
return ["-v", f"{os.getcwd()}:{core.WORKSPACE_MOUNT}", "-w", core.WORKSPACE_MOUNT]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extra_mounts():
|
|
42
|
+
"""Bind-mount out-of-tree dependency dirs (e.g. a shared Renode source cache) 1:1, so
|
|
43
|
+
containers reach them at the same path they have on the host. Empty for the common case."""
|
|
44
|
+
args = []
|
|
45
|
+
for d in core.EXTRA_MOUNTS:
|
|
46
|
+
args += ["-v", f"{d}:{d}"]
|
|
47
|
+
return args
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _runtime_flags(runtime):
|
|
51
|
+
"""Map aggregated RuntimeOptions (capabilities/devices/root, declared by mappings,
|
|
52
|
+
hubs, modules) to `docker run` flags.
|
|
53
|
+
|
|
54
|
+
A requested --device is dropped if absent on the host so `docker run` never fails;
|
|
55
|
+
and if devices were requested but none exist, no flags are applied at all (e.g. a
|
|
56
|
+
tap on a host without /dev/net/tun runs as the normal user in dummy mode rather than
|
|
57
|
+
needlessly as root)."""
|
|
58
|
+
if not runtime:
|
|
59
|
+
return []
|
|
60
|
+
present = [d for d in sorted(runtime.devices) if os.path.exists(d)]
|
|
61
|
+
if runtime.devices and not present:
|
|
62
|
+
return []
|
|
63
|
+
flags = []
|
|
64
|
+
if runtime.root:
|
|
65
|
+
flags += ["-u", "0:0"]
|
|
66
|
+
for cap in sorted(runtime.capabilities):
|
|
67
|
+
flags += ["--cap-add", cap]
|
|
68
|
+
for dev in present:
|
|
69
|
+
flags += ["--device", dev]
|
|
70
|
+
return flags
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_build(script, image):
|
|
74
|
+
"""Run a build script in the container as root (for apt-get + cmake)."""
|
|
75
|
+
if core.NO_DOCKER:
|
|
76
|
+
subprocess.run(["bash", "-c", script], check=True)
|
|
77
|
+
return
|
|
78
|
+
subprocess.run(
|
|
79
|
+
["docker", "run", "--rm", "-u", "0:0",
|
|
80
|
+
*_workspace_mount(), *_extra_mounts(), image, "bash", "-c", script],
|
|
81
|
+
check=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_shell_image(item, default_image, label="shell"):
|
|
86
|
+
"""Decide which image a shell step/test runs in, and log the decision.
|
|
87
|
+
|
|
88
|
+
- explicit ``image:`` → use it
|
|
89
|
+
- none → ``default_image`` (the run's renode image), logged
|
|
90
|
+
so the user sees the fallback
|
|
91
|
+
- under ``--no-docker`` → the image is irrelevant (the step runs on the host);
|
|
92
|
+
warn if one was set explicitly, since it's ignored
|
|
93
|
+
|
|
94
|
+
Returns the image string. Under --no-docker run_in_container ignores it, so the value
|
|
95
|
+
only matters for display there."""
|
|
96
|
+
image = item.get("image")
|
|
97
|
+
name = item.get("name") or label
|
|
98
|
+
if core.NO_DOCKER:
|
|
99
|
+
if image:
|
|
100
|
+
print(f"[WARN] --no-docker: image '{image}' for '{name}' is ignored; "
|
|
101
|
+
"running on the host.")
|
|
102
|
+
return image or default_image
|
|
103
|
+
if not image:
|
|
104
|
+
print(f"[INFO] '{name}': no image set; using the default image '{default_image}'.")
|
|
105
|
+
return image or default_image
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_in_container(script, image, live=False):
|
|
109
|
+
"""Run a shell script with the project bind-mounted at the workspace path, returning
|
|
110
|
+
(output, passed); streams when live=True. `set -ex` makes commands echo and the run
|
|
111
|
+
fail on the first error.
|
|
112
|
+
|
|
113
|
+
Backs the `shell` test type and the `build:` steps. Normally a one-off container of
|
|
114
|
+
`image`; under --no-docker the script runs directly on the host (the image is moot)."""
|
|
115
|
+
if core.NO_DOCKER:
|
|
116
|
+
cmd = ["bash", "-c", f"set -ex\n{script}"]
|
|
117
|
+
else:
|
|
118
|
+
cmd = ["docker", "run", "--rm", *_workspace_mount(), *_extra_mounts(),
|
|
119
|
+
*_user_flags(), image, "sh", "-c", f"set -ex\n{script}"]
|
|
120
|
+
if live:
|
|
121
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
|
122
|
+
stderr=subprocess.STDOUT, text=True)
|
|
123
|
+
lines = []
|
|
124
|
+
for line in proc.stdout:
|
|
125
|
+
print(line, end="", flush=True)
|
|
126
|
+
lines.append(line)
|
|
127
|
+
proc.wait()
|
|
128
|
+
return "".join(lines), proc.returncode == 0
|
|
129
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
130
|
+
if proc.stdout:
|
|
131
|
+
print(proc.stdout, end="")
|
|
132
|
+
return proc.stdout, proc.returncode == 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_renode_argv(resc_filename, image, ports=None, extra_args=None, runtime=None):
|
|
136
|
+
"""Build the exact argv that would run an emulation resc — the local `renode`
|
|
137
|
+
invocation under --no-docker, otherwise the full `docker run ... renode` command.
|
|
138
|
+
|
|
139
|
+
Returning the command (instead of running it) is what lets the `export` command
|
|
140
|
+
print it verbatim and keeps run_renode a one-liner around this builder.
|
|
141
|
+
runtime: aggregated RuntimeOptions whose docker flags (caps/devices/root) are added."""
|
|
142
|
+
if core.NO_DOCKER:
|
|
143
|
+
return ["renode", resc_filename] + (extra_args or [])
|
|
144
|
+
cmd = ["docker", "run", "-it", "--rm", *_xdisplay_args(), "--net=host",
|
|
145
|
+
*_workspace_mount(), *_extra_mounts(), *_user_flags(), *_runtime_flags(runtime)]
|
|
146
|
+
for port in (ports or []):
|
|
147
|
+
cmd += ["-p", f"{port}:{port}"]
|
|
148
|
+
cmd += [image, "renode", resc_filename] + (extra_args or [])
|
|
149
|
+
return cmd
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def run_renode(resc_filename, image, ports=None, extra_args=None, output_dir="results", runtime=None):
|
|
153
|
+
"""Run an emulation resc interactively. In debug mode, expose GDB ports.
|
|
154
|
+
runtime: aggregated RuntimeOptions whose docker flags (caps/devices/root) are added."""
|
|
155
|
+
if not core.NO_DOCKER:
|
|
156
|
+
os.makedirs(os.path.join(os.getcwd(), output_dir), exist_ok=True)
|
|
157
|
+
subprocess.run(build_renode_argv(resc_filename, image, ports, extra_args, runtime))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_renode_test(robot_file_host, image, results_dir_host,
|
|
161
|
+
resc_host_path=None, extra_args=None, interactive=False, runtime=None):
|
|
162
|
+
"""Run a .robot file with renode-test in the container. Returns True on pass.
|
|
163
|
+
|
|
164
|
+
resc_host_path: passed as --variable RESC:<path> so .robot files can load
|
|
165
|
+
the generated emulation script via ${RESC}.
|
|
166
|
+
extra_args: optional list of additional flags forwarded verbatim to renode-test.
|
|
167
|
+
interactive: attach stdin/tty and add --net=host (useful with Pause Execution
|
|
168
|
+
or when TCP ports need to be reachable from the host during the test).
|
|
169
|
+
"""
|
|
170
|
+
os.makedirs(results_dir_host, exist_ok=True)
|
|
171
|
+
if core.NO_DOCKER:
|
|
172
|
+
cmd = ["renode-test", robot_file_host, "-r", results_dir_host]
|
|
173
|
+
if resc_host_path:
|
|
174
|
+
cmd += ["--variable", f"RESC:{resc_host_path}"]
|
|
175
|
+
if extra_args:
|
|
176
|
+
cmd += extra_args
|
|
177
|
+
result = subprocess.run(cmd)
|
|
178
|
+
return result.returncode == 0
|
|
179
|
+
flags = ["-it"] if interactive else []
|
|
180
|
+
net = ["--net=host"] if interactive else []
|
|
181
|
+
display = _xdisplay_args() if interactive else []
|
|
182
|
+
cmd = ["docker", "run", "--rm", *flags, *net, *display,
|
|
183
|
+
*_workspace_mount(), *_extra_mounts(), *_user_flags(), *_runtime_flags(runtime),
|
|
184
|
+
image, "renode-test", to_container_path(robot_file_host),
|
|
185
|
+
"-r", to_container_path(results_dir_host)]
|
|
186
|
+
if resc_host_path:
|
|
187
|
+
cmd += ["--variable", f"RESC:{to_container_path(resc_host_path)}"]
|
|
188
|
+
if extra_args:
|
|
189
|
+
cmd += extra_args
|
|
190
|
+
result = subprocess.run(cmd)
|
|
191
|
+
return result.returncode == 0
|