fogies 0.0.0.dev4__tar.gz → 0.0.0.dev6__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 (43) hide show
  1. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/PKG-INFO +6 -2
  2. fogies-0.0.0.dev6/pyproject.toml +63 -0
  3. fogies-0.0.0.dev6/src/fogies/aws_access_key.py +148 -0
  4. fogies-0.0.0.dev6/src/fogies/includes/templates/aws.toml.template +6 -0
  5. fogies-0.0.0.dev6/src/fogies/includes/templates/backend_status.toml.template +5 -0
  6. fogies-0.0.0.dev6/src/fogies/includes/templates/poetry.toml.template +5 -0
  7. fogies-0.0.0.dev6/src/fogies/ready_poll.py +63 -0
  8. fogies-0.0.0.dev6/src/fogies/retry.py +48 -0
  9. fogies-0.0.0.dev6/src/fogies/tasks/access_key.py +286 -0
  10. fogies-0.0.0.dev6/src/fogies/tasks/format.py +65 -0
  11. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tasks/lint.py +27 -27
  12. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tasks/poetry.py +77 -71
  13. fogies-0.0.0.dev6/src/fogies/tasks/terraform.py +173 -0
  14. fogies-0.0.0.dev6/src/fogies/tasks/terraform_backend.py +180 -0
  15. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tasks/test.py +41 -41
  16. fogies-0.0.0.dev6/src/fogies/templates.py +60 -0
  17. fogies-0.0.0.dev6/src/fogies/terraform/access_key_permissions.py +31 -0
  18. fogies-0.0.0.dev6/src/fogies/terraform/alb.py +12 -0
  19. fogies-0.0.0.dev6/src/fogies/terraform/backend.py +222 -0
  20. fogies-0.0.0.dev6/src/fogies/terraform/cloudwatch.py +8 -0
  21. fogies-0.0.0.dev6/src/fogies/terraform/ecr.py +14 -0
  22. fogies-0.0.0.dev6/src/fogies/terraform/ecs.py +10 -0
  23. fogies-0.0.0.dev6/src/fogies/terraform/hosted_zone.py +9 -0
  24. fogies-0.0.0.dev6/src/fogies/terraform/network.py +9 -0
  25. fogies-0.0.0.dev6/src/fogies/tools/aws_environ.py +164 -0
  26. fogies-0.0.0.dev6/src/fogies/tools/boto.py +71 -0
  27. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tools/command.py +109 -78
  28. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tools/environ.py +108 -108
  29. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tools/ollama.py +352 -351
  30. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tools/terraform.py +503 -445
  31. fogies-0.0.0.dev6/src/fogies/tools/terraform_backend.py +99 -0
  32. fogies-0.0.0.dev6/src/fogies/typing.py +63 -0
  33. fogies-0.0.0.dev4/fogies_paths.py +0 -21
  34. fogies-0.0.0.dev4/pyproject.toml +0 -50
  35. fogies-0.0.0.dev4/src/fogies/tasks/format.py +0 -37
  36. fogies-0.0.0.dev4/src/fogies/terraform/backend.py +0 -17
  37. fogies-0.0.0.dev4/src/fogies/tools/aws_environ.py +0 -80
  38. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/README.md +0 -0
  39. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/__init__.py +0 -0
  40. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/py.typed +0 -0
  41. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tasks/__init__.py +0 -0
  42. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/terraform/__init__.py +0 -0
  43. {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tools/__init__.py +0 -0
@@ -1,16 +1,20 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fogies
3
- Version: 0.0.0.dev4
3
+ Version: 0.0.0.dev6
4
4
  Summary:
5
5
  Author: James Fogarty
6
6
  Author-email: jayfo@users.noreply.github.com
7
7
  Requires-Python: >=3.14,<4.0
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.14
10
+ Requires-Dist: boto3 (>=1.42.68,<2.0.0)
10
11
  Requires-Dist: filelock (>=3.25.2,<4.0.0)
11
- Requires-Dist: ollama (>=0.6.1,<0.7.0)
12
+ Requires-Dist: ollama (>=0.6.2,<0.7.0)
12
13
  Requires-Dist: psutil (>=7.2.2,<8.0.0)
13
14
  Requires-Dist: pydantic (>=2.12.5,<3.0.0)
15
+ Requires-Dist: requests (>=2.33.0,<2.34.0)
16
+ Requires-Dist: tenacity (>=9.1.4,<10.0.0)
17
+ Requires-Dist: tomlkit (>=0.15.1,<0.16.0)
14
18
  Description-Content-Type: text/markdown
15
19
 
16
20
  # pyfogies
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "fogies"
3
+ # Version follows semantic versioning <https://semver.org/>.
4
+ version = "0.0.0-dev.6"
5
+ description = ""
6
+ authors = [
7
+ {name = "James Fogarty",email = "jayfo@users.noreply.github.com"}
8
+ ]
9
+ readme = "README.md"
10
+ requires-python = ">=3.14,<4.0"
11
+
12
+ [tool.poetry]
13
+ packages = [
14
+ {include = "fogies", from = "src"},
15
+ ]
16
+ include = [
17
+ {path = "src/fogies/py.typed", format = ["sdist", "wheel"]},
18
+ {path = "src/fogies/includes/templates/aws.toml.template", format = ["sdist", "wheel"]},
19
+ {path = "src/fogies/includes/templates/poetry.toml.template", format = ["sdist", "wheel"]},
20
+ {path = "src/fogies/includes/templates/backend_status.toml.template", format = ["sdist", "wheel"]},
21
+ ]
22
+
23
+ [tool.poetry.dependencies]
24
+ # ^ allows all minor and patch releases.
25
+ # Example: ^1.2.3 will match 1.x.x versions >=1.2.3 and <2.0.0.
26
+ # ~ allows only patch releases.
27
+ # Example: ~1.2.3 will match versions >=1.2.3 and <1.3.0.
28
+ boto3 = "^1.42.68"
29
+ filelock = "^3.25.2"
30
+ ollama = "^0.6.2"
31
+ psutil = "^7.2.2"
32
+ pydantic = "^2.12.5"
33
+ requests = "~2.33.0"
34
+ tenacity = "^9.1.4"
35
+ tomlkit = "^0.15.1"
36
+
37
+ [tool.poetry.group.dev.dependencies]
38
+ # ^ allows all minor and patch releases.
39
+ # Example: ^1.2.3 will match 1.x.x versions >=1.2.3 and <2.0.0.
40
+ # ~ allows only patch releases.
41
+ # Example: ~1.2.3 will match versions >=1.2.3 and <1.3.0.
42
+ basedpyright = "^1.36.1"
43
+ black = "~26.5.1" # Black does not semver.
44
+ boto3-stubs = {extras = ["logs", "s3"], version = "^1.43.70"}
45
+ invoke = "^2.2.1"
46
+ isort = "^8.0.1"
47
+ poetry = "^2.2.1"
48
+ pytest = "^9.0.2"
49
+ semver = "^3.0.4"
50
+ types-requests = "~2.33.0.20260712"
51
+
52
+ [tool.basedpyright]
53
+ typeCheckingMode = "recommended"
54
+ pythonVersion = "3.14"
55
+ include = ["src", "tasks", "tests"]
56
+ allowedUntypedLibraries = ["invoke"]
57
+
58
+ [tool.black]
59
+ target-version = ["py314"]
60
+
61
+ [build-system]
62
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
63
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,148 @@
1
+ """IAM user and access key management, independent of invoke."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from fogies.tools.aws_environ import AwsProfile
11
+ from fogies.typing import boto_client_iam
12
+
13
+ if TYPE_CHECKING:
14
+ from mypy_boto3_iam.type_defs import AccessKeyMetadataTypeDef
15
+
16
+
17
+ class _IamAccessKeyInfo(BaseModel):
18
+ key_id: str
19
+ status: str
20
+ created: datetime.datetime | None
21
+ last_used: datetime.datetime | None
22
+
23
+
24
+ class IamAccessKeys(BaseModel):
25
+ current: _IamAccessKeyInfo | None # newest key
26
+ previous: _IamAccessKeyInfo | None # oldest key; only present when two keys exist
27
+
28
+
29
+ class _IamUserInfo(BaseModel):
30
+ username: str
31
+ keys: IamAccessKeys
32
+
33
+
34
+ def get_keys(*, username: str) -> IamAccessKeys:
35
+ """Return the access keys for an IAM user as current (newest) and previous (oldest).
36
+
37
+ Raises ValueError if the IAM user does not exist.
38
+ """
39
+ iam = boto_client_iam()
40
+ try:
41
+ raw_keys = iam.list_access_keys(UserName=username)["AccessKeyMetadata"]
42
+ except iam.exceptions.NoSuchEntityException as exc:
43
+ raise ValueError("IAM user '{}' not found.".format(username)) from exc
44
+ sorted_keys = sorted(
45
+ raw_keys,
46
+ key=lambda k: k.get("CreateDate") or datetime.datetime.min,
47
+ reverse=True,
48
+ )
49
+
50
+ def _to_info(raw_key: AccessKeyMetadataTypeDef) -> _IamAccessKeyInfo:
51
+ key_id = raw_key.get("AccessKeyId", "")
52
+ last_used_response = iam.get_access_key_last_used(AccessKeyId=key_id)
53
+ last_used = last_used_response["AccessKeyLastUsed"].get("LastUsedDate")
54
+ return _IamAccessKeyInfo(
55
+ key_id=key_id,
56
+ status=raw_key.get("Status", ""),
57
+ created=raw_key.get("CreateDate"),
58
+ last_used=last_used,
59
+ )
60
+
61
+ return IamAccessKeys(
62
+ current=_to_info(sorted_keys[0]) if len(sorted_keys) >= 1 else None,
63
+ previous=_to_info(sorted_keys[1]) if len(sorted_keys) >= 2 else None,
64
+ )
65
+
66
+
67
+ def list_users() -> list[_IamUserInfo]:
68
+ """List all IAM users (sorted by name) and their access keys."""
69
+ iam = boto_client_iam()
70
+ raw_users = [
71
+ user
72
+ for page in iam.get_paginator("list_users").paginate()
73
+ for user in page["Users"]
74
+ ]
75
+ users = [
76
+ _IamUserInfo(
77
+ username=user["UserName"], keys=get_keys(username=user["UserName"])
78
+ )
79
+ for user in raw_users
80
+ ]
81
+ return sorted(users, key=lambda u: u.username)
82
+
83
+
84
+ def create_user(*, username: str) -> AwsProfile:
85
+ """Create a new IAM user and an access key. Raises if the IAM user already exists."""
86
+ iam = boto_client_iam()
87
+ try:
88
+ _ = iam.get_user(UserName=username)
89
+ raise ValueError("IAM user '{}' already exists.".format(username))
90
+ except iam.exceptions.NoSuchEntityException:
91
+ pass
92
+
93
+ _ = iam.create_user(UserName=username)
94
+ key = iam.create_access_key(UserName=username)["AccessKey"]
95
+ return AwsProfile(
96
+ name=username,
97
+ aws_access_key_id=key["AccessKeyId"],
98
+ aws_secret_access_key=key["SecretAccessKey"],
99
+ )
100
+
101
+
102
+ def rotate_key(*, username: str, protected_key_ids: set[str]) -> AwsProfile:
103
+ """Create a new access key for an IAM user, deleting the previous key first if one exists.
104
+
105
+ Raises ValueError if the previous key is in protected_key_ids.
106
+ """
107
+ existing = get_keys(username=username)
108
+ if existing.previous is not None:
109
+ delete_key(
110
+ username=username,
111
+ key_id=existing.previous.key_id,
112
+ protected_key_ids=protected_key_ids,
113
+ )
114
+
115
+ iam = boto_client_iam()
116
+ key = iam.create_access_key(UserName=username)["AccessKey"]
117
+ return AwsProfile(
118
+ name=username,
119
+ aws_access_key_id=key["AccessKeyId"],
120
+ aws_secret_access_key=key["SecretAccessKey"],
121
+ )
122
+
123
+
124
+ def delete_key(*, username: str, key_id: str, protected_key_ids: set[str]) -> None:
125
+ """Delete a specific access key for the IAM user.
126
+
127
+ Raises ValueError if key_id is in protected_key_ids.
128
+ """
129
+ if key_id in protected_key_ids:
130
+ raise ValueError("Refusing to delete protected key '{}'.".format(key_id))
131
+ _ = boto_client_iam().delete_access_key(UserName=username, AccessKeyId=key_id)
132
+
133
+
134
+ def delete_user(*, username: str, protected_usernames: set[str]) -> None:
135
+ """Delete an IAM user.
136
+
137
+ Raises ValueError if the IAM user does not exist, still has access keys, or is protected.
138
+ """
139
+ if username in protected_usernames:
140
+ raise ValueError("Refusing to delete protected IAM user '{}'.".format(username))
141
+ iam = boto_client_iam()
142
+ try:
143
+ keys = iam.list_access_keys(UserName=username)["AccessKeyMetadata"]
144
+ except iam.exceptions.NoSuchEntityException as exc:
145
+ raise ValueError("IAM user '{}' not found.".format(username)) from exc
146
+ if keys:
147
+ raise ValueError("IAM user '{}' still has an access key.".format(username))
148
+ _ = iam.delete_user(UserName=username)
@@ -0,0 +1,6 @@
1
+ # AWS credentials. Keyed by profile name.
2
+ # https://docs.aws.amazon.com/sdkref/latest/guide/feature-static-credentials.html
3
+
4
+ # [profile-name]
5
+ # aws_access_key_id = "..."
6
+ # aws_secret_access_key = "..."
@@ -0,0 +1,5 @@
1
+ # Tracks this Terraform backend and the states using it.
2
+ # Managed by tasks for Terraform apply and destroy.
3
+
4
+ [backend]
5
+ applied = false
@@ -0,0 +1,5 @@
1
+ # Poetry credentials for publishing to PyPI.
2
+ # See https://pypi.org/manage/account/token/.
3
+
4
+ # [pypi]
5
+ # api_key = "pypi-..."
@@ -0,0 +1,63 @@
1
+ """Readiness-polling profiles built on tenacity.
2
+
3
+ Call sites should use one of the named profiles below rather than inventing
4
+ timing/exception values locally. Add a new profile here if a case genuinely
5
+ needs different numbers.
6
+ """
7
+
8
+ import dataclasses
9
+ import socket
10
+
11
+ import requests
12
+ import tenacity
13
+
14
+ # Exceptions that mean DNS resolution hasn't propagated yet.
15
+ READY_POLL_EXCEPTIONS_DNS: tuple[type[BaseException], ...] = (socket.gaierror,)
16
+
17
+ # Exceptions that mean an HTTP endpoint is not yet ready.
18
+ READY_POLL_EXCEPTIONS_HTTP: tuple[type[BaseException], ...] = (
19
+ requests.exceptions.ConnectionError,
20
+ requests.exceptions.HTTPError,
21
+ requests.exceptions.SSLError,
22
+ requests.exceptions.Timeout,
23
+ TimeoutError,
24
+ )
25
+
26
+
27
+ @dataclasses.dataclass(frozen=True, slots=True)
28
+ class ReadyPollTiming:
29
+ """Timeout and poll-interval pair for ready_poll, both in seconds."""
30
+
31
+ timeout_seconds: float
32
+ poll_interval_seconds: float
33
+
34
+
35
+ # Profile for something that can take minutes to become ready.
36
+ READY_POLL_TIMING_LONG = ReadyPollTiming(
37
+ timeout_seconds=300.0, poll_interval_seconds=5.0
38
+ )
39
+
40
+ # Profile for something expected ready within seconds.
41
+ READY_POLL_TIMING_SHORT = ReadyPollTiming(
42
+ timeout_seconds=10.0, poll_interval_seconds=0.5
43
+ )
44
+
45
+
46
+ def ready_poll(
47
+ *,
48
+ exceptions: type[BaseException] | tuple[type[BaseException], ...],
49
+ timing: ReadyPollTiming,
50
+ reraise: bool = True,
51
+ ) -> tenacity.Retrying:
52
+ """Retry policy for polling until a resource becomes ready or *timing.timeout_seconds* elapses.
53
+
54
+ Retries only on *exceptions*; any other exception propagates immediately.
55
+ On giving up, re-raises the last *exceptions* instance unless *reraise* is
56
+ False, in which case a `tenacity.RetryError` is raised instead.
57
+ """
58
+ return tenacity.Retrying(
59
+ retry=tenacity.retry_if_exception_type(exceptions),
60
+ wait=tenacity.wait_fixed(timing.poll_interval_seconds),
61
+ stop=tenacity.stop_after_delay(timing.timeout_seconds),
62
+ reraise=reraise,
63
+ )
@@ -0,0 +1,48 @@
1
+ """Retry policy for flaky one-off operations, built on tenacity.
2
+
3
+ Pass *log_path* to append a line for each retried failure. This is for
4
+ retries whose cause isn't already well understood and is worth investigating;
5
+ well-understood retries don't need it. Nothing is written, and no file is
6
+ created, unless a retry actually happens.
7
+ """
8
+
9
+ import pathlib
10
+ from collections.abc import Callable
11
+ from datetime import datetime
12
+ from typing import TypeVar
13
+
14
+ import tenacity
15
+
16
+ _T = TypeVar("_T")
17
+
18
+
19
+ def _log_retry(log_path: pathlib.Path) -> Callable[[tenacity.RetryCallState], None]:
20
+ def log_it(retry_state: tenacity.RetryCallState) -> None:
21
+ outcome = retry_state.outcome
22
+ assert outcome is not None and outcome.failed
23
+ exception = outcome.exception()
24
+ assert exception is not None
25
+ line = "{} attempt {} raised {}: {}\n".format(
26
+ datetime.now().isoformat(timespec="seconds"),
27
+ retry_state.attempt_number,
28
+ type(exception).__name__,
29
+ exception,
30
+ )
31
+ with log_path.open("a", encoding="utf-8") as f:
32
+ _ = f.write(line)
33
+
34
+ return log_it
35
+
36
+
37
+ def retry_transient(
38
+ *,
39
+ exceptions: type[BaseException] | tuple[type[BaseException], ...],
40
+ log_path: pathlib.Path | None = None,
41
+ ) -> Callable[[Callable[[], _T]], Callable[[], _T]]:
42
+ """Retry profile for a flaky one-off operation, with no backoff."""
43
+ return tenacity.retry(
44
+ retry=tenacity.retry_if_exception_type(exceptions),
45
+ stop=tenacity.stop_after_attempt(5),
46
+ reraise=True,
47
+ before_sleep=_log_retry(log_path) if log_path is not None else None,
48
+ )
@@ -0,0 +1,286 @@
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