cs-image-system-gcloud-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 (16) hide show
  1. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/PKG-INFO +74 -32
  2. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/README.md +70 -28
  3. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/pyproject.toml +4 -4
  4. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/src/cs_image_system/gcloud_runtime/bootstrap.py +74 -8
  5. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/src/cs_image_system/gcloud_runtime/gcp_provider_specific_image.py +7 -8
  6. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/src/cs_image_system/gcloud_runtime/gcp_runtime_builders.py +11 -6
  7. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/src/cs_image_system/gcloud_runtime/gcp_utils.py +40 -59
  8. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/tests/test_gcp_utils.py +29 -37
  9. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/.gitignore +0 -0
  10. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/LICENSE +0 -0
  11. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/src/cs_image_system/gcloud_runtime/__init__.py +0 -0
  12. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/src/cs_image_system/gcloud_runtime/gcp_packer_source.py +0 -0
  13. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/src/cs_image_system/gcloud_runtime/gcp_runtime_models.py +0 -0
  14. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/src/cs_image_system/gcloud_runtime/main.py +0 -0
  15. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/src/cs_image_system/py.typed +0 -0
  16. {cs_image_system_gcloud_runtime_plugin-0.1.4 → cs_image_system_gcloud_runtime_plugin-0.1.5}/tests/test_gcp_provider_specific_image.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cs-image-system-gcloud-runtime-plugin
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: The gcloud runtime type: Google Compute Engine as a place where images bake and instances run
5
5
  Project-URL: Repository, https://github.com/infrastructurebuilder/cs-image-system-3
6
6
  Project-URL: Documentation, https://github.com/infrastructurebuilder/cs-image-system-3/blob/develop/docs/OPERATIONS.md
@@ -9,9 +9,9 @@ Author-email: Mykel Alvis <mykelalvis@infrastructurebuilder.org>
9
9
  License-Expression: Apache-2.0
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.13
12
- Requires-Dist: cs-image-system-base==0.1.4
13
- Requires-Dist: cs-image-system-contract==0.1.4
14
- Requires-Dist: cs-image-system-hashicorp-utils==0.1.4
12
+ Requires-Dist: cs-image-system-base==0.1.5
13
+ Requires-Dist: cs-image-system-contract==0.1.5
14
+ Requires-Dist: cs-image-system-hashicorp-utils==0.1.5
15
15
  Requires-Dist: google-api-core>=2.31
16
16
  Requires-Dist: google-auth>=2.55
17
17
  Requires-Dist: google-cloud-compute>=1.49
