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.
- cs_image_system/gcloud_runtime/__init__.py +3 -0
- cs_image_system/gcloud_runtime/bootstrap.py +351 -0
- cs_image_system/gcloud_runtime/gcp_packer_source.py +135 -0
- cs_image_system/gcloud_runtime/gcp_provider_specific_image.py +38 -0
- cs_image_system/gcloud_runtime/gcp_runtime_builders.py +608 -0
- cs_image_system/gcloud_runtime/gcp_runtime_models.py +168 -0
- cs_image_system/gcloud_runtime/gcp_utils.py +498 -0
- cs_image_system/gcloud_runtime/main.py +28 -0
- cs_image_system/py.typed +0 -0
- cs_image_system_gcloud_runtime_plugin-0.1.1.dist-info/METADATA +1168 -0
- cs_image_system_gcloud_runtime_plugin-0.1.1.dist-info/RECORD +14 -0
- cs_image_system_gcloud_runtime_plugin-0.1.1.dist-info/WHEEL +4 -0
- cs_image_system_gcloud_runtime_plugin-0.1.1.dist-info/entry_points.txt +5 -0
- cs_image_system_gcloud_runtime_plugin-0.1.1.dist-info/licenses/LICENSE +202 -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
|
+
}
|