cs-image-system-aws-runtime-plugin 0.1.4__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (15) hide show
  1. cs_image_system_aws_runtime_plugin-0.1.4/README.md → cs_image_system_aws_runtime_plugin-0.1.5/PKG-INFO +99 -41
  2. cs_image_system_aws_runtime_plugin-0.1.4/PKG-INFO → cs_image_system_aws_runtime_plugin-0.1.5/README.md +79 -61
  3. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/pyproject.toml +4 -4
  4. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/src/cs_image_system/aws_runtime/aws_provider_specific_image.py +10 -13
  5. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/src/cs_image_system/aws_runtime/aws_runtime_builders.py +10 -3
  6. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/src/cs_image_system/aws_runtime/aws_utils.py +66 -82
  7. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/src/cs_image_system/aws_runtime/bootstrap.py +107 -5
  8. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/.gitignore +0 -0
  9. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/LICENSE +0 -0
  10. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/src/cs_image_system/aws_runtime/__init__.py +0 -0
  11. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/src/cs_image_system/aws_runtime/aws_packer_source.py +0 -0
  12. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/src/cs_image_system/aws_runtime/aws_runtime_models.py +0 -0
  13. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/src/cs_image_system/aws_runtime/main.py +0 -0
  14. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/src/cs_image_system/py.typed +0 -0
  15. {cs_image_system_aws_runtime_plugin-0.1.4 → cs_image_system_aws_runtime_plugin-0.1.5}/tests/test_aws_provider_specific_image.py +0 -0
@@ -1,3 +1,23 @@
1
+ Metadata-Version: 2.5
2
+ Name: cs-image-system-aws-runtime-plugin
3
+ Version: 0.1.5
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-base==0.1.5
15
+ Requires-Dist: cs-image-system-contract==0.1.5
16
+ Requires-Dist: cs-image-system-hashicorp-utils==0.1.5
17
+ Requires-Dist: pydantic>=2.13
18
+ Requires-Dist: python-hcl2>=8.1
19
+ Description-Content-Type: text/markdown
20
+
1
21
  # cs-image-system-aws-runtime-plugin
2
22
 
3
23
  The `aws` runtime type. A runtime is where images are baked and instances
