cs-image-system-gcloud-runtime-plugin 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,351 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """The GCP section of the bootstrap (stage 70 step 5; CI_SETUP.md 3.4).
6
+
7
+ What terraform makes, per the guide: the workload identity pool and its
8
+ GitHub provider (the attribute mapping and the condition that admits this
9
+ repository), the READ-ONLY service account with the roles a load and the
10
+ state query read, the optional WRITE service account with the roles a bake
11
+ uses, and the ``workloadIdentityUser`` bindings that let the repository
12
+ impersonate them (the write one narrowed to the production branch).
13
+
14
+ What already exists is READ, never managed: an existing pool, provider or
15
+ service account becomes a data source. A provider's attribute condition may
16
+ admit other repositories that share it, so the bootstrap never rewrites one;
17
+ when an existing condition does not name this repository, the generated
18
+ README says so as a step by hand. Everything the module adds is additive --
19
+ project roles and bindings are single-member resources -- so no existing
20
+ grant is touched.
21
+
22
+ Every "existing" question's default is a PROBE through ``gcloud`` when it can
23
+ answer (the project number, the pool, the provider with its condition and
24
+ mapping, the service accounts); when it cannot -- no ``gcloud``, no
25
+ credentials -- there is no default, and ``--quiet`` refuses the question by
26
+ name. A starter's placeholders are no defaults either.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from typing import Any
33
+
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
36
+
37
+ MODULE = "bootstrap_gcp"
38
+ ISSUER = "https://token.actions.githubusercontent.com"
39
+ ABSENT = ("NOT_FOUND", "not found", "404", "does not exist")
40
+ READ_ROLES = "roles/compute.viewer"
41
+ WRITE_ROLES = "roles/compute.instanceAdmin.v1,roles/compute.storageAdmin,roles/iam.serviceAccountUser"
42
+ # hygiene XII item 23 (the walk's F51): an account that already exists is
43
+ # granted NOTHING by default. What it holds was granted by whoever made it;
44
+ # a grant this root adds sits in this root's state, so a destroy here would
45
+ # take it from every tree that reads the account.
46
+ SHARED_ACCOUNT = ("for an account that already exists the default is none: it keeps what whoever made it "
47
+ "granted, and a grant held in this root's state leaves with this root's destroy, from every "
48
+ "tree that reads the account")
49
+
50
+
51
+ # ------------------------------------------------------------------ the project
52
+ def _runtime(facts: Facts) -> dict[str, Any]:
53
+ return runtime_of_type(facts, "gcloud") or {}
54
+
55
+
56
+ def _describe(facts: Facts, *args: str) -> tuple[bool | None, dict[str, Any]]:
57
+ """``(exists, record)``: True with what gcloud printed, False when the
58
+ project SAID it is not there, None when it could not be asked."""
59
+ code, out = facts.probe(["gcloud", *args, "--format=json"])
60
+ if code == 0:
61
+ try:
62
+ data = json.loads(out[out.index("{"):out.rindex("}") + 1]) if "{" in out else {}
63
+ except ValueError:
64
+ data = {}
65
+ return True, data if isinstance(data, dict) else {}
66
+ if any(mark in out for mark in ABSENT):
67
+ return False, {}
68
+ return None, {}
69
+
70
+
71
+ def _project_number(facts: Facts, answers: Answers) -> str | None:
72
+ project = answers.get("project")
73
+ if not project:
74
+ return None
75
+ code, out = facts.probe(["gcloud", "projects", "describe", str(project), "--format=value(projectNumber)"])
76
+ number = out.strip().splitlines()[0].strip() if code == 0 and out.strip() else ""
77
+ return number if number.isdigit() else None
78
+
79
+
80
+ def _pool(facts: Facts, answers: Answers) -> tuple[bool | None, dict[str, Any]]:
81
+ return _describe(facts, "iam", "workload-identity-pools", "describe", str(answers.get("pool")),
82
+ f"--project={answers.get('project')}", "--location=global")
83
+
84
+
85
+ def _provider(facts: Facts, answers: Answers) -> tuple[bool | None, dict[str, Any]]:
86
+ if answers.get("pool_exists") is False:
87
+ return False, {} # no pool, so no provider in it
88
+ return _describe(facts, "iam", "workload-identity-pools", "providers", "describe", str(answers.get("provider")),
89
+ f"--project={answers.get('project')}", "--location=global",
90
+ f"--workload-identity-pool={answers.get('pool')}")
91
+
92
+
93
+ def _account_email(answers: Answers, key: str) -> str:
94
+ return f"{answers.get(key)}@{answers.get('project')}.iam.gserviceaccount.com"
95
+
96
+
97
+ def _account_exists(key: str):
98
+ def probe(facts: Facts, answers: Answers) -> bool | None:
99
+ if not answers.get(key) or not answers.get("project"):
100
+ return None
101
+ return _describe(facts, "iam", "service-accounts", "describe", _account_email(answers, key),
102
+ f"--project={answers.get('project')}")[0]
103
+ return probe
104
+
105
+
106
+ # -------------------------------------------------------------------- defaults
107
+ def _principal_default(facts: Facts, answers: Answers) -> str:
108
+ """Which token attribute the bindings name: the repository's id (a name
109
+ can be recycled, an id cannot) when the provider maps it and the id is
110
+ known; the repository's name otherwise. An EXISTING provider decides by
111
+ what it already maps."""
112
+ has_id = bool(facts.repo_id)
113
+ if answers.get("provider_exists"):
114
+ mapping = (_provider(facts, answers)[1].get("attributeMapping") or {})
115
+ return "repository_id" if has_id and "attribute.repository_id" in mapping else "repository"
116
+ return "repository_id" if has_id else "repository"
117
+
118
+
119
+ def _condition_default(facts: Facts, answers: Answers) -> str | None:
120
+ """The provider's attribute condition: an existing provider's own, kept
121
+ verbatim (it may admit other repositories; it is read, never rewritten);
122
+ for a new one, the guide's -- this repository and nothing else."""
123
+ if answers.get("provider_exists"):
124
+ exists, record = _provider(facts, answers)
125
+ return str(record.get("attributeCondition") or "") if exists else None
126
+ if answers.get("principal_attribute") == "repository_id" and answers.get("repo_id"):
127
+ return f"assertion.repository_id == '{answers['repo_id']}'"
128
+ return f"assertion.repository == '{answers['repository']}'" if answers.get("repository") else None
129
+
130
+
131
+ def _write_default(facts: Facts, answers: Answers) -> bool:
132
+ """A WRITE account is needed only when CI performs on a GCE runtime: by
133
+ default, when the tree's default runtime is one."""
134
+ name = first_runtime_name(facts, prefer=())
135
+ return any(r.get("name") == name and str(r.get("type", "")).lower() == "gcloud" for r in facts.runtimes)
136
+
137
+
138
+ def _gcs(facts: Facts, key: str) -> str:
139
+ return real((default_state_backend(facts, "gcs") or {}).get(key)) or ""
140
+
141
+
142
+ def _bucket_exists(facts: Facts, answers: Answers) -> bool | None:
143
+ return _describe(facts, "storage", "buckets", "describe", f"gs://{answers.get('state_bucket')}")[0]
144
+
145
+
146
+ def _roles_default(exists_key: str, roles: str):
147
+ """The roles a NEW account is granted; none for one that exists."""
148
+ def default(facts: Facts, answers: Answers) -> str:
149
+ return "" if answers.get(exists_key) else roles
150
+ return default
151
+
152
+
153
+ def _labels_default(facts: Facts, answers: Answers) -> str:
154
+ tags = _runtime(facts).get("tags") or {}
155
+ return ",".join(f"{k}={v}" for k, v in sorted(tags.items()) if real(v)) if isinstance(tags, dict) else ""
156
+
157
+
158
+ def _split(text: Any) -> list[str]:
159
+ return [s.strip() for s in str(text or "").split(",") if s.strip()]
160
+
161
+
162
+ def names_repository(condition: str, answers: Answers) -> bool:
163
+ """Whether a condition's text names this repository, by name or by id."""
164
+ text = str(condition or "")
165
+ return bool(text) and (str(answers.get("repository") or "\0") in text
166
+ or (bool(answers.get("repo_id")) and str(answers["repo_id"]) in text))
167
+
168
+
169
+ UNASKABLE = "the project could not be asked: `gcloud` is absent or has no credentials"
170
+
171
+ QUESTIONS: tuple[Question, ...] = (
172
+ Question("project", "The GCP project id", default=lambda f, a: real(_runtime(f).get("project_id")),
173
+ help="the GCE runtime's `project_id` in cfg/runtime-builders.yml is unset or still the starter's placeholder"),
174
+ Question("project_number", "The project's NUMBER (`gcloud projects describe <project> --format='value(projectNumber)'`)",
175
+ default=_project_number, help=UNASKABLE),
176
+ Question("repository", "The GitHub repository the identities trust, as owner/name", default=lambda f, a: f.repository,
177
+ help="read from `git remote get-url origin` when the checkout has a GitHub remote"),
178
+ Question("pool", "The workload identity pool's id", default="github"),
179
+ Question("pool_exists", "Does that workload identity pool already exist (it is then read, not made)?", kind="bool",
180
+ default=lambda f, a: _pool(f, a)[0], help=UNASKABLE),
181
+ Question("provider", "The pool's GitHub provider id", default="github"),
182
+ Question("provider_exists", "Does that provider already exist (it is then read, never rewritten)?", kind="bool",
183
+ default=lambda f, a: _provider(f, a)[0], help=UNASKABLE),
184
+ Question("principal_attribute", "Which token attribute the bindings name: the repository's id (it cannot be "
185
+ "recycled) or its name", kind="choice", choices=("repository_id", "repository"), default=_principal_default),
186
+ Question("repo_id", "The repository's numeric id (`gh api repos/<owner>/<repo> --jq .id`)",
187
+ when=lambda a: a.get("principal_attribute") == "repository_id",
188
+ default=lambda f, a: str(f.repo_id) if f.repo_id else None, help="`gh` is absent or not logged in"),
189
+ Question("attribute_condition", "The provider's attribute condition (an existing provider's is shown as it "
190
+ "stands and is not changed)", default=_condition_default, help=UNASKABLE),
191
+ Question("production_branch", "The branch the WRITE account trusts (the perform job's)", default="main"),
192
+ Question("read_account", "The READ-ONLY service account's id (GCP_SERVICE_ACCOUNT)", default="csis-github-readonly"),
193
+ Question("read_account_exists", "Does the READ-ONLY service account already exist (it is then read, not made)?",
194
+ kind="bool", default=_account_exists("read_account"), help=UNASKABLE),
195
+ Question("read_roles", "Project roles this root GRANTS the READ-ONLY account, comma-separated (roles/compute.viewer "
196
+ "for a load and the state query; add roles/file.viewer for Filestore, a storage role for bucket "
197
+ f"metadata; {SHARED_ACCOUNT})", default=_roles_default("read_account_exists", READ_ROLES),
198
+ may_be_empty=True),
199
+ Question("want_write", "Does CI perform on a GCE runtime (it then needs a WRITE service account)?", kind="bool",
200
+ default=_write_default),
201
+ Question("write_account", "The WRITE service account's id (GCP_APPLY_SERVICE_ACCOUNT)",
202
+ when=lambda a: bool(a.get("want_write")), default="csis-github-apply"),
203
+ Question("write_account_exists", "Does the WRITE service account already exist (it is then read, not made)?",
204
+ kind="bool", when=lambda a: bool(a.get("want_write")), default=_account_exists("write_account"),
205
+ help=UNASKABLE),
206
+ Question("write_roles", "Project roles this root GRANTS the WRITE account, comma-separated (instances, images, "
207
+ f"disks, and acting as the runner service account; {SHARED_ACCOUNT})",
208
+ when=lambda a: bool(a.get("want_write")), default=_roles_default("write_account_exists", WRITE_ROLES),
209
+ may_be_empty=True),
210
+ Question("state_bucket", "The GCS state bucket, when this tree keeps its state in GCS (none otherwise)",
211
+ default=lambda f, a: _gcs(f, "bucket"), may_be_empty=True),
212
+ Question("state_prefix", "The state prefix within that bucket", when=lambda a: bool(a.get("state_bucket")),
213
+ default=lambda f, a: _gcs(f, "prefix").strip("/") or None,
214
+ help="the default GCS backend's `prefix` in cfg/state-backends.yml"),
215
+ 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),
217
+ Question("labels", "Labels for what this root makes, as k=v,k=v", default=_labels_default, may_be_empty=True),
218
+ )
219
+
220
+
221
+ def render(answers: Answers) -> Rendered:
222
+ project = str(answers["project"])
223
+ want_write = bool(answers.get("want_write"))
224
+ attribute = str(answers["principal_attribute"])
225
+ principal_value = str(answers.get("repo_id") or "") if attribute == "repository_id" else str(answers["repository"])
226
+ condition = str(answers.get("attribute_condition") or "")
227
+ provider_exists = bool(answers["provider_exists"])
228
+ names = ("project", "project_number", "pool", "pool_exists", "provider", "provider_exists", "principal_attribute",
229
+ "production_branch", "read_account", "read_account_exists")
230
+ bools = {"pool_exists", "provider_exists", "read_account_exists"}
231
+ described = {
232
+ "project": "the GCP project id", "project_number": "the project's number",
233
+ "pool": "the workload identity pool's id", "pool_exists": "the pool already exists (read, not made)",
234
+ "provider": "the pool's GitHub provider id",
235
+ "provider_exists": "the provider already exists (read, never rewritten)",
236
+ "principal_attribute": "the token attribute the bindings name: repository_id or repository",
237
+ "production_branch": "the branch the WRITE account trusts",
238
+ "read_account": "the READ-ONLY service account's id",
239
+ "read_account_exists": "the READ-ONLY service account already exists (read, not made)",
240
+ }
241
+ variables = tuple((f"gcp_{n}", "bool" if n in bools else "string", described[n]) for n in names) + (
242
+ ("gcp_principal_value", "string", "the value of that attribute: the repository's id, or its owner/name"),
243
+ ("gcp_attribute_condition", "string", "the provider's attribute condition (used only when the provider is made)"),
244
+ ("gcp_read_roles", "list(string)", "project roles the READ-ONLY account holds"),
245
+ ("gcp_want_write", "bool", "CI performs on a GCE runtime: a WRITE service account is made or read"),
246
+ ("gcp_write_account", "string", "the WRITE service account's id (empty when there is none)"),
247
+ ("gcp_write_account_exists", "bool", "the WRITE service account already exists (read, not made)"),
248
+ ("gcp_write_roles", "list(string)", "project roles the WRITE account holds"),
249
+ )
250
+ tfvars: dict[str, Any] = {f"gcp_{n}": (bool(answers[n]) if n in bools else str(answers[n])) for n in names}
251
+ tfvars.update({
252
+ "gcp_principal_value": principal_value,
253
+ "gcp_attribute_condition": condition,
254
+ "gcp_read_roles": _split(answers.get("read_roles")),
255
+ "gcp_want_write": want_write,
256
+ "gcp_write_account": str(answers.get("write_account") or "") if want_write else "",
257
+ "gcp_write_account_exists": bool(answers.get("write_account_exists")) if want_write else False,
258
+ "gcp_write_roles": _split(answers.get("write_roles")) if want_write else [],
259
+ })
260
+ 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"})
264
+ by_hand = ["The network and the firewall rules: the team's, never modified by the system."]
265
+ if provider_exists and not names_repository(condition, answers):
266
+ by_hand.insert(0, f"The existing provider `{answers['provider']}` has the attribute condition `{condition or '(none)'}`, "
267
+ f"which does not name this repository (`{answers['repository']}`"
268
+ + (f", id {answers['repo_id']}" if answers.get("repo_id") else "")
269
+ + "): add it to the condition by hand, or the bindings made here admit nobody. The bootstrap "
270
+ "never rewrites a provider that exists, because other repositories may share it.")
271
+ if not want_write:
272
+ by_hand.append("No WRITE service account: `GCP_APPLY_SERVICE_ACCOUNT` is set to the READ-ONLY account's "
273
+ "address (CI never writes to a runtime it does not perform on), or delete those lines from "
274
+ "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
+ destroy_takes: list[str] = []
277
+ for key, roles in (("read", _split(answers.get("read_roles"))),
278
+ ("write", _split(answers.get("write_roles")) if want_write else [])):
279
+ if roles and answers.get(f"{key}_account_exists"):
280
+ destroy_takes.append(f"the project roles {', '.join(f'`{r}`' for r in roles)} of the {key.upper()} account "
281
+ f"`{answers[f'{key}_account']}`, which already existed: every tree that reads the "
282
+ "account relies on what it holds")
283
+ return Rendered(
284
+ module=MODULE,
285
+ destroy_takes=tuple(destroy_takes),
286
+ # `provider` is a reserved variable name inside a module: the module calls it provider_id
287
+ module_args={**{{"provider": "provider_id"}.get(n, n): Raw(f"var.gcp_{n}") for n in names},
288
+ **{k: Raw(f"var.gcp_{k}") for k in ("principal_value", "attribute_condition", "read_roles",
289
+ "want_write", "write_account", "write_account_exists",
290
+ "write_roles")}},
291
+ variables=variables,
292
+ tfvars=tfvars,
293
+ outputs=(
294
+ ("gcp_workload_identity_provider", f"module.{MODULE}.workload_identity_provider",
295
+ "the provider's full name: the GCP_WORKLOAD_IDENTITY_PROVIDER secret"),
296
+ ("gcp_read_service_account", f"module.{MODULE}.read_service_account",
297
+ "the READ-ONLY service account's address: the GCP_SERVICE_ACCOUNT secret"),
298
+ ("gcp_write_service_account", f"module.{MODULE}.write_service_account",
299
+ "the address CI may write as (the READ-ONLY one when there is no WRITE account): the GCP_APPLY_SERVICE_ACCOUNT secret"),
300
+ ),
301
+ required_providers={"google": {"source": "hashicorp/google", "version": ">= 5.0"}},
302
+ provider_blocks=('provider "google" {\n project = var.gcp_project # the credentials: GOOGLE_OAUTH_ACCESS_TOKEN '
303
+ '(README.md, "Apply")\n}\n'),
304
+ # The probes asked as the active `gcloud` account; terraform must act as
305
+ # the same one. Application-default credentials are often something else
306
+ # entirely -- on the reference machine they impersonate a runtime's
307
+ # service account, which can read no IAM, and the first live plan failed
308
+ # with 403 on every data source.
309
+ apply_env=(("GOOGLE_OAUTH_ACCESS_TOKEN", "$(gcloud auth print-access-token)",
310
+ "your active `gcloud` account's token, which the google provider prefers over application-default "
311
+ "credentials (those may impersonate a runtime's service account that can read no IAM); it lasts "
312
+ "about an hour"),),
313
+ secrets=(
314
+ Secret("GCP_WORKLOAD_IDENTITY_PROVIDER", "output", "gcp_workload_identity_provider",
315
+ "the provider's full resource name, from the applied root"),
316
+ Secret("GCP_SERVICE_ACCOUNT", "output", "gcp_read_service_account",
317
+ "the READ-ONLY service account's address, from the applied root"),
318
+ Secret("GCP_APPLY_SERVICE_ACCOUNT", "output", "gcp_write_service_account",
319
+ "the address CI may write as, from the applied root"),
320
+ ),
321
+ by_hand=tuple(by_hand),
322
+ notes=(
323
+ f"project `{project}` (number {answers['project_number']}); the pool `{answers['pool']}` "
324
+ + ("read (it exists)" if answers["pool_exists"] else "created")
325
+ + f", its provider `{answers['provider']}` " + ("read (it exists, and is not rewritten)" if provider_exists
326
+ else f"created with the condition `{condition}`"),
327
+ f"the READ-ONLY account `{answers['read_account']}` " + ("read (it exists)" if answers["read_account_exists"] else "created")
328
+ + f", holding {', '.join(_split(answers.get('read_roles'))) or 'no project role'}; impersonated by "
329
+ f"`attribute.{attribute}/{principal_value}`",
330
+ (f"the WRITE account `{answers.get('write_account')}` "
331
+ + ("read (it exists)" if answers.get("write_account_exists") else "created")
332
+ + f", impersonated on `{answers['production_branch']}` alone") if want_write
333
+ else "no WRITE account: CI does not perform on a GCE runtime",
334
+ "every role and binding here is one member added; nothing existing is replaced"
335
+ + ("; an account that already exists is granted nothing unless you named the roles" if any(
336
+ answers.get(f"{k}_account_exists") for k in ("read", "write")) else ""),
337
+ *((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 ()),
339
+ ),
340
+ backend=backend,
341
+ )
342
+
343
+
344
+ def wanted(facts: Facts) -> bool:
345
+ """The tree uses GCP: it declares a GCE runtime or keeps its state in GCS."""
346
+ return runtime_of_type(facts, "gcloud") is not None or default_state_backend(facts, "gcs") is not None
347
+
348
+
349
+ def gcp_section() -> Section:
350
+ return Section(name="gcp", title="GCP", questions=QUESTIONS, render=render, wanted=wanted,
351
+ description="the workload identity pool and GitHub provider, the read-only and write service accounts, their bindings")
@@ -0,0 +1,135 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """The ``googlecompute`` packer source for an image baked on a GCE runtime
6
+ (EXPLORE GCP increment 2).
7
+
8
+ GCE has two rules AWS does not:
9
+
10
+ * **names and labels are constrained** -- image names match
11
+ ``[a-z]([-a-z0-9]*[a-z0-9])?`` (max 63), label keys/values are lowercase
12
+ ``[a-z0-9_-]`` (max 63). Every csis tag is mapped ONCE, here, by
13
+ :func:`gce_name` / :func:`gce_label`;
14
+ * **image families are native series** -- the bake sets ``image_family``
15
+ to the csis series, so "the latest build of series X" is GCE's own
16
+ ``source_image_family = X`` lookup and a deferred parent needs no
17
+ name-pattern query at all. A pinned parent (N5) bakes from
18
+ ``source_image = <build id>`` (the build id IS the image name).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import re
24
+ from typing import Any
25
+
26
+ import hcl2
27
+ from hcl2 import Builder
28
+
29
+ from cs_image_system.base.constants import OOPS_DEFAULTS, SELF
30
+ from cs_image_system.hashicorp_utils.hashicorp import FO
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ GOOGLECOMPUTE = "googlecompute"
35
+ _NAME_MAX = 63
36
+
37
+
38
+ def gce_name(value: str) -> str:
39
+ """A valid GCE resource name derived deterministically from ``value``."""
40
+ v = re.sub(r"[^a-z0-9-]+", "-", str(value).lower()).strip("-")
41
+ if not v or not v[0].isalpha():
42
+ v = f"i-{v}" if v else "i"
43
+ return v[:_NAME_MAX].rstrip("-")
44
+
45
+
46
+ def gce_label(value: str) -> str:
47
+ """A valid GCE label key/value derived from ``value``."""
48
+ return re.sub(r"[^a-z0-9_-]+", "-", str(value).lower())[:_NAME_MAX]
49
+
50
+
51
+ def googlecompute_source(model: Any, image: Any, *, runtime: str, source_type: str,
52
+ subconfig: Any, self_subconfig: Any, psi: Any, tags: dict[str, str],
53
+ final_name: str, pinned: str | None) -> list[str]:
54
+ from cs_image_system.base.lineage import series_of
55
+
56
+ c: dict[str, Any] = {}
57
+ if model.project_id:
58
+ c["project_id"] = model.project_id
59
+ if model.zone:
60
+ c["zone"] = model.zone
61
+ # --- the bake's source image
62
+ if pinned:
63
+ c["source_image"] = gce_name(pinned)
64
+ elif psi.is_resolved():
65
+ c["source_image"] = str(psi.identifier)
66
+ if psi.owner and psi.owner not in OOPS_DEFAULTS and psi.owner != SELF:
67
+ c["source_image_project_id"] = [f'"{psi.owner}"']
68
+ else:
69
+ # deferred: the parent series' family, built earlier in this run
70
+ # or by a previous one -- GCE resolves "latest in family" itself
71
+ c["source_image_family"] = gce_name(str(image.source_image))
72
+ # --- the artifact
73
+ c["image_name"] = gce_name(final_name)
74
+ c["image_family"] = gce_name(series_of(image))
75
+ c["image_labels"] = {gce_label(k): f'"{gce_label(v)}"' for k, v in sorted(tags.items())}
76
+ # bake size, AWS-parity fallback chain (finding 41: a dask bake fell
77
+ # through to the 1 GB runtime default and OOMed at 65 minutes)
78
+ mt = None
79
+ for cand in (getattr(subconfig, "machine_type", None) if subconfig else None,
80
+ getattr(subconfig, "default_machine_type", None) if subconfig else None):
81
+ if cand and cand not in OOPS_DEFAULTS:
82
+ mt = cand
83
+ break
84
+ c["machine_type"] = mt or model.get_default_machine_type()
85
+ if getattr(model, "service_account_email", None):
86
+ c["service_account_email"] = model.service_account_email
87
+ # finding 54 (found live): packer reached the build VM over its external
88
+ # IP on tcp:22, which only worked while the default VPC's
89
+ # default-allow-ssh rule existed; once that was deleted every bake timed
90
+ # out waiting for SSH. On an IAP runtime the bake tunnels through IAP
91
+ # like sessions do (operator prerequisite: iap.tunnelResourceAccessor
92
+ # for the runner service account); the ephemeral external IP stays for
93
+ # package egress only.
94
+ if str(getattr(model, "session_mechanism", "") or "").lower() == "iap":
95
+ c["use_iap"] = True
96
+ # found live (stage 10 proof): a bake that follows another build in the
97
+ # same run hits IAP's instance-lookup lag ("4047: Failed to lookup
98
+ # instance") for longer than packer's default 30 s tunnel wait, and
99
+ # SSH then times out with a healthy VM; give the tunnel two minutes
100
+ c["iap_tunnel_launch_wait"] = 120
101
+ # GCP-READINESS §6: spot build VMs; a preempted bake re-runs (stage 8.6)
102
+ if getattr(model, "bake_preemptible", False):
103
+ c["preemptible"] = True
104
+ # finding 51: the runtime's bake disk size wins over the image's (which
105
+ # inherits the OS builder's 200 GB default); the boot disk of every
106
+ # instance launched from the image is exactly this size
107
+ # stage 63: the one bake-disk rule the fingerprint also hashes (the
108
+ # runtime's default_disk_size first, finding 51)
109
+ from cs_image_system.base.global_context import GlobalTypeContext
110
+ from cs_image_system.base.lineage import bake_disk_size
111
+ size = bake_disk_size(GlobalTypeContext(), image, runtime)
112
+ if size:
113
+ c["disk_size"] = int(size)
114
+ # stage 63 items 22-23: the one resolver, the same the ansible provisioner
115
+ # asks (the runtime's ssh_username used to override the entry here, and
116
+ # the provisioner never read the entry); `packer` is the family default
117
+ # on GCE (googlecompute creates the account from metadata keys)
118
+ from cs_image_system.base.bake_user import resolve_bake_user
119
+ from cs_image_system.base.global_context import GlobalTypeContext
120
+ c["ssh_username"] = resolve_bake_user(GlobalTypeContext(), image, runtime)
121
+ networking = getattr(model, "networking", None)
122
+ if networking is not None:
123
+ if getattr(networking, "network", None) and networking.network not in OOPS_DEFAULTS:
124
+ c["network"] = networking.network
125
+ subnet = getattr(networking, "default_subnet_id", None)
126
+ if subnet and subnet not in OOPS_DEFAULTS:
127
+ c["subnetwork"] = subnet
128
+ if getattr(networking, "network_tags", None):
129
+ c["tags"] = [f'"{t}"' for t in networking.network_tags]
130
+ for k, v in c.items():
131
+ if isinstance(v, str) and not (v.startswith("var.") or v.startswith("data.")):
132
+ c[k] = f'"{v}"'
133
+ doc = Builder()
134
+ doc.block("source", labels=[f'"{source_type}"', f'"{image.name}"'], **c)
135
+ return hcl2.dumps(doc.build(), formatter_options=FO).splitlines()
@@ -0,0 +1,38 @@
1
+ # SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from cs_image_system.base.models.provider_specific_image import ProviderSpecificImage
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ class GcpProviderSpecificImage(ProviderSpecificImage):
14
+ """An image as GCP knows it: the identifier is an image name/self-link."""
15
+
16
+ @classmethod
17
+ def csis_name(cls) -> str:
18
+ return "gcloud"
19
+
20
+ def get_query_assets(self) -> dict[str, Any] | None:
21
+ if self.is_resolved():
22
+ ret: dict[str, Any] = {
23
+ "filters": {
24
+ "name": f'"{self.identifier}"',
25
+ },
26
+ }
27
+ if self.owner:
28
+ ret["owners"] = [f'"{self.owner}"']
29
+ return ret
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
+ }