@@ -86,7 +86,12 @@ cs_image_system.gcloud_runtime.bootstrap:gcp_section`. It owns the
86
86
  questions and the terraform for this cloud's one-time initialisation --
87
87
  the workload identity pool and GitHub provider of CI_SETUP.md 3.4, the
88
88
  READ-ONLY service account and the optional WRITE one, their project
89
- roles and `workloadIdentityUser` bindings (`tfmodules/bootstrap_gcp`).
89
+ roles and `workloadIdentityUser` bindings, and since hygiene XX the GCS
90
+ state bucket when the tree keeps its state in GCS and the project lacks
91
+ it (versioned, uniform access, no public access, the CI identities'
92
+ object access; managed from then on, with the AWS section's fork)
93
+ (`tfmodules/bootstrap_gcp`). The section renders its coverage audit for
94
+ the generated README: what it made, read, or leaves by hand and why.
90
95
  The section is wanted by default where the tree declares a GCE runtime
91
96
  or a `gcs` state backend. Its defaults come from the raw
92
97
  `cfg/runtime-builders.yml` and `cfg/state-backends.yml` (a starter's
@@ -291,46 +296,83 @@ firewall rule targets logs a warning.
291
296
 
292
297
  `query_provider_image()` takes one OS-builder runtime entry
293
298
  (`OSBuilderBaseImageBuilderSubconfig` in
294
- [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py)).
295
- `remap_for_image_query()` builds a Compute `list()` query:
296
-
297
- - `projects` is the entry's `get_owners()` (the OS builder's owners, the runtime's `default_owners`, the entry's own), resolved by `resolve_projects()`: `self` becomes the session project; the aliases `debian`, `ubuntu`, `rhel`, `centos`, `rocky`, `fedora`, `suse`, `windows`, `cos` and `google` become the public image projects (`debian-cloud`, `ubuntu-os-cloud`, `rhel-cloud`, ...); AWS-only aliases (`amazon`, `aws-marketplace`, `aws-backup-vault`) and anything that is not a valid project id (an AWS account number, for example) are dropped; a valid project id such as `almalinux-cloud` is kept as is.
298
- - `query.filters` become a filter expression: `name`, `family`, `state`/`status`, `architecture`, `description` and `creation_date` map to image fields; `state: available` becomes `status = "READY"` (forced when absent); architectures map to `X86_64`/`ARM64`; `tag:<key>` becomes `labels.<key>`; AWS-only keys (`root_device_type`, `virtualization_type`, `block_device_mapping.*`, ...) are dropped instead of excluding every result; keys the map does not know go to a post-query exact-match filter.
299
- - A `family` key with no filter expression resolves through `getFromFamily` instead.
300
-
301
- `query_image()` lists every candidate project, applies the post-query
302
- filter, and returns the newest `creation_timestamp` with the owning
303
- `project` added. The builder returns `(name, project, raw)`; the base
304
- registers a `GcpProviderSpecificImage` in the resolved state.
299
+ [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py))
300
+ and asks it for its effective query, `entry.image_query()`: an
301
+ `ImageQuery` in the SYSTEM's vocabulary
302
+ ([contract/image.py](../contract/src/cs_image_system/contract/image.py);
303
+ stage 106). That query is the image-source catalog's `gcloud` block for
304
+ the OS builder's family (the release's layer under the tree's
305
+ `cfg/image-sources*.yml`), with the OS builder's own `query` landing on it
306
+ key by key and the runtime entry's landing on that; the publishers
307
+ declared anywhere (the OS builder's `owners`, this runtime's
308
+ `default_owners`, the entry's `owners`) replace the catalog's. The builder
309
+ translates it with `translate_image_query()` (the table below) into
310
+ `projects` and a Compute `list()` filter expression; `query_image()` in
311
+ [gcp_utils.py](src/cs_image_system/gcloud_runtime/gcp_utils.py) resolves
312
+ the projects (`resolve_projects()`: `self` becomes the session project;
313
+ the aliases `debian`, `ubuntu`, `rhel`, `centos`, `rocky`, `fedora`,
314
+ `suse`, `windows`, `cos` and `google` become the public image projects;
315
+ AWS-only aliases and anything that is not a valid project id are dropped;
316
+ a valid project id such as `almalinux-cloud` is kept as is), lists every
317
+ candidate project, and returns the newest `creation_timestamp` with the
318
+ owning `project` added; with `newest: false` more than one match raises
319
+ `ImageQueryError` as ambiguous. The builder returns `(name, project,
320
+ raw)`; the base registers a `GcpProviderSpecificImage` in the resolved
321
+ state. `validate` has already refused an entry whose query uses a key this
322
+ runtime cannot translate (the EC2 particulars, which this runtime used to
323
+ drop with a debug line) or has no source at all, and has printed every
324
+ entry's query in both vocabularies.
325
+
326
+ ### Image query translation
327
+
328
+ What `translate_image_query()` does with each key of the system's
329
+ vocabulary (`supported_query_keys()` is the set of rows not marked
330
+ refused; [tests/test_docs_image_sources.py](../../tests/test_docs_image_sources.py)
331
+ holds this table to it). The answer is `{"projects": [...], "terms":
332
+ {<image field>: value}, "filter": "<the list() expression>",
333
+ "most_recent": bool}`.
334
+
335
+ | System key | Compute Engine field | Note |
336
+ | --- | --- | --- |
337
+ | `name` | `name` | a glob over the image name |
338
+ | `family` | `family` | the vendor's image family; the latest READY image of it is the match |
339
+ | `version` | refused | the catalog fills `{version}` into `name` or `family` |
340
+ | `architecture` | `architecture` | `x86_64` becomes `X86_64`, `arm64` becomes `ARM64` |
341
+ | `publisher` | the projects | project ids and the aliases above |
342
+ | `state` | `status` | `available` becomes `READY`, forced when unset |
343
+ | `virtualization` | refused | an EC2 particular |
344
+ | `root_device` | refused | an EC2 particular |
345
+ | `tags` | `labels.<key>` | one term per tag |
346
+ | `newest` | `most_recent` | true when unset; false refuses more than one match |
347
+ | `native` | verbatim | each key an image field, passed as written into the expression |
305
348
 
306
349
  When the entry declares `image_id` (stage 63), the base's resolve step
307
350
  calls `query_provider_image_by_id(entry, image_id)` instead. A GCE image
308
351
  is named uniquely within its project, so the id is an image name and is
309
- searched where the vendor query would search: `remap_for_image_query()`
310
- builds the same `projects` list (the entry's merged owners, or a
311
- `query.owners`), then the builder drops the query's `family` and `filter`
312
- and sets the filter to `name = "<id>"`; the post-query filter is not
313
- applied, and a match whose status is not `READY` is refused by name
314
- (stage 67: `GCP runtime <rt>: pinned image '<id>' is <status>, not READY; a
315
- bake from it would fail`). The owning project
316
- is read from the result as for the query (else `self`). No match returns
317
- `None`, and the base stops resolution with `OS builder <name>: image_id
318
- '<id>' on runtime <rt> is not known to the provider`. Pinning makes the
319
- base bake reproducible: the same vendor image every time, where the query
320
- takes the newest match (a vendor family publishes a new image often).
352
+ searched where the vendor query would search: the effective query's
353
+ publishers as the projects, and the filter `name = "<id>"`; a match whose
354
+ status is not `READY` is refused by name (stage 67: `GCP runtime <rt>:
355
+ pinned image '<id>' is <status>, not READY; a bake from it would fail`).
356
+ The owning project is read from the result as for the query (else
357
+ `self`). No match returns `None`, and the base stops resolution with `OS
358
+ builder <name>: image_id '<id>' on runtime <rt> is not known to the
359
+ provider`. Pinning makes the base bake reproducible: the same vendor image
360
+ every time, where the query takes the newest match (a vendor family
361
+ publishes a new image often).
321
362
 
322
363
  `GcpProviderSpecificImage.get_query_assets()` describes the image as a
323
364
  generic query (`filters = { name = "<name>" }` plus `owners` when resolved;
324
- `owners = ["self"]`, `filters = { name = "<pattern>*" }`, `most_recent`
325
- when deferred). The packer source does not use these assets: GCE's image
365
+ when deferred, an `ImageQuery` for `<pattern>*` from `self`, translated
366
+ and quoted: `owners = ["self"]`, `filters = { name = "<pattern>*" }`,
367
+ `most_recent`). The packer source does not use these assets: GCE's image
326
368
  families are the series, so `googlecompute_source()` writes `source_image`
327
369
  (a resolved or pinned parent) or `source_image_family` (a deferred parent)
328
370
  directly.
329
371
 
330
372
  [tests/test_gcp_utils.py](tests/test_gcp_utils.py) and
331
373
  [tests/test_gcp_provider_specific_image.py](tests/test_gcp_provider_specific_image.py)
332
- pin the remap, the project resolution, the newest-wins rule and the asset
333
- shapes without network access.
374
+ pin the translation, the project resolution, the newest-wins rule and the
375
+ asset shapes without network access.
334
376
 
335
377
  ### Image families
336
378
 
@@ -65,7 +65,12 @@ cs_image_system.gcloud_runtime.bootstrap:gcp_section`. It owns the
65
65
  questions and the terraform for this cloud's one-time initialisation --
