cs-image-system-aws-runtime-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,1146 @@
1
+ Metadata-Version: 2.5
2
+ Name: cs-image-system-aws-runtime-plugin
3
+ Version: 0.1.1
4
+ Summary: The aws runtime type: Amazon EC2 as a place where images bake and instances run
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/aws-runtime-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: boto3>=1.43
13
+ Requires-Dist: botocore>=1.43
14
+ Requires-Dist: cs-image-system-hashicorp-utils==0.1.1
15
+ Requires-Dist: cs-image-system-system==0.1.1
16
+ Requires-Dist: pydantic>=2.13
17
+ Requires-Dist: python-hcl2>=8.1
18
+ Description-Content-Type: text/markdown
19
+
20
+ # cs-image-system-aws-runtime-plugin
21
+
22
+ The `aws` runtime type. A runtime is where images are baked and instances
23
+ run; this package makes Amazon EC2 one. It registers one runtime model
24
+ (`AwsCloudBuilderModel`), one runtime builder (`AwsCloudBuilder`) and one
25
+ version checker (`AwsCLIVersionChecker`, for the `aws` CLI). At load it
26
+ discovers the account's VPCs, subnets and security groups with boto3 and
27
+ checks the declared networking against them. During a run it resolves vendor
28
+ AMIs by owner and filter, emits the `amazon-ebs` packer source for every image
29
+ baked here, bakes and reaches instances through AWS Systems Manager (SSM),
30
+ answers the state query from `describe-images` / `describe-instances`, reads
31
+ and (for one bounded task at a time) sets an instance's power state, tags
32
+ released AMIs, and disposes of AMIs together with their snapshots.
33
+
34
+ ## What it registers
35
+
36
+ The entry point is declared in [pyproject.toml](pyproject.toml):
37
+
38
+ ```toml
39
+ [project.entry-points."cs_image_system.plugins.runtime"]
40
+ aws_plugin = "cs_image_system.aws_runtime.main:initialize"
41
+ ```
42
+
43
+ `initialize()` in [main.py](src/cs_image_system/aws_runtime/main.py) returns an
44
+ `AwsRuntimePluginMetadata` whose services map the key `aws` to three classes
45
+ and whose `builders_for_models` binds the model to its builder.
46
+
47
+ | Class | `csis_name()` | `csis_classifier()` | Registered under |
48
+ |---|---|---|---|
49
+ | `AwsCloudBuilderModel` ([aws_runtime_models.py](src/cs_image_system/aws_runtime/aws_runtime_models.py)) | `aws` | `VCT.CLOUD_BUILDER_MODEL` | `cloud_builder_model` and, because the loader re-registers cloud models as runtimes, `runtime_builder_model` |
50
+ | `AwsCloudBuilder` ([aws_runtime_builders.py](src/cs_image_system/aws_runtime/aws_runtime_builders.py)) | `aws` | `VCT.RUNTIME_BUILDER` | `runtime_builder` |
51
+ | `AwsCLIVersionChecker` ([aws_runtime_builders.py](src/cs_image_system/aws_runtime/aws_runtime_builders.py)) | `aws-cli` | `VCT.VERSION_CHECKER` | `version_checker`, since stage 48.1 (2026-09-17) |
52
+
53
+ A YAML entry selects the model and builder with `type: aws` under
54
+ `runtime_builders:`. There are no type aliases; `aws` is the only type key.
55
+ An entry's own `aliases:` list gives that runtime extra names (the fixture's
56
+ `aws-east2-runtime` also answers to `aws`, `amazon` and `us-east-2`), and
57
+ other entries refer to it by any of them.
58
+
59
+ The version checker is selected differently: `validate` and every run look
60
+ up a checker for each entry of `cfg/executables.yml` under the entry's
61
+ `name` first, then its `type` (see
62
+ [validate.py](../base/src/cs_image_system/base/commands/validate.py)). An
63
+ executable named `aws-cli` therefore gets this checker, which runs
64
+ `<binary> --version` and reads the version from the regex
65
+ `aws-cli\/([\d\.]+)\s` on the first output line (`aws-cli/2.32.0 Python/...`).
66
+ An entry named anything else with `binary: aws` falls to the generic
67
+ checker.
68
+
69
+ Present in the package but not registered as a service:
70
+
71
+ - `AwsProviderSpecificImage` ([aws_provider_specific_image.py](src/cs_image_system/aws_runtime/aws_provider_specific_image.py)), reached through the builder's `provider_specific_image_class()` hook.
72
+
73
+ **The AWS section of the bootstrap** (stage 70 step 4), through the
74
+ `cs_image_system.bootstrap` entry-point group: `aws =
75
+ cs_image_system.aws_runtime.bootstrap:aws_section`. It owns the questions
76
+ and the terraform for this cloud's one-time initialisation -- the GitHub
77
+ OIDC identity provider, the READ-ONLY and WRITE roles of CI_SETUP.md 3.3
78
+ with their trust and permission documents, and the state bucket and the
79
+ Session Manager instance profile when the account lacks them
80
+ (`tfmodules/bootstrap_aws`). The section is wanted by default where the
81
+ tree declares an AWS runtime or an S3 state backend. Its defaults come
82
+ from the raw `cfg/runtime-builders.yml` and `cfg/state-backends.yml`
83
+ (read as YAML, never loaded; a starter's `REPLACE-ME` values and its
84
+ example account id are no defaults), and every "does it already exist?"
85
+ question is a probe of the account as the runtime's profile (`aws iam
86
+ get-open-id-connect-provider`, `get-role`, `get-instance-profile`,
87
+ `s3api head-bucket`): found, absent, or -- with no session -- unanswerable,
88
+ which `--quiet` refuses by name. A role that exists is adopted with an
89
+ `import` block in the root, and the subjects it already trusted that are
90
+ not this repository's are read from its trust document and kept (another
91
+ repository may share the role); the root's state binds to the tree's declared
92
+ S3 backend when the bucket exists. See OPERATIONS.md, "The bootstrap".
93
+
94
+ ## Models
95
+
96
+ ### `AwsCredentials`
97
+
98
+ Extends `CredentialsBase` in
99
+ [credentials.py](../base/src/cs_image_system/base/models/credentials.py).
100
+ The base declares no fields; each provider narrows it. Field names are
101
+ boto3 `Session` keyword arguments and pass straight through.
102
+
103
+ | Field | Type | Default | Meaning |
104
+ |---|---|---|---|
105
+ | `profile_name` | `str \| None` | `None` | A named profile in the operator's AWS configuration. The fixture uses `noaa`. |
106
+ | `aws_access_key_id` | `str \| None` | `None` | Key id; carries a template such as `{{ ENV['AWS_ACCESS_KEY_ID_east2'] }}`, never a literal. |
107
+ | `aws_secret_access_key` | `str \| None` | `None` | Secret; same rule. |
108
+ | `aws_session_token` | `str \| None` | `None` | Session token; same rule. |
109
+
110
+ `as_dict()` omits unset fields, so an absent key never reaches boto3 as
111
+ `None`. The shared model config forbids extra keys, so a misspelled key
112
+ inside `credentials:` is a validation error naming the field path. No
113
+ credential value belongs in the configuration tree: these fields name a
114
+ profile or read the environment.
115
+
116
+ ### `AwsCloudNetworkingModel`
117
+
118
+ Extends `CloudNetworkingConfig` in
119
+ [cloud_builder.py](../base/src/cs_image_system/base/models/cloud_builder.py),
120
+ which is `RuntimeNetworkingModel` in
121
+ [runtime.py](../base/src/cs_image_system/base/models/runtime.py) under
122
+ another name.
123
+
124
+ Fields it adds:
125
+
126
+ | Field | Type | Default | Meaning |
127
+ |---|---|---|---|
128
+ | `addl_security_groups` | `list[str]` | `[]` | Existing groups every launched instance also wears, unmodified. Checked against the account at load and the only list counted toward the limit of five below. The instance module's `vpc_security_group_ids` lists them after the generated group; the EFS storage builder uses them as the mount targets' client groups. |
129
+ | `ssh_ingress_security_group_ids` | `list[str]` | `[]` | Groups whose members may reach port 22 on launched instances. The generated instance security group's ingress references only these groups, never a CIDR range. Not checked against the account. |
130
+ | `_model_id` | `str \| None` | `None` (not an init field) | Back-reference to the owning runtime model. |
131
+
132
+ Base fields it inherits:
133
+
134
+ | Field | Type | Default | Meaning |
135
+ |---|---|---|---|
136
+ | `name` | `str` | the network id | Empty or `default` falls back to `network`. |
137
+ | `network` | `str` | `default` | The VPC id. `default` resolves at load to the account's default VPC. |
138
+ | `subnets` | `list[RuntimeSubnetModel]` | required, non-empty | Each has `name`, `subnet_id`, `is_default`, `public`, `cidr`, `availability_zone`, `config`. The first entry with `is_default: true` is the default subnet; without one the model refuses to load. |
139
+ | `availability_zones` | `list[RuntimeAvailabilityZoneModel]` | `[]` | `name`, `is_default`. The default zone, when declared, becomes the packer source's `availability_zone`. |
140
+
141
+ It strips `name` and refuses one that is empty afterwards (only a
142
+ whitespace-only name can reach that check, because the base already
143
+ substitutes the network id for an empty or `default` name).
144
+ `get_subnet_id()` raises when no default subnet exists. It does not accept
145
+ security groups by name, only by id, and it refuses more than five groups in
146
+ `addl_security_groups`.
147
+
148
+ There is no `security_group_ids` field any more (stage 63). Until
149
+ 2026-09-25 the model accepted it, checked its ids against the account and
150
+ counted them toward the limit of five, but no emitter ever read it, so a
151
+ group listed there reached neither the packer source nor the instance and
152
+ storage roots. It was removed rather than wired in, because
153
+ `addl_security_groups` already is the list that reaches the roots. Declaring
154
+ it is now an unknown-key refusal at load (the shared model config forbids
155
+ extra keys), and the error names the key; move the ids into
156
+ `addl_security_groups`. The accessor `get_security_group_ids()` went with
157
+ it; `get_addl_security_groups()` remains.
158
+
159
+ ### `AwsCloudBuilderModel`
160
+
161
+ Extends `CloudBuilderModel` in
162
+ [cloud_builder.py](../base/src/cs_image_system/base/models/cloud_builder.py),
163
+ which extends `RuntimeBuilderModel` in
164
+ [runtime.py](../base/src/cs_image_system/base/models/runtime.py), which
165
+ extends `BuilderModel` and `NameTyped` in
166
+ [builder_model.py](../base/src/cs_image_system/base/models/builder_model.py).
167
+
168
+ Fields it adds:
169
+
170
+ | Field | Type | Default | Meaning |
171
+ |---|---|---|---|
172
+ | `account_id` | `str \| None` | `None` | The AWS account. Reported by `runtime describe`; usable in templates as `{{ this.account_id }}`. Nothing else reads it: the account boto3 acts in is the profile's. |
173
+ | `credentials` | `AwsCredentials` | empty | Narrows the base `CredentialsBase`. |
174
+ | `state_configuration` | `str` | `DEFAULT` | Foreign key to a state backend. The second rung of every storage and instance root's backend resolution on this runtime: a root whose own `state_configuration` is `default` inherits this one; `default` here falls through to the default backend (stage 46.2, [orchestrator.py](../base/src/cs_image_system/base/orchestrator.py)). |
175
+ | `ena_support` | `bool \| None` | `None` | When declared, written into the `amazon-ebs` packer source as `ena_support = true\|false`, so the baked AMI's Elastic Network Adapter flag is set explicitly; unset, nothing is written and packer's default applies (stage 63; until 2026-09-25 accepted and read by nothing). |
176
+ | `sriov_support` | `bool \| None` | `None` | When declared, written into the `amazon-ebs` packer source as `sriov_support = true\|false` (enhanced networking through the Intel SR-IOV interface); unset, nothing is written and packer's default applies (stage 63; until 2026-09-25 accepted and read by nothing). |
177
+ | `iam_instance_profile` | `str \| None` | `None` | Instance profile written into the packer source for build instances when `session_instance_profile` is unset (it is also the SSM fallback then). When both are set the SSM profile wins (stage 63 item 9, decided 2026-09-24; until then this one, written after the SSM block, replaced it). Never attached to launched instances. |
178
+ | `session_mechanism` | `str \| None` | `None` | `ssm` (any case, surrounding whitespace ignored) is the only value. Any other string is an error the first time the builder's `session_mechanism()` is called, which is at generation. |
179
+ | `session_instance_profile` | `str \| None` | `None` | The SSM-capable instance profile given to launched instances (through the instance plugin) and to build instances (through the packer source). |
180
+ | `ssh_username` | `str` | `DEFAULT` | The bake's SSH user for images on this runtime that name none more specifically: step 4 of the bake-user order ([CONFIGURATION 5.1.1](../../docs/CONFIGURATION.md#511-the-bake-ssh-user)). Until stage 63 it overrode every entry. |
181
+ | `networking` | `AwsCloudNetworkingModel \| None` | `None` | Narrows the base type and makes it optional. Missing networking logs a warning at load; image generation then fails (the packer source reads the VPC and subnet from it). |
182
+ | `vpc_map` | `dict` | discovered | Not an init field. `{vpc_id: {"subnets": [...]}}` from the account. |
183
+ | `default_vpc_id` | `str \| None` | discovered | Not an init field. |
184
+ | `all_security_groups` | `dict[str, dict]` | discovered | Not an init field. `{group_id: description}` from the account. |
185
+
186
+ Base fields it inherits, and what this runtime does with them:
187
+
188
+ | Field | From | Default | Meaning here |
189
+ |---|---|---|---|
190
+ | `name`, `type`, `description`, `aliases` | `NameTyped` | `name`/`type` required | `type` is `aws`. |
191
+ | `executable`, `is_default`, `config`, `gitignore`, `tags` | `BuilderModel` | | `tags` merge into every image's tags; `is_default` makes this the runtime `default` resolves to. |
192
+ | `region` | `CloudBuilderModel` | required | boto3 `region_name`, the packer `region`, the `aws` provider block, `--region` on the release and storage-transition commands. |
193
+ | `default_machine_type` | `RuntimeBuilderModel` | required | Instance type when neither an OS-builder runtime entry nor an image names one; the instance type of a launched instance that declares no `machine_type`. |
194
+ | `default_image_builder` | `RuntimeBuilderModel` | `DEFAULT` | The image builder an OS-builder runtime entry with `image_builder: default` resolves to. |
195
+ | `default_owners` | `RuntimeBuilderModel` | `None` | Joins every vendor-image query's owner list on this runtime, after the OS builder's owners and before the entry's own. Read since stage 63 item 22: the lookup in [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py) keyed the runtime by the entry's `image_builder` name and never found it; it now takes the image builder's runtime. |
196
+ | `default_config_username` | `RuntimeBuilderModel` | `None` | The bake's SSH user when nothing more specific names one: step 5 of the bake-user order ([CONFIGURATION 5.1.1](../../docs/CONFIGURATION.md#511-the-bake-ssh-user)), read since stage 63 item 22. |
197
+ | `ephemeral` | `RuntimeBuilderModel` | `False` | See "Retention and the cycle". |
198
+ | `retention_keep` | `RuntimeBuilderModel` | `None` | Builds kept per series on this runtime when the image declares no retention. |
199
+ | `on_failure`, `teardown_after` | `RuntimeBuilderModel` | `None` | Runtime-level defaults for ephemeral instances. |
200
+
201
+ It overrides `finalize()` to run `update_networking()` after the base
202
+ finalization, and `csis_name()` to return `aws`. It carries the class
203
+ attribute `type = "aws"`.
204
+
205
+ Not supported: a top-level `profile:` key (the modelled key is
206
+ `credentials.profile_name`; unknown keys are rejected), a `parameters:` key
207
+ (retired; the load names the replacement), any session mechanism other than
208
+ `ssm`, and a run without a `region`.
209
+
210
+ ## The builder
211
+
212
+ `AwsCloudBuilder` extends `CloudBuilderBase` in
213
+ [builder_base_cloud.py](../base/src/cs_image_system/base/basic/builder_base_cloud.py),
214
+ an empty specialisation of `RuntimeBuilderBase` in
215
+ [builder_base_runtime.py](../base/src/cs_image_system/base/basic/builder_base_runtime.py).
216
+ The runtime builder writes no files of its own: `generate_items_during`
217
+ defers to the base and returns nothing. Everything it contributes reaches
218
+ `generated/` through the image, instance and release builders that call its
219
+ hooks. In the order a run reaches them:
220
+
221
+ | When | Hook | What it does |
222
+ |---|---|---|
223
+ | Configuration load | `AwsCloudBuilderModel.finalize()` | Runs `update_networking()` (network discovery below). |
224
+ | Resolution | `provider_specific_image_class()` | Returns `AwsProviderSpecificImage`. |
225
+ | Resolution | `query_provider_image(subconfig)` | Finds the vendor AMI for one OS-builder runtime entry (image query below). Returns `(ami_id, owner, raw_result)` or `None`. |
226
+ | Resolution | `query_provider_image_by_id(subconfig, image_id)` | Stage 63: the AMI an entry pins with `image_id`, looked up with `DescribeImages` by id (`aws_utils._query_by_ami_id`) instead of the query. Same return shape; `None` when AWS does not know the id. |
227
+ | Image generation | `packer_source_type()` | `amazon-ebs`. |
228
+ | Image generation | `packer_source_blocks(...)` | The `data "amazon-ami"` lookup and the `source "amazon-ebs"` block for one image. |
229
+ | Image generation | `session_mechanism()` | `ssm` or `None`; raises on any other declared value. |
230
+ | Image generation | `session_agent_commands(os_family)` | Installs and enables the SSM agent on a base image; `dpkg` path for `debian`/`ubuntu`, `yum` path otherwise. Empty when no mechanism is declared. |
231
+ | Image generation | `session_verify_commands(os_family)` | Assertions that the agent is present and enabled. Empty when no mechanism is declared. |
232
+ | Image generation | `default_bake_user(family)`, `bake_ssh_username(image)`, `bake_finalize_commands()` | `default_bake_user`: the vendor AMI's user, `admin` for `debian`, `ubuntu` for `ubuntu`, `ec2-user` otherwise (the last step of the bake-user order). `bake_ssh_username` keeps the base `None`, so the ansible provisioner keeps its own user on AWS; no closing provisioner. |
233
+ | After a bake | `build_id_from_artifact(artifact_id)` | Base default: the packer manifest's `<region>:<ami>` becomes the AMI id. |
234
+ | After a bake | `retag_image(ami, tags)` | The packer plugin stamps the resolved `csis_parent` and `csis_fingerprint` onto every recorded build. |
235
+ | Instance generation | `session_instance_profile()` | The instance profile the instance module attaches when the mechanism is `ssm`; `None` otherwise. |
236
+ | Instance verification | `verify_instance(name, expected_build, expect_mounts, timeout)` | Over SSM: waits for the launch completion marker `/var/lib/csis/launch-applied`, counts mounts under `/mnt`, compares the booted AMI with the expected build. |
237
+ | Instance operations | `run_session_command(name, script, timeout)` | `AWS-RunShellScript` through SSM on the RUNNING instance whose `Name` tag matches; polls the invocation to completion. |
238
+ | Power state (stage 57) | `can_query_instance_power_state()`, `query_instance_power_state(name)` | `True`; EC2's `State.Name` mapped onto the system's vocabulary (`pending` -> starting, `running`, `stopping`/`shutting-down` -> stopping, `stopped`, `terminated` -> absent, no instance -> absent, anything else -> unknown); `None` only when the cloud could not be asked. |
239
+ | Power state (stage 57) | `can_set_instance_power_state()`, `start_instance(name, timeout)`, `stop_instance(name, timeout)` | `True`; `start_instances` / `stop_instances` followed by the EC2 `instance_running` / `instance_stopped` waiter (5 s delay, `timeout // 5` attempts). Only `running_for_task` calls them. |
240
+ | Identity (stage 58) | `can_query_instance_identity()`, `query_instance_identity(name)` | `True`; `{"instance_id": InstanceId, "provider_hostname": PrivateDnsName}` of the named instance whatever its power state, or `None`. |
241
+ | Release | `release_commands(build_id, tags)` | One `aws ec2 create-tags --region ... --resources <ami> --tags Key=..,Value=..` executable, with `--profile` when `credentials.profile_name` is set. Runs only under `--no-dry-run` and only when the global `apply_release` is true. |
242
+ | State reconciliation | `retag_image(image_id, tags)` | `create-tags` on one of the system's own AMIs (`lineage relabel`, `lineage restamp`). |
243
+ | Retention | `dispose_image(build_id)` | Deregisters the AMI and deletes every EBS snapshot in its block device mappings. A missing AMI returns `False` (already gone). |
244
+ | State query | `query_images(series)` | Every AMI owned by `self` that carries a `csis_series` tag: `{image_id, name, state, created, tags}`. |
245
+ | State query | `can_query_instance_boot_image()`, `query_instance_boot_image(name)` | `True`; the `ImageId` of the pending or running instance whose `Name` tag matches, or `None`. Read-only, never fatal. |
246
+ | `empty --runtime` | `inventory()` | Not implemented on AWS; the base raises `NotImplementedError`, so the emptiness check refuses this runtime. |
247
+
248
+ Every instance lookup above is by the EC2 `Name` tag, which the instance
249
+ module sets to the instance's declared name
250
+ (`tags = merge({ Name = var.name }, var.tags)` in
251
+ [tfmodules/aws_instance/main.tf](../../tfmodules/aws_instance/main.tf)). Two
252
+ lookups differ on purpose: `_running_instance` filters on `pending`/`running`
253
+ (the boot-image query, the session command), `_named_instance` takes any
254
+ state and prefers a non-`terminated` record (the power-state and identity
255
+ queries, start and stop), because EC2 keeps a `terminated` record visible for
256
+ about an hour after the machine is gone.
257
+
258
+ ### Credentials
259
+
260
+ `AwsCloudBuilderModel.self_to_aws_client_config()` is the one conversion
261
+ point: `credentials.as_dict()` plus `region_name` from `region`. Every boto3
262
+ call in the plugin builds `boto3.Session(**config)` from it, so a
263
+ `profile_name` selects a profile and an empty `credentials:` lets boto3's
264
+ default chain run (environment variables, `AWS_PROFILE`, an instance role).
265
+ The same profile name is written into the packer source (`profile = "noaa"`
266
+ on both the data lookup and the source), passed as `--profile` to the release
267
+ command, and read by the state-backend, storage and instance plugins for
268
+ their provider blocks, backend files and transition scripts, so packer,
269
+ terraform and the plugin's own calls all authenticate the same way.
270
+
271
+ ### Network discovery at load
272
+
273
+ `update_networking()` in
274
+ [aws_runtime_models.py](src/cs_image_system/aws_runtime/aws_runtime_models.py)
275
+ calls `get_vpc_map_and_default_vpc_id()` in
276
+ [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py), which issues
277
+ `describe_vpcs`, `describe_subnets`, `describe_route_tables` and
278
+ `describe_security_groups` once each and builds:
279
+
280
+ - `vpc_map`: for every VPC, a `subnets` list of `{subnet_id, cidr, is_public, tags, config}`. A subnet is public when its effective route table (its own association, else the VPC's main table) has a `0.0.0.0/0` or `::/0` route whose gateway id starts with `igw-` or `vgw-`.
281
+ - `default_vpc_id`: the VPC with `IsDefault`.
282
+ - `all_security_groups`: every group by id.
283
+
284
+ Then it validates the declaration: `network: default` becomes the default
285
+ VPC (an error when the account has none); the VPC must exist; every
286
+ declared `subnets[].subnet_id` must be one of that VPC's subnets (stage 63,
287
+ below); every id in `addl_security_groups` must exist; more than five of
288
+ them is an error. The declared `subnets[].public` flag is the operator's
289
+ statement and is not overwritten by the discovered `is_public`.
290
+
291
+ The subnet check (stage 63) compares each declared `subnet_id` with the
292
+ subnets the account listed for the declared VPC in `vpc_map`. When a
293
+ declared id is not among them the load is refused with
294
+ `Subnet <id> (<name>) in networking configuration for AWS cloud builder
295
+ <runtime> is not in VPC <vpc>.` Until 2026-09-25 the declared subnet ids were
296
+ not checked at all, so a subnet from another VPC (or a typo) passed the load
297
+ and failed only at the first bake or apply. The check makes a claim only
298
+ when the account's answer lists subnets for the VPC: a VPC listed without
299
+ any subnets (an empty VPC, or a session that cannot see them) leaves every
300
+ declared subnet unchecked rather than refusing all of them. A load
301
+ therefore needs a live AWS session even for a dry run, and so does every
302
+ command that loads the configuration for another runtime (the GCE cycle
303
+ included).
304
+
305
+ ### The image query
306
+
307
+ `query_provider_image()` takes one OS-builder runtime entry
308
+ (`OSBuilderBaseImageBuilderSubconfig` in
309
+ [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py)).
310
+ `remap_for_image_query()` builds a `describe_images` request:
311
+
312
+ - `Owners` is the entry's `get_owners()`: the OS builder's owners, then the runtime's `default_owners`, then the entry's own, deduplicated in order (the runtime's were never found before stage 63 item 22).
313
+ - `query.filters` keys are mapped through `AWS_DI_MAP` (snake_case to EC2 filter names, `tag:<key>` kept), booleans lowercased, and `state: available` forced.
314
+ - Keys the map does not know go to a post-query exact-match filter on the result dictionaries.
315
+ - The rewrite works on a deep copy of the entry's `query` (stage 63). `get_query()` hands back the entry's own nested mappings, and until 2026-09-25 the rewrite injected `state: available` into the OS builder entry's own `filters` mapping on every resolution, so the loaded model changed each time an image was resolved. The entry is now left exactly as declared.
316
+
317
+ `query_image()` in [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py)
318
+ runs that `DescribeImages` query (paging through `describe_images`), keeps
319
+ every match that agrees with the post-query filter, and returns the newest
320
+ by `CreationDate`, or `None` when nothing matches. Its docstring says so
321
+ since stage 63; the unreachable `raise` that used to follow the return is
322
+ gone. The builder then reads the owner straight from the AMI record,
323
+ `ImageOwnerAlias` (for example `amazon`) else `OwnerId` (the account id),
324
+ and returns `(ImageId, owner, raw)`; the base registers an
325
+ `AwsProviderSpecificImage` in the resolved state. A record with neither
326
+ field raises `Could not get owner alias or owner id ...`. Before stage 63 a
327
+ second, unreachable `Could not get owner` branch followed that one; it was
328
+ removed on 2026-09-25. No match returns `None`, and the base's resolve step
329
+ then fails the run.
330
+
331
+ When the entry declares `image_id` (stage 63), the base's resolve step
332
+ does not call `query_provider_image()` at all: it calls
333
+ `query_provider_image_by_id(entry, image_id)`, which runs `DescribeImages`
334
+ with `ImageIds=[<id>]` through `_query_by_ami_id` in
335
+ [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py), with the
336
+ runtime's own client configuration (profile and region). The entry's
337
+ `owners` and `query` are not consulted. The owner is read from the record
338
+ the same way, `ImageOwnerAlias` else `OwnerId` (else `self`), and the
339
+ result is `(ImageId, owner, raw)`. An id AWS answers
340
+ `InvalidImageID.NotFound` or `InvalidImageID.Malformed` for, or one that
341
+ returns no image, gives `None`, and the base stops resolution with
342
+ `OS builder <name>: image_id '<id>' on runtime <rt> is not known to the
343
+ provider`; any other API refusal raises `AMIQueryError`. An AMI id is
344
+ regional, so a pinned id must exist in this runtime's region. Pinning
345
+ makes the base bake reproducible: the same vendor AMI every time, where
346
+ the query takes the newest match.
347
+
348
+ Stage 63 also removed helpers from
349
+ [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py) that nothing
350
+ called: `get_ami_owner`, `get_ami_ssh_user`, `remap_for_aws`,
351
+ `_query_by_name` and `_build_ami_filters`. `_query_by_ami_id` (an exact
352
+ lookup by AMI id) was kept, and since stage 63 it is what
353
+ `query_provider_image_by_id()` calls.
354
+
355
+ `AwsProviderSpecificImage.get_query_assets()` gives the packer
356
+ `data "amazon-ami"` body:
357
+
358
+ - resolved: `filters = { image-id = "<ami>" }` plus `owners = ["<owner>"]` when known;
359
+ - deferred (an image a build produces later): `owners = ["self"]`, `filters = { name = "<pattern>*", root-device-type = "ebs", virtualization-type = "hvm", architecture = "<arch or x86_64>" }`, `most_recent = true`.
360
+
361
+ [tests/test_aws_provider_specific_image.py](tests/test_aws_provider_specific_image.py)
362
+ pins both shapes.
363
+
364
+ ### The state query and instance hooks
365
+
366
+ `query_images`, `query_instance_boot_image`, `query_instance_power_state`,
367
+ `query_instance_identity`, `start_instance`, `stop_instance`,
368
+ `verify_instance`, `run_session_command`, `retag_image` and `dispose_image`
369
+ all go through `aws_utils.ec2_client()` / `aws_utils.aws_client("ssm", ...)`,
370
+ which build a client from the same session config. The state query in
371
+ [state_query.py](../base/src/cs_image_system/base/state_query.py) reads
372
+ `query_images` to classify `missing`, `foreign` and `changed` images, and
373
+ asks `query_instance_boot_image` only because `can_query_instance_boot_image()`
374
+ is `True`. When that answers `None` it asks `query_instance_power_state`
375
+ before calling the silence `unavailable`: a `stopped` machine becomes a
376
+ `note` in the report, not drift and not a failure to answer (stage 57).
377
+ `verify_instance` is the deferred verification step of the instance
378
+ lifecycle; `run_session_command` is how the system runs a script on an
379
+ instance (the unmount before a detach, the post-bake tests, the AltNames
380
+ write of stage 58). `query_instance_identity` feeds the generation ledger
381
+ and the alias pass in
382
+ [provider_aliases.py](../base/src/cs_image_system/base/provider_aliases.py).
383
+
384
+ ### Retention and the cycle
385
+
386
+ The closing `retention` lifecycle in
387
+ [retention.py](../base/src/cs_image_system/base/retention.py) runs
388
+ `dispose image --retention` at execution time. For each series on this
389
+ runtime the number of builds kept is, in order: `0` when the runtime is
390
+ `ephemeral`, the image's `retention.keep`, the runtime's `retention_keep`,
391
+ else everything. Builds beyond that are disposed through `dispose_image()`,
392
+ except released builds and builds an instance is pinned to or was launched
393
+ from, which are reported as retention debt and kept. Declared storages are
394
+ never touched by retention.
395
+
396
+ `ephemeral: true` on an AWS runtime means nothing baked there survives a
397
+ successful run. The fixture's AWS runtimes are not ephemeral and declare no
398
+ `retention_keep`, so every AMI baked on them stands until an explicit
399
+ `dispose image`. `on_failure` and `teardown_after` on the runtime are the
400
+ defaults for its ephemeral instances: `keep` leaves a failed instance
401
+ standing and fails the run; `teardown` tears it down and still fails the
402
+ run; `teardown_after: "2h"` lets a later run tear a standing failure down.
403
+
404
+ ### Cost posture
405
+
406
+ A run on an AWS runtime may leave standing: every AMI it baked and the
407
+ snapshots behind them (kept by default), every released AMI (exempt from
408
+ retention), the declared storages (managed by the storage plugins), every
409
+ non-ephemeral instance, and an ephemeral instance whose verification failed
410
+ under `on_failure: keep`. The packer build instance is terminated by packer;
411
+ `force_deregister = !var.release` lets a non-release rebuild replace an AMI
412
+ of the same name. Because `inventory()` is not implemented here,
413
+ `empty --runtime` cannot prove an AWS runtime empty; the state query and
414
+ `query_images` are the checks. An instance the operator switched off is
415
+ left off: the system never starts one except for a bounded task
416
+ (verification, the post-bake tests) and stops it again afterwards.
417
+
418
+ ## Emission
419
+
420
+ The plugin's output is visible in the golden emission under
421
+ [tests/fixtures/v2_golden/generated](../../tests/fixtures/v2_golden/generated).
422
+
423
+ - [pckr-ebs-ans-image-generation-source-basic-rh-10-block-000.pkr.hcl](../../tests/fixtures/v2_golden/generated/base-image/pckr-ebs-ans/image-generation/block-000/pckr-ebs-ans-image-generation-source-basic-rh-10-block-000.pkr.hcl):
424
+ the `data "amazon-ami"` lookup (`filters`, `owners`, `profile`) and the
425
+ `source "amazon-ebs"` block written by `amazon_ebs_source()` in
426
+ [aws_packer_source.py](src/cs_image_system/aws_runtime/aws_packer_source.py):
427
+ `source_ami`, `ami_name`, `instance_type` (the runtime entry's machine
428
+ type, else the runtime default), `region`, `vpc_id`, `subnet_id` (the
429
+ default subnet), the merged lineage `tags`, `profile`, `ena_support` and
430
+ `sriov_support` only when the runtime declares them (stage 63; the
431
+ fixture declares neither, so the golden source carries neither), and, because the
432
+ runtime declares `session_mechanism: ssm`, `ssh_interface =
433
+ "session_manager"`, `iam_instance_profile`, `associate_public_ip_address =
434
+ false`, `ssh_timeout = "15m"` and a `user_data` script that installs the
435
+ SSM agent on vendor images that lack it. `ssh_username` is what
436
+ `cs_image_system.base.bake_user.resolve_bake_user` answers (the image's
437
+ entry, the chain root's entry, `config_username`, this runtime's
438
+ `ssh_username` and `default_config_username`, then the family's vendor
439
+ user: `admin` for debian, `ubuntu` for ubuntu, `ec2-user` otherwise). `launch_block_device_mappings` uses the source
440
+ AMI's real `RootDeviceName` (else `/dev/xvda` for debian and amazon roots,
441
+ `/dev/sda1` otherwise), `gp3`, and `volume_size` from
442
+ `lineage.bake_disk_size(ctx, image, runtime)` (stage 63), the one rule the
443
+ input fingerprint also hashes: this runtime declares no
444
+ `default_disk_size`, so for a base image it is the OS builder entry's
445
+ `default_primary_disk_size` for this runtime when declared, else the OS
446
+ builder's `default_primary_disk_size` (200); for an instance image its
447
+ own `primary_disk_size`. The fixture's base images bake with
448
+ `volume_size = 200`.
449
+ - [pckr-ebs-ans-image-generation-block-000-build.pkr.hcl](../../tests/fixtures/v2_golden/generated/base-image/pckr-ebs-ans/image-generation/block-000/pckr-ebs-ans-image-generation-block-000-build.pkr.hcl):
450
+ the `# debug session mechanism (ssm)` shell provisioner per base image
451
+ (`session_agent_commands`) and the `# verify: SSM agent baked` lines inside
452
+ the in-bake verification provisioner (`session_verify_commands`). The
453
+ `amazon` entry in the neighbouring `-plugins.pkr.hcl` comes from the image
454
+ builder's `required_plugins`, not from this package.
455
+ - [open-tofu-instance-generation.tf](../../tests/fixtures/v2_golden/generated/instance-image/open-tofu/instance-generation/open-tofu-instance-generation.tf)
456
+ and
457
+ [open-tofu-instance-generation-instance-test.tf](../../tests/fixtures/v2_golden/generated/instance-image/open-tofu/instance-generation/open-tofu-instance-generation-instance-test.tf):
458
+ written by the instance plugin from this model. `provider "aws" { region,
459
+ profile, alias }`; an `aws_security_group` whose port-22 ingress lists
460
+ `ssh_ingress_security_group_ids` and no CIDR; the instance module's
461
+ `subnet_id` (the default subnet), `vpc_security_group_ids` (the generated
462
+ group, then `addl_security_groups`), and `iam_instance_profile`
463
+ (`session_instance_profile`). The `.tfbackend.hcl` beside them carries the
464
+ same `profile`, written by the state-backend plugin.
465
+ - [run-release.sh](../../tests/fixtures/v2_golden/generated/release/run-release.sh):
466
+ carries the `aws ec2 create-tags` line from `release_commands()` when a
467
+ release is declared. The golden declares none, so it is empty of them.
468
+ - Runtime facts: `cs-image-system runtime describe aws-east2-runtime`
469
+ ([runtime_facts.py](../base/src/cs_image_system/base/commands/runtime_facts.py))
470
+ reports `runtime`, `type`, `region`, `account_id`, `default_machine_type`,
471
+ `ephemeral`, `retention_keep`, the `images`, `storages`, `instances` and
472
+ `ephemeral_instances` declared on it, the `builders` bound to it and its
473
+ `emission` directories. It is a command, not a file.
474
+
475
+ ## Example configuration
476
+
477
+ From
478
+ [tests/fixtures/config/cfg/runtime-builders.yml](../../tests/fixtures/config/cfg/runtime-builders.yml),
479
+ the default runtime:
480
+
481
+ ```yaml
482
+ runtime_builders:
483
+ - name: aws-east2-runtime
484
+ is_default: true
485
+ aliases:
486
+ - aws
487
+ - amazon
488
+ - us-east-2
489
+ type: aws
490
+ account_id: "514190660293"
491
+ description: |
492
+ AWS runtime builder for the {{ this.region }} region account {{ this.account_id }}.
493
+ credentials:
494
+ profile_name: noaa
495
+ tags:
496
+ Project: MyProject
497
+ Environment: Development
498
+ region: us-east-2
499
+ # Debug sessions and bakes connect through SSM: no public IP, no SSH from outside.
500
+ session_mechanism: ssm
501
+ session_instance_profile: AmazonSSMRoleForInstancesQuickSetup
502
+ default_machine_type: t2.micro
503
+ default_image_builder: pckr-ebs-ans
504
+ networking:
505
+ # network is the VPC id
506
+ network: vpc-0c78d0d63b7a100df
507
+ # SSH reaches instances only through the session relay's group; no CIDR ingress
508
+ ssh_ingress_security_group_ids:
509
+ - sg-03015ec107ae5f81a
510
+ # existing groups every instance also wears (never modified here)
511
+ addl_security_groups:
512
+ - sg-03015ec107ae5f81a
513
+ subnets:
514
+ - name: east2-az1-private
515
+ subnet_id: subnet-09f79018af845358a
516
+ is_default: true
517
+ - name: east2-az2-private
518
+ subnet_id: subnet-00075cfbfcbc8f2cf
519
+ - name: east2-az1-public
520
+ subnet_id: subnet-01e56d2f9c1c95cc8
521
+ public: true
522
+ - name: east2-az2-public
523
+ subnet_id: subnet-0c127b762e075a94d
524
+ public: true
525
+ ```
526
+
527
+ The same file declares two more `type: aws` runtimes (`aws-east1`, `west1`)
528
+ with `network: default` and no session mechanism; they show the minimum an
529
+ AWS runtime needs: `name`, `type`, `region`, `credentials`,
530
+ `default_machine_type` and a `networking` block with one default subnet. The
531
+ live tree adds `availability_zone` to each subnet so that `validate` can
532
+ refuse a zone mismatch between an instance, its zonal storages and its
533
+ runtime's default subnet.
534
+
535
+ ## Prerequisites and integration
536
+
537
+ Everything below exists outside the system. The plugin creates none of it;
538
+ the rule in [docs/OPERATIONS.md](../../docs/OPERATIONS.md) ("The system
539
+ generates no IAM changes; IAP, SSM and impersonation are operator wiring")
540
+ applies to all of it.
541
+
542
+ **An AWS account and a region.** The plugin finds the region in the
543
+ runtime's `region` field. It acts in whatever account the credentials
544
+ resolve to; `account_id` is a fact for `runtime describe` and templates, not
545
+ a check.
546
+
547
+ **Credentials.** Found through `credentials:` on the runtime, converted by
548
+ `self_to_aws_client_config()`:
549
+
550
+ - `credentials.profile_name` names a profile in `~/.aws/config` (fixture and
551
+ live: `noaa`, an `sso-session` profile; `aws sso login --profile noaa`
552
+ opens it). This is the operator's path.
553
+ - `credentials.aws_access_key_id` / `aws_secret_access_key` /
554
+ `aws_session_token` carry `{{ ENV[...] }}` templates for static keys.
555
+ - An empty `credentials:` lets boto3's default chain run: `AWS_ACCESS_KEY_ID`
556
+ and friends, `AWS_PROFILE`, an instance role.
557
+
558
+ botocore drops the environment credential provider when a profile is named,
559
+ so a named profile must itself carry credentials: in CI the workflow writes
560
+ a `[noaa]` shim into `~/.aws/credentials` from the federated keys (the
561
+ `[noaa]` profile shim in [docs/OPERATIONS.md](../../docs/OPERATIONS.md)).
562
+ `preflight` reads the same field (else `AWS_PROFILE`) to find the SSO cache
563
+ under `~/.aws/sso/cache` (`CSIS_AWS_DIR` overrides the directory) and
564
+ reports the token's expiry; static keys have no readable expiry.
565
+
566
+ Because `update_networking()` runs at configuration load, the session must
567
+ be live for every load, dry runs and GCE-only work included. An
568
+ `sso-session` profile's token renews itself only while the browser portal
569
+ session lives; a session that lapses mid-run costs the legs that were
570
+ running (2026-09-08, and again 2026-09-21).
571
+
572
+ **IAM permissions for the caller.** What the plugin itself calls, all with
573
+ the session above: `ec2:DescribeVpcs`, `DescribeSubnets`,
574
+ `DescribeRouteTables`, `DescribeSecurityGroups` (every load);
575
+ `ec2:DescribeImages` (resolution, the state query, disposal);
576
+ `ec2:DescribeInstances` (every instance lookup); `ec2:CreateTags` (retag,
577
+ and the release command through the CLI); `ec2:DeregisterImage` and
578
+ `ec2:DeleteSnapshot` (disposal); `ec2:StartInstances`, `ec2:StopInstances`
579
+ (the bounded power-state exception); `ssm:SendCommand` and
580
+ `ssm:GetCommandInvocation` (verification, unmounts, post-bake tests, the
581
+ alias write). Packer's bake and the tofu roots need their own permissions
582
+ on top, including `iam:PassRole` for the instance profile and
583
+ `ssm:StartSession` for the Session Manager communicator; the CI table in
584
+ [docs/OPERATIONS.md](../../docs/OPERATIONS.md) (`AWS_ROLE_ARN` read-only,
585
+ `AWS_APPLY_ROLE_ARN` write) lists the two roles the live job uses.
586
+
587
+ **Network.** Found through `networking:`: the VPC (`network`, or `default`
588
+ for the account's default VPC), the subnets by id, the security groups by
589
+ id. The fixture's VPC has no internet gateway, which shapes everything else:
590
+ bakes and instances go on the default (private) subnet with
591
+ `associate_public_ip_address = false`, egress is the subnet's NAT, and both
592
+ packer and the operator reach machines through SSM. The default subnet must
593
+ route to the SSM endpoints and to the package repositories the bootstrap
594
+ downloads from (`s3.<region>.amazonaws.com`); SSH ingress is by
595
+ security-group reference only (`ssh_ingress_security_group_ids`, the Okta
596
+ gateway's group), and the gateway relay requires the target to wear that
597
+ same group (`addl_security_groups`).
598
+
599
+ **An SSM-capable instance profile.** Found in `session_instance_profile`
600
+ (live: `AmazonSSMRoleForInstancesQuickSetup`), attached to launched
601
+ instances by the instance plugin and to build instances by the packer
602
+ source. Without it, `session_mechanism: ssm` still bakes the agent but
603
+ packer keeps the SSH communicator and instances get no profile. The role
604
+ behind it needs the SSM managed policy; the caller needs `iam:PassRole` on
605
+ it.
606
+
607
+ **Tools.** Found through `cfg/executables.yml`
608
+ ([executables.yml](../../tests/fixtures/config/cfg/executables.yml)):
609
+
610
+ - `aws` (the entry named `aws-cli`, fixture floor `>=2.32`): the release
611
+ command and the storage builders' transition scripts run it; its version
612
+ is read by `AwsCLIVersionChecker` at `validate` and at every run.
613
+ - `packer` with the `amazon` plugin: declared on the image builder's
614
+ `required_plugins`, not here; the source this package emits needs it.
615
+ - The AWS Session Manager plugin for packer's `ssh_interface =
616
+ "session_manager"`: installed on the machine that bakes (CI installs it
617
+ before the performing step). Not declared in the tree; packer fails
618
+ without it.
619
+ - `tofu` with the `hashicorp/aws` provider for the instance and storage
620
+ roots, declared on those builders.
621
+ - Python: `boto3>=1.43`, `botocore>=1.43`, `python-hcl2>=8.1`,
622
+ `pydantic>=2.13`, and the `cs-image-system-hashicorp-utils` package for
623
+ the HCL formatter ([pyproject.toml](pyproject.toml)).
624
+
625
+ **Vendor images.** Found through the OS builders' `owners` and `query`
626
+ (fixture: AlmaLinux 10 from owner `764336703387`, the Debian images by
627
+ family name). The account must be allowed to see and launch them (Allowed
628
+ AMIs, Marketplace subscriptions where they apply).
629
+
630
+ **Instances reachable by name.** The instance module tags every machine
631
+ `Name=<declared name>`; every lookup in this plugin depends on that tag and
632
+ on the SSM agent being registered on the machine (baked in by
633
+ `session_agent_commands`, bootstrapped by the source's `user_data` on a
634
+ vendor image that lacks it). The launch script writes
635
+ `/var/lib/csis/launch-applied` as its last act; verification waits for it.
636
+
637
+ **Nothing from the identity side.** Enrollment, OPA and `sft` are the
638
+ identity plugins' concern; this plugin only runs their scripts on the
639
+ machine over SSM when asked.
640
+
641
+ ## Configuration reference
642
+
643
+ Every field the plugin reads from the YAML it owns (the `type: aws` entry
644
+ under `runtime_builders:`). The base's own fields are included because this
645
+ plugin is what reads them on AWS. Types are the model's; `default` in a
646
+ value column means the literal string `default`.
647
+
648
+ ### The runtime entry
649
+
650
+ | Field | Type | Default | Meaning |
651
+ |---|---|---|---|
652
+ | `name` | str | required | The runtime's name; the value other builders' `runtime:` and the lineage records carry. |
653
+ | `type` | str | required | `aws`. |
654
+ | `description` | str or null | null | Free text; templates such as `{{ this.region }}` resolve. |
655
+ | `aliases` | list[str] | `[]` | Extra names the runtime answers to. |
656
+ | `is_default` | bool | `false` | The runtime `default` resolves to; at most one per class. |
657
+ | `tags` | mapping[str, str] | `{}` | Merged into every image's tags, so they reach the AMI and the packer source. |
658
+ | `region` | str | required | boto3 `region_name`; the packer `region`; the provider block; `--region` on the CLI commands the plugin and the storage builders emit. |
659
+ | `default_machine_type` | str | required | The bake instance type when neither the OS-builder runtime entry (`default_machine_type`) nor the image's runtime entry (`machine_type`) names one; the instance type of a launched instance without `machine_type`. |
660
+ | `default_image_builder` | str | `default` | What an OS-builder runtime entry with `image_builder: default` resolves to. |
661
+ | `credentials` | `AwsCredentials` | `{}` | See the model above. An unknown key is a validation error naming its path. |
662
+ | `credentials.profile_name` | str or null | null | The profile. Reaches boto3, the packer source, the release command, the provider blocks and the backend files. |
663
+ | `credentials.aws_access_key_id`, `aws_secret_access_key`, `aws_session_token` | str or null | null | Static keys through `{{ ENV[...] }}` templates; reach boto3 only. |
664
+ | `account_id` | str or null | null | Reported by `runtime describe`; a template value. A bare number is coerced to a string. |
665
+ | `state_configuration` | str | `default` | The backend a storage or instance root on this runtime inherits when its own is `default`; `default` here means the default backend. |
666
+ | `session_mechanism` | str or null | null | `ssm` bakes the SSM agent into base images, makes packer connect through Session Manager, attaches `session_instance_profile` to instances, and makes every instance operation possible (verification, unmount, post-bake tests, aliases). Any other value is refused at generation. |
667
+ | `session_instance_profile` | str or null | null | The instance profile for launched instances and (when `iam_instance_profile` is unset) build instances. Read only when the mechanism is `ssm`. |
668
+ | `iam_instance_profile` | str or null | null | The build instances' profile in the packer source when `session_instance_profile` is unset; with both set the SSM profile wins (stage 63 item 9). Never reaches launched instances. |
669
+ | `ssh_username` | str | `default` | The bake SSH user for images that name none more specifically (step 4 of the bake-user order, CONFIGURATION 5.1.1). |
670
+ | `networking` | mapping or null | null | See below. Null loads with a warning and fails at image generation. |
671
+ | `ephemeral` | bool | `false` | Nothing baked here survives a successful run: the closing retention disposes every build on this runtime. |
672
+ | `retention_keep` | int or null | null | Builds kept per series when the image declares no `retention`; null keeps all. |
673
+ | `on_failure` | str or null | null | Default failure policy for ephemeral instances here: `keep` or `teardown`. |
674
+ | `teardown_after` | str or null | null | Default grace for ephemeral instances here: `<number>` then `m`, `h` or `d`. |
675
+ | `default_owners` | list[str] or null | null | Owners added to every vendor-image query on this runtime (stage 63 item 22). |
676
+ | `default_config_username` | str or null | null | The bake SSH user when no image entry, OS entry, `config_username` or runtime `ssh_username` names one (stage 63 item 22). |
677
+ | `ena_support` | bool or null | null | Declared `true` or `false`: written as `ena_support = true\|false` in the `amazon-ebs` packer source of every image baked here. Null: not written; packer's default applies (stage 63; until 2026-09-25 accepted and read by nothing). |
678
+ | `sriov_support` | bool or null | null | Declared `true` or `false`: written as `sriov_support = true\|false` in the same source. Null: not written; packer's default applies (stage 63; until 2026-09-25 accepted and read by nothing). |
679
+ | `executable`, `config`, `gitignore` | | | Accepted from `BuilderModel`; this plugin reads none of them. |
680
+ | `profile`, `parameters`, `runtime_classifier`, `executables` | | | Refused (unknown keys); the fixture's comments record the replacements. |
681
+
682
+ ### `networking`
683
+
684
+ | Field | Type | Default | Meaning |
685
+ |---|---|---|---|
686
+ | `name` | str | the network id | A label; stripped, and refused when empty afterwards. |
687
+ | `network` | str | `default` | The VPC id; `default` resolves at load to the account's default VPC. Written as the packer `vpc_id`, the instance root's `data "aws_vpc"` id and the EFS builder's `vpc_id`. |
688
+ | `subnets` | list | required, at least one | Exactly one entry with `is_default: true`; the load refuses none. |
689
+ | `subnets[].name` | str | the subnet id | Label. |
690
+ | `subnets[].subnet_id` | str | required | The subnet id. The default one is the packer `subnet_id` and the instance module's `subnet_id`; the EFS builder mounts on every non-public one (one per zone). Checked at load against the subnets the account lists for the declared VPC, and refused when it is not one of them (stage 63; until 2026-09-25 not checked). A VPC the account lists without subnets makes no claim. |
691
+ | `subnets[].is_default` | bool | `false` | The subnet bakes and instances use. |
692
+ | `subnets[].public` | bool | `false` | The operator's statement; not overwritten by discovery. The EFS builder skips public subnets. |
693
+ | `subnets[].cidr` | str or null | null | Informational. |
694
+ | `subnets[].availability_zone` | str or null | null | Declared, never inferred; `validate` refuses a zone incompatible with an instance's zonal storages. The EFS builder uses it to pick one subnet per zone. |
695
+ | `subnets[].config` | mapping | `{}` | Accepted, not read. |
696
+ | `availability_zones` | list | `[]` | `{name, is_default}`; the default one becomes the packer `availability_zone`. |
697
+ | `addl_security_groups` | list[str] | `[]` | Checked to exist at load; the only list counted toward the limit of five; appended to the instance's `vpc_security_group_ids`; the EFS mount targets' client groups. |
698
+ | `ssh_ingress_security_group_ids` | list[str] | `[]` | Not checked at load. The generated instance group's port-22 source; when empty the ingress is the VPC's own CIDR block instead. |
699
+ | `security_group_ids` | | | Refused (unknown key) since stage 63. Until 2026-09-25 it was checked and counted but reached no emitter; put the ids in `addl_security_groups`. |
700
+
701
+ ### What other entries contribute
702
+
703
+ These are not this plugin's fields, but the packer source reads them:
704
+
705
+ | Where | Field | Effect on AWS |
706
+ |---|---|---|
707
+ | OS builder `runtimes[]` entry | `default_machine_type` | The bake instance type for that base (the fixture bakes on `t3.medium` because a `t2.micro` OOMs). |
708
+ | OS builder `runtimes[]` entry | `ssh_username` | The bake user for that OS on this runtime (step 2 of the bake-user order); unset, the order continues down to the chain root family's vendor user. |
709
+ | OS builder `runtimes[]` entry | `owners`, `query` | The `describe_images` request. |
710
+ | OS builder `runtimes[]` entry | `image_id` | Stage 63: an AMI id that replaces the query; looked up by id with `DescribeImages`. |
711
+ | OS builder `runtimes[]` entry | `default_primary_disk_size` | Stage 63: the base image's `volume_size` on this runtime when declared. |
712
+ | OS builder | `default_primary_disk_size` | The base image's `volume_size` when the entry declares none (200 by default). |
713
+ | image `runtimes[]` entry | `machine_type` | The bake instance type for that instance image. |
714
+ | image | `primary_disk_size` | The root volume size of an instance-image bake, in GB. |
715
+ | instance | `machine_type` | The launched instance type; the runtime default otherwise. |
716
+
717
+ ### Variations
718
+
719
+ - **A queried vs a pinned vendor AMI.** Without `image_id` on the OS
720
+ builder's entry, resolution runs the `describe_images` query and takes
721
+ the newest match, so the next base bake may start from a newer vendor
722
+ AMI. With `image_id` (stage 63) the query is skipped and that one AMI is
723
+ looked up by id; it must exist in this runtime's region, and the id is
724
+ part of the fingerprint's vendor source.
725
+ - **Where the bake disk comes from.** `volume_size` is
726
+ `lineage.bake_disk_size` (stage 63): this runtime has no
727
+ `default_disk_size`, so a base image takes its entry's
728
+ `default_primary_disk_size`, else the OS builder's (200). Until
729
+ 2026-09-25 the fingerprint hashed the entry's value (then defaulting to
730
+ 100) while the bake used the OS builder's 200; the fingerprint now
731
+ hashes what is baked, so every AWS base image's recorded fingerprint
732
+ moved once and reads DUE until `lineage restamp --runtime <rt> --commit`
733
+ records the new one.
734
+
735
+ - **`session_mechanism: ssm` declared, with a profile.** The packer source
736
+ gets `ssh_interface = "session_manager"`, the profile as
737
+ `iam_instance_profile`, `associate_public_ip_address = false`,
738
+ `ssh_timeout = "15m"` and the agent-installing `user_data`; base images get
739
+ the agent provisioner and its verification lines; launched instances get
740
+ the profile; verification, unmounts, post-bake tests and alias writes work.
741
+ **Not declared:** the source keeps packer's SSH communicator with
742
+ `associate_public_ip_address = true` and no `user_data`, no agent
743
+ provisioner is emitted, instances get no profile, and every session-based
744
+ operation fails with SSM's `TargetNotConnected` / `InvalidInstanceId`.
745
+ **Declared without any profile:** the agent is baked and verified, but
746
+ the source keeps the SSH communicator (the SSM block needs a profile).
747
+ - **`iam_instance_profile` set beside `session_instance_profile`.** Both
748
+ build and launched instances wear `session_instance_profile` (the SSM
749
+ profile wins, stage 63 item 9); `iam_instance_profile` applies to build
750
+ instances only when the SSM profile is unset.
751
+ - **`networking.network: default`** resolves to the account's default VPC
752
+ at load and is refused when the account has none. **A VPC id** must be one
753
+ the session can describe. Either way, **a declared subnet in that VPC**
754
+ loads and **a subnet id from another VPC** (or a mistyped one) is refused
755
+ at load (stage 63; it used to pass and fail at the first bake or apply);
756
+ **a VPC the account lists without subnets** leaves the declared subnets
757
+ unchecked.
758
+ - **Security groups in `addl_security_groups`** are checked, counted
759
+ (at most five) and worn by every launched instance; **the same ids under
760
+ `security_group_ids`** are an unknown-key refusal at load (stage 63; until
761
+ 2026-09-25 accepted, checked and counted, but never emitted).
762
+ - **`ena_support` / `sriov_support` declared** (`true` or `false`) are
763
+ written into the `amazon-ebs` source as that value; **not declared**, the
764
+ source carries neither line and packer's defaults decide (stage 63; until
765
+ 2026-09-25 the fields were accepted and read by nothing, whatever their
766
+ value).
767
+ - **`ephemeral: true`** keeps nothing baked here past a successful run;
768
+ **`false`** (the fixture) keeps everything until `retention_keep`, an
769
+ image's `retention` or an explicit `dispose image`.
770
+ - **A pinned parent** (`parent_policy: pinned` with a pin, or a rebuild)
771
+ writes `filters = { image-id = "<pinned>" }`, `owners = ["self"]` into
772
+ the data lookup; **an unpinned or deferred parent** writes the
773
+ name-pattern / most-recent lookup from `get_query_assets()`; **a
774
+ resolved vendor image** writes its exact id and owner.
775
+ - **An entry's `ssh_username`** wins over the runtime's (stage 63 item 23;
776
+ the runtime's used to win); the OS builder's `config_username` wins over
777
+ the runtime's too. With nothing named anywhere, an image takes `admin`
778
+ (debian root), `ubuntu` (ubuntu root) or `ec2-user`.
779
+ - **A vendor query that resolved** gives the bake the real
780
+ `RootDeviceName`; **a deferred source** falls back by family (`/dev/xvda`
781
+ for debian and amazon, `/dev/sda1` otherwise).
782
+ - **Dry run versus real run.** A dry run still loads (network discovery
783
+ runs), still resolves vendor AMIs, still emits the source and provider
784
+ blocks, and still runs the state query's read-only hooks; it never bakes,
785
+ never tags, never disposes, never starts or stops a machine, and the
786
+ release and dispose commands print their plan. Only `--no-dry-run` runs
787
+ the runner scripts that call `aws ec2 create-tags` and `dispose image`.
788
+ - **`apply_release` on** (global `config`) adds the `create-tags`
789
+ executables to the release runner; **off**, `release` records the release
790
+ in meta-state and tags nothing in the cloud.
791
+ - **`apply_instances` on for this runtime** lets the instance root apply,
792
+ after which the plugin's instance hooks have something to find; **off**,
793
+ the lookups answer "absent" and the stage-58 alias pass does nothing.
794
+ - **A stopped instance** is a `note` in the state report, skips
795
+ verification and login proof unless the task may start it, and is left
796
+ off by the alias pass; **a running one** is verified and aliased; **a
797
+ terminated record** counts as absent.
798
+ - **An encrypted value** (`ENC[age:...]`) anywhere in the entry is
799
+ decrypted by the core before this plugin sees it; the plugin has no
800
+ behaviour of its own for encryption.
801
+
802
+ ## What it tests and verifies
803
+
804
+ **At load (pydantic validators and `finalize`).**
805
+
806
+ - `AwsCredentials`: unknown keys refused (`extra="forbid"`), so
807
+ `profile_nme` is a validation error naming `credentials.profile_nme`.
808
+ - `RuntimeNetworkingModel.__post_init__`: `network` non-empty; at least one
809
+ subnet; exactly one default subnet. `AwsCloudNetworkingModel`: the name
810
+ non-empty after stripping. `RuntimeBuilderModel`: `default_machine_type`
811
+ present. `CloudBuilderModel`: `region` present.
812
+ - `update_networking()` (at `finalize`, after the base): discovers the
813
+ account (four describe calls) and refuses a `default` network with no
814
+ default VPC, a VPC the account does not have, a declared
815
+ `subnets[].subnet_id` that is not among the subnets the account lists for
816
+ that VPC (stage 63; skipped when the account lists none for it), a
817
+ security group in `addl_security_groups` the account does not have, and
818
+ more than five groups in `addl_security_groups`. Missing
819
+ `networking` is a warning, not an error. Every verdict is a `ValueError`
820
+ with the message logged at ERROR first; the load fails and `run` exits 1
821
+ (`validate` exits 1).
822
+ - `security_group_ids` under `networking:` is refused as an unknown key by
823
+ the shared model config, like any other undeclared key (stage 63; until
824
+ 2026-09-25 it was a field that was validated and counted but never
825
+ emitted).
826
+ - The AWS CLI version: `validate` and every run look up the checker for
827
+ each declared executable; for the entry named `aws-cli` it is this
828
+ package's, and a missing binary, an unreadable version or one below the
829
+ floor fails validation by name.
830
+
831
+ **At `validate`.** Nothing of this plugin's own beyond the load and the
832
+ tool check. The base's N9 dead-end rule reads `session_mechanism()`: a base
833
+ image with no admin public key and no session mechanism on a runtime is a
834
+ validation error naming the runtime.
835
+
836
+ **At generation.** `session_mechanism()` raises `ValueError` on a value
837
+ other than `ssm`. `amazon_ebs_source()` asserts that the source image has
838
+ query assets (an `AssertionError` naming the image and runtime; it cannot
839
+ fire for a resolved or deferred PSI). `ena_support` and `sriov_support` are
840
+ written into the source only when declared, as the declared boolean; there
841
+ is no further check on them (stage 63). Resolution's `query_provider_image`
842
+ raises when the region is unset or the AMI record carries neither
843
+ `ImageOwnerAlias` nor `OwnerId`, and returns `None` on no match, which
844
+ [resolve.py](../base/src/cs_image_system/base/commands/resolve.py) turns
845
+ into a failed run. For an entry that pins `image_id`,
846
+ `query_provider_image_by_id` returns `None` when AWS does not know the id,
847
+ and resolve.py stops the run naming the id and the runtime (stage 63).
848
+
849
+ **At apply.** Nothing in this package runs at apply: packer and tofu do,
850
+ with what this package emitted. Packer's own in-bake verification carries
851
+ the two SSM assertions from `session_verify_commands` (the agent binary
852
+ present, the unit enabled); a failed assertion fails the bake.
853
+
854
+ **After apply (post-finalize hooks and deferred commands).**
855
+
856
+ - After a bake the packer plugin calls `retag_image()` for every recorded
857
+ build; the verdict is the AMI's tags (checked by the next state query).
858
+ - `verify instance <name>` (the ephemeral sequence, `just cloud-verify`,
859
+ the post-bake tests) calls `verify_instance()`: three checks named
860
+ `startup scripts` (the marker within `timeout`, polled every 15 s over
861
+ SSM), `booted image` (the running instance's `ImageId` against the
862
+ expected build, or against lineage when nothing is pinned yet) and, when
863
+ the instance declares storages, `data disks mounted` (`findmnt` targets
864
+ under `/mnt/` at least the declared count). The base appends
865
+ `declared tests` from `tests.post_bake`, run over `run_session_command`.
866
+ The verdict lands in `meta-state/verifications.yaml` with the last 20
867
+ lines of output as evidence; a failed verdict stops the runner and
868
+ `verify assert` fails the run.
869
+ - Before a detach, `unmount storage` runs the unmount script over
870
+ `run_session_command`; the verdict is the receipt under
871
+ `unmount-receipts/<instance>__<storage>.json` in the root's workspace,
872
+ which `gate-plan --require-unmounted` reads.
873
+ - After an applies-on instance run the alias pass asks
874
+ `query_instance_identity` and `query_instance_power_state`, and writes
875
+ AltNames over `run_session_command` to running machines only; the verdict
876
+ is a log line per instance and `reality.instances` in the state report.
877
+ The generation ledger (`meta-state/instance-state.yaml`) records the
878
+ provider's instance id from the same identity query.
879
+ - `verify login` skips a machine whose power state is not `running`.
880
+
881
+ **In the state query** (`state query`, `just cloud-preflight`, the start of
882
+ every run): `query_images` on this runtime feeds `missing`, `foreign` and
883
+ `changed`; `query_instance_boot_image` compares each pinned instance's
884
+ booted AMI with its pin (`changed` when they differ, a `note` when a
885
+ pending replacement explains it), and a standing ephemeral is reported;
886
+ `query_instance_power_state` turns a stopped machine into a `note` and a
887
+ stopped ephemeral into a STANDING line. A hook that raises makes an
888
+ `unavailable:` line, never a failure. The verdicts land in
889
+ `generated/state-report.json` (run-local, never committed) and on the
890
+ console; `--strict` exits 1 on anything but `stale`.
891
+
892
+ `tests/test_v2_bootstrap_aws.py` holds the bootstrap's AWS section: the
893
+ entry point and the tree-derived gate, the starter's placeholders and an
894
+ unaskable account refused by name under `--quiet`, the forks following a
895
+ stubbed account, the subject forms following the ids, the import blocks,
896
+ the backend (the declared bucket when it stands, local when the bootstrap
897
+ makes it, and the second interview that binds it), the module's
898
+ statements held to the guide's Sid for Sid, and a real `tofu validate` of
899
+ a root carrying the GitHub and AWS sections together.
900
+
901
+ ## When it fails
902
+
903
+ Failures that have happened, oldest first, then the ones the code raises
904
+ that have not. Dates are from [docs/history/LEDGER.md](../../docs/history/LEDGER.md),
905
+ [docs/OPERATIONS.md](../../docs/OPERATIONS.md) and the tests.
906
+
907
+ ### Seen live
908
+
909
+ - **2026-08-31, stage 1: packer's `data "amazon-ami"` found no credentials.**
910
+ `No valid credential sources found` from the datasource although the
911
+ operator's shell was logged in: packer does not read a profile it is not
912
+ told about. The source now carries `profile = "<profile_name>"` on both
913
+ the lookup and the source. If it recurs, the runtime's
914
+ `credentials.profile_name` is unset or the profile has no live session.
915
+ - **2026-08-31, stage 1: `Timeout waiting for SSH` on every bake.** The
916
+ VPC has no internet gateway, so packer's SSH communicator could never
917
+ reach the build instance. `session_mechanism: ssm` with a profile makes
918
+ packer connect through Session Manager; the same message today means the
919
+ Session Manager plugin is missing on the baking machine, the build
920
+ instance never registered with SSM (no route to the SSM endpoints from
921
+ the default subnet, the `user_data` bootstrap failed, or the profile
922
+ lacks the SSM policy), or the bake ran past `ssh_timeout = "15m"`. Look at
923
+ the packer log of the bake and at SSM Fleet Manager for the instance.
924
+ - **2026-08-31, stage 1: a Debian chain baked as `ec2-user` never
925
+ authenticated** (again `Timeout waiting for SSH`, over the SSM tunnel).
926
+ Only the vendor user's `authorized_keys` receives packer's key, so the
927
+ bake user now follows the chain root's family (`admin` for debian). A
928
+ runtime `ssh_username` or an entry `ssh_username` that names the wrong
929
+ user brings it back.
930
+ - **2026-08-31, stage 1: two root-slot volumes on the deb-11 chain.** A
931
+ hard-coded `/dev/sda1` device on a Debian root (whose root is
932
+ `/dev/xvda`) baked an AMI carrying two volumes in the root slot; instances
933
+ from it never booted. The mapping now uses the source AMI's real
934
+ `RootDeviceName`, else the family default. A deferred source still falls
935
+ back by family, so a chain whose root family is unknown to
936
+ `_chain_root_family` gets `/dev/sda1`.
937
+ - **2026-08-31 to 2026-09-01, stage 1: `t2.micro` out of memory** at bake
938
+ (`dnf update` killed) and on instances (the SSM agent killed, finding 25).
939
+ The OS builder entries bake on `t3.medium`; an instance declares its own
940
+ `machine_type`. The symptom is a bake that dies mid-provisioner or an
941
+ instance that stops answering SSM after boot.
942
+ - **Stage 1: `curl: (23)` re-downloading the SSM agent** on Debian during
943
+ the agent provisioner, because the `user_data` bootstrap had already left
944
+ a root-owned `/tmp/ssm.deb`. The provisioner now skips the install when
945
+ the agent is present and downloads to its own file name.
946
+ - **2026-09-01 to 2026-09-02, stage 1, findings 27 to 31: `sft ssh` hung
947
+ on the advertised private address.** Instances wore only the VPC default
948
+ group; the instance root now creates `csis-instances` with SSH ingress
949
+ from `ssh_ingress_security_group_ids` only (opening the shared CIDRs is
950
+ forbidden), and the gateway relay also needs the target in the gateway's
951
+ own group (`addl_security_groups`). An instance that cannot be reached by
952
+ the relay while SSM works points at those two lists.
953
+ - **2026-09-02 and 2026-09-03: orphan AMIs from failed and unrequested
954
+ bakes.** Eleven AMIs and twelve snapshots from stage 1, and two more from
955
+ stale runner scripts, appeared as `foreign` in the state query (they carry
956
+ our tags, no record has them). The operator deregistered them by hand;
957
+ `state import` adopts such an image, `dispose image` removes a recorded
958
+ one. A `foreign` line is never hard drift, but every line is actionable.
959
+ - **2026-09-08: a follow image kept its generation-time tags.** A child
960
+ baked in the same run as its parent was tagged with a `series-...`
961
+ parent and a placeholder fingerprint; the state query reported `changed`.
962
+ The packer plugin now calls `retag_image` after every bake, and `lineage
963
+ relabel --runtime <rt>` re-tags any build whose tags still disagree with
964
+ its record.
965
+ - **2026-09-08: the AWS SSO session lapsed mid-run.** A `tofu plan` failed
966
+ with `No valid credential sources found ... backend s3 ... the SSO
967
+ session has expired` after `cloud-preflight` had passed at the start;
968
+ the same lapse ends any load with `AWS Error: ...` from
969
+ `get_vpc_map_and_default_vpc_id`, on GCE work too. Environmental, not a
970
+ defect: `aws sso login --profile noaa`, then `just full-test-legs` or the
971
+ legs that were running. `preflight` and the strict state query flag a
972
+ session expiring within `config.preflight.expected_run_minutes`;
973
+ an `sso-session` profile reports no fixed expiry.
974
+ - **2026-09-09: the first live AWS disposal** (`dispose image <two EL8
975
+ AMIs> --no-dry-run --commit`) deregistered both and deleted their
976
+ snapshots; `describe-images` on both ids answered an empty list
977
+ afterwards. Not a failure, but the proof that a disposal's success is
978
+ read from the cloud, not from the log line.
979
+ - **2026-09-10, ledger 72: `InvocationDoesNotExist` right after
980
+ `send_command`.** The unmount had already run on the instance when
981
+ `get_command_invocation` raised, so no receipt was written and the gate
982
+ refused the detach. The poll now tolerates that code until its deadline
983
+ ([test_v2_aws_parity.py](../../tests/test_v2_aws_parity.py)); any other
984
+ SSM error still raises.
985
+ - **2026-09-12, stage 17: `credentials.profile_nme` was silently dropped.**
986
+ With `credentials` a plain mapping the typo left the profile unset and
987
+ boto3 fell back to `AWS_PROFILE` or nothing. The typed `AwsCredentials`
988
+ makes it a validation error naming `credentials.profile_nme`.
989
+ - **2026-09-21, stage 57: a stopped machine was reported as "the provider
990
+ could not answer".** The boot-image probe filters on `running`, so a
991
+ machine the operator had switched off looked like an unreachable cloud
992
+ and `run_session_command` said `no running instance` for both. The
993
+ power-state hook separates them: the state report says `STOPPED (switched
994
+ off; not drift)` in a `note`, and a session command on a stopped machine
995
+ fails with `cannot run a command on '<name>': it is STOPPED (switched off;
996
+ not drift)`.
997
+ - **2026-09-22: the strict query refused the launch that finishes an
998
+ upgrade.** After `upgrade instance` moved the pin, the booted AMI was
999
+ behind it by design, and `cloud-launch`'s preflight called that
1000
+ `changed`. With the pending-replacement marker present it is now a
1001
+ `note`; without the marker it is drift again.
1002
+ - **2026-09-22, stage 58: the first live replacement wrote no aliases.**
1003
+ The alias pass ran before the new machine had enrolled; a machine launched
1004
+ in the same run is now waited for, and one that never enrolls gets no
1005
+ alias that run (the log says so).
1006
+
1007
+ ### Raised by the code, not yet seen
1008
+
1009
+ Load (`ValueError`, logged at ERROR, `run`/`validate` exit 1):
1010
+
1011
+ ```text
1012
+ No VPC ID specified in networking configuration for AWS cloud builder <name>, and no default VPC found in AWS account. ...
1013
+ VPC ID <vpc> specified in networking configuration for AWS cloud builder <name> not found in AWS account.
1014
+ Subnet <id> (<name>) in networking configuration for AWS cloud builder <runtime> is not in VPC <vpc>.
1015
+ Security group ID <sg> specified in networking configuration for AWS cloud builder <name> not found in AWS account.
1016
+ Total number of security groups specified in networking configuration for AWS cloud builder <name> is <n>, which may exceed limits for certain instance types. ...
1017
+ AWS Error: <botocore message>
1018
+ Networking configuration name cannot be empty for <model>.
1019
+ ```
1020
+
1021
+ The subnet line (stage 63) means a declared `subnets[].subnet_id` is not one
1022
+ of the subnets the account lists for the declared VPC: a typo, a subnet from
1023
+ another VPC or account, or a `network: default` that resolved to a VPC other
1024
+ than the one the subnets belong to. Fix the subnet id or the `network` in the
1025
+ tree. Before 2026-09-25 this passed the load and surfaced at the first bake
1026
+ or apply as a packer or tofu error about the subnet. The security-group
1027
+ count line counts only `addl_security_groups` since stage 63.
1028
+
1029
+ Declaring `networking.security_group_ids` fails the load as an unknown key
1030
+ (stage 63), with the shared model config's extra-key error naming
1031
+ `security_group_ids`. Until 2026-09-25 the field was accepted, checked and
1032
+ counted but never emitted; move its ids into `addl_security_groups`, which
1033
+ is the list that reaches the instance and storage roots.
1034
+
1035
+ `AWS Error:` wraps every `BotoCoreError`/`ClientError` of the discovery: an
1036
+ unknown profile (`The config profile (<p>) could not be found`), an expired
1037
+ token, a role without the four describe permissions
1038
+ (`UnauthorizedOperation`), no network. The message names the cause; the fix
1039
+ is on the credential side, never in the tree. The warning
1040
+ `No networking configuration provided for AWS cloud builder <name>` is not
1041
+ fatal at load but the image generation that follows raises on the missing
1042
+ VPC.
1043
+
1044
+ Resolution:
1045
+
1046
+ ```text
1047
+ Failed to create EC2 client: <e> (AMIQueryError)
1048
+ Failed to query AMI with filters: <e> (AMIQueryError: a describe_images refusal, e.g. an invalid filter name)
1049
+ Region must be specified in builder config to resolve image identifier, but got None
1050
+ Could not get owner alias or owner id for image <ami> in region <region>, got <dict>
1051
+ No resolved Image identifiers for OS <os builder> in predefined_resolve (base: the query matched nothing)
1052
+ Failed to query AMI by ID: <e> (AMIQueryError: a DescribeImages refusal other than NotFound/Malformed, for a pinned image_id)
1053
+ OS builder <name>: image_id '<id>' on runtime <rt> is not known to the provider (base, stage 63: the pinned AMI is not in this region, is deregistered, or is malformed)
1054
+ ```
1055
+
1056
+ The `No resolved Image identifiers` line is the usual symptom of a vendor query that is too narrow, an
1057
+ owner id that is wrong, or a vendor image that went away; the request that
1058
+ was sent is logged at DEBUG (`Querying for AMI with filters: ...`) and the
1059
+ count found at INFO.
1060
+
1061
+ Generation:
1062
+
1063
+ ```text
1064
+ AWS runtime <name>: unknown session mechanism '<value>' (supported: ssm)
1065
+ Provider-specific image for image <image> on runtime <rt> must have query assets ... (AssertionError)
1066
+ ```
1067
+
1068
+ Instance operations (`RuntimeError` unless noted; the caller records or
1069
+ reports it):
1070
+
1071
+ ```text
1072
+ AWS runtime <name>: cannot run a command on '<instance>': it is STOPPED (switched off; not drift)
1073
+ AWS runtime <name>: cannot run a command on '<instance>': it is absent (no such machine)
1074
+ AWS runtime <name>: cannot run a command on '<instance>': it is power state unavailable (no runtime could answer)
1075
+ AWS runtime <name>: no instance named '<instance>' to start
1076
+ AWS runtime <name>: no instance named '<instance>' to stop
1077
+ ssm command <id> still <status> after <timeout>s (returned as exit 1 with this text, not raised)
1078
+ ```
1079
+
1080
+ An SSM `send_command` on a running instance whose agent is not registered
1081
+ raises botocore's `InvalidInstanceId`; a machine that exists but cannot be
1082
+ reached shows as `TargetNotConnected` in the verification evidence. The EC2
1083
+ waiters raise `WaiterError` when a start or stop exceeds `timeout`;
1084
+ `running_for_task` then logs the machine as started but unreachable and
1085
+ skips the work, and a failed stop is logged at ERROR with the instruction to
1086
+ stop it by hand. An unrecognised `State.Name` is logged
1087
+ (`reports unrecognised state '<raw>'; treating it as unknown`) and answered
1088
+ as `unknown`.
1089
+
1090
+ Verification (`verify_instance`; the check's `detail` in
1091
+ `meta-state/verifications.yaml`):
1092
+
1093
+ ```text
1094
+ no completion marker within <timeout>s
1095
+ booted <ami>, expected <build>
1096
+ <n> mount(s) under /mnt, <m> declared
1097
+ ```
1098
+
1099
+ Disposal and tags: `AMI <id> not found; nothing to deregister` is a
1100
+ warning and returns `False` (the record is still dropped by the caller);
1101
+ any other `describe_images`, `deregister_image` or `delete_snapshot`
1102
+ refusal raises and stops `dispose image` with nothing recorded. A
1103
+ `create_tags` refusal in `retag_image` is caught by `lineage relabel`
1104
+ (`could not retag <id>`) and the state query keeps showing `changed`; in
1105
+ the packer plugin's post-bake retag it propagates.
1106
+
1107
+ The state query and the read-only probes never fail a run on this plugin's
1108
+ account: `query_images` raising becomes
1109
+ `state query images/<rt> unavailable: <e>` and an `unavailable:` line;
1110
+ `query_instance_boot_image`, `query_instance_power_state` and
1111
+ `query_instance_identity` swallow every exception into a DEBUG line and
1112
+ answer `None` (no claim).
1113
+
1114
+ Release: the runner script runs `aws ec2 create-tags ... --profile <p>`
1115
+ under `set -euo pipefail`; a missing `aws` binary, a profile without a
1116
+ session or a role without `ec2:CreateTags` fails the release lifecycle's
1117
+ apply (`failed` in `run-summary.json`) after meta-state already recorded
1118
+ the release. Re-run `run release` once the cause is fixed; the command is
1119
+ idempotent.
1120
+
1121
+ `empty --runtime <aws runtime>` prints
1122
+ `empty: AwsCloudBuilder cannot list its inventory` and exits 2; the
1123
+ runtime cannot be proved empty this way.
1124
+
1125
+ Tool check (`validate` and every run, exit 1):
1126
+
1127
+ ```text
1128
+ aws-cli: binary 'aws' not found (declared in cfg/executables.yml; an absolute path, or a name on PATH)
1129
+ aws-cli: could not read its version (`aws --version`): <e>
1130
+ aws-cli: AwsCLIVersionChecker could not parse a version from `aws --version`
1131
+ ```
1132
+
1133
+ and the base's version-floor message naming `aws-cli` and the requirement
1134
+ when the installed version is below it.
1135
+
1136
+ ## Related
1137
+
1138
+ - [gcloud-runtime-plugin](../gcloud-runtime-plugin/README.md): the other runtime type, with the same hook surface on Compute Engine.
1139
+ - [default-os-plugin](../default-os-plugin/README.md): the OS builders whose runtime entries this plugin resolves to AMIs.
1140
+ - [packer-plugin](../packer-plugin/README.md): the image builder that asks this plugin for the `amazon-ebs` source and the session provisioners.
1141
+ - [tf-ebs-instance-plugin](../tf-ebs-instance-plugin/README.md): the instance and storage roots that read this model's networking, profile and session profile.
1142
+ - [dummy-plugin](../dummy-plugin/README.md): the extension template; an unregistered copy of its group model sits at the bottom of this package's models module.
1143
+ - Base classes: [runtime.py](../base/src/cs_image_system/base/models/runtime.py), [cloud_builder.py](../base/src/cs_image_system/base/models/cloud_builder.py), [credentials.py](../base/src/cs_image_system/base/models/credentials.py), [provider_specific_image.py](../base/src/cs_image_system/base/models/provider_specific_image.py), [builder_base_runtime.py](../base/src/cs_image_system/base/basic/builder_base_runtime.py), [abstract_version_checker.py](../base/src/cs_image_system/base/basic/abstract_version_checker.py).
1144
+ - Consumers of the hooks: [state_query.py](../base/src/cs_image_system/base/state_query.py), [retention.py](../base/src/cs_image_system/base/retention.py), [dispose.py](../base/src/cs_image_system/base/commands/dispose.py), [verify_instance.py](../base/src/cs_image_system/base/commands/verify_instance.py), [unmount.py](../base/src/cs_image_system/base/commands/unmount.py), [relabel.py](../base/src/cs_image_system/base/commands/relabel.py), [release.py](../base/src/cs_image_system/base/release.py), [power_state.py](../base/src/cs_image_system/base/power_state.py), [provider_aliases.py](../base/src/cs_image_system/base/provider_aliases.py), [runtime_facts.py](../base/src/cs_image_system/base/commands/runtime_facts.py).
1145
+ - [docs/OPERATIONS.md](../../docs/OPERATIONS.md) for the credential contract and the operator's cycles; [docs/DESIGN.md](../../docs/DESIGN.md) for the design; [docs/PLUGINS.md](../../docs/PLUGINS.md) for the package index.
1146
+ - [The configuration reference](../../docs/CONFIGURATION.md) -- every field of the YAML this plugin reads, with an example.