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.
@@ -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()
File without changes