66
66
  the workload identity pool and GitHub provider of CI_SETUP.md 3.4, the
67
67
  READ-ONLY service account and the optional WRITE one, their project
68
- roles and `workloadIdentityUser` bindings (`tfmodules/bootstrap_gcp`).
68
+ roles and `workloadIdentityUser` bindings, and since hygiene XX the GCS
69
+ state bucket when the tree keeps its state in GCS and the project lacks
70
+ it (versioned, uniform access, no public access, the CI identities'
71
+ object access; managed from then on, with the AWS section's fork)
72
+ (`tfmodules/bootstrap_gcp`). The section renders its coverage audit for
73
+ the generated README: what it made, read, or leaves by hand and why.
69
74
  The section is wanted by default where the tree declares a GCE runtime
70
75
  or a `gcs` state backend. Its defaults come from the raw
71
76
  `cfg/runtime-builders.yml` and `cfg/state-backends.yml` (a starter's
@@ -270,46 +275,83 @@ firewall rule targets logs a warning.
270
275
 
271
276
  `query_provider_image()` takes one OS-builder runtime entry
272
277
  (`OSBuilderBaseImageBuilderSubconfig` in
273
- [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py)).
274
- `remap_for_image_query()` builds a Compute `list()` query:
275
-
276
- - `projects` is the entry's `get_owners()` (the OS builder's owners, the runtime's `default_owners`, the entry's own), resolved by `resolve_projects()`: `self` becomes the session project; the aliases `debian`, `ubuntu`, `rhel`, `centos`, `rocky`, `fedora`, `suse`, `windows`, `cos` and `google` become the public image projects (`debian-cloud`, `ubuntu-os-cloud`, `rhel-cloud`, ...); AWS-only aliases (`amazon`, `aws-marketplace`, `aws-backup-vault`) and anything that is not a valid project id (an AWS account number, for example) are dropped; a valid project id such as `almalinux-cloud` is kept as is.
277
- - `query.filters` become a filter expression: `name`, `family`, `state`/`status`, `architecture`, `description` and `creation_date` map to image fields; `state: available` becomes `status = "READY"` (forced when absent); architectures map to `X86_64`/`ARM64`; `tag:<key>` becomes `labels.<key>`; AWS-only keys (`root_device_type`, `virtualization_type`, `block_device_mapping.*`, ...) are dropped instead of excluding every result; keys the map does not know go to a post-query exact-match filter.
278
- - A `family` key with no filter expression resolves through `getFromFamily` instead.
279
-
280
- `query_image()` lists every candidate project, applies the post-query
281
- filter, and returns the newest `creation_timestamp` with the owning
282
- `project` added. The builder returns `(name, project, raw)`; the base
283
- registers a `GcpProviderSpecificImage` in the resolved state.
278
+ [os_builder_runtime_config.py](../base/src/cs_image_system/base/models/os_builder_runtime_config.py))
279
+ and asks it for its effective query, `entry.image_query()`: an
280
+ `ImageQuery` in the SYSTEM's vocabulary
281
+ ([contract/image.py](../contract/src/cs_image_system/contract/image.py);
282
+ stage 106). That query is the image-source catalog's `gcloud` block for
283
+ the OS builder's family (the release's layer under the tree's
284
+ `cfg/image-sources*.yml`), with the OS builder's own `query` landing on it
285
+ key by key and the runtime entry's landing on that; the publishers
286
+ declared anywhere (the OS builder's `owners`, this runtime's
287
+ `default_owners`, the entry's `owners`) replace the catalog's. The builder
288
+ translates it with `translate_image_query()` (the table below) into
289
+ `projects` and a Compute `list()` filter expression; `query_image()` in
290
+ [gcp_utils.py](src/cs_image_system/gcloud_runtime/gcp_utils.py) resolves
291
+ the projects (`resolve_projects()`: `self` becomes the session project;
292
+ the aliases `debian`, `ubuntu`, `rhel`, `centos`, `rocky`, `fedora`,
293
+ `suse`, `windows`, `cos` and `google` become the public image projects;
294
+ AWS-only aliases and anything that is not a valid project id are dropped;
295
+ a valid project id such as `almalinux-cloud` is kept as is), lists every
296
+ candidate project, and returns the newest `creation_timestamp` with the
297
+ owning `project` added; with `newest: false` more than one match raises
298
+ `ImageQueryError` as ambiguous. The builder returns `(name, project,
299
+ raw)`; the base registers a `GcpProviderSpecificImage` in the resolved
300
+ state. `validate` has already refused an entry whose query uses a key this
301
+ runtime cannot translate (the EC2 particulars, which this runtime used to
302
+ drop with a debug line) or has no source at all, and has printed every
303
+ entry's query in both vocabularies.
304
+
305
+ ### Image query translation
306
+
307
+ What `translate_image_query()` does with each key of the system's
308
+ vocabulary (`supported_query_keys()` is the set of rows not marked
309
+ refused; [tests/test_docs_image_sources.py](../../tests/test_docs_image_sources.py)
310
+ holds this table to it). The answer is `{"projects": [...], "terms":
311
+ {<image field>: value}, "filter": "<the list() expression>",
312
+ "most_recent": bool}`.
313
+
314
+ | System key | Compute Engine field | Note |
315
+ | --- | --- | --- |
316
+ | `name` | `name` | a glob over the image name |
317
+ | `family` | `family` | the vendor's image family; the latest READY image of it is the match |
318
+ | `version` | refused | the catalog fills `{version}` into `name` or `family` |
319
+ | `architecture` | `architecture` | `x86_64` becomes `X86_64`, `arm64` becomes `ARM64` |
320
+ | `publisher` | the projects | project ids and the aliases above |
321
+ | `state` | `status` | `available` becomes `READY`, forced when unset |
322
+ | `virtualization` | refused | an EC2 particular |
323
+ | `root_device` | refused | an EC2 particular |
324
+ | `tags` | `labels.<key>` | one term per tag |
325
+ | `newest` | `most_recent` | true when unset; false refuses more than one match |
326
+ | `native` | verbatim | each key an image field, passed as written into the expression |
284
327
 
285
328
  When the entry declares `image_id` (stage 63), the base's resolve step
286
329
  calls `query_provider_image_by_id(entry, image_id)` instead. A GCE image
287
330
  is named uniquely within its project, so the id is an image name and is
288
- searched where the vendor query would search: `remap_for_image_query()`
289
- builds the same `projects` list (the entry's merged owners, or a
290
- `query.owners`), then the builder drops the query's `family` and `filter`
291
- and sets the filter to `name = "<id>"`; the post-query filter is not
292
- applied, and a match whose status is not `READY` is refused by name
293
- (stage 67: `GCP runtime <rt>: pinned image '<id>' is <status>, not READY; a
294
- bake from it would fail`). The owning project
295
- is read from the result as for the query (else `self`). No match returns
296
- `None`, and the base stops resolution with `OS builder <name>: image_id
297
- '<id>' on runtime <rt> is not known to the provider`. Pinning makes the
298
- base bake reproducible: the same vendor image every time, where the query
299
- takes the newest match (a vendor family publishes a new image often).
331
+ searched where the vendor query would search: the effective query's
332
+ publishers as the projects, and the filter `name = "<id>"`; a match whose
333
+ status is not `READY` is refused by name (stage 67: `GCP runtime <rt>:
334
+ pinned image '<id>' is <status>, not READY; a bake from it would fail`).
335
+ The owning project is read from the result as for the query (else
336
+ `self`). No match returns `None`, and the base stops resolution with `OS
337
+ builder <name>: image_id '<id>' on runtime <rt> is not known to the
338
+ provider`. Pinning makes the base bake reproducible: the same vendor image
339
+ every time, where the query takes the newest match (a vendor family
340
+ publishes a new image often).
300
341
 
301
342
  `GcpProviderSpecificImage.get_query_assets()` describes the image as a
302
343
  generic query (`filters = { name = "<name>" }` plus `owners` when resolved;
303
- `owners = ["self"]`, `filters = { name = "<pattern>*" }`, `most_recent`
304
- when deferred). The packer source does not use these assets: GCE's image
344
+ when deferred, an `ImageQuery` for `<pattern>*` from `self`, translated
345
+ and quoted: `owners = ["self"]`, `filters = { name = "<pattern>*" }`,
346
+ `most_recent`). The packer source does not use these assets: GCE's image
305
347
  families are the series, so `googlecompute_source()` writes `source_image`
