hector-cli 0.5.0__tar.gz → 0.7.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.7.0}/PKG-INFO +35 -4
  2. {hector_cli-0.5.0 → hector_cli-0.7.0}/README.md +34 -3
  3. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/commands/base.py +8 -2
  4. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/core.py +50 -12
  5. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/docker.py +24 -15
  6. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/generator.py +9 -7
  7. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/mappings.py +2 -2
  8. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/modules.py +3 -3
  9. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/pipeline.py +48 -4
  10. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/runners.py +3 -3
  11. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/validator.py +8 -1
  12. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector_cli.egg-info/PKG-INFO +35 -4
  13. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_core_paths.py +27 -0
  14. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_docker_argv.py +59 -6
  15. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_generator.py +17 -1
  16. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_pipeline_helpers.py +84 -0
  17. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_validator.py +16 -0
  18. {hector_cli-0.5.0 → hector_cli-0.7.0}/LICENSE +0 -0
  19. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/__init__.py +0 -0
  20. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/__main__.py +0 -0
  21. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/commands/__init__.py +0 -0
  22. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/commands/clean.py +0 -0
  23. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/commands/export.py +0 -0
  24. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/commands/init.py +0 -0
  25. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/commands/run.py +0 -0
  26. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/commands/test.py +0 -0
  27. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/commands/validate.py +0 -0
  28. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/connections.py +0 -0
  29. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/dependencies.py +0 -0
  30. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/hubs.py +0 -0
  31. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/peripherals.py +0 -0
  32. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/reporters.py +0 -0
  33. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector/scaffold.py +0 -0
  34. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector_cli.egg-info/SOURCES.txt +0 -0
  35. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector_cli.egg-info/dependency_links.txt +0 -0
  36. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector_cli.egg-info/entry_points.txt +0 -0
  37. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector_cli.egg-info/requires.txt +0 -0
  38. {hector_cli-0.5.0 → hector_cli-0.7.0}/hector_cli.egg-info/top_level.txt +0 -0
  39. {hector_cli-0.5.0 → hector_cli-0.7.0}/setup.cfg +0 -0
  40. {hector_cli-0.5.0 → hector_cli-0.7.0}/setup.py +0 -0
  41. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_arguments.py +0 -0
  42. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_clean.py +0 -0
  43. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_cli_smoke.py +0 -0
  44. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_connections.py +0 -0
  45. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_core_registry.py +0 -0
  46. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_core_text.py +0 -0
  47. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_hubs.py +0 -0
  48. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_mappings.py +0 -0
  49. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_matrix.py +0 -0
  50. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_needs_renode_source.py +0 -0
  51. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_peripherals.py +0 -0
  52. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_reporters.py +0 -0
  53. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_runners.py +0 -0
  54. {hector_cli-0.5.0 → hector_cli-0.7.0}/tests/test_scaffold.py +0 -0
  55. {hector_cli-0.5.0 → hector_cli-0.7.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.7.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,10 +206,11 @@ 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 |
212
- | `hector run --workspace-mount /mnt` | Override the container path the project root is mounted at |
213
+ | `hector run --workspace-mount /mnt` | Mount the hectorfile's folder at this container path and execute there |
213
214
  | `hector run --snapshot path/to/snapshot.save` | Load a snapshot instead of booting from the resc |
214
215
  | `hector run --snapshot snap.save --renode-version 1.16.1` | Load a snapshot without a `.hector.yaml` |
215
216
  | `hector test` | Run the `tests:` section; exit 1 on any failure |
@@ -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. (With [`--workspace-mount`](#overriding-the-workspace-mount-path---workspace-mount) the hectorfile's folder is mounted at the given path instead.) 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.
@@ -545,12 +572,15 @@ An existing directory is reused as-is; a missing one is cloned into. The locatio
545
572
 
546
573
  #### Overriding the workspace mount path (`--workspace-mount`)
547
574
 
548
- By default the project root is bind-mounted into the container at the **same absolute path** as on the host (e.g. `/home/user/projects/myboard`), so paths look identical inside and outside the container. Override this only if the host path is unavailable at that location inside the image:
575
+ By default everything is bind-mounted into the container at the **same absolute path** as on the host (e.g. `/home/user/projects/myboard`), so paths look identical inside and outside the container. `--workspace-mount PATH` instead mounts the **hectorfile's folder** at `PATH`, and that's where every step, Renode and `renode-test` execute:
549
576
 
550
577
  ```bash
551
- hector run --workspace-mount /workspace
578
+ hector run --workspace-mount /workspace # . → /workspace
579
+ hector test -f boards/nucleo/.hector.yaml --workspace-mount /workspace # boards/nucleo → /workspace
552
580
  ```
553
581
 
582
+ When you run from a different folder than the hectorfile's (the second line), that folder — where `.hector/` and `--output` live — is additionally mounted at its own path, unless it's already inside the hectorfile's folder.
583
+
554
584
  **Cannot be combined with `--no-docker`** — if you are running locally there is no container mount to configure.
555
585
 
556
586
  ### Selecting a matrix combination (`--job`)
@@ -625,6 +655,7 @@ The configuration lives in `.hector.yaml`.
625
655
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
626
656
  | `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
657
  | `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). |
658
+ | `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
659
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
629
660
  | `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
661
  | `matrix` | No | Cross-product job expansion. |
@@ -176,10 +176,11 @@ 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 |
182
- | `hector run --workspace-mount /mnt` | Override the container path the project root is mounted at |
183
+ | `hector run --workspace-mount /mnt` | Mount the hectorfile's folder at this container path and execute there |
183
184
  | `hector run --snapshot path/to/snapshot.save` | Load a snapshot instead of booting from the resc |
184
185
  | `hector run --snapshot snap.save --renode-version 1.16.1` | Load a snapshot without a `.hector.yaml` |
185
186
  | `hector test` | Run the `tests:` section; exit 1 on any failure |
@@ -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. (With [`--workspace-mount`](#overriding-the-workspace-mount-path---workspace-mount) the hectorfile's folder is mounted at the given path instead.) 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.
@@ -515,12 +542,15 @@ An existing directory is reused as-is; a missing one is cloned into. The locatio
515
542
 
516
543
  #### Overriding the workspace mount path (`--workspace-mount`)
517
544
 
518
- By default the project root is bind-mounted into the container at the **same absolute path** as on the host (e.g. `/home/user/projects/myboard`), so paths look identical inside and outside the container. Override this only if the host path is unavailable at that location inside the image:
545
+ By default everything is bind-mounted into the container at the **same absolute path** as on the host (e.g. `/home/user/projects/myboard`), so paths look identical inside and outside the container. `--workspace-mount PATH` instead mounts the **hectorfile's folder** at `PATH`, and that's where every step, Renode and `renode-test` execute:
519
546
 
520
547
  ```bash
521
- hector run --workspace-mount /workspace
548
+ hector run --workspace-mount /workspace # . → /workspace
549
+ hector test -f boards/nucleo/.hector.yaml --workspace-mount /workspace # boards/nucleo → /workspace
522
550
  ```
523
551
 
552
+ When you run from a different folder than the hectorfile's (the second line), that folder — where `.hector/` and `--output` live — is additionally mounted at its own path, unless it's already inside the hectorfile's folder.
553
+
524
554
  **Cannot be combined with `--no-docker`** — if you are running locally there is no container mount to configure.
525
555
 
526
556
  ### Selecting a matrix combination (`--job`)
@@ -595,6 +625,7 @@ The configuration lives in `.hector.yaml`.
595
625
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
596
626
  | `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
627
  | `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). |
628
+ | `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
629
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
599
630
  | `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
631
  | `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 "
@@ -104,8 +110,8 @@ def add_common_arguments(parser):
104
110
  )
105
111
  docker_mode.add_argument(
106
112
  "--workspace-mount", metavar="PATH", dest="workspace_mount", default=None,
107
- help="Container path where the project root is bind-mounted "
108
- "(default: same absolute path as on the host).",
113
+ help="Mount the hectorfile's folder at this container path and execute there "
114
+ "(default: everything is mounted at its own host path).",
109
115
  )
110
116
 
111
117
 
@@ -67,7 +67,7 @@ class Registry:
67
67
  # ==========================================================================
68
68
  # CONSTANTS
69
69
  # ==========================================================================
70
- TOOL_VERSION = "0.5.0"
70
+ TOOL_VERSION = "0.7.0"
71
71
  SUPPORTED_SCHEMA_VERSIONS = {"0.1"}
72
72
 
73
73
  RENODE_REPO_URL = "https://github.com/renode/renode.git"
@@ -78,8 +78,16 @@ ARTIFACTS_DIR = ".hector" # everything we generate/clone lives here (gi
78
78
  # hosts we mirror the host cwd, so paths look identical in and out; on Windows the host
79
79
  # path (C:\...) is not a valid Linux path, so use a fixed POSIX mount point and translate.
80
80
  WORKSPACE_MOUNT = "/workspace" if os.name == "nt" else os.getcwd()
81
- NO_DOCKER = False # when True, call renode/renode-test directly on host
81
+ # Host directory bind-mounted at WORKSPACE_MOUNT. None = the cwd; --workspace-mount
82
+ # sets it to the config's directory, so the mount point is where execution happens.
83
+ WORKSPACE_ROOT = None
84
+ NO_DOCKER = False # when True, call renode/renode-test directly on host
82
85
  EXTRA_MOUNTS = [] # dep dirs outside the workspace, bind-mounted 1:1 into every container
86
+ DOCKER_ARGS = [] # user flags (docker_args: + --docker-args) added to every `docker run`
87
+ # Host directory of the config file being executed. Paths written in the config resolve
88
+ # against it, and scripts / Renode run with it as their working directory. None = cwd
89
+ # (no config loaded, e.g. `run --snapshot` without one).
90
+ CONFIG_DIR = None
83
91
  LIBOPENLIBM_NAME = "libopenlibm-Linux-x86_64.a"
84
92
 
85
93
  DEFAULT_QUANTUM = "0.00001" # 10 us; applied when any hub is present
@@ -119,10 +127,20 @@ def host_uid_gid():
119
127
  return None
120
128
 
121
129
 
130
+ def workspace_root():
131
+ """Host directory mounted at WORKSPACE_MOUNT (see WORKSPACE_ROOT)."""
132
+ return os.path.realpath(WORKSPACE_ROOT or os.getcwd())
133
+
134
+
122
135
  def to_container_path(host_path):
123
136
  abs_path = os.path.realpath(host_path)
124
- root = os.path.realpath(os.getcwd())
137
+ root = workspace_root()
125
138
  if os.path.commonpath([abs_path, root]) != root:
139
+ # Dirs outside the workspace registered in EXTRA_MOUNTS (the config's dir or the
140
+ # cwd, a shared Renode cache) are mounted 1:1, so their paths are valid as-is.
141
+ for m in EXTRA_MOUNTS:
142
+ if os.path.commonpath([abs_path, m]) == m:
143
+ return abs_path
126
144
  raise ValueError(
127
145
  f"Path '{host_path}' resolves outside the project root '{root}'. Everything used "
128
146
  f"at runtime must live under the project folder (mounted at '{WORKSPACE_MOUNT}')."
@@ -130,7 +148,7 @@ def to_container_path(host_path):
130
148
  # The container is always Linux: build a POSIX path under WORKSPACE_MOUNT (this is a
131
149
  # no-op on Linux, where WORKSPACE_MOUNT is the cwd and os.sep is already '/').
132
150
  rel = os.path.relpath(abs_path, root).replace(os.sep, "/")
133
- return posixpath.join(WORKSPACE_MOUNT, rel)
151
+ return WORKSPACE_MOUNT if rel == "." else posixpath.join(WORKSPACE_MOUNT, rel)
134
152
 
135
153
 
136
154
  def register_dependency_mount(host_dir):
@@ -140,13 +158,12 @@ def register_dependency_mount(host_dir):
140
158
  Dirs under the workspace are already covered by the workspace mount, so they map via
141
159
  to_container_path. An out-of-tree dir (e.g. a shared cache) is registered in
142
160
  EXTRA_MOUNTS and used at its own absolute path inside the container."""
143
- abs_dir = os.path.realpath(host_dir)
144
- root = os.path.realpath(os.getcwd())
145
- if os.path.commonpath([abs_dir, root]) == root:
146
- return to_container_path(host_dir)
147
- if abs_dir not in EXTRA_MOUNTS:
161
+ try:
162
+ return to_container_path(host_dir) # already reachable through some mount
163
+ except ValueError:
164
+ abs_dir = os.path.realpath(host_dir)
148
165
  EXTRA_MOUNTS.append(abs_dir)
149
- return abs_dir
166
+ return abs_dir
150
167
 
151
168
 
152
169
  def container_path_for(host_path):
@@ -166,13 +183,34 @@ def container_to_host(container_path):
166
183
  if container_path != WORKSPACE_MOUNT and not container_path.startswith(WORKSPACE_MOUNT + "/"):
167
184
  raise ValueError(f"'{container_path}' is not under {WORKSPACE_MOUNT}.")
168
185
  rel = posixpath.relpath(container_path, WORKSPACE_MOUNT).replace("/", os.sep)
169
- host = os.path.realpath(os.path.join(os.getcwd(), rel))
170
- root = os.path.realpath(os.getcwd())
186
+ root = workspace_root()
187
+ host = os.path.realpath(os.path.join(root, rel))
171
188
  if os.path.commonpath([host, root]) != root:
172
189
  raise ValueError(f"'{container_path}' maps outside the project root.")
173
190
  return host
174
191
 
175
192
 
193
+ def config_dir():
194
+ """Host directory config-relative paths resolve against (see CONFIG_DIR)."""
195
+ return CONFIG_DIR or os.getcwd()
196
+
197
+
198
+ def from_config(path):
199
+ """Resolve a path written in the config against the config file's directory.
200
+ Absolute paths (host or container, e.g. an interpolated ${RENODE_DIR}) and URLs
201
+ are returned unchanged."""
202
+ if not path or os.path.isabs(path) or path.startswith("/") or "://" in path:
203
+ return path
204
+ return os.path.normpath(os.path.join(config_dir(), path))
205
+
206
+
207
+ def execution_dir():
208
+ """Working directory for everything hector executes (build/shell steps, Renode,
209
+ renode-test): the config's directory — as a container path under Docker, a host
210
+ path under --no-docker."""
211
+ return config_dir() if NO_DOCKER else to_container_path(config_dir())
212
+
213
+
176
214
  def split_host_container(path):
177
215
  if path == WORKSPACE_MOUNT or path.startswith(WORKSPACE_MOUNT + "/"):
178
216
  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"{core.workspace_root()}:{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"):
@@ -47,6 +47,9 @@ from .validator import print_issues, validate_config
47
47
  # The config file every command reads/writes. Overridable with -f/--file; set by
48
48
  # apply_global_flags before any config is loaded.
49
49
  CONFIG_PATH = ".hector.yaml"
50
+ # --workspace-mount given: mount the config's directory (where execution happens) at
51
+ # that path, instead of the cwd. Set by apply_global_flags, applied once the config loads.
52
+ MOUNT_CONFIG_DIR = False
50
53
 
51
54
 
52
55
  def apply_global_flags(args):
@@ -60,7 +63,9 @@ def apply_global_flags(args):
60
63
  global CONFIG_PATH
61
64
  CONFIG_PATH = path
62
65
  if getattr(args, "workspace_mount", None):
66
+ global MOUNT_CONFIG_DIR
63
67
  _core.WORKSPACE_MOUNT = args.workspace_mount
68
+ MOUNT_CONFIG_DIR = True
64
69
  if getattr(args, "no_docker", False) or os.environ.get("HECTOR_NO_DOCKER"):
65
70
  _core.NO_DOCKER = True
66
71
  _core.WORKSPACE_MOUNT = os.getcwd()
@@ -104,9 +109,26 @@ def load_config_or_exit():
104
109
  if v is not None and str(v) not in SUPPORTED_SCHEMA_VERSIONS:
105
110
  print(f"[WARN] .hector.yaml 'version: {v}' is not in supported versions "
106
111
  f"({', '.join(sorted(SUPPORTED_SCHEMA_VERSIONS))}). Proceeding anyway.")
112
+ _set_config_dir(config_path)
107
113
  return config
108
114
 
109
115
 
116
+ def _set_config_dir(config_path):
117
+ """Make the config's directory the base for its paths and the working directory of
118
+ everything executed (see core.CONFIG_DIR), and make sure both it and the cwd (where
119
+ .hector/ and --output live) are reachable from the container.
120
+
121
+ By default the cwd is the workspace mount and a config dir outside it is mounted
122
+ 1:1 as well. With --workspace-mount, the config dir is mounted at that path instead
123
+ (so execution happens there) and a cwd outside it is the one mounted 1:1."""
124
+ _core.CONFIG_DIR = os.path.realpath(os.path.dirname(os.path.abspath(config_path)))
125
+ if MOUNT_CONFIG_DIR:
126
+ _core.WORKSPACE_ROOT = _core.CONFIG_DIR
127
+ _core.register_dependency_mount(os.getcwd())
128
+ else:
129
+ _core.register_dependency_mount(_core.CONFIG_DIR)
130
+
131
+
110
132
  _VERSION_TAG_RE = re.compile(r"^v?\d+\.\d+\.\d+$")
111
133
 
112
134
 
@@ -138,6 +160,16 @@ def resolve_renode_image(config, args):
138
160
  return (f"v{normalized}" if normalized else None), image
139
161
 
140
162
 
163
+ def apply_docker_args(config, args):
164
+ """Collect the user's extra `docker run` flags — the config's `docker_args:` (a
165
+ string or a list of strings) followed by --docker-args — into core.DOCKER_ARGS."""
166
+ raw = config.get("docker_args") or []
167
+ flags = []
168
+ for item in ([raw] if isinstance(raw, str) else raw):
169
+ flags += shlex.split(str(item))
170
+ _core.DOCKER_ARGS = flags + shlex.split(getattr(args, "docker_args", "") or "")
171
+
172
+
141
173
  def _resolve_set_overrides(args):
142
174
  overrides = {}
143
175
  for kv in (getattr(args, "set_args", None) or []):
@@ -166,6 +198,7 @@ def build_job_config(action, config, args, *, test_mode=False):
166
198
  """Assemble a JobConfig from parsed args — the shared run / test / export path:
167
199
  pin the image, prepare the framework (lazily), resolve arguments + artifacts."""
168
200
  git_tag, docker_image = resolve_renode_image(config, args)
201
+ apply_docker_args(config, args)
169
202
 
170
203
  # Clone Renode only when the config actually needs the source; otherwise skip it
171
204
  # entirely (a ~200 MB clone) and leave the framework vars empty.
@@ -186,8 +219,14 @@ def build_job_config(action, config, args, *, test_mode=False):
186
219
 
187
220
  resolved_args = resolve_arguments(config.get("arguments", {}),
188
221
  overrides=_resolve_set_overrides(args))
189
- artifacts_cfg = (args.artifacts if getattr(args, "artifacts", None)
190
- else as_lines(config.get("artifacts", [])))
222
+ # --artifacts globs are relative to where hector runs; the config's own 'artifacts:'
223
+ # to the config's directory (like every other path written in the config).
224
+ if getattr(args, "artifacts", None):
225
+ artifacts_cfg, artifacts_base = args.artifacts, os.getcwd()
226
+ else:
227
+ artifacts_cfg = as_lines(config.get("artifacts", []))
228
+ artifacts_base = _core.config_dir()
229
+ test_file = getattr(args, "test_file", None)
191
230
  return JobConfig(
192
231
  config=config,
193
232
  fw_vars=fw_vars,
@@ -196,7 +235,8 @@ def build_job_config(action, config, args, *, test_mode=False):
196
235
  action=action,
197
236
  test_mode=test_mode,
198
237
  debug_args=getattr(args, "debug", None) or [],
199
- test_file=getattr(args, "test_file", None),
238
+ # Absolute so it isn't re-resolved against the config's directory.
239
+ test_file=os.path.abspath(test_file) if test_file else None,
200
240
  reporter_names=_resolve_reporters(getattr(args, "reporters", None)),
201
241
  renode_args=shlex.split(getattr(args, "renode_args", "") or ""),
202
242
  renode_test_args=shlex.split(getattr(args, "renode_test_args", "") or ""),
@@ -208,6 +248,7 @@ def build_job_config(action, config, args, *, test_mode=False):
208
248
  output_dir=getattr(args, "output", "results"),
209
249
  gather_metrics=getattr(args, "gather_execution_metrics", None),
210
250
  artifacts=artifacts_cfg,
251
+ artifacts_base=artifacts_base,
211
252
  )
212
253
 
213
254
 
@@ -241,6 +282,7 @@ def snapshot_fastpath(args):
241
282
  print(f"[ERROR] Snapshot not found: {args.snapshot}", file=sys.stderr)
242
283
  sys.exit(1)
243
284
  _, docker_image = resolve_renode_image({}, args)
285
+ apply_docker_args({}, args)
244
286
  resc_root = os.path.join(ARTIFACTS_DIR, "resc")
245
287
  os.makedirs(resc_root, exist_ok=True)
246
288
  # No .hector.yaml here, so debug machine names come straight from --debug.
@@ -485,6 +527,7 @@ class JobConfig:
485
527
  output_dir: str = "results"
486
528
  gather_metrics: list = None
487
529
  artifacts: list = field(default_factory=list)
530
+ artifacts_base: str = "." # directory the artifact globs resolve against
488
531
 
489
532
 
490
533
  @dataclass
@@ -757,7 +800,8 @@ def run_job(i, total_jobs, job_context, cfg):
757
800
  # Global glob patterns (top-level 'artifacts:' or --artifacts), interpolated
758
801
  # with this job's context so matrix variables resolve.
759
802
  artifact_patterns = as_lines(interpolate(list(cfg.artifacts), context))
760
- collect_artifacts(artifact_patterns, cfg.output_dir, i + 1)
803
+ collect_artifacts(artifact_patterns, cfg.output_dir, i + 1,
804
+ base_dir=cfg.artifacts_base)
761
805
 
762
806
  except Exception as exc:
763
807
  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.7.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,10 +206,11 @@ 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 |
212
- | `hector run --workspace-mount /mnt` | Override the container path the project root is mounted at |
213
+ | `hector run --workspace-mount /mnt` | Mount the hectorfile's folder at this container path and execute there |
213
214
  | `hector run --snapshot path/to/snapshot.save` | Load a snapshot instead of booting from the resc |
214
215
  | `hector run --snapshot snap.save --renode-version 1.16.1` | Load a snapshot without a `.hector.yaml` |
215
216
  | `hector test` | Run the `tests:` section; exit 1 on any failure |
@@ -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. (With [`--workspace-mount`](#overriding-the-workspace-mount-path---workspace-mount) the hectorfile's folder is mounted at the given path instead.) 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.
@@ -545,12 +572,15 @@ An existing directory is reused as-is; a missing one is cloned into. The locatio
545
572
 
546
573
  #### Overriding the workspace mount path (`--workspace-mount`)
547
574
 
548
- By default the project root is bind-mounted into the container at the **same absolute path** as on the host (e.g. `/home/user/projects/myboard`), so paths look identical inside and outside the container. Override this only if the host path is unavailable at that location inside the image:
575
+ By default everything is bind-mounted into the container at the **same absolute path** as on the host (e.g. `/home/user/projects/myboard`), so paths look identical inside and outside the container. `--workspace-mount PATH` instead mounts the **hectorfile's folder** at `PATH`, and that's where every step, Renode and `renode-test` execute:
549
576
 
550
577
  ```bash
551
- hector run --workspace-mount /workspace
578
+ hector run --workspace-mount /workspace # . → /workspace
579
+ hector test -f boards/nucleo/.hector.yaml --workspace-mount /workspace # boards/nucleo → /workspace
552
580
  ```
553
581
 
582
+ When you run from a different folder than the hectorfile's (the second line), that folder — where `.hector/` and `--output` live — is additionally mounted at its own path, unless it's already inside the hectorfile's folder.
583
+
554
584
  **Cannot be combined with `--no-docker`** — if you are running locally there is no container mount to configure.
555
585
 
556
586
  ### Selecting a matrix combination (`--job`)
@@ -625,6 +655,7 @@ The configuration lives in `.hector.yaml`.
625
655
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
626
656
  | `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
657
  | `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). |
658
+ | `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
659
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
629
660
  | `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
661
  | `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,90 @@ 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
+ monkeypatch.setattr(core, "WORKSPACE_ROOT", None)
50
+ monkeypatch.setattr(pipeline, "MOUNT_CONFIG_DIR", False)
51
+ (tmp_path / "board").mkdir()
52
+ (tmp_path / "board" / ".hector.yaml").write_text("renode_version: '1.16.1'\n")
53
+ return pipeline, core, tmp_path
54
+
55
+
56
+ def test_load_config_sets_config_dir(config_in_subdir, monkeypatch):
57
+ pipeline, core, root = config_in_subdir
58
+ monkeypatch.setattr(pipeline, "CONFIG_PATH", "board/.hector.yaml")
59
+ pipeline.load_config_or_exit()
60
+ assert core.CONFIG_DIR == str(root / "board")
61
+ assert core.execution_dir() == str(root / "board")
62
+ assert core.EXTRA_MOUNTS == [] # inside the cwd mount already
63
+
64
+
65
+ def test_load_config_outside_cwd_is_mounted_too(config_in_subdir, monkeypatch):
66
+ # Run from a sibling dir: the cwd stays the workspace mount, and ../board is
67
+ # bind-mounted 1:1 so the config's paths and working dir are reachable.
68
+ pipeline, core, root = config_in_subdir
69
+ (root / "elsewhere").mkdir()
70
+ monkeypatch.setattr(core, "WORKSPACE_MOUNT", str(root / "elsewhere"))
71
+ monkeypatch.chdir(root / "elsewhere")
72
+ monkeypatch.setattr(pipeline, "CONFIG_PATH", "../board/.hector.yaml")
73
+ pipeline.load_config_or_exit()
74
+ board = str((root / "board").resolve())
75
+ assert core.EXTRA_MOUNTS == [board]
76
+ assert core.execution_dir() == board
77
+ assert core.to_container_path(core.from_config("fw.elf")) == f"{board}/fw.elf"
78
+
79
+
80
+ def test_workspace_mount_mounts_config_dir_there(config_in_subdir, monkeypatch):
81
+ # Run from the parent with --workspace-mount /workspace: the config's dir is what's
82
+ # mounted there and where execution happens; the cwd (.hector/, --output) is 1:1.
83
+ pipeline, core, root = config_in_subdir
84
+ monkeypatch.setattr(core, "WORKSPACE_MOUNT", "/workspace")
85
+ monkeypatch.setattr(pipeline, "MOUNT_CONFIG_DIR", True)
86
+ monkeypatch.setattr(pipeline, "CONFIG_PATH", "board/.hector.yaml")
87
+ pipeline.load_config_or_exit()
88
+ board, cwd = str((root / "board").resolve()), str(root.resolve())
89
+ assert core.workspace_root() == board
90
+ assert core.execution_dir() == "/workspace"
91
+ assert core.to_container_path(core.from_config("fw/app.elf")) == "/workspace/fw/app.elf"
92
+ assert core.EXTRA_MOUNTS == [cwd]
93
+ assert core.to_container_path(".hector/resc/job_1.resc") == f"{cwd}/.hector/resc/job_1.resc"
94
+
95
+
96
+ def test_workspace_mount_from_config_dir_needs_no_extra_mount(config_in_subdir, monkeypatch):
97
+ pipeline, core, root = config_in_subdir
98
+ monkeypatch.chdir(root / "board")
99
+ monkeypatch.setattr(core, "WORKSPACE_MOUNT", "/workspace")
100
+ monkeypatch.setattr(pipeline, "MOUNT_CONFIG_DIR", True)
101
+ monkeypatch.setattr(pipeline, "CONFIG_PATH", ".hector.yaml")
102
+ pipeline.load_config_or_exit()
103
+ assert core.execution_dir() == "/workspace"
104
+ assert core.EXTRA_MOUNTS == []
105
+
106
+
107
+ # ---- apply_docker_args ----
108
+
109
+ @pytest.mark.parametrize("cfg,flag,expected", [
110
+ ({}, "", []),
111
+ ({"docker_args": "-v /a:/a -e X=1"}, "", ["-v", "/a:/a", "-e", "X=1"]),
112
+ ({"docker_args": ["-v /a:/a", "--env 'Y=two words'"]}, "",
113
+ ["-v", "/a:/a", "--env", "Y=two words"]),
114
+ ({"docker_args": "-v /a:/a"}, "-v /b:/b", ["-v", "/a:/a", "-v", "/b:/b"]), # flag appends
115
+ ])
116
+ def test_apply_docker_args(monkeypatch, cfg, flag, expected):
117
+ from hector import core, pipeline
118
+ monkeypatch.setattr(core, "DOCKER_ARGS", [])
119
+ pipeline.apply_docker_args(cfg, SimpleNamespace(docker_args=flag))
120
+ assert core.DOCKER_ARGS == expected
121
+
122
+
39
123
  # ---- resolve_renode_image ----
40
124
 
41
125
  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