hector-cli 0.4.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 (56) hide show
  1. {hector_cli-0.4.0 → hector_cli-0.6.0}/PKG-INFO +33 -1
  2. {hector_cli-0.4.0 → hector_cli-0.6.0}/README.md +32 -0
  3. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/commands/base.py +6 -0
  4. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/core.py +33 -2
  5. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/docker.py +42 -30
  6. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/generator.py +9 -7
  7. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/mappings.py +2 -2
  8. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/modules.py +3 -3
  9. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/pipeline.py +36 -8
  10. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/runners.py +3 -3
  11. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/validator.py +8 -1
  12. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector_cli.egg-info/PKG-INFO +33 -1
  13. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_core_paths.py +27 -0
  14. hector_cli-0.6.0/tests/test_docker_argv.py +171 -0
  15. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_generator.py +17 -1
  16. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_pipeline_helpers.py +55 -0
  17. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_validator.py +16 -0
  18. hector_cli-0.4.0/tests/test_docker_argv.py +0 -101
  19. {hector_cli-0.4.0 → hector_cli-0.6.0}/LICENSE +0 -0
  20. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/__init__.py +0 -0
  21. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/__main__.py +0 -0
  22. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/commands/__init__.py +0 -0
  23. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/commands/clean.py +0 -0
  24. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/commands/export.py +0 -0
  25. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/commands/init.py +0 -0
  26. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/commands/run.py +0 -0
  27. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/commands/test.py +0 -0
  28. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/commands/validate.py +0 -0
  29. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/connections.py +0 -0
  30. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/dependencies.py +0 -0
  31. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/hubs.py +0 -0
  32. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/peripherals.py +0 -0
  33. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/reporters.py +0 -0
  34. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector/scaffold.py +0 -0
  35. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector_cli.egg-info/SOURCES.txt +0 -0
  36. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector_cli.egg-info/dependency_links.txt +0 -0
  37. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector_cli.egg-info/entry_points.txt +0 -0
  38. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector_cli.egg-info/requires.txt +0 -0
  39. {hector_cli-0.4.0 → hector_cli-0.6.0}/hector_cli.egg-info/top_level.txt +0 -0
  40. {hector_cli-0.4.0 → hector_cli-0.6.0}/setup.cfg +0 -0
  41. {hector_cli-0.4.0 → hector_cli-0.6.0}/setup.py +0 -0
  42. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_arguments.py +0 -0
  43. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_clean.py +0 -0
  44. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_cli_smoke.py +0 -0
  45. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_connections.py +0 -0
  46. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_core_registry.py +0 -0
  47. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_core_text.py +0 -0
  48. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_hubs.py +0 -0
  49. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_mappings.py +0 -0
  50. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_matrix.py +0 -0
  51. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_needs_renode_source.py +0 -0
  52. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_peripherals.py +0 -0
  53. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_reporters.py +0 -0
  54. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_runners.py +0 -0
  55. {hector_cli-0.4.0 → hector_cli-0.6.0}/tests/test_scaffold.py +0 -0
  56. {hector_cli-0.4.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.4.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,36 @@ 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
+
512
+ #### Container networking
513
+
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.
515
+
485
516
  #### Running without Docker (`--no-docker`)
486
517
 
487
518
  Call the locally installed `renode` and `renode-test` binaries directly instead of launching a Docker container. `build:` steps and `shell` tests also run on the host (their `image:` is ignored, with a warning), as do module builds.
@@ -621,6 +652,7 @@ The configuration lives in `.hector.yaml`.
621
652
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
622
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. |
623
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). |
624
656
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
625
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. |
626
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,36 @@ 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
+
482
+ #### Container networking
483
+
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.
485
+
455
486
  #### Running without Docker (`--no-docker`)
456
487
 
457
488
  Call the locally installed `renode` and `renode-test` binaries directly instead of launching a Docker container. `build:` steps and `shell` tests also run on the host (their `image:` is ignored, with a warning), as do module builds.