306
348
  (a resolved or pinned parent) or `source_image_family` (a deferred parent)
307
349
  directly.
308
350
 
309
351
  [tests/test_gcp_utils.py](tests/test_gcp_utils.py) and
310
352
  [tests/test_gcp_provider_specific_image.py](tests/test_gcp_provider_specific_image.py)
311
- pin the remap, the project resolution, the newest-wins rule and the asset
312
- shapes without network access.
353
+ pin the translation, the project resolution, the newest-wins rule and the
354
+ asset shapes without network access.
313
355
 
314
356
  ### Image families
315
357
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  [project]
6
6
  name = "cs-image-system-gcloud-runtime-plugin"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "The gcloud runtime type: Google Compute Engine 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
  "google-cloud-compute>=1.49",
19
19
  "google-api-core>=2.31",
20
20
  "google-auth>=2.55",
@@ -32,7 +32,7 @@ import json
32
32
  from typing import Any
33
33
 
34
34
  from cs_image_system.base.bootstrap.facts import Facts, default_state_backend, first_runtime_name, real, runtime_of_type
35
- from cs_image_system.base.bootstrap.questions import Answers, Question, Raw, Rendered, Secret, Section
35
+ from cs_image_system.base.bootstrap.questions import BY_HAND, MADE, READ, Answers, Question, Raw, Rendered, Secret, Section
36
36
 
37
37
  MODULE = "bootstrap_gcp"
38
38
  ISSUER = "https://token.actions.githubusercontent.com"
@@ -143,6 +143,13 @@ def _bucket_exists(facts: Facts, answers: Answers) -> bool | None:
143
143
  return _describe(facts, "storage", "buckets", "describe", f"gs://{answers.get('state_bucket')}")[0]
144
144
 
145
145
 
146
+ def _bucket_location_default(facts: Facts, answers: Answers) -> str:
147
+ """The region of the GCE runtime's zone (``us-east1-b`` -> ``us-east1``),
148
+ else the multi-region ``US``."""
149
+ zone = real(_runtime(facts).get("zone")) or ""
150
+ return zone.rsplit("-", 1)[0] if zone.count("-") >= 2 else "US"
151
+
152
+
146
153
  def _roles_default(exists_key: str, roles: str):
147
154
  """The roles a NEW account is granted; none for one that exists."""
148
155
  def default(facts: Facts, answers: Answers) -> str:
@@ -213,7 +220,15 @@ QUESTIONS: tuple[Question, ...] = (
213
220
  default=lambda f, a: _gcs(f, "prefix").strip("/") or None,
214
221
  help="the default GCS backend's `prefix` in cfg/state-backends.yml"),