@@ -56,9 +76,13 @@ Present in the package but not registered as a service:
56
76
  cs_image_system.aws_runtime.bootstrap:aws_section`. It owns the questions
57
77
  and the terraform for this cloud's one-time initialisation -- the GitHub
58
78
  OIDC identity provider, the READ-ONLY and WRITE roles of CI_SETUP.md 3.3
59
- with their trust and permission documents, and the state bucket and the
60
- Session Manager instance profile when the account lacks them
61
- (`tfmodules/bootstrap_aws`). The section is wanted by default where the
79
+ with their trust and permission documents, and the state buckets (the
80
+ default backend's, and since hygiene XX every other declared S3
81
+ backend's that a root is bound to) and the Session Manager instance
82
+ profile when the account lacks them (`tfmodules/bootstrap_aws`). The
83
+ section renders its coverage audit for the generated README: what it
84
+ made, read, adopted, or leaves by hand and why (a backend bound to no
85
+ root is left out and said so). The section is wanted by default where the
62
86
  tree declares an AWS runtime or an S3 state backend. Its defaults come
63
87
  from the raw `cfg/runtime-builders.yml` and `cfg/state-backends.yml`
64
88
  (read as YAML, never loaded; a starter's `REPLACE-ME` values and its
@@ -287,27 +311,64 @@ included).
287
311
 
288
312
  `query_provider_image()` takes one OS-builder runtime entry
289
313
  (`OSBuilderBaseImageBuilderSubconfig` in
290
- [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py)).
291
- `remap_for_image_query()` builds a `describe_images` request:
292
-
293
- - `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).
294
- - `query.filters` keys are mapped through `AWS_DI_MAP` (snake_case to EC2 filter names, `tag:<key>` kept), booleans lowercased, and `state: available` forced.
295
- - Keys the map does not know go to a post-query exact-match filter on the result dictionaries.
296
- - 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.
297
-
314
+ [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py))
315
+ and asks it for its effective query, `entry.image_query()`: an
316
+ `ImageQuery` in the SYSTEM's vocabulary
317
+ ([contract/image.py](../contract/src/cs_image_system/contract/image.py);
318
+ stage 106). That query is the image-source catalog's `aws` block for the
319
+ OS builder's family (the release's layer under the tree's
320
+ `cfg/image-sources*.yml`), with the OS builder's own `query` landing on it
321
+ key by key and the runtime entry's landing on that; the publishers declared
322
+ anywhere (the OS builder's `owners`, this runtime's `default_owners`, the
323
+ entry's `owners`, in that order, deduplicated) replace the catalog's. The
324
+ entry's own `query` mapping is never rewritten (stage 63). The builder then
325
+ translates it with `translate_image_query()` (the table below) and
298
326
  `query_image()` in [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py)
299
- runs that `DescribeImages` query (paging through `describe_images`), keeps
300
- every match that agrees with the post-query filter, and returns the newest
301
- by `CreationDate`, or `None` when nothing matches. Its docstring says so
302
- since stage 63; the unreachable `raise` that used to follow the return is
303
- gone. The builder then reads the owner straight from the AMI record,
304
- `ImageOwnerAlias` (for example `amazon`) else `OwnerId` (the account id),
305
- and returns `(ImageId, owner, raw)`; the base registers an
306
- `AwsProviderSpecificImage` in the resolved state. A record with neither
307
- field raises `Could not get owner alias or owner id ...`. Before stage 63 a
308
- second, unreachable `Could not get owner` branch followed that one; it was
309
- removed on 2026-09-25. No match returns `None`, and the base's resolve step
310
- then fails the run.
327
+ runs the `DescribeImages` request `describe_images_request()` makes from
328
+ it, paging through the results, and returns the newest by `CreationDate`,
329
+ or `None` when nothing matches; with `newest: false` more than one match
330
+ raises `AMIQueryError` as ambiguous. The builder reads the owner straight
331
+ from the AMI record, `ImageOwnerAlias` (for example `amazon`) else
332
+ `OwnerId` (the account id), and returns `(ImageId, owner, raw)`; the base
333
+ registers an `AwsProviderSpecificImage` in the resolved state. A record
334
+ with neither field raises `Could not get owner alias or owner id ...`. No
335
+ match returns `None`, and the base's resolve step then fails the run.
336
+ `validate` has already refused an entry whose query uses a key this
337
+ runtime cannot translate, or has no source at all, and has printed every
338
+ entry's query in both vocabularies (`image source: OS builder '<name>' on
339
+ runtime <rt> (aws): <system query> -> <EC2 query>`).
340
+
341
+ Before stage 106 the entry's `query.filters` were EC2's field names in
342
+ snake case (the system started on AWS and used its filter keys: the
343
+ operator's incidental choice), passed through a key map, with unknown keys
344
+ going to a post-query exact-match filter. The vocabulary is the system's
345
+ now, EC2's spellings kept where they were good names; the old spellings
346
+ still read (`root_device_type`, `virtualization_type`, `owners`, the
347
+ nested `filters:` form), and an unknown key is refused by name instead of
348
+ buried.
349
+
350
+ ### Image query translation
351
+
352
+ What `translate_image_query()` does with each key of the system's
353
+ vocabulary (`supported_query_keys()` is the set of rows not marked
354
+ refused; [tests/test_docs_image_sources.py](../../tests/test_docs_image_sources.py)
355
+ holds this table to it). The answer is `{"owners": [...], "filters":
356
+ {<EC2 filter name>: value}, "most_recent": bool}`: what packer's
357
+ `data "amazon-ami"` body and `describe_images` both take.
358
+
359
+ | System key | EC2 filter | Note |
360
+ | --- | --- | --- |
361
+ | `name` | `name` | a glob over the AMI name |
362
+ | `family` | refused | EC2 has no image families; name the image |
363
+ | `version` | refused | the catalog fills `{version}` into `name` |
364
+ | `architecture` | `architecture` | `x86_64` or `arm64`, EC2's own words |
365
+ | `publisher` | `owners` | account ids and the aliases `self`, `amazon`, `aws-marketplace` |
366
+ | `state` | `state` | `available` when unset: the query always forced it |
367
+ | `virtualization` | `virtualization-type` | `hvm` |
368
+ | `root_device` | `root-device-type` | `ebs` |
369
+ | `tags` | `tag:<key>` | one filter per tag |
370
+ | `newest` | `most_recent` | true when unset; false refuses more than one match |
371
+ | `native` | verbatim | each key an EC2 filter name, passed as written |
311
372
 
312
373
  When the entry declares `image_id` (stage 63), the base's resolve step
313
374
  does not call `query_provider_image()` at all: it calls
@@ -315,29 +376,26 @@ does not call `query_provider_image()` at all: it calls
315
376
  with `ImageIds=[<id>]` through `_query_by_ami_id` in
316
377
  [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py), with the
317
378
  runtime's own client configuration (profile and region). The entry's
318
- `owners` and `query` are not consulted. The owner is read from the record
319
- the same way, `ImageOwnerAlias` else `OwnerId` (else `self`), and the
320
- result is `(ImageId, owner, raw)`. An id AWS answers
321
- `InvalidImageID.NotFound` or `InvalidImageID.Malformed` for, or one that
322
- returns no image, gives `None`, and the base stops resolution with
323
- `OS builder <name>: image_id '<id>' on runtime <rt> is not known to the
324
- provider`; any other API refusal raises `AMIQueryError`. An AMI id is
325
- regional, so a pinned id must exist in this runtime's region. Pinning
326
- makes the base bake reproducible: the same vendor AMI every time, where
327
- the query takes the newest match.
328
-
329
- Stage 63 also removed helpers from
330
- [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py) that nothing
331
- called: `get_ami_owner`, `get_ami_ssh_user`, `remap_for_aws`,
332
- `_query_by_name` and `_build_ami_filters`. `_query_by_ami_id` (an exact
333
- lookup by AMI id) was kept, and since stage 63 it is what
334
- `query_provider_image_by_id()` calls.
379
+ query is not consulted. The owner is read from the record the same way,
380
+ `ImageOwnerAlias` else `OwnerId` (else `self`), and the result is
381
+ `(ImageId, owner, raw)`. An id AWS answers `InvalidImageID.NotFound` or
382
+ `InvalidImageID.Malformed` for, or one that returns no image, gives
383
+ `None`, and the base stops resolution with `OS builder <name>: image_id
384
+ '<id>' on runtime <rt> is not known to the provider`; any other API
385
+ refusal raises `AMIQueryError`. An AMI id is regional, so a pinned id must
386
+ exist in this runtime's region. Pinning makes the base bake reproducible:
387
+ the same vendor AMI every time, where the query takes the newest match.
335
388
 
336
389
  `AwsProviderSpecificImage.get_query_assets()` gives the packer
337
390
  `data "amazon-ami"` body:
338
391
 
339
392
  - resolved: `filters = { image-id = "<ami>" }` plus `owners = ["<owner>"]` when known;
340
- - 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`.
393
+ - deferred (an image a build produces later): an `ImageQuery` (`name =
394
+ "<pattern>*"`, `root_device = ebs`, `virtualization = hvm`, the
395
+ architecture or `x86_64`, publisher `self`) translated like a vendor
396
+ query and quoted: `owners = ["self"]`, `filters = { name = "<pattern>*",
397
+ root-device-type = "ebs", virtualization-type = "hvm", architecture =
398
+ "<arch>" }`, `most_recent = true`.
341
399
 
342
400
  [tests/test_aws_provider_specific_image.py](tests/test_aws_provider_specific_image.py)
343
401
  pins both shapes.
@@ -1,23 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: cs-image-system-aws-runtime-plugin
3
- Version: 0.1.4
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-base==0.1.4
15
- Requires-Dist: cs-image-system-contract==0.1.4
16
- Requires-Dist: cs-image-system-hashicorp-utils==0.1.4
17
- Requires-Dist: pydantic>=2.13
18
- Requires-Dist: python-hcl2>=8.1
19
- Description-Content-Type: text/markdown
20
-
21
1
  # cs-image-system-aws-runtime-plugin
22
2
 
23
3
  The `aws` runtime type. A runtime is where images are baked and instances
@@ -76,9 +56,13 @@ Present in the package but not registered as a service:
76
56
  cs_image_system.aws_runtime.bootstrap:aws_section`. It owns the questions
77
57
  and the terraform for this cloud's one-time initialisation -- the GitHub
78
58
  OIDC identity provider, the READ-ONLY and WRITE roles of CI_SETUP.md 3.3
79
- with their trust and permission documents, and the state bucket and the
80
- Session Manager instance profile when the account lacks them
81
- (`tfmodules/bootstrap_aws`). The section is wanted by default where the
59
+ with their trust and permission documents, and the state buckets (the
60
+ default backend's, and since hygiene XX every other declared S3
61
+ backend's that a root is bound to) and the Session Manager instance
62
+ profile when the account lacks them (`tfmodules/bootstrap_aws`). The
63
+ section renders its coverage audit for the generated README: what it
64
+ made, read, adopted, or leaves by hand and why (a backend bound to no
65
+ root is left out and said so). The section is wanted by default where the
82
66
  tree declares an AWS runtime or an S3 state backend. Its defaults come
83
67
  from the raw `cfg/runtime-builders.yml` and `cfg/state-backends.yml`
84
68
  (read as YAML, never loaded; a starter's `REPLACE-ME` values and its
@@ -307,27 +291,64 @@ included).
307
291
 
308
292
  `query_provider_image()` takes one OS-builder runtime entry
309
293
  (`OSBuilderBaseImageBuilderSubconfig` in
310
- [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py)).
311
- `remap_for_image_query()` builds a `describe_images` request:
312
-
313
- - `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).
314
- - `query.filters` keys are mapped through `AWS_DI_MAP` (snake_case to EC2 filter names, `tag:<key>` kept), booleans lowercased, and `state: available` forced.
315
- - Keys the map does not know go to a post-query exact-match filter on the result dictionaries.
316
- - 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.
317
-
294
+ [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py))
295
+ and asks it for its effective query, `entry.image_query()`: an
296
+ `ImageQuery` in the SYSTEM's vocabulary
297
+ ([contract/image.py](../contract/src/cs_image_system/contract/image.py);
298
+ stage 106). That query is the image-source catalog's `aws` block for the
299
+ OS builder's family (the release's layer under the tree's
300
+ `cfg/image-sources*.yml`), with the OS builder's own `query` landing on it
301
+ key by key and the runtime entry's landing on that; the publishers declared
302
+ anywhere (the OS builder's `owners`, this runtime's `default_owners`, the
303
+ entry's `owners`, in that order, deduplicated) replace the catalog's. The
304
+ entry's own `query` mapping is never rewritten (stage 63). The builder then
305
+ translates it with `translate_image_query()` (the table below) and
318
306
  `query_image()` in [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py)
319
- runs that `DescribeImages` query (paging through `describe_images`), keeps
320
- every match that agrees with the post-query filter, and returns the newest
321
- by `CreationDate`, or `None` when nothing matches. Its docstring says so
322
- since stage 63; the unreachable `raise` that used to follow the return is
323
- gone. The builder then reads the owner straight from the AMI record,
324
- `ImageOwnerAlias` (for example `amazon`) else `OwnerId` (the account id),
325
- and returns `(ImageId, owner, raw)`; the base registers an
326
- `AwsProviderSpecificImage` in the resolved state. A record with neither
327
- field raises `Could not get owner alias or owner id ...`. Before stage 63 a
328
- second, unreachable `Could not get owner` branch followed that one; it was
329
- removed on 2026-09-25. No match returns `None`, and the base's resolve step
330
- then fails the run.
307
+ runs the `DescribeImages` request `describe_images_request()` makes from
308
+ it, paging through the results, and returns the newest by `CreationDate`,
309
+ or `None` when nothing matches; with `newest: false` more than one match
310
+ raises `AMIQueryError` as ambiguous. The builder reads the owner straight
311
+ from the AMI record, `ImageOwnerAlias` (for example `amazon`) else
312
+ `OwnerId` (the account id), and returns `(ImageId, owner, raw)`; the base
313
+ registers an `AwsProviderSpecificImage` in the resolved state. A record
314
+ with neither field raises `Could not get owner alias or owner id ...`. No
315
+ match returns `None`, and the base's resolve step then fails the run.
316
+ `validate` has already refused an entry whose query uses a key this
317
+ runtime cannot translate, or has no source at all, and has printed every
318
+ entry's query in both vocabularies (`image source: OS builder '<name>' on
319
+ runtime <rt> (aws): <system query> -> <EC2 query>`).
320
+
321
+ Before stage 106 the entry's `query.filters` were EC2's field names in
322
+ snake case (the system started on AWS and used its filter keys: the
323
+ operator's incidental choice), passed through a key map, with unknown keys
324
+ going to a post-query exact-match filter. The vocabulary is the system's
325
+ now, EC2's spellings kept where they were good names; the old spellings
326
+ still read (`root_device_type`, `virtualization_type`, `owners`, the
327
+ nested `filters:` form), and an unknown key is refused by name instead of
328
+ buried.
329
+
330
+ ### Image query translation
331
+
332
+ What `translate_image_query()` does with each key of the system's
333
+ vocabulary (`supported_query_keys()` is the set of rows not marked
334
+ refused; [tests/test_docs_image_sources.py](../../tests/test_docs_image_sources.py)
335
+ holds this table to it). The answer is `{"owners": [...], "filters":
336
+ {<EC2 filter name>: value}, "most_recent": bool}`: what packer's
337
+ `data "amazon-ami"` body and `describe_images` both take.
338
+
339
+ | System key | EC2 filter | Note |
340
+ | --- | --- | --- |
341
+ | `name` | `name` | a glob over the AMI name |
342
+ | `family` | refused | EC2 has no image families; name the image |
343
+ | `version` | refused | the catalog fills `{version}` into `name` |
344
+ | `architecture` | `architecture` | `x86_64` or `arm64`, EC2's own words |
345
+ | `publisher` | `owners` | account ids and the aliases `self`, `amazon`, `aws-marketplace` |
346
+ | `state` | `state` | `available` when unset: the query always forced it |
347
+ | `virtualization` | `virtualization-type` | `hvm` |
348
+ | `root_device` | `root-device-type` | `ebs` |
349
+ | `tags` | `tag:<key>` | one filter per tag |
350
+ | `newest` | `most_recent` | true when unset; false refuses more than one match |
351
+ | `native` | verbatim | each key an EC2 filter name, passed as written |
331
352
 
332
353
  When the entry declares `image_id` (stage 63), the base's resolve step
333
354
  does not call `query_provider_image()` at all: it calls
@@ -335,29 +356,26 @@ does not call `query_provider_image()` at all: it calls
335
356
  with `ImageIds=[<id>]` through `_query_by_ami_id` in
336
357
  [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py), with the
337
358
  runtime's own client configuration (profile and region). The entry's
338
- `owners` and `query` are not consulted. The owner is read from the record
339
- the same way, `ImageOwnerAlias` else `OwnerId` (else `self`), and the
340
- result is `(ImageId, owner, raw)`. An id AWS answers
341
- `InvalidImageID.NotFound` or `InvalidImageID.Malformed` for, or one that
342
- returns no image, gives `None`, and the base stops resolution with
343
- `OS builder <name>: image_id '<id>' on runtime <rt> is not known to the
344
- provider`; any other API refusal raises `AMIQueryError`. An AMI id is
345
- regional, so a pinned id must exist in this runtime's region. Pinning
346
- makes the base bake reproducible: the same vendor AMI every time, where
347
- the query takes the newest match.
348
-
349
- Stage 63 also removed helpers from
350
- [aws_utils.py](src/cs_image_system/aws_runtime/aws_utils.py) that nothing
351
- called: `get_ami_owner`, `get_ami_ssh_user`, `remap_for_aws`,
352
- `_query_by_name` and `_build_ami_filters`. `_query_by_ami_id` (an exact
353
- lookup by AMI id) was kept, and since stage 63 it is what
354
- `query_provider_image_by_id()` calls.
359
+ query is not consulted. The owner is read from the record the same way,
360
+ `ImageOwnerAlias` else `OwnerId` (else `self`), and the result is
361
+ `(ImageId, owner, raw)`. An id AWS answers `InvalidImageID.NotFound` or
362
+ `InvalidImageID.Malformed` for, or one that returns no image, gives
363
+ `None`, and the base stops resolution with `OS builder <name>: image_id
364
+ '<id>' on runtime <rt> is not known to the provider`; any other API
365
+ refusal raises `AMIQueryError`. An AMI id is regional, so a pinned id must
366
+ exist in this runtime's region. Pinning makes the base bake reproducible:
367
+ the same vendor AMI every time, where the query takes the newest match.
355
368
 
356
369
  `AwsProviderSpecificImage.get_query_assets()` gives the packer
357
370
  `data "amazon-ami"` body:
358
371
 
359
372
  - resolved: `filters = { image-id = "<ami>" }` plus `owners = ["<owner>"]` when known;
360
- - 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`.
373
+ - deferred (an image a build produces later): an `ImageQuery` (`name =
374
+ "<pattern>*"`, `root_device = ebs`, `virtualization = hvm`, the
375
+ architecture or `x86_64`, publisher `self`) translated like a vendor
376
+ query and quoted: `owners = ["self"]`, `filters = { name = "<pattern>*",
377
+ root-device-type = "ebs", virtualization-type = "hvm", architecture =
378
+ "<arch>" }`, `most_recent = true`.
361
379
 
362
380
  [tests/test_aws_provider_specific_image.py](tests/test_aws_provider_specific_image.py)
363
381
  pins both shapes.
@@ -4,7 +4,7 @@
4
4
 
5
5
  [project]
6
6
  name = "cs-image-system-aws-runtime-plugin"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "The aws runtime type: Amazon EC2 as a place where images bake and instances run"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Mykel Alvis", email = "mykelalvis@infrastructurebuilder.org" }]
@@ -12,9 +12,9 @@ license = "Apache-2.0"
12
12
  license-files = ["LICENSE"]
13
13
  requires-python = ">=3.13"
14
14
  dependencies = [
15
- "cs-image-system-contract==0.1.4",
16
- "cs-image-system-base==0.1.4",
17
- "cs-image-system-hashicorp-utils==0.1.4",
15
+ "cs-image-system-contract==0.1.5",
16
+ "cs-image-system-base==0.1.5",
17
+ "cs-image-system-hashicorp-utils==0.1.5",
18
18
  "boto3>=1.43",
19
19
  "botocore>=1.43",
20
20
  "python-hcl2>=8.1",
@@ -28,16 +28,13 @@ class AwsProviderSpecificImage(ProviderSpecificImage):
28
28
  ret["owners"] = [f'"{self.owner}"']
29
29
  return ret
30
30
  # Deferred: the image is produced by a build in this account; find the
31
- # most recent image matching the output-name pattern (same shape as the
32
- # chained-image lookup in the packer-ebs builder).
33
- architecture = self.architecture if self.architecture else "x86_64"
34
- return {
35
- "owners": ['"self"'],
36
- "filters": {
37
- "name": f'"{self.deferred_name_pattern}*"',
38
- "root-device-type": '"ebs"',
39
- "virtualization-type": '"hvm"',
40
- "architecture": f'"{architecture}"',
41
- },
42
- "most_recent": True,
43
- }
31
+ # most recent image matching the output-name pattern. stage 106: the
32
+ # lookup is an ImageQuery in the system's vocabulary, translated like
33
+ # a vendor query, then quoted for the packer data source.
34
+ from cs_image_system.contract.image import ImageQuery
35
+ from .aws_utils import translate_image_query
36
+ native = translate_image_query(ImageQuery(
37
+ name=f"{self.deferred_name_pattern}*", root_device="ebs", virtualization="hvm",
38
+ architecture=self.architecture if self.architecture else "x86_64", publisher=("self",), newest=True))
39
+ filters = {k: f'"{v}"' for k, v in native["filters"].items() if k != "state"}
40
+ return {"owners": [f'"{o}"' for o in native["owners"]], "filters": filters, "most_recent": True}
@@ -19,7 +19,8 @@ from cs_image_system.contract.core import PluginArtifactProtocol
19
19
 
20
20
  from .aws_provider_specific_image import AwsProviderSpecificImage
21
21
  from .aws_runtime_models import AWS_CLI, AwsCloudBuilderModel
22
- from .aws_utils import remap_for_image_query, query_image
22
+ from .aws_utils import AWS_QUERY_KEYS, query_image, translate_image_query
23
+ from cs_image_system.contract.image import ImageQuery
23
24
  from cs_image_system.aws_runtime import aws_utils
24
25
 
25
26
  log = logging.getLogger(__name__)
@@ -43,12 +44,18 @@ class AwsCloudBuilder(CloudBuilderBase[AwsCloudBuilderModel], PluginArtifactProt
43
44
  def provider_specific_image_class(self) -> type[ProviderSpecificImage]:
44
45
  return AwsProviderSpecificImage
45
46
 
47
+ # --- the image query (stage 106)
48
+ def supported_query_keys(self) -> frozenset[str]:
49
+ return AWS_QUERY_KEYS
50
+
51
+ def translate_image_query(self, query: ImageQuery) -> dict[str, Any]:
52
+ return translate_image_query(query)
53
+
46
54
  def query_provider_image(self, os_builder: OSBuilderBaseImageBuilderSubconfig) -> tuple[str, str, Mapping[str,Any]] | None:
47
55
  retval: str | None = None
48
- q,missed = remap_for_image_query(os_builder) if os_builder else ({}, {})
56
+ q = self.translate_image_query(os_builder.image_query()) if os_builder else {}
49
57
  kv = query_image(
50
58
  query=q,
51
- post_query_filter=missed,
52
59
  session_config=self.model.self_to_aws_client_config(),
53
60
  )
54
61
  if kv:
@@ -6,14 +6,14 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import copy
10
9
  import logging
11
- from typing import Any, Sequence, cast
10
+ from typing import Any, Mapping, cast
12
11
  import boto3 # type: ignore[import-untyped]
13
12
  from botocore.client import BaseClient as EC2 # type: ignore[import-untyped]
14
13
  from botocore.exceptions import BotoCoreError, ClientError
15
14
 
16
- from cs_image_system.base.models.os_builder_runtime_config import OSBuilderBaseImageBuilderSubconfig
15
+ from cs_image_system.contract.image import ImageQuery
16
+
17
17
 
18
18
  log = logging.getLogger(__name__)
19
19
 
@@ -120,81 +120,75 @@ AWS_DI_MAP: dict[str, str] = {
120
120
  }
121
121
 
122
122
 
123
- def remap_for_image_query(rc: OSBuilderBaseImageBuilderSubconfig) -> tuple[dict[str, Any], dict[str, Any]]:
124
- ret: dict[str, Any] = {
125
- "DryRun": False,
126
- "IncludeDisabled": False,
127
- "IncludeDeprecated": False,
128
- }
129
- owners: Sequence[Any] = rc.get_owners() if rc else []
130
- ret["Owners"] = owners # completely overridden if specified in config
131
- missed: dict[str, Any] = {}
132
- # stage 63: a deep copy -- the loop below rewrites the filters mapping in
133
- # place, and get_query() hands back the entry's own nested dicts, so the
134
- # model's query used to be mutated by every resolution
135
- for topk, topv in copy.deepcopy(dict(rc.get_query())).items():
136
- _key2 = AWS_DI_MAP.get(topk, None)
137
- if not _key2:
138
- missed[topk] = topv
139
- continue
140
- topk = _key2
141
- if topk == "Owners":
142
- if isinstance(topv, list):
143
- topv = [str(i) for i in topv]
144
- else:
145
- topv = [str(topv)]
146
- elif topk == "Filters":
147
- if isinstance(topv, dict):
148
- topv["state"] = (
149
- "available" # Ensure we only get available images, but allow override
150
- )
151
- new_dict: dict[str, Any] = {}
152
- for _k, _v in topv.items():
153
- _k = AWS_DI_MAP.get(_k, _k)
154
- assert _k is not None, "Bad Filter key is None"
155
- if _k == "Owners":
156
- if isinstance(_v, list):
157
- _v = [str(i) for i in _v]
158
- else:
159
- _v = [str(_v)]
160
- if _k.startswith("tag:"):
161
- _k = f"tag:{_k[4:]}"
162
- if isinstance(_v, bool):
163
- _v = str(_v).lower()
164
- new_dict[_k] = _v
165
- v = new_dict
166
- topv = [
167
- {
168
- "Name": AWS_DI_MAP.get(_k, _k),
169
- "Values": _v if isinstance(_v, list) else [str(_v)],
170
- }
171
- for _k, _v in v.items()
172
- ]
173
- else:
174
- raise ValueError(f"Expected 'filter' value to be a dict, got {type(topv)}")
175
- ret[topk] = topv
176
- return (ret, missed)
123
+ #: stage 106: the keys of the system's image-query vocabulary EC2's
124
+ #: DescribeImages can answer, and the filter each becomes.
125
+ AWS_QUERY_KEYS: frozenset[str] = frozenset({"name", "architecture", "publisher", "state", "virtualization",
126
+ "root_device", "tags", "newest", "native"})
127
+ AWS_FILTER_NAMES: dict[str, str] = { # in the order the filters are written out
128
+ "name": "name",
129
+ "root_device": "root-device-type",
130
+ "virtualization": "virtualization-type",
131
+ "architecture": "architecture",
132
+ "state": "state",
133
+ }
134
+
135
+
136
+ def translate_image_query(query: ImageQuery) -> dict[str, Any]:
137
+ """The system's query in EC2's words (stage 106): ``{"owners": [...],
138
+ "filters": {<DescribeImages filter name>: value}, "most_recent": bool}``,
139
+ the shape packer's ``amazon-ami`` data source and ``describe_images``
140
+ both take (``describe_images_request`` makes the boto call from it).
141
+ ``state`` defaults to ``available``, as the query always has. A key EC2
142
+ cannot answer raises ValueError naming it (``validate`` refuses it
143
+ first); ``native`` keys are EC2 filter names, passed verbatim."""
144
+ unsupported = sorted(k for k in query.keys() if k not in AWS_QUERY_KEYS)
145
+ if unsupported:
146
+ raise ValueError(f"AWS cannot translate image query key(s) {', '.join(unsupported)}")
147
+ filters: dict[str, Any] = {}
148
+ for key, name in AWS_FILTER_NAMES.items():
149
+ value = getattr(query, key)
150
+ if value is not None:
151
+ filters[name] = value
152
+ filters.setdefault("state", "available")
153
+ for k, v in sorted(query.tags.items()):
154
+ filters[f"tag:{k}"] = v
155
+ for k, v in query.native.items():
156
+ filters[str(k)] = v
157
+ return {"owners": list(query.publisher), "filters": filters,
158
+ "most_recent": True if query.newest is None else bool(query.newest)}
159
+
160
+
161
+ def describe_images_request(native: Mapping[str, Any]) -> dict[str, Any]:
162
+ """A ``describe_images`` request from ``translate_image_query``'s answer."""
163
+ filters = []
164
+ for name, value in dict(native.get("filters") or {}).items():
165
+ if isinstance(value, bool):
166
+ value = str(value).lower()
167
+ filters.append({"Name": str(name), "Values": [str(v) for v in value] if isinstance(value, (list, tuple)) else [str(value)]})
168
+ return {"DryRun": False, "IncludeDisabled": False, "IncludeDeprecated": False,
169
+ "Owners": [str(o) for o in (native.get("owners") or [])], "Filters": filters}
177
170
 
178
171
 
179
172
  def query_image(
180
173
  query: dict[str, Any],
181
- post_query_filter: dict[str, Any] | None = None,
182
174
  session_config: dict[str, Any] | None = None,
183
175
  ) -> dict[str, Any] | None:
184
- """The newest AMI matching an EC2 ``DescribeImages`` query.
185
-
186
- ``query`` is what ``remap_for_image_query`` builds from an OS builder
187
- entry: ``Owners``, ``Filters`` (``[{"Name": ..., "Values": [...]}]``) and
188
- the ``DryRun``/``IncludeDisabled``/``IncludeDeprecated`` flags. Every
189
- match is kept that agrees with ``post_query_filter`` (a mapping of AMI
190
- record keys to required values); the newest by ``CreationDate`` is
191
- returned, or None when nothing matches. ``session_config`` is the
192
- runtime's boto3 session configuration and is required. A pinned
193
- image id does not come through here (stage 63 reads the entry's
194
- ``image_id`` through ``query_image_by_id``).
176
+ """The AMI matching an image query in EC2's words (stage 106:
177
+ ``translate_image_query``'s answer, ``owners``/``filters``/``most_recent``;
178
+ the boto-shaped request of before is accepted too). The newest by
179
+ ``CreationDate`` is returned, or None when nothing matches; with
180
+ ``most_recent`` false, more than one match is refused as ambiguous.
181
+ ``session_config`` is the runtime's boto3 session configuration and is
182
+ required. A pinned image id does not come through here (stage 63 reads
183
+ the entry's ``image_id`` through ``query_image_by_id``).
195
184
  """
196
185
  if not query:
197
186
  raise ValueError("Query dictionary cannot be empty")
187
+ if "Filters" not in query and "Owners" not in query:
188
+ most_recent = query.get("most_recent", True)
189
+ query = describe_images_request(query)
190
+ else:
191
+ most_recent = True
198
192
 
199
193
  try:
200
194
  if not session_config:
@@ -207,22 +201,12 @@ def query_image(
207
201
  except (BotoCoreError, ClientError) as e:
208
202
  raise AMIQueryError(f"Failed to create EC2 client: {e}") from e
209
203
 
210
- results = _query_by_filters(ec2_client, query)
211
- # Return most recently created AMI if multiple matches
212
- ilist = []
213
- for r in results:
214
- if post_query_filter:
215
- match = True
216
- for k, v in post_query_filter.items():
217
- if r.get(k) != v:
218
- match = False
219
- break
220
- if not match:
221
- continue
222
- ilist.append(r)
204
+ ilist = list(_query_by_filters(ec2_client, query))
223
205
  if not ilist:
224
206
  return None
225
207
  if len(ilist) > 1:
208
+ if not most_recent:
209
+ raise AMIQueryError(f"{len(ilist)} AMIs match and the query says newest: false; narrow it")
226
210
  ilist.sort(key=lambda x: x.get("CreationDate", ""), reverse=True)
227
211
 
228
212
  return cast(dict[str, Any], ilist[0])
@@ -30,8 +30,10 @@ from __future__ import annotations
30
30
  import json
31
31
  from typing import Any
32
32
 
33
- from cs_image_system.base.bootstrap.facts import Facts, default_state_backend, real, runtime_of_type
34
- from cs_image_system.base.bootstrap.questions import Answers, Question, Raw, Rendered, Secret, Section
33
+ from cs_image_system.base.bootstrap.facts import (Facts, bound_backend_names, default_state_backend, other_state_backends,
34
+ real, runtime_of_type)
35
+ from cs_image_system.base.bootstrap.questions import (ADOPTED, BY_HAND, MADE, READ, Answers, Question, Raw, Rendered,
36
+ Secret, Section)
35
37
 
36
38
  MODULE = "bootstrap_aws"
37
39
  OIDC_HOST = "token.actions.githubusercontent.com"
@@ -137,6 +139,55 @@ def _bucket_exists(facts: Facts, answers: Answers) -> bool | None:
137
139
  ("Not Found", "404", "NoSuchBucket"))
138
140
 
139
141
 
142
+ def _bucket_named_exists(facts: Facts, answers: Answers, bucket: str) -> bool | None:
143
+ return _exists(_aws(facts, answers, "s3api", "head-bucket", "--bucket", bucket), ("Not Found", "404", "NoSuchBucket"))
144
+
145
+
146
+ def other_s3_buckets(facts: Facts) -> list[tuple[str, str, str, bool]]:
147
+ """The tree's other S3 backends (hygiene XX item 1): ``(backend name,
148
+ bucket, prefix, bound)`` for each with a real bucket, in declaration
149
+ order; ``bound`` when some root's ``state_configuration`` names it."""
150
+ bound = bound_backend_names(facts)
151
+ out: list[tuple[str, str, str, bool]] = []
152
+ for backend in other_state_backends(facts, "s3"):
153
+ bucket = real(backend.get("bucket"))
154
+ if not bucket:
155
+ continue
156
+ prefix = (real(backend.get("key")) or "").strip("/")
157
+ out.append((str(backend["name"]), bucket, prefix, str(backend["name"]) in bound))
158
+ return out
159
+
160
+
161
+ def _other_buckets_default(facts: Facts, answers: Answers) -> str | None:
162
+ """The other S3 backends' buckets this root should make: every one that
163
+ is bound to a root and that the account says is absent, as
164
+ ``bucket=prefix,...``; a bucket bound to no root is left out (the README
165
+ says so); a bucket the account cannot be asked about leaves no default."""
166
+ pairs: list[str] = []
167
+ for _name, bucket, prefix, bound in other_s3_buckets(facts):
168
+ if not bound:
169
+ continue
170
+ exists = _bucket_named_exists(facts, answers, bucket)
171
+ if exists is None:
172
+ return None
173
+ if not exists:
174
+ pairs.append(f"{bucket}={prefix}")
175
+ return ",".join(pairs)
176
+
177
+
178
+ def parse_buckets(text: Any) -> dict[str, str]:
179
+ """``bucket=prefix,bucket=prefix`` as a mapping (an empty prefix allowed)."""
180
+ out: dict[str, str] = {}
181
+ for part in str(text or "").split(","):
182
+ part = part.strip()
183
+ if not part:
184
+ continue
185
+ bucket, _, prefix = part.partition("=")
186
+ if bucket.strip():
187
+ out[bucket.strip()] = prefix.strip().strip("/")
188
+ return out
189
+
190
+
140
191
  def _profile_exists(facts: Facts, answers: Answers) -> bool | None:
141
192
  name = answers.get("instance_profile")
142
193
  if not name:
@@ -236,6 +287,14 @@ QUESTIONS: tuple[Question, ...] = (
236
287
  "here, so a destroy of the root removes it? (a bucket that stood before this root is read, never "
237
288
  "managed; one this root makes is managed from then on)", kind="bool",
238
289
  default=lambda f, a: not a.get("state_bucket_exists")),
290
+ # hygiene XX item 1: the tree's OTHER S3 backends. A choice, not an
291
+ # observation: the second interview keeps the answer, so a bucket this
292
+ # root made never leaves the plan (the trap item 5 of hygiene XII closed
293
+ # for the default bucket).
294
+ Question("other_state_buckets", "The tree's other S3 backends' buckets this root MAKES when absent and manages "
295
+ "from then on, as bucket=prefix,bucket=prefix (by default every one bound to a root that the account "
296
+ "says is absent; a backend bound to no root is left out)", default=_other_buckets_default,
297
+ help="the account could not be asked about one of the other buckets: no AWS session", may_be_empty=True),
239
298
  Question("instance_profile", "The instance profile build and standing instances wear for Session Manager",
240
299
  default=_session_profile,
241
300
  help="the AWS runtime's `session_instance_profile` in cfg/runtime-builders.yml is unset or a placeholder"),
@@ -245,12 +304,29 @@ QUESTIONS: tuple[Question, ...] = (
245
304
  )
246
305
 
247
306
 
248
- def render(answers: Answers) -> Rendered:
307
+ def render(answers: Answers, facts: Facts | None = None) -> Rendered:
249
308
  profile = str(answers.get("profile") or "")
250
309
  prefix = str(answers["state_prefix"]).strip("/")
251
310
  tags = {**parse_tags(str(answers.get("tags") or "")), "ManagedBy": MANAGED_BY}
252
311
  bucket_exists = bool(answers["state_bucket_exists"])
253
312
  bucket_managed = bool(answers.get("state_bucket_managed")) or not bucket_exists # made here: managed, always
313
+ other_made = parse_buckets(answers.get("other_state_buckets"))
314
+ # the other backends this root does NOT make, with the reason (item 1: a line, never a silence)
315
+ others = other_s3_buckets(facts) if facts is not None else []
316
+ other_rows: list[tuple[str, str, str]] = []
317
+ for name, bucket, _bprefix, bound in others:
318
+ if bucket in other_made:
319
+ other_rows.append((f"the S3 backend `{name}`'s bucket `{bucket}`", MADE,
320
+ "declared, bound to a root and absent at the interview: made here (versioned, encrypted, private) and managed from then on"))
321
+ elif not bound:
322
+ other_rows.append((f"the S3 backend `{name}`'s bucket `{bucket}`", BY_HAND,
323
+ "declared but bound to no root, so nothing would use it: not made; bind a root to it and re-run the interview, or drop the backend"))
324
+ else:
325
+ other_rows.append((f"the S3 backend `{name}`'s bucket `{bucket}`", READ,
326
+ "stood at the interview, or was left out of the answer: read, never managed here"))
327
+ for bucket in other_made:
328
+ if bucket not in {b for _n, b, _p, _b in others}:
329
+ other_rows.append((f"the bucket `{bucket}`", MADE, "named in the answer; no declared S3 backend names it"))
254
330
  backend = None
255
331
  if bucket_exists:
256
332
  settings: dict[str, Any] = {"bucket": answers["state_bucket"], "key": f"{prefix}/bootstrap.tfstate",
@@ -285,6 +361,7 @@ def render(answers: Answers) -> Rendered:
285
361
  "instance_profile_exists": "the instance profile already exists (read, not made)",
286
362
  }
287
363
  variables = tuple((f"aws_{n}", types.get(n, "string"), described[n]) for n in names) + (
364
+ ("aws_other_state_buckets", "map(string)", "the tree's other S3 backends' buckets this root makes and manages: bucket -> key prefix"),
288
365
  ("aws_profile", "string", "the AWS profile this root is applied with (empty: the environment's credentials)"),
289
366
  ("aws_tags", "map(string)", "tags on everything this root makes"),
290
367
  ("aws_read_extra_subjects", "list(string)", "other OIDC subjects the READ-ONLY role keeps trusting"),
@@ -294,6 +371,7 @@ def render(answers: Answers) -> Rendered:
294
371
  for n in names}
295
372
  tfvars["aws_state_bucket_managed"] = bucket_managed
296
373
  tfvars["aws_state_prefix"] = prefix
374
+ tfvars["aws_other_state_buckets"] = other_made
297
375
  tfvars["aws_profile"] = profile
298
376
  tfvars["aws_tags"] = tags
299
377
  extras = {k: parse_subjects(answers.get(f"{k}_extra_subjects")) for k in ("read", "write")}
@@ -308,6 +386,7 @@ def render(answers: Answers) -> Rendered:
308
386
  if extras[k] else "")
309
387
  for k in ("read", "write") if answers.get(f"{k}_role_exists")),
