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.
Files changed (37) hide show
  1. {toast_cli-4.0.5 → toast_cli-4.1.2}/ARCHITECTURE.md +24 -10
  2. {toast_cli-4.0.5 → toast_cli-4.1.2}/PKG-INFO +49 -15
  3. {toast_cli-4.0.5 → toast_cli-4.1.2}/README.md +48 -14
  4. toast_cli-4.1.2/VERSION +1 -0
  5. toast_cli-4.1.2/tests/test_storage.py +379 -0
  6. toast_cli-4.1.2/toast/plugins/dot_plugin.py +21 -0
  7. toast_cli-4.1.2/toast/plugins/prompt_plugin.py +21 -0
  8. toast_cli-4.1.2/toast/plugins/storage.py +832 -0
  9. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/utils.py +22 -11
  10. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/PKG-INFO +49 -15
  11. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/SOURCES.txt +2 -0
  12. toast_cli-4.0.5/VERSION +0 -1
  13. toast_cli-4.0.5/toast/plugins/dot_plugin.py +0 -405
  14. toast_cli-4.0.5/toast/plugins/prompt_plugin.py +0 -403
  15. {toast_cli-4.0.5 → toast_cli-4.1.2}/.mergify.yml +0 -0
  16. {toast_cli-4.0.5 → toast_cli-4.1.2}/LICENSE +0 -0
  17. {toast_cli-4.0.5 → toast_cli-4.1.2}/MANIFEST.in +0 -0
  18. {toast_cli-4.0.5 → toast_cli-4.1.2}/pyproject.toml +0 -0
  19. {toast_cli-4.0.5 → toast_cli-4.1.2}/setup.cfg +0 -0
  20. {toast_cli-4.0.5 → toast_cli-4.1.2}/setup.py +0 -0
  21. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/__init__.py +0 -0
  22. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/__main__.py +0 -0
  23. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/helpers.py +0 -0
  24. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/__init__.py +0 -0
  25. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/am_plugin.py +0 -0
  26. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/base_plugin.py +0 -0
  27. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/cdw_plugin.py +0 -0
  28. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/ctx_plugin.py +0 -0
  29. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/env_plugin.py +0 -0
  30. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/git_plugin.py +0 -0
  31. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/region_plugin.py +0 -0
  32. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast/plugins/ssm_plugin.py +0 -0
  33. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/dependency_links.txt +0 -0
  34. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/entry_points.txt +0 -0
  35. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/not-zip-safe +0 -0
  36. {toast_cli-4.0.5 → toast_cli-4.1.2}/toast_cli.egg-info/requires.txt +0 -0
  37. {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 AWS SSM integration |
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 AWS SSM integration |
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 with AWS SSM Parameter Store
131
- - Default behavior: `sync` (compare local/remote, show diff, choose upload/download)
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
- - Uploads/downloads environment variables as SecureString
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 with AWS SSM Parameter Store
139
- - Default behavior: `sync` (compare local/remote, show diff, choose upload/download)
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
- - Uploads/downloads prompt files as SecureString
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.0.5
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**: AWS SSM SecureString storage for sensitive files
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 SSM, choose action (default: sync)
133
- toast dot up # Upload .env.local to SSM
134
- toast dot down # Download .env.local from SSM (alias: dn)
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 SSM, choose action (default: sync)
139
- toast prompt up # Upload .prompt.md to SSM
140
- toast prompt down # Download .prompt.md from SSM (alias: dn)
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
- ### AWS SSM Storage Paths
221
+ ### Env-store (S3) Storage Paths
222
222
 
223
- Toast-cli stores files in AWS SSM Parameter Store with the following structure:
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
- /toast/local/{org}/{project}/env-local # .env.local files
227
- /toast/local/{org}/{project}/prompt-md # .prompt.md files
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
- Files are stored as SecureString type for encryption at rest.
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**: AWS SSM SecureString storage for sensitive files
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 SSM, choose action (default: sync)
98
- toast dot up # Upload .env.local to SSM
99
- toast dot down # Download .env.local from SSM (alias: dn)
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 SSM, choose action (default: sync)
104
- toast prompt up # Upload .prompt.md to SSM
105
- toast prompt down # Download .prompt.md from SSM (alias: dn)
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
- ### AWS SSM Storage Paths
186
+ ### Env-store (S3) Storage Paths
187
187
 
188
- Toast-cli stores files in AWS SSM Parameter Store with the following structure:
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
- /toast/local/{org}/{project}/env-local # .env.local files
192
- /toast/local/{org}/{project}/prompt-md # .prompt.md files
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
- Files are stored as SecureString type for encryption at rest.
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
 
@@ -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)