fogies 0.0.0.dev7__tar.gz → 0.0.0.dev10__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.dev7 → fogies-0.0.0.dev10}/PKG-INFO +1 -1
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/pyproject.toml +1 -1
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/aws_access_key.py +9 -11
- fogies-0.0.0.dev7/src/fogies/typing.py → fogies-0.0.0.dev10/src/fogies/boto_clients.py +9 -1
- fogies-0.0.0.dev10/src/fogies/download_verified.py +39 -0
- fogies-0.0.0.dev10/src/fogies/pyfogies.py +67 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/retry.py +1 -1
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/access_key.py +53 -46
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/poetry.py +8 -7
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/terraform.py +57 -35
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/terraform_backend.py +63 -46
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/test.py +6 -3
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/templates.py +1 -1
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/backend.py +2 -2
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/aws_environ.py +34 -20
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/boto.py +1 -1
- fogies-0.0.0.dev10/src/fogies/tools/claude_sync.py +130 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/environ.py +25 -16
- fogies-0.0.0.dev10/src/fogies/tools/go.py +135 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/ollama.py +12 -13
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/terraform.py +93 -77
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/terraform_backend.py +55 -7
- fogies-0.0.0.dev7/src/fogies/pyfogies.py +0 -37
- fogies-0.0.0.dev7/src/fogies/terraform/access_key_permissions.py +0 -31
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/README.md +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/__init__.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/includes/templates/aws.toml.template +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/includes/templates/backend_status.toml.template +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/includes/templates/poetry.toml.template +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/py.typed +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/ready_poll.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/__init__.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/format.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/lint.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/__init__.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/alb.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/cloudwatch.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/ecr.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/ecs.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/hosted_zone.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/network.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/__init__.py +0 -0
- {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/command.py +0 -0
|
@@ -7,14 +7,14 @@ from typing import TYPE_CHECKING
|
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
10
|
+
from fogies.boto_clients import boto_client_iam
|
|
10
11
|
from fogies.tools.aws_environ import AwsProfile
|
|
11
|
-
from fogies.typing import boto_client_iam
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
14
|
from mypy_boto3_iam.type_defs import AccessKeyMetadataTypeDef
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
class
|
|
17
|
+
class IamAccessKeyInfo(BaseModel):
|
|
18
18
|
key_id: str
|
|
19
19
|
status: str
|
|
20
20
|
created: datetime.datetime | None
|
|
@@ -22,11 +22,11 @@ class _IamAccessKeyInfo(BaseModel):
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class IamAccessKeys(BaseModel):
|
|
25
|
-
current:
|
|
26
|
-
previous:
|
|
25
|
+
current: IamAccessKeyInfo | None # newest key
|
|
26
|
+
previous: IamAccessKeyInfo | None # oldest key; only present when two keys exist
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
class
|
|
29
|
+
class IamUserInfo(BaseModel):
|
|
30
30
|
username: str
|
|
31
31
|
keys: IamAccessKeys
|
|
32
32
|
|
|
@@ -47,11 +47,11 @@ def get_keys(*, username: str) -> IamAccessKeys:
|
|
|
47
47
|
reverse=True,
|
|
48
48
|
)
|
|
49
49
|
|
|
50
|
-
def _to_info(raw_key: AccessKeyMetadataTypeDef) ->
|
|
50
|
+
def _to_info(raw_key: AccessKeyMetadataTypeDef) -> IamAccessKeyInfo:
|
|
51
51
|
key_id = raw_key.get("AccessKeyId", "")
|
|
52
52
|
last_used_response = iam.get_access_key_last_used(AccessKeyId=key_id)
|
|
53
53
|
last_used = last_used_response["AccessKeyLastUsed"].get("LastUsedDate")
|
|
54
|
-
return
|
|
54
|
+
return IamAccessKeyInfo(
|
|
55
55
|
key_id=key_id,
|
|
56
56
|
status=raw_key.get("Status", ""),
|
|
57
57
|
created=raw_key.get("CreateDate"),
|
|
@@ -64,7 +64,7 @@ def get_keys(*, username: str) -> IamAccessKeys:
|
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def list_users() -> list[
|
|
67
|
+
def list_users() -> list[IamUserInfo]:
|
|
68
68
|
"""List all IAM users (sorted by name) and their access keys."""
|
|
69
69
|
iam = boto_client_iam()
|
|
70
70
|
raw_users = [
|
|
@@ -73,9 +73,7 @@ def list_users() -> list[_IamUserInfo]:
|
|
|
73
73
|
for user in page["Users"]
|
|
74
74
|
]
|
|
75
75
|
users = [
|
|
76
|
-
|
|
77
|
-
username=user["UserName"], keys=get_keys(username=user["UserName"])
|
|
78
|
-
)
|
|
76
|
+
IamUserInfo(username=user["UserName"], keys=get_keys(username=user["UserName"]))
|
|
79
77
|
for user in raw_users
|
|
80
78
|
]
|
|
81
79
|
return sorted(users, key=lambda u: u.username)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Typed factories for boto3 clients."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -9,6 +9,7 @@ import boto3
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
10
|
from mypy_boto3_ec2.client import EC2Client
|
|
11
11
|
from mypy_boto3_ecs.client import ECSClient
|
|
12
|
+
from mypy_boto3_elbv2.client import ElasticLoadBalancingv2Client
|
|
12
13
|
from mypy_boto3_iam.client import IAMClient
|
|
13
14
|
from mypy_boto3_logs.client import CloudWatchLogsClient
|
|
14
15
|
from mypy_boto3_s3.client import S3Client
|
|
@@ -39,6 +40,13 @@ def boto_client_ecs(*, region: str) -> ECSClient:
|
|
|
39
40
|
)
|
|
40
41
|
|
|
41
42
|
|
|
43
|
+
def boto_client_elbv2(*, region: str) -> ElasticLoadBalancingv2Client:
|
|
44
|
+
"""Obtain a boto3 Elastic Load Balancing v2 client for region."""
|
|
45
|
+
return boto3.Session().client( # pyright: ignore[reportUnknownMemberType]
|
|
46
|
+
"elbv2", region_name=region
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
42
50
|
def boto_client_logs(*, region: str) -> CloudWatchLogsClient:
|
|
43
51
|
"""Obtain a boto3 CloudWatch Logs client for region."""
|
|
44
52
|
return boto3.Session().client( # pyright: ignore[reportUnknownMemberType]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Download a file over HTTP, verifying its SHA-256 checksum."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import urllib.request
|
|
5
|
+
from http.client import HTTPResponse
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
# Timeout for each blocking socket operation of a download, in seconds.
|
|
9
|
+
DOWNLOAD_VERIFIED_TIMEOUT_SECONDS = 300.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def download_verified(
|
|
13
|
+
*,
|
|
14
|
+
url: str,
|
|
15
|
+
sha256: str,
|
|
16
|
+
timeout_seconds: float = DOWNLOAD_VERIFIED_TIMEOUT_SECONDS,
|
|
17
|
+
) -> bytes:
|
|
18
|
+
"""Download *url* and return its content, if its SHA-256 matches *sha256*.
|
|
19
|
+
|
|
20
|
+
*sha256* is the expected checksum as a hexadecimal string, taken from the
|
|
21
|
+
publisher and recorded alongside the pinned version being downloaded.
|
|
22
|
+
*timeout_seconds* applies to each blocking socket operation, not to the
|
|
23
|
+
download as a whole, so a stalled connection fails instead of hanging.
|
|
24
|
+
Raises ValueError if the checksum does not match, returning nothing.
|
|
25
|
+
"""
|
|
26
|
+
response = cast(HTTPResponse, urllib.request.urlopen(url, timeout=timeout_seconds))
|
|
27
|
+
with response:
|
|
28
|
+
content: bytes = response.read()
|
|
29
|
+
|
|
30
|
+
actual_sha256 = hashlib.sha256(content).hexdigest()
|
|
31
|
+
if actual_sha256 != sha256.lower():
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"SHA-256 mismatch for '{}': expected {}, got {}".format(
|
|
34
|
+
url,
|
|
35
|
+
sha256.lower(),
|
|
36
|
+
actual_sha256,
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return content
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Facts about the installed pyfogies package itself."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import re
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
# importlib.metadata reports the PEP 440 canonical form, which is how Poetry
|
|
8
|
+
# normalizes the SemVer-hyphenated version string written in pyproject.toml
|
|
9
|
+
# and CHANGELOG.md (e.g. "0.0.0-dev.6" becomes "0.0.0.dev6"). These are the
|
|
10
|
+
# only two shapes this project's own versions ever take.
|
|
11
|
+
_FINAL_RELEASE_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
12
|
+
_DEV_RELEASE_RE = re.compile(r"^(?P<base>\d+\.\d+\.\d+)\.dev(?P<dev>\d+)$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PyfogiesVersionFormat(StrEnum):
|
|
16
|
+
"""The formats in which pyfogies_version() can return the installed version."""
|
|
17
|
+
|
|
18
|
+
# As importlib.metadata reports it, e.g. "0.0.0.dev6".
|
|
19
|
+
PEP440 = "pep440"
|
|
20
|
+
# As written in pyproject.toml and CHANGELOG.md, e.g. "0.0.0-dev.6".
|
|
21
|
+
SEMVER = "semver"
|
|
22
|
+
# The release tag for the version, e.g. "v0.0.0-dev.6".
|
|
23
|
+
GIT_TAG = "git_tag"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _pep440_version() -> str:
|
|
27
|
+
"""The installed pyfogies version, as PEP 440 (e.g. "0.0.0.dev6").
|
|
28
|
+
|
|
29
|
+
Raises if the installed version doesn't match either expected shape,
|
|
30
|
+
rather than guessing at an unfamiliar one.
|
|
31
|
+
"""
|
|
32
|
+
installed_version = importlib.metadata.version("fogies")
|
|
33
|
+
|
|
34
|
+
if _FINAL_RELEASE_RE.match(installed_version) or _DEV_RELEASE_RE.match(
|
|
35
|
+
installed_version
|
|
36
|
+
):
|
|
37
|
+
return installed_version
|
|
38
|
+
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Unrecognized pyfogies version '{}'; expected 'X.Y.Z' or 'X.Y.Z.devN'".format(
|
|
41
|
+
installed_version
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _semver_version() -> str:
|
|
47
|
+
"""The installed pyfogies version, as SemVer (e.g. "0.0.0-dev.6")."""
|
|
48
|
+
pep440_version = _pep440_version()
|
|
49
|
+
|
|
50
|
+
dev_release_match = _DEV_RELEASE_RE.match(pep440_version)
|
|
51
|
+
if dev_release_match:
|
|
52
|
+
return "{}-dev.{}".format(
|
|
53
|
+
dev_release_match.group("base"), dev_release_match.group("dev")
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return pep440_version
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pyfogies_version(*, version_format: PyfogiesVersionFormat) -> str:
|
|
60
|
+
"""The installed pyfogies version, in the requested *version_format*."""
|
|
61
|
+
match PyfogiesVersionFormat(version_format):
|
|
62
|
+
case PyfogiesVersionFormat.PEP440:
|
|
63
|
+
return _pep440_version()
|
|
64
|
+
case PyfogiesVersionFormat.SEMVER:
|
|
65
|
+
return _semver_version()
|
|
66
|
+
case PyfogiesVersionFormat.GIT_TAG:
|
|
67
|
+
return "v{}".format(_semver_version())
|
|
@@ -28,7 +28,7 @@ def _log_retry(log_path: pathlib.Path) -> Callable[[tenacity.RetryCallState], No
|
|
|
28
28
|
type(exception).__name__,
|
|
29
29
|
exception,
|
|
30
30
|
)
|
|
31
|
-
with log_path.open("a", encoding="utf-8") as f:
|
|
31
|
+
with log_path.open("a", encoding="utf-8", newline="\n") as f:
|
|
32
32
|
_ = f.write(line)
|
|
33
33
|
|
|
34
34
|
return log_it
|
|
@@ -13,11 +13,11 @@ from invoke.tasks import Task, task
|
|
|
13
13
|
|
|
14
14
|
import fogies.aws_access_key as aws_access_key
|
|
15
15
|
from fogies.tools.aws_environ import (
|
|
16
|
+
AwsEnviron,
|
|
16
17
|
AwsEnvironFactory,
|
|
17
18
|
AwsProfile,
|
|
18
19
|
aws_environ_from_profile,
|
|
19
20
|
)
|
|
20
|
-
from fogies.typing import boto_client_sts
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def _prompt_admin_credentials() -> tuple[str, str]:
|
|
@@ -30,16 +30,17 @@ def _prompt_admin_credentials() -> tuple[str, str]:
|
|
|
30
30
|
@contextlib.contextmanager
|
|
31
31
|
def _resolve_admin_environ(
|
|
32
32
|
*, aws_environ_factory: AwsEnvironFactory | None, prompt: bool
|
|
33
|
-
) -> Generator[
|
|
33
|
+
) -> Generator[AwsEnviron]:
|
|
34
34
|
"""Enter the configured AWS environment, or prompt for admin credentials.
|
|
35
35
|
|
|
36
36
|
Prompts if aws_environ_factory is None, or if prompt is True (e.g. to use
|
|
37
37
|
different/elevated credentials for a single run). Prompted credentials
|
|
38
|
-
are never written anywhere. Yields the active
|
|
38
|
+
are never written anywhere. Yields the active environment, which
|
|
39
|
+
identifies the caller's username and access key ID.
|
|
39
40
|
"""
|
|
40
41
|
if aws_environ_factory is not None and not prompt:
|
|
41
42
|
with aws_environ_factory() as env:
|
|
42
|
-
yield env
|
|
43
|
+
yield env
|
|
43
44
|
return
|
|
44
45
|
|
|
45
46
|
access_key_id, secret_access_key = _prompt_admin_credentials()
|
|
@@ -54,18 +55,11 @@ def _resolve_admin_environ(
|
|
|
54
55
|
with contextlib.ExitStack() as stack:
|
|
55
56
|
try:
|
|
56
57
|
env = stack.enter_context(
|
|
57
|
-
aws_environ_from_profile(profile=profile,
|
|
58
|
+
aws_environ_from_profile(profile=profile, raise_if_env_exists=False)
|
|
58
59
|
)
|
|
59
60
|
except ValueError as exc:
|
|
60
61
|
raise SystemExit(str(exc)) from exc
|
|
61
|
-
yield env
|
|
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]
|
|
62
|
+
yield env
|
|
69
63
|
|
|
70
64
|
|
|
71
65
|
def _print_toml_block(*, profile: AwsProfile) -> None:
|
|
@@ -102,14 +96,17 @@ def _print_user_key_overview(
|
|
|
102
96
|
def get_task_create(
|
|
103
97
|
*, aws_environ_factory: AwsEnvironFactory | None = None
|
|
104
98
|
) -> Task[Callable[..., None]]:
|
|
105
|
-
@task(
|
|
99
|
+
@task(
|
|
100
|
+
name="create",
|
|
101
|
+
positional=[],
|
|
102
|
+
help={
|
|
103
|
+
"prompt": "Prompt for admin credentials.",
|
|
104
|
+
"username": "IAM username.",
|
|
105
|
+
},
|
|
106
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
106
107
|
def task_create(context: Context, *, prompt: bool = False, username: str) -> None:
|
|
107
108
|
"""
|
|
108
109
|
Create an IAM user and an associated access key.
|
|
109
|
-
|
|
110
|
-
Flags:
|
|
111
|
-
--prompt Prompt for admin credentials.
|
|
112
|
-
--username IAM username.
|
|
113
110
|
"""
|
|
114
111
|
_ = context
|
|
115
112
|
with _resolve_admin_environ(
|
|
@@ -126,20 +123,22 @@ def get_task_create(
|
|
|
126
123
|
def get_task_delete(
|
|
127
124
|
*, aws_environ_factory: AwsEnvironFactory | None = None
|
|
128
125
|
) -> Task[Callable[..., None]]:
|
|
129
|
-
@task(
|
|
126
|
+
@task(
|
|
127
|
+
name="delete",
|
|
128
|
+
positional=[],
|
|
129
|
+
help={
|
|
130
|
+
"prompt": "Prompt for admin credentials.",
|
|
131
|
+
"username": "IAM username.",
|
|
132
|
+
},
|
|
133
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
130
134
|
def task_delete(context: Context, *, prompt: bool = False, username: str) -> None:
|
|
131
135
|
"""
|
|
132
136
|
Delete an IAM user with no remaining access keys.
|
|
133
|
-
|
|
134
|
-
Flags:
|
|
135
|
-
--prompt Prompt for admin credentials.
|
|
136
|
-
--username IAM username.
|
|
137
137
|
"""
|
|
138
138
|
_ = context
|
|
139
139
|
with _resolve_admin_environ(
|
|
140
140
|
aws_environ_factory=aws_environ_factory, prompt=prompt
|
|
141
|
-
):
|
|
142
|
-
protected_username = _caller_username()
|
|
141
|
+
) as admin:
|
|
143
142
|
confirm = (
|
|
144
143
|
input("Delete IAM user '{}'? [y/N] ".format(username)).strip().lower()
|
|
145
144
|
)
|
|
@@ -148,7 +147,7 @@ def get_task_delete(
|
|
|
148
147
|
return
|
|
149
148
|
aws_access_key.delete_user(
|
|
150
149
|
username=username,
|
|
151
|
-
protected_usernames={
|
|
150
|
+
protected_usernames={admin.username},
|
|
152
151
|
)
|
|
153
152
|
print("Deleted IAM user '{}'.".format(username))
|
|
154
153
|
|
|
@@ -158,21 +157,24 @@ def get_task_delete(
|
|
|
158
157
|
def get_task_delete_key(
|
|
159
158
|
*, aws_environ_factory: AwsEnvironFactory | None = None
|
|
160
159
|
) -> Task[Callable[..., None]]:
|
|
161
|
-
@task(
|
|
160
|
+
@task(
|
|
161
|
+
name="delete-key",
|
|
162
|
+
positional=[],
|
|
163
|
+
help={
|
|
164
|
+
"prompt": "Prompt for admin credentials.",
|
|
165
|
+
"username": "IAM username.",
|
|
166
|
+
},
|
|
167
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
162
168
|
def task_delete_key(
|
|
163
169
|
context: Context, *, prompt: bool = False, username: str
|
|
164
170
|
) -> None:
|
|
165
171
|
"""
|
|
166
172
|
Delete the oldest access key for an IAM user.
|
|
167
|
-
|
|
168
|
-
Flags:
|
|
169
|
-
--prompt Prompt for admin credentials.
|
|
170
|
-
--username IAM username.
|
|
171
173
|
"""
|
|
172
174
|
_ = context
|
|
173
175
|
with _resolve_admin_environ(
|
|
174
176
|
aws_environ_factory=aws_environ_factory, prompt=prompt
|
|
175
|
-
) as
|
|
177
|
+
) as admin:
|
|
176
178
|
keys = aws_access_key.get_keys(username=username)
|
|
177
179
|
target = keys.previous or keys.current
|
|
178
180
|
if target is None:
|
|
@@ -192,7 +194,7 @@ def get_task_delete_key(
|
|
|
192
194
|
aws_access_key.delete_key(
|
|
193
195
|
username=username,
|
|
194
196
|
key_id=key_id,
|
|
195
|
-
protected_key_ids={
|
|
197
|
+
protected_key_ids={admin.aws_access_key_id},
|
|
196
198
|
)
|
|
197
199
|
print("Deleted key {}.".format(key_id))
|
|
198
200
|
|
|
@@ -202,13 +204,15 @@ def get_task_delete_key(
|
|
|
202
204
|
def get_task_list(
|
|
203
205
|
*, aws_environ_factory: AwsEnvironFactory | None = None
|
|
204
206
|
) -> Task[Callable[..., None]]:
|
|
205
|
-
@task(
|
|
207
|
+
@task(
|
|
208
|
+
name="list",
|
|
209
|
+
help={
|
|
210
|
+
"prompt": "Prompt for admin credentials.",
|
|
211
|
+
},
|
|
212
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
206
213
|
def task_list(context: Context, *, prompt: bool = False) -> None:
|
|
207
214
|
"""
|
|
208
215
|
List all IAM users and their access keys.
|
|
209
|
-
|
|
210
|
-
Flags:
|
|
211
|
-
--prompt Prompt for admin credentials.
|
|
212
216
|
"""
|
|
213
217
|
_ = context
|
|
214
218
|
with _resolve_admin_environ(
|
|
@@ -229,21 +233,24 @@ def get_task_list(
|
|
|
229
233
|
def get_task_rotate_key(
|
|
230
234
|
*, aws_environ_factory: AwsEnvironFactory | None = None
|
|
231
235
|
) -> Task[Callable[..., None]]:
|
|
232
|
-
@task(
|
|
236
|
+
@task(
|
|
237
|
+
name="rotate-key",
|
|
238
|
+
positional=[],
|
|
239
|
+
help={
|
|
240
|
+
"prompt": "Prompt for admin credentials.",
|
|
241
|
+
"username": "IAM username.",
|
|
242
|
+
},
|
|
243
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
233
244
|
def task_rotate_key(
|
|
234
245
|
context: Context, *, prompt: bool = False, username: str
|
|
235
246
|
) -> None:
|
|
236
247
|
"""
|
|
237
248
|
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
249
|
"""
|
|
243
250
|
_ = context
|
|
244
251
|
with _resolve_admin_environ(
|
|
245
252
|
aws_environ_factory=aws_environ_factory, prompt=prompt
|
|
246
|
-
) as
|
|
253
|
+
) as admin:
|
|
247
254
|
keys = aws_access_key.get_keys(username=username)
|
|
248
255
|
if keys.previous is not None:
|
|
249
256
|
_print_user_key_overview(username=username, keys=keys)
|
|
@@ -258,7 +265,7 @@ def get_task_rotate_key(
|
|
|
258
265
|
return
|
|
259
266
|
new_profile = aws_access_key.rotate_key(
|
|
260
267
|
username=username,
|
|
261
|
-
protected_key_ids={
|
|
268
|
+
protected_key_ids={admin.aws_access_key_id},
|
|
262
269
|
)
|
|
263
270
|
print("Created access key {}.".format(new_profile.aws_access_key_id))
|
|
264
271
|
_print_toml_block(profile=new_profile)
|
|
@@ -267,10 +274,10 @@ def get_task_rotate_key(
|
|
|
267
274
|
|
|
268
275
|
|
|
269
276
|
def get_collection(
|
|
270
|
-
*, aws_environ_factory: AwsEnvironFactory | None = None
|
|
277
|
+
*, collection_name: str, aws_environ_factory: AwsEnvironFactory | None = None
|
|
271
278
|
) -> Collection:
|
|
272
279
|
"""Get a collection of tasks for managing IAM users and access keys."""
|
|
273
|
-
collection = Collection(
|
|
280
|
+
collection = Collection(collection_name)
|
|
274
281
|
collection.add_task(get_task_create(aws_environ_factory=aws_environ_factory))
|
|
275
282
|
collection.add_task(get_task_delete(aws_environ_factory=aws_environ_factory))
|
|
276
283
|
collection.add_task(get_task_delete_key(aws_environ_factory=aws_environ_factory))
|
|
@@ -32,44 +32,45 @@ def get_task_build() -> Task[Callable[[Context], None]]:
|
|
|
32
32
|
return cast(Task[Callable[[Context], None]], task_build)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def get_task_publish(*,
|
|
35
|
+
def get_task_publish(*, secrets_poetry_path: Path) -> Task[Callable[[Context], None]]:
|
|
36
36
|
@task(name="publish") # pyright: ignore[reportUntypedFunctionDecorator]
|
|
37
37
|
def task_publish(context: Context) -> None:
|
|
38
38
|
"""
|
|
39
39
|
Publish package to PyPI.
|
|
40
40
|
"""
|
|
41
41
|
ensure_from_template(
|
|
42
|
-
path=
|
|
42
|
+
path=secrets_poetry_path, template_factory=poetry_toml_template_factory
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
-
with
|
|
45
|
+
with secrets_poetry_path.open("rb") as handle:
|
|
46
46
|
secrets_poetry = tomllib.load(handle)
|
|
47
47
|
|
|
48
48
|
api_key: str = cast(str, secrets_poetry["pypi"]["api_key"])
|
|
49
49
|
|
|
50
|
+
# Read the PyPI token from the environment,
|
|
51
|
+
# so it is not echoed to the terminal as exposed to other processes.
|
|
50
52
|
_ = context.run(
|
|
51
53
|
command=" ".join(
|
|
52
54
|
[
|
|
53
55
|
"poetry",
|
|
54
56
|
"publish",
|
|
55
|
-
"--username __token__",
|
|
56
|
-
"--password {}".format(api_key),
|
|
57
57
|
]
|
|
58
58
|
),
|
|
59
59
|
echo=True,
|
|
60
|
+
env={"POETRY_PYPI_TOKEN_PYPI": api_key},
|
|
60
61
|
)
|
|
61
62
|
|
|
62
63
|
return cast(Task[Callable[[Context], None]], task_publish)
|
|
63
64
|
|
|
64
65
|
|
|
65
|
-
def get_collection(*,
|
|
66
|
+
def get_collection(*, secrets_poetry_path: Path) -> Collection:
|
|
66
67
|
"""
|
|
67
68
|
Get a collection of tasks.
|
|
68
69
|
"""
|
|
69
70
|
namespace = Collection("poetry")
|
|
70
71
|
|
|
71
72
|
task_build = get_task_build()
|
|
72
|
-
task_publish = get_task_publish(
|
|
73
|
+
task_publish = get_task_publish(secrets_poetry_path=secrets_poetry_path)
|
|
73
74
|
|
|
74
75
|
namespace.add_task(task_build)
|
|
75
76
|
namespace.add_task(task_publish)
|
|
@@ -4,6 +4,7 @@ import pathlib
|
|
|
4
4
|
from contextlib import ExitStack
|
|
5
5
|
from typing import Callable, cast
|
|
6
6
|
|
|
7
|
+
from invoke.collection import Collection
|
|
7
8
|
from invoke.context import Context
|
|
8
9
|
from invoke.tasks import Task, task
|
|
9
10
|
|
|
@@ -17,7 +18,6 @@ from fogies.tools.terraform import (
|
|
|
17
18
|
TfbackendFactory,
|
|
18
19
|
TfvarsFactory,
|
|
19
20
|
terraform,
|
|
20
|
-
terraform_templated,
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
|
|
@@ -40,7 +40,15 @@ def get_task_apply(
|
|
|
40
40
|
if (backend_status_path is None) != (backend is None):
|
|
41
41
|
raise ValueError("backend_status_path and backend must be provided together")
|
|
42
42
|
|
|
43
|
-
@task(
|
|
43
|
+
@task(
|
|
44
|
+
name="apply",
|
|
45
|
+
help={
|
|
46
|
+
"init": "Include terraform init. Skip with --no-init.",
|
|
47
|
+
"init_upgrade": "During init, upgrade providers.",
|
|
48
|
+
"init_reconfigure": "During init, reconfigure the backend.",
|
|
49
|
+
"apply_auto_approve": "Skip interactive confirmation.",
|
|
50
|
+
},
|
|
51
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
44
52
|
def task_apply(
|
|
45
53
|
context: Context,
|
|
46
54
|
init: bool = default_init,
|
|
@@ -50,14 +58,6 @@ def get_task_apply(
|
|
|
50
58
|
) -> None:
|
|
51
59
|
"""
|
|
52
60
|
Apply a Terraform configuration.
|
|
53
|
-
|
|
54
|
-
Flags:
|
|
55
|
-
--init Run terraform init before apply (downloads providers, sets up backend).
|
|
56
|
-
--init-upgrade Pass -upgrade to init; checks for newer provider versions within constraints.
|
|
57
|
-
--init-reconfigure Pass -reconfigure to init; re-initializes backend from scratch.
|
|
58
|
-
--apply-auto-approve Skip Terraform's interactive confirmation prompt.
|
|
59
|
-
|
|
60
|
-
--init-upgrade and --init-reconfigure require --init.
|
|
61
61
|
"""
|
|
62
62
|
if init_upgrade and not init:
|
|
63
63
|
raise ValueError("--init-upgrade requires --init")
|
|
@@ -72,14 +72,6 @@ def get_task_apply(
|
|
|
72
72
|
)
|
|
73
73
|
|
|
74
74
|
with ExitStack() as stack:
|
|
75
|
-
templated_paths = stack.enter_context(
|
|
76
|
-
terraform_templated(module_path=module_path)
|
|
77
|
-
)
|
|
78
|
-
if templated_paths:
|
|
79
|
-
print("Rendered __PYFOGIES_VERSION__ in:")
|
|
80
|
-
for templated_path in templated_paths:
|
|
81
|
-
print(" {}".format(templated_path))
|
|
82
|
-
|
|
83
75
|
if aws_environ_factory is not None:
|
|
84
76
|
_ = stack.enter_context(aws_environ_factory())
|
|
85
77
|
|
|
@@ -129,7 +121,15 @@ def get_task_destroy(
|
|
|
129
121
|
if (backend_status_path is None) != (backend is None):
|
|
130
122
|
raise ValueError("backend_status_path and backend must be provided together")
|
|
131
123
|
|
|
132
|
-
@task(
|
|
124
|
+
@task(
|
|
125
|
+
name="destroy",
|
|
126
|
+
help={
|
|
127
|
+
"init": "Include terraform init. Skip with --no-init.",
|
|
128
|
+
"init_upgrade": "During init, upgrade providers.",
|
|
129
|
+
"init_reconfigure": "During init, reconfigure the backend.",
|
|
130
|
+
"destroy_auto_approve": "Skip interactive confirmation.",
|
|
131
|
+
},
|
|
132
|
+
) # pyright: ignore[reportUntypedFunctionDecorator]
|
|
133
133
|
def task_destroy(
|
|
134
134
|
context: Context,
|
|
135
135
|
init: bool = default_init,
|
|
@@ -139,14 +139,6 @@ def get_task_destroy(
|
|
|
139
139
|
) -> None:
|
|
140
140
|
"""
|
|
141
141
|
Destroy a Terraform configuration.
|
|
142
|
-
|
|
143
|
-
Flags:
|
|
144
|
-
--init Run terraform init before destroy (downloads providers, sets up backend).
|
|
145
|
-
--init-upgrade Pass -upgrade to init; checks for newer provider versions within constraints.
|
|
146
|
-
--init-reconfigure Pass -reconfigure to init; re-initializes backend from scratch.
|
|
147
|
-
--destroy-auto-approve Skip Terraform's interactive confirmation prompt.
|
|
148
|
-
|
|
149
|
-
--init-upgrade and --init-reconfigure require --init.
|
|
150
142
|
"""
|
|
151
143
|
if init_upgrade and not init:
|
|
152
144
|
raise ValueError("--init-upgrade requires --init")
|
|
@@ -161,14 +153,6 @@ def get_task_destroy(
|
|
|
161
153
|
)
|
|
162
154
|
|
|
163
155
|
with ExitStack() as stack:
|
|
164
|
-
templated_paths = stack.enter_context(
|
|
165
|
-
terraform_templated(module_path=module_path)
|
|
166
|
-
)
|
|
167
|
-
if templated_paths:
|
|
168
|
-
print("Rendered __PYFOGIES_VERSION__ in:")
|
|
169
|
-
for templated_path in templated_paths:
|
|
170
|
-
print(" {}".format(templated_path))
|
|
171
|
-
|
|
172
156
|
if aws_environ_factory is not None:
|
|
173
157
|
_ = stack.enter_context(aws_environ_factory())
|
|
174
158
|
|
|
@@ -196,3 +180,41 @@ def get_task_destroy(
|
|
|
196
180
|
)
|
|
197
181
|
|
|
198
182
|
return cast(Task[Callable[[Context, bool, bool, bool, bool], None]], task_destroy)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_collection(
|
|
186
|
+
*,
|
|
187
|
+
collection_name: str,
|
|
188
|
+
binary_cache_path: pathlib.Path,
|
|
189
|
+
module_path: pathlib.Path,
|
|
190
|
+
aws_environ_factory: AwsEnvironFactory | None = None,
|
|
191
|
+
backend: BackendConfig | None = None,
|
|
192
|
+
backend_status_path: pathlib.Path | None = None,
|
|
193
|
+
tfbackend_factory: TfbackendFactory | None = None,
|
|
194
|
+
tfvars_factory: TfvarsFactory | None = None,
|
|
195
|
+
) -> Collection:
|
|
196
|
+
"""Get a collection of tasks for applying and destroying a Terraform configuration."""
|
|
197
|
+
collection = Collection(collection_name)
|
|
198
|
+
collection.add_task(
|
|
199
|
+
get_task_apply(
|
|
200
|
+
binary_cache_path=binary_cache_path,
|
|
201
|
+
module_path=module_path,
|
|
202
|
+
aws_environ_factory=aws_environ_factory,
|
|
203
|
+
backend=backend,
|
|
204
|
+
backend_status_path=backend_status_path,
|
|
205
|
+
tfbackend_factory=tfbackend_factory,
|
|
206
|
+
tfvars_factory=tfvars_factory,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
collection.add_task(
|
|
210
|
+
get_task_destroy(
|
|
211
|
+
binary_cache_path=binary_cache_path,
|
|
212
|
+
module_path=module_path,
|
|
213
|
+
aws_environ_factory=aws_environ_factory,
|
|
214
|
+
backend=backend,
|
|
215
|
+
backend_status_path=backend_status_path,
|
|
216
|
+
tfbackend_factory=tfbackend_factory,
|
|
217
|
+
tfvars_factory=tfvars_factory,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
return collection
|