@@ -591,6 +622,7 @@ The configuration lives in `.hector.yaml`.
591
622
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
592
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. |
593
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). |
594
626
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
595
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. |
596
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.4.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,24 @@ 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
62
+
63
+
64
+ # Every container shares the host's network stack: GDB servers, tcp: mappings and
65
+ # anything a build/test step listens on are reachable from the host as-is, and steps
66
+ # can reach host services (e.g. a local registry or a TCP-mapped UART) on localhost.
67
+ # No -p publishing is needed (Docker ignores it in this mode anyway).
68
+ _HOST_NETWORK = ["--net=host"]
61
69
 
62
70
 
63
71
  def _runtime_flags(runtime):
@@ -86,10 +94,10 @@ def _runtime_flags(runtime):
86
94
  def run_build(script, image):
87
95
  """Run a build script in the container as root (for apt-get + cmake)."""
88
96
  if core.NO_DOCKER:
89
- subprocess.run(["bash", "-c", script], check=True)
97
+ subprocess.run(["bash", "-c", script], check=True, cwd=core.execution_dir())
90
98
  return
91
99
  subprocess.run(
92
- ["docker", "run", "--rm", "-u", "0:0",
100
+ ["docker", "run", "--rm", *_HOST_NETWORK, "-u", "0:0",
93
101
  *_workspace_mount(), *_extra_mounts(), image, "bash", "-c", script],
94
102
  check=True,
95
103
  )
@@ -124,14 +132,17 @@ def run_in_container(script, image, live=False):
124
132
  fail on the first error.
125
133
 
126
134
  Backs the `shell` test type and the `build:` steps. Normally a one-off container of
127
- `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
128
138
  if core.NO_DOCKER:
129
139
  cmd = ["bash", "-c", f"set -ex\n{script}"]
140
+ cwd = core.execution_dir()
130
141
  else:
131
- cmd = ["docker", "run", "--rm", *_workspace_mount(), *_extra_mounts(),
132
- *_user_flags(), image, "sh", "-c", f"set -ex\n{script}"]
142
+ cmd = ["docker", "run", "--rm", *_HOST_NETWORK, *_workspace_mount(),
143
+ *_extra_mounts(), *_user_flags(), image, "sh", "-c", f"set -ex\n{script}"]
133
144
  if live:
134
- proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
145
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, cwd=cwd,
135
146
  stderr=subprocess.STDOUT, text=True)
136
147
  lines = []
137
148
  for line in proc.stdout:
@@ -139,35 +150,36 @@ def run_in_container(script, image, live=False):
139
150
  lines.append(line)
140
151
  proc.wait()
141
152
  return "".join(lines), proc.returncode == 0
142
- 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)
143
155
  if proc.stdout:
144
156
  print(proc.stdout, end="")
145
157
  return proc.stdout, proc.returncode == 0
146
158
 
147
159
 
148
- def build_renode_argv(resc_filename, image, ports=None, extra_args=None, runtime=None):
160
+ def build_renode_argv(resc_filename, image, extra_args=None, runtime=None):
149
161
  """Build the exact argv that would run an emulation resc — the local `renode`
150
162
  invocation under --no-docker, otherwise the full `docker run ... renode` command.
151
163
 
152
164
  Returning the command (instead of running it) is what lets the `export` command
153
165
  print it verbatim and keeps run_renode a one-liner around this builder.
154
- 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."""
155
168
  if core.NO_DOCKER:
156
- return ["renode", resc_filename] + (extra_args or [])
157
- cmd = ["docker", "run", "-it", "--rm", *_xdisplay_args(), "--net=host",
158
- *_workspace_mount(), *_extra_mounts(), *_user_flags(), *_runtime_flags(runtime)]
159
- for port in (ports or []):
160
- cmd += ["-p", f"{port}:{port}"]
161
- cmd += [image, "renode", resc_filename] + (extra_args or [])
162
- return cmd
169
+ return ["renode", os.path.abspath(resc_filename)] + (extra_args or [])
170
+ return ["docker", "run", "-it", "--rm", *_xdisplay_args(), *_HOST_NETWORK,
171
+ *_workspace_mount(), *_extra_mounts(), *_user_flags(), *_runtime_flags(runtime),
172
+ image, "renode", to_container_path(resc_filename)] + (extra_args or [])
163
173
 
164
174
 
165
- def run_renode(resc_filename, image, ports=None, extra_args=None, output_dir="results", runtime=None):
166
- """Run an emulation resc interactively. In debug mode, expose GDB ports.
175
+ def run_renode(resc_filename, image, extra_args=None, output_dir="results", runtime=None):
176
+ """Run an emulation resc interactively. GDB ports (--debug) are reachable from the
177
+ host through the host network.
167
178
  runtime: aggregated RuntimeOptions whose docker flags (caps/devices/root) are added."""
168
179
  if not core.NO_DOCKER:
169
180
  os.makedirs(os.path.join(os.getcwd(), output_dir), exist_ok=True)
170
- subprocess.run(build_renode_argv(resc_filename, image, ports, 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)
171
183
 
172
184
 
173
185
  def run_renode_test(robot_file_host, image, results_dir_host,
@@ -177,22 +189,22 @@ def run_renode_test(robot_file_host, image, results_dir_host,
177
189
  resc_host_path: passed as --variable RESC:<path> so .robot files can load
178
190
  the generated emulation script via ${RESC}.
179
191
  extra_args: optional list of additional flags forwarded verbatim to renode-test.
180
- interactive: attach stdin/tty and add --net=host (useful with Pause Execution
181
- or when TCP ports need to be reachable from the host during the test).
192
+ interactive: attach stdin/tty and X display (useful with Pause Execution).
182
193
  """
183
194
  os.makedirs(results_dir_host, exist_ok=True)
184
195
  if core.NO_DOCKER:
185
- 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)]
186
199
  if resc_host_path:
187
- cmd += ["--variable", f"RESC:{resc_host_path}"]
200
+ cmd += ["--variable", f"RESC:{os.path.abspath(resc_host_path)}"]
188
201
  if extra_args:
189
202
  cmd += extra_args
190
- result = subprocess.run(cmd)
203
+ result = subprocess.run(cmd, cwd=core.execution_dir())
191
204
  return result.returncode == 0
192
205
  flags = ["-it"] if interactive else []
193
- net = ["--net=host"] if interactive else []
194
206
  display = _xdisplay_args() if interactive else []
195
- cmd = ["docker", "run", "--rm", *flags, *net, *display,
207
+ cmd = ["docker", "run", "--rm", *flags, *_HOST_NETWORK, *display,
196
208
  *_workspace_mount(), *_extra_mounts(), *_user_flags(), *_runtime_flags(runtime),
197
209
  image, "renode-test", to_container_path(robot_file_host),
198
210
  "-r", to_container_path(results_dir_host)]
@@ -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.
@@ -251,8 +280,7 @@ def snapshot_fastpath(args):
251
280
  no_start=getattr(args, "no_start", False)))
252
281
  print(f"[SNAP] {args.snapshot}"
253
282
  + (f" (+GDB: {debug_config})" if debug_config else ""))
254
- run_renode(snap_resc, docker_image, ports=list(debug_config.values()) or None,
255
- extra_args=shlex.split(args.renode_args or ""), output_dir=args.output)
283
+ run_renode(snap_resc, docker_image, extra_args=shlex.split(args.renode_args or ""), output_dir=args.output)
256
284
 
257
285
 
258
286
  def _run_validate():
@@ -486,6 +514,7 @@ class JobConfig:
486
514
  output_dir: str = "results"
487
515
  gather_metrics: list = None
488
516
  artifacts: list = field(default_factory=list)
517
+ artifacts_base: str = "." # directory the artifact globs resolve against
489
518
 
490
519
 
491
520
  @dataclass
@@ -524,7 +553,6 @@ def action_simulate(state, cfg, result):
524
553
  if not state.resc_filename:
525
554
  raise ValueError("`hector run` needs at least one machine in 'machines:'.")
526
555
  run_renode(_resolve_active_resc(state, cfg), cfg.docker_image,
527
- ports=list(state.debug_config.values()) or None,
528
556
  extra_args=cfg.renode_args, output_dir=cfg.output_dir,
529
557
  runtime=state.runtime)
530
558
 
@@ -536,7 +564,6 @@ def action_export(state, cfg, result):
536
564
  if not state.resc_filename:
537
565
  raise ValueError("`hector export` needs at least one machine in 'machines:'.")
538
566
  argv = build_renode_argv(_resolve_active_resc(state, cfg), cfg.docker_image,
539
- ports=list(state.debug_config.values()) or None,
540
567
  extra_args=cfg.renode_args, runtime=state.runtime)
541
568
  line = shlex.join(argv)
542
569
  print(line)
@@ -760,7 +787,8 @@ def run_job(i, total_jobs, job_context, cfg):
760
787
  # Global glob patterns (top-level 'artifacts:' or --artifacts), interpolated
761
788
  # with this job's context so matrix variables resolve.
762
789
  artifact_patterns = as_lines(interpolate(list(cfg.artifacts), context))
763
- 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)
764
792
 
765
793
  except Exception as exc:
766
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.4.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,36 @@ 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
+
512
+ #### Container networking
513
+
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.
515
+
485
516
  #### Running without Docker (`--no-docker`)
486
517
 
487
518
  Call the locally installed `renode` and `renode-test` binaries directly instead of launching a Docker container. `build:` steps and `shell` tests also run on the host (their `image:` is ignored, with a warning), as do module builds.
@@ -621,6 +652,7 @@ The configuration lives in `.hector.yaml`.
621
652
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
622
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. |
623
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). |
624
656
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
625
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. |
626
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"):
@@ -0,0 +1,171 @@
1
+ """Unit tests for hector.docker command construction. We assert on the argv that
2
+ *would* run — never invoking Docker or Renode. build_renode_argv is the seam the
3
+ `export` command prints verbatim."""
4
+
5
+ import pytest
6
+
7
+ from hector import core, docker
8
+ from hector.core import RuntimeOptions
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def clean_env(monkeypatch, tmp_path):
13
+ monkeypatch.chdir(tmp_path)
14
+ monkeypatch.setattr(core, "WORKSPACE_MOUNT", str(tmp_path))
15
+ monkeypatch.setattr(core, "EXTRA_MOUNTS", [])
16
+ monkeypatch.setattr(core, "NO_DOCKER", False)
17
+ monkeypatch.setattr(core, "CONFIG_DIR", None)
18
+ monkeypatch.setattr(core, "DOCKER_ARGS", [])
19
+ # Neutralise X11 forwarding so argv is deterministic across hosts.
20
+ monkeypatch.delenv("XAUTHORITY", raising=False)
21
+ monkeypatch.delenv("HOME", raising=False)
22
+
23
+
24
+ # ---- build_renode_argv ----
25
+
26
+ def test_no_docker_is_bare_renode(monkeypatch, tmp_path):
27
+ monkeypatch.setattr(core, "NO_DOCKER", True)
28
+ # Absolute: renode runs in the config's directory, not the cwd.
29
+ assert docker.build_renode_argv("job.resc", "img", extra_args=["--console"]) == [
30
+ "renode", str(tmp_path / "job.resc"), "--console"]
31
+
32
+
33
+ def test_docker_argv_core_tokens(tmp_path):
34
+ argv = docker.build_renode_argv("job.resc", "antmicro/renode:1.16.1")
35
+ assert argv[:3] == ["docker", "run", "-it"]
36
+ assert "--net=host" in argv
37
+ assert "antmicro/renode:1.16.1" in argv
38
+ # The image must be immediately followed by the renode invocation.
39
+ i = argv.index("antmicro/renode:1.16.1")
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
91
+
92
+
93
+ def test_docker_argv_does_not_publish_ports():
94
+ # Host networking exposes GDB ports directly; -p would be ignored by Docker.
95
+ argv = docker.build_renode_argv("j.resc", "img")
96
+ assert "-p" not in argv
97
+
98
+
99
+ def test_every_container_uses_host_network(monkeypatch):
100
+ calls = []
101
+
102
+ class _Proc:
103
+ returncode, stdout = 0, ""
104
+
105
+ monkeypatch.setattr(docker.subprocess, "run",
106
+ lambda cmd, **kw: calls.append(cmd) or _Proc())
107
+ monkeypatch.setattr(docker.os, "makedirs", lambda *a, **kw: None)
108
+ docker.run_build("true", "img")
109
+ docker.run_in_container("true", "img")
110
+ docker.run_renode_test("t.robot", "img", "res")
111
+ docker.run_renode("j.resc", "img")
112
+ assert len(calls) == 4
113
+ assert all("--net=host" in cmd for cmd in calls)
114
+
115
+
116
+ def test_docker_argv_appends_extra_args_after_resc(tmp_path):
117
+ argv = docker.build_renode_argv("j.resc", "img", extra_args=["--console", "--hide-log"])
118
+ assert argv[-3:] == [str(tmp_path / "j.resc"), "--console", "--hide-log"]
119
+
120
+
121
+ # ---- _runtime_flags ----
122
+
123
+ def test_runtime_flags_none():
124
+ assert docker._runtime_flags(None) == []
125
+
126
+
127
+ def test_runtime_flags_root_and_caps():
128
+ flags = docker._runtime_flags(RuntimeOptions(root=True, capabilities={"NET_ADMIN"}))
129
+ assert "-u" in flags and "0:0" in flags
130
+ assert flags[flags.index("--cap-add") + 1] == "NET_ADMIN"
131
+
132
+
133
+ def test_runtime_flags_device_present(monkeypatch):
134
+ monkeypatch.setattr(docker.os.path, "exists", lambda p: p == "/dev/net/tun")
135
+ flags = docker._runtime_flags(RuntimeOptions(devices={"/dev/net/tun"}))
136
+ assert flags[flags.index("--device") + 1] == "/dev/net/tun"
137
+
138
+
139
+ def test_runtime_flags_all_devices_absent_yields_nothing(monkeypatch):
140
+ # A requested device missing on the host must not fail the run, and (per the
141
+ # docstring) no flags at all are applied so it drops to unprivileged dummy mode.
142
+ monkeypatch.setattr(docker.os.path, "exists", lambda p: False)
143
+ assert docker._runtime_flags(
144
+ RuntimeOptions(devices={"/dev/net/tun"}, root=True)) == []
145
+
146
+
147
+ # ---- _user_flags ----
148
+
149
+ def test_user_flags_maps_host_uid(monkeypatch):
150
+ monkeypatch.setattr(core, "host_uid_gid", lambda: (1000, 1000))
151
+ assert docker._user_flags() == ["-u", "1000:1000"]
152
+
153
+
154
+ def test_user_flags_empty_without_uid(monkeypatch):
155
+ monkeypatch.setattr(core, "host_uid_gid", lambda: None)
156
+ assert docker._user_flags() == []
157
+
158
+
159
+ # ---- resolve_shell_image ----
160
+
161
+ def test_shell_image_explicit_wins():
162
+ assert docker.resolve_shell_image({"image": "alpine"}, "default:img") == "alpine"
163
+
164
+
165
+ def test_shell_image_falls_back_to_default():
166
+ assert docker.resolve_shell_image({}, "default:img") == "default:img"
167
+
168
+
169
+ def test_shell_image_no_docker_uses_default(monkeypatch):
170
+ monkeypatch.setattr(core, "NO_DOCKER", True)
171
+ assert docker.resolve_shell_image({"image": "alpine"}, "default:img") == "alpine"
@@ -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"}}}
@@ -1,101 +0,0 @@
1
- """Unit tests for hector.docker command construction. We assert on the argv that
2
- *would* run — never invoking Docker or Renode. build_renode_argv is the seam the
3
- `export` command prints verbatim."""
4
-
5
- import pytest
6
-
7
- from hector import core, docker
8
- from hector.core import RuntimeOptions
9
-
10
-
11
- @pytest.fixture(autouse=True)
12
- def clean_env(monkeypatch, tmp_path):
13
- monkeypatch.chdir(tmp_path)
14
- monkeypatch.setattr(core, "WORKSPACE_MOUNT", str(tmp_path))
15
- monkeypatch.setattr(core, "EXTRA_MOUNTS", [])
16
- monkeypatch.setattr(core, "NO_DOCKER", False)
17
- # Neutralise X11 forwarding so argv is deterministic across hosts.
18
- monkeypatch.delenv("XAUTHORITY", raising=False)
19
- monkeypatch.delenv("HOME", raising=False)
20
-
21
-
22
- # ---- build_renode_argv ----
23
-
24
- def test_no_docker_is_bare_renode(monkeypatch):
25
- monkeypatch.setattr(core, "NO_DOCKER", True)
26
- assert docker.build_renode_argv("job.resc", "img", extra_args=["--console"]) == [
27
- "renode", "job.resc", "--console"]
28
-
29
-
30
- def test_docker_argv_core_tokens():
31
- argv = docker.build_renode_argv("job.resc", "antmicro/renode:1.16.1")
32
- assert argv[:3] == ["docker", "run", "-it"]
33
- assert "--net=host" in argv
34
- assert "antmicro/renode:1.16.1" in argv
35
- # The image must be immediately followed by the renode invocation.
36
- i = argv.index("antmicro/renode:1.16.1")
37
- assert argv[i + 1:i + 3] == ["renode", "job.resc"]
38
-
39
-
40
- def test_docker_argv_publishes_ports():
41
- argv = docker.build_renode_argv("j.resc", "img", ports=[3333, 3334])
42
- joined = " ".join(argv)
43
- assert "-p 3333:3333" in joined and "-p 3334:3334" in joined
44
-
45
-
46
- def test_docker_argv_appends_extra_args_after_resc():
47
- argv = docker.build_renode_argv("j.resc", "img", extra_args=["--console", "--hide-log"])
48
- assert argv[-3:] == ["j.resc", "--console", "--hide-log"]
49
-
50
-
51
- # ---- _runtime_flags ----
52
-
53
- def test_runtime_flags_none():
54
- assert docker._runtime_flags(None) == []
55
-
56
-
57
- def test_runtime_flags_root_and_caps():
58
- flags = docker._runtime_flags(RuntimeOptions(root=True, capabilities={"NET_ADMIN"}))
59
- assert "-u" in flags and "0:0" in flags
60
- assert flags[flags.index("--cap-add") + 1] == "NET_ADMIN"
61
-
62
-
63
- def test_runtime_flags_device_present(monkeypatch):
64
- monkeypatch.setattr(docker.os.path, "exists", lambda p: p == "/dev/net/tun")
65
- flags = docker._runtime_flags(RuntimeOptions(devices={"/dev/net/tun"}))
66
- assert flags[flags.index("--device") + 1] == "/dev/net/tun"
67
-
68
-
69
- def test_runtime_flags_all_devices_absent_yields_nothing(monkeypatch):
70
- # A requested device missing on the host must not fail the run, and (per the
71
- # docstring) no flags at all are applied so it drops to unprivileged dummy mode.
72
- monkeypatch.setattr(docker.os.path, "exists", lambda p: False)
73
- assert docker._runtime_flags(
74
- RuntimeOptions(devices={"/dev/net/tun"}, root=True)) == []
75
-
76
-
77
- # ---- _user_flags ----
78
-
79
- def test_user_flags_maps_host_uid(monkeypatch):
80
- monkeypatch.setattr(core, "host_uid_gid", lambda: (1000, 1000))
81
- assert docker._user_flags() == ["-u", "1000:1000"]
82
-
83
-
84
- def test_user_flags_empty_without_uid(monkeypatch):
85
- monkeypatch.setattr(core, "host_uid_gid", lambda: None)
86
- assert docker._user_flags() == []
87
-
88
-
89
- # ---- resolve_shell_image ----
90
-
91
- def test_shell_image_explicit_wins():
92
- assert docker.resolve_shell_image({"image": "alpine"}, "default:img") == "alpine"
93
-
94
-
95
- def test_shell_image_falls_back_to_default():
96
- assert docker.resolve_shell_image({}, "default:img") == "default:img"
97
-
98
-
99
- def test_shell_image_no_docker_uses_default(monkeypatch):
100
- monkeypatch.setattr(core, "NO_DOCKER", True)
101
- assert docker.resolve_shell_image({"image": "alpine"}, "default:img") == "alpine"
File without changes
File without changes
File without changes
File without changes