cs-image-system-bash-mod-plugin 0.1.6__tar.gz → 0.1.8__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.
@@ -13,16 +13,21 @@ build/
13
13
  .venv/
14
14
  .DS_Store
15
15
  packages/**/*.code-workspace
16
- # the IDE rewrites the root workspace file whenever folders are opened: it is the editor's, not the repository's (hygiene XXI)
16
+ # the IDE rewrites the root workspace file whenever folders are opened: it is the editor's, not the repository's
17
17
  /*.code-workspace
18
18
  trial
19
19
  tfmodules/tftest
20
20
  YAML_DUMP.yaml
21
+ # a configuration tree's run-local plaintexts and debug output, wherever a tree
22
+ # sits in this repository (a copy of the frozen fixture included)
23
+ _private/
21
24
  .obsidian
22
25
  .tofu-plugin-cache/
23
26
  _uncommitted/
24
27
  target/
25
- # never publishable, whatever the root (stage 35 refuses them; stage 40's publish-tree checks the policy)
28
+ # never publishable, whatever the root (the public-safe hook refuses them; `just publish-tree` checks the policy)
26
29
  tfplan
27
30
  *.tfstate
28
31
  *.tfstate.backup
32
+ REEXAMINE*
33
+ TODO2.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cs-image-system-bash-mod-plugin
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: The shell modification plugin: inline script, script files and an ensure form become Packer shell provisioners
5
5
  Project-URL: Repository, https://github.com/infrastructurebuilder/cs-image-system-3
6
6
  Project-URL: Documentation, https://github.com/infrastructurebuilder/cs-image-system-3/blob/develop/docs/OPERATIONS.md
@@ -9,8 +9,8 @@ Author-email: Mykel Alvis <mykelalvis@infrastructurebuilder.org>
9
9
  License-Expression: Apache-2.0
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.13
12
- Requires-Dist: cs-image-system-base==0.1.6
13
- Requires-Dist: cs-image-system-contract==0.1.6
12
+ Requires-Dist: cs-image-system-base==0.1.8
13
+ Requires-Dist: cs-image-system-contract<0.5,>=0.4
14
14
  Requires-Dist: pydantic>=2.13
15
15
  Description-Content-Type: text/markdown
16
16
 
@@ -55,18 +55,15 @@ How a YAML entry selects these classes:
55
55
  looks the builder up by name or alias, reads the builder's own `type`
56
56
  (`bash-remote`) and loads the item into the class registered under that
57
57
  service key for `MOD_BUILDER_ITEM_MODEL`: `BashModItemModel`. The
58
- registry alone decides; the builder model's
59
- `get_target_deferred_type_by_VCT` method, which also named that class
60
- and had no caller, is gone (stage 63, 2026-09-25), as is the plugin's
61
- empty `helpers.py`.
62
- - **The item's `type` is rewritten to the builder's `name` at load**
63
- (stage 63 item 15). The image builder later fetches the builder with
58
+ registry alone decides.
59
+ - **The item's `type` is rewritten to the builder's `name` at load.**
60
+ The image builder later fetches the builder with
64
61
  `ctx.mod_builders.get(mod.get_type())`, a map keyed by builder NAME
65
- only, so an alias must not survive the load. An item declared
66
- `type: bash` (the fixture builder's alias) is loaded as
67
- `type: bash-remote`. Before 2026-09-25 the alias was kept, and
68
- generation stopped with
62
+ only, so an alias must not survive the load: a kept alias would stop
63
+ generation with
69
64
  `AssertionError: No mod builder bash found in context for <image>`.
65
+ An item declared `type: bash` (the fixture builder's alias) is loaded
66
+ as `type: bash-remote`.
70
67
  - An `executables:` entry named `bash` is version-checked by
71
68
  `BashVersionChecker` (regex `.*version\s+([\d\.]+)` over
72
69
  `bash --version`).
@@ -80,21 +77,31 @@ Defined in [bash_models.py](src/cs_image_system/bash_mod_plugin/bash_models.py).
80
77
  [`ModBuilderModel`](../base/src/cs_image_system/base/models/mod_builder.py),
81
78
  which adds nothing to
82
79
  [`BuilderModel`](../base/src/cs_image_system/base/models/builder_model.py)
83
- (`name`, `type`, `description`, `aliases`, `executable`, `is_default`,
84
- `config`, `gitignore`, `tags`). `BashModBuilderModel` is the registered
85
- subclass; it adds no fields.
86
-
87
- | Field | Type | Default | Meaning |
88
- |---|---|---|---|
89
- | `execute_command` | `str \| None` | `None` | Emitted as `execute_command = "..."` on every shell provisioner of every item (Packer's command template, for example `sudo -E bash '{{.Path}}'`). The way to change how the script is invoked. May not be declared together with `configuration_user`. |
90
- | `configuration_user` | `str \| None` | `None` | When set, every shell provisioner of this builder gets `execute_command = "chmod +x {{ .Path }}; sudo su - <user> -c '{{ .Vars }} {{ .Path }}'"`, so each item's lines run as that user; the bake user only launches them (stage 63; declared and not read until 2026-09-25). Declaring it together with `execute_command` is refused at load. |
91
- | `environment_vars` | `list[str]` | `[]` | Emitted as `environment_vars = [...]` on every shell provisioner. |
92
- | `expect_disconnect` | `bool` | `False` | Emits `expect_disconnect = true` on every shell provisioner. |
93
-
94
- `extra_arguments` is not a field (stage 63): it was declared and never
95
- read, so it is removed, and a builder that still declares it fails to load
96
- with the unknown-key refusal. Use `execute_command` to change the
97
- invocation.
80
+ (the common builder fields: `name`, `type`, `description`, `aliases`,
81
+ `executable`, `is_default`, `config`, `gitignore`, `tags`;
82
+ [CONFIGURATION section 4.1](../../docs/CONFIGURATION.md#41-fields-common-to-every-builder)
83
+ holds that table). `BashModBuilderModel` is the registered subclass; it
84
+ adds no fields.
85
+
86
+ The keys this plugin adds are `execute_command`, `configuration_user`,
87
+ `environment_vars` and `expect_disconnect`: each is a Packer
88
+ shell-provisioner argument, emitted on every shell provisioner of every
89
+ item of the builder.
90
+ [CONFIGURATION section 7.2](../../docs/CONFIGURATION.md#72-bash-remote)
91
+ holds the field table; [Configuration reference](#configuration-reference)
92
+ below gives this plugin's reading of each key. Rules only this plugin
93
+ enforces:
94
+
95
+ - `execute_command` (Packer's command template, for example
96
+ `sudo -E bash '{{.Path}}'`) is the way to change how the script is
97
+ invoked. `configuration_user` writes its own: every shell provisioner
98
+ of the builder gets `execute_command = "chmod +x {{ .Path }}; sudo su -
99
+ <user> -c '{{ .Vars }} {{ .Path }}'"`, so each item's lines run as that
100
+ user and the bake user only launches them. Because both say how the
101
+ script runs, declaring them together is refused at load.
102
+ - `extra_arguments` is not a field: a builder that declares it fails to
103
+ load with the unknown-key refusal. Use `execute_command` to change the
104
+ invocation.
98
105
 
99
106
  Base fields this plugin constrains: `executable` should name the `bash`
100
107
  executable (the fixture does), though the emission does not invoke it;
@@ -105,13 +112,28 @@ base-model rule). `config:` on the builder is carried but never read.
105
112
 
106
113
  Same file. Extends
107
114
  [`ModItemModel`](../base/src/cs_image_system/base/models/moditem_type.py)
108
- (`SubRootItem` -> `RootItem` -> `NameTyped`).
109
-
110
- | Field | Type | Default | Meaning |
111
- |---|---|---|---|
112
- | `script` | `list[str]` | `[]` | Inline command lines, run in order in one `provisioner "shell"` (`inline = [...]`). Literal shell; no templating. Blank entries are dropped. |
113
- | `scripts` | `list[str]` | `[]` | Script files, relative to the configuration root (the process's working directory, which the configuration load sets to the root), copied beside the Packer root and run as `scripts = [...]`. Blank entries are dropped. |
114
- | `ensure` | `dict[str, Any]` | `{}` | The declarative form. Allowed keys: `packages` (list of names), `files` (list of `{path, content, mode}`; `mode` defaults to `0644`), `services` (list of unit names, enabled and started), `commands` (list of `{run, unless}`; `run` executes only when `unless` fails). Every entry is validated at load by `validate_ensure` (stage 63 item 16): a wrong shape, an unknown key, a bad mode or an all-empty mapping raises `ValueError` naming the item and the entry. |
115
+ (`SubRootItem` -> `RootItem` -> `NameTyped`). The item's own keys are
116
+ `script`, `scripts` and `ensure`; the inherited ones (`name`, `type`,
117
+ `description`, `aliases`, `tags`, `config`) are the modification item's,
118
+ tabled in
119
+ [CONFIGURATION section 7.3](../../docs/CONFIGURATION.md#73-the-modification-item-inside-an-images-modifications),
120
+ and [Configuration reference](#configuration-reference) below reads every
121
+ key as this plugin does.
122
+
123
+ - `script` is the list of inline command lines, run in order in one
124
+ `provisioner "shell"` (`inline = [...]`). Literal shell; no templating.
125
+ Blank entries are dropped.
126
+ - `scripts` is the list of script files, relative to the configuration
127
+ root (the process's working directory, which the configuration load
128
+ sets to the root), copied beside the Packer root and run as
129
+ `scripts = [...]`. Blank entries are dropped.
130
+ - `ensure` is the declarative form. Allowed keys: `packages` (list of
131
+ names), `files` (list of `{path, content, mode}`; `mode` defaults to
132
+ `0644`), `services` (list of unit names, enabled and started),
133
+ `commands` (list of `{run, unless}`; `run` executes only when `unless`
134
+ fails). Every entry is validated at load by `validate_ensure`: a wrong
135
+ shape, an unknown key, a bad mode or an all-empty mapping raises
136
+ `ValueError` naming the item and the entry.
115
137
 
116
138
  Validation at load: at least one of `script`, `scripts`, `ensure` must be
117
139
  non-empty, otherwise `ValueError` ("must supply 'script' (inline lines),
@@ -134,15 +156,13 @@ Derived behaviour:
134
156
  - `remap_self_with_copied_assets(copied)`: replaces each `scripts` entry
135
157
  with its copied path when present in the mapping.
136
158
 
137
- Inherited item fields:
138
-
139
- | Base field | From | Default | Use in this plugin |
140
- |---|---|---|---|
141
- | `name` | `NameTyped` | required | Names the provisioner comment and the local bundle directory (`NN-<name>`). |
142
- | `type` | `ModItemModel` | `default` | Foreign key to a `mod_builders:` entry (see above). |
143
- | `description`, `aliases`, `tags` | `NameTyped` / `RootItem` | | Carried; not emitted. |
144
- | `config` | `RootItem` | `{}` | Not read by this plugin. It takes part in the lineage content hash of the modification and nothing else. |
145
- | `_model_id` | `ModItemModel` | set at load | The owning image's global id; `identified_model` resolves to the image. |
159
+ Of the inherited fields, `name` names the provisioner comment and the
160
+ local bundle directory (`NN-<name>`); `type` is the foreign key to a
161
+ `mod_builders:` entry (see above); `description`, `aliases` and `tags`
162
+ are carried and not emitted; `config` is not read by this plugin and
163
+ takes part in the lineage content hash of the modification and nothing
164
+ else; `_model_id`, set at load, is the owning image's global id, and
165
+ `identified_model` resolves to the image.
146
166
 
147
167
  ### A modification item with only `config:`
148
168
 
@@ -171,10 +191,11 @@ calls, in order:
171
191
  `scripts` to `<block_dir>/<script path>` and returns
172
192
  `{script: script path}`. The source is resolved against the process's
173
193
  current working directory; the configuration load has already changed
174
- directory to the configuration root (`--root-dir`, else
175
- `working_directory` in `cfg/_config.yml`, else `.`), so in practice the
176
- path is root-relative. A missing file raises `FileNotFoundError`; an
177
- absolute path is not copied and is left in place.
194
+ directory to the configuration root (`--root-dir`, else the directory
195
+ the command was started from; `working_directory` in `cfg/_config.yml`
196
+ is not read), so the path is root-relative. A missing file raises
197
+ `FileNotFoundError`; an absolute path is not copied and is left in
198
+ place.
178
199
  2. `mod.remap_self_with_copied_assets(mapping)` on the item. Because the
179
200
  mapping's value is the same relative path the copy was placed at, the
180
201
  item's `scripts` entries keep their text; the emitted `scripts = [...]`
@@ -183,8 +204,8 @@ calls, in order:
183
204
  4. `generate_items_during_modification(image, mod, image_builder, phase, build_path)`:
184
205
  - `inline` = `ensure_lines()` followed by the `script` lines; `scripts`
185
206
  = the script files. When both lists are empty it logs a warning and
186
- emits nothing; since stage 63 item 16 an all-empty `ensure` is
187
- refused at load, so an item can no longer reach this.
207
+ emits nothing; an all-empty `ensure` is refused at load, so an item
208
+ cannot reach this.
188
209
  - Appends `# Modifications for <item name> of type <item type> (shell)`.
189
210
  - Then, because Packer's shell provisioner takes either `scripts` or
190
211
  `inline` but never both, one or two `provisioner "shell"` blocks:
@@ -197,7 +218,7 @@ calls, in order:
197
218
  with a `configuration_user`, `chmod +x {{ .Path }}; sudo su - <user>
198
219
  -c '{{ .Vars }} {{ .Path }}'` (Packer's `{{ .Vars }}`, the
199
220
  environment variables, travel inside the command because `su -`
200
- starts a fresh login environment), else none (stage 63).
221
+ starts a fresh login environment), else none.
201
222
  - Every string is rendered as an HCL literal: backslashes and double
202
223
  quotes are escaped, and `${` / `%{` become `$${` / `%%{` so shell text
203
224
  such as `dpkg-query -f='${Package}'` survives Packer's template parser.
@@ -212,9 +233,7 @@ modification bundle records the item: its directory
212
233
  `run.sh` that runs `sh '<script>'` per script file and then
213
234
  `sh ./inline.sh`, and a `MANIFEST.yaml` with `operation: bash` and the
214
235
  item's `idempotent` value. `csis-mods rerun` on the image therefore
215
- re-applies the declarative `ensure` form too (stage 63). Until 2026-09-25
216
- the `ensure` lines were not in the bundle and a rerun replayed only the
217
- script files and the `script` lines.
236
+ re-applies the declarative `ensure` form too.
218
237
 
219
238
  ## Emission
220
239
 
@@ -316,8 +335,8 @@ entry in `cfg/executables.yml`
316
335
  ([the fixture's](../../tests/fixtures/config/cfg/executables.yml) is
317
336
  `name: bash`, `binary: /usr/local/bin/bash`, no `version`), because
318
337
  `validate` checks every builder's `executable` against that list and every
319
- run checks every declared entry (stage 48: the binary must exist as an
320
- absolute path or a name on `PATH`, `BashVersionChecker` must parse a version
338
+ run checks every declared entry (the binary must exist as an absolute
339
+ path or a name on `PATH`, `BashVersionChecker` must parse a version
321
340
  from `<binary> --version`, and when the entry declares `version:` the parsed
322
341
  version must satisfy it). That is the whole extent of the plugin's use of a
323
342
  local bash: it is looked at, never run. The fixture declares no floor;
@@ -354,8 +373,8 @@ in `ensure` lines works there too.
354
373
  **How the plugin finds things.** The builder by the item's `type:` (the
355
374
  builder's name); the item class by the builder's `type: bash-remote`; the
356
375
  `bash` entry by the builder's `executable:`; script files by path relative
357
- to the configuration root (the working directory the load selects from
358
- `--root-dir`, else `cfg/_config.yml`'s `working_directory`, else `.`); the
376
+ to the configuration root (the working directory the load changes into:
377
+ `--root-dir`, else the directory the command was started from); the
359
378
  build VM through the image builder that hosts the emission. No environment
360
379
  variable, profile or file of its own.
361
380
 
@@ -365,7 +384,7 @@ variable, profile or file of its own.
365
384
 
366
385
  Every key the model accepts. An unknown key is refused at load
367
386
  (`extra="forbid"` in the shared model config), and `parameters:` is refused
368
- by name (retired in stage 26).
387
+ by name.
369
388
 
370
389
  | Field | Type | Default | Meaning |
371
390
  |---|---|---|---|
@@ -379,24 +398,24 @@ by name (retired in stage 26).
379
398
  | `gitignore` | list[str] | `[]` | Accepted by the base model; nothing in this plugin reads it. |
380
399
  | `tags` | mapping | `{}` | Accepted, not read. |
381
400
  | `execute_command` | str or null | `null` | Emitted verbatim (HCL-escaped) as `execute_command` on every shell block of every item of this builder. Refused together with `configuration_user`. |
382
- | `configuration_user` | str or null | `null` | Read since stage 63 (accepted and ignored until 2026-09-25): every shell block of this builder runs its lines as this user through `execute_command = "chmod +x {{ .Path }}; sudo su - <user> -c '{{ .Vars }} {{ .Path }}'"`. Refused together with `execute_command` (`... both say how the script runs; declare one`). |
401
+ | `configuration_user` | str or null | `null` | Every shell block of this builder runs its lines as this user through `execute_command = "chmod +x {{ .Path }}; sudo su - <user> -c '{{ .Vars }} {{ .Path }}'"`. Refused together with `execute_command` (`... both say how the script runs; declare one`). |
383
402
  | `environment_vars` | list[str] | `[]` | Emitted as `environment_vars = [...]` on every shell block. |
384
403
  | `expect_disconnect` | bool | `false` | When true, `expect_disconnect = true` on every shell block. |
385
- | `extra_arguments` | | | Refused at load as an unknown key (removed in stage 63; it was accepted and never read). Use `execute_command`. |
404
+ | `extra_arguments` | | | Not a field; refused at load as an unknown key. Use `execute_command`. |
386
405
 
387
406
  ### The item (an image's `modifications:` entry whose `type:` names this builder)
388
407
 
389
408
  | Field | Type | Default | Meaning |
390
409
  |---|---|---|---|
391
410
  | `name` | str | required | Unique within the image. Names the comment line, the bundle directory `NN-<name>` and the lineage record. |
392
- | `type` | str | `default` | The builder's name, an alias, or `default` (or omitted). Rewritten to the builder's name at load (stage 63 item 15). Omitted or `default` with no `is_default: true` mod builder: refused at load (stage 63). |
411
+ | `type` | str | `default` | The builder's name, an alias, or `default` (or omitted). Rewritten to the builder's name at load. Omitted or `default` with no `is_default: true` mod builder: refused at load. |
393
412
  | `description` | str or null | `null` | Accepted, not read. |
394
413
  | `aliases` | list[str] | `[]` | Accepted, not read. |
395
414
  | `tags` | mapping | `{}` | Accepted, not read. |
396
415
  | `config` | mapping | `{}` | Not read by the plugin. Part of the modification's content hash, so a change re-bakes the image. Not passed to the shell. |
397
416
  | `script` | list[str] | `[]` | Inline lines, literal, in order, after the `ensure` lines. Blank and whitespace-only entries are dropped. |
398
417
  | `scripts` | list[str] | `[]` | Script files; each is copied beside the Packer root and run first, in order. Blank entries are dropped. An absolute path is not copied and is emitted as is. |
399
- | `ensure` | mapping | `{}` | Only the four keys below are allowed; any other key is refused at load. Every entry's values are validated at load since stage 63 item 16 (`validate_ensure`; see "What it tests and verifies"). |
418
+ | `ensure` | mapping | `{}` | Only the four keys below are allowed; any other key is refused at load. Every entry's values are validated at load (`validate_ensure`; see "What it tests and verifies"). |
400
419
 
401
420
  The `ensure` keys:
402
421
 
@@ -417,11 +436,11 @@ The `ensure` keys:
417
436
  `idempotent: unknown`, the same as free-form alone. `ensure` lines always
418
437
  precede the `script` lines in the inline block.
419
438
  - **`ensure` whose lists are all empty** (`ensure: {packages: []}`) is
420
- refused at load (stage 63 item 16). Before 2026-09-25 it passed load,
421
- counted as `declared`, and emitted nothing: the builder logged
422
- `Bash modification <name> has no script lines or files; nothing emitted`
423
- and the bake proceeded without a provisioner for it. The bundle directory
424
- still exists, with a `run.sh` that runs nothing.
439
+ refused at load, because such an item would count as `declared` and
440
+ emit nothing: the builder would log
441
+ `Bash modification <name> has no script lines or files; nothing emitted`,
442
+ the bake would proceed without a provisioner for it, and the bundle
443
+ directory would hold a `run.sh` that runs nothing.
425
444
  - **`commands` with `unless`** is guarded; **without `unless`** the command
426
445
  runs on every bake and on every `csis-mods rerun`, so the item is
427
446
  idempotent only if the command is.
@@ -431,7 +450,7 @@ The `ensure` keys:
431
450
  - **`execute_command` set** puts that template on every block of every
432
451
  item of the builder; **`configuration_user` set** instead puts
433
452
  `chmod +x {{ .Path }}; sudo su - <user> -c '{{ .Vars }} {{ .Path }}'`
434
- there, so the lines run as that user (stage 63); **both set** is refused
453
+ there, so the lines run as that user; **both set** is refused
435
454
  at load; **neither**, Packer's default applies and the lines run as the
436
455
  bake user. The same for
437
456
  `environment_vars` (emitted only when non-empty) and `expect_disconnect`
@@ -440,11 +459,10 @@ The `ensure` keys:
440
459
  `type`** reaches whichever mod builder is `is_default: true`; in the
441
460
  fixture that is `ansible-default`, so the item is loaded as an ANSIBLE
442
461
  item and its `script`/`scripts`/`ensure` keys are refused as unknown.
443
- With no `is_default: true` mod builder at all, the load is refused
444
- (stage 63): `No default builder found in mod_builder list.` first, and
445
- the orchestrator's `` modification '<name>' names no builder (`type`) ... ``
446
- for an item that reaches it anyway. Until 2026-09-25 such an item
447
- became an `AttributeError` at generation.
462
+ With no `is_default: true` mod builder at all, the load is refused:
463
+ `No default builder found in mod_builder list.` first, and the
464
+ orchestrator's `` modification '<name>' names no builder (`type`) ... ``
465
+ for an item that reaches it anyway.
448
466
  **`type: <alias>`** behaves exactly as the builder's name: the loader
449
467
  rewrites it.
450
468
  - **AWS versus GCE runtime**: the lines are identical; only the `only`
@@ -479,7 +497,7 @@ integer mode no higher than `0777`, and not every kind empty); an item
479
497
  with none of `script`, `scripts`, `ensure`; an item `type:` that names no
480
498
  builder; an item with no `type:` when no mod builder is the default; a
481
499
  builder declaring both `configuration_user` and `execute_command`, or
482
- the removed `extra_arguments` (stage 63). Each is a
500
+ `extra_arguments`. Each is a
483
501
  `ValueError` (or `KeyError` for the type) that stops the load; the CLI
484
502
  prints it and exits 1. Nothing loads, so nothing is generated.
485
503
 
@@ -532,81 +550,41 @@ failure prints `FAIL <image>/<mod>: rc=...` on stderr and exits 1. A
532
550
 
533
551
  ## When it fails
534
552
 
535
- Failures that have happened, in date order:
536
-
537
- - **2026-08-27, three defects found in the pre-V2 builder**
538
- ([LEDGER](../../docs/history/LEDGER.md), "Finding 2026-08-27"). Every
539
- item resolved to the default mod builder (the lookup used the field name
540
- `modifications`, not the item's `type`), so a bash item was recorded as
541
- `ansible-default` with no content; the bash builder wrote raw lines and an
542
- `exit 0` straight into the build file, so any real bash item failed
543
- `packer validate`; and `scripts:` was not a field, so files were dropped.
544
- Fixed at gate 8; guarded by
545
- [test_v2_gate8_modifications.py](../../tests/test_v2_gate8_modifications.py)
546
- and [tests/test_bash_provisioner.py](tests/test_bash_provisioner.py). If
547
- a comment line ever appears outside a `provisioner "shell" {` block, or
548
- `exit 0` appears in a build file, this regressed.
549
- - **2026-09-10, the first `full-test`'s mod-test leg could not start a
550
- container** for an EL 10 image: Docker Hub's `rockylinux` library stops at
551
- 9. The stand-in for `rhel` 10 and above is `almalinux:<version>` since
552
- (`test_image_for` in [mod_tests.py](../base/src/cs_image_system/base/mod_tests.py)).
553
- Symptom then: `docker run` failed to pull and the bash item's test never
554
- ran. If `test-mods` reports `unsupported` with
555
- `no local test image for the root OS family`, the family/version has no
556
- stand-in; set `local_test_image` on the OS builder.
557
- - **Found live, undated, in the sibling shell emitter for image tests**
558
- ([v2_provisioners.py](../packer-plugin/src/cs_image_system/packer_plugin/v2_provisioners.py)):
559
- an `rpm --qf '%{NAME}'` line broke `packer fmt`, because `%{` and `${`
560
- open Packer template directives. This plugin's `_hcl_string` escapes both
561
- the same way. If `packer fmt` or `validate` ever reports
562
- `Invalid template directive` or `Invalid template interpolation` on a
563
- line you wrote, the escape regressed; the line in `generated/` should
564
- read `%%{` / `$${`.
565
- - **2026-09-20, an assertion that could not fail** (image `tests:`, not this
566
- plugin, but the same shell and the same trap;
567
- [OPERATIONS.md](../../docs/OPERATIONS.md), "What a green bake proves").
568
- Under `sh -e`, a command inside an AND-OR list other than the last does
569
- not abort the script, so a `script:` line written `check || { echo no; }`
570
- never fails the build however false `check` is. Two bakes went green
571
- asserting a package that did not exist. If a `script` line must be able
572
- to stop the bake, end it in something that exits nonzero (`|| exit 1`),
573
- not in a braced group that returns 0. The plugin's own `commands` form,
574
- `( unless ) || { run; }`, is deliberate: the group is the LAST element,
575
- so a failing `run` does abort.
576
- - **2026-09-23, an item declared with the builder's alias failed at
577
- generation** (found while writing this README, on a copy of the fixture
578
- with `type: bash` on `derivative-setup`). Load and `validate` passed; the
579
- run log then showed
580
- `AssertionError: No mod builder bash found in context for imgfile-basic-dask`
581
- and the summary was `ok: false` with no validation errors. Fixed by
582
- stage 63 item 15 (2026-09-25): the loader rewrites the alias to the
583
- builder's name, and `tests/test_v2_defects_loading.py` generates that
584
- same fixture copy.
585
-
586
- Failures the code raises that have not been seen outside tests:
553
+ Incidents that have happened, with their cause and fix, are recorded on
554
+ [INCIDENTS.md](../../docs/INCIDENTS.md); what follows keeps each
555
+ failure's symptom, its meaning and what to do.
556
+
557
+ Symptoms of the emission and of the bake, with what each means and what
558
+ to do:
587
559
 
588
560
  | What you see | Where | Meaning and what to do |
589
561
  |---|---|---|
562
+ | A `# Modifications for ...` comment line outside a `provisioner "shell" {` block, or `exit 0` in a build file | `generated/`; `packer validate` fails | The builder wrote raw lines into the build file instead of provisioner blocks. [tests/test_bash_provisioner.py](tests/test_bash_provisioner.py) and [test_modifications.py](../../tests/test_modifications.py) pin the block form; this is a regression of the plugin, not of the item. |
563
+ | A bash item recorded in lineage as `ansible-default` with no content, or its script files dropped | `meta-state/lineage.yaml` after a bake | The item was resolved to the default mod builder instead of the builder its `type:` names. The same two tests pin the lookup by the item's `type`; check the item's `type:` names this builder. |
564
+ | `test-mods` reports `unsupported` with `no local test image for the root OS family` | `test-mods` | The OS family and version have no stand-in container image (`test_image_for` in [mod_tests.py](../base/src/cs_image_system/base/mod_tests.py)): Docker Hub's `rockylinux` library stops at 9, so `rhel` 10 and above stand in as `almalinux:<version>`, and a family outside that table has none. Set `local_test_image` on the OS builder. |
565
+ | `Invalid template directive` or `Invalid template interpolation` from `packer fmt` or `validate` on a line you wrote | the Packer plugin's run-now commands | `%{` and `${` open Packer template directives; this plugin's `_hcl_string` escapes both as `%%{` / `$${` (the sibling shell emitter for image tests, [v2_provisioners.py](../packer-plugin/src/cs_image_system/packer_plugin/v2_provisioners.py), does the same). If the line in `generated/` does not read so, the escape regressed. |
566
+ | A `script:` line written `check || { echo no; }` never fails the build however false `check` is | the bake, green | Under `sh -e` a command inside an AND-OR list other than the last does not abort the script, so a bake can go green asserting a package that does not exist (the same trap holds for an image's `tests:`; [OPERATIONS.md](../../docs/OPERATIONS.md), "What a green bake proves"). If a `script` line must be able to stop the bake, end it in something that exits nonzero (`\|\| exit 1`), not in a braced group that returns 0. The plugin's own `commands` form, `( unless ) \|\| { run; }`, is deliberate: the group is the LAST element, so a failing `run` does abort. |
567
+ | `AssertionError: No mod builder bash found in context for <image>` in the run log, summary `ok: false` with no validation errors, after load and `validate` passed | generation | An item's `type:` reached generation as the builder's alias; the loader rewrites an alias to the builder's name at load (`tests/test_modifications.py` generates a fixture copy with `type: bash`), so a tree whose loader does not is on an older form ([the stage index](../../docs/CHANGES.md#s63)). Write the builder's name, or update. |
590
568
  | `Bash modification '<name>' must supply 'script' (inline lines), 'scripts' (script files) and/or 'ensure' (declarative, idempotent)` | load; CLI exit 1 | The item has none of the three (a `config:`-only item). Add one or remove the item. |
591
569
  | `Bash modification '<name>': unknown ensure keys ['users']` | load; CLI exit 1 | Only `packages`, `files`, `services`, `commands` exist. Move anything else to `commands`. |
592
570
  | A pydantic error naming a key (`Extra inputs are not permitted`) on the builder or the item | load; CLI exit 1 | A misspelt or foreign key (`playbooks:` on a bash item, `script:` on an ansible item). Check the item's `type:` reaches the builder you meant; `type: default` reaches the default builder, which in the fixture is ansible. |
593
- | ``<builder>: `parameters` was retired (stage 26) ...`` | load; CLI exit 1 | Delete `parameters:` from the builder. |
571
+ | ``<builder>: `parameters` was retired ...`` | load; CLI exit 1 | Delete `parameters:` from the builder. |
594
572
  | `KeyError: Could not resolve builder '<type>' referenced in Image(...)` | load; CLI exit 1 | The item's `type:` names no `mod_builders:` entry. |
595
- | `` mod builder '<name>': `configuration_user` and `execute_command` both say how the script runs; declare one `` | load; CLI exit 1 | Stage 63. `configuration_user` writes its own `execute_command`; keep one. To run as another user with a custom command, put the `sudo su - <user>` in your `execute_command`. |
596
- | A pydantic unknown-key error naming `extra_arguments` on a `bash-remote` builder | load; CLI exit 1 | Stage 63 removed the field (it was never read). Delete it; change the invocation with `execute_command`. |
597
- | `No default builder found in mod_builder list.` | load; CLI exit 1 | No mod builder says `is_default: true`. Mark one, or give every item a `type:`. The orchestrator's own form, for an item that reaches it, is `` modification '<name>' names no builder (`type`) and no mod builder is `is_default: true`; name one with `type:` or mark a default `` (stage 63; it used to be an `AttributeError` at generation). |
573
+ | `` mod builder '<name>': `configuration_user` and `execute_command` both say how the script runs; declare one `` | load; CLI exit 1 | `configuration_user` writes its own `execute_command`; keep one. To run as another user with a custom command, put the `sudo su - <user>` in your `execute_command`. |
574
+ | A pydantic unknown-key error naming `extra_arguments` on a `bash-remote` builder | load; CLI exit 1 | The field does not exist. Delete it; change the invocation with `execute_command`. |
575
+ | `No default builder found in mod_builder list.` | load; CLI exit 1 | No mod builder says `is_default: true`. Mark one, or give every item a `type:`. The orchestrator's own form, for an item that reaches it, is `` modification '<name>' names no builder (`type`) and no mod builder is `is_default: true`; name one with `type:` or mark a default ``. |
598
576
  | `Executable bash specified for provider bash-remote not found in executables list.` | `validate`; exit 1 | Add a `bash` entry to `cfg/executables.yml` or change the builder's `executable:`. |
599
577
  | `bash: binary '/usr/local/bin/bash' not found (declared in cfg/executables.yml; an absolute path, or a name on PATH)` | `validate` and every run; exit 1 | The pinned path is wrong on this machine. Symlink the binary there or fix `binary:`. |
600
578
  | ``bash: BashVersionChecker could not parse a version from `/usr/local/bin/bash --version` `` | `validate` and every run | The first line of `--version` has no `version N.N`. Something other than bash is at that path. |
601
579
  | `bash 5.3.15 does not meet its requirement <spec> (cfg/executables.yml)` | `validate` and every run | The floor moved or the machine is behind. |
602
580
  | `FileNotFoundError: Bash modification script mod_image.sh does not exist or is not a file` | generation; run `ok: false`, traceback in the log | The path is resolved from the configuration root, not from the image file's directory or the shell's. Fix the path or `--root-dir`. |
603
581
  | `Bash modification '<name>': ensure declares [...] but every one is empty, so it would emit nothing` | load; CLI exit 1 | Every `ensure` list is empty. Fill one or drop `ensure`. |
604
- | `Bash modification '<name>': ensure.files[N]: `path` is required` (or `ensure.commands[N]: `run` is required`) | load; CLI exit 1 | A `files` entry without `path`, or a `commands` entry without `run`. Before stage 63 this was a `KeyError` at generation. |
605
- | `Bash modification '<name>': ensure.packages must be a list of package names, not str 'git'` | load; CLI exit 1 | `packages:` (or `services:`) given as a string; the old code iterated its letters. Write a list (`packages: [git]`). |
582
+ | `Bash modification '<name>': ensure.files[N]: `path` is required` (or `ensure.commands[N]: `run` is required`) | load; CLI exit 1 | A `files` entry without `path`, or a `commands` entry without `run`. |
583
+ | `Bash modification '<name>': ensure.packages must be a list of package names, not str 'git'` | load; CLI exit 1 | `packages:` (or `services:`) given as a string, which shell would iterate letter by letter. Write a list (`packages: [git]`). |
606
584
  | `Bash modification '<name>': ensure.files[N].mode 644 is not a mode YAML could have read from octal` | load; CLI exit 1 | An unquoted `644`, which YAML reads as a decimal number. Write `mode: "0644"`. |
607
585
  | `Bash modification '<name>': ensure.files[N]: unknown keys [...]` | load; CLI exit 1 | Only `path`, `content`, `mode` on a file and `run`, `unless` on a command. |
608
586
  | `csis ensure: no dnf, yum or apt-get to install <pkg>` | apply / `test-mods` | The image has none of the three package managers. Use a `script` line with the one it has. |
609
- | `dnf` (or `yum`, `apt-get`) error on a package step | apply / `test-mods` | The first package manager found could not install the package, and its message is the real one (no match, no repository, no network from the build subnet). Before stage 63 the line fell through to `apt-get` and showed `apt-get: command not found` last on an EL host. |
587
+ | `dnf` (or `yum`, `apt-get`) error on a package step | apply / `test-mods` | The first package manager found could not install the package, and its message is the real one (no match, no repository, no network from the build subnet); the others are never tried, so no misleading `apt-get: command not found` follows it on an EL host. |
610
588
  | `cmp: command not found` or `base64: command not found` | apply | The image lacks diffutils / coreutils. Add the package to an earlier item's `ensure.packages` or a `script` line. |
611
589
  | `sudo: a terminal is required to read the password` | apply | The bake ssh user has no passwordless `sudo`. This is an OS builder / runtime matter, not the item's. |
612
590
  | `Failed to enable unit: Unit <svc>.service does not exist` | apply | `services:` names a unit the image has not installed yet; install it earlier in the same item's `packages` (packages run before services). |
@@ -638,20 +616,20 @@ Failures the code raises that have not been seen outside tests:
638
616
  [abstract_version_checker.py](../base/src/cs_image_system/base/basic/abstract_version_checker.py).
639
617
  - The image builder that calls it and hosts its output:
640
618
  [packer-plugin](../packer-plugin)
641
- ([packer_ebs_builder.py](../packer-plugin/src/cs_image_system/packer_plugin/packer_ebs_builder.py),
619
+ ([packer_image_builder.py](../packer-plugin/src/cs_image_system/packer_plugin/packer_image_builder.py),
642
620
  [v2_local_mods.py](../packer-plugin/src/cs_image_system/packer_plugin/v2_local_mods.py)).
643
621
  - Lineage records for modifications:
644
622
  [lineage.py](../base/src/cs_image_system/base/lineage.py) (`mod_records`);
645
623
  the release gate in [release.py](../base/src/cs_image_system/base/release.py).
646
624
  - Tests: [tests/test_bash_provisioner.py](tests/test_bash_provisioner.py)
647
625
  (two blocks for files plus inline, builder options, script copying);
648
- [test_v2_gate8_modifications.py](../../tests/test_v2_gate8_modifications.py)
626
+ [test_modifications.py](../../tests/test_modifications.py)
649
627
  (both kinds emitted, unknown type refused, the load rule);
650
- [test_v2_explore_local_mods.py](../../tests/test_v2_explore_local_mods.py)
628
+ [test_modifications.py](../../tests/test_modifications.py)
651
629
  (`ensure` guards, idempotence recorded, the bundle);
652
- [test_v2_explore_mod_tests.py](../../tests/test_v2_explore_mod_tests.py)
630
+ [test_mod_tests.py](../../tests/test_mod_tests.py)
653
631
  (the container tests on the fixture's bash item);
654
- [test_v2_hygiene_three.py](../../tests/test_v2_hygiene_three.py)
632
+ [test_version_checks.py](../../tests/test_version_checks.py)
655
633
  (`BashVersionChecker` found by name, versions judged).
656
634
  - No terraform modules and no hashicorp-utils usage: the plugin writes
657
635
  Packer provisioner lines directly.
@@ -39,18 +39,15 @@ How a YAML entry selects these classes:
39
39
  looks the builder up by name or alias, reads the builder's own `type`
40
40
  (`bash-remote`) and loads the item into the class registered under that
41
41
  service key for `MOD_BUILDER_ITEM_MODEL`: `BashModItemModel`. The
42
- registry alone decides; the builder model's
43
- `get_target_deferred_type_by_VCT` method, which also named that class
44
- and had no caller, is gone (stage 63, 2026-09-25), as is the plugin's
45
- empty `helpers.py`.
46
- - **The item's `type` is rewritten to the builder's `name` at load**
47
- (stage 63 item 15). The image builder later fetches the builder with
42
+ registry alone decides.
43
+ - **The item's `type` is rewritten to the builder's `name` at load.**
44
+ The image builder later fetches the builder with
48
45
  `ctx.mod_builders.get(mod.get_type())`, a map keyed by builder NAME
49
- only, so an alias must not survive the load. An item declared
50
- `type: bash` (the fixture builder's alias) is loaded as
51
- `type: bash-remote`. Before 2026-09-25 the alias was kept, and
52
- generation stopped with
46
+ only, so an alias must not survive the load: a kept alias would stop
47
+ generation with
53
48
  `AssertionError: No mod builder bash found in context for <image>`.
49
+ An item declared `type: bash` (the fixture builder's alias) is loaded
50
+ as `type: bash-remote`.
54
51
  - An `executables:` entry named `bash` is version-checked by
55
52
  `BashVersionChecker` (regex `.*version\s+([\d\.]+)` over
56
53
  `bash --version`).
@@ -64,21 +61,31 @@ Defined in [bash_models.py](src/cs_image_system/bash_mod_plugin/bash_models.py).
64
61
  [`ModBuilderModel`](../base/src/cs_image_system/base/models/mod_builder.py),
65
62
  which adds nothing to
66
63
  [`BuilderModel`](../base/src/cs_image_system/base/models/builder_model.py)
67
- (`name`, `type`, `description`, `aliases`, `executable`, `is_default`,
68
- `config`, `gitignore`, `tags`). `BashModBuilderModel` is the registered
69
- subclass; it adds no fields.
70
-
71
- | Field | Type | Default | Meaning |
72
- |---|---|---|---|
73
- | `execute_command` | `str \| None` | `None` | Emitted as `execute_command = "..."` on every shell provisioner of every item (Packer's command template, for example `sudo -E bash '{{.Path}}'`). The way to change how the script is invoked. May not be declared together with `configuration_user`. |
74
- | `configuration_user` | `str \| None` | `None` | When set, every shell provisioner of this builder gets `execute_command = "chmod +x {{ .Path }}; sudo su - <user> -c '{{ .Vars }} {{ .Path }}'"`, so each item's lines run as that user; the bake user only launches them (stage 63; declared and not read until 2026-09-25). Declaring it together with `execute_command` is refused at load. |
75
- | `environment_vars` | `list[str]` | `[]` | Emitted as `environment_vars = [...]` on every shell provisioner. |
76
- | `expect_disconnect` | `bool` | `False` | Emits `expect_disconnect = true` on every shell provisioner. |
77
-
78
- `extra_arguments` is not a field (stage 63): it was declared and never
79
- read, so it is removed, and a builder that still declares it fails to load
80
- with the unknown-key refusal. Use `execute_command` to change the
81
- invocation.
64
+ (the common builder fields: `name`, `type`, `description`, `aliases`,
65
+ `executable`, `is_default`, `config`, `gitignore`, `tags`;
66
+ [CONFIGURATION section 4.1](../../docs/CONFIGURATION.md#41-fields-common-to-every-builder)
67
+ holds that table). `BashModBuilderModel` is the registered subclass; it
68
+ adds no fields.
69
+
70
+ The keys this plugin adds are `execute_command`, `configuration_user`,
71
+ `environment_vars` and `expect_disconnect`: each is a Packer
72
+ shell-provisioner argument, emitted on every shell provisioner of every
73
+ item of the builder.
74
+ [CONFIGURATION section 7.2](../../docs/CONFIGURATION.md#72-bash-remote)
75
+ holds the field table; [Configuration reference](#configuration-reference)
76
+ below gives this plugin's reading of each key. Rules only this plugin
77
+ enforces:
78
+
79
+ - `execute_command` (Packer's command template, for example
80
+ `sudo -E bash '{{.Path}}'`) is the way to change how the script is
81
+ invoked. `configuration_user` writes its own: every shell provisioner
82
+ of the builder gets `execute_command = "chmod +x {{ .Path }}; sudo su -
83
+ <user> -c '{{ .Vars }} {{ .Path }}'"`, so each item's lines run as that
84
+ user and the bake user only launches them. Because both say how the
85
+ script runs, declaring them together is refused at load.
86
+ - `extra_arguments` is not a field: a builder that declares it fails to
87
+ load with the unknown-key refusal. Use `execute_command` to change the
88
+ invocation.
82
89
 
83
90
  Base fields this plugin constrains: `executable` should name the `bash`
84
91
  executable (the fixture does), though the emission does not invoke it;
@@ -89,13 +96,28 @@ base-model rule). `config:` on the builder is carried but never read.
89
96
 
90
97
  Same file. Extends
91
98
  [`ModItemModel`](../base/src/cs_image_system/base/models/moditem_type.py)
92
- (`SubRootItem` -> `RootItem` -> `NameTyped`).
93
-
94
- | Field | Type | Default | Meaning |
95
- |---|---|---|---|
96
- | `script` | `list[str]` | `[]` | Inline command lines, run in order in one `provisioner "shell"` (`inline = [...]`). Literal shell; no templating. Blank entries are dropped. |
97
- | `scripts` | `list[str]` | `[]` | Script files, relative to the configuration root (the process's working directory, which the configuration load sets to the root), copied beside the Packer root and run as `scripts = [...]`. Blank entries are dropped. |
98
- | `ensure` | `dict[str, Any]` | `{}` | The declarative form. Allowed keys: `packages` (list of names), `files` (list of `{path, content, mode}`; `mode` defaults to `0644`), `services` (list of unit names, enabled and started), `commands` (list of `{run, unless}`; `run` executes only when `unless` fails). Every entry is validated at load by `validate_ensure` (stage 63 item 16): a wrong shape, an unknown key, a bad mode or an all-empty mapping raises `ValueError` naming the item and the entry. |
99
+ (`SubRootItem` -> `RootItem` -> `NameTyped`). The item's own keys are
100
+ `script`, `scripts` and `ensure`; the inherited ones (`name`, `type`,
101
+ `description`, `aliases`, `tags`, `config`) are the modification item's,
102
+ tabled in
103
+ [CONFIGURATION section 7.3](../../docs/CONFIGURATION.md#73-the-modification-item-inside-an-images-modifications),
104
+ and [Configuration reference](#configuration-reference) below reads every
105
+ key as this plugin does.
106
+
107
+ - `script` is the list of inline command lines, run in order in one
108
+ `provisioner "shell"` (`inline = [...]`). Literal shell; no templating.
109
+ Blank entries are dropped.
110
+ - `scripts` is the list of script files, relative to the configuration
111
+ root (the process's working directory, which the configuration load
112
+ sets to the root), copied beside the Packer root and run as
113
+ `scripts = [...]`. Blank entries are dropped.
114
+ - `ensure` is the declarative form. Allowed keys: `packages` (list of
115
+ names), `files` (list of `{path, content, mode}`; `mode` defaults to
116
+ `0644`), `services` (list of unit names, enabled and started),
117
+ `commands` (list of `{run, unless}`; `run` executes only when `unless`
118
+ fails). Every entry is validated at load by `validate_ensure`: a wrong
119
+ shape, an unknown key, a bad mode or an all-empty mapping raises
120
+ `ValueError` naming the item and the entry.
99
121
 
100
122
  Validation at load: at least one of `script`, `scripts`, `ensure` must be
101
123
  non-empty, otherwise `ValueError` ("must supply 'script' (inline lines),
@@ -118,15 +140,13 @@ Derived behaviour:
118
140
  - `remap_self_with_copied_assets(copied)`: replaces each `scripts` entry
119
141
  with its copied path when present in the mapping.
120
142
 
121
- Inherited item fields:
122
-
123
- | Base field | From | Default | Use in this plugin |
124
- |---|---|---|---|
125
- | `name` | `NameTyped` | required | Names the provisioner comment and the local bundle directory (`NN-<name>`). |
126
- | `type` | `ModItemModel` | `default` | Foreign key to a `mod_builders:` entry (see above). |
127
- | `description`, `aliases`, `tags` | `NameTyped` / `RootItem` | | Carried; not emitted. |
128
- | `config` | `RootItem` | `{}` | Not read by this plugin. It takes part in the lineage content hash of the modification and nothing else. |
129
- | `_model_id` | `ModItemModel` | set at load | The owning image's global id; `identified_model` resolves to the image. |
143
+ Of the inherited fields, `name` names the provisioner comment and the
144
+ local bundle directory (`NN-<name>`); `type` is the foreign key to a
145
+ `mod_builders:` entry (see above); `description`, `aliases` and `tags`
146
+ are carried and not emitted; `config` is not read by this plugin and
147
+ takes part in the lineage content hash of the modification and nothing
148
+ else; `_model_id`, set at load, is the owning image's global id, and
149
+ `identified_model` resolves to the image.
130
150
 
131
151
  ### A modification item with only `config:`
132
152
 
@@ -155,10 +175,11 @@ calls, in order:
155
175
  `scripts` to `<block_dir>/<script path>` and returns
156
176
  `{script: script path}`. The source is resolved against the process's
157
177
  current working directory; the configuration load has already changed
158
- directory to the configuration root (`--root-dir`, else
159
- `working_directory` in `cfg/_config.yml`, else `.`), so in practice the
160
- path is root-relative. A missing file raises `FileNotFoundError`; an
161
- absolute path is not copied and is left in place.
178
+ directory to the configuration root (`--root-dir`, else the directory
179
+ the command was started from; `working_directory` in `cfg/_config.yml`
180
+ is not read), so the path is root-relative. A missing file raises
181
+ `FileNotFoundError`; an absolute path is not copied and is left in
182
+ place.
162
183
  2. `mod.remap_self_with_copied_assets(mapping)` on the item. Because the
163
184
  mapping's value is the same relative path the copy was placed at, the
164
185
  item's `scripts` entries keep their text; the emitted `scripts = [...]`
@@ -167,8 +188,8 @@ calls, in order:
167
188
  4. `generate_items_during_modification(image, mod, image_builder, phase, build_path)`:
168
189
  - `inline` = `ensure_lines()` followed by the `script` lines; `scripts`
169
190
  = the script files. When both lists are empty it logs a warning and
170
- emits nothing; since stage 63 item 16 an all-empty `ensure` is
171
- refused at load, so an item can no longer reach this.
191
+ emits nothing; an all-empty `ensure` is refused at load, so an item
192
+ cannot reach this.
172
193
  - Appends `# Modifications for <item name> of type <item type> (shell)`.
173
194
  - Then, because Packer's shell provisioner takes either `scripts` or
174
195
  `inline` but never both, one or two `provisioner "shell"` blocks:
@@ -181,7 +202,7 @@ calls, in order:
181
202
  with a `configuration_user`, `chmod +x {{ .Path }}; sudo su - <user>
182
203
  -c '{{ .Vars }} {{ .Path }}'` (Packer's `{{ .Vars }}`, the
183
204
  environment variables, travel inside the command because `su -`
184
- starts a fresh login environment), else none (stage 63).
205
+ starts a fresh login environment), else none.
185
206
  - Every string is rendered as an HCL literal: backslashes and double
186
207
  quotes are escaped, and `${` / `%{` become `$${` / `%%{` so shell text
187
208
  such as `dpkg-query -f='${Package}'` survives Packer's template parser.
@@ -196,9 +217,7 @@ modification bundle records the item: its directory
196
217
  `run.sh` that runs `sh '<script>'` per script file and then
197
218
  `sh ./inline.sh`, and a `MANIFEST.yaml` with `operation: bash` and the
198
219
  item's `idempotent` value. `csis-mods rerun` on the image therefore
199
- re-applies the declarative `ensure` form too (stage 63). Until 2026-09-25
200
- the `ensure` lines were not in the bundle and a rerun replayed only the
201
- script files and the `script` lines.
220
+ re-applies the declarative `ensure` form too.
202
221
 
203
222
  ## Emission
204
223
 
@@ -300,8 +319,8 @@ entry in `cfg/executables.yml`
300
319
  ([the fixture's](../../tests/fixtures/config/cfg/executables.yml) is
301
320
  `name: bash`, `binary: /usr/local/bin/bash`, no `version`), because
302
321
  `validate` checks every builder's `executable` against that list and every
303
- run checks every declared entry (stage 48: the binary must exist as an
304
- absolute path or a name on `PATH`, `BashVersionChecker` must parse a version
322
+ run checks every declared entry (the binary must exist as an absolute
323
+ path or a name on `PATH`, `BashVersionChecker` must parse a version
305
324
  from `<binary> --version`, and when the entry declares `version:` the parsed
306
325
  version must satisfy it). That is the whole extent of the plugin's use of a
307
326
  local bash: it is looked at, never run. The fixture declares no floor;
@@ -338,8 +357,8 @@ in `ensure` lines works there too.
338
357
  **How the plugin finds things.** The builder by the item's `type:` (the
339
358
  builder's name); the item class by the builder's `type: bash-remote`; the
340
359
  `bash` entry by the builder's `executable:`; script files by path relative
341
- to the configuration root (the working directory the load selects from
342
- `--root-dir`, else `cfg/_config.yml`'s `working_directory`, else `.`); the
360
+ to the configuration root (the working directory the load changes into:
361
+ `--root-dir`, else the directory the command was started from); the
343
362
  build VM through the image builder that hosts the emission. No environment
344
363
  variable, profile or file of its own.
345
364
 
@@ -349,7 +368,7 @@ variable, profile or file of its own.
349
368
 
350
369
  Every key the model accepts. An unknown key is refused at load
351
370
  (`extra="forbid"` in the shared model config), and `parameters:` is refused
352
- by name (retired in stage 26).
371
+ by name.
353
372
 
354
373
  | Field | Type | Default | Meaning |
355
374
  |---|---|---|---|
@@ -363,24 +382,24 @@ by name (retired in stage 26).
363
382
  | `gitignore` | list[str] | `[]` | Accepted by the base model; nothing in this plugin reads it. |
364
383
  | `tags` | mapping | `{}` | Accepted, not read. |
365
384
  | `execute_command` | str or null | `null` | Emitted verbatim (HCL-escaped) as `execute_command` on every shell block of every item of this builder. Refused together with `configuration_user`. |
366
- | `configuration_user` | str or null | `null` | Read since stage 63 (accepted and ignored until 2026-09-25): every shell block of this builder runs its lines as this user through `execute_command = "chmod +x {{ .Path }}; sudo su - <user> -c '{{ .Vars }} {{ .Path }}'"`. Refused together with `execute_command` (`... both say how the script runs; declare one`). |
385
+ | `configuration_user` | str or null | `null` | Every shell block of this builder runs its lines as this user through `execute_command = "chmod +x {{ .Path }}; sudo su - <user> -c '{{ .Vars }} {{ .Path }}'"`. Refused together with `execute_command` (`... both say how the script runs; declare one`). |
367
386
  | `environment_vars` | list[str] | `[]` | Emitted as `environment_vars = [...]` on every shell block. |
368
387
  | `expect_disconnect` | bool | `false` | When true, `expect_disconnect = true` on every shell block. |
369
- | `extra_arguments` | | | Refused at load as an unknown key (removed in stage 63; it was accepted and never read). Use `execute_command`. |
388
+ | `extra_arguments` | | | Not a field; refused at load as an unknown key. Use `execute_command`. |
370
389
 
371
390
  ### The item (an image's `modifications:` entry whose `type:` names this builder)
372
391
 
373
392
  | Field | Type | Default | Meaning |
374
393
  |---|---|---|---|
375
394
  | `name` | str | required | Unique within the image. Names the comment line, the bundle directory `NN-<name>` and the lineage record. |
376
- | `type` | str | `default` | The builder's name, an alias, or `default` (or omitted). Rewritten to the builder's name at load (stage 63 item 15). Omitted or `default` with no `is_default: true` mod builder: refused at load (stage 63). |
395
+ | `type` | str | `default` | The builder's name, an alias, or `default` (or omitted). Rewritten to the builder's name at load. Omitted or `default` with no `is_default: true` mod builder: refused at load. |
377
396
  | `description` | str or null | `null` | Accepted, not read. |
378
397
  | `aliases` | list[str] | `[]` | Accepted, not read. |
379
398
  | `tags` | mapping | `{}` | Accepted, not read. |
380
399
  | `config` | mapping | `{}` | Not read by the plugin. Part of the modification's content hash, so a change re-bakes the image. Not passed to the shell. |
381
400
  | `script` | list[str] | `[]` | Inline lines, literal, in order, after the `ensure` lines. Blank and whitespace-only entries are dropped. |
382
401
  | `scripts` | list[str] | `[]` | Script files; each is copied beside the Packer root and run first, in order. Blank entries are dropped. An absolute path is not copied and is emitted as is. |
383
- | `ensure` | mapping | `{}` | Only the four keys below are allowed; any other key is refused at load. Every entry's values are validated at load since stage 63 item 16 (`validate_ensure`; see "What it tests and verifies"). |
402
+ | `ensure` | mapping | `{}` | Only the four keys below are allowed; any other key is refused at load. Every entry's values are validated at load (`validate_ensure`; see "What it tests and verifies"). |
384
403
 
385
404
  The `ensure` keys:
386
405
 
@@ -401,11 +420,11 @@ The `ensure` keys:
401
420
  `idempotent: unknown`, the same as free-form alone. `ensure` lines always
402
421
  precede the `script` lines in the inline block.
403
422
  - **`ensure` whose lists are all empty** (`ensure: {packages: []}`) is
404
- refused at load (stage 63 item 16). Before 2026-09-25 it passed load,
405
- counted as `declared`, and emitted nothing: the builder logged
406
- `Bash modification <name> has no script lines or files; nothing emitted`
407
- and the bake proceeded without a provisioner for it. The bundle directory
408
- still exists, with a `run.sh` that runs nothing.
423
+ refused at load, because such an item would count as `declared` and
424
+ emit nothing: the builder would log
425
+ `Bash modification <name> has no script lines or files; nothing emitted`,
426
+ the bake would proceed without a provisioner for it, and the bundle
427
+ directory would hold a `run.sh` that runs nothing.
409
428
  - **`commands` with `unless`** is guarded; **without `unless`** the command
410
429
  runs on every bake and on every `csis-mods rerun`, so the item is
411
430
  idempotent only if the command is.
@@ -415,7 +434,7 @@ The `ensure` keys:
415
434
  - **`execute_command` set** puts that template on every block of every
416
435
  item of the builder; **`configuration_user` set** instead puts
417
436
  `chmod +x {{ .Path }}; sudo su - <user> -c '{{ .Vars }} {{ .Path }}'`
418
- there, so the lines run as that user (stage 63); **both set** is refused
437
+ there, so the lines run as that user; **both set** is refused
419
438
  at load; **neither**, Packer's default applies and the lines run as the
420
439
  bake user. The same for
421
440
  `environment_vars` (emitted only when non-empty) and `expect_disconnect`
@@ -424,11 +443,10 @@ The `ensure` keys:
424
443
  `type`** reaches whichever mod builder is `is_default: true`; in the
425
444
  fixture that is `ansible-default`, so the item is loaded as an ANSIBLE
426
445
  item and its `script`/`scripts`/`ensure` keys are refused as unknown.
427
- With no `is_default: true` mod builder at all, the load is refused
428
- (stage 63): `No default builder found in mod_builder list.` first, and
429
- the orchestrator's `` modification '<name>' names no builder (`type`) ... ``
430
- for an item that reaches it anyway. Until 2026-09-25 such an item
431
- became an `AttributeError` at generation.
446
+ With no `is_default: true` mod builder at all, the load is refused:
447
+ `No default builder found in mod_builder list.` first, and the
448
+ orchestrator's `` modification '<name>' names no builder (`type`) ... ``
449
+ for an item that reaches it anyway.
432
450
  **`type: <alias>`** behaves exactly as the builder's name: the loader
433
451
  rewrites it.
434
452
  - **AWS versus GCE runtime**: the lines are identical; only the `only`
@@ -463,7 +481,7 @@ integer mode no higher than `0777`, and not every kind empty); an item
463
481
  with none of `script`, `scripts`, `ensure`; an item `type:` that names no
464
482
  builder; an item with no `type:` when no mod builder is the default; a
465
483
  builder declaring both `configuration_user` and `execute_command`, or
466
- the removed `extra_arguments` (stage 63). Each is a
484
+ `extra_arguments`. Each is a
467
485
  `ValueError` (or `KeyError` for the type) that stops the load; the CLI
468
486
  prints it and exits 1. Nothing loads, so nothing is generated.
469
487
 
@@ -516,81 +534,41 @@ failure prints `FAIL <image>/<mod>: rc=...` on stderr and exits 1. A
516
534
 
517
535
  ## When it fails
518
536
 
519
- Failures that have happened, in date order:
520
-
521
- - **2026-08-27, three defects found in the pre-V2 builder**
522
- ([LEDGER](../../docs/history/LEDGER.md), "Finding 2026-08-27"). Every
523
- item resolved to the default mod builder (the lookup used the field name
524
- `modifications`, not the item's `type`), so a bash item was recorded as
525
- `ansible-default` with no content; the bash builder wrote raw lines and an
526
- `exit 0` straight into the build file, so any real bash item failed
527
- `packer validate`; and `scripts:` was not a field, so files were dropped.
528
- Fixed at gate 8; guarded by
529
- [test_v2_gate8_modifications.py](../../tests/test_v2_gate8_modifications.py)
530
- and [tests/test_bash_provisioner.py](tests/test_bash_provisioner.py). If
531
- a comment line ever appears outside a `provisioner "shell" {` block, or
532
- `exit 0` appears in a build file, this regressed.
533
- - **2026-09-10, the first `full-test`'s mod-test leg could not start a
534
- container** for an EL 10 image: Docker Hub's `rockylinux` library stops at
535
- 9. The stand-in for `rhel` 10 and above is `almalinux:<version>` since
536
- (`test_image_for` in [mod_tests.py](../base/src/cs_image_system/base/mod_tests.py)).
537
- Symptom then: `docker run` failed to pull and the bash item's test never
538
- ran. If `test-mods` reports `unsupported` with
539
- `no local test image for the root OS family`, the family/version has no
540
- stand-in; set `local_test_image` on the OS builder.
541
- - **Found live, undated, in the sibling shell emitter for image tests**
542
- ([v2_provisioners.py](../packer-plugin/src/cs_image_system/packer_plugin/v2_provisioners.py)):
543
- an `rpm --qf '%{NAME}'` line broke `packer fmt`, because `%{` and `${`
544
- open Packer template directives. This plugin's `_hcl_string` escapes both
545
- the same way. If `packer fmt` or `validate` ever reports
546
- `Invalid template directive` or `Invalid template interpolation` on a
547
- line you wrote, the escape regressed; the line in `generated/` should
548
- read `%%{` / `$${`.
549
- - **2026-09-20, an assertion that could not fail** (image `tests:`, not this
550
- plugin, but the same shell and the same trap;
551
- [OPERATIONS.md](../../docs/OPERATIONS.md), "What a green bake proves").
552
- Under `sh -e`, a command inside an AND-OR list other than the last does
553
- not abort the script, so a `script:` line written `check || { echo no; }`
554
- never fails the build however false `check` is. Two bakes went green
555
- asserting a package that did not exist. If a `script` line must be able
556
- to stop the bake, end it in something that exits nonzero (`|| exit 1`),
557
- not in a braced group that returns 0. The plugin's own `commands` form,
558
- `( unless ) || { run; }`, is deliberate: the group is the LAST element,
559
- so a failing `run` does abort.
560
- - **2026-09-23, an item declared with the builder's alias failed at
561
- generation** (found while writing this README, on a copy of the fixture
562
- with `type: bash` on `derivative-setup`). Load and `validate` passed; the
563
- run log then showed
564
- `AssertionError: No mod builder bash found in context for imgfile-basic-dask`
565
- and the summary was `ok: false` with no validation errors. Fixed by
566
- stage 63 item 15 (2026-09-25): the loader rewrites the alias to the
567
- builder's name, and `tests/test_v2_defects_loading.py` generates that
568
- same fixture copy.
569
-
570
- Failures the code raises that have not been seen outside tests:
537
+ Incidents that have happened, with their cause and fix, are recorded on
538
+ [INCIDENTS.md](../../docs/INCIDENTS.md); what follows keeps each
539
+ failure's symptom, its meaning and what to do.
540
+
541
+ Symptoms of the emission and of the bake, with what each means and what
542
+ to do:
571
543
 
572
544
  | What you see | Where | Meaning and what to do |
573
545
  |---|---|---|
546
+ | A `# Modifications for ...` comment line outside a `provisioner "shell" {` block, or `exit 0` in a build file | `generated/`; `packer validate` fails | The builder wrote raw lines into the build file instead of provisioner blocks. [tests/test_bash_provisioner.py](tests/test_bash_provisioner.py) and [test_modifications.py](../../tests/test_modifications.py) pin the block form; this is a regression of the plugin, not of the item. |
547
+ | A bash item recorded in lineage as `ansible-default` with no content, or its script files dropped | `meta-state/lineage.yaml` after a bake | The item was resolved to the default mod builder instead of the builder its `type:` names. The same two tests pin the lookup by the item's `type`; check the item's `type:` names this builder. |
548
+ | `test-mods` reports `unsupported` with `no local test image for the root OS family` | `test-mods` | The OS family and version have no stand-in container image (`test_image_for` in [mod_tests.py](../base/src/cs_image_system/base/mod_tests.py)): Docker Hub's `rockylinux` library stops at 9, so `rhel` 10 and above stand in as `almalinux:<version>`, and a family outside that table has none. Set `local_test_image` on the OS builder. |
549
+ | `Invalid template directive` or `Invalid template interpolation` from `packer fmt` or `validate` on a line you wrote | the Packer plugin's run-now commands | `%{` and `${` open Packer template directives; this plugin's `_hcl_string` escapes both as `%%{` / `$${` (the sibling shell emitter for image tests, [v2_provisioners.py](../packer-plugin/src/cs_image_system/packer_plugin/v2_provisioners.py), does the same). If the line in `generated/` does not read so, the escape regressed. |
550
+ | A `script:` line written `check || { echo no; }` never fails the build however false `check` is | the bake, green | Under `sh -e` a command inside an AND-OR list other than the last does not abort the script, so a bake can go green asserting a package that does not exist (the same trap holds for an image's `tests:`; [OPERATIONS.md](../../docs/OPERATIONS.md), "What a green bake proves"). If a `script` line must be able to stop the bake, end it in something that exits nonzero (`\|\| exit 1`), not in a braced group that returns 0. The plugin's own `commands` form, `( unless ) \|\| { run; }`, is deliberate: the group is the LAST element, so a failing `run` does abort. |
551
+ | `AssertionError: No mod builder bash found in context for <image>` in the run log, summary `ok: false` with no validation errors, after load and `validate` passed | generation | An item's `type:` reached generation as the builder's alias; the loader rewrites an alias to the builder's name at load (`tests/test_modifications.py` generates a fixture copy with `type: bash`), so a tree whose loader does not is on an older form ([the stage index](../../docs/CHANGES.md#s63)). Write the builder's name, or update. |
574
552
  | `Bash modification '<name>' must supply 'script' (inline lines), 'scripts' (script files) and/or 'ensure' (declarative, idempotent)` | load; CLI exit 1 | The item has none of the three (a `config:`-only item). Add one or remove the item. |
575
553
  | `Bash modification '<name>': unknown ensure keys ['users']` | load; CLI exit 1 | Only `packages`, `files`, `services`, `commands` exist. Move anything else to `commands`. |
576
554
  | A pydantic error naming a key (`Extra inputs are not permitted`) on the builder or the item | load; CLI exit 1 | A misspelt or foreign key (`playbooks:` on a bash item, `script:` on an ansible item). Check the item's `type:` reaches the builder you meant; `type: default` reaches the default builder, which in the fixture is ansible. |
577
- | ``<builder>: `parameters` was retired (stage 26) ...`` | load; CLI exit 1 | Delete `parameters:` from the builder. |
555
+ | ``<builder>: `parameters` was retired ...`` | load; CLI exit 1 | Delete `parameters:` from the builder. |
578
556
  | `KeyError: Could not resolve builder '<type>' referenced in Image(...)` | load; CLI exit 1 | The item's `type:` names no `mod_builders:` entry. |
579
- | `` mod builder '<name>': `configuration_user` and `execute_command` both say how the script runs; declare one `` | load; CLI exit 1 | Stage 63. `configuration_user` writes its own `execute_command`; keep one. To run as another user with a custom command, put the `sudo su - <user>` in your `execute_command`. |
580
- | A pydantic unknown-key error naming `extra_arguments` on a `bash-remote` builder | load; CLI exit 1 | Stage 63 removed the field (it was never read). Delete it; change the invocation with `execute_command`. |
581
- | `No default builder found in mod_builder list.` | load; CLI exit 1 | No mod builder says `is_default: true`. Mark one, or give every item a `type:`. The orchestrator's own form, for an item that reaches it, is `` modification '<name>' names no builder (`type`) and no mod builder is `is_default: true`; name one with `type:` or mark a default `` (stage 63; it used to be an `AttributeError` at generation). |
557
+ | `` mod builder '<name>': `configuration_user` and `execute_command` both say how the script runs; declare one `` | load; CLI exit 1 | `configuration_user` writes its own `execute_command`; keep one. To run as another user with a custom command, put the `sudo su - <user>` in your `execute_command`. |
558
+ | A pydantic unknown-key error naming `extra_arguments` on a `bash-remote` builder | load; CLI exit 1 | The field does not exist. Delete it; change the invocation with `execute_command`. |
559
+ | `No default builder found in mod_builder list.` | load; CLI exit 1 | No mod builder says `is_default: true`. Mark one, or give every item a `type:`. The orchestrator's own form, for an item that reaches it, is `` modification '<name>' names no builder (`type`) and no mod builder is `is_default: true`; name one with `type:` or mark a default ``. |
582
560
  | `Executable bash specified for provider bash-remote not found in executables list.` | `validate`; exit 1 | Add a `bash` entry to `cfg/executables.yml` or change the builder's `executable:`. |
583
561
  | `bash: binary '/usr/local/bin/bash' not found (declared in cfg/executables.yml; an absolute path, or a name on PATH)` | `validate` and every run; exit 1 | The pinned path is wrong on this machine. Symlink the binary there or fix `binary:`. |
584
562
  | ``bash: BashVersionChecker could not parse a version from `/usr/local/bin/bash --version` `` | `validate` and every run | The first line of `--version` has no `version N.N`. Something other than bash is at that path. |
585
563
  | `bash 5.3.15 does not meet its requirement <spec> (cfg/executables.yml)` | `validate` and every run | The floor moved or the machine is behind. |
586
564
  | `FileNotFoundError: Bash modification script mod_image.sh does not exist or is not a file` | generation; run `ok: false`, traceback in the log | The path is resolved from the configuration root, not from the image file's directory or the shell's. Fix the path or `--root-dir`. |
587
565
  | `Bash modification '<name>': ensure declares [...] but every one is empty, so it would emit nothing` | load; CLI exit 1 | Every `ensure` list is empty. Fill one or drop `ensure`. |
588
- | `Bash modification '<name>': ensure.files[N]: `path` is required` (or `ensure.commands[N]: `run` is required`) | load; CLI exit 1 | A `files` entry without `path`, or a `commands` entry without `run`. Before stage 63 this was a `KeyError` at generation. |
589
- | `Bash modification '<name>': ensure.packages must be a list of package names, not str 'git'` | load; CLI exit 1 | `packages:` (or `services:`) given as a string; the old code iterated its letters. Write a list (`packages: [git]`). |
566
+ | `Bash modification '<name>': ensure.files[N]: `path` is required` (or `ensure.commands[N]: `run` is required`) | load; CLI exit 1 | A `files` entry without `path`, or a `commands` entry without `run`. |
567
+ | `Bash modification '<name>': ensure.packages must be a list of package names, not str 'git'` | load; CLI exit 1 | `packages:` (or `services:`) given as a string, which shell would iterate letter by letter. Write a list (`packages: [git]`). |
590
568
  | `Bash modification '<name>': ensure.files[N].mode 644 is not a mode YAML could have read from octal` | load; CLI exit 1 | An unquoted `644`, which YAML reads as a decimal number. Write `mode: "0644"`. |
591
569
  | `Bash modification '<name>': ensure.files[N]: unknown keys [...]` | load; CLI exit 1 | Only `path`, `content`, `mode` on a file and `run`, `unless` on a command. |
592
570
  | `csis ensure: no dnf, yum or apt-get to install <pkg>` | apply / `test-mods` | The image has none of the three package managers. Use a `script` line with the one it has. |
593
- | `dnf` (or `yum`, `apt-get`) error on a package step | apply / `test-mods` | The first package manager found could not install the package, and its message is the real one (no match, no repository, no network from the build subnet). Before stage 63 the line fell through to `apt-get` and showed `apt-get: command not found` last on an EL host. |
571
+ | `dnf` (or `yum`, `apt-get`) error on a package step | apply / `test-mods` | The first package manager found could not install the package, and its message is the real one (no match, no repository, no network from the build subnet); the others are never tried, so no misleading `apt-get: command not found` follows it on an EL host. |
594
572
  | `cmp: command not found` or `base64: command not found` | apply | The image lacks diffutils / coreutils. Add the package to an earlier item's `ensure.packages` or a `script` line. |
595
573
  | `sudo: a terminal is required to read the password` | apply | The bake ssh user has no passwordless `sudo`. This is an OS builder / runtime matter, not the item's. |
596
574
  | `Failed to enable unit: Unit <svc>.service does not exist` | apply | `services:` names a unit the image has not installed yet; install it earlier in the same item's `packages` (packages run before services). |
@@ -622,20 +600,20 @@ Failures the code raises that have not been seen outside tests:
622
600
  [abstract_version_checker.py](../base/src/cs_image_system/base/basic/abstract_version_checker.py).
623
601
  - The image builder that calls it and hosts its output:
624
602
  [packer-plugin](../packer-plugin)
625
- ([packer_ebs_builder.py](../packer-plugin/src/cs_image_system/packer_plugin/packer_ebs_builder.py),
603
+ ([packer_image_builder.py](../packer-plugin/src/cs_image_system/packer_plugin/packer_image_builder.py),
626
604
  [v2_local_mods.py](../packer-plugin/src/cs_image_system/packer_plugin/v2_local_mods.py)).
627
605
  - Lineage records for modifications:
628
606
  [lineage.py](../base/src/cs_image_system/base/lineage.py) (`mod_records`);
629
607
  the release gate in [release.py](../base/src/cs_image_system/base/release.py).
630
608
  - Tests: [tests/test_bash_provisioner.py](tests/test_bash_provisioner.py)
631
609
  (two blocks for files plus inline, builder options, script copying);
632
- [test_v2_gate8_modifications.py](../../tests/test_v2_gate8_modifications.py)
610
+ [test_modifications.py](../../tests/test_modifications.py)
633
611
  (both kinds emitted, unknown type refused, the load rule);
634
- [test_v2_explore_local_mods.py](../../tests/test_v2_explore_local_mods.py)
612
+ [test_modifications.py](../../tests/test_modifications.py)
635
613
  (`ensure` guards, idempotence recorded, the bundle);
636
- [test_v2_explore_mod_tests.py](../../tests/test_v2_explore_mod_tests.py)
614
+ [test_mod_tests.py](../../tests/test_mod_tests.py)
637
615
  (the container tests on the fixture's bash item);
638
- [test_v2_hygiene_three.py](../../tests/test_v2_hygiene_three.py)
616
+ [test_version_checks.py](../../tests/test_version_checks.py)
639
617
  (`BashVersionChecker` found by name, versions judged).
640
618
  - No terraform modules and no hashicorp-utils usage: the plugin writes
641
619
  Packer provisioner lines directly.
@@ -4,7 +4,7 @@
4
4
 
5
5
  [project]
6
6
  name = "cs-image-system-bash-mod-plugin"
7
- version = "0.1.6"
7
+ version = "0.1.8"
8
8
  description = "The shell modification plugin: inline script, script files and an ensure form become Packer shell provisioners"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Mykel Alvis", email = "mykelalvis@infrastructurebuilder.org" }]
@@ -12,8 +12,8 @@ license = "Apache-2.0"
12
12
  license-files = ["LICENSE"]
13
13
  requires-python = ">=3.13"
14
14
  dependencies = [
15
- "cs-image-system-contract==0.1.6",
16
- "cs-image-system-base==0.1.6",
15
+ "cs-image-system-contract>=0.4,<0.5",
16
+ "cs-image-system-base==0.1.8",
17
17
  "pydantic>=2.13",
18
18
  ]
19
19
 
@@ -9,17 +9,15 @@ import shutil
9
9
  from pathlib import Path
10
10
  from typing import TypeVar, cast
11
11
 
12
- from cs_image_system.contract.assets import AssetSet
13
- from cs_image_system.base.bake_steps import execute_command_line
14
-
15
- from .bash_models import BASH_BUILDER, BASH_EXECUTABLE, BashBuilderModel, BashModItemModel, BashModBuilderModel
16
12
  from cs_image_system.base.basic.abstract_version_checker import AbstractVersionChecker
13
+ from cs_image_system.base.basic.builder_base_image import ImageBuilderBase, build_target_label_of
17
14
  from cs_image_system.base.basic.builder_base_mod import ModBuilderBase
18
- from cs_image_system.contract.lifecycle import ExecutionLifecyclePhase
19
15
  from cs_image_system.base.models.image import Image
20
- from cs_image_system.base.basic.builder_base_image import ImageBuilderBase
21
16
  from cs_image_system.base.models.moditem_type import ModItemModel
22
- from cs_image_system.base.basic.builder_base_image import build_target_label_of
17
+ from cs_image_system.contract.assets import AssetSet
18
+ from cs_image_system.contract.lifecycle import ExecutionLifecyclePhase
19
+
20
+ from .bash_models import BASH_BUILDER, BASH_EXECUTABLE, BashBuilderModel, BashModBuilderModel, BashModItemModel
23
21
 
24
22
  log = logging.getLogger(__name__)
25
23
 
@@ -81,14 +79,14 @@ class BashModBuilder(ModBuilderBase[Q]):
81
79
  for body in parts:
82
80
  lines += ['provisioner "shell" {', f' only = ["{build_target_label_of(ibb, image)}"]']
83
81
  lines += body
84
- command = self.model.effective_execute_command() # stage 63: configuration_user
82
+ command = self.model.effective_execute_command() # configuration_user
85
83
  if command:
86
84
  lines.append(f" execute_command = {_hcl_string(command)}")
87
85
  else:
88
- # stage 86: through the bake's step runner, so `ensure: packages`
86
+ # through the bake's step runner, so `ensure: packages`
89
87
  # and a script's own installs wait out a held package database;
90
88
  # a builder that says how its script runs keeps its own command
91
- lines.append(execute_command_line(" "))
89
+ lines.append(self._get_context().services.commands.execute_command_line(" "))
92
90
  if self.model.environment_vars:
93
91
  lines.append(" environment_vars = [" + ", ".join(_hcl_string(v) for v in self.model.environment_vars) + "]")
94
92
  if self.model.expect_disconnect:
@@ -4,13 +4,15 @@
4
4
 
5
5
  import re
6
6
  from dataclasses import field
7
- from cs_image_system.contract.model_config import CSIS_MODEL_CONFIG
8
- from pydantic.dataclasses import dataclass # stage 23: validation at construction
9
7
  from pathlib import Path
10
8
  from typing import Any
11
9
 
10
+ from pydantic.dataclasses import dataclass # validation at construction
11
+
12
12
  from cs_image_system.base.models.mod_builder import ModBuilderModel
13
13
  from cs_image_system.base.models.moditem_type import ModItemModel
14
+ from cs_image_system.contract.model_config import CSIS_MODEL_CONFIG
15
+
14
16
  BASH_EXECUTABLE: str = "bash"
15
17
 
16
18
  BASH_BUILDER: str = "bash-remote"
@@ -31,8 +33,8 @@ def _mode_text(mode: Any) -> str:
31
33
  """A file mode as the four-digit octal text ``install -m`` takes.
32
34
 
33
35
  YAML reads an unquoted ``0644`` as the integer 420, so an integer is
34
- rendered back in octal (stage 63 item 16, decided 2026-09-25); a
35
- string must already be three or four octal digits."""
36
+ rendered back in octal; a string must already be three or four octal
37
+ digits."""
36
38
  if isinstance(mode, bool):
37
39
  raise ValueError(f"mode {mode!r} is not a file mode")
38
40
  if isinstance(mode, int):
@@ -74,8 +76,8 @@ def _entries(label: str, kind: str, value: Any, allowed: set[str], required: str
74
76
 
75
77
 
76
78
  def validate_ensure(label: str, ensure: Any) -> dict[str, Any]:
77
- """Every ``ensure`` entry checked at load, one rule per kind (stage 63
78
- item 16). A valid mapping comes back UNCHANGED except that an integer
79
+ """Every ``ensure`` entry checked at load, one rule per kind. A valid
80
+ mapping comes back UNCHANGED except that an integer
79
81
  file mode becomes its octal text: the lineage fingerprint hashes this
80
82
  mapping, so a tree that was already valid keeps every fingerprint.
81
83
 
@@ -171,7 +173,7 @@ class BashModItemModel(ModItemModel):
171
173
  pkgs = [str(p) for p in self.ensure.get("packages", []) or []]
172
174
  if pkgs:
173
175
  joined = " ".join(pkgs)
174
- # stage 63 item 16: the FIRST package manager that exists installs,
176
+ # the FIRST package manager that exists installs,
175
177
  # and its failure is the failure (the old chain fell through dnf,
176
178
  # yum and apt-get, so an EL host showed `apt-get: command not found`)
177
179
  out.append(f"for p in {joined}; do rpm -q \"$p\" >/dev/null 2>&1 || dpkg -s \"$p\" >/dev/null 2>&1 || "
@@ -202,7 +204,7 @@ class BashBuilderModel(ModBuilderModel):
202
204
  """Dataclass representing a Bash script modification.
203
205
  """
204
206
  type = BASH_BUILDER
205
- # stage 63: when set, every item's lines run as this user -- the shell
207
+ # when set, every item's lines run as this user -- the shell
206
208
  # provisioner's execute_command becomes `sudo su - <user> -c '...'`, so the
207
209
  # bake user only launches them. Declaring it with execute_command is
208
210
  # refused (one of the two decides how the script runs).
@@ -3,10 +3,12 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
5
  import logging
6
- from .bash_builder import BashModBuilder, BashVersionChecker
7
- from .bash_models import BASH_BUILDER, BASH_EXECUTABLE, BashModItemModel, BashModBuilderModel
6
+
8
7
  from cs_image_system.base.basic.abstract_plugin_metadata import AbstractPluginMetadata
9
8
 
9
+ from .bash_builder import BashModBuilder, BashVersionChecker
10
+ from .bash_models import BASH_BUILDER, BASH_EXECUTABLE, BashModBuilderModel, BashModItemModel
11
+
10
12
  log = logging.getLogger(__name__)
11
13
 
12
14
  class BashModTypes(AbstractPluginMetadata):
@@ -7,26 +7,34 @@ from pathlib import Path
7
7
  from types import SimpleNamespace
8
8
  from typing import Any, cast
9
9
 
10
+ from cs_image_system.base.bake_steps import execute_command_line
10
11
  from cs_image_system.bash_mod_plugin.bash_builder import BashModBuilder
11
12
  from cs_image_system.bash_mod_plugin.bash_models import BashModItemModel
12
- from cs_image_system.base.lifecycle import ExecutionLifecyclePhase
13
+ from cs_image_system.contract.lifecycle import ExecutionLifecyclePhase
13
14
 
14
15
 
15
16
  def _builder(**model_fields):
16
17
  fields = {"execute_command": None, "environment_vars": [], "expect_disconnect": False}
17
18
  fields.update(model_fields)
18
19
  model = SimpleNamespace(**fields)
19
- # stage 63: the builder asks the model how the script runs
20
+ # the builder asks the model how the script runs
20
21
  model.effective_execute_command = lambda: model.execute_command
21
22
  b = BashModBuilder.__new__(BashModBuilder)
22
23
  b._model = model # type: ignore[attr-defined]
24
+ # the builder asks its run's commands service for the execute_command
25
+ # line; a builder made here is bound to a stand-in that answers with the
26
+ # kit's own (the loader binds a real one)
27
+ b.bind(cast(Any, SimpleNamespace(services=SimpleNamespace(
28
+ commands=SimpleNamespace(execute_command_line=execute_command_line)))))
23
29
  return b
24
30
 
25
31
 
26
32
  def _emit(builder, mod):
27
33
  image = SimpleNamespace(name="img")
34
+ # the image builder labels the build target through its runtime; a stub answers here
35
+ ibb = SimpleNamespace(build_target_label=lambda img: f"amazon-ebs.{img.name}")
28
36
  items = builder.generate_items_during_modification(
29
- image, mod, cast(Any, None), ExecutionLifecyclePhase.IMAGE_GENERATION, Path("build.pkr.hcl"))
37
+ image, mod, cast(Any, ibb), ExecutionLifecyclePhase.IMAGE_GENERATION, Path("build.pkr.hcl"))
30
38
  return "\n".join(a.value for a in items)
31
39
 
32
40