fogies 0.0.0.dev5__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 (46) hide show
  1. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/PKG-INFO +3 -2
  2. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/pyproject.toml +7 -4
  3. fogies-0.0.0.dev7/src/fogies/aws_access_key.py +148 -0
  4. fogies-0.0.0.dev7/src/fogies/includes/templates/aws.toml.template +6 -0
  5. fogies-0.0.0.dev7/src/fogies/includes/templates/backend_status.toml.template +5 -0
  6. fogies-0.0.0.dev7/src/fogies/includes/templates/poetry.toml.template +5 -0
  7. fogies-0.0.0.dev7/src/fogies/pyfogies.py +37 -0
  8. fogies-0.0.0.dev7/src/fogies/ready_poll.py +63 -0
  9. fogies-0.0.0.dev7/src/fogies/retry.py +48 -0
  10. fogies-0.0.0.dev7/src/fogies/tasks/access_key.py +279 -0
  11. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tasks/lint.py +27 -27
  12. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tasks/poetry.py +77 -78
  13. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tasks/terraform.py +46 -21
  14. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tasks/terraform_backend.py +33 -12
  15. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tasks/test.py +41 -41
  16. fogies-0.0.0.dev7/src/fogies/templates.py +60 -0
  17. fogies-0.0.0.dev7/src/fogies/terraform/access_key_permissions.py +31 -0
  18. fogies-0.0.0.dev7/src/fogies/terraform/alb.py +12 -0
  19. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/terraform/backend.py +42 -11
  20. fogies-0.0.0.dev7/src/fogies/terraform/cloudwatch.py +8 -0
  21. fogies-0.0.0.dev7/src/fogies/terraform/ecr.py +14 -0
  22. fogies-0.0.0.dev7/src/fogies/terraform/ecs.py +10 -0
  23. fogies-0.0.0.dev7/src/fogies/terraform/hosted_zone.py +9 -0
  24. fogies-0.0.0.dev7/src/fogies/terraform/network.py +9 -0
  25. fogies-0.0.0.dev7/src/fogies/tools/aws_environ.py +171 -0
  26. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tools/command.py +109 -109
  27. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tools/environ.py +108 -108
  28. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tools/ollama.py +352 -346
  29. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tools/terraform.py +93 -23
  30. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tools/terraform_backend.py +23 -12
  31. fogies-0.0.0.dev7/src/fogies/typing.py +63 -0
  32. fogies-0.0.0.dev5/src/fogies/retry.py +0 -102
  33. fogies-0.0.0.dev5/src/fogies/terraform/alb.py +0 -22
  34. fogies-0.0.0.dev5/src/fogies/terraform/ecr.py +0 -23
  35. fogies-0.0.0.dev5/src/fogies/terraform/hosted_zone.py +0 -14
  36. fogies-0.0.0.dev5/src/fogies/terraform/network.py +0 -16
  37. fogies-0.0.0.dev5/src/fogies/tools/aws_environ.py +0 -100
  38. fogies-0.0.0.dev5/src/fogies/typing.py +0 -17
  39. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/README.md +0 -0
  40. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/__init__.py +0 -0
  41. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/py.typed +0 -0
  42. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tasks/__init__.py +0 -0
  43. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tasks/format.py +0 -0
  44. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/terraform/__init__.py +0 -0
  45. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tools/__init__.py +0 -0
  46. {fogies-0.0.0.dev5 → fogies-0.0.0.dev7}/src/fogies/tools/boto.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fogies
3
- Version: 0.0.0.dev5
3
+ Version: 0.0.0.dev7
4
4
  Summary:
5
5
  Author: James Fogarty
6
6
  Author-email: jayfo@users.noreply.github.com
@@ -12,8 +12,9 @@ Requires-Dist: filelock (>=3.25.2,<4.0.0)
12
12
  Requires-Dist: ollama (>=0.6.2,<0.7.0)
13
13
  Requires-Dist: psutil (>=7.2.2,<8.0.0)
14
14
  Requires-Dist: pydantic (>=2.12.5,<3.0.0)
15
+ Requires-Dist: requests (>=2.33.0,<2.34.0)
15
16
  Requires-Dist: tenacity (>=9.1.4,<10.0.0)
16
- Requires-Dist: tomli-w (>=1.2.0,<2.0.0)
17
+ Requires-Dist: tomlkit (>=0.15.1,<0.16.0)
17
18
  Description-Content-Type: text/markdown
18
19
 
19
20
  # pyfogies
@@ -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.5"
4
+ version = "0.0.0-dev.7"
5
5
  description = ""
6
6
  authors = [
7
7
  {name = "James Fogarty",email = "jayfo@users.noreply.github.com"}
@@ -15,6 +15,9 @@ packages = [
15
15
  ]
16
16
  include = [
17
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"]},
18
21
  ]
19
22
 
20
23
  [tool.poetry.dependencies]
@@ -27,8 +30,9 @@ filelock = "^3.25.2"
27
30
  ollama = "^0.6.2"
28
31
  psutil = "^7.2.2"
29
32
  pydantic = "^2.12.5"
33
+ requests = "~2.33.0"
30
34
  tenacity = "^9.1.4"
31
- tomli-w = "^1.2.0"
35
+ tomlkit = "^0.15.1"
32
36
 
33
37
  [tool.poetry.group.dev.dependencies]
34
38
  # ^ allows all minor and patch releases.
@@ -37,12 +41,11 @@ tomli-w = "^1.2.0"
37
41
  # Example: ~1.2.3 will match versions >=1.2.3 and <1.3.0.
38
42
  basedpyright = "^1.36.1"
39
43
  black = "~26.5.1" # Black does not semver.
40
- boto3-stubs = {version = "^1.43.60", extras = ["s3"]}
44
+ boto3-stubs = {extras = ["logs", "s3"], version = "^1.43.70"}
41
45
  invoke = "^2.2.1"
42
46
  isort = "^8.0.1"
43
47
  poetry = "^2.2.1"
44
48
  pytest = "^9.0.2"
45
- requests = "~2.33.0"
46
49
  semver = "^3.0.4"
47
50
  types-requests = "~2.33.0.20260712"
48
51
 
@@ -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,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
+ )
@@ -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,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
+ 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