toast-cli 4.0.5__tar.gz → 4.1.2__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.
- {toast_cli-4.0.5 → toast_cli-4.1.2}/ARCHITECTURE.md +24 -10
- {toast_cli-4.0.5 → toast_cli-4.1.2}/PKG-INFO +49 -15
- {toast_cli-4.0.5 → toast_cli-4.1.2}/README.md +48 -14
- toast_cli-4.1.2/VERSION +1 -0
- toast_cli-4.1.2/tests/test_storage.py +379 -0
- toast_cli-4.1.2/toast/plugins/dot_plugin.py +21 -0
- toast_cli-4.1.2/toast/plugins/prompt_plugin.py +21 -0
- toast_cli-4.1.2/toast/plugins/storage.py +832 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/utils.py +22 -11
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/PKG-INFO +49 -15
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/SOURCES.txt +2 -0
- toast_cli-4.0.5/VERSION +0 -1
- toast_cli-4.0.5/toast/plugins/dot_plugin.py +0 -405
- toast_cli-4.0.5/toast/plugins/prompt_plugin.py +0 -403
- {toast_cli-4.0.5 → toast_cli-4.1.2}/.mergify.yml +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/LICENSE +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/MANIFEST.in +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/pyproject.toml +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/setup.cfg +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/setup.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/__init__.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/__main__.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/helpers.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/__init__.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/am_plugin.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/base_plugin.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/cdw_plugin.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/ctx_plugin.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/env_plugin.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/git_plugin.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/region_plugin.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/ssm_plugin.py +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/dependency_links.txt +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/entry_points.txt +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/not-zip-safe +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/requires.txt +0 -0
- {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/top_level.txt +0 -0
|
@@ -38,6 +38,7 @@ toast-cli/
|
|
|
38
38
|
├── prompt_plugin.py
|
|
39
39
|
├── region_plugin.py
|
|
40
40
|
├── ssm_plugin.py
|
|
41
|
+
├── storage.py
|
|
41
42
|
└── utils.py
|
|
42
43
|
```
|
|
43
44
|
|
|
@@ -102,10 +103,10 @@ Each plugin:
|
|
|
102
103
|
| am | Show AWS caller identity |
|
|
103
104
|
| cdw | Navigate to workspace directories |
|
|
104
105
|
| ctx | Manage Kubernetes contexts (switch, add EKS clusters, delete) |
|
|
105
|
-
| dot | Manage .env.local files with
|
|
106
|
+
| dot | Manage .env.local files with S3 env-store (SSM transition) |
|
|
106
107
|
| env | Manage AWS profiles |
|
|
107
108
|
| git | Manage Git repositories (clone, branch, pull, push, rm, mirror) |
|
|
108
|
-
| prompt | Manage .prompt.md files with
|
|
109
|
+
| prompt | Manage .prompt.md files with S3 env-store (SSM transition) |
|
|
109
110
|
| region | Set AWS region |
|
|
110
111
|
| ssm | AWS SSM Parameter Store operations (get, put, delete, list) |
|
|
111
112
|
|
|
@@ -127,21 +128,34 @@ Each plugin:
|
|
|
127
128
|
- Interactive selection of contexts and clusters
|
|
128
129
|
|
|
129
130
|
#### DotPlugin (dot)
|
|
130
|
-
- Manages .env.local files
|
|
131
|
-
- Default behavior: `sync` (compare local/
|
|
131
|
+
- Manages .env.local files via the S3 env-store (`storage.py`)
|
|
132
|
+
- Default behavior: `sync` (compare local/store, show diff, choose upload/download)
|
|
132
133
|
- Commands: `sync` (default), `up` (upload), `down`/`dn` (download), `ls` (list)
|
|
133
|
-
-
|
|
134
|
-
- SSM path: `/toast/local/{org}/{project}/env-local`
|
|
134
|
+
- S3 key: `local/{org}/{project}/env-local` (SSE-KMS)
|
|
135
135
|
- Validates workspace path structure (`workspace/github.com/{org}/{project}`)
|
|
136
136
|
|
|
137
137
|
#### PromptPlugin (prompt)
|
|
138
|
-
- Manages .prompt.md files
|
|
139
|
-
- Default behavior: `sync` (compare local/
|
|
138
|
+
- Manages .prompt.md files via the S3 env-store (`storage.py`)
|
|
139
|
+
- Default behavior: `sync` (compare local/store, show diff, choose upload/download)
|
|
140
140
|
- Commands: `sync` (default), `up` (upload), `down`/`dn` (download), `ls` (list)
|
|
141
|
-
-
|
|
142
|
-
- SSM path: `/toast/local/{org}/{project}/prompt-md`
|
|
141
|
+
- S3 key: `local/{org}/{project}/prompt-md` (SSE-KMS)
|
|
143
142
|
- Validates workspace path structure (`workspace/github.com/{org}/{project}`)
|
|
144
143
|
|
|
144
|
+
#### Env-store backend (storage.py)
|
|
145
|
+
- Shared storage layer for the dot/prompt plugins
|
|
146
|
+
- Dual-backend during SSM → S3 transition: reads check both S3 and SSM and use
|
|
147
|
+
the newest copy (ties prefer S3); writes always go to S3
|
|
148
|
+
- SSM is a read-only fallback and is harvested into S3 on the next upload
|
|
149
|
+
- All access uses a dedicated AWS profile (`TOAST_ENV_STORE_PROFILE`, default
|
|
150
|
+
`{username}-admin`)
|
|
151
|
+
- Profile defaults to the OS username + `-admin`; bucket defaults to
|
|
152
|
+
`env-store-{account-id}` of that profile (account id via
|
|
153
|
+
`aws sts get-caller-identity`)
|
|
154
|
+
- Configurable via env vars (`TOAST_ENV_STORE_PROFILE`, `TOAST_ENV_STORE_BUCKET`,
|
|
155
|
+
`TOAST_ENV_STORE_KMS_KEY`, `TOAST_ENV_STORE_REGION`) or the config file
|
|
156
|
+
`~/.config/toast/config` (`KEY=VALUE`, created on first run by prompting the
|
|
157
|
+
user); precedence: env var > config file > default
|
|
158
|
+
|
|
145
159
|
#### EnvPlugin (env)
|
|
146
160
|
- Manages AWS profiles from `~/.aws/credentials`
|
|
147
161
|
- Interactive selection of profiles
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: toast-cli
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.1.2
|
|
4
4
|
Summary: A Python-based CLI utility with a plugin architecture for AWS, Kubernetes, Git, and more
|
|
5
5
|
Home-page: https://github.com/opspresso/toast-cli
|
|
6
6
|
Author: nalbam
|
|
@@ -59,7 +59,7 @@ Python-based CLI utility with plugin architecture for AWS, Kubernetes, and Git o
|
|
|
59
59
|
* **Git**: Repository management (clone, branch, pull, push, rm, mirror), organization-specific GitHub hosts
|
|
60
60
|
* **Workspace**: Directory navigation, environment file management (.env.local, .prompt.md)
|
|
61
61
|
* **Interface**: FZF-powered interactive menus, formatted output with Rich
|
|
62
|
-
* **Security**:
|
|
62
|
+
* **Security**: S3 env-store with SSE-KMS for sensitive files (SSM fallback during transition)
|
|
63
63
|
|
|
64
64
|
## Architecture
|
|
65
65
|
|
|
@@ -129,16 +129,16 @@ toast ctx # Switch contexts
|
|
|
129
129
|
# Select [Del...] to delete contexts (individual or all)
|
|
130
130
|
|
|
131
131
|
# Environment Files (.env.local)
|
|
132
|
-
toast dot # Compare local and
|
|
133
|
-
toast dot up # Upload .env.local to
|
|
134
|
-
toast dot down # Download .env.local from
|
|
135
|
-
toast dot ls # List all .env.local files in SSM
|
|
132
|
+
toast dot # Compare local and env-store, choose action (default: sync)
|
|
133
|
+
toast dot up # Upload .env.local to env-store (S3)
|
|
134
|
+
toast dot down # Download .env.local from env-store (alias: dn)
|
|
135
|
+
toast dot ls # List all .env.local files in env-store (S3 + SSM)
|
|
136
136
|
|
|
137
137
|
# Prompt Files (.prompt.md)
|
|
138
|
-
toast prompt # Compare local and
|
|
139
|
-
toast prompt up # Upload .prompt.md to
|
|
140
|
-
toast prompt down # Download .prompt.md from
|
|
141
|
-
toast prompt ls # List all .prompt.md files in SSM
|
|
138
|
+
toast prompt # Compare local and env-store, choose action (default: sync)
|
|
139
|
+
toast prompt up # Upload .prompt.md to env-store (S3)
|
|
140
|
+
toast prompt down # Download .prompt.md from env-store (alias: dn)
|
|
141
|
+
toast prompt ls # List all .prompt.md files in env-store (S3 + SSM)
|
|
142
142
|
|
|
143
143
|
# SSM Parameter Store
|
|
144
144
|
toast ssm # Interactive mode: browse and select parameters
|
|
@@ -218,16 +218,50 @@ Host myorg-github.com
|
|
|
218
218
|
- Automatic host detection based on workspace location
|
|
219
219
|
- Seamless switching between GitHub accounts
|
|
220
220
|
|
|
221
|
-
###
|
|
221
|
+
### Env-store (S3) Storage Paths
|
|
222
222
|
|
|
223
|
-
|
|
223
|
+
The `dot` and `prompt` plugins store files in the S3 env-store bucket. During
|
|
224
|
+
the transition from AWS SSM Parameter Store, reads check both backends and use
|
|
225
|
+
whichever copy is newest; writes always go to S3 (the bucket is the source of
|
|
226
|
+
truth), and SSM copies become stale and are harvested into S3 on the next
|
|
227
|
+
upload.
|
|
224
228
|
|
|
225
229
|
```
|
|
226
|
-
/
|
|
227
|
-
/
|
|
230
|
+
s3://env-store-{account-id}/local/{org}/{project}/env-local # .env.local files
|
|
231
|
+
s3://env-store-{account-id}/local/{org}/{project}/prompt-md # .prompt.md files
|
|
232
|
+
|
|
233
|
+
/toast/local/{org}/{project}/env-local # legacy SSM (read-only fallback)
|
|
234
|
+
/toast/local/{org}/{project}/prompt-md # legacy SSM (read-only fallback)
|
|
228
235
|
```
|
|
229
236
|
|
|
230
|
-
|
|
237
|
+
S3 objects are written with SSE-KMS encryption. All env-store access uses a
|
|
238
|
+
dedicated AWS profile so it is decoupled from your current default profile.
|
|
239
|
+
|
|
240
|
+
**Configuration** — precedence: environment variable > config file > default.
|
|
241
|
+
|
|
242
|
+
Environment variables:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
TOAST_ENV_STORE_PROFILE # default: {username}-admin
|
|
246
|
+
TOAST_ENV_STORE_BUCKET # default: env-store-{account-id of the profile}
|
|
247
|
+
TOAST_ENV_STORE_KMS_KEY # default: bucket/account default KMS key
|
|
248
|
+
TOAST_ENV_STORE_REGION # default: profile's region
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The profile defaults to your OS username + `-admin`, and the bucket defaults to
|
|
252
|
+
`env-store-` + the AWS account id of that profile (looked up via
|
|
253
|
+
`aws sts get-caller-identity`).
|
|
254
|
+
|
|
255
|
+
Config file `~/.config/toast/config` (`KEY=VALUE` format). On first run, if it
|
|
256
|
+
is missing, toast prompts for the values and saves them (interactive sessions
|
|
257
|
+
only):
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
ENV_STORE_BUCKET=env-store-{account-id}
|
|
261
|
+
ENV_STORE_PROFILE={username}-admin
|
|
262
|
+
ENV_STORE_KMS_KEY=
|
|
263
|
+
ENV_STORE_REGION=
|
|
264
|
+
```
|
|
231
265
|
|
|
232
266
|
## Creating Plugins
|
|
233
267
|
|
|
@@ -24,7 +24,7 @@ Python-based CLI utility with plugin architecture for AWS, Kubernetes, and Git o
|
|
|
24
24
|
* **Git**: Repository management (clone, branch, pull, push, rm, mirror), organization-specific GitHub hosts
|
|
25
25
|
* **Workspace**: Directory navigation, environment file management (.env.local, .prompt.md)
|
|
26
26
|
* **Interface**: FZF-powered interactive menus, formatted output with Rich
|
|
27
|
-
* **Security**:
|
|
27
|
+
* **Security**: S3 env-store with SSE-KMS for sensitive files (SSM fallback during transition)
|
|
28
28
|
|
|
29
29
|
## Architecture
|
|
30
30
|
|
|
@@ -94,16 +94,16 @@ toast ctx # Switch contexts
|
|
|
94
94
|
# Select [Del...] to delete contexts (individual or all)
|
|
95
95
|
|
|
96
96
|
# Environment Files (.env.local)
|
|
97
|
-
toast dot # Compare local and
|
|
98
|
-
toast dot up # Upload .env.local to
|
|
99
|
-
toast dot down # Download .env.local from
|
|
100
|
-
toast dot ls # List all .env.local files in SSM
|
|
97
|
+
toast dot # Compare local and env-store, choose action (default: sync)
|
|
98
|
+
toast dot up # Upload .env.local to env-store (S3)
|
|
99
|
+
toast dot down # Download .env.local from env-store (alias: dn)
|
|
100
|
+
toast dot ls # List all .env.local files in env-store (S3 + SSM)
|
|
101
101
|
|
|
102
102
|
# Prompt Files (.prompt.md)
|
|
103
|
-
toast prompt # Compare local and
|
|
104
|
-
toast prompt up # Upload .prompt.md to
|
|
105
|
-
toast prompt down # Download .prompt.md from
|
|
106
|
-
toast prompt ls # List all .prompt.md files in SSM
|
|
103
|
+
toast prompt # Compare local and env-store, choose action (default: sync)
|
|
104
|
+
toast prompt up # Upload .prompt.md to env-store (S3)
|
|
105
|
+
toast prompt down # Download .prompt.md from env-store (alias: dn)
|
|
106
|
+
toast prompt ls # List all .prompt.md files in env-store (S3 + SSM)
|
|
107
107
|
|
|
108
108
|
# SSM Parameter Store
|
|
109
109
|
toast ssm # Interactive mode: browse and select parameters
|
|
@@ -183,16 +183,50 @@ Host myorg-github.com
|
|
|
183
183
|
- Automatic host detection based on workspace location
|
|
184
184
|
- Seamless switching between GitHub accounts
|
|
185
185
|
|
|
186
|
-
###
|
|
186
|
+
### Env-store (S3) Storage Paths
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
The `dot` and `prompt` plugins store files in the S3 env-store bucket. During
|
|
189
|
+
the transition from AWS SSM Parameter Store, reads check both backends and use
|
|
190
|
+
whichever copy is newest; writes always go to S3 (the bucket is the source of
|
|
191
|
+
truth), and SSM copies become stale and are harvested into S3 on the next
|
|
192
|
+
upload.
|
|
189
193
|
|
|
190
194
|
```
|
|
191
|
-
/
|
|
192
|
-
/
|
|
195
|
+
s3://env-store-{account-id}/local/{org}/{project}/env-local # .env.local files
|
|
196
|
+
s3://env-store-{account-id}/local/{org}/{project}/prompt-md # .prompt.md files
|
|
197
|
+
|
|
198
|
+
/toast/local/{org}/{project}/env-local # legacy SSM (read-only fallback)
|
|
199
|
+
/toast/local/{org}/{project}/prompt-md # legacy SSM (read-only fallback)
|
|
193
200
|
```
|
|
194
201
|
|
|
195
|
-
|
|
202
|
+
S3 objects are written with SSE-KMS encryption. All env-store access uses a
|
|
203
|
+
dedicated AWS profile so it is decoupled from your current default profile.
|
|
204
|
+
|
|
205
|
+
**Configuration** — precedence: environment variable > config file > default.
|
|
206
|
+
|
|
207
|
+
Environment variables:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
TOAST_ENV_STORE_PROFILE # default: {username}-admin
|
|
211
|
+
TOAST_ENV_STORE_BUCKET # default: env-store-{account-id of the profile}
|
|
212
|
+
TOAST_ENV_STORE_KMS_KEY # default: bucket/account default KMS key
|
|
213
|
+
TOAST_ENV_STORE_REGION # default: profile's region
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The profile defaults to your OS username + `-admin`, and the bucket defaults to
|
|
217
|
+
`env-store-` + the AWS account id of that profile (looked up via
|
|
218
|
+
`aws sts get-caller-identity`).
|
|
219
|
+
|
|
220
|
+
Config file `~/.config/toast/config` (`KEY=VALUE` format). On first run, if it
|
|
221
|
+
is missing, toast prompts for the values and saves them (interactive sessions
|
|
222
|
+
only):
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
ENV_STORE_BUCKET=env-store-{account-id}
|
|
226
|
+
ENV_STORE_PROFILE={username}-admin
|
|
227
|
+
ENV_STORE_KMS_KEY=
|
|
228
|
+
ENV_STORE_REGION=
|
|
229
|
+
```
|
|
196
230
|
|
|
197
231
|
## Creating Plugins
|
|
198
232
|
|
toast_cli-4.1.2/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
v4.1.2
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Unit tests for the env-store backend pure logic (no AWS access)."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from datetime import timezone
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
from toast.plugins import storage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ParseTimestampTests(unittest.TestCase):
|
|
15
|
+
def test_iso_with_offset(self):
|
|
16
|
+
dt = storage.parse_timestamp("2024-01-02T03:04:05+00:00")
|
|
17
|
+
self.assertEqual(dt.year, 2024)
|
|
18
|
+
self.assertEqual(dt.tzinfo, timezone.utc)
|
|
19
|
+
|
|
20
|
+
def test_iso_with_z(self):
|
|
21
|
+
dt = storage.parse_timestamp("2024-01-02T03:04:05Z")
|
|
22
|
+
self.assertEqual(dt.hour, 3)
|
|
23
|
+
self.assertEqual(dt.tzinfo, timezone.utc)
|
|
24
|
+
|
|
25
|
+
def test_iso_fractional(self):
|
|
26
|
+
dt = storage.parse_timestamp("2024-01-02T03:04:05.123456+00:00")
|
|
27
|
+
self.assertIsNotNone(dt)
|
|
28
|
+
self.assertEqual(dt.microsecond, 123456)
|
|
29
|
+
|
|
30
|
+
def test_epoch_float(self):
|
|
31
|
+
dt = storage.parse_timestamp(0)
|
|
32
|
+
self.assertEqual(dt.year, 1970)
|
|
33
|
+
|
|
34
|
+
def test_none(self):
|
|
35
|
+
self.assertIsNone(storage.parse_timestamp(None))
|
|
36
|
+
|
|
37
|
+
def test_blank(self):
|
|
38
|
+
self.assertIsNone(storage.parse_timestamp(" "))
|
|
39
|
+
|
|
40
|
+
def test_ordering(self):
|
|
41
|
+
older = storage.parse_timestamp("2024-01-01T00:00:00Z")
|
|
42
|
+
newer = storage.parse_timestamp("2024-02-01T00:00:00+00:00")
|
|
43
|
+
self.assertLess(older, newer)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DefaultsTests(unittest.TestCase):
|
|
47
|
+
def test_default_profile(self):
|
|
48
|
+
with mock.patch.object(storage, "_current_username", return_value="alice"):
|
|
49
|
+
self.assertEqual(storage.default_profile(), "alice-admin")
|
|
50
|
+
|
|
51
|
+
def test_get_account_id_success(self):
|
|
52
|
+
fake = mock.Mock(returncode=0, stdout="123456789012\n", stderr="")
|
|
53
|
+
with mock.patch.object(storage.subprocess, "run", return_value=fake):
|
|
54
|
+
self.assertEqual(storage.get_account_id("p"), "123456789012")
|
|
55
|
+
|
|
56
|
+
def test_get_account_id_failure(self):
|
|
57
|
+
fake = mock.Mock(returncode=255, stdout="", stderr="error")
|
|
58
|
+
with mock.patch.object(storage.subprocess, "run", return_value=fake):
|
|
59
|
+
self.assertIsNone(storage.get_account_id("p"))
|
|
60
|
+
|
|
61
|
+
def test_default_bucket(self):
|
|
62
|
+
with mock.patch.object(storage, "get_account_id", return_value="123456789012"):
|
|
63
|
+
self.assertEqual(storage.default_bucket("p"), "env-store-123456789012")
|
|
64
|
+
|
|
65
|
+
def test_default_bucket_none(self):
|
|
66
|
+
with mock.patch.object(storage, "get_account_id", return_value=None):
|
|
67
|
+
self.assertIsNone(storage.default_bucket("p"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConfigTests(unittest.TestCase):
|
|
71
|
+
def test_defaults_no_file(self):
|
|
72
|
+
with tempfile.TemporaryDirectory() as d:
|
|
73
|
+
path = os.path.join(d, "config")
|
|
74
|
+
with mock.patch.dict(os.environ, {}, clear=True), mock.patch.object(
|
|
75
|
+
storage, "_current_username", return_value="bob"
|
|
76
|
+
), mock.patch.object(storage, "get_account_id", return_value="111122223333"):
|
|
77
|
+
c = storage.resolve_config(config_path=path, create=False)
|
|
78
|
+
self.assertEqual(c.profile, "bob-admin")
|
|
79
|
+
self.assertEqual(c.bucket, "env-store-111122223333")
|
|
80
|
+
self.assertIsNone(c.kms_key)
|
|
81
|
+
self.assertIsNone(c.region)
|
|
82
|
+
|
|
83
|
+
def test_bucket_none_on_sts_failure(self):
|
|
84
|
+
with tempfile.TemporaryDirectory() as d:
|
|
85
|
+
path = os.path.join(d, "config")
|
|
86
|
+
with mock.patch.dict(os.environ, {}, clear=True), mock.patch.object(
|
|
87
|
+
storage, "get_account_id", return_value=None
|
|
88
|
+
):
|
|
89
|
+
c = storage.resolve_config(config_path=path, create=False)
|
|
90
|
+
self.assertIsNone(c.bucket)
|
|
91
|
+
|
|
92
|
+
def test_env_overrides(self):
|
|
93
|
+
env = {
|
|
94
|
+
"TOAST_ENV_STORE_BUCKET": "my-bucket",
|
|
95
|
+
"TOAST_ENV_STORE_PROFILE": "my-profile",
|
|
96
|
+
"TOAST_ENV_STORE_KMS_KEY": "key-123",
|
|
97
|
+
"TOAST_ENV_STORE_REGION": "us-west-2",
|
|
98
|
+
}
|
|
99
|
+
with tempfile.TemporaryDirectory() as d:
|
|
100
|
+
path = os.path.join(d, "config")
|
|
101
|
+
with mock.patch.dict(os.environ, env, clear=True):
|
|
102
|
+
c = storage.resolve_config(config_path=path, create=False)
|
|
103
|
+
self.assertEqual(c.bucket, "my-bucket")
|
|
104
|
+
self.assertEqual(c.profile, "my-profile")
|
|
105
|
+
self.assertEqual(c.kms_key, "key-123")
|
|
106
|
+
self.assertEqual(c.region, "us-west-2")
|
|
107
|
+
|
|
108
|
+
def test_file_values_used(self):
|
|
109
|
+
with tempfile.TemporaryDirectory() as d:
|
|
110
|
+
path = os.path.join(d, "config")
|
|
111
|
+
with open(path, "w") as f:
|
|
112
|
+
f.write("ENV_STORE_BUCKET=file-bucket\nENV_STORE_PROFILE=file-profile\n")
|
|
113
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
114
|
+
c = storage.resolve_config(config_path=path, create=False)
|
|
115
|
+
self.assertEqual(c.bucket, "file-bucket")
|
|
116
|
+
self.assertEqual(c.profile, "file-profile")
|
|
117
|
+
|
|
118
|
+
def test_env_beats_file(self):
|
|
119
|
+
with tempfile.TemporaryDirectory() as d:
|
|
120
|
+
path = os.path.join(d, "config")
|
|
121
|
+
with open(path, "w") as f:
|
|
122
|
+
f.write("ENV_STORE_BUCKET=file-bucket\n")
|
|
123
|
+
with mock.patch.dict(
|
|
124
|
+
os.environ, {"TOAST_ENV_STORE_BUCKET": "env-bucket"}, clear=True
|
|
125
|
+
):
|
|
126
|
+
c = storage.resolve_config(config_path=path, create=False)
|
|
127
|
+
self.assertEqual(c.bucket, "env-bucket")
|
|
128
|
+
|
|
129
|
+
def test_prompt_creates_file(self):
|
|
130
|
+
# Prompt order: profile, region, bucket, kms
|
|
131
|
+
with tempfile.TemporaryDirectory() as d:
|
|
132
|
+
path = os.path.join(d, "toast", "config")
|
|
133
|
+
self.assertFalse(os.path.exists(path))
|
|
134
|
+
with mock.patch.dict(os.environ, {}, clear=True), mock.patch(
|
|
135
|
+
"sys.stdin.isatty", return_value=True
|
|
136
|
+
), mock.patch.object(
|
|
137
|
+
storage, "get_account_id", return_value="999988887777"
|
|
138
|
+
), mock.patch.object(
|
|
139
|
+
storage.click, "confirm", return_value=True
|
|
140
|
+
), mock.patch.object(
|
|
141
|
+
storage.click, "prompt", side_effect=["p1", "", "b1", ""]
|
|
142
|
+
):
|
|
143
|
+
c = storage.resolve_config(config_path=path, create=True)
|
|
144
|
+
self.assertTrue(os.path.exists(path))
|
|
145
|
+
self.assertEqual(c.profile, "p1")
|
|
146
|
+
self.assertEqual(c.bucket, "b1")
|
|
147
|
+
self.assertIsNone(c.kms_key)
|
|
148
|
+
|
|
149
|
+
def test_decline_does_not_create(self):
|
|
150
|
+
with tempfile.TemporaryDirectory() as d:
|
|
151
|
+
path = os.path.join(d, "toast", "config")
|
|
152
|
+
with mock.patch.dict(os.environ, {}, clear=True), mock.patch(
|
|
153
|
+
"sys.stdin.isatty", return_value=True
|
|
154
|
+
), mock.patch.object(
|
|
155
|
+
storage, "get_account_id", return_value="111122223333"
|
|
156
|
+
), mock.patch.object(storage.click, "confirm", return_value=False):
|
|
157
|
+
c = storage.resolve_config(config_path=path, create=True)
|
|
158
|
+
self.assertFalse(os.path.exists(path))
|
|
159
|
+
self.assertEqual(c.bucket, "env-store-111122223333")
|
|
160
|
+
|
|
161
|
+
def test_no_prompt_when_not_tty(self):
|
|
162
|
+
with tempfile.TemporaryDirectory() as d:
|
|
163
|
+
path = os.path.join(d, "toast", "config")
|
|
164
|
+
with mock.patch.dict(os.environ, {}, clear=True), mock.patch(
|
|
165
|
+
"sys.stdin.isatty", return_value=False
|
|
166
|
+
), mock.patch.object(
|
|
167
|
+
storage, "get_account_id", return_value="111122223333"
|
|
168
|
+
):
|
|
169
|
+
c = storage.resolve_config(config_path=path, create=True)
|
|
170
|
+
self.assertFalse(os.path.exists(path))
|
|
171
|
+
self.assertEqual(c.bucket, "env-store-111122223333")
|
|
172
|
+
|
|
173
|
+
def test_render_roundtrip(self):
|
|
174
|
+
with tempfile.TemporaryDirectory() as d:
|
|
175
|
+
path = os.path.join(d, "config")
|
|
176
|
+
with open(path, "w") as f:
|
|
177
|
+
f.write(storage.render_config("b", "p", "k", "r"))
|
|
178
|
+
cfg = storage.read_config_file(path)
|
|
179
|
+
self.assertEqual(cfg["ENV_STORE_BUCKET"], "b")
|
|
180
|
+
self.assertEqual(cfg["ENV_STORE_PROFILE"], "p")
|
|
181
|
+
self.assertEqual(cfg["ENV_STORE_REGION"], "r")
|
|
182
|
+
|
|
183
|
+
def test_read_skips_comments(self):
|
|
184
|
+
with tempfile.TemporaryDirectory() as d:
|
|
185
|
+
path = os.path.join(d, "config")
|
|
186
|
+
with open(path, "w") as f:
|
|
187
|
+
f.write("# comment\n\n#ENV_STORE_BUCKET=x\nENV_STORE_PROFILE=p\n")
|
|
188
|
+
self.assertEqual(storage.read_config_file(path), {"ENV_STORE_PROFILE": "p"})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class PathTests(unittest.TestCase):
|
|
192
|
+
def test_ssm_path(self):
|
|
193
|
+
self.assertEqual(
|
|
194
|
+
storage.ssm_path("org", "proj", "env-local"),
|
|
195
|
+
"/toast/local/org/proj/env-local",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def test_s3_key(self):
|
|
199
|
+
self.assertEqual(
|
|
200
|
+
storage.s3_key("org", "proj", "prompt-md"),
|
|
201
|
+
"local/org/proj/prompt-md",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def test_parse_s3_key(self):
|
|
205
|
+
self.assertEqual(
|
|
206
|
+
storage._parse_s3_key("local/org/proj/env-local"),
|
|
207
|
+
("org", "proj", "env-local"),
|
|
208
|
+
)
|
|
209
|
+
self.assertIsNone(storage._parse_s3_key("bad/key"))
|
|
210
|
+
self.assertIsNone(storage._parse_s3_key("other/org/proj/env-local"))
|
|
211
|
+
|
|
212
|
+
def test_parse_ssm_name(self):
|
|
213
|
+
self.assertEqual(
|
|
214
|
+
storage._parse_ssm_name("/toast/local/org/proj/prompt-md"),
|
|
215
|
+
("org", "proj", "prompt-md"),
|
|
216
|
+
)
|
|
217
|
+
self.assertIsNone(storage._parse_ssm_name("/foo/bar"))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class AwsCommandTests(unittest.TestCase):
|
|
221
|
+
def test_profile_injected(self):
|
|
222
|
+
cfg = storage.StoreConfig("b", "myprofile", None, None)
|
|
223
|
+
cmd = storage._aws(cfg, ["s3api", "list-objects-v2"])
|
|
224
|
+
self.assertIn("--profile", cmd)
|
|
225
|
+
self.assertEqual(cmd[cmd.index("--profile") + 1], "myprofile")
|
|
226
|
+
self.assertNotIn("--region", cmd)
|
|
227
|
+
|
|
228
|
+
def test_region_injected(self):
|
|
229
|
+
cfg = storage.StoreConfig("b", "p", None, "us-west-2")
|
|
230
|
+
cmd = storage._aws(cfg, ["ssm", "get-parameter"])
|
|
231
|
+
self.assertEqual(cmd[cmd.index("--region") + 1], "us-west-2")
|
|
232
|
+
|
|
233
|
+
def test_s3_put_uses_kms_server_side_encryption(self):
|
|
234
|
+
cfg = storage.StoreConfig("b", "p", None, None)
|
|
235
|
+
captured = {}
|
|
236
|
+
|
|
237
|
+
def fake_run(cmd, **kw):
|
|
238
|
+
captured["cmd"] = cmd
|
|
239
|
+
return mock.Mock(returncode=0, stdout="{}", stderr="")
|
|
240
|
+
|
|
241
|
+
with mock.patch.object(storage.subprocess, "run", side_effect=fake_run):
|
|
242
|
+
ok, err = storage.s3_put(cfg, "local/o/p/env-local", "data")
|
|
243
|
+
|
|
244
|
+
self.assertTrue(ok)
|
|
245
|
+
cmd = captured["cmd"]
|
|
246
|
+
self.assertIn("--server-side-encryption", cmd)
|
|
247
|
+
self.assertEqual(cmd[cmd.index("--server-side-encryption") + 1], "aws:kms")
|
|
248
|
+
# '--sse' is not a valid s3api option (it is the high-level `aws s3` shorthand)
|
|
249
|
+
self.assertNotIn("--sse", cmd)
|
|
250
|
+
|
|
251
|
+
def test_ssm_get_passes_profile_and_region(self):
|
|
252
|
+
cfg = storage.StoreConfig("b", "myprofile", None, "eu-west-1")
|
|
253
|
+
with mock.patch.object(
|
|
254
|
+
storage, "get_ssm_parameter", return_value=("v", "t", None)
|
|
255
|
+
) as m:
|
|
256
|
+
storage.ssm_get(cfg, "/toast/local/o/p/env-local")
|
|
257
|
+
m.assert_called_once_with(
|
|
258
|
+
"/toast/local/o/p/env-local", profile="myprofile", region="eu-west-1"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class StoreReadTests(unittest.TestCase):
|
|
263
|
+
def _cfg(self):
|
|
264
|
+
return storage.StoreConfig("b", "p", None, None)
|
|
265
|
+
|
|
266
|
+
def test_both_s3_newer(self):
|
|
267
|
+
with mock.patch.object(
|
|
268
|
+
storage, "s3_get", return_value=("s3val", "2024-02-01T00:00:00Z", None)
|
|
269
|
+
), mock.patch.object(
|
|
270
|
+
storage, "ssm_get", return_value=("ssmval", "2024-01-01T00:00:00Z", None)
|
|
271
|
+
):
|
|
272
|
+
r = storage.store_read(self._cfg(), "o", "p", "env-local")
|
|
273
|
+
self.assertEqual(r.source, "s3")
|
|
274
|
+
self.assertEqual(r.value, "s3val")
|
|
275
|
+
self.assertEqual(r.status, "both")
|
|
276
|
+
|
|
277
|
+
def test_both_ssm_newer(self):
|
|
278
|
+
with mock.patch.object(
|
|
279
|
+
storage, "s3_get", return_value=("s3val", "2024-01-01T00:00:00Z", None)
|
|
280
|
+
), mock.patch.object(
|
|
281
|
+
storage, "ssm_get", return_value=("ssmval", "2024-02-01T00:00:00Z", None)
|
|
282
|
+
):
|
|
283
|
+
r = storage.store_read(self._cfg(), "o", "p", "env-local")
|
|
284
|
+
self.assertEqual(r.source, "ssm")
|
|
285
|
+
self.assertEqual(r.value, "ssmval")
|
|
286
|
+
|
|
287
|
+
def test_tie_prefers_s3(self):
|
|
288
|
+
ts = "2024-01-01T00:00:00Z"
|
|
289
|
+
with mock.patch.object(
|
|
290
|
+
storage, "s3_get", return_value=("s3val", ts, None)
|
|
291
|
+
), mock.patch.object(storage, "ssm_get", return_value=("ssmval", ts, None)):
|
|
292
|
+
r = storage.store_read(self._cfg(), "o", "p", "env-local")
|
|
293
|
+
self.assertEqual(r.source, "s3")
|
|
294
|
+
|
|
295
|
+
def test_s3_only(self):
|
|
296
|
+
with mock.patch.object(
|
|
297
|
+
storage, "s3_get", return_value=("s3val", "2024-01-01T00:00:00Z", None)
|
|
298
|
+
), mock.patch.object(storage, "ssm_get", return_value=(None, None, None)):
|
|
299
|
+
r = storage.store_read(self._cfg(), "o", "p", "env-local")
|
|
300
|
+
self.assertEqual(r.source, "s3")
|
|
301
|
+
self.assertEqual(r.status, "s3_only")
|
|
302
|
+
|
|
303
|
+
def test_ssm_only(self):
|
|
304
|
+
with mock.patch.object(
|
|
305
|
+
storage, "s3_get", return_value=(None, None, None)
|
|
306
|
+
), mock.patch.object(
|
|
307
|
+
storage, "ssm_get", return_value=("ssmval", "2024-01-01T00:00:00Z", None)
|
|
308
|
+
):
|
|
309
|
+
r = storage.store_read(self._cfg(), "o", "p", "env-local")
|
|
310
|
+
self.assertEqual(r.source, "ssm")
|
|
311
|
+
self.assertEqual(r.status, "ssm_only")
|
|
312
|
+
|
|
313
|
+
def test_none(self):
|
|
314
|
+
with mock.patch.object(
|
|
315
|
+
storage, "s3_get", return_value=(None, None, None)
|
|
316
|
+
), mock.patch.object(storage, "ssm_get", return_value=(None, None, None)):
|
|
317
|
+
r = storage.store_read(self._cfg(), "o", "p", "env-local")
|
|
318
|
+
self.assertEqual(r.status, "none")
|
|
319
|
+
self.assertIsNone(r.value)
|
|
320
|
+
self.assertIsNone(r.source)
|
|
321
|
+
|
|
322
|
+
def test_s3_error_falls_back_to_ssm(self):
|
|
323
|
+
with mock.patch.object(
|
|
324
|
+
storage, "s3_get", return_value=(None, None, "AccessDenied")
|
|
325
|
+
), mock.patch.object(
|
|
326
|
+
storage, "ssm_get", return_value=("ssmval", "2024-01-01T00:00:00Z", None)
|
|
327
|
+
):
|
|
328
|
+
r = storage.store_read(self._cfg(), "o", "p", "env-local")
|
|
329
|
+
self.assertEqual(r.source, "ssm")
|
|
330
|
+
self.assertTrue(any(src == "s3" for src, _ in r.errors))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class StoreListTests(unittest.TestCase):
|
|
334
|
+
def _cfg(self):
|
|
335
|
+
return storage.StoreConfig("b", "p", None, None)
|
|
336
|
+
|
|
337
|
+
def test_merge_and_newest(self):
|
|
338
|
+
s3_entries = [
|
|
339
|
+
{"key": "local/org/proj/env-local", "last_modified": "2024-02-01T00:00:00Z"}
|
|
340
|
+
]
|
|
341
|
+
ssm_entries = [
|
|
342
|
+
{
|
|
343
|
+
"name": "/toast/local/org/proj/env-local",
|
|
344
|
+
"last_modified": "2024-01-01T00:00:00Z",
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
"name": "/toast/local/org/other/env-local",
|
|
348
|
+
"last_modified": "2024-01-01T00:00:00Z",
|
|
349
|
+
},
|
|
350
|
+
]
|
|
351
|
+
with mock.patch.object(
|
|
352
|
+
storage, "s3_list", return_value=(s3_entries, None)
|
|
353
|
+
), mock.patch.object(storage, "ssm_list", return_value=(ssm_entries, None)):
|
|
354
|
+
rows, errors = storage.store_list(self._cfg(), "env-local")
|
|
355
|
+
|
|
356
|
+
self.assertEqual(errors, [])
|
|
357
|
+
self.assertEqual([r["path"] for r in rows], ["org/other", "org/proj"])
|
|
358
|
+
by_path = {r["path"]: r for r in rows}
|
|
359
|
+
# org/proj exists in both; S3 is newer -> current S3, in both backends
|
|
360
|
+
self.assertEqual(by_path["org/proj"]["source"], "s3")
|
|
361
|
+
self.assertTrue(by_path["org/proj"]["in_s3"])
|
|
362
|
+
self.assertTrue(by_path["org/proj"]["in_ssm"])
|
|
363
|
+
# org/other only in SSM
|
|
364
|
+
self.assertEqual(by_path["org/other"]["source"], "ssm")
|
|
365
|
+
self.assertFalse(by_path["org/other"]["in_s3"])
|
|
366
|
+
|
|
367
|
+
def test_kind_filter(self):
|
|
368
|
+
s3_entries = [
|
|
369
|
+
{"key": "local/org/proj/prompt-md", "last_modified": "2024-02-01T00:00:00Z"}
|
|
370
|
+
]
|
|
371
|
+
with mock.patch.object(
|
|
372
|
+
storage, "s3_list", return_value=(s3_entries, None)
|
|
373
|
+
), mock.patch.object(storage, "ssm_list", return_value=([], None)):
|
|
374
|
+
rows, _ = storage.store_list(self._cfg(), "env-local")
|
|
375
|
+
self.assertEqual(rows, [])
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
unittest.main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from toast.plugins.base_plugin import BasePlugin
|
|
5
|
+
from toast.plugins.storage import run_file_sync
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DotPlugin(BasePlugin):
|
|
9
|
+
"""Plugin for 'dot' command - manages .env.local files."""
|
|
10
|
+
|
|
11
|
+
name = "dot"
|
|
12
|
+
help = "Manage .env.local files"
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def get_arguments(cls, func):
|
|
16
|
+
func = click.argument("command", required=False)(func)
|
|
17
|
+
return func
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def execute(cls, command=None, **kwargs):
|
|
21
|
+
run_file_sync("env-local", ".env.local", command)
|