cs-image-system-aws-runtime-plugin 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cs_image_system/aws_runtime/__init__.py +3 -0
- cs_image_system/aws_runtime/aws_packer_source.py +180 -0
- cs_image_system/aws_runtime/aws_provider_specific_image.py +43 -0
- cs_image_system/aws_runtime/aws_runtime_builders.py +518 -0
- cs_image_system/aws_runtime/aws_runtime_models.py +177 -0
- cs_image_system/aws_runtime/aws_utils.py +476 -0
- cs_image_system/aws_runtime/bootstrap.py +367 -0
- cs_image_system/aws_runtime/main.py +28 -0
- cs_image_system/py.typed +0 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/METADATA +1146 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/RECORD +14 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/WHEEL +4 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/entry_points.txt +5 -0
- cs_image_system_aws_runtime_plugin-0.1.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""The AWS section of the bootstrap (stage 70 step 4; CI_SETUP.md 3.3).
|
|
6
|
+
|
|
7
|
+
What terraform makes, per the guide: the GitHub OIDC identity provider, the
|
|
8
|
+
READ-ONLY role and the WRITE role with their trust documents (the subject
|
|
9
|
+
forms the interview chose: plain, id-bearing, or both) and permission
|
|
10
|
+
policies (the guide's, with the interview's bucket, prefix, region, account
|
|
11
|
+
and instance profile filled in), and optionally the state bucket (versioned,
|
|
12
|
+
encrypted, private) and the SSM instance profile when the account lacks
|
|
13
|
+
them. The network is never touched.
|
|
14
|
+
|
|
15
|
+
Every "existing" question is a fork, and its default is a PROBE of the
|
|
16
|
+
account when a session is there (``aws iam get-role``, ``head-bucket``...):
|
|
17
|
+
exists means a data source or an ``import`` block that adopts it, absent
|
|
18
|
+
means the resource. With no session a probe cannot answer, so ``--quiet``
|
|
19
|
+
refuses that question by name rather than guess -- a wrong guess is a failed
|
|
20
|
+
apply at best. A starter's placeholders (``REPLACE-ME``, the example
|
|
21
|
+
account id) are no defaults either.
|
|
22
|
+
|
|
23
|
+
The root's state binds to the tree's declared S3 backend when the bucket
|
|
24
|
+
exists (decision D8); when the bootstrap creates the bucket, the first apply
|
|
25
|
+
is local and a second interview, the bucket now standing, binds it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from cs_image_system.base.bootstrap.facts import Facts, default_state_backend, real, runtime_of_type
|
|
34
|
+
from cs_image_system.base.bootstrap.questions import Answers, Question, Raw, Rendered, Secret, Section
|
|
35
|
+
|
|
36
|
+
MODULE = "bootstrap_aws"
|
|
37
|
+
OIDC_HOST = "token.actions.githubusercontent.com"
|
|
38
|
+
MANAGED_BY = "cs-image-system-bootstrap"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------ the account
|
|
42
|
+
def _runtime(facts: Facts) -> dict[str, Any]:
|
|
43
|
+
return runtime_of_type(facts, "aws") or {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _profile_of(facts: Facts, answers: Answers) -> str:
|
|
47
|
+
if "profile" in answers:
|
|
48
|
+
return str(answers.get("profile") or "")
|
|
49
|
+
return real((_runtime(facts).get("credentials") or {}).get("profile_name")) or ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _aws(facts: Facts, answers: Answers, *args: str) -> tuple[int, str]:
|
|
53
|
+
cmd = ["aws", *args]
|
|
54
|
+
profile = _profile_of(facts, answers)
|
|
55
|
+
if profile:
|
|
56
|
+
cmd += ["--profile", profile]
|
|
57
|
+
return facts.probe(cmd)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _exists(result: tuple[int, str], absent_marks: tuple[str, ...]) -> bool | None:
|
|
61
|
+
"""True when the probe found it, False when the account SAID it is not
|
|
62
|
+
there, None when the probe could not ask (no tool, no session, denied)."""
|
|
63
|
+
code, out = result
|
|
64
|
+
if code == 0:
|
|
65
|
+
return True
|
|
66
|
+
if any(mark in out for mark in absent_marks):
|
|
67
|
+
return False
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _oidc_exists(facts: Facts, answers: Answers) -> bool | None:
|
|
72
|
+
account = answers.get("account_id")
|
|
73
|
+
if not account:
|
|
74
|
+
return None
|
|
75
|
+
arn = f"arn:aws:iam::{account}:oidc-provider/{OIDC_HOST}"
|
|
76
|
+
return _exists(_aws(facts, answers, "iam", "get-open-id-connect-provider", "--open-id-connect-provider-arn", arn),
|
|
77
|
+
("NoSuchEntity",))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _role_exists(key: str):
|
|
81
|
+
def probe(facts: Facts, answers: Answers) -> bool | None:
|
|
82
|
+
name = answers.get(key)
|
|
83
|
+
return _exists(_aws(facts, answers, "iam", "get-role", "--role-name", str(name)), ("NoSuchEntity",)) if name else None
|
|
84
|
+
return probe
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def trusted_subjects(document: Any) -> list[str]:
|
|
88
|
+
"""Every OIDC subject a trust document admits, in order, whatever the
|
|
89
|
+
operator (StringLike, StringEquals) and whether one value or a list."""
|
|
90
|
+
out: list[str] = []
|
|
91
|
+
statements = (document or {}).get("Statement") or []
|
|
92
|
+
for st in statements if isinstance(statements, list) else [statements]:
|
|
93
|
+
for values in ((st or {}).get("Condition") or {}).values():
|
|
94
|
+
for key, subs in (values or {}).items():
|
|
95
|
+
if str(key).endswith(":sub"):
|
|
96
|
+
for s in subs if isinstance(subs, list) else [subs]:
|
|
97
|
+
if s not in out:
|
|
98
|
+
out.append(str(s))
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extra_subjects(kind: str):
|
|
103
|
+
"""The subjects an EXISTING role trusts that are not this repository's:
|
|
104
|
+
another repository's CI may share the role (the reference account's
|
|
105
|
+
read-only role serves the system's own repository too, and the first
|
|
106
|
+
live plan of an adoption would have removed it). Adopting a role keeps
|
|
107
|
+
them by default; the prompt shows them so a person can drop them. A role
|
|
108
|
+
that does not exist has none; one whose trust cannot be read has no
|
|
109
|
+
default."""
|
|
110
|
+
def default(facts: Facts, answers: Answers) -> str | None:
|
|
111
|
+
if not answers.get(f"{kind}_role_exists"):
|
|
112
|
+
return ""
|
|
113
|
+
code, out = _aws(facts, answers, "iam", "get-role", "--role-name", str(answers[f"{kind}_role_name"]),
|
|
114
|
+
"--query", "Role.AssumeRolePolicyDocument", "--output", "json")
|
|
115
|
+
if code != 0:
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
document = json.loads(out[out.index("{"):out.rindex("}") + 1]) if "{" in out else {}
|
|
119
|
+
except ValueError:
|
|
120
|
+
return None
|
|
121
|
+
repository = str(answers.get("repository") or "")
|
|
122
|
+
owner, _, name = repository.partition("/")
|
|
123
|
+
mine = (f"repo:{repository}:", f"repo:{owner}@{answers.get('owner_id')}/{name}@{answers.get('repo_id')}:")
|
|
124
|
+
return ",".join(s for s in trusted_subjects(document) if not s.startswith(mine))
|
|
125
|
+
return default
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_subjects(text: Any) -> list[str]:
|
|
129
|
+
return [s.strip() for s in str(text or "").split(",") if s.strip()]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _bucket_exists(facts: Facts, answers: Answers) -> bool | None:
|
|
133
|
+
bucket = answers.get("state_bucket")
|
|
134
|
+
if not bucket:
|
|
135
|
+
return None
|
|
136
|
+
return _exists(_aws(facts, answers, "s3api", "head-bucket", "--bucket", str(bucket)),
|
|
137
|
+
("Not Found", "404", "NoSuchBucket"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _profile_exists(facts: Facts, answers: Answers) -> bool | None:
|
|
141
|
+
name = answers.get("instance_profile")
|
|
142
|
+
if not name:
|
|
143
|
+
return None
|
|
144
|
+
return _exists(_aws(facts, answers, "iam", "get-instance-profile", "--instance-profile-name", str(name)),
|
|
145
|
+
("NoSuchEntity",))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# -------------------------------------------------------------------- defaults
|
|
149
|
+
def _state(facts: Facts, key: str) -> str | None:
|
|
150
|
+
return real((default_state_backend(facts, "s3") or {}).get(key))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _state_prefix(facts: Facts, answers: Answers) -> str | None:
|
|
154
|
+
prefix = _state(facts, "key")
|
|
155
|
+
return prefix.strip("/") if prefix else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _forms_default(facts: Facts, answers: Answers) -> str:
|
|
159
|
+
return "both" if facts.owner_id and facts.repo_id else "plain"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _needs_ids(answers: Answers) -> bool:
|
|
163
|
+
return answers.get("subject_forms") in ("ids", "both")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _tags_default(facts: Facts, answers: Answers) -> str:
|
|
167
|
+
tags = _runtime(facts).get("tags") or {}
|
|
168
|
+
pairs = [f"{k}={v}" for k, v in sorted(tags.items()) if real(v)] if isinstance(tags, dict) else []
|
|
169
|
+
return ",".join(pairs)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _session_profile(facts: Facts, answers: Answers) -> str | None:
|
|
173
|
+
rt = _runtime(facts)
|
|
174
|
+
return real(rt.get("session_instance_profile")) or real(rt.get("iam_instance_profile"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_tags(text: str) -> dict[str, str]:
|
|
178
|
+
out: dict[str, str] = {}
|
|
179
|
+
for part in str(text or "").split(","):
|
|
180
|
+
if "=" in part:
|
|
181
|
+
k, v = part.split("=", 1)
|
|
182
|
+
if k.strip() and v.strip():
|
|
183
|
+
out[k.strip()] = v.strip()
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
QUESTIONS: tuple[Question, ...] = (
|
|
188
|
+
Question("account_id", "The AWS account id", default=lambda f, a: real(_runtime(f).get("account_id")),
|
|
189
|
+
help="the AWS runtime's `account_id` in cfg/runtime-builders.yml is unset or still the starter's example"),
|
|
190
|
+
Question("region", "The region of the AWS runtime", default=lambda f, a: real(_runtime(f).get("region")),
|
|
191
|
+
help="the AWS runtime's `region` in cfg/runtime-builders.yml"),
|
|
192
|
+
Question("profile", "The AWS profile you apply this root with (none to use the environment's credentials)",
|
|
193
|
+
default=lambda f, a: _profile_of(f, {}), may_be_empty=True),
|
|
194
|
+
Question("repository", "The GitHub repository the roles trust, as owner/name", default=lambda f, a: f.repository,
|
|
195
|
+
help="read from `git remote get-url origin` when the checkout has a GitHub remote"),
|
|
196
|
+
Question("production_branch", "The branch the WRITE role trusts (the perform job's)", default="main"),
|
|
197
|
+
Question("subject_forms", "Which OIDC subject forms the roles trust: the plain name form, the id-bearing form "
|
|
198
|
+
"(an organisation that customises the subject), or both", kind="choice",
|
|
199
|
+
choices=("plain", "ids", "both"), default=_forms_default),
|
|
200
|
+
Question("owner_id", "The repository owner's numeric id (`gh api users/<owner> --jq .id`)", when=_needs_ids,
|
|
201
|
+
default=lambda f, a: str(f.owner_id) if f.owner_id else None, help="`gh` is absent or not logged in"),
|
|
202
|
+
Question("repo_id", "The repository's numeric id (`gh api repos/<owner>/<repo> --jq .id`)", when=_needs_ids,
|
|
203
|
+
default=lambda f, a: str(f.repo_id) if f.repo_id else None, help="`gh` is absent or not logged in"),
|
|
204
|
+
Question("oidc_provider_exists", "Does the account already have the GitHub OIDC identity provider "
|
|
205
|
+
f"({OIDC_HOST})?", kind="bool", default=_oidc_exists,
|
|
206
|
+
help="the account could not be asked: no AWS session, or `aws` is not installed"),
|
|
207
|
+
Question("read_role_name", "The READ-ONLY role's name (AWS_ROLE_ARN)", default="csis-github-readonly"),
|
|
208
|
+
Question("read_role_exists", "Does the READ-ONLY role already exist (it is then adopted by import)?", kind="bool",
|
|
209
|
+
default=_role_exists("read_role_name"), help="the account could not be asked: no AWS session"),
|
|
210
|
+
Question("read_extra_subjects", "Other OIDC subjects the READ-ONLY role keeps trusting, comma-separated (an "
|
|
211
|
+
"existing role's subjects that are not this repository's)",
|
|
212
|
+
default=_extra_subjects("read"), help="the existing role's trust could not be read: no AWS session",
|
|
213
|
+
may_be_empty=True),
|
|
214
|
+
Question("write_role_name", "The WRITE role's name (AWS_APPLY_ROLE_ARN)", default="csis-github-apply"),
|
|
215
|
+
Question("write_role_exists", "Does the WRITE role already exist (it is then adopted by import)?", kind="bool",
|
|
216
|
+
default=_role_exists("write_role_name"), help="the account could not be asked: no AWS session"),
|
|
217
|
+
Question("write_extra_subjects", "Other OIDC subjects the WRITE role keeps trusting, comma-separated",
|
|
218
|
+
default=_extra_subjects("write"), help="the existing role's trust could not be read: no AWS session",
|
|
219
|
+
may_be_empty=True),
|
|
220
|
+
Question("state_bucket", "The state bucket's name", default=lambda f, a: _state(f, "bucket"),
|
|
221
|
+
help="the default S3 backend's `bucket` in cfg/state-backends.yml is unset or still the starter's placeholder"),
|
|
222
|
+
Question("state_prefix", "The state key prefix within the bucket", default=_state_prefix,
|
|
223
|
+
help="the default S3 backend's `key` in cfg/state-backends.yml"),
|
|
224
|
+
Question("state_region", "The state bucket's region",
|
|
225
|
+
default=lambda f, a: _state(f, "region") or a.get("region")),
|
|
226
|
+
Question("state_bucket_exists", "Does the state bucket already exist?", kind="bool", default=_bucket_exists,
|
|
227
|
+
help="the account could not be asked: no AWS session", observed=True),
|
|
228
|
+
# hygiene XII item 5 (the walk's F17): the second interview, run so the
|
|
229
|
+
# root binds to the bucket it made, answered "exists" and the module's
|
|
230
|
+
# four bucket resources went to count 0 while they sat in the state --
|
|
231
|
+
# the next plan destroyed versioning, encryption and the access block.
|
|
232
|
+
# Whether the bucket STANDS is observed each time (above); whether this
|
|
233
|
+
# root MANAGES it is a choice recorded once, and a bucket the root makes
|
|
234
|
+
# is managed from then on.
|
|
235
|
+
Question("state_bucket_managed", "Does this root MANAGE the state bucket: made by an apply of this root and kept "
|
|
236
|
+
"here, so a destroy of the root removes it? (a bucket that stood before this root is read, never "
|
|
237
|
+
"managed; one this root makes is managed from then on)", kind="bool",
|
|
238
|
+
default=lambda f, a: not a.get("state_bucket_exists")),
|
|
239
|
+
Question("instance_profile", "The instance profile build and standing instances wear for Session Manager",
|
|
240
|
+
default=_session_profile,
|
|
241
|
+
help="the AWS runtime's `session_instance_profile` in cfg/runtime-builders.yml is unset or a placeholder"),
|
|
242
|
+
Question("instance_profile_exists", "Does that instance profile already exist?", kind="bool",
|
|
243
|
+
default=_profile_exists, help="the account could not be asked: no AWS session"),
|
|
244
|
+
Question("tags", "Tags for everything this root makes, as k=v,k=v", default=_tags_default, may_be_empty=True),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def render(answers: Answers) -> Rendered:
|
|
249
|
+
profile = str(answers.get("profile") or "")
|
|
250
|
+
prefix = str(answers["state_prefix"]).strip("/")
|
|
251
|
+
tags = {**parse_tags(str(answers.get("tags") or "")), "ManagedBy": MANAGED_BY}
|
|
252
|
+
bucket_exists = bool(answers["state_bucket_exists"])
|
|
253
|
+
bucket_managed = bool(answers.get("state_bucket_managed")) or not bucket_exists # made here: managed, always
|
|
254
|
+
backend = None
|
|
255
|
+
if bucket_exists:
|
|
256
|
+
settings: dict[str, Any] = {"bucket": answers["state_bucket"], "key": f"{prefix}/bootstrap.tfstate",
|
|
257
|
+
"region": answers["state_region"], "encrypt": True, "use_lockfile": True}
|
|
258
|
+
if profile:
|
|
259
|
+
settings["profile"] = profile
|
|
260
|
+
backend = ("s3", settings)
|
|
261
|
+
imports = ""
|
|
262
|
+
for key, address in (("read", "read"), ("write", "write")):
|
|
263
|
+
if answers.get(f"{key}_role_exists"):
|
|
264
|
+
imports += (f"# the {key.upper()} role already stands: adopted, so its trust and policy are maintained from here\n"
|
|
265
|
+
f"import {{\n to = module.{MODULE}.aws_iam_role.{address}\n id = \"{answers[f'{key}_role_name']}\"\n}}\n\n")
|
|
266
|
+
names = ("account_id", "region", "repository", "production_branch", "subject_forms", "owner_id", "repo_id",
|
|
267
|
+
"oidc_provider_exists", "read_role_name", "write_role_name", "state_bucket", "state_prefix",
|
|
268
|
+
"state_bucket_exists", "state_bucket_managed", "instance_profile", "instance_profile_exists")
|
|
269
|
+
types = {"oidc_provider_exists": "bool", "state_bucket_exists": "bool", "state_bucket_managed": "bool",
|
|
270
|
+
"instance_profile_exists": "bool"}
|
|
271
|
+
described = {
|
|
272
|
+
"account_id": "the AWS account", "region": "the AWS runtime's region",
|
|
273
|
+
"repository": "the GitHub repository the roles trust, as owner/name",
|
|
274
|
+
"production_branch": "the branch the WRITE role trusts",
|
|
275
|
+
"subject_forms": "the OIDC subject forms trusted: plain, ids or both",
|
|
276
|
+
"owner_id": "the repository owner's numeric id (empty when the plain form alone is trusted)",
|
|
277
|
+
"repo_id": "the repository's numeric id (empty when the plain form alone is trusted)",
|
|
278
|
+
"oidc_provider_exists": "the account already has the GitHub OIDC provider (read, not made)",
|
|
279
|
+
"read_role_name": "the READ-ONLY role's name", "write_role_name": "the WRITE role's name",
|
|
280
|
+
"state_bucket": "the state bucket", "state_prefix": "the state key prefix",
|
|
281
|
+
"state_bucket_exists": "the state bucket already stands (observed at every interview; decides the backend)",
|
|
282
|
+
"state_bucket_managed": "this root manages the state bucket: made by an apply of it and kept here "
|
|
283
|
+
"(decides the bucket resources; a destroy of the root removes the bucket)",
|
|
284
|
+
"instance_profile": "the Session Manager instance profile",
|
|
285
|
+
"instance_profile_exists": "the instance profile already exists (read, not made)",
|
|
286
|
+
}
|
|
287
|
+
variables = tuple((f"aws_{n}", types.get(n, "string"), described[n]) for n in names) + (
|
|
288
|
+
("aws_profile", "string", "the AWS profile this root is applied with (empty: the environment's credentials)"),
|
|
289
|
+
("aws_tags", "map(string)", "tags on everything this root makes"),
|
|
290
|
+
("aws_read_extra_subjects", "list(string)", "other OIDC subjects the READ-ONLY role keeps trusting"),
|
|
291
|
+
("aws_write_extra_subjects", "list(string)", "other OIDC subjects the WRITE role keeps trusting"),
|
|
292
|
+
)
|
|
293
|
+
tfvars: dict[str, Any] = {f"aws_{n}": (bool(answers.get(n)) if n in types else str(answers.get(n) or ""))
|
|
294
|
+
for n in names}
|
|
295
|
+
tfvars["aws_state_bucket_managed"] = bucket_managed
|
|
296
|
+
tfvars["aws_state_prefix"] = prefix
|
|
297
|
+
tfvars["aws_profile"] = profile
|
|
298
|
+
tfvars["aws_tags"] = tags
|
|
299
|
+
extras = {k: parse_subjects(answers.get(f"{k}_extra_subjects")) for k in ("read", "write")}
|
|
300
|
+
for k in ("read", "write"):
|
|
301
|
+
tfvars[f"aws_{k}_extra_subjects"] = extras[k]
|
|
302
|
+
adopted = [answers[f"{k}_role_name"] for k in ("read", "write") if answers.get(f"{k}_role_exists")]
|
|
303
|
+
return Rendered(
|
|
304
|
+
module=MODULE,
|
|
305
|
+
destroy_takes=tuple(
|
|
306
|
+
f"the {k.upper()} role `{answers[f'{k}_role_name']}`, adopted by import: the destroy deletes the role "
|
|
307
|
+
"itself, with every subject it trusted" + (f" ({', '.join(f'`{s}`' for s in extras[k])} among them)"
|
|
308
|
+
if extras[k] else "")
|
|
309
|
+
for k in ("read", "write") if answers.get(f"{k}_role_exists")),
|
|
310
|
+
module_args={**{n: Raw(f"var.aws_{n}") for n in names},
|
|
311
|
+
"read_extra_subjects": Raw("var.aws_read_extra_subjects"),
|
|
312
|
+
"write_extra_subjects": Raw("var.aws_write_extra_subjects")},
|
|
313
|
+
variables=variables,
|
|
314
|
+
tfvars=tfvars,
|
|
315
|
+
outputs=(
|
|
316
|
+
("aws_read_role_arn", f"module.{MODULE}.read_role_arn", "the READ-ONLY role's ARN: the AWS_ROLE_ARN secret"),
|
|
317
|
+
("aws_write_role_arn", f"module.{MODULE}.write_role_arn", "the WRITE role's ARN: the AWS_APPLY_ROLE_ARN secret"),
|
|
318
|
+
("aws_oidc_provider_arn", f"module.{MODULE}.oidc_provider_arn", "the GitHub OIDC identity provider"),
|
|
319
|
+
("aws_state_bucket", f"module.{MODULE}.state_bucket", "the state bucket"),
|
|
320
|
+
("aws_instance_profile_arn", f"module.{MODULE}.instance_profile_arn", "the Session Manager instance profile"),
|
|
321
|
+
),
|
|
322
|
+
required_providers={"aws": {"source": "hashicorp/aws", "version": ">= 5.81"}},
|
|
323
|
+
provider_blocks=('provider "aws" {\n region = var.aws_region\n'
|
|
324
|
+
' profile = var.aws_profile != "" ? var.aws_profile : null\n'
|
|
325
|
+
' default_tags {\n tags = var.aws_tags\n }\n}\n'),
|
|
326
|
+
apply_env=(("AWS_PROFILE", profile, "the profile the interview probed the account as; its session must be "
|
|
327
|
+
"live (`aws sso login`), and it must be allowed to write IAM"),) if profile else (),
|
|
328
|
+
secrets=(
|
|
329
|
+
Secret("AWS_ROLE_ARN", "output", "aws_read_role_arn", "the READ-ONLY role's ARN, from the applied root"),
|
|
330
|
+
Secret("AWS_APPLY_ROLE_ARN", "output", "aws_write_role_arn", "the WRITE role's ARN, from the applied root"),
|
|
331
|
+
),
|
|
332
|
+
by_hand=(
|
|
333
|
+
"The network (VPC, subnets, security groups, the access gateway): the team's, never modified by the system.",
|
|
334
|
+
"If an adopted role carries an inline policy made by hand, it stays beside the managed one: once the plan "
|
|
335
|
+
"is clean and CI is green, remove the hand-made policy in the console so one document is the truth.",
|
|
336
|
+
"The first performing run (CI_SETUP.md 3.8), after the secrets are set.",
|
|
337
|
+
),
|
|
338
|
+
notes=(
|
|
339
|
+
f"account `{answers['account_id']}`, region `{answers['region']}`; the GitHub OIDC provider "
|
|
340
|
+
+ ("read (it exists)" if answers["oidc_provider_exists"] else "created"),
|
|
341
|
+
f"the READ-ONLY role `{answers['read_role_name']}` (any ref of `{answers['repository']}`) and the WRITE role "
|
|
342
|
+
f"`{answers['write_role_name']}` (`{answers['production_branch']}` alone), subject forms: {answers['subject_forms']}"
|
|
343
|
+
+ (f"; adopted by import: {', '.join(adopted)}" if adopted else ""),
|
|
344
|
+
*(f"the {k.upper()} role also keeps trusting: {', '.join(f'`{s}`' for s in extras[k])} (not this repository's; "
|
|
345
|
+
"kept because the role already trusted them)" for k in ("read", "write") if extras[k]),
|
|
346
|
+
f"the state bucket `{answers['state_bucket']}` "
|
|
347
|
+
+ ("exists and is MANAGED here (made by an earlier apply of this root; a destroy of the root removes it): "
|
|
348
|
+
"this root's state is bound to it (decision D8)" if bucket_exists and bucket_managed else
|
|
349
|
+
"exists: this root's state is bound to it (decision D8); the bucket stood before this root and is not managed here"
|
|
350
|
+
if bucket_exists else
|
|
351
|
+
"is CREATED here (versioned, encrypted, private) and managed from then on: local state for this first apply"),
|
|
352
|
+
f"the instance profile `{answers['instance_profile']}` "
|
|
353
|
+
+ ("read (it exists)" if answers["instance_profile_exists"] else "created with AmazonSSMManagedInstanceCore"),
|
|
354
|
+
),
|
|
355
|
+
backend=backend,
|
|
356
|
+
root_hcl=imports,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def wanted(facts: Facts) -> bool:
|
|
361
|
+
"""The tree uses AWS: it declares an AWS runtime or keeps its state in S3."""
|
|
362
|
+
return runtime_of_type(facts, "aws") is not None or default_state_backend(facts, "s3") is not None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def aws_section() -> Section:
|
|
366
|
+
return Section(name="aws", title="AWS", questions=QUESTIONS, render=render, wanted=wanted,
|
|
367
|
+
description="the GitHub OIDC provider, the read-only and write roles, the optional state bucket and instance profile")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mykel Alvis <mykelalvis@infrastructurebuilder.org>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from cs_image_system.base.basic.abstract_plugin_metadata import AbstractPluginMetadata
|
|
8
|
+
from cs_image_system.base.protocols.plugin_metadata import PluginMetadataProtocol
|
|
9
|
+
from .aws_runtime_builders import AWS_RUNTIME, AwsCLIVersionChecker, AwsCloudBuilder
|
|
10
|
+
from .aws_runtime_models import AwsCloudBuilderModel
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
class AwsRuntimePluginMetadata(AbstractPluginMetadata,PluginMetadataProtocol):
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
super().__init__( "1", "3.13",
|
|
17
|
+
{AWS_RUNTIME: [
|
|
18
|
+
AwsCloudBuilderModel,
|
|
19
|
+
AwsCloudBuilder,
|
|
20
|
+
AwsCLIVersionChecker, # stage 48.1: registered under its own name, aws-cli
|
|
21
|
+
]},
|
|
22
|
+
{AwsCloudBuilderModel: AwsCloudBuilder}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def initialize():
|
|
27
|
+
log.debug("Initializing AWS Runtime Plugin")
|
|
28
|
+
return AwsRuntimePluginMetadata()
|
cs_image_system/py.typed
ADDED
|
File without changes
|