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.
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/PKG-INFO +6 -2
- fogies-0.0.0.dev6/pyproject.toml +63 -0
- fogies-0.0.0.dev6/src/fogies/aws_access_key.py +148 -0
- fogies-0.0.0.dev6/src/fogies/includes/templates/aws.toml.template +6 -0
- fogies-0.0.0.dev6/src/fogies/includes/templates/backend_status.toml.template +5 -0
- fogies-0.0.0.dev6/src/fogies/includes/templates/poetry.toml.template +5 -0
- fogies-0.0.0.dev6/src/fogies/ready_poll.py +63 -0
- fogies-0.0.0.dev6/src/fogies/retry.py +48 -0
- fogies-0.0.0.dev6/src/fogies/tasks/access_key.py +286 -0
- fogies-0.0.0.dev6/src/fogies/tasks/format.py +65 -0
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tasks/lint.py +27 -27
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tasks/poetry.py +77 -71
- fogies-0.0.0.dev6/src/fogies/tasks/terraform.py +173 -0
- fogies-0.0.0.dev6/src/fogies/tasks/terraform_backend.py +180 -0
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tasks/test.py +41 -41
- fogies-0.0.0.dev6/src/fogies/templates.py +60 -0
- fogies-0.0.0.dev6/src/fogies/terraform/access_key_permissions.py +31 -0
- fogies-0.0.0.dev6/src/fogies/terraform/alb.py +12 -0
- fogies-0.0.0.dev6/src/fogies/terraform/backend.py +222 -0
- fogies-0.0.0.dev6/src/fogies/terraform/cloudwatch.py +8 -0
- fogies-0.0.0.dev6/src/fogies/terraform/ecr.py +14 -0
- fogies-0.0.0.dev6/src/fogies/terraform/ecs.py +10 -0
- fogies-0.0.0.dev6/src/fogies/terraform/hosted_zone.py +9 -0
- fogies-0.0.0.dev6/src/fogies/terraform/network.py +9 -0
- fogies-0.0.0.dev6/src/fogies/tools/aws_environ.py +164 -0
- fogies-0.0.0.dev6/src/fogies/tools/boto.py +71 -0
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tools/command.py +109 -78
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tools/environ.py +108 -108
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tools/ollama.py +352 -351
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tools/terraform.py +503 -445
- fogies-0.0.0.dev6/src/fogies/tools/terraform_backend.py +99 -0
- fogies-0.0.0.dev6/src/fogies/typing.py +63 -0
- fogies-0.0.0.dev4/fogies_paths.py +0 -21
- fogies-0.0.0.dev4/pyproject.toml +0 -50
- fogies-0.0.0.dev4/src/fogies/tasks/format.py +0 -37
- fogies-0.0.0.dev4/src/fogies/terraform/backend.py +0 -17
- fogies-0.0.0.dev4/src/fogies/tools/aws_environ.py +0 -80
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/README.md +0 -0
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/__init__.py +0 -0
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/py.typed +0 -0
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/tasks/__init__.py +0 -0
- {fogies-0.0.0.dev4 → fogies-0.0.0.dev6}/src/fogies/terraform/__init__.py +0 -0
- {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.
|
|
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.
|
|
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,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
|