215
222
  Question("state_bucket_exists", "Does that bucket already exist?", kind="bool",
216
- when=lambda a: bool(a.get("state_bucket")), default=_bucket_exists, help=UNASKABLE),
223
+ when=lambda a: bool(a.get("state_bucket")), default=_bucket_exists, help=UNASKABLE, observed=True),
224
+ # hygiene XX item 2: the bucket is MADE when absent, as the AWS section
225
+ # makes its own; whether this root manages it is a choice recorded once
226
+ Question("state_bucket_managed", "Does this root MANAGE the state bucket: made by an apply of this root and kept "
227
+ "here, so a destroy of the root removes it? (a bucket that stood before this root is read, never "
228
+ "managed; one this root makes is managed from then on)", kind="bool",
229
+ when=lambda a: bool(a.get("state_bucket")), default=lambda f, a: not a.get("state_bucket_exists")),
230
+ Question("state_bucket_location", "The bucket's location when this root makes it (a region, or a multi-region "
231
+ "such as US)", when=lambda a: bool(a.get("state_bucket")), default=_bucket_location_default),
217
232
  Question("labels", "Labels for what this root makes, as k=v,k=v", default=_labels_default, may_be_empty=True),
218
233
  )
219
234
 
@@ -257,10 +272,30 @@ def render(answers: Answers) -> Rendered:
257
272
  "gcp_write_account_exists": bool(answers.get("write_account_exists")) if want_write else False,
258
273
  "gcp_write_roles": _split(answers.get("write_roles")) if want_write else [],
259
274
  })
275
+ bucket = str(answers.get("state_bucket") or "")
276
+ bucket_exists = bool(answers.get("state_bucket_exists")) if bucket else False
277
+ bucket_managed = (bool(answers.get("state_bucket_managed")) or not bucket_exists) if bucket else False
278
+ bucket_prefix = str(answers.get("state_prefix") or "").strip("/") if bucket else ""
279
+ labels = {k: v for k, v in (p.split("=", 1) for p in _split(answers.get("labels")) if "=" in p)}
280
+ tfvars.update({
281
+ "gcp_state_bucket": bucket,
282
+ "gcp_state_prefix": bucket_prefix,
283
+ "gcp_state_bucket_exists": bucket_exists,
284
+ "gcp_state_bucket_managed": bucket_managed,
285
+ "gcp_state_bucket_location": str(answers.get("state_bucket_location") or "US") if bucket else "US",
286
+ "gcp_labels": labels,
287
+ })
288
+ variables = variables + (
289
+ ("gcp_state_bucket", "string", "the GCS state bucket (empty when the tree keeps no state in GCS)"),
290
+ ("gcp_state_prefix", "string", "the state prefix within that bucket"),
291
+ ("gcp_state_bucket_exists", "bool", "the bucket already stands (observed at every interview; decides the backend)"),
292
+ ("gcp_state_bucket_managed", "bool", "this root manages the state bucket: made by an apply of it and kept here"),
293
+ ("gcp_state_bucket_location", "string", "the bucket's location when this root makes it"),
294
+ ("gcp_labels", "map(string)", "labels on what this root makes that carries labels"),
295
+ )
260
296
  backend = None
261
- if answers.get("state_bucket") and answers.get("state_bucket_exists"):
262
- backend = ("gcs", {"bucket": answers["state_bucket"],
263
- "prefix": f"{str(answers['state_prefix']).strip('/')}/bootstrap"})
297
+ if bucket and bucket_exists:
298
+ backend = ("gcs", {"bucket": bucket, "prefix": f"{bucket_prefix}/bootstrap"})
264
299
  by_hand = ["The network and the firewall rules: the team's, never modified by the system."]
265
300
  if provider_exists and not names_repository(condition, answers):
266
301
  by_hand.insert(0, f"The existing provider `{answers['provider']}` has the attribute condition `{condition or '(none)'}`, "
@@ -272,7 +307,6 @@ def render(answers: Answers) -> Rendered:
272
307
  by_hand.append("No WRITE service account: `GCP_APPLY_SERVICE_ACCOUNT` is set to the READ-ONLY account's "
273
308
  "address (CI never writes to a runtime it does not perform on), or delete those lines from "
274
309
  "the `perform` job (CI_SETUP.md 3.4 step 4).")
275
- labels = {k: v for k, v in (p.split("=", 1) for p in _split(answers.get("labels")) if "=" in p)}
276
310
  destroy_takes: list[str] = []
277
311
  for key, roles in (("read", _split(answers.get("read_roles"))),
278
312
  ("write", _split(answers.get("write_roles")) if want_write else [])):
@@ -287,7 +321,9 @@ def render(answers: Answers) -> Rendered:
287
321
  module_args={**{{"provider": "provider_id"}.get(n, n): Raw(f"var.gcp_{n}") for n in names},
288
322
  **{k: Raw(f"var.gcp_{k}") for k in ("principal_value", "attribute_condition", "read_roles",
289
323
  "want_write", "write_account", "write_account_exists",
290
- "write_roles")}},
324
+ "write_roles", "state_bucket", "state_prefix",
325
+ "state_bucket_exists", "state_bucket_managed",
326
+ "state_bucket_location", "labels")}},
291
327
  variables=variables,
292
328
  tfvars=tfvars,
293
329
  outputs=(
@@ -297,6 +333,7 @@ def render(answers: Answers) -> Rendered:
297
333
  "the READ-ONLY service account's address: the GCP_SERVICE_ACCOUNT secret"),
298
334
  ("gcp_write_service_account", f"module.{MODULE}.write_service_account",
299
335
  "the address CI may write as (the READ-ONLY one when there is no WRITE account): the GCP_APPLY_SERVICE_ACCOUNT secret"),
336
+ ("gcp_state_bucket", f"module.{MODULE}.state_bucket", "the GCS state bucket, when the tree keeps its state in GCS"),
300
337
  ),
301
338
  required_providers={"google": {"source": "hashicorp/google", "version": ">= 5.0"}},