310
388
  module_args={**{n: Raw(f"var.aws_{n}") for n in names},
389
+ "other_state_buckets": Raw("var.aws_other_state_buckets"),
311
390
  "read_extra_subjects": Raw("var.aws_read_extra_subjects"),
312
391
  "write_extra_subjects": Raw("var.aws_write_extra_subjects")},
313
392
  variables=variables,
@@ -317,6 +396,7 @@ def render(answers: Answers) -> Rendered:
317
396
  ("aws_write_role_arn", f"module.{MODULE}.write_role_arn", "the WRITE role's ARN: the AWS_APPLY_ROLE_ARN secret"),
318
397
  ("aws_oidc_provider_arn", f"module.{MODULE}.oidc_provider_arn", "the GitHub OIDC identity provider"),
319
398
  ("aws_state_bucket", f"module.{MODULE}.state_bucket", "the state bucket"),
399
+ ("aws_other_state_buckets", f"module.{MODULE}.other_state_buckets", "the other S3 backends' buckets made here"),
320
400
  ("aws_instance_profile_arn", f"module.{MODULE}.instance_profile_arn", "the Session Manager instance profile"),
321
401
  ),
322
402
  required_providers={"aws": {"source": "hashicorp/aws", "version": ">= 5.81"}},
@@ -349,11 +429,33 @@ def render(answers: Answers) -> Rendered:
349
429
  "exists: this root's state is bound to it (decision D8); the bucket stood before this root and is not managed here"
