cs-image-system-dummy-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,152 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+
6
+
7
+ from cs_image_system.base.basic.asset import Asset, AssetSet
8
+ from cs_image_system.base.basic.builder_base_group import GroupBuilderBase
9
+ from cs_image_system.base.basic.builder_base_user import UserBuilderBase
10
+ from cs_image_system.base.lifecycle import ExecutionLifecyclePhase
11
+ from cs_image_system.base.models.executable import ExecutableModel
12
+ from cs_image_system.base.models.executable_adds import CFExecutables
13
+ from cs_image_system.base.models.user import User
14
+
15
+ from .dummy_models import DUMMY, DummyGroupBuilderModel, DummyUserBuilderModel
16
+
17
+
18
+ """
19
+ Group provider implementation via Terraform/Tofu for Dummy groups using Dummy/Dummypam
20
+
21
+ """
22
+
23
+
24
+
25
+ # TODO Specific type configs for DefaultGroupProvider if needed
26
+ class DummyGroupBuilder(GroupBuilderBase[DummyGroupBuilderModel]):
27
+ """Dummy group provider implementation via Terraform/Tofu."""
28
+ @classmethod
29
+ def csis_name(cls) -> str:
30
+ return DUMMY
31
+
32
+
33
+ @property
34
+ def model(self) -> DummyGroupBuilderModel:
35
+ return self._model # type: ignore # FIXME: This is dangerous
36
+
37
+ def generate_items_before(
38
+ self, phase: ExecutionLifecyclePhase
39
+ ) -> AssetSet: # list[tuple[Path, str]]:
40
+ """Generate a list of items to create before a specific execution lifecycle
41
+ event."""
42
+ # items: list[tuple[Path, str]] = []
43
+ items: AssetSet = AssetSet()
44
+ if phase == ExecutionLifecyclePhase.GROUP_GENERATION:
45
+ ...
46
+ return items
47
+
48
+ def get_commands_to_run_before(
49
+ self, phase: ExecutionLifecyclePhase
50
+ ) -> CFExecutables:
51
+ """Get a list of commands to run before a specific execution lifecycle event."""
52
+ return CFExecutables([], [])
53
+
54
+ def generate_items_during(
55
+ self, phase: ExecutionLifecyclePhase
56
+ ) -> AssetSet:
57
+ """Generate the items to create during a specific execution lifecycle
58
+ event: none, as an ``AssetSet`` (the contract's type; the generators
59
+ call ``sort_and_write`` on it, and a bare list broke the identity
60
+ lifecycle for any declared ``type: dummy`` builder -- stage 63 item 1)."""
61
+ return AssetSet()
62
+
63
+ def get_commands_to_run_during(
64
+ self, phase: ExecutionLifecyclePhase
65
+ ) -> CFExecutables:
66
+ """Get a list of commands to run during a specific execution lifecycle event."""
67
+ return CFExecutables([], [])
68
+
69
+ def generate_items_after(
70
+ self, phase: ExecutionLifecyclePhase
71
+ ) -> AssetSet: # list[tuple[Path, str]]:
72
+ """Generate a list of items to create after a specific execution lifecycle event."""
73
+ return AssetSet()
74
+
75
+ def get_commands_to_run_after(
76
+ self, phase: ExecutionLifecyclePhase
77
+ ) -> CFExecutables:
78
+ """Get a list of commands to run after a specific execution lifecycle event."""
79
+ # return ([],[]) # Shortcut!
80
+ commands: list[ExecutableModel] = []
81
+ if phase == ExecutionLifecyclePhase.GROUP_GENERATION:
82
+ ...
83
+ return CFExecutables(commands, [])
84
+
85
+
86
+ # TODO Specific type configs for DefaultGroupProvider if needed
87
+ class DummyUserBuilder(UserBuilderBase[DummyUserBuilderModel]):
88
+ # class DummyUserBuilder(UserBuilderBase[DummyUserBuilderModel, User]):
89
+ """Dummy user provider implementation via Terraform/Tofu."""
90
+
91
+
92
+ @classmethod
93
+ def csis_name(cls) -> str:
94
+ return DUMMY
95
+
96
+
97
+ def query_existing_users(self) -> list[User]:
98
+ return []
99
+
100
+ def generate_items_before(
101
+ self, phase: ExecutionLifecyclePhase
102
+ ) -> AssetSet: # list[tuple[Path, str]]:
103
+ """Generate a list of items to create before a specific execution lifecycle
104
+ event."""
105
+ files: AssetSet = AssetSet()
106
+ if phase == ExecutionLifecyclePhase.USER_GENERATION:
107
+ # stage 63: under the builder's own root like every other builder's
108
+ # (it wrote `Dummy-tf/dummy_users.tf` beside them)
109
+ rpath = self.get_path_for_phase(phase, "users", suffix=".tf")
110
+ files.append(
111
+ Asset((
112
+ rpath,
113
+ f"# Terraform configuration for Dummy users generated by "
114
+ f"{self.__class__.__name__}\n",
115
+ ))
116
+ )
117
+ return files
118
+
119
+ def get_commands_to_run_before(
120
+ self, phase: ExecutionLifecyclePhase
121
+ ) -> CFExecutables:
122
+ """Get a list of commands to run before a specific execution lifecycle event."""
123
+ commands: list[ExecutableModel] = []
124
+ return CFExecutables(commands, [])
125
+
126
+ def generate_items_during(
127
+ self, phase: ExecutionLifecyclePhase
128
+ ) -> AssetSet:
129
+ """Generate the items to create during a specific execution lifecycle
130
+ event: none, as an ``AssetSet`` (stage 63 item 1)."""
131
+ return AssetSet()
132
+
133
+ def get_commands_to_run_during(
134
+ self, phase: ExecutionLifecyclePhase
135
+ ) -> CFExecutables:
136
+ """Get a list of commands to run during a specific execution lifecycle event."""
137
+
138
+ return CFExecutables([], [])
139
+
140
+ def generate_items_after(
141
+ self, phase: ExecutionLifecyclePhase
142
+ ) -> AssetSet: # list[tuple[Path, str]]:
143
+ """Generate a list of items to create after a specific execution lifecycle event."""
144
+ return AssetSet()
145
+
146
+ def get_commands_to_run_after(
147
+ self, phase: ExecutionLifecyclePhase
148
+ ) -> CFExecutables:
149
+ """Get a list of commands to run after a specific execution lifecycle event."""
150
+ commands: list[ExecutableModel] = []
151
+ after: list[ExecutableModel] = []
152
+ return CFExecutables(commands, after)
@@ -0,0 +1,43 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from cs_image_system.base.models.model_config import CSIS_MODEL_CONFIG
6
+ from pydantic.dataclasses import dataclass # stage 23: validation at construction
7
+
8
+
9
+
10
+ from cs_image_system.base.models.group_builder import GroupBuilderModel
11
+ from cs_image_system.base.models.user_builder import UserBuilderModel
12
+
13
+ DUMMY: str = "dummy"
14
+ @dataclass(kw_only=True, config=CSIS_MODEL_CONFIG)
15
+ class DummyGroupBuilderModel(GroupBuilderModel):
16
+ """The template's group builder model: the shape a group plugin's model
17
+ takes, and nothing more.
18
+
19
+ Attributes:
20
+ org, team: two required example fields, validated at load and read
21
+ by nothing -- they show a plugin author how a builder declares
22
+ what its provider needs. (Stage 63: the credential-shaped `key`,
23
+ `secret` and `api_host` were removed, so the template never
24
+ suggests putting a credential in the tree.)
25
+ """
26
+
27
+ org: str
28
+ team: str
29
+
30
+ @classmethod
31
+ def csis_name(cls) -> str:
32
+ return DUMMY
33
+
34
+ @dataclass(kw_only=True, config=CSIS_MODEL_CONFIG)
35
+ class DummyUserBuilderModel(UserBuilderModel):
36
+ """The template's user builder model; ``org`` and ``team`` as on the
37
+ group model: required example fields, read by nothing."""
38
+ org: str
39
+ team: str
40
+
41
+ @classmethod
42
+ def csis_name(cls) -> str:
43
+ return DUMMY
@@ -0,0 +1,47 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Example hook plugin (EXPLORE "Expanding What Else Plugins Could Do").
6
+
7
+ Registered under the ``cs_image_system.plugins.hooks`` entry-point group.
8
+ Proves the two seams: an **on-summary notifier** (appends one JSON line per
9
+ run to the file named by ``CSIS_NOTIFY_FILE``, when set) and a
10
+ **plugin-registered lifecycle** (``notify``, positioned last, no phases:
11
+ it exists so the runner treats it exactly like a built-in -- its own
12
+ directory, hooks, gating).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+
20
+ from cs_image_system.base.commands.run_lifecycles import HookSet
21
+ from cs_image_system.base.lifecycles import LifecycleSpec
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ NOTIFY_FILE_ENV = "CSIS_NOTIFY_FILE"
26
+ # The example lifecycle is opt-in: a registered lifecycle joins `run --all`,
27
+ # and an example must not change the meta-workflow of every real run.
28
+ LIFECYCLE_ENV = "CSIS_DUMMY_LIFECYCLE"
29
+
30
+
31
+ def notify_summary(summary) -> None:
32
+ path = os.environ.get(NOTIFY_FILE_ENV)
33
+ if not path:
34
+ log.debug("dummy notifier: CSIS_NOTIFY_FILE unset; nothing to do")
35
+ return
36
+ with open(path, "a") as f:
37
+ f.write(json.dumps({"run": summary.run_id, "ok": summary.ok,
38
+ "requested": summary.requested, "apply": summary.apply}) + "\n")
39
+ log.info(f"dummy notifier: appended run {summary.run_id} to {path}")
40
+
41
+
42
+ def initialize() -> HookSet:
43
+ lifecycles = []
44
+ if os.environ.get(LIFECYCLE_ENV):
45
+ lifecycles.append(LifecycleSpec(name="notify", after=None,
46
+ description="example plugin lifecycle: no phases, no builders"))
47
+ return HookSet(on_summary=[notify_summary], lifecycles=lifecycles)
@@ -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
+
7
+ from cs_image_system.base.basic.abstract_plugin_metadata import AbstractPluginMetadata
8
+ from cs_image_system.base.protocols.plugin_metadata import PluginMetadataProtocol
9
+ from cs_image_system.dummy_plugin.dummy_builders import DummyGroupBuilder, DummyUserBuilder
10
+ from cs_image_system.dummy_plugin.dummy_models import DUMMY, DummyGroupBuilderModel, DummyUserBuilderModel
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ class DummyPluginMetadata(AbstractPluginMetadata,PluginMetadataProtocol):
15
+ def __init__(self) -> None:
16
+ super().__init__("1", "3.13",
17
+ {
18
+ DUMMY:[
19
+ DummyGroupBuilderModel,
20
+ DummyGroupBuilder,
21
+ DummyUserBuilderModel,
22
+ DummyUserBuilder,
23
+ ]},
24
+ {DummyGroupBuilderModel: DummyGroupBuilder,
25
+ DummyUserBuilderModel: DummyUserBuilder}
26
+ )
27
+
28
+
29
+ def initialize():
30
+ log.debug("Initializing Dummy Plugin")
31
+ return DummyPluginMetadata()
File without changes
@@ -0,0 +1,603 @@
1
+ Metadata-Version: 2.5
2
+ Name: cs-image-system-dummy-plugin
3
+ Version: 0.1.1
4
+ Summary: The extension template and test double: a builder plugin and a hook plugin in their smallest working form
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/dummy-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-dummy-plugin
17
+
18
+ The extension template and test double. It exists to show, in the smallest
19
+ working form, the two ways a package extends the system: a *builder plugin*
20
+ that registers model and builder classes selectable from YAML, and a *hook
21
+ plugin* that participates in the runner without any YAML at all. It
22
+ registers a `dummy` group builder and a `dummy` user builder that generate
23
+ almost nothing, an on-summary notifier, and an optional `notify` lifecycle.
24
+ Nothing in the frozen fixture selects its builders; the runner's tests
25
+ exercise its hooks. Copy it to start a new plugin.
26
+
27
+ Two things to know before copying it. First, the builders are a template
28
+ of the *shape*, not a working example: a declared `type: dummy` builder
29
+ loads, validates and runs the identity lifecycle (since stage 63; until
30
+ 2026-09-24 it failed at generation, see [When it fails](#when-it-fails)),
31
+ but it manages nothing: the group builder writes nothing and the user
32
+ builder writes one comment-only file. The hook plugin is the part the
33
+ tests prove most. Second, the package has no tests of its own (there is no
34
+ `packages/dummy-plugin/tests` directory, and since stage 63 the root
35
+ [pyproject.toml](../../pyproject.toml) no longer lists one among its
36
+ `testpaths`); the repository's
37
+ [tests/test_v2_explore_plugin_hooks.py](../../tests/test_v2_explore_plugin_hooks.py)
38
+ covers the hooks, and
39
+ [tests/test_v2_defects_low_hanging.py](../../tests/test_v2_defects_low_hanging.py)
40
+ and [tests/test_v2_defects_dead.py](../../tests/test_v2_defects_dead.py)
41
+ cover the builders and models.
42
+
43
+ ## What it registers
44
+
45
+ Two entry points are declared in [pyproject.toml](pyproject.toml):
46
+
47
+ ```toml
48
+ [project.entry-points."cs_image_system.plugins.runtime"]
49
+ dummy_plugin = "cs_image_system.dummy_plugin.main:initialize"
50
+
51
+ [project.entry-points."cs_image_system.plugins.hooks"]
52
+ dummy_hooks = "cs_image_system.dummy_plugin.hooks:initialize"
53
+ ```
54
+
55
+ The first is a builder plugin. `initialize()` in
56
+ [main.py](src/cs_image_system/dummy_plugin/main.py) returns a
57
+ `DummyPluginMetadata` whose services map the key `dummy` to four classes
58
+ and whose `builders_for_models` names a builder class per model class.
59
+ The entry-point group is `runtime`, but the group only decides load order:
60
+ the loader ([loader.py](../base/src/cs_image_system/base/loader.py))
61
+ registers each class under the classification its own `csis_classifier()`
62
+ reports.
63
+
64
+ | Class | `csis_name()` | `csis_classifier()` | Registered under |
65
+ |---|---|---|---|
66
+ | `DummyGroupBuilderModel` ([dummy_models.py](src/cs_image_system/dummy_plugin/dummy_models.py)) | `dummy` | `VCT.GROUP_BUILDER_MODEL` | `group_builder_model` |
67
+ | `DummyGroupBuilder` ([dummy_builders.py](src/cs_image_system/dummy_plugin/dummy_builders.py)) | `dummy` | `VCT.GROUP_BUILDER` | `group_builder` |
68
+ | `DummyUserBuilderModel` ([dummy_models.py](src/cs_image_system/dummy_plugin/dummy_models.py)) | `dummy` | `VCT.USER_BUILDER_MODEL` | `user_builder_model` |
69
+ | `DummyUserBuilder` ([dummy_builders.py](src/cs_image_system/dummy_plugin/dummy_builders.py)) | `dummy` | `VCT.USER_BUILDER` | `user_builder` |
70
+
71
+ How a model finds its builder: not through `builders_for_models`. The
72
+ registry ([registry.py](../base/src/cs_image_system/base/registry.py))
73
+ stores that map (and refuses a model class bound twice), but the lookup
74
+ the context performs when it builds a declared builder
75
+ (`Registry.get_builder_for_model(type_name, classification)`) goes by the
76
+ service registry: the class registered under the same `csis_name()` and
77
+ the builder classification. That is why model and builder must report the
78
+ same name. The `builders_for_models` map is registered and read by
79
+ nothing; its one reader, `Registry.get_builder(model_type)`, had no caller
80
+ and is gone (stage 67). The map stays as the template's shape.
81
+
82
+ A `group_builders:` entry selects the group pair with `type: dummy`; a
83
+ `user_builders:` entry selects the user pair with the same key. There are
84
+ no type aliases. The constant `DUMMY = "dummy"` in `dummy_models.py` is the
85
+ key. No other package imports anything from this one. (The AWS and GCE
86
+ runtime plugins used to carry unregistered copies of
87
+ `DummyGroupBuilderModel` left from the template; those copies are gone
88
+ from their source.)
89
+
90
+ The second is a hook plugin. `initialize()` in
91
+ [hooks.py](src/cs_image_system/dummy_plugin/hooks.py) returns a `HookSet`
92
+ (defined in
93
+ [run_lifecycles.py](../base/src/cs_image_system/base/commands/run_lifecycles.py))
94
+ carrying:
95
+
96
+ | Hook | Registered | What it does |
97
+ |---|---|---|
98
+ | `on_summary`: `notify_summary` | always | When the environment variable `CSIS_NOTIFY_FILE` names a file, appends one JSON line per run: `{"run", "ok", "requested", "apply"}`. Unset, it logs at debug level and does nothing. |
99
+ | `lifecycles`: `LifecycleSpec(name="notify", after=None)` | only when `CSIS_DUMMY_LIFECYCLE` is set | A lifecycle with no phases and no builders, positioned last. It gets its own `generated/notify/` directory, joins `run --all`, and is a no-op at apply time because it defers nothing. |
100
+
101
+ The `HookSet` also accepts `validators`, `after_generate`, `before_apply`
102
+ and `after_apply` lists; the dummy plugin leaves them empty.
103
+
104
+ ## Models
105
+
106
+ ### `DummyGroupBuilderModel`
107
+
108
+ Extends `GroupBuilderModel` in
109
+ [group_builder.py](../base/src/cs_image_system/base/models/group_builder.py),
110
+ which extends `BuilderModel` and `NameTyped` in
111
+ [builder_model.py](../base/src/cs_image_system/base/models/builder_model.py).
112
+ The base adds no fields of its own beyond `BuilderModel`.
113
+
114
+ Fields it adds:
115
+
116
+ | Field | Type | Default | Meaning |
117
+ |---|---|---|---|
118
+ | `org` | `str` | required | An organisation label: a required example field, validated at load and read by nothing. It shows a plugin author how a builder declares what its provider needs. |
119
+ | `team` | `str` | required | A team label; the same. |
120
+
121
+ `key`, `secret` and `api_host` are gone (stage 63): they are now unknown
122
+ keys, refused at load like any other. Until 2026-09-25 the model accepted
123
+ them with the default `default` and read none of them; they were removed
124
+ so the template never suggests putting a credential in the tree. A real
125
+ plugin reads a credential from the environment or a credential cache,
126
+ never from the configuration.
127
+
128
+ Base fields it inherits: `name`, `type`, `description`, `aliases`,
129
+ `executable`, `is_default`, `config`, `gitignore`, `tags`. It overrides
130
+ `csis_name()` to return `dummy`. It rejects unknown keys and the retired
131
+ `parameters:` key like every builder model.
132
+
133
+ ### `DummyUserBuilderModel`
134
+
135
+ Extends `UserBuilderModel` in
136
+ [user_builder.py](../base/src/cs_image_system/base/models/user_builder.py).
137
+
138
+ Fields it adds:
139
+
140
+ | Field | Type | Default | Meaning |
141
+ |---|---|---|---|
142
+ | `org` | `str` | required | As on the group model: a required example field, validated and read by nothing. |
143
+ | `team` | `str` | required | The same. |
144
+
145
+ Base fields it inherits, and their meaning here:
146
+
147
+ | Field | Type | Default | Meaning |
148
+ |---|---|---|---|
149
+ | `default_user_email_template` | `str` | `{{ user.name }}` | Template for a user's email when the user declares none. |
150
+ | `default_user_description_template` | `str` | `User {{ user.name }} / {{ user.email }}` | Template for a user's description. |
151
+ | `email_domain` | `str` or null | null | The domain half of a derived address: when set and the email template contains `{{ builder.email_domain }}`, `get_user_email_template()` substitutes it in Python before the template renders. |
152
+ | `email_as_username` | `bool` | `True` | When true, `UserBuilderBase.add_user_to_builder()` requires `user.name` to equal `user.email` (case-folded) and fills a blank name from the email. |
153
+ | `name`, `type`, `description`, `aliases`, `executable`, `is_default`, `config`, `gitignore`, `tags` | | | From `BuilderModel`/`NameTyped`. |
154
+
155
+ It overrides `csis_name()` to return `dummy`; the YAML `type:` key is
156
+ required, as on every builder model (a model built without it fails with
157
+ `type: Field required`). Neither model narrows or adds validation. (Until
158
+ 2026-09-25 both carried an inert, unannotated class attribute
159
+ `type = DUMMY` and docstrings naming attributes that did not exist; stage
160
+ 63 removed them.)
161
+
162
+ ## The builder
163
+
164
+ Both builders implement the six generation hooks of `BuilderBase` in
165
+ [builder_base.py](../base/src/cs_image_system/base/basic/builder_base.py):
166
+ `generate_items_before`, `get_commands_to_run_before`,
167
+ `generate_items_during`, `get_commands_to_run_during`,
168
+ `generate_items_after`, `get_commands_to_run_after`. The runner calls each
169
+ with a lifecycle phase; a builder answers with assets to write (an
170
+ `AssetSet` of path and content) or commands to run (a `CFExecutables` of
171
+ commands now and commands at finalization). The base class returns an
172
+ `AssetSet` from every `generate_items_*` hook, and so do the two dummy
173
+ builders (since stage 63 item 1; `generate_items_during` returned a plain
174
+ `list` until 2026-09-24, the defect described under
175
+ [When it fails](#when-it-fails)).
176
+
177
+ ### `DummyGroupBuilder`
178
+
179
+ Extends `GroupBuilderBase` in
180
+ [builder_base_group.py](../base/src/cs_image_system/base/basic/builder_base_group.py).
181
+ Every hook returns empty, in the order the group lifecycle runs them:
182
+
183
+ | Phase | Hook | Returns |
184
+ |---|---|---|
185
+ | `group-generation` | `generate_items_before` | An empty `AssetSet`. |
186
+ | `group-generation` | `get_commands_to_run_before` | No commands. |
187
+ | `group-generation` | `generate_items_during` | An empty `AssetSet` (since stage 63 item 1; a plain `list` until then, which the runner's `.sort_and_write()` call broke on). |
188
+ | `group-generation` | `get_commands_to_run_during` | No commands. |
189
+ | `group-generation` | `generate_items_after` | An empty `AssetSet`. |
190
+ | `group-generation` | `get_commands_to_run_after` | No commands. |
191
+
192
+ It inherits the identity contract unchanged: `identity_type()` is `dummy`
193
+ (the default is the plugin's name), so a base image declaring
194
+ `identity_types: [dummy]` bakes nothing for it (`base_image_prerequisites`,
195
+ `verify_commands`, `activation_commands`, `activation_verify_commands` and
196
+ `launch_parameters` are the empty base defaults, `enrollment_token_reference`
197
+ is `None`); `gid_policy()` is `provider-assigned`; `query_state()`,
198
+ `query_attributes()`, `attribute_conflicts()` and `export_gids()` raise
199
+ `NotImplementedError`, so the state query and the attributes probe leave it
200
+ out of the picture, while the gid shim reports the error (see
201
+ [What it tests and verifies](#what-it-tests-and-verifies)). The stage-55
202
+ server registry (`can_query_servers()` is `False`, `registered_servers()`
203
+ is `None`, `retire_servers_named()` raises), the stage-56 workload access
204
+ (`can_manage_workload_access()` is `False`, `workload_access_expected()`
205
+ and `workload_access_state()` are `None`, `ensure_workload_access()`
206
+ raises) and the stage-61 `prune_stale_attachments()` (returns 0, does
207
+ nothing) are likewise the base defaults.
208
+
209
+ ### `DummyUserBuilder`
210
+
211
+ Extends `UserBuilderBase` in
212
+ [builder_base_user.py](../base/src/cs_image_system/base/basic/builder_base_user.py).
213
+
214
+ | Phase | Hook | Returns |
215
+ |---|---|---|
216
+ | any | `query_existing_users()` | An empty list. Plugin-local: nothing in the core calls it. |
217
+ | `user-generation` | `generate_items_before` | One asset at `get_path_for_phase(phase, "users", suffix=".tf")`, that is `<builder>/user-generation/<builder>-user-generation-users.tf` under the lifecycle directory, containing a single comment line naming the builder class (stage 63; it wrote `Dummy-tf/dummy_users.tf` through a plugin-local `get_subpath()`, now gone, until 2026-09-25). |
218
+ | `user-generation` | `get_commands_to_run_before` | No commands. |
219
+ | `user-generation` | `generate_items_during` | An empty `AssetSet` (since stage 63 item 1). |
220
+ | `user-generation` | `get_commands_to_run_during` | No commands. |
221
+ | `user-generation` | `generate_items_after` | An empty `AssetSet`. |
222
+ | `user-generation` | `get_commands_to_run_after` | No commands. |
223
+
224
+ It inherits `add_user_to_builder()` with its `email_as_username` check,
225
+ `default_managed()` (`True`), `validate_user()` (nothing to say),
226
+ `validate_attributes()` (any declared attributes are reported as
227
+ unsupported) and `query_attributes()` (raises `NotImplementedError`).
228
+
229
+ ## Emission
230
+
231
+ With a `type: dummy` user builder named `<builder>` in the tree, the
232
+ identity lifecycle's before-phase hook writes
233
+ `generated/identity/<builder>/user-generation/<builder>-user-generation-users.tf`
234
+ with one comment line: under the builder's own root, the
235
+ `<builder>/<phase>/...` convention every other builder follows (see
236
+ [OPERATIONS.md](../../docs/OPERATIONS.md)). Since stage 63; until
237
+ 2026-09-25 it wrote `generated/identity/Dummy-tf/dummy_users.tf`, beside
238
+ the builder directories instead of inside one. The group builder writes
239
+ nothing, and neither builder defers a command, so that one file is all a
240
+ run with a dummy builder produces. The frozen fixture declares neither, so
241
+ the golden emission under
242
+ [tests/fixtures/v2_golden/generated](../../tests/fixtures/v2_golden/generated)
243
+ carries no dummy output.
244
+
245
+ The hook plugin's emission is outside `generated/`: one JSON line appended
246
+ to the file named by `CSIS_NOTIFY_FILE` after every run, and, with
247
+ `CSIS_DUMMY_LIFECYCLE` set, a `generated/notify/.gitignore` and a `notify`
248
+ section in `generated/final_execution.sh`.
249
+ [tests/test_v2_explore_plugin_hooks.py](../../tests/test_v2_explore_plugin_hooks.py)
250
+ pins both: the notifier records the run id and the requested lifecycles;
251
+ the `notify` lifecycle takes its place after the four built-in lifecycles,
252
+ runs, and reports `no-script` at apply time.
253
+
254
+ ## Example configuration
255
+
256
+ The plugin is configured through its entry points (above) and two
257
+ environment variables:
258
+
259
+ | Variable | Effect |
260
+ |---|---|
261
+ | `CSIS_NOTIFY_FILE=/path/to/notify.jsonl` | The notifier appends a line per run. |
262
+ | `CSIS_DUMMY_LIFECYCLE=1` | The `notify` lifecycle is registered. |
263
+
264
+ No file under
265
+ [tests/fixtures/config/cfg](../../tests/fixtures/config/cfg) selects the
266
+ builders. An entry that would, shown only to illustrate the shape (the
267
+ fixture's real group and user builders are Okta ones in
268
+ [group-builders.yml](../../tests/fixtures/config/cfg/group-builders.yml)).
269
+ It loads, validates and runs the identity lifecycle, which emits nothing
270
+ for its groups and one comment-only file for the user builder (since
271
+ stage 63 item 1; before that the lifecycle failed at generation, see
272
+ [When it fails](#when-it-fails)). `org` and `team` are the only fields of
273
+ its own either entry takes; `key`, `secret` and `api_host` are refused as
274
+ unknown keys (stage 63). A tree that manages real
275
+ identities needs a real identity plugin instead.
276
+
277
+ ```yaml
278
+ group_builders:
279
+ - name: dummy-groups
280
+ type: dummy
281
+ org: example-org
282
+ team: example-team
283
+
284
+ user_builders:
285
+ - name: dummy-users
286
+ type: dummy
287
+ org: example-org
288
+ team: example-team
289
+ email_as_username: false
290
+ default_user_email_template: "{{ user.name }}@example.invalid"
291
+ ```
292
+
293
+ ## Using it as a template
294
+
295
+ To write a new builder plugin: copy this package, rename the `cs_image_system.<name>`
296
+ module, keep one model per builder type extending the base model for that
297
+ classification (`GroupBuilderModel`, `UserBuilderModel`,
298
+ `StorageBuilderModel`, ...), give each model and builder the same
299
+ `csis_name()` (the `type:` key), list them in the metadata's services and
300
+ `builders_for_models`, and declare the entry point under the
301
+ `cs_image_system.plugins.<kind>` group for the builder kind (`runtime`,
302
+ `state`, `modification`, `storage`, `group`, `user`, `image`, `os`,
303
+ `instance`, `other`; the list is `PLUGIN_TYPES` in
304
+ [constants.py](../base/src/cs_image_system/base/constants.py)). Return an
305
+ `AssetSet` from every `generate_items_*` hook, not a list. To write a
306
+ hook plugin: return a `HookSet` from an entry point in the
307
+ `cs_image_system.plugins.hooks` group. Registration is validated: a
308
+ lifecycle cannot reuse a built-in name, its `after` anchor must exist, and
309
+ registering the same name twice with a different definition is an error.
310
+ A second worked example lives under
311
+ [docs/example-plugin/plugin-b](../../docs/example-plugin/plugin-b).
312
+
313
+ ## Prerequisites and integration
314
+
315
+ Nothing beyond the core. The plugin touches no account, cloud API, IAM
316
+ role, external tool, credential or network; it never calls
317
+ `get_executable_copy()`, so it needs no executable declared. What it does
318
+ need:
319
+
320
+ - **The package installed** in the same environment as the CLI. Both entry
321
+ points are discovered through `importlib.metadata`, so an uninstalled
322
+ package registers nothing: `type: dummy` is then an unknown type and the
323
+ `notify` lifecycle does not exist. In this repository it is a workspace
324
+ member pinned by the root `cs-image-system` metapackage; `just init`
325
+ installs it. Its own requirements ([pyproject.toml](pyproject.toml)) are
326
+ Python 3.13 or newer, `cs-image-system-system` at the same version (the
327
+ CLI package, which brings the base) and `pydantic` 2.13 or newer.
328
+ - **Two environment variables**, both optional, both read by
329
+ [hooks.py](src/cs_image_system/dummy_plugin/hooks.py) straight from
330
+ `os.environ`:
331
+
332
+ | Variable | Read when | Effect |
333
+ |---|---|---|
334
+ | `CSIS_NOTIFY_FILE` | at the end of every run, inside `notify_summary` | The path of a file the notifier appends to (`open(path, "a")`). The directory must already exist; the file is created on first use. Unset or empty: the notifier logs `dummy notifier: CSIS_NOTIFY_FILE unset; nothing to do` at debug level and returns. |
335
+ | `CSIS_DUMMY_LIFECYCLE` | once per process, when `load_hook_plugins()` first runs `initialize()` | Any non-empty value registers the `notify` lifecycle. It is read at load, not at run: setting it after the hooks have loaded changes nothing until a new process (the loader keeps a `_HOOK_PLUGINS_LOADED` flag; only `force=True`, which the tests use, reloads). |
336
+
337
+ - **When the hooks load.** `load_hook_plugins()` runs at the start of
338
+ `run_lifecycles()` and `validate_only()` in
339
+ [run_lifecycles.py](../base/src/cs_image_system/base/commands/run_lifecycles.py),
340
+ and the `run` command in
341
+ [cli.py](../system/src/cs_image_system/system/cli.py) calls it before it
342
+ resolves lifecycle names, so `notify` is a valid name for `run notify`
343
+ and part of `run --all` in the same process (the reason is a failure
344
+ that happened; see [When it fails](#when-it-fails)).
345
+ - **No credential, anywhere.** The plugin has no provider to talk to and
346
+ its models have no credential-shaped field: `key`, `secret` and
347
+ `api_host` were removed in stage 63 (they had been accepted and never
348
+ read until 2026-09-25) and are now refused as unknown keys. A credential
349
+ value never belongs in the tree, encrypted or not.
350
+
351
+ ## Configuration reference
352
+
353
+ The plugin owns two list entries of the configuration tree, one under
354
+ `group_builders:` and one under `user_builders:`, both selected by
355
+ `type: dummy`. The manual's general builder fields are in
356
+ [CONFIGURATION.md](../../docs/CONFIGURATION.md) section 4.1; what follows
357
+ is every field these two models accept and what, if anything, reads it.
358
+
359
+ ### Common builder fields (both models)
360
+
361
+ | Field | Type | Default | Meaning |
362
+ |---|---|---|---|
363
+ | `name` | str | required | The builder's name; normalised by `safe_name`, must not contain `/` or `\`, must not be a reserved word (`default`, `self`, `none`, empty or null, in any case), and must be unique within its list. Users and groups name it in their own `type:` to attach to it. |
364
+ | `type` | str | required | Must be `dummy` to select this plugin's pair. Any other value selects another plugin or is unknown. |
365
+ | `description` | str or null | null | Accepted, not read by anything in this plugin's path. |
366
+ | `aliases` | set of str | empty | Alternative names, normalised like `name` and registered at load; an alias already registered under the same classification is refused. |
367
+ | `executable` | str or null | null | Accepted, not read: the dummy builders run no commands and never resolve an executable. |
368
+ | `is_default` | bool | `false` | Whether users (or groups) that declare no `type:` attach to this builder. Exactly one builder per list must be the default, dummy or otherwise; two is an error, none is an error. |
369
+ | `config` | dict | empty | Accepted, not read. |
370
+ | `gitignore` | list of str | empty | Accepted, not read. |
371
+ | `tags` | dict of str | empty | Accepted, not read. |
372
+
373
+ ### `group_builders:` entry (`DummyGroupBuilderModel`)
374
+
375
+ | Field | Type | Default | Meaning |
376
+ |---|---|---|---|
377
+ | `org` | str | required | Required and validated at load; read by nothing (an example field). |
378
+ | `team` | str | required | Required and validated at load; read by nothing (an example field). |
379
+ | `key`, `secret`, `api_host` | -- | -- | GONE (stage 63): an unknown key, refused at load. Until 2026-09-25 each was accepted with the default `default` and read by nothing. |
380
+
381
+ ### `user_builders:` entry (`DummyUserBuilderModel`)
382
+
383
+ | Field | Type | Default | Meaning |
384
+ |---|---|---|---|
385
+ | `org` | str | required | Required and validated at load; read by nothing (an example field). |
386
+ | `team` | str | required | Required and validated at load; read by nothing (an example field). |
387
+ | `default_user_email_template` | str | `{{ user.name }}` | Read by the core's template resolution for a user that declares no `email`. |
388
+ | `default_user_description_template` | str | `User {{ user.name }} / {{ user.email }}` | Read by the core's template resolution for a user that declares no description. |
389
+ | `email_domain` | str or null | null | Folded into the email template in place of `{{ builder.email_domain }}` by `get_user_email_template()`; ignored when the template has no placeholder. |
390
+ | `email_as_username` | bool | `true` | Read by the inherited `add_user_to_builder()` at load. |
391
+
392
+ ### Items that attach to these builders
393
+
394
+ Users ([user.py](../base/src/cs_image_system/base/models/user.py)) and
395
+ groups carry a `type:` whose default is `default`. The context
396
+ ([global_context.py](../base/src/cs_image_system/base/global_context.py))
397
+ resolves `default` to the list's default builder and any other value to a
398
+ builder by name or alias, then calls the builder's `add_user_to_builder()`
399
+ or `add_group_to_builder()`. So `type: dummy-users` on a user attaches it
400
+ to the example builder above; a user with no `type:` goes to whichever
401
+ user builder is `is_default: true`.
402
+
403
+ ### Environment
404
+
405
+ | Variable | Type | Default | Meaning |
406
+ |---|---|---|---|
407
+ | `CSIS_NOTIFY_FILE` | path | unset | See [Prerequisites and integration](#prerequisites-and-integration). |
408
+ | `CSIS_DUMMY_LIFECYCLE` | any non-empty string | unset | See [Prerequisites and integration](#prerequisites-and-integration). |
409
+
410
+ ### Variations
411
+
412
+ - **When `CSIS_DUMMY_LIFECYCLE` is set**, `initialize()` returns a
413
+ `HookSet` carrying `LifecycleSpec(name="notify", after=None)` and the
414
+ runner registers it: `notify` appears last in `all_lifecycles()`, joins
415
+ `run --all`, is accepted by `run notify`, gets `generated/notify/`
416
+ (wiped and regenerated with a `.gitignore` each time it is requested)
417
+ and a `notify` section in `final_execution.sh`. Unset, the `HookSet`
418
+ carries no lifecycle, `run notify` is refused as an unknown lifecycle,
419
+ and `run --all` is the built-ins plus whatever other plugins register.
420
+ - **When `CSIS_NOTIFY_FILE` is set**, every run (successful, failed, dry or
421
+ real, validation-refused included, because the summary hooks run in the
422
+ runner's `finally`) appends one line to it and logs
423
+ `dummy notifier: appended run <id> to <path>` at info level. Unset, it
424
+ logs at debug level and does nothing.
425
+ - **Dry run versus real run**: the notifier writes whatever the summary
426
+ holds, so the line's `apply` map shows `dry-run` per lifecycle under the
427
+ default dry run and `executed` under `--no-dry-run`; the `notify`
428
+ lifecycle itself reports `no-script` either way because it defers no
429
+ command and so writes no runner script. With apply switched off
430
+ (`apply=False` in the runner) every lifecycle reports `not-attempted`.
431
+ - **`email_as_username: true` (the default)** makes the load refuse a user
432
+ whose `name` differs from its `email` (case-folded) and fill a blank name
433
+ from the email; `false` attaches the user as declared.
434
+ - **`email_domain` set** substitutes into the email template only when the
435
+ template contains `{{ builder.email_domain }}`; otherwise it is ignored.
436
+ - **`is_default: true`** on a dummy builder routes every user (or group)
437
+ without a `type:` to it; `false` leaves the builder with only the items
438
+ that name it. In a tree whose only user builder is a dummy one, it must
439
+ be the default or the load fails.
440
+ - **Declared versus not declared**: with no `type: dummy` entry the
441
+ builder plugin does nothing at all; the four classes sit in the registry
442
+ and no code path reaches them. With one declared, the load, validation
443
+ and identity lifecycle succeed; the group builder emits nothing and the
444
+ user builder one comment-only file under its own root (since stage 63;
445
+ the lifecycle failed at generation until 2026-09-24, and the user file
446
+ sat at `Dummy-tf/dummy_users.tf` until 2026-09-25).
447
+ - **One runtime versus another, `--only-runtime`, `--apply-runtime`**:
448
+ no effect. The models carry no `runtime` field and identity builders are
449
+ not runtime-scoped, so a scoped run treats them like any identity
450
+ builder.
451
+ - **Encrypted versus clear values**: the plugin has no encryption-specific
452
+ behaviour; it reads none of its own fields, so a marker in `org` or
453
+ `team` is decrypted or not by the core alone.
454
+ - **`validate` versus `run`**: both load the hook plugins, so the `notify`
455
+ lifecycle exists for both when the variable is set; neither exercises
456
+ the notifier differently.
457
+
458
+ ## What it tests and verifies
459
+
460
+ The plugin adds no checks of its own: its `HookSet` has empty
461
+ `validators`, `after_generate`, `before_apply` and `after_apply` lists,
462
+ its builders validate nothing and its `validate_user()` returns nothing.
463
+ What is checked around it, and where the verdict lands:
464
+
465
+ - **At load (pydantic, model construction).** `org` and `team` must be
466
+ present (`org: Field required`); an unknown key is refused
467
+ (`Unexpected keyword argument`, `extra="forbid"`), and since stage 63
468
+ that includes `key`, `secret` and `api_host`
469
+ ([test_v2_defects_dead.py](../../tests/test_v2_defects_dead.py),
470
+ `test_the_template_models_keep_org_and_team_and_refuse_credentials`); the retired
471
+ `parameters:` key is refused with the stage-26 message; `name` and
472
+ `aliases` are checked for `/`, `\` and the reserved words (`default`,
473
+ `self`, `none`, empty, null -- refused first where the file is read,
474
+ naming the file and the entry); the YAML
475
+ reader refuses a duplicate `name` in the list and template markers in
476
+ `name` or `type`. After construction the context refuses two defaults
477
+ (`Multiple default builders found`), no default
478
+ (`No default builder found in <classification> list.`), a user or
479
+ group naming a builder that is not declared
480
+ (`users builder '<type>' specified for '<name>' not configured`), and,
481
+ through `add_user_to_builder()`, a name/email mismatch under
482
+ `email_as_username`. Every one of these is a `ValueError` (pydantic's
483
+ `ValidationError` is one) raised while the configuration loads, before
484
+ any lifecycle: the CLI exits non-zero with the message; no run summary
485
+ is written because there is no run.
486
+ - **At `validate` (and at the start of every run).** The core's
487
+ `validate_identity_items` in
488
+ [identity_attributes.py](../base/src/cs_image_system/base/identity_attributes.py)
489
+ asks the owning builder about each user and group. For a dummy builder
490
+ that yields one error string per item that declares `attributes:`
491
+ (`user '<name>': builder <builder> (dummy) supports no attributes
492
+ (declared [...])`) and nothing otherwise. `validate_base_images` in
493
+ [v2_validation.py](../base/src/cs_image_system/base/v2_validation.py)
494
+ accepts `identity_types: [dummy]` on a base image exactly when a dummy
495
+ group builder is declared (its `identity_type()` is `dummy`), and
496
+ `validate_capabilities` accepts an instance image whose group is
497
+ attached to a dummy builder when its root base declares `dummy`. Errors
498
+ land as `validation: <message>` log lines, in `validation_errors` of the
499
+ run summary (`generated/run-summary.json`), in the run's entry in
500
+ `meta-state/runs.yaml` (`error: LifecycleRunError: Validation failed
501
+ with N error(s)`) and in the notifier's line (`"ok": false`).
502
+ - **At generation.** Nothing is verified. The dummy builders' hooks
503
+ return empty asset sets, or the user builder's one asset, and no
504
+ commands. The user builder's file lands under its own root,
505
+ `generated/identity/<builder>/user-generation/` (stage 63,
506
+ `test_the_dummy_user_builder_writes_under_its_own_root` in
507
+ [test_v2_defects_dead.py](../../tests/test_v2_defects_dead.py), which
508
+ also checks that no `Dummy-tf` directory appears).
509
+ - **At apply.** Nothing. The builders defer no commands, so no runner
510
+ script names them; the `notify` lifecycle has no phases and no script
511
+ and reports `no-script` in the summary's `apply` map.
512
+ - **After apply.** No `after_apply` hooks. The `on_summary` notifier runs
513
+ after every run, including failed ones; it never changes the verdict
514
+ and cannot fail the run (the runner logs a failing notifier and moves
515
+ on).
516
+ - **In the state query** ([state_query.py](../base/src/cs_image_system/base/state_query.py)):
517
+ `query_state()` raises `NotImplementedError`, which the query treats as
518
+ "not part of the picture": groups under a dummy builder appear nowhere
519
+ in `generated/state-report.json`, not even under `unavailable`. The
520
+ identity attributes probe lists them instead:
521
+ `groups/<name>: dummy cannot query attributes` under `unavailable`, and
522
+ skips `attribute_conflicts()`. The identity read-model written after
523
+ the identity lifecycle records each attached group with
524
+ `identity_type: dummy` and `gid_policy: provider-assigned`, and each
525
+ dummy user builder as `<name>: dummy`.
526
+ - **The gid shim** (`cs-image-system identity export-gids`, in
527
+ [identity_gids.py](../base/src/cs_image_system/base/commands/identity_gids.py))
528
+ does not skip it: a query naming `identity_type: dummy` finds
529
+ `DummyGroupBuilder`, whose `export_gids()` raises, and the command
530
+ prints `export-gids: DummyGroupBuilder does not implement a gid shim;
531
+ its terraform root must expose gids some other queryable way (N7)` on
532
+ stderr and exits 1. Nothing emits such a query for a dummy builder;
533
+ this is what would happen if something did.
534
+ - **Lifecycle registration** ([lifecycles.py](../base/src/cs_image_system/base/lifecycles.py)):
535
+ when the `notify` spec is installed, `register_lifecycle` verifies the
536
+ name is not a built-in, not `all`, not empty and has no `/`, that the
537
+ `after` anchor (here none) exists, and that a spec already registered
538
+ under the name is equal. Re-registering the identical spec (the tests'
539
+ `force=True` reload) is a no-op.
540
+
541
+ ## When it fails
542
+
543
+ Failures that have happened, first.
544
+
545
+ - **2026-08-27, the `notify` lifecycle leaked into every run.** The first
546
+ version of [hooks.py](src/cs_image_system/dummy_plugin/hooks.py)
547
+ registered `notify` unconditionally; because a registered lifecycle
548
+ joins `run --all`, every golden and gate run of the exploration branch
549
+ grew a `generated/notify/` directory and a fifth lifecycle. Symptom: an
550
+ unexpected `notify` entry in run summaries and golden diffs. Fix: the
551
+ lifecycle is opt-in behind `CSIS_DUMMY_LIFECYCLE`. Recorded in
552
+ [docs/history/EXPLORE.md](../../docs/history/EXPLORE.md) ("Expanding
553
+ What Else Plugins Could Do", findings). If `notify` appears in a run you
554
+ did not ask for it in, the variable is set in that shell.
555
+ - **2026-09-08, hook plugins loaded after the lifecycle names resolved
556
+ (finding 62).** A fresh CLI process resolved `run --all` to the four
557
+ built-ins because `load_hook_plugins()` ran only inside
558
+ `run_lifecycles()`; every plugin-registered lifecycle (`release` then,
559
+ `notify` alike) was silently absent from `--all` and `run <name>` was
560
+ refused with `Unknown lifecycle '<name>'; expected one of [...]` and
561
+ exit code 2. Fix: the `run` command loads the hooks before parsing
562
+ names. Recorded in [docs/history/LEDGER.md](../../docs/history/LEDGER.md)
563
+ (Stage 1 live-run findings, 61 to 64) and in the same ledger's release
564
+ cycle, where the registration test of the `release` lifecycle turned
565
+ out to pass only after another test had loaded the hook plugins; it now
566
+ loads them itself. If `run notify` is refused although the variable is
567
+ set, check that the command path loads the hooks before it resolves
568
+ names (a library caller must call `load_hook_plugins()` before
569
+ `parse_lifecycles()`).
570
+ - **A declared `type: dummy` builder used to fail the identity lifecycle**
571
+ (reproduced over a copy of the frozen fixture on 2026-09-23; never live,
572
+ since no tree declares one). Both builders' `generate_items_during`
573
+ returned a plain `list` where the contract wants an `AssetSet`, and the
574
+ during step ([gen_users.py](../base/src/cs_image_system/base/commands/gen_users.py),
575
+ [gen_groups.py](../base/src/cs_image_system/base/commands/gen_groups.py))
576
+ died on `.sort_and_write()`:
577
+
578
+ ```text
579
+ ERROR cs_image_system.base.commands.run_lifecycles:AttributeError: 'list' object has no attribute 'sort_and_write'
580
+ ```
581
+
582
+ Fixed in stage 63 item 1 (2026-09-24): both return `AssetSet()`, and a
583
+ declared dummy builder with a group on it now loads, validates and runs
584
+ the identity lifecycle dry, emitting nothing
585
+ ([tests/test_v2_defects_low_hanging.py](../../tests/test_v2_defects_low_hanging.py)).
586
+ - **Until 2026-09-25 the user builder wrote outside its own root.** Its
587
+ one file went to `generated/identity/Dummy-tf/dummy_users.tf`, beside the
588
+ builder directories, because it built the path from a plugin-local
589
+ `get_subpath()` instead of `get_path_for_phase()`; a template copied
590
+ from it taught the wrong layout. Stage 63 moved it to
591
+ `<builder>/user-generation/<builder>-user-generation-users.tf`.
592
+ - **Declaring `key`, `secret` or `api_host` on a dummy builder is refused
593
+ at load** (stage 63; accepted and ignored until 2026-09-25). pydantic
594
+ names the key (`Unexpected keyword argument`); delete it. The template
595
+ has no credential field on purpose.
596
+
597
+ ## Related
598
+
599
+ - Base classes: [group_builder.py](../base/src/cs_image_system/base/models/group_builder.py), [user_builder.py](../base/src/cs_image_system/base/models/user_builder.py), [builder_base.py](../base/src/cs_image_system/base/basic/builder_base.py), [builder_base_group.py](../base/src/cs_image_system/base/basic/builder_base_group.py), [builder_base_user.py](../base/src/cs_image_system/base/basic/builder_base_user.py), [abstract_plugin_metadata.py](../base/src/cs_image_system/base/basic/abstract_plugin_metadata.py).
600
+ - Plugin loading: [loader.py](../base/src/cs_image_system/base/loader.py) (builder plugins), [run_lifecycles.py](../base/src/cs_image_system/base/commands/run_lifecycles.py) (hook plugins and `HookSet`), [lifecycles.py](../base/src/cs_image_system/base/lifecycles.py) (`LifecycleSpec`).
601
+ - The real identity plugin the fixture uses: [okta-opa-plugin](../okta-opa-plugin).
602
+ - [docs/PLUGINS.md](../../docs/PLUGINS.md) for the package index; [docs/DESIGN.md](../../docs/DESIGN.md) for the design.
603
+ - [The configuration reference](../../docs/CONFIGURATION.md) -- every field of the YAML this plugin reads, with an example.
@@ -0,0 +1,11 @@
1
+ cs_image_system/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ cs_image_system/dummy_plugin/__init__.py,sha256=GAFRNaGWQxXquHOlCzxan4LkPZ9_NsiyswknxdPcX0c,122
3
+ cs_image_system/dummy_plugin/dummy_builders.py,sha256=9gTM1WDSHrga2psq-AxS2p7W4TJ0c6KJY9w_XiGU5JQ,5662
4
+ cs_image_system/dummy_plugin/dummy_models.py,sha256=oaeE3MJdfno7ZcrNX1aInAEUzSZs6jrAjpB8agdRcM0,1463
5
+ cs_image_system/dummy_plugin/hooks.py,sha256=uUT3UxE9sfuqSzcvfA3kyNxqcAW8kNI4fjWIfiunEQE,1826
6
+ cs_image_system/dummy_plugin/main.py,sha256=v0eQzh0Fc5qJeiaIQRAhNT8JAGUJtC-bpgRsTOsML6M,1162
7
+ cs_image_system_dummy_plugin-0.1.1.dist-info/METADATA,sha256=PH0VR0I4tdUgcw1iT-iwoUrxFdyLKDq8GlBdME1lwQY,36035
8
+ cs_image_system_dummy_plugin-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ cs_image_system_dummy_plugin-0.1.1.dist-info/entry_points.txt,sha256=lONdW_HhuyxOs5MxhLwF_Ujq09AgA0XjDal6APMmx8c,187
10
+ cs_image_system_dummy_plugin-0.1.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
11
+ cs_image_system_dummy_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,5 @@
1
+ [cs_image_system.plugins.hooks]
2
+ dummy_hooks = cs_image_system.dummy_plugin.hooks:initialize
3
+
4
+ [cs_image_system.plugins.runtime]
5
+ dummy_plugin = cs_image_system.dummy_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.