302
339
  provider_blocks=('provider "google" {\n project = var.gcp_project # the credentials: GOOGLE_OAUTH_ACCESS_TOKEN '
@@ -334,10 +371,39 @@ def render(answers: Answers) -> Rendered:
334
371
  "every role and binding here is one member added; nothing existing is replaced"
335
372
  + ("; an account that already exists is granted nothing unless you named the roles" if any(
336
373
  answers.get(f"{k}_account_exists") for k in ("read", "write")) else ""),
374
+ *((f"the GCS state bucket `{bucket}` "
375
+ + ("exists and is MANAGED here (made by an earlier apply of this root; a destroy of the root removes it): "
376
+ "this root's state is bound to it (decision D8)" if bucket_exists and bucket_managed else
377
+ "exists: this root's state is bound to it (decision D8); the bucket stood before this root and is not managed here"
378
+ if bucket_exists else
379
+ f"is CREATED here in {answers.get('state_bucket_location') or 'US'} (versioned, uniform access, no public "
380
+ "access; the CI identities get object access on it) and managed from then on: local state for this first apply"),)
381
+ if bucket else ("no GCS state bucket: the tree keeps its state elsewhere",)),
337
382
  *((f"labels: {', '.join(f'{k}={v}' for k, v in sorted(labels.items()))} (service accounts carry no labels; "
338
- "kept for what later iterations make)",) if labels else ()),
383
+ "the bucket does, when made here)",) if labels else ()),
339
384
  ),
340
385
  backend=backend,
386
+ coverage=(
387
+ (f"the workload identity pool `{answers['pool']}`", READ if answers["pool_exists"] else MADE,
388
+ "read as it stands" if answers["pool_exists"] else "the project lacked it"),
389
+ (f"the pool's GitHub provider `{answers['provider']}`", READ if provider_exists else MADE,
390
+ "read as it stands, never rewritten: other repositories may share its condition" if provider_exists
391
+ else "the project lacked it: made with a condition admitting this repository alone"),
392
+ (f"the READ-ONLY service account `{answers['read_account']}`",
393
+ READ if answers["read_account_exists"] else MADE,
394
+ "read as it stands; granted only the roles you named" if answers["read_account_exists"] else "the project lacked it"),
395
+ ((f"the WRITE service account `{answers.get('write_account')}`",
396
+ READ if answers.get("write_account_exists") else MADE,
397
+ "read as it stands; granted only the roles you named" if answers.get("write_account_exists")
398
+ else "the project lacked it") if want_write
399
+ else ("a WRITE service account", BY_HAND, "not wanted: CI performs on no GCE runtime, so none is made or read")),
400
+ ((f"the GCS state bucket `{bucket}`", MADE if bucket_managed else READ,
401
+ "made by an apply of this root and managed from then on, the CI identities' object access included"
402
+ if bucket_managed else "stood before this root: read, never managed")
403
+ if bucket else ("a GCS state bucket", BY_HAND, "the tree declares no gcs backend: nothing to make")),
404
+ ("the network and the firewall rules", BY_HAND,
405
+ "the team's, by decision: the system never creates or modifies network configuration"),
406
+ ),
341
407
  )
342
408
 
343
409
 
@@ -28,11 +28,10 @@ class GcpProviderSpecificImage(ProviderSpecificImage):
28
28
  ret["owners"] = [f'"{self.owner}"']
29
29
  return ret
30
30
  # Deferred: find the most recent image matching the output-name pattern
31
- # produced by a build in this project.
32
- return {
33
- "owners": ['"self"'],
34
- "filters": {
35
- "name": f'"{self.deferred_name_pattern}*"',
36
- },
37
- "most_recent": True,
38
- }
31
+ # produced by a build in this project. stage 106: an ImageQuery in the
32
+ # system's vocabulary, translated like a vendor query, then quoted.
33
+ from cs_image_system.contract.image import ImageQuery
34
+ from .gcp_utils import translate_image_query
35
+ native = translate_image_query(ImageQuery(name=f"{self.deferred_name_pattern}*", publisher=("self",), newest=True))
36
+ filters = {k: f'"{v}"' for k, v in native["terms"].items() if k != "status"}
37
+ return {"owners": [f'"{p}"' for p in native["projects"]], "filters": filters, "most_recent": True}
@@ -20,7 +20,8 @@ from cs_image_system.contract.core import PluginArtifactProtocol
20
20
  from .gcp_provider_specific_image import GcpProviderSpecificImage
21
21
  from .gcp_runtime_models import GCPCloudBuilderModel
22
22
  from cs_image_system.gcloud_runtime import gcp_utils
23
- from .gcp_utils import remap_for_image_query, query_image
23
+ from .gcp_utils import GCP_QUERY_KEYS, query_image, translate_image_query
24
+ from cs_image_system.contract.image import ImageQuery
24
25
 
25
26
  GCP_RUNTIME: str = "gcloud"
26
27
 
