fogies 0.0.0.dev6__tar.gz → 0.0.0.dev7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/PKG-INFO +1 -1
  2. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/pyproject.toml +1 -1
  3. fogies-0.0.0.dev7/src/fogies/pyfogies.py +37 -0
  4. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tasks/access_key.py +279 -286
  5. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tasks/terraform.py +46 -21
  6. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tasks/terraform_backend.py +33 -12
  7. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/terraform/backend.py +222 -222
  8. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tools/aws_environ.py +11 -4
  9. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tools/terraform.py +77 -7
  10. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/README.md +0 -0
  11. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/__init__.py +0 -0
  12. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/aws_access_key.py +0 -0
  13. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/includes/templates/aws.toml.template +0 -0
  14. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/includes/templates/backend_status.toml.template +0 -0
  15. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/includes/templates/poetry.toml.template +0 -0
  16. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/py.typed +0 -0
  17. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/ready_poll.py +0 -0
  18. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/retry.py +0 -0
  19. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tasks/__init__.py +0 -0
  20. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tasks/format.py +0 -0
  21. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tasks/lint.py +0 -0
  22. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tasks/poetry.py +0 -0
  23. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tasks/test.py +0 -0
  24. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/templates.py +0 -0
  25. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/terraform/__init__.py +0 -0
  26. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/terraform/access_key_permissions.py +0 -0
  27. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/terraform/alb.py +0 -0
  28. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/terraform/cloudwatch.py +0 -0
  29. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/terraform/ecr.py +0 -0
  30. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/terraform/ecs.py +0 -0
  31. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/terraform/hosted_zone.py +0 -0
  32. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/terraform/network.py +0 -0
  33. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tools/__init__.py +0 -0
  34. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tools/boto.py +0 -0
  35. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tools/command.py +0 -0
  36. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tools/environ.py +0 -0
  37. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tools/ollama.py +0 -0
  38. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/tools/terraform_backend.py +0 -0
  39. {fogies-0.0.0.dev6 → fogies-0.0.0.dev7}/src/fogies/typing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fogies
3
- Version: 0.0.0.dev6
3
+ Version: 0.0.0.dev7
4
4
  Summary:
5
5
  Author: James Fogarty
6
6
  Author-email: jayfo@users.noreply.github.com
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "fogies"
3
3
  # Version follows semantic versioning <https://semver.org/>.
4
- version = "0.0.0-dev.6"
4
+ version = "0.0.0-dev.7"
5
5
  description = ""