350
430
  if bucket_exists else
351
431
  "is CREATED here (versioned, encrypted, private) and managed from then on: local state for this first apply"),
432
+ *(f"the S3 backend's bucket `{b}` (prefix `{p or '(none)'}`) is CREATED here (versioned, encrypted, private) "
433
+ "and managed from then on" for b, p in sorted(other_made.items())),
352
434
  f"the instance profile `{answers['instance_profile']}` "
353
435
  + ("read (it exists)" if answers["instance_profile_exists"] else "created with AmazonSSMManagedInstanceCore"),
354
436
  ),
355
437
  backend=backend,
356
438
  root_hcl=imports,
439
+ coverage=(
440
+ ("the GitHub OIDC identity provider", READ if answers["oidc_provider_exists"] else MADE,
441
+ "read as a data source when the account has it" if answers["oidc_provider_exists"] else "the account lacked it"),
442
+ *((f"the {k.upper()} role `{answers[f'{k}_role_name']}`", ADOPTED if answers.get(f"{k}_role_exists") else MADE,
443
+ "stood at the interview: an import block, so its trust and policy are maintained from here"
444
+ if answers.get(f"{k}_role_exists") else "the account lacked it") for k in ("read", "write")),
445
+ (f"the default S3 backend's bucket `{answers['state_bucket']}`",
446
+ MADE if bucket_managed else READ,
447
+ "made by an apply of this root and managed from then on" if bucket_managed
448
+ else "stood before this root: read, never managed"),
449
+ *other_rows,
450
+ (f"the Session Manager instance profile `{answers['instance_profile']}`",
451
+ READ if answers["instance_profile_exists"] else MADE,
452
+ "read as a data source when the account has it" if answers["instance_profile_exists"]
453
+ else "the account lacked it: a role with AmazonSSMManagedInstanceCore"),
454
+ ("the network: VPC, subnets, security groups, the access gateway", BY_HAND,
455
+ "the team's, by decision (stage 1's network rule): the system never creates or modifies network configuration"),
456
+ ("the bake hosts' route to SSM and the package repositories", BY_HAND,
457
+ "part of the network: the team's"),
458
+ ),
357
459
  )
358
460
 
359
461
 
@@ -363,5 +465,5 @@ def wanted(facts: Facts) -> bool:
363
465
 
364
466
 
365
467
  def aws_section() -> Section:
366
- return Section(name="aws", title="AWS", questions=QUESTIONS, render=render, wanted=wanted,
367
- description="the GitHub OIDC provider, the read-only and write roles, the optional state bucket and instance profile")
468
+ return Section(name="aws", title="AWS", questions=QUESTIONS, render=render, wanted=wanted, wants_facts=True,
469
+ description="the GitHub OIDC provider, the read-only and write roles, the state buckets and the instance profile")