@@ -41,12 +42,18 @@ class GCPCloudBuilder(CloudBuilderBase[GCPCloudBuilderModel], PluginArtifactProt
41
42
  def provider_specific_image_class(self) -> type[ProviderSpecificImage]:
42
43
  return GcpProviderSpecificImage
43
44
 
45
+ # --- the image query (stage 106)
46
+ def supported_query_keys(self) -> frozenset[str]:
47
+ return GCP_QUERY_KEYS
48
+
49
+ def translate_image_query(self, query: ImageQuery) -> dict[str, Any]:
50
+ return translate_image_query(query)
51
+
44
52
  def query_provider_image(self, os_builder: OSBuilderBaseImageBuilderSubconfig) -> tuple[str, str, Mapping[str,Any]] | None:
45
53
  retval: str | None = None
46
- q, missed = remap_for_image_query(os_builder) if os_builder else ({}, {})
54
+ q = self.translate_image_query(os_builder.image_query()) if os_builder else {}
47
55
  kv = query_image(
48
56
  query=q,
49
- post_query_filter=missed,
50
57
  session_config=self.model.self_to_gcp_client_config(),
51
58
  )
52
59
  if kv:
@@ -71,9 +78,7 @@ class GCPCloudBuilder(CloudBuilderBase[GCPCloudBuilderModel], PluginArtifactProt
71
78
  """The image an entry pins by ``image_id`` (stage 63): a GCE image is
72
79
  named uniquely within its project, so the name is searched in the
73
80
  entry's owner projects, as the vendor query is."""
74
- q, _missed = remap_for_image_query(os_builder)
75
- q = {k: v for k, v in q.items() if k not in ("family", "filter")}
76
- q["filter"] = f'name = "{image_id}"'
81
+ q = {"projects": list(os_builder.image_query().publisher), "filter": f'name = "{image_id}"'}
77
82
  kv = query_image(query=q, session_config=self.model.self_to_gcp_client_config())
78
83
  if not kv:
79
84
  return None
@@ -8,6 +8,8 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
+
12
+ from cs_image_system.contract.image import ImageQuery
11
13
  import re
12
14
  from collections.abc import Mapping
13
15
  from typing import Any, Sequence, cast
@@ -16,7 +18,6 @@ from google.api_core import exceptions as gcp_exceptions
16
18
  from google.cloud import compute_v1
17
19
 
18
20
  from cs_image_system.contract.constants import SELF
19
- from cs_image_system.base.models.os_builder_runtime_config import OSBuilderBaseImageBuilderSubconfig
20
21
 
21
22
  log = logging.getLogger(__name__)
22
23
 
@@ -204,65 +205,42 @@ def build_filter_expression(terms: Mapping[str, Any]) -> str:
204
205
  return " AND ".join(parts)
205
206
 
206
207
 
207
- def remap_for_image_query(
208
- rc: OSBuilderBaseImageBuilderSubconfig,
209
- ) -> tuple[dict[str, Any], dict[str, Any]]:
210
- """Convert a generic image-builder subconfig query to a GCP image query.
211
-
212
- Returns a tuple of (query, missed) where ``query`` carries the projects to
213
- search and a Compute Engine filter expression, and ``missed`` holds
214
- unmappable keys applied as a post-query filter (mirrors the AWS remap).
215
- """
216
- ret: dict[str, Any] = {}
217
- owners: Sequence[Any] = rc.get_owners() if rc else []
218
- ret["projects"] = list(owners) # resolved to real projects at query time
219
- missed: dict[str, Any] = {}
220
- filter_terms: dict[str, Any] = {}
221
-
222
- for topk, topv in rc.get_query().items():
223
- if topk == "owners":
224
- ret["projects"] = topv if isinstance(topv, list) else [topv]
208
+ #: stage 106: the keys of the system's image-query vocabulary Compute
209
+ #: Engine's images.list can answer, and the field each becomes.
210
+ GCP_QUERY_KEYS: frozenset[str] = frozenset({"name", "family", "architecture", "publisher", "state", "tags",
211
+ "newest", "native"})
212
+ GCP_FIELD_NAMES: dict[str, str] = {"name": "name", "family": "family", "architecture": "architecture", "state": "status"}
213
+
214
+
215
+ def translate_image_query(query: ImageQuery) -> dict[str, Any]:
216
+ """The system's query in Compute Engine's words (stage 106):
217
+ ``{"projects": [...], "terms": {<image field>: value}, "filter": <the
218
+ list() expression>, "most_recent": bool}``; ``query_image`` takes it.
219
+ ``state`` defaults to ``available`` (READY); ``architecture`` and
220
+ ``state`` values are mapped; ``tags`` become ``labels.<k>``. A key the
221
+ API cannot answer (the EC2 particulars) raises ValueError naming it
222
+ (``validate`` refuses it first); ``native`` keys are image fields,
223
+ passed verbatim."""
224
+ unsupported = sorted(k for k in query.keys() if k not in GCP_QUERY_KEYS)
225
+ if unsupported:
226
+ raise ValueError(f"GCE cannot translate image query key(s) {', '.join(unsupported)}")
227
+ terms: dict[str, Any] = {}
228
+ for key, name in GCP_FIELD_NAMES.items():
229
+ value = getattr(query, key)
230
+ if value is None:
225
231
  continue
226
- if topk == "filters":
227
- if not isinstance(topv, dict):
228
- raise ValueError(f"Expected 'filters' value to be a dict, got {type(topv)}")
229
- terms = dict(topv)
230
- # Ensure we only get READY images, but allow override.
231
- terms["state"] = terms.get("state", "available")
232
- for _k, _v in terms.items():
233
- if _k in AWS_ONLY_QUERY_KEYS or _k.startswith("block_device_mapping"):
234
- log.debug(f"Dropping AWS-only image query key {_k!r} for GCP query.")
235
- continue
236
- if _k.startswith("tag:"):
237
- filter_terms[f"labels.{_k[4:]}"] = _v
238
- continue
239
- field_name = GCP_DI_MAP.get(_k)
240
- if not field_name:
241
- missed[_k] = _v
242
- continue
243
- if field_name == "status":
244
- _v = _gcp_status(_v)
245
- elif field_name == "architecture":
246
- _v = _gcp_architecture(_v)
247
- filter_terms[field_name] = _v
248
- continue
249
- field_name = GCP_DI_MAP.get(topk)
250
- if not field_name:
251
- if topk in AWS_ONLY_QUERY_KEYS:
252
- log.debug(f"Dropping AWS-only image query key {topk!r} for GCP query.")
253
- else:
254
- missed[topk] = topv
255
- continue
256
- if field_name == "status":
257
- topv = _gcp_status(topv)
258
- elif field_name == "architecture":
259
- topv = _gcp_architecture(topv)
260
- filter_terms[field_name] = topv
261
-
262
- if "status" not in filter_terms:
263
- filter_terms["status"] = "READY"
264
- ret["filter"] = build_filter_expression(filter_terms)
265
- return (ret, missed)
232
+ if name == "status":
233
+ value = _gcp_status(value)
234
+ elif name == "architecture":
235
+ value = _gcp_architecture(value)
236
+ terms[name] = value
237
+ terms.setdefault("status", "READY")
238
+ for k, v in sorted(query.tags.items()):
239
+ terms[f"labels.{k}"] = v
240
+ for k, v in query.native.items():
241
+ terms[str(k)] = v
242
+ return {"projects": list(query.publisher), "terms": terms, "filter": build_filter_expression(terms),
243
+ "most_recent": True if query.newest is None else bool(query.newest)}
266
244
 
267
245
 
268
246
  def _image_to_dict(image: compute_v1.Image, project: str) -> dict[str, Any]:
@@ -339,6 +317,7 @@ def query_image(
339
317
  results: list[dict[str, Any]] = []
340
318
  filter_expr = query.get("filter", "")
341
319
  family = query.get("family")
320
+ most_recent = query.get("most_recent", True)
342
321
  for project in projects:
343
322
  if filter_expr:
344
323
  results.extend(_query_by_filters(client, project, filter_expr))
@@ -365,6 +344,8 @@ def query_image(
365
344
  if not ilist:
366
345
  return None
367
346
  if len(ilist) > 1:
347
+ if not most_recent:
348
+ raise ImageQueryError(f"{len(ilist)} images match and the query says newest: false; narrow it")
368
349
  ilist.sort(key=lambda x: x.get("creation_timestamp", ""), reverse=True)
369
350
 
370
351
  return ilist[0]
@@ -3,7 +3,7 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
5
  """Unit tests for the GCP-native image query utilities (no network access)."""
6
- from typing import Any, cast
6
+ from typing import Any
7
7
 
8
8
  import pytest
9
9
  from google.cloud import compute_v1
@@ -13,9 +13,10 @@ from cs_image_system.gcloud_runtime.gcp_utils import (
13
13
  build_filter_expression,
14
14
  get_image_owner,
15
15
  query_image,
16
- remap_for_image_query,
17
16
  resolve_projects,
17
+ translate_image_query,
18
18
  )
19
+ from cs_image_system.contract.image import ImageQuery
19
20
 
20
21
 
21
22
  class FakeSubconfig:
@@ -51,41 +52,32 @@ def _img(name: str, ts: str, **kw: Any) -> compute_v1.Image:
51
52
  return compute_v1.Image(name=name, creation_timestamp=ts, **kw)
52
53
 
53
54
 
54
- def test_remap_translates_aws_shaped_query_to_gcp_filter():
55
- rc = FakeSubconfig(
56
- owners=["self", "amazon"],
57
- query={
58
- "filters": {
59
- "name": "RHEL-9.5*x86_64*",
60
- "root_device_type": "ebs", # AWS-only: dropped
61
- "virtualization_type": "hvm", # AWS-only: dropped
62
- "architecture": "x86_64",
63
- "state": "available",
64
- }
65
- },
66
- )
67
- q, missed = remap_for_image_query(cast(Any, rc))
68
- assert q["projects"] == ["self", "amazon"]
69
- assert '(name = "RHEL-9.5*x86_64*")' in q["filter"]
70
- assert '(status = "READY")' in q["filter"]
71
- assert '(architecture = "X86_64")' in q["filter"]
72
- assert "root_device_type" not in q["filter"]
73
- assert "virtualization" not in q["filter"]
74
- # AWS-only keys must not leak into the post-query filter either
75
- assert missed == {}
76
-
77
-
78
- def test_remap_defaults_status_ready_and_maps_tags_to_labels():
79
- rc = FakeSubconfig(owners=[], query={"filters": {"tag:Environment": "prod"}})
80
- q, _ = remap_for_image_query(cast(Any, rc))
81
- assert '(labels.Environment = "prod")' in q["filter"]
82
- assert '(status = "READY")' in q["filter"]
83
-
84
-
85
- def test_remap_unknown_keys_go_to_missed():
86
- rc = FakeSubconfig(owners=[], query={"filters": {"totally_custom": "x"}})
87
- _, missed = remap_for_image_query(cast(Any, rc))
88
- assert missed == {"totally_custom": "x"}
55
+ def test_translate_puts_the_systems_query_in_compute_engines_words():
56
+ # stage 106: the vocabulary is the system's; the values are mapped
57
+ q = translate_image_query(ImageQuery.from_mapping({
58
+ "publisher": ["self", "rhel-cloud"], "name": "rhel-9-v*", "architecture": "x86_64", "state": "available"}))
59
+ assert q["projects"] == ["self", "rhel-cloud"]
60
+ assert q["terms"] == {"name": "rhel-9-v*", "architecture": "X86_64", "status": "READY"}
61
+ assert '(name = "rhel-9-v*")' in q["filter"] and '(status = "READY")' in q["filter"]
62
+ assert '(architecture = "X86_64")' in q["filter"] and q["most_recent"] is True
63
+
64
+
65
+ def test_translate_defaults_status_ready_maps_tags_to_labels_and_passes_native():
66
+ q = translate_image_query(ImageQuery.from_mapping({"tag:Environment": "prod", "family": "debian-12",
67
+ "native": {"deprecated.state": "ACTIVE"}}))
68
+ assert q["terms"] == {"family": "debian-12", "status": "READY", "labels.Environment": "prod",
69
+ "deprecated.state": "ACTIVE"}
70
+
71
+
72
+ def test_translate_refuses_the_ec2_particulars_by_name():
73
+ # the keys GCE used to drop with a debug line are refused, named (validate refuses them first)
74
+ with pytest.raises(ValueError, match="root_device, virtualization"):
75
+ translate_image_query(ImageQuery.from_mapping({"filters": {"root_device_type": "ebs", "virtualization_type": "hvm"}}))
76
+
77
+
78
+ def test_an_unknown_key_is_refused_by_the_vocabulary_not_buried():
79
+ with pytest.raises(ValueError, match="totally_custom"):
80
+ ImageQuery.from_mapping({"filters": {"totally_custom": "x"}})
89
81
 
90
82
 
91
83
  def test_build_filter_expression_quotes_strings_and_lowers_bools():