hector-cli 0.5.0__tar.gz → 0.6.0__tar.gz

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.
Files changed (55) hide show
  1. {hector_cli-0.5.0 → hector_cli-0.6.0}/PKG-INFO +29 -1
  2. {hector_cli-0.5.0 → hector_cli-0.6.0}/README.md +28 -0
  3. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/commands/base.py +6 -0
  4. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/core.py +33 -2
  5. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/docker.py +24 -15
  6. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/generator.py +9 -7
  7. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/mappings.py +2 -2
  8. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/modules.py +3 -3
  9. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/pipeline.py +35 -4
  10. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/runners.py +3 -3
  11. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/validator.py +8 -1
  12. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector_cli.egg-info/PKG-INFO +29 -1
  13. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_core_paths.py +27 -0
  14. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_docker_argv.py +59 -6
  15. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_generator.py +17 -1
  16. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_pipeline_helpers.py +55 -0
  17. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_validator.py +16 -0
  18. {hector_cli-0.5.0 → hector_cli-0.6.0}/LICENSE +0 -0
  19. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/__init__.py +0 -0
  20. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/__main__.py +0 -0
  21. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/commands/__init__.py +0 -0
  22. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/commands/clean.py +0 -0
  23. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/commands/export.py +0 -0
  24. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/commands/init.py +0 -0
  25. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/commands/run.py +0 -0
  26. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/commands/test.py +0 -0
  27. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/commands/validate.py +0 -0
  28. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/connections.py +0 -0
  29. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/dependencies.py +0 -0
  30. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/hubs.py +0 -0
  31. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/peripherals.py +0 -0
  32. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/reporters.py +0 -0
  33. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector/scaffold.py +0 -0
  34. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector_cli.egg-info/SOURCES.txt +0 -0
  35. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector_cli.egg-info/dependency_links.txt +0 -0
  36. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector_cli.egg-info/entry_points.txt +0 -0
  37. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector_cli.egg-info/requires.txt +0 -0
  38. {hector_cli-0.5.0 → hector_cli-0.6.0}/hector_cli.egg-info/top_level.txt +0 -0
  39. {hector_cli-0.5.0 → hector_cli-0.6.0}/setup.cfg +0 -0
  40. {hector_cli-0.5.0 → hector_cli-0.6.0}/setup.py +0 -0
  41. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_arguments.py +0 -0
  42. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_clean.py +0 -0
  43. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_cli_smoke.py +0 -0
  44. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_connections.py +0 -0
  45. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_core_registry.py +0 -0
  46. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_core_text.py +0 -0
  47. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_hubs.py +0 -0
  48. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_mappings.py +0 -0
  49. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_matrix.py +0 -0
  50. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_needs_renode_source.py +0 -0
  51. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_peripherals.py +0 -0
  52. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_reporters.py +0 -0
  53. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_runners.py +0 -0
  54. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_scaffold.py +0 -0
  55. {hector_cli-0.5.0 → hector_cli-0.6.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hector-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: YAML-driven Renode + Verilator simulation orchestrator for embedded hardware CI
5
5
  Home-page: https://hector-ci.com
6
6
  Author: Nemesis
@@ -206,6 +206,7 @@ the CLI natively with `pip` and let hector launch real per-step containers.
206
206
  | `hector run --gather-execution-metrics boardA --gather-execution-metrics boardB` | Enable profiler for selected machines |
207
207
  | `hector run --renode-version 1.16.1` | Override the Renode version from `.hector.yaml` |
208
208
  | `hector run --renode-image my-renode:dev` | Use a specific (e.g. self-built) Renode Docker image |
209
+ | `hector run --docker-args '-v /opt/tools:/opt/tools'` | Add extra flags (mounts, env, …) to every `docker run` |
209
210
  | `hector run --renode-dir /cache/renode` | Use/cache the Renode source checkout at this path (only fetched when needed) |
210
211
  | `hector run --renode-integration-dir /cache/integration` | Likewise for the verilator-integration checkout |
211
212
  | `hector run --no-docker` | Use locally installed `renode` / `renode-test` instead of Docker |
@@ -482,6 +483,32 @@ hector run --renode-args '--console'
482
483
  hector run --renode-args '--hide-log --config my.conf'
483
484
  ```
484
485
 
486
+ #### Paths and working directory
487
+
488
+ Everything a hectorfile executes — `build:` steps, `shell` tests, Renode and `renode-test` — runs **in the hectorfile's folder**, and paths written in the file (`firmware:`, `platform:`, module `source:`, test `file:` / `snapshot:`, `file:` mappings, `artifacts:`, paths inside scripts and `commands:`) are **relative to that folder**. So `hector test -f boards/nucleo/.hector.yaml` behaves the same as running `hector test` inside `boards/nucleo/`.
489
+
490
+ The directory you run hector from is mounted into the container (at the same path), and the container's working directory (`-w`) is the hectorfile's folder. If the hectorfile lives **outside** the directory you run from (`hector test -f ../other/.hector.yaml`), its folder is mounted as well, at its own path. A path like `../shared/board.repl` works as long as it lands inside one of those mounts — for anything else, add a mount with [`docker_args`](#extra-docker-flags---docker-args). Under `--no-docker` the processes simply run with the hectorfile's folder as their cwd.
491
+
492
+ Paths given on the **command line** (`--output`, `--snapshot`, `--test-file`, `--artifacts`, `--renode-dir`, …) stay relative to where you run hector, and `.hector/` is still created there.
493
+
494
+ #### Extra Docker flags (`--docker-args`)
495
+
496
+ Add your own flags to every `docker run` hector starts — Renode, robot tests, `shell` tests, `build:` steps and module builds — e.g. to mount more directories or set environment variables. Declare them in `.hector.yaml` as a string or a list:
497
+
498
+ ```yaml
499
+ docker_args:
500
+ - -v /opt/toolchains:/opt/toolchains:ro
501
+ - -e LICENSE_SERVER=10.0.0.5
502
+ ```
503
+
504
+ or pass them on the command line, where they are **appended** to the config's:
505
+
506
+ ```bash
507
+ hector test --docker-args '-v /data/firmware:/data/firmware'
508
+ ```
509
+
510
+ Use absolute host paths for `-v` (Docker treats a bare name as a named volume). Ignored under `--no-docker`.
511
+
485
512
  #### Container networking
486
513
 
487
514
  Every container hector starts — the Renode emulation (`run`), robot tests, `shell` tests, `build:` steps and module builds — runs with `--net=host`. GDB servers (`--debug`), `tcp:` mappings and anything a step listens on are reachable from the host on the same port, and steps can reach host services on `localhost`. No port publishing is involved.
@@ -625,6 +652,7 @@ The configuration lives in `.hector.yaml`.
625
652
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
626
653
  | `renode_version` | Yes¹ | Renode version, no leading `v` (e.g. `1.16.1`). Selects both the git tag for cloning and the Docker image tag. |
627
654
  | `renode_image` | Yes¹ | Full Renode Docker image to use instead of `antmicro/renode:<renode_version>` (e.g. a self-built `my-renode:dev`). See [custom Renode image](#using-a-custom-renode-image---renode-image). |
655
+ | `docker_args` | No | Extra `docker run` flags (string or list) for every container, e.g. more `-v` mounts. See [extra Docker flags](#extra-docker-flags---docker-args). |
628
656
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
629
657
  | `build` | No | Pre-sim shell steps run once per job in containers (compile firmware, fetch/generate files, …). Interpolated with the job's arguments/matrix variables. |
630
658
  | `matrix` | No | Cross-product job expansion. |
@@ -176,6 +176,7 @@ the CLI natively with `pip` and let hector launch real per-step containers.
176
176
  | `hector run --gather-execution-metrics boardA --gather-execution-metrics boardB` | Enable profiler for selected machines |
177
177
  | `hector run --renode-version 1.16.1` | Override the Renode version from `.hector.yaml` |
178
178
  | `hector run --renode-image my-renode:dev` | Use a specific (e.g. self-built) Renode Docker image |
179
+ | `hector run --docker-args '-v /opt/tools:/opt/tools'` | Add extra flags (mounts, env, …) to every `docker run` |
179
180
  | `hector run --renode-dir /cache/renode` | Use/cache the Renode source checkout at this path (only fetched when needed) |
180
181
  | `hector run --renode-integration-dir /cache/integration` | Likewise for the verilator-integration checkout |
181
182
  | `hector run --no-docker` | Use locally installed `renode` / `renode-test` instead of Docker |
@@ -452,6 +453,32 @@ hector run --renode-args '--console'
452
453
  hector run --renode-args '--hide-log --config my.conf'
453
454
  ```
454
455
 
456
+ #### Paths and working directory
457
+
458
+ Everything a hectorfile executes — `build:` steps, `shell` tests, Renode and `renode-test` — runs **in the hectorfile's folder**, and paths written in the file (`firmware:`, `platform:`, module `source:`, test `file:` / `snapshot:`, `file:` mappings, `artifacts:`, paths inside scripts and `commands:`) are **relative to that folder**. So `hector test -f boards/nucleo/.hector.yaml` behaves the same as running `hector test` inside `boards/nucleo/`.
459
+
460
+ The directory you run hector from is mounted into the container (at the same path), and the container's working directory (`-w`) is the hectorfile's folder. If the hectorfile lives **outside** the directory you run from (`hector test -f ../other/.hector.yaml`), its folder is mounted as well, at its own path. A path like `../shared/board.repl` works as long as it lands inside one of those mounts — for anything else, add a mount with [`docker_args`](#extra-docker-flags---docker-args). Under `--no-docker` the processes simply run with the hectorfile's folder as their cwd.
461
+
462
+ Paths given on the **command line** (`--output`, `--snapshot`, `--test-file`, `--artifacts`, `--renode-dir`, …) stay relative to where you run hector, and `.hector/` is still created there.
463
+
464
+ #### Extra Docker flags (`--docker-args`)
465
+
466
+ Add your own flags to every `docker run` hector starts — Renode, robot tests, `shell` tests, `build:` steps and module builds — e.g. to mount more directories or set environment variables. Declare them in `.hector.yaml` as a string or a list:
467
+
468
+ ```yaml
469
+ docker_args:
470
+ - -v /opt/toolchains:/opt/toolchains:ro
471
+ - -e LICENSE_SERVER=10.0.0.5
472
+ ```
473
+
474
+ or pass them on the command line, where they are **appended** to the config's:
475
+
476
+ ```bash
477
+ hector test --docker-args '-v /data/firmware:/data/firmware'
478
+ ```
479
+
480
+ Use absolute host paths for `-v` (Docker treats a bare name as a named volume). Ignored under `--no-docker`.
481
+
455
482
  #### Container networking
456
483
 
457
484
  Every container hector starts — the Renode emulation (`run`), robot tests, `shell` tests, `build:` steps and module builds — runs with `--net=host`. GDB servers (`--debug`), `tcp:` mappings and anything a step listens on are reachable from the host on the same port, and steps can reach host services on `localhost`. No port publishing is involved.
@@ -595,6 +622,7 @@ The configuration lives in `.hector.yaml`.
595
622
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
596
623
  | `renode_version` | Yes¹ | Renode version, no leading `v` (e.g. `1.16.1`). Selects both the git tag for cloning and the Docker image tag. |
597
624
  | `renode_image` | Yes¹ | Full Renode Docker image to use instead of `antmicro/renode:<renode_version>` (e.g. a self-built `my-renode:dev`). See [custom Renode image](#using-a-custom-renode-image---renode-image). |
625
+ | `docker_args` | No | Extra `docker run` flags (string or list) for every container, e.g. more `-v` mounts. See [extra Docker flags](#extra-docker-flags---docker-args). |
598
626
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
599
627
  | `build` | No | Pre-sim shell steps run once per job in containers (compile firmware, fetch/generate files, …). Interpolated with the job's arguments/matrix variables. |
600
628
  | `matrix` | No | Cross-product job expansion. |
@@ -59,6 +59,12 @@ def add_common_arguments(parser):
59
59
  "image, e.g. a self-built one: my-renode:dev). Overrides renode_image / "
60
60
  "renode_version from .hector.yaml.",
61
61
  )
62
+ parser.add_argument(
63
+ "--docker-args", metavar="ARGS", dest="docker_args", default="",
64
+ help="Extra flags added to every `docker run` hector starts, e.g. more mounts "
65
+ "(--docker-args '-v /opt/tools:/opt/tools -e FOO=1'). Appended to "
66
+ "docker_args from .hector.yaml.",
67
+ )
62
68
  parser.add_argument(
63
69
  "--renode-dir", metavar="PATH", dest="renode_dir", default=None,
64
70
  help="Location of the Renode source checkout (only fetched when a config builds "
@@ -67,7 +67,7 @@ class Registry:
67
67
  # ==========================================================================
68
68
  # CONSTANTS
69
69
  # ==========================================================================
70
- TOOL_VERSION = "0.5.0"
70
+ TOOL_VERSION = "0.6.0"
71
71
  SUPPORTED_SCHEMA_VERSIONS = {"0.1"}
72
72
 
73
73
  RENODE_REPO_URL = "https://github.com/renode/renode.git"
@@ -80,6 +80,11 @@ ARTIFACTS_DIR = ".hector" # everything we generate/clone lives here (gi
80
80
  WORKSPACE_MOUNT = "/workspace" if os.name == "nt" else os.getcwd()
81
81
  NO_DOCKER = False # when True, call renode/renode-test directly on host
82
82
  EXTRA_MOUNTS = [] # dep dirs outside the workspace, bind-mounted 1:1 into every container
83
+ DOCKER_ARGS = [] # user flags (docker_args: + --docker-args) added to every `docker run`
84
+ # Host directory of the config file being executed. Paths written in the config resolve
85
+ # against it, and scripts / Renode run with it as their working directory. None = cwd
86
+ # (no config loaded, e.g. `run --snapshot` without one).
87
+ CONFIG_DIR = None
83
88
  LIBOPENLIBM_NAME = "libopenlibm-Linux-x86_64.a"
84
89
 
85
90
  DEFAULT_QUANTUM = "0.00001" # 10 us; applied when any hub is present
@@ -123,6 +128,11 @@ def to_container_path(host_path):
123
128
  abs_path = os.path.realpath(host_path)
124
129
  root = os.path.realpath(os.getcwd())
125
130
  if os.path.commonpath([abs_path, root]) != root:
131
+ # Out-of-tree dirs registered in EXTRA_MOUNTS (the config's dir, a shared
132
+ # Renode cache) are mounted 1:1, so their paths are valid as-is.
133
+ for m in EXTRA_MOUNTS:
134
+ if os.path.commonpath([abs_path, m]) == m:
135
+ return abs_path
126
136
  raise ValueError(
127
137
  f"Path '{host_path}' resolves outside the project root '{root}'. Everything used "
128
138
  f"at runtime must live under the project folder (mounted at '{WORKSPACE_MOUNT}')."
@@ -130,7 +140,7 @@ def to_container_path(host_path):
130
140
  # The container is always Linux: build a POSIX path under WORKSPACE_MOUNT (this is a
131
141
  # no-op on Linux, where WORKSPACE_MOUNT is the cwd and os.sep is already '/').
132
142
  rel = os.path.relpath(abs_path, root).replace(os.sep, "/")
133
- return posixpath.join(WORKSPACE_MOUNT, rel)
143
+ return WORKSPACE_MOUNT if rel == "." else posixpath.join(WORKSPACE_MOUNT, rel)
134
144
 
135
145
 
136
146
  def register_dependency_mount(host_dir):
@@ -173,6 +183,27 @@ def container_to_host(container_path):
173
183
  return host
174
184
 
175
185
 
186
+ def config_dir():
187
+ """Host directory config-relative paths resolve against (see CONFIG_DIR)."""
188
+ return CONFIG_DIR or os.getcwd()
189
+
190
+
191
+ def from_config(path):
192
+ """Resolve a path written in the config against the config file's directory.
193
+ Absolute paths (host or container, e.g. an interpolated ${RENODE_DIR}) and URLs
194
+ are returned unchanged."""
195
+ if not path or os.path.isabs(path) or path.startswith("/") or "://" in path:
196
+ return path
197
+ return os.path.normpath(os.path.join(config_dir(), path))
198
+
199
+
200
+ def execution_dir():
201
+ """Working directory for everything hector executes (build/shell steps, Renode,
202
+ renode-test): the config's directory — as a container path under Docker, a host
203
+ path under --no-docker."""
204
+ return config_dir() if NO_DOCKER else to_container_path(config_dir())
205
+
206
+
176
207
  def split_host_container(path):
177
208
  if path == WORKSPACE_MOUNT or path.startswith(WORKSPACE_MOUNT + "/"):
178
209
  return container_to_host(path), path
@@ -48,16 +48,17 @@ def _user_flags():
48
48
 
49
49
 
50
50
  def _workspace_mount():
51
- return ["-v", f"{os.getcwd()}:{core.WORKSPACE_MOUNT}", "-w", core.WORKSPACE_MOUNT]
51
+ return ["-v", f"{os.getcwd()}:{core.WORKSPACE_MOUNT}", "-w", core.execution_dir()]
52
52
 
53
53
 
54
54
  def _extra_mounts():
55
- """Bind-mount out-of-tree dependency dirs (e.g. a shared Renode source cache) 1:1, so
56
- containers reach them at the same path they have on the host. Empty for the common case."""
55
+ """Bind-mount out-of-tree dirs (the config's dir, a shared Renode source cache) 1:1,
56
+ so containers reach them at the same path they have on the host, then append the
57
+ user's own `docker run` flags (extra -v mounts, --env, ...). Empty for the common case."""
57
58
  args = []
58
59
  for d in core.EXTRA_MOUNTS:
59
60
  args += ["-v", f"{d}:{d}"]
60
- return args
61
+ return args + core.DOCKER_ARGS
61
62
 
62
63
 
63
64
  # Every container shares the host's network stack: GDB servers, tcp: mappings and
@@ -93,7 +94,7 @@ def _runtime_flags(runtime):
93
94
  def run_build(script, image):
94
95
  """Run a build script in the container as root (for apt-get + cmake)."""
95
96
  if core.NO_DOCKER:
96
- subprocess.run(["bash", "-c", script], check=True)
97
+ subprocess.run(["bash", "-c", script], check=True, cwd=core.execution_dir())
97
98
  return
98
99
  subprocess.run(
99
100
  ["docker", "run", "--rm", *_HOST_NETWORK, "-u", "0:0",
@@ -131,14 +132,17 @@ def run_in_container(script, image, live=False):
131
132
  fail on the first error.
132
133
 
133
134
  Backs the `shell` test type and the `build:` steps. Normally a one-off container of
134
- `image`; under --no-docker the script runs directly on the host (the image is moot)."""
135
+ `image`; under --no-docker the script runs directly on the host (the image is moot).
136
+ Either way it runs in the config's directory (core.execution_dir)."""
137
+ cwd = None
135
138
  if core.NO_DOCKER:
136
139
  cmd = ["bash", "-c", f"set -ex\n{script}"]
140
+ cwd = core.execution_dir()
137
141
  else:
138
142
  cmd = ["docker", "run", "--rm", *_HOST_NETWORK, *_workspace_mount(),
139
143
  *_extra_mounts(), *_user_flags(), image, "sh", "-c", f"set -ex\n{script}"]
140
144
  if live:
141
- proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
145
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, cwd=cwd,
142
146
  stderr=subprocess.STDOUT, text=True)
143
147
  lines = []
144
148
  for line in proc.stdout:
@@ -146,7 +150,8 @@ def run_in_container(script, image, live=False):
146
150
  lines.append(line)
147
151
  proc.wait()
148
152
  return "".join(lines), proc.returncode == 0
149
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
153
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
154
+ cwd=cwd)
150
155
  if proc.stdout:
151
156
  print(proc.stdout, end="")
152
157
  return proc.stdout, proc.returncode == 0
@@ -158,12 +163,13 @@ def build_renode_argv(resc_filename, image, extra_args=None, runtime=None):
158
163
 
159
164
  Returning the command (instead of running it) is what lets the `export` command
160
165
  print it verbatim and keeps run_renode a one-liner around this builder.
161
- runtime: aggregated RuntimeOptions whose docker flags (caps/devices/root) are added."""
166
+ runtime: aggregated RuntimeOptions whose docker flags (caps/devices/root) are added.
167
+ The resc path is made absolute: Renode runs in the config's directory, not the cwd."""
162
168
  if core.NO_DOCKER:
163
- return ["renode", resc_filename] + (extra_args or [])
169
+ return ["renode", os.path.abspath(resc_filename)] + (extra_args or [])
164
170
  return ["docker", "run", "-it", "--rm", *_xdisplay_args(), *_HOST_NETWORK,
165
171
  *_workspace_mount(), *_extra_mounts(), *_user_flags(), *_runtime_flags(runtime),
166
- image, "renode", resc_filename] + (extra_args or [])
172
+ image, "renode", to_container_path(resc_filename)] + (extra_args or [])
167
173
 
168
174
 
169
175
  def run_renode(resc_filename, image, extra_args=None, output_dir="results", runtime=None):
@@ -172,7 +178,8 @@ def run_renode(resc_filename, image, extra_args=None, output_dir="results", runt
172
178
  runtime: aggregated RuntimeOptions whose docker flags (caps/devices/root) are added."""
173
179
  if not core.NO_DOCKER:
174
180
  os.makedirs(os.path.join(os.getcwd(), output_dir), exist_ok=True)
175
- subprocess.run(build_renode_argv(resc_filename, image, extra_args, runtime))
181
+ subprocess.run(build_renode_argv(resc_filename, image, extra_args, runtime),
182
+ cwd=core.execution_dir() if core.NO_DOCKER else None)
176
183
 
177
184
 
178
185
  def run_renode_test(robot_file_host, image, results_dir_host,
@@ -186,12 +193,14 @@ def run_renode_test(robot_file_host, image, results_dir_host,
186
193
  """
187
194
  os.makedirs(results_dir_host, exist_ok=True)
188
195
  if core.NO_DOCKER:
189
- cmd = ["renode-test", robot_file_host, "-r", results_dir_host]
196
+ # Absolute paths: renode-test runs in the config's directory, not the cwd.
197
+ cmd = ["renode-test", os.path.abspath(robot_file_host),
198
+ "-r", os.path.abspath(results_dir_host)]
190
199
  if resc_host_path:
191
- cmd += ["--variable", f"RESC:{resc_host_path}"]
200
+ cmd += ["--variable", f"RESC:{os.path.abspath(resc_host_path)}"]
192
201
  if extra_args:
193
202
  cmd += extra_args
194
- result = subprocess.run(cmd)
203
+ result = subprocess.run(cmd, cwd=core.execution_dir())
195
204
  return result.returncode == 0
196
205
  flags = ["-it"] if interactive else []
197
206
  display = _xdisplay_args() if interactive else []
@@ -262,7 +262,7 @@ def snapshot_resc(snapshot_container_path, debug_config=None, no_start=False):
262
262
  # ARTIFACT COLLECTION
263
263
  # ==========================================================================
264
264
 
265
- def collect_artifacts(patterns, output_dir, job_index=None):
265
+ def collect_artifacts(patterns, output_dir, job_index=None, base_dir="."):
266
266
  """Sweep the global artifact glob patterns and copy matches into
267
267
  <output_dir>/artifacts/ so a single directory can be uploaded by CI / read by a
268
268
  dashboard.
@@ -270,6 +270,8 @@ def collect_artifacts(patterns, output_dir, job_index=None):
270
270
  patterns : list of glob strings (top-level 'artifacts:' or --artifacts override)
271
271
  output_dir : the run output directory (--output)
272
272
  job_index : 1-based job number; prefixes copied filenames to avoid cross-job clashes
273
+ base_dir : directory relative patterns resolve against (the config's directory
274
+ for 'artifacts:', the cwd for --artifacts)
273
275
 
274
276
  Returns the list of collected destination paths (relative to cwd).
275
277
  """
@@ -283,7 +285,7 @@ def collect_artifacts(patterns, output_dir, job_index=None):
283
285
  collected, seen = [], set()
284
286
  header_shown = False
285
287
  for pattern in patterns:
286
- for src in sorted(_match_artifacts(pattern, dest_root)):
288
+ for src in sorted(_match_artifacts(pattern, dest_root, base_dir)):
287
289
  real = os.path.realpath(src)
288
290
  if real in seen or not os.path.isfile(real):
289
291
  continue
@@ -308,19 +310,19 @@ def collect_artifacts(patterns, output_dir, job_index=None):
308
310
  return collected
309
311
 
310
312
 
311
- def _match_artifacts(pattern, dest_root):
312
- """Resolve one artifact glob pattern to a list of host paths.
313
+ def _match_artifacts(pattern, dest_root, base_dir="."):
314
+ """Resolve one artifact glob pattern (relative to base_dir) to a list of host paths.
313
315
 
314
- A bare filename pattern (no '/', e.g. '*.xml') searches the whole project tree
316
+ A bare filename pattern (no '/', e.g. '*.xml') searches the whole base_dir tree
315
317
  recursively, pruning hidden dirs (.hector, .git, …) and the artifacts output dir —
316
318
  so 'results/junit.xml' is found without sweeping the bundled Renode clone.
317
319
  A pattern with a path component ('results/*.bin', 'logs/**/*.log') is passed to
318
320
  glob as-is, so explicit directories and '**' behave normally.
319
321
  """
320
322
  if "/" in pattern or os.sep in pattern:
321
- return glob.glob(pattern, recursive=True)
323
+ return glob.glob(os.path.join(base_dir, pattern), recursive=True)
322
324
  matches = []
323
- for root, dirs, files in os.walk("."):
325
+ for root, dirs, files in os.walk(base_dir):
324
326
  dirs[:] = [d for d in dirs
325
327
  if not d.startswith(".")
326
328
  and os.path.realpath(os.path.join(root, d)) != dest_root]
@@ -33,7 +33,7 @@ YAML then accepts: mappings: |\n boardA.eth0 -> tap:tap0
33
33
  from dataclasses import dataclass, field
34
34
  from typing import List
35
35
 
36
- from .core import Registry, RuntimeOptions, parse_endpoint, to_container_path
36
+ from .core import Registry, RuntimeOptions, from_config, parse_endpoint, to_container_path
37
37
  from .generator import queue_command
38
38
 
39
39
  MAPPING_BACKENDS = Registry("mapping backend")
@@ -55,7 +55,7 @@ def file_backend(_node_name, peripheral, param, _node_config):
55
55
  if not param:
56
56
  raise ValueError("'file' backend requires a path (file:<path>).")
57
57
  return MappingResult(
58
- machine_cmd=f"{peripheral} CreateFileBackend @{to_container_path(param)} true",
58
+ machine_cmd=f"{peripheral} CreateFileBackend @{to_container_path(from_config(param))} true",
59
59
  )
60
60
 
61
61
 
@@ -14,7 +14,7 @@ import shutil
14
14
  from dataclasses import dataclass
15
15
 
16
16
  from .core import (ARTIFACTS_DIR, LIBOPENLIBM_NAME, Registry,
17
- container_path_for, host_uid_gid, split_host_container,
17
+ container_path_for, from_config, host_uid_gid, split_host_container,
18
18
  to_container_path)
19
19
  from .docker import run_build
20
20
 
@@ -46,7 +46,7 @@ def build_renode_verilator(module_name, module_config, ctx):
46
46
  if not source:
47
47
  raise ValueError(f"Module '{module_name}' is missing required 'source'.")
48
48
 
49
- source_host, source_container = split_host_container(source)
49
+ source_host, source_container = split_host_container(from_config(source))
50
50
  if not os.path.isdir(source_host):
51
51
  raise FileNotFoundError(f"Module '{module_name}': source dir not found: {source} ({source_host})")
52
52
 
@@ -113,7 +113,7 @@ def build_csharp(module_name, module_config, ctx):
113
113
  if not source:
114
114
  raise ValueError(f"Module '{module_name}' is missing required 'source'.")
115
115
 
116
- source_host, _ = split_host_container(source)
116
+ source_host, _ = split_host_container(from_config(source))
117
117
 
118
118
  # ---- raw .cs: Renode compiles it at load time (bundled Roslyn) — no SDK, no build ----
119
119
  if source_host.endswith(".cs"):
@@ -104,9 +104,18 @@ def load_config_or_exit():
104
104
  if v is not None and str(v) not in SUPPORTED_SCHEMA_VERSIONS:
105
105
  print(f"[WARN] .hector.yaml 'version: {v}' is not in supported versions "
106
106
  f"({', '.join(sorted(SUPPORTED_SCHEMA_VERSIONS))}). Proceeding anyway.")
107
+ _set_config_dir(config_path)
107
108
  return config
108
109
 
109
110
 
111
+ def _set_config_dir(config_path):
112
+ """Make the config's directory the base for its paths and the working directory of
113
+ everything executed (see core.CONFIG_DIR). The cwd stays the workspace mount; a
114
+ config outside it gets its directory bind-mounted 1:1 as well, so it's reachable."""
115
+ _core.CONFIG_DIR = os.path.realpath(os.path.dirname(os.path.abspath(config_path)))
116
+ _core.register_dependency_mount(_core.CONFIG_DIR)
117
+
118
+
110
119
  _VERSION_TAG_RE = re.compile(r"^v?\d+\.\d+\.\d+$")
111
120
 
112
121
 
@@ -138,6 +147,16 @@ def resolve_renode_image(config, args):
138
147
  return (f"v{normalized}" if normalized else None), image
139
148
 
140
149
 
150
+ def apply_docker_args(config, args):
151
+ """Collect the user's extra `docker run` flags — the config's `docker_args:` (a
152
+ string or a list of strings) followed by --docker-args — into core.DOCKER_ARGS."""
153
+ raw = config.get("docker_args") or []
154
+ flags = []
155
+ for item in ([raw] if isinstance(raw, str) else raw):
156
+ flags += shlex.split(str(item))
157
+ _core.DOCKER_ARGS = flags + shlex.split(getattr(args, "docker_args", "") or "")
158
+
159
+
141
160
  def _resolve_set_overrides(args):
142
161
  overrides = {}
143
162
  for kv in (getattr(args, "set_args", None) or []):
@@ -166,6 +185,7 @@ def build_job_config(action, config, args, *, test_mode=False):
166
185
  """Assemble a JobConfig from parsed args — the shared run / test / export path:
167
186
  pin the image, prepare the framework (lazily), resolve arguments + artifacts."""
168
187
  git_tag, docker_image = resolve_renode_image(config, args)
188
+ apply_docker_args(config, args)
169
189
 
170
190
  # Clone Renode only when the config actually needs the source; otherwise skip it
171
191
  # entirely (a ~200 MB clone) and leave the framework vars empty.
@@ -186,8 +206,14 @@ def build_job_config(action, config, args, *, test_mode=False):
186
206
 
187
207
  resolved_args = resolve_arguments(config.get("arguments", {}),
188
208
  overrides=_resolve_set_overrides(args))
189
- artifacts_cfg = (args.artifacts if getattr(args, "artifacts", None)
190
- else as_lines(config.get("artifacts", [])))
209
+ # --artifacts globs are relative to where hector runs; the config's own 'artifacts:'
210
+ # to the config's directory (like every other path written in the config).
211
+ if getattr(args, "artifacts", None):
212
+ artifacts_cfg, artifacts_base = args.artifacts, os.getcwd()
213
+ else:
214
+ artifacts_cfg = as_lines(config.get("artifacts", []))
215
+ artifacts_base = _core.config_dir()
216
+ test_file = getattr(args, "test_file", None)
191
217
  return JobConfig(
192
218
  config=config,
193
219
  fw_vars=fw_vars,
@@ -196,7 +222,8 @@ def build_job_config(action, config, args, *, test_mode=False):
196
222
  action=action,
197
223
  test_mode=test_mode,
198
224
  debug_args=getattr(args, "debug", None) or [],
199
- test_file=getattr(args, "test_file", None),
225
+ # Absolute so it isn't re-resolved against the config's directory.
226
+ test_file=os.path.abspath(test_file) if test_file else None,
200
227
  reporter_names=_resolve_reporters(getattr(args, "reporters", None)),
201
228
  renode_args=shlex.split(getattr(args, "renode_args", "") or ""),
202
229
  renode_test_args=shlex.split(getattr(args, "renode_test_args", "") or ""),
@@ -208,6 +235,7 @@ def build_job_config(action, config, args, *, test_mode=False):
208
235
  output_dir=getattr(args, "output", "results"),
209
236
  gather_metrics=getattr(args, "gather_execution_metrics", None),
210
237
  artifacts=artifacts_cfg,
238
+ artifacts_base=artifacts_base,
211
239
  )
212
240
 
213
241
 
@@ -241,6 +269,7 @@ def snapshot_fastpath(args):
241
269
  print(f"[ERROR] Snapshot not found: {args.snapshot}", file=sys.stderr)
242
270
  sys.exit(1)
243
271
  _, docker_image = resolve_renode_image({}, args)
272
+ apply_docker_args({}, args)
244
273
  resc_root = os.path.join(ARTIFACTS_DIR, "resc")
245
274
  os.makedirs(resc_root, exist_ok=True)
246
275
  # No .hector.yaml here, so debug machine names come straight from --debug.
@@ -485,6 +514,7 @@ class JobConfig:
485
514
  output_dir: str = "results"
486
515
  gather_metrics: list = None
487
516
  artifacts: list = field(default_factory=list)
517
+ artifacts_base: str = "." # directory the artifact globs resolve against
488
518
 
489
519
 
490
520
  @dataclass
@@ -757,7 +787,8 @@ def run_job(i, total_jobs, job_context, cfg):
757
787
  # Global glob patterns (top-level 'artifacts:' or --artifacts), interpolated
758
788
  # with this job's context so matrix variables resolve.
759
789
  artifact_patterns = as_lines(interpolate(list(cfg.artifacts), context))
760
- collect_artifacts(artifact_patterns, cfg.output_dir, i + 1)
790
+ collect_artifacts(artifact_patterns, cfg.output_dir, i + 1,
791
+ base_dir=cfg.artifacts_base)
761
792
 
762
793
  except Exception as exc:
763
794
  result["passed"] = False
@@ -20,7 +20,7 @@ import time
20
20
  from dataclasses import dataclass, field
21
21
 
22
22
  from . import core as _core
23
- from .core import ARTIFACTS_DIR, Registry, to_container_path
23
+ from .core import ARTIFACTS_DIR, Registry, from_config, to_container_path
24
24
  from .docker import resolve_shell_image, run_in_container, run_renode_test
25
25
  from .reporters import StepResult
26
26
 
@@ -170,7 +170,7 @@ def generate_robot_file(test_name, steps, resc_host_path, job_index, test_index,
170
170
  def _run_robot_test(test, steps, ctx, test_index):
171
171
  """Run a robot test. Returns a single StepResult for the whole test."""
172
172
  test_name = test.get("name", f"test_{test_index}")
173
- effective_snapshot = ctx.snapshot or test.get("snapshot", "")
173
+ effective_snapshot = ctx.snapshot or from_config(test.get("snapshot", ""))
174
174
  if effective_snapshot and not os.path.isfile(effective_snapshot):
175
175
  raise FileNotFoundError(f"Snapshot not found: {effective_snapshot}")
176
176
 
@@ -190,7 +190,7 @@ def _run_robot_test(test, steps, ctx, test_index):
190
190
  if len(steps) == 1 and steps[0].get("file"):
191
191
  if effective_snapshot:
192
192
  print(" [WARN] snapshot ignored for file-based robot tests.")
193
- robot_file = steps[0]["file"]
193
+ robot_file = from_config(steps[0]["file"])
194
194
  else:
195
195
  if any(s.get("file") for s in steps):
196
196
  raise ValueError(f"Test '{test_name}': mixing file: and script: steps is not supported.")
@@ -28,7 +28,7 @@ _TRIGGER_TOP_KEYS = {"when", "cron", "reporters", "jobs", "fail_fast", "timeout"
28
28
 
29
29
  # Top-level keys defined by this schema version; anything else is flagged.
30
30
  _KNOWN_TOP_KEYS = {
31
- "version", "renode_version", "renode_image", "arguments", "matrix",
31
+ "version", "renode_version", "renode_image", "docker_args", "arguments", "matrix",
32
32
  "modules", "hubs", "machines", "connections", "mappings", "build", "tests", "quantum",
33
33
  "artifacts",
34
34
  } | _TRIGGER_TOP_KEYS
@@ -91,6 +91,13 @@ def validate_config(config) -> Tuple[List[Issue], List[Issue]]:
91
91
  if ri and not isinstance(ri, str):
92
92
  err("renode_image", f"Must be a string like 'my-renode:dev', got {type(ri).__name__}.")
93
93
 
94
+ # ---- docker_args ----
95
+ da = config.get("docker_args")
96
+ if da is not None and not (isinstance(da, str) or
97
+ (isinstance(da, list) and all(isinstance(x, str) for x in da))):
98
+ err("docker_args", "Must be a string or a list of strings "
99
+ "(e.g. ['-v /opt/tools:/opt/tools', '-e FOO=1']).")
100
+
94
101
  # ---- unknown / renamed top-level keys ----
95
102
  for k in config:
96
103
  if k in _RENAMED_TOP_KEYS:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hector-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: YAML-driven Renode + Verilator simulation orchestrator for embedded hardware CI
5
5
  Home-page: https://hector-ci.com
6
6
  Author: Nemesis
@@ -206,6 +206,7 @@ the CLI natively with `pip` and let hector launch real per-step containers.
206
206
  | `hector run --gather-execution-metrics boardA --gather-execution-metrics boardB` | Enable profiler for selected machines |
207
207
  | `hector run --renode-version 1.16.1` | Override the Renode version from `.hector.yaml` |
208
208
  | `hector run --renode-image my-renode:dev` | Use a specific (e.g. self-built) Renode Docker image |
209
+ | `hector run --docker-args '-v /opt/tools:/opt/tools'` | Add extra flags (mounts, env, …) to every `docker run` |
209
210
  | `hector run --renode-dir /cache/renode` | Use/cache the Renode source checkout at this path (only fetched when needed) |
210
211
  | `hector run --renode-integration-dir /cache/integration` | Likewise for the verilator-integration checkout |
211
212
  | `hector run --no-docker` | Use locally installed `renode` / `renode-test` instead of Docker |
@@ -482,6 +483,32 @@ hector run --renode-args '--console'
482
483
  hector run --renode-args '--hide-log --config my.conf'
483
484
  ```
484
485
 
486
+ #### Paths and working directory
487
+
488
+ Everything a hectorfile executes — `build:` steps, `shell` tests, Renode and `renode-test` — runs **in the hectorfile's folder**, and paths written in the file (`firmware:`, `platform:`, module `source:`, test `file:` / `snapshot:`, `file:` mappings, `artifacts:`, paths inside scripts and `commands:`) are **relative to that folder**. So `hector test -f boards/nucleo/.hector.yaml` behaves the same as running `hector test` inside `boards/nucleo/`.
489
+
490
+ The directory you run hector from is mounted into the container (at the same path), and the container's working directory (`-w`) is the hectorfile's folder. If the hectorfile lives **outside** the directory you run from (`hector test -f ../other/.hector.yaml`), its folder is mounted as well, at its own path. A path like `../shared/board.repl` works as long as it lands inside one of those mounts — for anything else, add a mount with [`docker_args`](#extra-docker-flags---docker-args). Under `--no-docker` the processes simply run with the hectorfile's folder as their cwd.
491
+
492
+ Paths given on the **command line** (`--output`, `--snapshot`, `--test-file`, `--artifacts`, `--renode-dir`, …) stay relative to where you run hector, and `.hector/` is still created there.
493
+
494
+ #### Extra Docker flags (`--docker-args`)
495
+
496
+ Add your own flags to every `docker run` hector starts — Renode, robot tests, `shell` tests, `build:` steps and module builds — e.g. to mount more directories or set environment variables. Declare them in `.hector.yaml` as a string or a list:
497
+
498
+ ```yaml
499
+ docker_args:
500
+ - -v /opt/toolchains:/opt/toolchains:ro
501
+ - -e LICENSE_SERVER=10.0.0.5
502
+ ```
503
+
504
+ or pass them on the command line, where they are **appended** to the config's:
505
+
506
+ ```bash
507
+ hector test --docker-args '-v /data/firmware:/data/firmware'
508
+ ```
509
+
510
+ Use absolute host paths for `-v` (Docker treats a bare name as a named volume). Ignored under `--no-docker`.
511
+
485
512
  #### Container networking
486
513
 
487
514
  Every container hector starts — the Renode emulation (`run`), robot tests, `shell` tests, `build:` steps and module builds — runs with `--net=host`. GDB servers (`--debug`), `tcp:` mappings and anything a step listens on are reachable from the host on the same port, and steps can reach host services on `localhost`. No port publishing is involved.
@@ -625,6 +652,7 @@ The configuration lives in `.hector.yaml`.
625
652
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
626
653
  | `renode_version` | Yes¹ | Renode version, no leading `v` (e.g. `1.16.1`). Selects both the git tag for cloning and the Docker image tag. |
627
654
  | `renode_image` | Yes¹ | Full Renode Docker image to use instead of `antmicro/renode:<renode_version>` (e.g. a self-built `my-renode:dev`). See [custom Renode image](#using-a-custom-renode-image---renode-image). |
655
+ | `docker_args` | No | Extra `docker run` flags (string or list) for every container, e.g. more `-v` mounts. See [extra Docker flags](#extra-docker-flags---docker-args). |
628
656
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
629
657
  | `build` | No | Pre-sim shell steps run once per job in containers (compile firmware, fetch/generate files, …). Interpolated with the job's arguments/matrix variables. |
630
658
  | `matrix` | No | Cross-product job expansion. |
@@ -84,6 +84,33 @@ def test_container_path_for_prefers_extra_mount(project, tmp_path_factory):
84
84
  assert core.container_path_for(str(inner)) == str((cache / "renode" / "x.so").resolve())
85
85
 
86
86
 
87
+ def test_from_config_resolves_against_config_dir(project, monkeypatch):
88
+ monkeypatch.setattr(core, "CONFIG_DIR", str(project / "board"))
89
+ assert core.from_config("fw/app.elf") == str(project / "board" / "fw" / "app.elf")
90
+ assert core.from_config("../shared/x.repl") == str(project / "shared" / "x.repl")
91
+
92
+
93
+ def test_from_config_leaves_absolute_and_urls(project, monkeypatch):
94
+ monkeypatch.setattr(core, "CONFIG_DIR", str(project / "board"))
95
+ for p in ("/opt/renode/x.repl", "https://example.com/fw.elf", "", None):
96
+ assert core.from_config(p) == p
97
+
98
+
99
+ def test_from_config_defaults_to_cwd(project, monkeypatch):
100
+ monkeypatch.setattr(core, "CONFIG_DIR", None)
101
+ assert core.from_config("fw.elf") == str(project / "fw.elf")
102
+
103
+
104
+ def test_execution_dir_is_container_path_of_config_dir(project, monkeypatch):
105
+ (project / "board").mkdir()
106
+ monkeypatch.setattr(core, "NO_DOCKER", False)
107
+ monkeypatch.setattr(core, "WORKSPACE_MOUNT", "/workspace")
108
+ monkeypatch.setattr(core, "CONFIG_DIR", str(project / "board"))
109
+ assert core.execution_dir() == "/workspace/board"
110
+ monkeypatch.setattr(core, "NO_DOCKER", True)
111
+ assert core.execution_dir() == str(project / "board")
112
+
113
+
87
114
  def test_host_uid_gid_on_posix():
88
115
  ids = core.host_uid_gid()
89
116
  if hasattr(os, "getuid"):
@@ -14,6 +14,8 @@ def clean_env(monkeypatch, tmp_path):
14
14
  monkeypatch.setattr(core, "WORKSPACE_MOUNT", str(tmp_path))
15
15
  monkeypatch.setattr(core, "EXTRA_MOUNTS", [])
16
16
  monkeypatch.setattr(core, "NO_DOCKER", False)
17
+ monkeypatch.setattr(core, "CONFIG_DIR", None)
18
+ monkeypatch.setattr(core, "DOCKER_ARGS", [])
17
19
  # Neutralise X11 forwarding so argv is deterministic across hosts.
18
20
  monkeypatch.delenv("XAUTHORITY", raising=False)
19
21
  monkeypatch.delenv("HOME", raising=False)
@@ -21,20 +23,71 @@ def clean_env(monkeypatch, tmp_path):
21
23
 
22
24
  # ---- build_renode_argv ----
23
25
 
24
- def test_no_docker_is_bare_renode(monkeypatch):
26
+ def test_no_docker_is_bare_renode(monkeypatch, tmp_path):
25
27
  monkeypatch.setattr(core, "NO_DOCKER", True)
28
+ # Absolute: renode runs in the config's directory, not the cwd.
26
29
  assert docker.build_renode_argv("job.resc", "img", extra_args=["--console"]) == [
27
- "renode", "job.resc", "--console"]
30
+ "renode", str(tmp_path / "job.resc"), "--console"]
28
31
 
29
32
 
30
- def test_docker_argv_core_tokens():
33
+ def test_docker_argv_core_tokens(tmp_path):
31
34
  argv = docker.build_renode_argv("job.resc", "antmicro/renode:1.16.1")
32
35
  assert argv[:3] == ["docker", "run", "-it"]
33
36
  assert "--net=host" in argv
34
37
  assert "antmicro/renode:1.16.1" in argv
35
38
  # The image must be immediately followed by the renode invocation.
36
39
  i = argv.index("antmicro/renode:1.16.1")
37
- assert argv[i + 1:i + 3] == ["renode", "job.resc"]
40
+ assert argv[i + 1:i + 3] == ["renode", str(tmp_path / "job.resc")]
41
+
42
+
43
+ def test_workdir_is_cwd_without_config(tmp_path):
44
+ argv = docker.build_renode_argv("j.resc", "img")
45
+ assert argv[argv.index("-w") + 1] == str(tmp_path)
46
+
47
+
48
+ def test_workdir_is_config_dir_mount_unchanged(monkeypatch, tmp_path):
49
+ (tmp_path / "sub").mkdir()
50
+ monkeypatch.setattr(core, "CONFIG_DIR", str(tmp_path / "sub"))
51
+ argv = docker.build_renode_argv("j.resc", "img")
52
+ assert argv[argv.index("-v") + 1] == f"{tmp_path}:{tmp_path}"
53
+ assert argv[argv.index("-w") + 1] == str(tmp_path / "sub")
54
+
55
+
56
+ def test_user_docker_args_reach_every_container(monkeypatch):
57
+ monkeypatch.setattr(core, "DOCKER_ARGS", ["-v", "/opt/tools:/opt/tools", "-e", "FOO=1"])
58
+ calls = []
59
+
60
+ class _Proc:
61
+ returncode, stdout = 0, ""
62
+
63
+ monkeypatch.setattr(docker.subprocess, "run",
64
+ lambda cmd, **kw: calls.append(cmd) or _Proc())
65
+ monkeypatch.setattr(docker.os, "makedirs", lambda *a, **kw: None)
66
+ docker.run_build("true", "img")
67
+ docker.run_in_container("true", "img")
68
+ docker.run_renode_test("t.robot", "img", "res")
69
+ docker.run_renode("j.resc", "img")
70
+ for cmd in calls:
71
+ i = cmd.index("/opt/tools:/opt/tools")
72
+ assert cmd[i - 1] == "-v" and cmd[i + 1:i + 3] == ["-e", "FOO=1"]
73
+ assert i < cmd.index("img") # flags must precede the image
74
+
75
+
76
+ def test_no_docker_steps_run_in_config_dir(monkeypatch, tmp_path):
77
+ monkeypatch.setattr(core, "NO_DOCKER", True)
78
+ monkeypatch.setattr(core, "CONFIG_DIR", str(tmp_path / "sub"))
79
+ cwds = []
80
+
81
+ class _Proc:
82
+ returncode, stdout = 0, ""
83
+
84
+ monkeypatch.setattr(docker.subprocess, "run",
85
+ lambda cmd, **kw: cwds.append(kw.get("cwd")) or _Proc())
86
+ docker.run_build("true", "img")
87
+ docker.run_in_container("true", "img")
88
+ docker.run_renode_test("t.robot", "img", "res")
89
+ docker.run_renode("j.resc", "img")
90
+ assert cwds == [str(tmp_path / "sub")] * 4
38
91
 
39
92
 
40
93
  def test_docker_argv_does_not_publish_ports():
@@ -60,9 +113,9 @@ def test_every_container_uses_host_network(monkeypatch):
60
113
  assert all("--net=host" in cmd for cmd in calls)
61
114
 
62
115
 
63
- def test_docker_argv_appends_extra_args_after_resc():
116
+ def test_docker_argv_appends_extra_args_after_resc(tmp_path):
64
117
  argv = docker.build_renode_argv("j.resc", "img", extra_args=["--console", "--hide-log"])
65
- assert argv[-3:] == ["j.resc", "--console", "--hide-log"]
118
+ assert argv[-3:] == [str(tmp_path / "j.resc"), "--console", "--hide-log"]
66
119
 
67
120
 
68
121
  # ---- _runtime_flags ----
@@ -6,7 +6,7 @@ import os
6
6
  import pytest
7
7
 
8
8
  from hector.generator import (NodeDescriptor, PeripheralDef, build_repl_fragment,
9
- finalize_commands, generate_emulation_resc,
9
+ collect_artifacts, finalize_commands, generate_emulation_resc,
10
10
  generate_node_repl, repl_value, snapshot_resc,
11
11
  write_inline_repl)
12
12
 
@@ -194,3 +194,19 @@ def test_snapshot_resc_reopens_gdb_after_load():
194
194
  # A GDB server is never part of saved state, so it must be (re)started post-Load.
195
195
  assert text.index("Load @") < text.index("StartGdbServer 4444")
196
196
  assert 'mach set "mcu"' in text
197
+
198
+
199
+ # ---- collect_artifacts ----
200
+
201
+ def test_collect_artifacts_resolves_against_base_dir(tmp_path, monkeypatch):
202
+ # Config in board/: its 'artifacts:' globs resolve there, not against the cwd.
203
+ monkeypatch.chdir(tmp_path)
204
+ (tmp_path / "board" / "build").mkdir(parents=True)
205
+ (tmp_path / "board" / "build" / "fw.bin").write_text("x")
206
+ (tmp_path / "board" / "uart.log").write_text("x")
207
+ (tmp_path / "other.log").write_text("x")
208
+ collected = collect_artifacts(["build/*.bin", "*.log"], "results", 1,
209
+ base_dir=str(tmp_path / "board"))
210
+ assert sorted(collected) == [
211
+ os.path.join("results", "artifacts", "job_1", "board", "build", "fw.bin"),
212
+ os.path.join("results", "artifacts", "job_1", "board", "uart.log")]
@@ -36,6 +36,61 @@ def test_config_path_returns_requested_when_neither_exists(tmp_path, monkeypatch
36
36
  assert resolve_config_path("missing.yaml") == "missing.yaml"
37
37
 
38
38
 
39
+ # ---- load_config_or_exit: the config's directory ----
40
+
41
+ @pytest.fixture
42
+ def config_in_subdir(tmp_path, monkeypatch):
43
+ from hector import core, pipeline
44
+ monkeypatch.chdir(tmp_path)
45
+ monkeypatch.setattr(core, "WORKSPACE_MOUNT", str(tmp_path))
46
+ monkeypatch.setattr(core, "NO_DOCKER", False)
47
+ monkeypatch.setattr(core, "CONFIG_DIR", None)
48
+ monkeypatch.setattr(core, "EXTRA_MOUNTS", [])
49
+ (tmp_path / "board").mkdir()
50
+ (tmp_path / "board" / ".hector.yaml").write_text("renode_version: '1.16.1'\n")
51
+ return pipeline, core, tmp_path
52
+
53
+
54
+ def test_load_config_sets_config_dir(config_in_subdir, monkeypatch):
55
+ pipeline, core, root = config_in_subdir
56
+ monkeypatch.setattr(pipeline, "CONFIG_PATH", "board/.hector.yaml")
57
+ pipeline.load_config_or_exit()
58
+ assert core.CONFIG_DIR == str(root / "board")
59
+ assert core.execution_dir() == str(root / "board")
60
+ assert core.EXTRA_MOUNTS == [] # inside the cwd mount already
61
+
62
+
63
+ def test_load_config_outside_cwd_is_mounted_too(config_in_subdir, monkeypatch):
64
+ # Run from a sibling dir: the cwd stays the workspace mount, and ../board is
65
+ # bind-mounted 1:1 so the config's paths and working dir are reachable.
66
+ pipeline, core, root = config_in_subdir
67
+ (root / "elsewhere").mkdir()
68
+ monkeypatch.setattr(core, "WORKSPACE_MOUNT", str(root / "elsewhere"))
69
+ monkeypatch.chdir(root / "elsewhere")
70
+ monkeypatch.setattr(pipeline, "CONFIG_PATH", "../board/.hector.yaml")
71
+ pipeline.load_config_or_exit()
72
+ board = str((root / "board").resolve())
73
+ assert core.EXTRA_MOUNTS == [board]
74
+ assert core.execution_dir() == board
75
+ assert core.to_container_path(core.from_config("fw.elf")) == f"{board}/fw.elf"
76
+
77
+
78
+ # ---- apply_docker_args ----
79
+
80
+ @pytest.mark.parametrize("cfg,flag,expected", [
81
+ ({}, "", []),
82
+ ({"docker_args": "-v /a:/a -e X=1"}, "", ["-v", "/a:/a", "-e", "X=1"]),
83
+ ({"docker_args": ["-v /a:/a", "--env 'Y=two words'"]}, "",
84
+ ["-v", "/a:/a", "--env", "Y=two words"]),
85
+ ({"docker_args": "-v /a:/a"}, "-v /b:/b", ["-v", "/a:/a", "-v", "/b:/b"]), # flag appends
86
+ ])
87
+ def test_apply_docker_args(monkeypatch, cfg, flag, expected):
88
+ from hector import core, pipeline
89
+ monkeypatch.setattr(core, "DOCKER_ARGS", [])
90
+ pipeline.apply_docker_args(cfg, SimpleNamespace(docker_args=flag))
91
+ assert core.DOCKER_ARGS == expected
92
+
93
+
39
94
  # ---- resolve_renode_image ----
40
95
 
41
96
  def test_image_from_config():
@@ -39,6 +39,22 @@ def test_missing_renode_version_is_error():
39
39
  assert any(i.path == "renode_version" for i in errors)
40
40
 
41
41
 
42
+ def test_renode_image_alone_satisfies_requirement():
43
+ errors, _ = validate_config({"renode_image": "my-renode:dev"})
44
+ assert errors == []
45
+
46
+
47
+ @pytest.mark.parametrize("value", ["-v /a:/a", ["-v /a:/a", "-e X=1"]])
48
+ def test_docker_args_accepts_string_or_list(value):
49
+ errors, warnings = validate_config({"renode_version": "1.16.1", "docker_args": value})
50
+ assert errors == [] and not any(w.path == "docker_args" for w in warnings)
51
+
52
+
53
+ def test_docker_args_wrong_type_is_error():
54
+ errors, _ = validate_config({"renode_version": "1.16.1", "docker_args": {"v": "/a"}})
55
+ assert any(i.path == "docker_args" for i in errors)
56
+
57
+
42
58
  def test_unknown_module_kind_is_error():
43
59
  cfg = {"renode_version": "1.16.1",
44
60
  "modules": {"u": {"kind": "bogus", "type": "X", "source": "s"}}}
File without changes
File without changes
File without changes
File without changes