cs-image-system-bash-mod-plugin 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,136 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import TypeVar, cast
11
+
12
+ from cs_image_system.base.basic.asset 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
+ from cs_image_system.base.basic.abstract_version_checker import AbstractVersionChecker
17
+ from cs_image_system.base.basic.builder_base_mod import ModBuilderBase
18
+ from cs_image_system.base.lifecycle import ExecutionLifecyclePhase
19
+ from cs_image_system.base.models.image import Image
20
+ from cs_image_system.base.basic.builder_base_image import ImageBuilderBase
21
+ 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
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ Q = TypeVar("Q", bound=BashModBuilderModel)
28
+
29
+
30
+ def _hcl_string(s: str) -> str:
31
+ """HCL2 string literal; ${ and %{ are escaped so shell lines like
32
+ dpkg-query -f='${Package}' survive packer's template parser."""
33
+ t = s.replace("\\", "\\\\").replace('"', '\\"')
34
+ return '"' + t.replace("${", "$${").replace("%{", "%%{") + '"'
35
+
36
+
37
+ class BashModBuilder(ModBuilderBase[Q]):
38
+ """Bash-based modification builder (DESIGN §3M2).
39
+
40
+ Emits well-formed packer ``provisioner "shell"`` blocks scoped to the
41
+ image: script files (copied beside the packer root) as ``scripts = [...]``
42
+ and inline lines as ``inline = [...]`` -- as two blocks when both are
43
+ given, because packer forbids both arguments in one provisioner.
44
+ """
45
+ @classmethod
46
+ def csis_name(cls) -> str:
47
+ return BASH_BUILDER
48
+
49
+
50
+ @property
51
+ def model(self) -> BashBuilderModel: # type: ignore
52
+ return cast(BashBuilderModel, self._model)
53
+
54
+ def generate_items_before_modification(self, image: Image, mod: ModItemModel,
55
+ ibb: ImageBuilderBase,
56
+ phase: ExecutionLifecyclePhase,
57
+ path: Path | None = None) -> AssetSet:
58
+ return AssetSet()
59
+
60
+ def generate_items_during_modification(self, image: Image, mod: BashModItemModel, # pyright: ignore[reportIncompatibleMethodOverride]
61
+ ibb: ImageBuilderBase,
62
+ phase: ExecutionLifecyclePhase,
63
+ path: Path | None = None) -> AssetSet:
64
+ retval = AssetSet()
65
+ if path is None:
66
+ path = self.get_path_for_phase(phase)
67
+ ensured: list[str] = list(mod.ensure_lines()) if isinstance(mod, BashModItemModel) else []
68
+ inline: list[str] = ensured + [str(l) for l in (getattr(mod, "script", None) or [])]
69
+ scripts = list(getattr(mod, "scripts", None) or [])
70
+ if not inline and not scripts:
71
+ log.warning(f"Bash modification {mod.get_display_name()} has no script lines or files; nothing emitted")
72
+ return retval
73
+ # Packer's shell provisioner takes EITHER `scripts` OR `inline`, never
74
+ # both: a mod with both emits two blocks, script files first.
75
+ lines = [f"# Modifications for {mod.name} of type {mod.type_} (shell)"]
76
+ parts: list[list[str]] = []
77
+ if scripts:
78
+ parts.append([" scripts = [" + ", ".join(_hcl_string(s) for s in scripts) + "]"])
79
+ if inline:
80
+ parts.append([" inline = ["] + [f" {_hcl_string(l)}," for l in inline] + [" ]"])
81
+ for body in parts:
82
+ lines += ['provisioner "shell" {', f' only = ["{build_target_label_of(ibb, image)}"]']
83
+ lines += body
84
+ command = self.model.effective_execute_command() # stage 63: configuration_user
85
+ if command:
86
+ lines.append(f" execute_command = {_hcl_string(command)}")
87
+ else:
88
+ # stage 86: through the bake's step runner, so `ensure: packages`
89
+ # and a script's own installs wait out a held package database;
90
+ # a builder that says how its script runs keeps its own command
91
+ lines.append(execute_command_line(" "))
92
+ if self.model.environment_vars:
93
+ lines.append(" environment_vars = [" + ", ".join(_hcl_string(v) for v in self.model.environment_vars) + "]")
94
+ if self.model.expect_disconnect:
95
+ lines.append(" expect_disconnect = true")
96
+ lines.append("}\n")
97
+ for l in lines:
98
+ retval.add(path, l)
99
+ return retval
100
+
101
+ def generate_items_after_modification(self, image: Image, mod: ModItemModel,
102
+ ibb: ImageBuilderBase,
103
+ phase: ExecutionLifecyclePhase,
104
+ path: Path | None = None) -> AssetSet:
105
+ return AssetSet()
106
+
107
+ def copy_external_assets(self, target_path: Path, mod: ModItemModel | None = None) -> dict[str, Path]:
108
+ """Copy the item's script files beside the packer root (cwd-relative
109
+ sources, like the ansible builder's playbooks) and return the mapping
110
+ the item uses to remap itself."""
111
+ assets: dict[str, Path] = {}
112
+ scripts = list(getattr(mod, "scripts", None) or []) if mod is not None else []
113
+ for script in scripts:
114
+ source = Path(script)
115
+ if source.is_absolute():
116
+ assets[script] = source
117
+ continue
118
+ if not source.is_file():
119
+ raise FileNotFoundError(f"Bash modification script {source} does not exist or is not a file")
120
+ target = target_path / source
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ shutil.copy2(source, target)
123
+ assets[script] = source
124
+ return assets
125
+
126
+
127
+ class BashVersionChecker(AbstractVersionChecker):
128
+ """Version checker for Bash."""
129
+
130
+ @classmethod
131
+ def csis_name(cls) -> str:
132
+ return BASH_EXECUTABLE
133
+
134
+
135
+ def get_regex(self) -> str:
136
+ return r".*version\s+([\d\.]+)"
@@ -0,0 +1,238 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import re
6
+ from dataclasses import field
7
+ from cs_image_system.base.models.model_config import CSIS_MODEL_CONFIG
8
+ from pydantic.dataclasses import dataclass # stage 23: validation at construction
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cs_image_system.base.models.mod_builder import ModBuilderModel
13
+ from cs_image_system.base.models.moditem_type import ModItemModel
14
+ BASH_EXECUTABLE: str = "bash"
15
+
16
+ BASH_BUILDER: str = "bash-remote"
17
+
18
+
19
+ ENSURE_KINDS: dict[str, str] = {
20
+ "packages": "a list of package names",
21
+ "files": "a list of {path, content, mode} mappings",
22
+ "services": "a list of systemd unit names",
23
+ "commands": "a list of {run, unless} mappings",
24
+ }
25
+ _FILE_KEYS = {"path", "content", "mode"}
26
+ _COMMAND_KEYS = {"run", "unless"}
27
+ _MODE_TEXT = re.compile(r"^[0-7]{3,4}$")
28
+
29
+
30
+ def _mode_text(mode: Any) -> str:
31
+ """A file mode as the four-digit octal text ``install -m`` takes.
32
+
33
+ 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
+ if isinstance(mode, bool):
37
+ raise ValueError(f"mode {mode!r} is not a file mode")
38
+ if isinstance(mode, int):
39
+ return f"{mode:04o}"
40
+ return str(mode)
41
+
42
+
43
+ def _names(label: str, kind: str, value: Any) -> list[str]:
44
+ if not isinstance(value, list):
45
+ raise ValueError(f"{label}: ensure.{kind} must be {ENSURE_KINDS[kind]}, "
46
+ f"not {type(value).__name__} {value!r} (write `{kind}: [{value}]` for one)")
47
+ out: list[str] = []
48
+ for i, v in enumerate(value):
49
+ if not isinstance(v, str) or not v.strip():
50
+ raise ValueError(f"{label}: ensure.{kind}[{i}] must be a non-empty name, not {v!r}")
51
+ out.append(v)
52
+ return out
53
+
54
+
55
+ def _entries(label: str, kind: str, value: Any, allowed: set[str], required: str) -> list[dict[str, Any]]:
56
+ if not isinstance(value, list):
57
+ raise ValueError(f"{label}: ensure.{kind} must be {ENSURE_KINDS[kind]}, not {type(value).__name__}")
58
+ for i, entry in enumerate(value):
59
+ where = f"{label}: ensure.{kind}[{i}]"
60
+ if not isinstance(entry, dict):
61
+ raise ValueError(f"{where} must be a mapping ({ENSURE_KINDS[kind]}), not {entry!r}")
62
+ unknown = set(entry) - allowed
63
+ if unknown:
64
+ raise ValueError(f"{where}: unknown keys {sorted(unknown)} (allowed: {sorted(allowed)})")
65
+ req = entry.get(required)
66
+ if not isinstance(req, str) or not req.strip():
67
+ raise ValueError(f"{where}: `{required}` is required and must be non-empty text")
68
+ for k, v in entry.items():
69
+ if k in (required, "mode"):
70
+ continue
71
+ if v is not None and not isinstance(v, str):
72
+ raise ValueError(f"{where}: `{k}` must be text, not {type(v).__name__} {v!r}")
73
+ return value
74
+
75
+
76
+ 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
+ file mode becomes its octal text: the lineage fingerprint hashes this
80
+ mapping, so a tree that was already valid keeps every fingerprint.
81
+
82
+ Refused: an unknown kind; a kind that is not a list (``packages: git``
83
+ iterated the letters); an entry of the wrong shape or with unknown keys
84
+ (a missing ``path`` or ``run`` was a ``KeyError`` at generation); a mode
85
+ that is not three or four octal digits; an integer mode above 0777 (an
86
+ unquoted ``644`` is the decimal number, not the mode); and an
87
+ ``ensure`` whose kinds are all empty (it loaded as ``idempotent:
88
+ declared`` and emitted nothing)."""
89
+ label = f"Bash modification '{label}'"
90
+ if ensure is None:
91
+ return {}
92
+ if not isinstance(ensure, dict):
93
+ raise ValueError(f"{label}: ensure must be a mapping of {sorted(ENSURE_KINDS)}, not {ensure!r}")
94
+ ensure = dict(ensure)
95
+ if not ensure:
96
+ return {}
97
+ unknown = set(ensure) - set(ENSURE_KINDS)
98
+ if unknown:
99
+ raise ValueError(f"{label}: unknown ensure keys {sorted(unknown)} (allowed: {sorted(ENSURE_KINDS)})")
100
+ for kind in ("packages", "services"):
101
+ if ensure.get(kind) is not None:
102
+ _names(label, kind, ensure[kind])
103
+ if ensure.get("files") is not None:
104
+ files = _entries(label, "files", ensure["files"], _FILE_KEYS, "path")
105
+ fixed: list[dict[str, Any]] = []
106
+ for i, f in enumerate(files):
107
+ if "mode" in f and f["mode"] is not None:
108
+ mode = f["mode"]
109
+ if isinstance(mode, int) and not isinstance(mode, bool) and not 0 <= mode <= 0o777:
110
+ raise ValueError(f"{label}: ensure.files[{i}].mode {mode} is not a mode YAML could have "
111
+ f"read from octal (an unquoted `644` is the decimal number); quote it: \"0644\"")
112
+ text = _mode_text(mode)
113
+ if not _MODE_TEXT.match(text):
114
+ raise ValueError(f"{label}: ensure.files[{i}].mode {mode!r} must be three or four octal digits")
115
+ f = {**f, "mode": text} if isinstance(mode, int) else f
116
+ fixed.append(f)
117
+ ensure["files"] = fixed
118
+ if ensure.get("commands") is not None:
119
+ _entries(label, "commands", ensure["commands"], _COMMAND_KEYS, "run")
120
+ if not any(ensure.get(k) for k in ENSURE_KINDS):
121
+ raise ValueError(f"{label}: ensure declares {sorted(ensure)} but every one is empty, so it would "
122
+ f"emit nothing; remove `ensure` or give it an entry")
123
+ return ensure
124
+
125
+
126
+ @dataclass(kw_only=True, config=CSIS_MODEL_CONFIG)
127
+ class BashModItemModel(ModItemModel):
128
+ """A shell-script modification (DESIGN §3M2).
129
+
130
+ Attributes:
131
+ script: inline command lines, run in order inside one
132
+ ``provisioner "shell"`` (``inline = [...]``).
133
+ scripts: script FILES (config-root-relative), copied beside the
134
+ packer root and run as ``scripts = [...]``.
135
+ ensure: a declarative, IDEMPOTENT form (EXPLORE "Generation of
136
+ Idempotent Script-based Modifications") the plugin turns into
137
+ guarded shell: ``packages: [..]``, ``files: [{path, content,
138
+ mode}]``, ``services: [..]`` (enabled + started), ``commands:
139
+ [{run, unless}]`` (run only when ``unless`` fails).
140
+ At least one of the three must be given. Free-form ``script``/``scripts``
141
+ are recorded as ``idempotent: unknown``; ``ensure``-only items as
142
+ ``idempotent: declared``.
143
+ """
144
+ type = BASH_BUILDER
145
+ script: list[str] = field(default_factory=list)
146
+ scripts: list[str] = field(default_factory=list)
147
+ ensure: dict[str, Any] = field(default_factory=dict)
148
+
149
+ @classmethod
150
+ def csis_name(cls) -> str:
151
+ return BASH_BUILDER
152
+
153
+ def __post_init__(self) -> None:
154
+ super().__post_init__()
155
+ self.script = [str(s) for s in (self.script or []) if str(s).strip()]
156
+ self.scripts = [str(s) for s in (self.scripts or []) if str(s).strip()]
157
+ self.ensure = validate_ensure(self.get_display_name(), self.ensure)
158
+ if not self.script and not self.scripts and not self.ensure:
159
+ raise ValueError(
160
+ f"Bash modification '{self.get_display_name()}' must supply 'script' (inline lines), "
161
+ "'scripts' (script files) and/or 'ensure' (declarative, idempotent)")
162
+
163
+ @property
164
+ def idempotent(self) -> str:
165
+ return "declared" if self.ensure and not self.script and not self.scripts else "unknown"
166
+
167
+ def ensure_lines(self) -> list[str]:
168
+ """Guarded shell for the declarative form: every step checks before it
169
+ changes, so re-running is a no-op."""
170
+ out: list[str] = []
171
+ pkgs = [str(p) for p in self.ensure.get("packages", []) or []]
172
+ if pkgs:
173
+ joined = " ".join(pkgs)
174
+ # stage 63 item 16: the FIRST package manager that exists installs,
175
+ # and its failure is the failure (the old chain fell through dnf,
176
+ # yum and apt-get, so an EL host showed `apt-get: command not found`)
177
+ out.append(f"for p in {joined}; do rpm -q \"$p\" >/dev/null 2>&1 || dpkg -s \"$p\" >/dev/null 2>&1 || "
178
+ "if command -v dnf >/dev/null 2>&1; then sudo dnf -y install \"$p\"; "
179
+ "elif command -v yum >/dev/null 2>&1; then sudo yum -y install \"$p\"; "
180
+ "elif command -v apt-get >/dev/null 2>&1; then sudo apt-get install -y \"$p\"; "
181
+ "else echo \"csis ensure: no dnf, yum or apt-get to install $p\" >&2; false; fi || exit 1; done")
182
+ for f in self.ensure.get("files", []) or []:
183
+ path = str(f["path"]); content = str(f.get("content", "")); mode = _mode_text(f.get("mode", "0644"))
184
+ b64 = __import__("base64").b64encode(content.encode()).decode()
185
+ out.append(f"printf '%s' '{b64}' | base64 -d > /tmp/.csis-ensure && "
186
+ f"( sudo cmp -s /tmp/.csis-ensure '{path}' || sudo install -m {mode} /tmp/.csis-ensure '{path}' ) && rm -f /tmp/.csis-ensure")
187
+ for svc in self.ensure.get("services", []) or []:
188
+ out.append(f"sudo systemctl is-enabled '{svc}' >/dev/null 2>&1 || sudo systemctl enable '{svc}'")
189
+ out.append(f"sudo systemctl is-active '{svc}' >/dev/null 2>&1 || sudo systemctl start '{svc}'")
190
+ for c in self.ensure.get("commands", []) or []:
191
+ run = str(c["run"]); unless = c.get("unless")
192
+ out.append(f"( {unless} ) >/dev/null 2>&1 || {{ {run}; }}" if unless else run)
193
+ return out
194
+
195
+ def remap_self_with_copied_assets(self, copied_assets: dict[str, Path]) -> None:
196
+ """Point script files at their copies beside the packer root."""
197
+ self.scripts = [str(copied_assets.get(s, s)) for s in self.scripts]
198
+
199
+
200
+ @dataclass(kw_only=True, config=CSIS_MODEL_CONFIG)
201
+ class BashBuilderModel(ModBuilderModel):
202
+ """Dataclass representing a Bash script modification.
203
+ """
204
+ type = BASH_BUILDER
205
+ # stage 63: when set, every item's lines run as this user -- the shell
206
+ # provisioner's execute_command becomes `sudo su - <user> -c '...'`, so the
207
+ # bake user only launches them. Declaring it with execute_command is
208
+ # refused (one of the two decides how the script runs).
209
+ configuration_user: str | None = None
210
+ # Optional packer shell-provisioner settings applied to every item.
211
+ execute_command: str | None = None
212
+ environment_vars: list[str] = field(default_factory=list)
213
+ expect_disconnect: bool = False
214
+
215
+ def __post_init__(self) -> None:
216
+ super().__post_init__()
217
+ if self.configuration_user and self.execute_command:
218
+ raise ValueError(f"mod builder {self.name!r}: `configuration_user` and `execute_command` both say "
219
+ "how the script runs; declare one")
220
+
221
+ def effective_execute_command(self) -> str | None:
222
+ """The shell provisioner's execute_command: the declared one, else,
223
+ with a configuration_user, packer's default command run as that user
224
+ through `su -` (its environment variables travel inside the command)."""
225
+ if self.execute_command:
226
+ return self.execute_command
227
+ if self.configuration_user:
228
+ return (f"chmod +x {{{{ .Path }}}}; sudo su - {self.configuration_user} "
229
+ f"-c '{{{{ .Vars }}}} {{{{ .Path }}}}'")
230
+ return None
231
+
232
+
233
+ @dataclass(kw_only=True, config=CSIS_MODEL_CONFIG)
234
+ class BashModBuilderModel(BashBuilderModel):
235
+
236
+ @classmethod
237
+ def csis_name(cls) -> str:
238
+ return BASH_BUILDER
@@ -0,0 +1,31 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import logging
6
+ from .bash_builder import BashModBuilder, BashVersionChecker
7
+ from .bash_models import BASH_BUILDER, BASH_EXECUTABLE, BashModItemModel, BashModBuilderModel
8
+ from cs_image_system.base.basic.abstract_plugin_metadata import AbstractPluginMetadata
9
+ from cs_image_system.base.protocols.plugin_metadata import PluginMetadataProtocol
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ class BashModTypes(AbstractPluginMetadata, PluginMetadataProtocol):
14
+ def __init__(self) -> None:
15
+ super().__init__( "1", "3.13",
16
+ {BASH_BUILDER: [
17
+ BashModBuilderModel,
18
+ BashModBuilder,
19
+ BashModItemModel
20
+ ],
21
+ BASH_EXECUTABLE: [
22
+ BashVersionChecker,
23
+ ]},
24
+ {BashModBuilderModel: BashModBuilder}
25
+ )
26
+
27
+
28
+ def initialize():
29
+ log.debug("Initializing Bash Provider")
30
+ return BashModTypes()
31
+
File without changes
@@ -0,0 +1,656 @@
1
+ Metadata-Version: 2.5
2
+ Name: cs-image-system-bash-mod-plugin
3
+ Version: 0.1.1
4
+ Summary: The shell modification plugin: inline script, script files and an ensure form become Packer shell provisioners
5
+ Project-URL: Repository, https://github.com/infrastructurebuilder/cs-image-system-3
6
+ Project-URL: Documentation, https://github.com/infrastructurebuilder/cs-image-system-3/blob/develop/docs/OPERATIONS.md
7
+ Project-URL: Package README, https://github.com/infrastructurebuilder/cs-image-system-3/blob/develop/packages/bash-mod-plugin/README.md
8
+ Author-email: Mykel Alvis <mykelalvis@infrastructurebuilder.org>
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.13
12
+ Requires-Dist: cs-image-system-system==0.1.1
13
+ Requires-Dist: pydantic>=2.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # cs-image-system-bash-mod-plugin
17
+
18
+ The shell modification plugin. It registers one modification builder,
19
+ `bash-remote`, and the modification item model that goes with it. A
20
+ modification item under an image's `modifications:` carries inline shell
21
+ lines (`script`), script files (`scripts`) and/or a declarative, idempotent
22
+ form (`ensure`); the builder turns them into `provisioner "shell"` blocks in
23
+ the image builder's Packer build file, scoped to that one image. The plugin
24
+ owns no files, roots or commands of its own: everything it produces lands in
25
+ the Packer root the image builder writes and runs when that root's
26
+ `packer build .` runs.
27
+
28
+ ## What it registers
29
+
30
+ Entry point ([pyproject.toml](pyproject.toml)):
31
+
32
+ ```toml
33
+ [project.entry-points."cs_image_system.plugins.modification"]
34
+ bash_plugin = "cs_image_system.bash_mod_plugin.main:initialize"
35
+ ```
36
+
37
+ `initialize()` ([main.py](src/cs_image_system/bash_mod_plugin/main.py))
38
+ returns a `BashModTypes` metadata object (metadata version `1`, Python
39
+ `3.13`) with two service keys:
40
+
41
+ | Service key | Classes | Classification (VCT) |
42
+ |---|---|---|
43
+ | `bash-remote` | `BashModBuilderModel`, `BashModBuilder`, `BashModItemModel` | `MOD_BUILDER_MODEL` / `MOD_BUILDER` / `MOD_BUILDER_ITEM_MODEL` |
44
+ | `bash` | `BashVersionChecker` | `VERSION_CHECKER` |
45
+
46
+ How a YAML entry selects these classes:
47
+
48
+ - A `mod_builders:` entry uses `type: bash-remote` (no aliases on the
49
+ type key) to get a `BashModBuilderModel` and its `BashModBuilder`.
50
+ - A modification item under an image's `modifications:` uses `type:` to
51
+ name a *builder*: the builder's `name`, one of its `aliases:`, or
52
+ `default`. The orchestrator
53
+ ([orchestrator.py](../base/src/cs_image_system/base/orchestrator.py))
54
+ looks the builder up by name or alias, reads the builder's own `type`
55
+ (`bash-remote`) and loads the item into the class registered under that
56
+ service key for `MOD_BUILDER_ITEM_MODEL`: `BashModItemModel`. The
57
+ registry alone decides; the builder model's
58
+ `get_target_deferred_type_by_VCT` method, which also named that class
59
+ and had no caller, is gone (stage 63, 2026-09-25), as is the plugin's
60
+ empty `helpers.py`.
61
+ - **The item's `type` is rewritten to the builder's `name` at load**
62
+ (stage 63 item 15). The image builder later fetches the builder with
63
+ `ctx.mod_builders.get(mod.get_type())`, a map keyed by builder NAME
64
+ only, so an alias must not survive the load. An item declared
65
+ `type: bash` (the fixture builder's alias) is loaded as
66
+ `type: bash-remote`. Before 2026-09-25 the alias was kept, and
67
+ generation stopped with
68
+ `AssertionError: No mod builder bash found in context for <image>`.
69
+ - An `executables:` entry named `bash` is version-checked by
70
+ `BashVersionChecker` (regex `.*version\s+([\d\.]+)` over
71
+ `bash --version`).
72
+
73
+ ## Models
74
+
75
+ ### `BashBuilderModel` and `BashModBuilderModel` (`bash-remote`)
76
+
77
+ Defined in [bash_models.py](src/cs_image_system/bash_mod_plugin/bash_models.py).
78
+ `BashBuilderModel` extends
79
+ [`ModBuilderModel`](../base/src/cs_image_system/base/models/mod_builder.py),
80
+ which adds nothing to
81
+ [`BuilderModel`](../base/src/cs_image_system/base/models/builder_model.py)
82
+ (`name`, `type`, `description`, `aliases`, `executable`, `is_default`,
83
+ `config`, `gitignore`, `tags`). `BashModBuilderModel` is the registered
84
+ subclass; it adds no fields.
85
+
86
+ | Field | Type | Default | Meaning |
87
+ |---|---|---|---|
88
+ | `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`. |
89
+ | `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. |
90
+ | `environment_vars` | `list[str]` | `[]` | Emitted as `environment_vars = [...]` on every shell provisioner. |
91
+ | `expect_disconnect` | `bool` | `False` | Emits `expect_disconnect = true` on every shell provisioner. |
92
+
93
+ `extra_arguments` is not a field (stage 63): it was declared and never
94
+ read, so it is removed, and a builder that still declares it fails to load
95
+ with the unknown-key refusal. Use `execute_command` to change the
96
+ invocation.
97
+
98
+ Base fields this plugin constrains: `executable` should name the `bash`
99
+ executable (the fixture does), though the emission does not invoke it;
100
+ Packer runs the shell on the build VM. `parameters:` is refused at load (a
101
+ base-model rule). `config:` on the builder is carried but never read.
102
+
103
+ ### `BashModItemModel`
104
+
105
+ Same file. Extends
106
+ [`ModItemModel`](../base/src/cs_image_system/base/models/moditem_type.py)
107
+ (`SubRootItem` -> `RootItem` -> `NameTyped`).
108
+
109
+ | Field | Type | Default | Meaning |
110
+ |---|---|---|---|
111
+ | `script` | `list[str]` | `[]` | Inline command lines, run in order in one `provisioner "shell"` (`inline = [...]`). Literal shell; no templating. Blank entries are dropped. |
112
+ | `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. |
113
+ | `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. |
114
+
115
+ Validation at load: at least one of `script`, `scripts`, `ensure` must be
116
+ non-empty, otherwise `ValueError` ("must supply 'script' (inline lines),
117
+ 'scripts' (script files) and/or 'ensure' (declarative, idempotent)").
118
+
119
+ Derived behaviour:
120
+
121
+ - `idempotent`: `declared` when only `ensure` is given, `unknown`
122
+ otherwise. This is what the lineage record and the local bundle's
123
+ `MANIFEST.yaml` report.
124
+ - `ensure_lines()`: guarded shell for the declarative form, so re-running
125
+ is a no-op. Packages: `rpm -q` or `dpkg -s` first; only when missing,
126
+ install with the FIRST of `dnf`, `yum`, `apt-get` that exists on the
127
+ host, and stop the bake if that one fails (the others are never tried).
128
+ With none of the three, the line says so and fails. Files: content is base64-embedded,
129
+ decoded to a temp file, and `install -m <mode>` runs only when `cmp`
130
+ says the target differs. Services: `systemctl enable` / `start` only when
131
+ not already enabled / active. Commands: `( <unless> ) || { <run>; }`, or
132
+ the bare `run` when there is no `unless`.
133
+ - `remap_self_with_copied_assets(copied)`: replaces each `scripts` entry
134
+ with its copied path when present in the mapping.
135
+
136
+ Inherited item fields:
137
+
138
+ | Base field | From | Default | Use in this plugin |
139
+ |---|---|---|---|
140
+ | `name` | `NameTyped` | required | Names the provisioner comment and the local bundle directory (`NN-<name>`). |
141
+ | `type` | `ModItemModel` | `default` | Foreign key to a `mod_builders:` entry (see above). |
142
+ | `description`, `aliases`, `tags` | `NameTyped` / `RootItem` | | Carried; not emitted. |
143
+ | `config` | `RootItem` | `{}` | Not read by this plugin. It takes part in the lineage content hash of the modification and nothing else. |
144
+ | `_model_id` | `ModItemModel` | set at load | The owning image's global id; `identified_model` resolves to the image. |
145
+
146
+ ### A modification item with only `config:`
147
+
148
+ An item that declares `config:` and none of `script`, `scripts`, `ensure`
149
+ does not load: `BashModItemModel.__post_init__` raises the `ValueError`
150
+ above, so the configuration fails at load and nothing is generated. `config:`
151
+ alone is never a modification; nothing in this plugin reads it.
152
+
153
+ ## The builder
154
+
155
+ `BashModBuilder`
156
+ ([bash_builder.py](src/cs_image_system/bash_mod_plugin/bash_builder.py))
157
+ extends
158
+ [`ModBuilderBase`](../base/src/cs_image_system/base/basic/builder_base_mod.py).
159
+ A modification builder implements the three per-item hooks the image
160
+ builder calls while it writes a build file; it does not implement the
161
+ lifecycle-level hooks (`generate_items_before/during/after`,
162
+ `get_commands_to_run_*`, `pre/post_finalize_phase`), so it emits no files
163
+ of its own, runs no generation-time commands and defers nothing to a
164
+ `run-<lifecycle>.sh`.
165
+
166
+ For every modification of an instance image, the Packer image builder
167
+ calls, in order:
168
+
169
+ 1. `copy_external_assets(block_dir, mod)`: copies each of the **item's**
170
+ `scripts` to `<block_dir>/<script path>` and returns
171
+ `{script: script path}`. The source is resolved against the process's
172
+ current working directory; the configuration load has already changed
173
+ directory to the configuration root (`--root-dir`, else
174
+ `working_directory` in `cfg/_config.yml`, else `.`), so in practice the
175
+ path is root-relative. A missing file raises `FileNotFoundError`; an
176
+ absolute path is not copied and is left in place.
177
+ 2. `mod.remap_self_with_copied_assets(mapping)` on the item. Because the
178
+ mapping's value is the same relative path the copy was placed at, the
179
+ item's `scripts` entries keep their text; the emitted `scripts = [...]`
180
+ is relative to the Packer root, where the copy now lies.
181
+ 3. `generate_items_before_modification`: returns an empty asset set.
182
+ 4. `generate_items_during_modification(image, mod, image_builder, phase, build_path)`:
183
+ - `inline` = `ensure_lines()` followed by the `script` lines; `scripts`
184
+ = the script files. When both lists are empty it logs a warning and
185
+ emits nothing; since stage 63 item 16 an all-empty `ensure` is
186
+ refused at load, so an item can no longer reach this.
187
+ - Appends `# Modifications for <item name> of type <item type> (shell)`.
188
+ - Then, because Packer's shell provisioner takes either `scripts` or
189
+ `inline` but never both, one or two `provisioner "shell"` blocks:
190
+ first `scripts = ["..."]` when there are script files, then
191
+ `inline = [ "...", ]` when there are inline lines. Each block carries
192
+ `only = ["<source type>.<image>"]` and, when set on the builder model,
193
+ `execute_command`, `environment_vars` and `expect_disconnect = true`.
194
+ The `execute_command` written is the model's
195
+ `effective_execute_command()`: the declared `execute_command`, else,
196
+ with a `configuration_user`, `chmod +x {{ .Path }}; sudo su - <user>
197
+ -c '{{ .Vars }} {{ .Path }}'` (Packer's `{{ .Vars }}`, the
198
+ environment variables, travel inside the command because `su -`
199
+ starts a fresh login environment), else none (stage 63).
200
+ - Every string is rendered as an HCL literal: backslashes and double
201
+ quotes are escaped, and `${` / `%{` become `$${` / `%%{` so shell text
202
+ such as `dpkg-query -f='${Package}'` survives Packer's template parser.
203
+ 5. `generate_items_after_modification`: returns an empty asset set.
204
+
205
+ The provisioners run on the build VM when the image builder's deferred
206
+ `packer build .` executes. Separately, the Packer plugin's local
207
+ modification bundle records the item: its directory
208
+ `csis-mods/<image>/NN-<name>/` receives a copy of each script file, an
209
+ `inline.sh` holding the item's guarded `ensure` lines followed by its
210
+ `script` lines (with `set -eu`; the same order the bake runs them), a
211
+ `run.sh` that runs `sh '<script>'` per script file and then
212
+ `sh ./inline.sh`, and a `MANIFEST.yaml` with `operation: bash` and the
213
+ item's `idempotent` value. `csis-mods rerun` on the image therefore
214
+ re-applies the declarative `ensure` form too (stage 63). Until 2026-09-25
215
+ the `ensure` lines were not in the bundle and a rerun replayed only the
216
+ script files and the `script` lines.
217
+
218
+ ## Emission
219
+
220
+ Nothing under `generated/` belongs to this plugin alone; its output is
221
+ embedded in the Packer plugin's files. From the golden emission for the
222
+ `derivative-setup` item of `imgfile-basic-dask` in
223
+ [pckr-ebs-ans-image-generation-block-000-build.pkr.hcl](../../tests/fixtures/v2_golden/generated/instance-image/pckr-ebs-ans/image-generation/block-000/pckr-ebs-ans-image-generation-block-000-build.pkr.hcl):
224
+
225
+ ```hcl
226
+ # Modifications for derivative-setup of type bash-remote (shell)
227
+ provisioner "shell" {
228
+ only = ["amazon-ebs.imgfile-basic-dask"]
229
+ scripts = ["mod_image.sh"]
230
+ }
231
+ provisioner "shell" {
232
+ only = ["amazon-ebs.imgfile-basic-dask"]
233
+ inline = [
234
+ "for p in git; do rpm -q \"$p\" >/dev/null 2>&1 || dpkg -s \"$p\" >/dev/null 2>&1 || if command -v dnf >/dev/null 2>&1; then ... fi || exit 1; done",
235
+ "printf '%s' 'dmVyc2lvbj0xLjAuMAo=' | base64 -d > /tmp/.csis-ensure && ( sudo cmp -s /tmp/.csis-ensure '/etc/derivative.conf' || sudo install -m 0644 /tmp/.csis-ensure '/etc/derivative.conf' ) && rm -f /tmp/.csis-ensure",
236
+ "( test -d /opt/derivative/data ) >/dev/null 2>&1 || { sudo mkdir -p /opt/derivative/data; }",
237
+ "echo 'derivative setup'",
238
+ "sudo mkdir -p /opt/derivative && echo 1.0.0 | sudo tee /opt/derivative/VERSION",
239
+ ]
240
+ }
241
+ ```
242
+
243
+ The three `ensure` lines (package, file, command) come first, then the two
244
+ `script` lines. Files it causes to exist beside the Packer root
245
+ (`generated/instance-image/pckr-ebs-ans/image-generation/block-000/`):
246
+
247
+ | File | Origin |
248
+ |---|---|
249
+ | `mod_image.sh` | The item's script file, copied by `copy_external_assets`. |
250
+ | [csis-mods/imgfile-basic-dask/02-derivative-setup/](../../tests/fixtures/v2_golden/generated/instance-image/pckr-ebs-ans/image-generation/block-000/csis-mods/imgfile-basic-dask/02-derivative-setup/MANIFEST.yaml) `MANIFEST.yaml`, `run.sh`, `inline.sh`, `mod_image.sh` | The local bundle entry the Packer plugin writes for this item (`operation: bash`, `idempotent: unknown`, `files: [mod_image.sh, inline.sh]`). |
251
+
252
+ The GCE builder's root
253
+ ([pckr-gce-ans/.../block-000](../../tests/fixtures/v2_golden/generated/instance-image/pckr-gce-ans/image-generation/block-000/pckr-gce-ans-image-generation-block-000-build.pkr.hcl))
254
+ carries the same two blocks with `only = ["googlecompute.imgfile-basic-dask"]`.
255
+
256
+ ## Example configuration
257
+
258
+ The builder, from
259
+ [tests/fixtures/config/cfg/mod-builders.yml](../../tests/fixtures/config/cfg/mod-builders.yml):
260
+
261
+ ```yaml
262
+ mod_builders:
263
+ - name: bash-remote
264
+ aliases:
265
+ - bash
266
+ is_default: false
267
+ type: bash-remote
268
+ executable: bash
269
+ config:
270
+ some_unknown_mod_builder_setting: "{{ ENV.USER }} and some_value"
271
+ ```
272
+
273
+ The item that uses it, from
274
+ [tests/fixtures/config/images/image1.yaml](../../tests/fixtures/config/images/image1.yaml)
275
+ (the enclosing image is `imgfile-basic-dask`; comments removed):
276
+
277
+ ```yaml
278
+ modifications:
279
+ - name: derivative-setup
280
+ type: bash-remote
281
+ script:
282
+ - "echo 'derivative setup'"
283
+ - "sudo mkdir -p /opt/derivative && echo 1.0.0 | sudo tee /opt/derivative/VERSION"
284
+ scripts:
285
+ - mod_image.sh
286
+ ensure:
287
+ packages: [git]
288
+ files:
289
+ - path: /etc/derivative.conf
290
+ content: "version=1.0.0\n"
291
+ mode: "0644"
292
+ commands:
293
+ - run: "sudo mkdir -p /opt/derivative/data"
294
+ unless: "test -d /opt/derivative/data"
295
+ config:
296
+ derivative_version: "1.0.0"
297
+ ```
298
+
299
+ The script path is relative to the configuration root
300
+ ([tests/fixtures/config/mod_image.sh](../../tests/fixtures/config/mod_image.sh)).
301
+ Note the quoted `mode: "0644"`. An unquoted `0644` also works: YAML reads
302
+ it as the integer 420, and the loader renders an integer back in octal
303
+ (`install -m 0644`). What does NOT work is an unquoted `644` without the
304
+ leading zero: YAML reads the decimal number 644, which is no mode a file
305
+ should have, and the loader refuses any integer above `0777` and asks for
306
+ quotes. Quoting is the habit that never needs the rule.
307
+
308
+ ## Prerequisites and integration
309
+
310
+ Nothing beyond the core on the operator's machine, with one declaration the
311
+ core demands and two facts about where the shell actually runs.
312
+
313
+ **The `bash` executable entry.** The builder's `executable:` must name an
314
+ entry in `cfg/executables.yml`
315
+ ([the fixture's](../../tests/fixtures/config/cfg/executables.yml) is
316
+ `name: bash`, `binary: /usr/local/bin/bash`, no `version`), because
317
+ `validate` checks every builder's `executable` against that list and every
318
+ run checks every declared entry (stage 48: the binary must exist as an
319
+ absolute path or a name on `PATH`, `BashVersionChecker` must parse a version
320
+ from `<binary> --version`, and when the entry declares `version:` the parsed
321
+ version must satisfy it). That is the whole extent of the plugin's use of a
322
+ local bash: it is looked at, never run. The fixture declares no floor;
323
+ `GNU bash, version 3.2.57` (macOS `/bin/bash`) and `5.3.15` both parse.
324
+
325
+ **The build VM.** The provisioners execute on the machine Packer boots for
326
+ the bake, reached the way the image builder's source reaches it (ssh over
327
+ Session Manager on AWS, IAP on GCE; see [packer-plugin](../packer-plugin)).
328
+ This plugin holds no credentials and opens no connection of its own. On that
329
+ VM it assumes:
330
+
331
+ - a POSIX `sh` for the inline lines (Packer's shell provisioner runs them
332
+ through its `inline_shebang`, `/bin/sh -e` unless `execute_command` or the
333
+ Packer defaults are changed) and whatever interpreter each script file's
334
+ own shebang names;
335
+ - with `configuration_user` set, that user must exist on the VM, and the
336
+ bake's ssh user must be able to `sudo su - <user>` without a password;
337
+ - passwordless `sudo` for the bake's ssh user: every `ensure` line that
338
+ changes the system runs it through `sudo`;
339
+ - for `ensure.packages`: `rpm` or `dpkg` to query, and `dnf`, `yum` or
340
+ `apt-get` to install, plus network reach to the image's package
341
+ repositories when something is missing;
342
+ - for `ensure.files`: `base64`, `cmp` (diffutils) and `install`
343
+ (coreutils);
344
+ - for `ensure.services`: `systemctl`.
345
+
346
+ **The modification tests.** `just test-mods` applies the same item twice in
347
+ a throwaway container on the operator's machine
348
+ ([mod_tests.py](../base/src/cs_image_system/base/mod_tests.py)); it needs
349
+ `docker` on `PATH` and a daemon that answers `docker info`, no cloud
350
+ session. The container gets a `sudo` shim when it has none, so the `sudo`
351
+ in `ensure` lines works there too.
352
+
353
+ **How the plugin finds things.** The builder by the item's `type:` (the
354
+ builder's name); the item class by the builder's `type: bash-remote`; the
355
+ `bash` entry by the builder's `executable:`; script files by path relative
356
+ to the configuration root (the working directory the load selects from
357
+ `--root-dir`, else `cfg/_config.yml`'s `working_directory`, else `.`); the
358
+ build VM through the image builder that hosts the emission. No environment
359
+ variable, profile or file of its own.
360
+
361
+ ## Configuration reference
362
+
363
+ ### The builder (`cfg/mod-builders.yml`, `type: bash-remote`)
364
+
365
+ Every key the model accepts. An unknown key is refused at load
366
+ (`extra="forbid"` in the shared model config), and `parameters:` is refused
367
+ by name (retired in stage 26).
368
+
369
+ | Field | Type | Default | Meaning |
370
+ |---|---|---|---|
371
+ | `name` | str | required | The builder's name; what an item's `type:` must say. Characters `/` and `\` are refused; `default` is refused as a name. |
372
+ | `type` | str | required | `bash-remote`. Selects this plugin's model and builder. |
373
+ | `description` | str or null | `null` | Free text. Accepted, not read. |
374
+ | `aliases` | list[str] | `[]` | Extra names an item's `type:` may use; the loader rewrites them to the builder's `name`. |
375
+ | `executable` | str or null | `null` | Must name an `executables:` entry (the fixture: `bash`), or `validate` fails. Never invoked by this plugin. |
376
+ | `is_default` | bool | `false` | Makes this the builder an item with `type: default` (or no `type`) resolves to. |
377
+ | `config` | mapping | `{}` | Accepted, not read. |
378
+ | `gitignore` | list[str] | `[]` | Accepted by the base model; nothing in this plugin reads it. |
379
+ | `tags` | mapping | `{}` | Accepted, not read. |
380
+ | `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`. |
381
+ | `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`). |
382
+ | `environment_vars` | list[str] | `[]` | Emitted as `environment_vars = [...]` on every shell block. |
383
+ | `expect_disconnect` | bool | `false` | When true, `expect_disconnect = true` on every shell block. |
384
+ | `extra_arguments` | | | Refused at load as an unknown key (removed in stage 63; it was accepted and never read). Use `execute_command`. |
385
+
386
+ ### The item (an image's `modifications:` entry whose `type:` names this builder)
387
+
388
+ | Field | Type | Default | Meaning |
389
+ |---|---|---|---|
390
+ | `name` | str | required | Unique within the image. Names the comment line, the bundle directory `NN-<name>` and the lineage record. |
391
+ | `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). |
392
+ | `description` | str or null | `null` | Accepted, not read. |
393
+ | `aliases` | list[str] | `[]` | Accepted, not read. |
394
+ | `tags` | mapping | `{}` | Accepted, not read. |
395
+ | `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. |
396
+ | `script` | list[str] | `[]` | Inline lines, literal, in order, after the `ensure` lines. Blank and whitespace-only entries are dropped. |
397
+ | `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. |
398
+ | `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"). |
399
+
400
+ The `ensure` keys:
401
+
402
+ | Key | Type | Default | Meaning |
403
+ |---|---|---|---|
404
+ | `packages` | list[str] | `[]` | One `for` line: each name is queried with `rpm -q` then `dpkg -s`; only when both fail, installed with `dnf -y`, else `yum -y`, else `apt-get install -y`, under `sudo`. |
405
+ | `files` | list of mappings | `[]` | One line per entry: `path` (required), `content` (default empty), `mode` (default `0644`, quote it). Content is base64-embedded in the build file, decoded to `/tmp/.csis-ensure` and installed with `sudo install -m <mode>` only when `sudo cmp -s` says the target differs. |
406
+ | `services` | list[str] | `[]` | Two lines per unit: `sudo systemctl enable` unless already enabled, `sudo systemctl start` unless already active. |
407
+ | `commands` | list of mappings | `[]` | One line per entry: `run` (required) and optional `unless`. With `unless`: `( <unless> ) >/dev/null 2>&1 \|\| { <run>; }`. Without: the bare `run`, every time. |
408
+
409
+ ### Variations
410
+
411
+ - **`script` only** is one `provisioner "shell"` with `inline`. **`scripts`
412
+ only** is one block with `scripts`. **Both** are two blocks, the files
413
+ first, because Packer refuses both arguments in one provisioner.
414
+ - **`ensure` only** is recorded as `idempotent: declared` (lineage and the
415
+ bundle manifest); **`ensure` with `script` or `scripts`** is
416
+ `idempotent: unknown`, the same as free-form alone. `ensure` lines always
417
+ precede the `script` lines in the inline block.
418
+ - **`ensure` whose lists are all empty** (`ensure: {packages: []}`) is
419
+ refused at load (stage 63 item 16). Before 2026-09-25 it passed load,
420
+ counted as `declared`, and emitted nothing: the builder logged
421
+ `Bash modification <name> has no script lines or files; nothing emitted`
422
+ and the bake proceeded without a provisioner for it. The bundle directory
423
+ still exists, with a `run.sh` that runs nothing.
424
+ - **`commands` with `unless`** is guarded; **without `unless`** the command
425
+ runs on every bake and on every `csis-mods rerun`, so the item is
426
+ idempotent only if the command is.
427
+ - **A relative script path** is copied to the same relative path beside the
428
+ Packer root and emitted relative; **an absolute path** is emitted
429
+ absolute and Packer reads it from the operator's machine at build time.
430
+ - **`execute_command` set** puts that template on every block of every
431
+ item of the builder; **`configuration_user` set** instead puts
432
+ `chmod +x {{ .Path }}; sudo su - <user> -c '{{ .Vars }} {{ .Path }}'`
433
+ there, so the lines run as that user (stage 63); **both set** is refused
434
+ at load; **neither**, Packer's default applies and the lines run as the
435
+ bake user. The same for
436
+ `environment_vars` (emitted only when non-empty) and `expect_disconnect`
437
+ (emitted only when true).
438
+ - **`type: <builder name>`** reaches this plugin. **`type: default` or no
439
+ `type`** reaches whichever mod builder is `is_default: true`; in the
440
+ fixture that is `ansible-default`, so the item is loaded as an ANSIBLE
441
+ item and its `script`/`scripts`/`ensure` keys are refused as unknown.
442
+ With no `is_default: true` mod builder at all, the load is refused
443
+ (stage 63): `No default builder found in mod_builder list.` first, and
444
+ the orchestrator's `` modification '<name>' names no builder (`type`) ... ``
445
+ for an item that reaches it anyway. Until 2026-09-25 such an item
446
+ became an `AttributeError` at generation.
447
+ **`type: <alias>`** behaves exactly as the builder's name: the loader
448
+ rewrites it.
449
+ - **AWS versus GCE runtime**: the lines are identical; only the `only`
450
+ label differs (`amazon-ebs.<image>` versus `googlecompute.<image>`), and
451
+ the same blocks land in every image builder's root the image bakes on.
452
+ - **Dry run versus real run**: generation is the same, so the build file,
453
+ the copied scripts and the bundle appear under `generated/` either way.
454
+ A dry run never runs `packer build`, so nothing executes on a VM and no
455
+ lineage record is written. `--no-apply` generates and skips
456
+ finalization too.
457
+ - **Base image versus instance image**: a base image's `modifications:`
458
+ are ignored with a warning by the image builder; only instance images
459
+ reach this plugin.
460
+ - **Bake versus `test-mods`**: in the bake, the inline lines run under
461
+ Packer's `/bin/sh -e` on the VM; under `test-mods` the same lines (ensure
462
+ lines included) are written to an `inline.sh` with `set -eu` and run with
463
+ `sh` inside the container, after each script file, as root behind a
464
+ `sudo` shim. A line that passes in one and fails in the other usually
465
+ differs on `sudo`, on `$HOME`, or on a package repository the container
466
+ cannot reach.
467
+
468
+ ## What it tests and verifies
469
+
470
+ **At load** (pydantic validators and `__post_init__`, before any command
471
+ does anything). Unknown keys on the builder or the item; `parameters:` on
472
+ the builder; `/` or `\` in a name or alias; `ensure` keys outside
473
+ `packages`, `files`, `services`, `commands`; every `ensure` entry's shape
474
+ (`packages`/`services` a list of non-empty names, `files` entries with a
475
+ `path` and only `path`/`content`/`mode`, `commands` entries with a `run`
476
+ and only `run`/`unless`, a mode of three or four octal digits, an
477
+ integer mode no higher than `0777`, and not every kind empty); an item
478
+ with none of `script`, `scripts`, `ensure`; an item `type:` that names no
479
+ builder; an item with no `type:` when no mod builder is the default; a
480
+ builder declaring both `configuration_user` and `execute_command`, or
481
+ the removed `extra_arguments` (stage 63). Each is a
482
+ `ValueError` (or `KeyError` for the type) that stops the load; the CLI
483
+ prints it and exits 1. Nothing loads, so nothing is generated.
484
+
485
+ **At `validate`** ([validate.py](../base/src/cs_image_system/base/commands/validate.py)).
486
+ The builder's `executable:` must name an `executables:` entry; the `bash`
487
+ entry's binary must exist, its version must parse and, when declared, meet
488
+ the requirement. Every failure is collected by name and printed; `validate`
489
+ exits 1 and `run` refuses before generation. One INFO line lists every
490
+ checked tool and the version found (`bash 5.3.15 (no requirement)` for the
491
+ fixture).
492
+
493
+ **At generation.** Two checks, both fatal to the run: a `scripts` entry
494
+ that is not a file (`FileNotFoundError`), and the assertion that the item's
495
+ `type` is a builder name. Neither is a validation error; the run summary
496
+ reports `ok: false` with an empty validation list and the traceback is in
497
+ the run log. The plugin does not validate the HCL it writes; the Packer
498
+ plugin's run-now commands (`packer fmt`, `init`, `validate`) do, and a
499
+ malformed block fails there before any bake. An empty emission is a WARNING
500
+ line, not a failure.
501
+
502
+ **At apply.** Nothing by this plugin. Packer runs the blocks in order; the
503
+ inline lines run under `-e`, so the first failing line aborts the
504
+ provisioner and the build, no image is produced and no lineage record is
505
+ written. The `ensure` lines are the only ones the plugin vouches for as
506
+ safe to re-run.
507
+
508
+ **After apply.** The Packer plugin's post-finalize hook records every
509
+ modification of the built image in `meta-state/lineage.yaml`
510
+ ([lineage.py](../base/src/cs_image_system/base/lineage.py) `mod_records`):
511
+ `operation: bash`, `idempotent` (`declared` or `unknown`), and a
512
+ `content_hash` over the item's type, `config`, `script` lines, the CONTENT
513
+ of each script file and the `ensure` mapping. That hash feeds the image's
514
+ `input_fingerprint`, which is what makes an edited modification re-bake the
515
+ image on the next run. The on-image bundle under `/opt/csis/mods/` carries
516
+ the same record.
517
+
518
+ **In `test-mods`** ([mod_tests.py](../base/src/cs_image_system/base/mod_tests.py)).
519
+ The item is applied twice in a container of the image's OS family. Run one
520
+ must exit 0; run two must exit 0 and change no file outside the volatile
521
+ paths (`docker diff`). The verdict lands in the live configuration's
522
+ `meta-state/mod-tests.yaml`, keyed by the content hash, as
523
+ `status: pass|fail|skipped|unsupported` and `idempotent: true|false`; a
524
+ failure prints `FAIL <image>/<mod>: rc=...` on stderr and exits 1. A
525
+ `release` of a build whose recorded modification has `fail` or
526
+ `idempotent: false` is refused; a missing record is tolerated unless
527
+ `config.require_mod_tests` is true.
528
+
529
+ **In the state query.** Nothing. The plugin owns no provider resource, so
530
+ `state query` has nothing of its to diff.
531
+
532
+ ## When it fails
533
+
534
+ Failures that have happened, in date order:
535
+
536
+ - **2026-08-27, three defects found in the pre-V2 builder**
537
+ ([LEDGER](../../docs/history/LEDGER.md), "Finding 2026-08-27"). Every
538
+ item resolved to the default mod builder (the lookup used the field name
539
+ `modifications`, not the item's `type`), so a bash item was recorded as
540
+ `ansible-default` with no content; the bash builder wrote raw lines and an
541
+ `exit 0` straight into the build file, so any real bash item failed
542
+ `packer validate`; and `scripts:` was not a field, so files were dropped.
543
+ Fixed at gate 8; guarded by
544
+ [test_v2_gate8_modifications.py](../../tests/test_v2_gate8_modifications.py)
545
+ and [tests/test_bash_provisioner.py](tests/test_bash_provisioner.py). If
546
+ a comment line ever appears outside a `provisioner "shell" {` block, or
547
+ `exit 0` appears in a build file, this regressed.
548
+ - **2026-09-10, the first `full-test`'s mod-test leg could not start a
549
+ container** for an EL 10 image: Docker Hub's `rockylinux` library stops at
550
+ 9. The stand-in for `rhel` 10 and above is `almalinux:<version>` since
551
+ (`test_image_for` in [mod_tests.py](../base/src/cs_image_system/base/mod_tests.py)).
552
+ Symptom then: `docker run` failed to pull and the bash item's test never
553
+ ran. If `test-mods` reports `unsupported` with
554
+ `no local test image for the root OS family`, the family/version has no
555
+ stand-in; set `local_test_image` on the OS builder.
556
+ - **Found live, undated, in the sibling shell emitter for image tests**
557
+ ([v2_provisioners.py](../packer-plugin/src/cs_image_system/packer_plugin/v2_provisioners.py)):
558
+ an `rpm --qf '%{NAME}'` line broke `packer fmt`, because `%{` and `${`
559
+ open Packer template directives. This plugin's `_hcl_string` escapes both
560
+ the same way. If `packer fmt` or `validate` ever reports
561
+ `Invalid template directive` or `Invalid template interpolation` on a
562
+ line you wrote, the escape regressed; the line in `generated/` should
563
+ read `%%{` / `$${`.
564
+ - **2026-09-20, an assertion that could not fail** (image `tests:`, not this
565
+ plugin, but the same shell and the same trap;
566
+ [OPERATIONS.md](../../docs/OPERATIONS.md), "What a green bake proves").
567
+ Under `sh -e`, a command inside an AND-OR list other than the last does
568
+ not abort the script, so a `script:` line written `check || { echo no; }`
569
+ never fails the build however false `check` is. Two bakes went green
570
+ asserting a package that did not exist. If a `script` line must be able
571
+ to stop the bake, end it in something that exits nonzero (`|| exit 1`),
572
+ not in a braced group that returns 0. The plugin's own `commands` form,
573
+ `( unless ) || { run; }`, is deliberate: the group is the LAST element,
574
+ so a failing `run` does abort.
575
+ - **2026-09-23, an item declared with the builder's alias failed at
576
+ generation** (found while writing this README, on a copy of the fixture
577
+ with `type: bash` on `derivative-setup`). Load and `validate` passed; the
578
+ run log then showed
579
+ `AssertionError: No mod builder bash found in context for imgfile-basic-dask`
580
+ and the summary was `ok: false` with no validation errors. Fixed by
581
+ stage 63 item 15 (2026-09-25): the loader rewrites the alias to the
582
+ builder's name, and `tests/test_v2_defects_loading.py` generates that
583
+ same fixture copy.
584
+
585
+ Failures the code raises that have not been seen outside tests:
586
+
587
+ | What you see | Where | Meaning and what to do |
588
+ |---|---|---|
589
+ | `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. |
590
+ | `Bash modification '<name>': unknown ensure keys ['users']` | load; CLI exit 1 | Only `packages`, `files`, `services`, `commands` exist. Move anything else to `commands`. |
591
+ | 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. |
592
+ | ``<builder>: `parameters` was retired (stage 26) ...`` | load; CLI exit 1 | Delete `parameters:` from the builder. |
593
+ | `KeyError: Could not resolve builder '<type>' referenced in Image(...)` | load; CLI exit 1 | The item's `type:` names no `mod_builders:` entry. |
594
+ | `` 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`. |
595
+ | 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`. |
596
+ | `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). |
597
+ | `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:`. |
598
+ | `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:`. |
599
+ | ``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. |
600
+ | `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. |
601
+ | `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`. |
602
+ | `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`. |
603
+ | `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. |
604
+ | `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]`). |
605
+ | `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"`. |
606
+ | `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. |
607
+ | `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. |
608
+ | `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. |
609
+ | `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. |
610
+ | `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. |
611
+ | `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). |
612
+ | `Build 'amazon-ebs.<image>' errored ...` and no new build in `meta-state/lineage.yaml` | apply (`run-instance-image.sh`, `set -euo pipefail`) | A line exited nonzero under `-e`. Packer's log names the provisioner by its position; match it to the `# Modifications for <name>` comment in the build file under `generated/`. Nothing is recorded for a failed bake; fix and re-run. |
613
+ | `FAIL <image>/<mod>: rc=1/None idempotent=None <tail of output>` | `test-mods`; exit 1 | Run one failed in the container. The output tail is the last 2000 characters; `meta-state/mod-tests.yaml` keeps it under the content hash. |
614
+ | `FAIL <image>/<mod>: rc=0/0 idempotent=False ['A /opt/x', ...]` | `test-mods`; exit 1 | Run two changed files. A `script` line that appends, or a `commands` entry without `unless`. Make it `ensure`, or add `unless`. |
615
+ | `mod tests: N tested, M skipped` with `docker is not available` in the JSON | `test-mods`; exit 1 only with `--strict` | Start docker. `just full-test` runs this leg with `--strict`. |
616
+ | `modification '<name>' of build '<id>' failed its local mod test (status=fail, idempotent=...)` | `release`; exit 1 | Fix the modification, re-bake, re-test. A release never overrides a failed record. |
617
+ | `modification '<name>' of build '<id>' has no local mod-test result (config.require_mod_tests)` | `release`; exit 1 | Run `just test-mods` on the live tree and commit `meta-state/mod-tests.yaml`. |
618
+ | `local mods: <src> for <name> not found; the bundle will lack it` | generation log (Packer plugin) | The bundle could not find a script file under the block dir, the working path or as given; the bake still emits the provisioner. Usually the same cause as the `FileNotFoundError` above, seen when the file was removed between copy and bundle. |
619
+
620
+ ## Related
621
+
622
+ - Manuals: [CONFIGURATION.md](../../docs/CONFIGURATION.md) sections 3 and
623
+ 7 (executables, mod builders, the modification item),
624
+ [OPERATIONS.md](../../docs/OPERATIONS.md) (the modification tests, what a
625
+ green bake proves), [DESIGN.md](../../docs/DESIGN.md) M2 (the bash mod
626
+ contract).
627
+ - Base models extended:
628
+ [mod_builder.py](../base/src/cs_image_system/base/models/mod_builder.py),
629
+ [moditem_type.py](../base/src/cs_image_system/base/models/moditem_type.py),
630
+ [root_item.py](../base/src/cs_image_system/base/models/root_item.py),
631
+ [builder_model.py](../base/src/cs_image_system/base/models/builder_model.py).
632
+ - Base builder class:
633
+ [builder_base_mod.py](../base/src/cs_image_system/base/basic/builder_base_mod.py);
634
+ the label helper `build_target_label_of` in
635
+ [builder_base_image.py](../base/src/cs_image_system/base/basic/builder_base_image.py);
636
+ the version checker base in
637
+ [abstract_version_checker.py](../base/src/cs_image_system/base/basic/abstract_version_checker.py).
638
+ - The image builder that calls it and hosts its output:
639
+ [packer-plugin](../packer-plugin)
640
+ ([packer_ebs_builder.py](../packer-plugin/src/cs_image_system/packer_plugin/packer_ebs_builder.py),
641
+ [v2_local_mods.py](../packer-plugin/src/cs_image_system/packer_plugin/v2_local_mods.py)).
642
+ - Lineage records for modifications:
643
+ [lineage.py](../base/src/cs_image_system/base/lineage.py) (`mod_records`);
644
+ the release gate in [release.py](../base/src/cs_image_system/base/release.py).
645
+ - Tests: [tests/test_bash_provisioner.py](tests/test_bash_provisioner.py)
646
+ (two blocks for files plus inline, builder options, script copying);
647
+ [test_v2_gate8_modifications.py](../../tests/test_v2_gate8_modifications.py)
648
+ (both kinds emitted, unknown type refused, the load rule);
649
+ [test_v2_explore_local_mods.py](../../tests/test_v2_explore_local_mods.py)
650
+ (`ensure` guards, idempotence recorded, the bundle);
651
+ [test_v2_explore_mod_tests.py](../../tests/test_v2_explore_mod_tests.py)
652
+ (the container tests on the fixture's bash item);
653
+ [test_v2_hygiene_three.py](../../tests/test_v2_hygiene_three.py)
654
+ (`BashVersionChecker` found by name, versions judged).
655
+ - No terraform modules and no hashicorp-utils usage: the plugin writes
656
+ Packer provisioner lines directly.
@@ -0,0 +1,10 @@
1
+ cs_image_system/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ cs_image_system/bash_mod_plugin/__init__.py,sha256=GAFRNaGWQxXquHOlCzxan4LkPZ9_NsiyswknxdPcX0c,122
3
+ cs_image_system/bash_mod_plugin/bash_builder.py,sha256=RUi1S-BMnkAOksXOXwx4Keq0rozxYgGgsJqVFMQgVQU,6245
4
+ cs_image_system/bash_mod_plugin/bash_models.py,sha256=9Pi3NKp7bB1BNecRpKFsGkZUJwzy2KoyMtw7gNdqNGo,11782
5
+ cs_image_system/bash_mod_plugin/main.py,sha256=1pWG7kr7w7ay2Dqzom58U8oRcUXFuf96iLmqVYfnLFA,1101
6
+ cs_image_system_bash_mod_plugin-0.1.1.dist-info/METADATA,sha256=cXnXNyddsxdXKlbP1koR434gjJhSlJDch39lHnR-u8U,44117
7
+ cs_image_system_bash_mod_plugin-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ cs_image_system_bash_mod_plugin-0.1.1.dist-info/entry_points.txt,sha256=yZSr8VNr4qw_sn9RjeR5U2xsuBfmFJok072qjsGx8xQ,101
9
+ cs_image_system_bash_mod_plugin-0.1.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
10
+ cs_image_system_bash_mod_plugin-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [cs_image_system.plugins.modification]
2
+ bash_plugin = cs_image_system.bash_mod_plugin.main:initialize
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.