6
6
  authors = [
7
7
  {name = "James Fogarty",email = "jayfo@users.noreply.github.com"}
@@ -0,0 +1,37 @@
1
+ """Facts about the installed pyfogies package itself."""
2
+
3
+ import importlib.metadata
4
+ import re
5
+
6
+ # importlib.metadata reports the PEP 440 canonical form, which is how Poetry
7
+ # normalizes the SemVer-hyphenated version string written in pyproject.toml
8
+ # and CHANGELOG.md (e.g. "0.0.0-dev.6" becomes "0.0.0.dev6"). These are the
9
+ # only two forms this project's own versions ever take.
10
+ _FINAL_RELEASE_RE = re.compile(r"^\d+\.\d+\.\d+$")
11
+ _DEV_RELEASE_RE = re.compile(r"^(?P<base>\d+\.\d+\.\d+)\.dev(?P<dev>\d+)$")
12
+
13
+
14
+ def pyfogies_version() -> str:
15
+ """The installed pyfogies version, in this project's own format (e.g. "0.0.0-dev.6").
16
+
17
+ Converts the PEP 440 form importlib.metadata reports back to the
18
+ SemVer-hyphenated form used everywhere else in the project. Raises if
19
+ the installed version doesn't match either expected shape, rather than
20
+ guessing at an unfamiliar one.
21
+ """
22
+ installed_version = importlib.metadata.version("fogies")
23
+
24
+ if _FINAL_RELEASE_RE.match(installed_version):
25
+ return installed_version
26
+
27
+ dev_release_match = _DEV_RELEASE_RE.match(installed_version)
28
+ if dev_release_match:
29
+ return "{}-dev.{}".format(
30
+ dev_release_match.group("base"), dev_release_match.group("dev")
31
+ )
32
+
33
+ raise ValueError(
34
+ "Unrecognized pyfogies version '{}'; expected 'X.Y.Z' or 'X.Y.Z.devN'".format(
35
+ installed_version
36
+ )
37
+ )
@@ -1,286 +1,279 @@
1
- """Tasks for managing IAM users and access keys."""
2
-
3
- from __future__ import annotations
4
-
5
- import contextlib
6
- import getpass
7
- from collections.abc import Callable, Generator
8
- from typing import cast
9
-
10
- from invoke.collection import Collection
11
- from invoke.context import Context
12
- from invoke.tasks import Task, task
13
-
14
- import fogies.aws_access_key as aws_access_key
15
- from fogies.tools.aws_environ import (
16
- AwsEnvironContextManager,
17
- AwsProfile,
18
- aws_environ_from_profile,
19
- )
20
- from fogies.typing import boto_client_sts
21
-
22
- # A factory rather than a pre-built context manager: get_collection() shares
23
- # this across five tasks, and a context manager built by @contextlib.
24
- # contextmanager can only be entered once, so each task must build its own
25
- # fresh instance at run time. Mirrors the _aws_environ_factory() pattern used
26
- # by consumers of these tasks (see e.g. fogies-infrastructure's tasks/__init__.py).
27
- AwsEnvironFactory = Callable[[], AwsEnvironContextManager]
28
-
29
-
30
- def _prompt_admin_credentials() -> tuple[str, str]:
31
- """Prompt for admin credentials interactively; credentials are never written anywhere."""
32
- access_key_id = input("Admin Access Key ID: ").strip()
33
- secret_access_key = getpass.getpass("Admin Secret Access Key: ").strip()
34
- return access_key_id, secret_access_key
35
-
36
-
37
- @contextlib.contextmanager
38
- def _resolve_admin_environ(
39
- *, aws_environ_factory: AwsEnvironFactory | None, prompt: bool
40
- ) -> Generator[str]:
41
- """Enter the configured AWS environment, or prompt for admin credentials.
42
-
43
- Prompts if aws_environ_factory is None, or if prompt is True (e.g. to use
44
- different/elevated credentials for a single run). Prompted credentials
45
- are never written anywhere. Yields the active access key ID.
46
- """
47
- if aws_environ_factory is not None and not prompt:
48
- with aws_environ_factory() as env:
49
- yield env.aws_access_key_id
50
- return
51
-
52
- access_key_id, secret_access_key = _prompt_admin_credentials()
53
- profile = AwsProfile(
54
- name="prompt-admin",
55
- aws_access_key_id=access_key_id,
56
- aws_secret_access_key=secret_access_key,
57
- )
58
- # try/except wraps only entering the context manager (where validation
59
- # happens), not the yield below — catching ValueError around the yield
60
- # too would risk mis-catching an unrelated error raised by the task body.
61
- with contextlib.ExitStack() as stack:
62
- try:
63
- env = stack.enter_context(
64
- aws_environ_from_profile(profile=profile, raise_if_exists=False)
65
- )
66
- except ValueError as exc:
67
- raise SystemExit(str(exc)) from exc
68
- yield env.aws_access_key_id
69
-
70
-
71
- def _caller_username() -> str:
72
- """Return the IAM username that owns the active credentials, via STS."""
73
- arn = boto_client_sts().get_caller_identity()["Arn"]
74
- # ARN format for IAM users: arn:aws:iam::123456789012:user/username
75
- return arn.split("/")[-1]
76
-
77
-
78
- def _print_toml_block(*, profile: AwsProfile) -> None:
79
- """Print a TOML block suitable for pasting into aws.toml."""
80
- print()
81
- print("[{}]".format(profile.name))
82
- print('aws_access_key_id = "{}"'.format(profile.aws_access_key_id))
83
- print('aws_secret_access_key = "{}"'.format(profile.aws_secret_access_key))
84
-
85
-
86
- def _print_user_key_overview(
87
- *, username: str, keys: aws_access_key.IamAccessKeys
88
- ) -> None:
89
- """Print an IAM user's key overview: current/previous key details, or a no-keys note.
90
-
91
- Shared by `list` (once per user) and `delete-key`'s confirmation prompt
92
- (for the one user being acted on), so both show the same information.
93
- """
94
- print(username)
95
- for label, key in (("current", keys.current), ("previous", keys.previous)):
96
- if key is None:
97
- continue
98
- created_str = key.created.strftime("%Y-%m-%d") if key.created else "unknown"
99
- last_used_str = key.last_used.strftime("%Y-%m-%d") if key.last_used else "never"
100
- print(
101
- " {} {} | {} | created {} | last used {}".format(
102
- label, key.key_id, key.status, created_str, last_used_str
103
- )
104
- )
105
- if keys.current is None:
106
- print(" (no access keys)")
107
-
108
-
109
- def get_task_create(
110
- *, aws_environ_factory: AwsEnvironFactory | None = None
111
- ) -> Task[Callable[..., None]]:
112
- @task(name="create") # pyright: ignore[reportUntypedFunctionDecorator]
113
- def task_create(context: Context, *, prompt: bool = False, username: str) -> None:
114
- """
115
- Create an IAM user and an associated access key.
116
-
117
- Flags:
118
- --prompt Prompt for admin credentials.
119
- --username IAM username.
120
- """
121
- _ = context
122
- with _resolve_admin_environ(
123
- aws_environ_factory=aws_environ_factory, prompt=prompt
124
- ):
125
- new_profile = aws_access_key.create_user(username=username)
126
- print("Created IAM user '{}'.".format(username))
127
- print("Created access key {}.".format(new_profile.aws_access_key_id))
128
- _print_toml_block(profile=new_profile)
129
-
130
- return cast(Task[Callable[..., None]], task_create)
131
-
132
-
133
- def get_task_delete(
134
- *, aws_environ_factory: AwsEnvironFactory | None = None
135
- ) -> Task[Callable[..., None]]:
136
- @task(name="delete") # pyright: ignore[reportUntypedFunctionDecorator]
137
- def task_delete(context: Context, *, prompt: bool = False, username: str) -> None:
138
- """
139
- Delete an IAM user with no remaining access keys.
140
-
141
- Flags:
142
- --prompt Prompt for admin credentials.
143
- --username IAM username.
144
- """
145
- _ = context
146
- with _resolve_admin_environ(
147
- aws_environ_factory=aws_environ_factory, prompt=prompt
148
- ):
149
- protected_username = _caller_username()
150
- confirm = (
151
- input("Delete IAM user '{}'? [y/N] ".format(username)).strip().lower()
152
- )
153
- if confirm != "y":
154
- print("Aborted.")
155
- return
156
- aws_access_key.delete_user(
157
- username=username,
158
- protected_usernames={protected_username},
159
- )
160
- print("Deleted IAM user '{}'.".format(username))
161
-
162
- return cast(Task[Callable[..., None]], task_delete)
163
-
164
-
165
- def get_task_delete_key(
166
- *, aws_environ_factory: AwsEnvironFactory | None = None
167
- ) -> Task[Callable[..., None]]:
168
- @task(name="delete-key") # pyright: ignore[reportUntypedFunctionDecorator]
169
- def task_delete_key(
170
- context: Context, *, prompt: bool = False, username: str
171
- ) -> None:
172
- """
173
- Delete the oldest access key for an IAM user.
174
-
175
- Flags:
176
- --prompt Prompt for admin credentials.
177
- --username IAM username.
178
- """
179
- _ = context
180
- with _resolve_admin_environ(
181
- aws_environ_factory=aws_environ_factory, prompt=prompt
182
- ) as admin_key_id:
183
- keys = aws_access_key.get_keys(username=username)
184
- target = keys.previous or keys.current
185
- if target is None:
186
- raise SystemExit("IAM user '{}' has no access keys.".format(username))
187
- key_id = target.key_id
188
- _print_user_key_overview(username=username, keys=keys)
189
- print()
190
- if keys.previous is None:
191
- print(
192
- "WARNING: This is the only key. Deleting it will revoke all access."
193
- )
194
- print()
195
- confirm = input("Delete key {}? [y/N] ".format(key_id)).strip().lower()
196
- if confirm != "y":
197
- print("Aborted.")
198
- return
199
- aws_access_key.delete_key(
200
- username=username,
201
- key_id=key_id,
202
- protected_key_ids={admin_key_id},
203
- )
204
- print("Deleted key {}.".format(key_id))
205
-
206
- return cast(Task[Callable[..., None]], task_delete_key)
207
-
208
-
209
- def get_task_list(
210
- *, aws_environ_factory: AwsEnvironFactory | None = None
211
- ) -> Task[Callable[..., None]]:
212
- @task(name="list") # pyright: ignore[reportUntypedFunctionDecorator]
213
- def task_list(context: Context, *, prompt: bool = False) -> None:
214
- """
215
- List all IAM users and their access keys.
216
-
217
- Flags:
218
- --prompt Prompt for admin credentials.
219
- """
220
- _ = context
221
- with _resolve_admin_environ(
222
- aws_environ_factory=aws_environ_factory, prompt=prompt
223
- ):
224
- users = aws_access_key.list_users()
225
- if not users:
226
- print("No IAM users found.")
227
- return
228
- for index, user in enumerate(users):
229
- if index > 0:
230
- print()
231
- _print_user_key_overview(username=user.username, keys=user.keys)
232
-
233
- return cast(Task[Callable[..., None]], task_list)
234
-
235
-
236
- def get_task_rotate_key(
237
- *, aws_environ_factory: AwsEnvironFactory | None = None
238
- ) -> Task[Callable[..., None]]:
239
- @task(name="rotate-key") # pyright: ignore[reportUntypedFunctionDecorator]
240
- def task_rotate_key(
241
- context: Context, *, prompt: bool = False, username: str
242
- ) -> None:
243
- """
244
- Add a new access key to an existing IAM user.
245
-
246
- Flags:
247
- --prompt Prompt for admin credentials instead of using the configured AWS environment.
248
- --username IAM username to rotate a key for.
249
- """
250
- _ = context
251
- with _resolve_admin_environ(
252
- aws_environ_factory=aws_environ_factory, prompt=prompt
253
- ) as admin_key_id:
254
- keys = aws_access_key.get_keys(username=username)
255
- if keys.previous is not None:
256
- _print_user_key_overview(username=username, keys=keys)
257
- print()
258
- confirm = (
259
- input("Delete key {}? [y/N] ".format(keys.previous.key_id))
260
- .strip()
261
- .lower()
262
- )
263
- if confirm != "y":
264
- print("Aborted.")
265
- return
266
- new_profile = aws_access_key.rotate_key(
267
- username=username,
268
- protected_key_ids={admin_key_id},
269
- )
270
- print("Created access key {}.".format(new_profile.aws_access_key_id))
271
- _print_toml_block(profile=new_profile)
272
-
273
- return cast(Task[Callable[..., None]], task_rotate_key)
274
-
275
-
276
- def get_collection(
277
- *, aws_environ_factory: AwsEnvironFactory | None = None
278
- ) -> Collection:
279
- """Get a collection of tasks for managing IAM users and access keys."""
280
- collection = Collection("access-key")
281
- collection.add_task(get_task_create(aws_environ_factory=aws_environ_factory))
282
- collection.add_task(get_task_delete(aws_environ_factory=aws_environ_factory))
283
- collection.add_task(get_task_delete_key(aws_environ_factory=aws_environ_factory))
284
- collection.add_task(get_task_list(aws_environ_factory=aws_environ_factory))
285
- collection.add_task(get_task_rotate_key(aws_environ_factory=aws_environ_factory))
286
- return collection
1
+ """Tasks for managing IAM users and access keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import getpass
7
+ from collections.abc import Callable, Generator
8
+ from typing import cast
9
+
10
+ from invoke.collection import Collection
11
+ from invoke.context import Context
12
+ from invoke.tasks import Task, task
13
+
14
+ import fogies.aws_access_key as aws_access_key
15
+ from fogies.tools.aws_environ import (
16
+ AwsEnvironFactory,
17
+ AwsProfile,
18
+ aws_environ_from_profile,
19
+ )
20
+ from fogies.typing import boto_client_sts
21
+
22
+
23
+ def _prompt_admin_credentials() -> tuple[str, str]:
24
+ """Prompt for admin credentials interactively; credentials are never written anywhere."""
25
+ access_key_id = input("Admin Access Key ID: ").strip()
26
+ secret_access_key = getpass.getpass("Admin Secret Access Key: ").strip()
27
+ return access_key_id, secret_access_key
28
+
29
+
30
+ @contextlib.contextmanager
31
+ def _resolve_admin_environ(
32
+ *, aws_environ_factory: AwsEnvironFactory | None, prompt: bool
33
+ ) -> Generator[str]:
34
+ """Enter the configured AWS environment, or prompt for admin credentials.
35
+
36
+ Prompts if aws_environ_factory is None, or if prompt is True (e.g. to use
37
+ different/elevated credentials for a single run). Prompted credentials
38
+ are never written anywhere. Yields the active access key ID.
39
+ """
40
+ if aws_environ_factory is not None and not prompt:
41
+ with aws_environ_factory() as env:
42
+ yield env.aws_access_key_id
43
+ return
44
+
45
+ access_key_id, secret_access_key = _prompt_admin_credentials()
46
+ profile = AwsProfile(
47
+ name="prompt-admin",
48
+ aws_access_key_id=access_key_id,
49
+ aws_secret_access_key=secret_access_key,
50
+ )
51
+ # try/except wraps only entering the context manager (where validation
52
+ # happens), not the yield below — catching ValueError around the yield
53
+ # too would risk mis-catching an unrelated error raised by the task body.
54
+ with contextlib.ExitStack() as stack:
55
+ try:
56
+ env = stack.enter_context(
57
+ aws_environ_from_profile(profile=profile, raise_if_exists=False)
58
+ )
59
+ except ValueError as exc:
60
+ raise SystemExit(str(exc)) from exc
61
+ yield env.aws_access_key_id
62
+
63
+
64
+ def _caller_username() -> str:
65
+ """Return the IAM username that owns the active credentials, via STS."""
66
+ arn = boto_client_sts().get_caller_identity()["Arn"]
67
+ # ARN format for IAM users: arn:aws:iam::123456789012:user/username
68
+ return arn.split("/")[-1]
69
+
70
+
71
+ def _print_toml_block(*, profile: AwsProfile) -> None:
72
+ """Print a TOML block suitable for pasting into aws.toml."""
73
+ print()
74
+ print("[{}]".format(profile.name))
75
+ print('aws_access_key_id = "{}"'.format(profile.aws_access_key_id))
76
+ print('aws_secret_access_key = "{}"'.format(profile.aws_secret_access_key))
77
+
78
+
79
+ def _print_user_key_overview(
80
+ *, username: str, keys: aws_access_key.IamAccessKeys
81
+ ) -> None:
82
+ """Print an IAM user's key overview: current/previous key details, or a no-keys note.
83
+
84
+ Shared by `list` (once per user) and `delete-key`'s confirmation prompt
85
+ (for the one user being acted on), so both show the same information.
86
+ """
87
+ print(username)
88
+ for label, key in (("current", keys.current), ("previous", keys.previous)):
89
+ if key is None:
90
+ continue
91
+ created_str = key.created.strftime("%Y-%m-%d") if key.created else "unknown"
92
+ last_used_str = key.last_used.strftime("%Y-%m-%d") if key.last_used else "never"
93
+ print(
94
+ " {} {} | {} | created {} | last used {}".format(
95
+ label, key.key_id, key.status, created_str, last_used_str
96
+ )
97
+ )
98
+ if keys.current is None:
99
+ print(" (no access keys)")
100
+
101
+
102
+ def get_task_create(
103
+ *, aws_environ_factory: AwsEnvironFactory | None = None
104
+ ) -> Task[Callable[..., None]]:
105
+ @task(name="create") # pyright: ignore[reportUntypedFunctionDecorator]
106
+ def task_create(context: Context, *, prompt: bool = False, username: str) -> None:
107
+ """
108
+ Create an IAM user and an associated access key.
109
+
110
+ Flags:
111
+ --prompt Prompt for admin credentials.
112
+ --username IAM username.
113
+ """
114
+ _ = context
115
+ with _resolve_admin_environ(
116
+ aws_environ_factory=aws_environ_factory, prompt=prompt
117
+ ):
118
+ new_profile = aws_access_key.create_user(username=username)
119
+ print("Created IAM user '{}'.".format(username))
120
+ print("Created access key {}.".format(new_profile.aws_access_key_id))
121
+ _print_toml_block(profile=new_profile)
122
+
123
+ return cast(Task[Callable[..., None]], task_create)
124
+
125
+
126
+ def get_task_delete(
127
+ *, aws_environ_factory: AwsEnvironFactory | None = None
128
+ ) -> Task[Callable[..., None]]:
129
+ @task(name="delete") # pyright: ignore[reportUntypedFunctionDecorator]
130
+ def task_delete(context: Context, *, prompt: bool = False, username: str) -> None:
131
+ """
132
+ Delete an IAM user with no remaining access keys.
133
+
134
+ Flags:
135
+ --prompt Prompt for admin credentials.
136
+ --username IAM username.
137
+ """
138
+ _ = context
139
+ with _resolve_admin_environ(
140
+ aws_environ_factory=aws_environ_factory, prompt=prompt
141
+ ):
142
+ protected_username = _caller_username()
143
+ confirm = (
144
+ input("Delete IAM user '{}'? [y/N] ".format(username)).strip().lower()
145
+ )
146
+ if confirm != "y":
147
+ print("Aborted.")
148
+ return
149
+ aws_access_key.delete_user(
150
+ username=username,
151
+ protected_usernames={protected_username},
152
+ )
153
+ print("Deleted IAM user '{}'.".format(username))
154
+
155
+ return cast(Task[Callable[..., None]], task_delete)
156
+
157
+
158
+ def get_task_delete_key(
159
+ *, aws_environ_factory: AwsEnvironFactory | None = None
160
+ ) -> Task[Callable[..., None]]:
161
+ @task(name="delete-key") # pyright: ignore[reportUntypedFunctionDecorator]
162
+ def task_delete_key(
163
+ context: Context, *, prompt: bool = False, username: str
164
+ ) -> None:
165
+ """
166
+ Delete the oldest access key for an IAM user.
167
+
168
+ Flags:
169
+ --prompt Prompt for admin credentials.
170
+ --username IAM username.
171
+ """
172
+ _ = context
173
+ with _resolve_admin_environ(
174
+ aws_environ_factory=aws_environ_factory, prompt=prompt
175
+ ) as admin_key_id:
176
+ keys = aws_access_key.get_keys(username=username)
177
+ target = keys.previous or keys.current
178
+ if target is None:
179
+ raise SystemExit("IAM user '{}' has no access keys.".format(username))
180
+ key_id = target.key_id
181
+ _print_user_key_overview(username=username, keys=keys)
182
+ print()
183
+ if keys.previous is None:
184
+ print(
185
+ "WARNING: This is the only key. Deleting it will revoke all access."
186
+ )
187
+ print()
188
+ confirm = input("Delete key {}? [y/N] ".format(key_id)).strip().lower()
189
+ if confirm != "y":
190
+ print("Aborted.")
191
+ return
192
+ aws_access_key.delete_key(
193
+ username=username,
194
+ key_id=key_id,
195
+ protected_key_ids={admin_key_id},
196
+ )
197
+ print("Deleted key {}.".format(key_id))
198
+
199
+ return cast(Task[Callable[..., None]], task_delete_key)
200
+
201
+
202
+ def get_task_list(
203
+ *, aws_environ_factory: AwsEnvironFactory | None = None
204
+ ) -> Task[Callable[..., None]]:
205
+ @task(name="list") # pyright: ignore[reportUntypedFunctionDecorator]
206
+ def task_list(context: Context, *, prompt: bool = False) -> None:
207
+ """
208
+ List all IAM users and their access keys.
209
+
210
+ Flags:
211
+ --prompt Prompt for admin credentials.
212
+ """
213
+ _ = context
214
+ with _resolve_admin_environ(
215
+ aws_environ_factory=aws_environ_factory, prompt=prompt
216
+ ):
217
+ users = aws_access_key.list_users()
218
+ if not users:
219
+ print("No IAM users found.")
220
+ return
221
+ for index, user in enumerate(users):
222
+ if index > 0:
223
+ print()
224
+ _print_user_key_overview(username=user.username, keys=user.keys)
225
+
226
+ return cast(Task[Callable[..., None]], task_list)
227
+
228
+
229
+ def get_task_rotate_key(
230
+ *, aws_environ_factory: AwsEnvironFactory | None = None
231
+ ) -> Task[Callable[..., None]]:
232
+ @task(name="rotate-key") # pyright: ignore[reportUntypedFunctionDecorator]
233
+ def task_rotate_key(
234
+ context: Context, *, prompt: bool = False, username: str
235
+ ) -> None:
236
+ """
237
+ Add a new access key to an existing IAM user.
238
+
239
+ Flags:
240
+ --prompt Prompt for admin credentials instead of using the configured AWS environment.
241
+ --username IAM username to rotate a key for.
242
+ """
243
+ _ = context
244
+ with _resolve_admin_environ(
245
+ aws_environ_factory=aws_environ_factory, prompt=prompt
246
+ ) as admin_key_id:
247
+ keys = aws_access_key.get_keys(username=username)
248
+ if keys.previous is not None:
249
+ _print_user_key_overview(username=username, keys=keys)
250
+ print()
251
+ confirm = (
252
+ input("Delete key {}? [y/N] ".format(keys.previous.key_id))
253
+ .strip()
254
+ .lower()
255
+ )
256
+ if confirm != "y":
257
+ print("Aborted.")
258
+ return
259
+ new_profile = aws_access_key.rotate_key(
260
+ username=username,
261
+ protected_key_ids={admin_key_id},
262
+ )
263
+ print("Created access key {}.".format(new_profile.aws_access_key_id))
264
+ _print_toml_block(profile=new_profile)
265
+
266
+ return cast(Task[Callable[..., None]], task_rotate_key)
267
+
268
+
269
+ def get_collection(
270
+ *, aws_environ_factory: AwsEnvironFactory | None = None
271
+ ) -> Collection:
272
+ """Get a collection of tasks for managing IAM users and access keys."""
273
+ collection = Collection("access-key")
274
+ collection.add_task(get_task_create(aws_environ_factory=aws_environ_factory))
275
+ collection.add_task(get_task_delete(aws_environ_factory=aws_environ_factory))
276
+ collection.add_task(get_task_delete_key(aws_environ_factory=aws_environ_factory))
277
+ collection.add_task(get_task_list(aws_environ_factory=aws_environ_factory))
278
+ collection.add_task(get_task_rotate_key(aws_environ_factory=aws_environ_factory))
279
+ return collection