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.
Files changed (43) hide show
  1. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/PKG-INFO +1 -1
  2. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/pyproject.toml +1 -1
  3. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/aws_access_key.py +9 -11
  4. fogies-0.0.0.dev7/src/fogies/typing.py → fogies-0.0.0.dev10/src/fogies/boto_clients.py +9 -1
  5. fogies-0.0.0.dev10/src/fogies/download_verified.py +39 -0
  6. fogies-0.0.0.dev10/src/fogies/pyfogies.py +67 -0
  7. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/retry.py +1 -1
  8. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/access_key.py +53 -46
  9. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/poetry.py +8 -7
  10. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/terraform.py +57 -35
  11. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/terraform_backend.py +63 -46
  12. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/test.py +6 -3
  13. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/templates.py +1 -1
  14. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/backend.py +2 -2
  15. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/aws_environ.py +34 -20
  16. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/boto.py +1 -1
  17. fogies-0.0.0.dev10/src/fogies/tools/claude_sync.py +130 -0
  18. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/environ.py +25 -16
  19. fogies-0.0.0.dev10/src/fogies/tools/go.py +135 -0
  20. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/ollama.py +12 -13
  21. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/terraform.py +93 -77
  22. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/terraform_backend.py +55 -7
  23. fogies-0.0.0.dev7/src/fogies/pyfogies.py +0 -37
  24. fogies-0.0.0.dev7/src/fogies/terraform/access_key_permissions.py +0 -31
  25. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/README.md +0 -0
  26. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/__init__.py +0 -0
  27. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/includes/templates/aws.toml.template +0 -0
  28. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/includes/templates/backend_status.toml.template +0 -0
  29. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/includes/templates/poetry.toml.template +0 -0
  30. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/py.typed +0 -0
  31. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/ready_poll.py +0 -0
  32. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/__init__.py +0 -0
  33. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/format.py +0 -0
  34. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tasks/lint.py +0 -0
  35. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/__init__.py +0 -0
  36. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/alb.py +0 -0
  37. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/cloudwatch.py +0 -0
  38. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/ecr.py +0 -0
  39. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/ecs.py +0 -0
  40. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/hosted_zone.py +0 -0
  41. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/terraform/network.py +0 -0
  42. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/__init__.py +0 -0
  43. {fogies-0.0.0.dev7 → fogies-0.0.0.dev10}/src/fogies/tools/command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fogies
3
- Version: 0.0.0.dev7
3
+ Version: 0.0.0.dev10
4
4
  Summary:
5
5
  Author: James Fogarty
6
6
  Author-email: jayfo@users.noreply.github.com
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "fogies"
3
3
  # Version follows semantic versioning <https://semver.org/>.
4
- version = "0.0.0-dev.7"
4
+ version = "0.0.0-dev.10"
5
5
  description = ""
6
6
  authors = [
7
7
  {name = "James Fogarty",email = "jayfo@users.noreply.github.com"}
@@ -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 _IamAccessKeyInfo(BaseModel):
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: _IamAccessKeyInfo | None # newest key
26
- previous: _IamAccessKeyInfo | None # oldest key; only present when two keys exist
25
+ current: IamAccessKeyInfo | None # newest key
26
+ previous: IamAccessKeyInfo | None # oldest key; only present when two keys exist
27
27
 
28
28
 
29
- class _IamUserInfo(BaseModel):
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) -> _IamAccessKeyInfo:
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 _IamAccessKeyInfo(
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[_IamUserInfo]:
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
- _IamUserInfo(
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
- """Typing helpers for cleanly annotating loosely-typed third-party APIs."""
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[str]:
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 access key ID.
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.aws_access_key_id
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, raise_if_exists=False)
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.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]
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(name="create") # pyright: ignore[reportUntypedFunctionDecorator]
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(name="delete") # pyright: ignore[reportUntypedFunctionDecorator]
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={protected_username},
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(name="delete-key") # pyright: ignore[reportUntypedFunctionDecorator]
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 admin_key_id:
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={admin_key_id},
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(name="list") # pyright: ignore[reportUntypedFunctionDecorator]
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(name="rotate-key") # pyright: ignore[reportUntypedFunctionDecorator]
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 admin_key_id:
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={admin_key_id},
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("access-key")
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(*, path_secrets_poetry: Path) -> Task[Callable[[Context], None]]:
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=path_secrets_poetry, template_factory=poetry_toml_template_factory
42
+ path=secrets_poetry_path, template_factory=poetry_toml_template_factory
43
43
  )
44
44
 
45
- with path_secrets_poetry.open("rb") as handle:
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(*, path_secrets_poetry: Path) -> 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(path_secrets_poetry=path_secrets_poetry)
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(name="apply") # pyright: ignore[reportUntypedFunctionDecorator]
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(name="destroy") # pyright: ignore[reportUntypedFunctionDecorator]
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