tracebit-python 0.1.0__tar.gz → 0.1.1__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.
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/PKG-INFO +6 -7
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/README.md +3 -6
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/pyproject.toml +7 -1
- tracebit_python-0.1.1/src/tracebit/__init__.py +1 -0
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/api.py +1 -1
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/cli.py +51 -29
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/PKG-INFO +6 -7
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/SOURCES.txt +5 -1
- tracebit_python-0.1.1/src/tracebit_python.egg-info/requires.txt +4 -0
- tracebit_python-0.1.1/tests/test_api.py +89 -0
- tracebit_python-0.1.1/tests/test_aws.py +125 -0
- tracebit_python-0.1.1/tests/test_config.py +60 -0
- tracebit_python-0.1.1/tests/test_state.py +129 -0
- tracebit_python-0.1.0/src/tracebit/__init__.py +0 -1
- tracebit_python-0.1.0/src/tracebit_python.egg-info/requires.txt +0 -1
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/LICENSE +0 -0
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/setup.cfg +0 -0
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/aws.py +0 -0
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/config.py +0 -0
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/state.py +0 -0
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/dependency_links.txt +0 -0
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/entry_points.txt +0 -0
- {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tracebit-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: CLI for managing Tracebit canary credentials on headless servers
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Repository, https://github.com/SiteRelEnby/tracebit-python
|
|
@@ -21,6 +21,8 @@ Requires-Python: >=3.8
|
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: requests>=2.20
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
26
|
Dynamic: license-file
|
|
25
27
|
|
|
26
28
|
# tracebit-python
|
|
@@ -96,11 +98,11 @@ see an alert on the Tracebit dashboard within a few minutes.
|
|
|
96
98
|
|
|
97
99
|
### 5. Keep credentials fresh
|
|
98
100
|
|
|
99
|
-
Canary credentials expire after ~
|
|
101
|
+
Canary credentials expire after ~12 hours. Set up a cron job to refresh them:
|
|
100
102
|
|
|
101
103
|
```bash
|
|
102
104
|
# crontab -e
|
|
103
|
-
0 */
|
|
105
|
+
0 */6 * * * /path/to/tracebit refresh --hours 4
|
|
104
106
|
```
|
|
105
107
|
|
|
106
108
|
## Commands
|
|
@@ -132,10 +134,7 @@ from cron.
|
|
|
132
134
|
|
|
133
135
|
| Option | Default | Description |
|
|
134
136
|
|--------|---------|-------------|
|
|
135
|
-
| `--hours` | `
|
|
136
|
-
|
|
137
|
-
With 24h credentials and a 12h cron, the default of 13 hours ensures every
|
|
138
|
-
cron run refreshes credentials.
|
|
137
|
+
| `--hours` | `2` | Refresh credentials expiring within this many hours |
|
|
139
138
|
|
|
140
139
|
### `tracebit trigger aws`
|
|
141
140
|
|
|
@@ -71,11 +71,11 @@ see an alert on the Tracebit dashboard within a few minutes.
|
|
|
71
71
|
|
|
72
72
|
### 5. Keep credentials fresh
|
|
73
73
|
|
|
74
|
-
Canary credentials expire after ~
|
|
74
|
+
Canary credentials expire after ~12 hours. Set up a cron job to refresh them:
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
77
|
# crontab -e
|
|
78
|
-
0 */
|
|
78
|
+
0 */6 * * * /path/to/tracebit refresh --hours 4
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
## Commands
|
|
@@ -107,10 +107,7 @@ from cron.
|
|
|
107
107
|
|
|
108
108
|
| Option | Default | Description |
|
|
109
109
|
|--------|---------|-------------|
|
|
110
|
-
| `--hours` | `
|
|
111
|
-
|
|
112
|
-
With 24h credentials and a 12h cron, the default of 13 hours ensures every
|
|
113
|
-
cron run refreshes credentials.
|
|
110
|
+
| `--hours` | `2` | Refresh credentials expiring within this many hours |
|
|
114
111
|
|
|
115
112
|
### `tracebit trigger aws`
|
|
116
113
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tracebit-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "CLI for managing Tracebit canary credentials on headless servers"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -28,6 +28,9 @@ classifiers = [
|
|
|
28
28
|
]
|
|
29
29
|
dependencies = ["requests>=2.20"]
|
|
30
30
|
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=7.0"]
|
|
33
|
+
|
|
31
34
|
[project.urls]
|
|
32
35
|
Repository = "https://github.com/SiteRelEnby/tracebit-python"
|
|
33
36
|
Issues = "https://github.com/SiteRelEnby/tracebit-python/issues"
|
|
@@ -37,3 +40,6 @@ tracebit = "tracebit.cli:main"
|
|
|
37
40
|
|
|
38
41
|
[tool.setuptools.packages.find]
|
|
39
42
|
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -18,6 +18,15 @@ from .state import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def _quiet(args):
|
|
22
|
+
return getattr(args, "quiet", False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _log(args, msg):
|
|
26
|
+
if not _quiet(args):
|
|
27
|
+
print(msg)
|
|
28
|
+
|
|
29
|
+
|
|
21
30
|
def _get_client(args):
|
|
22
31
|
token = getattr(args, "token", None) or load_token()
|
|
23
32
|
if not token:
|
|
@@ -60,7 +69,7 @@ def cmd_configure(args):
|
|
|
60
69
|
sys.exit(1)
|
|
61
70
|
|
|
62
71
|
save_token(token)
|
|
63
|
-
|
|
72
|
+
_log(args, "Token saved to ~/.config/tracebit/token")
|
|
64
73
|
|
|
65
74
|
|
|
66
75
|
def cmd_deploy_aws(args):
|
|
@@ -114,13 +123,13 @@ def cmd_deploy_aws(args):
|
|
|
114
123
|
for old in existing:
|
|
115
124
|
try:
|
|
116
125
|
client.remove_credentials(old["name"], "aws")
|
|
117
|
-
|
|
126
|
+
_log(args, f"Expired previous canary '{old['name']}' on Tracebit.")
|
|
118
127
|
except TracebitError:
|
|
119
128
|
pass
|
|
120
129
|
remove_credential(old["name"], "aws")
|
|
121
130
|
|
|
122
131
|
# issue credentials
|
|
123
|
-
|
|
132
|
+
_log(args, f"Issuing AWS canary credentials (name={name}, profile={profile})...")
|
|
124
133
|
try:
|
|
125
134
|
result = client.issue_credentials(
|
|
126
135
|
name=name, types=["aws"], source="tracebit-python",
|
|
@@ -143,12 +152,12 @@ def cmd_deploy_aws(args):
|
|
|
143
152
|
secret_access_key=aws["awsSecretAccessKey"],
|
|
144
153
|
session_token=aws["awsSessionToken"],
|
|
145
154
|
)
|
|
146
|
-
|
|
155
|
+
_log(args, f"Credentials written to ~/.aws/credentials [{profile}]")
|
|
147
156
|
|
|
148
157
|
# confirm deployment
|
|
149
158
|
try:
|
|
150
159
|
client.confirm_credentials(aws["awsConfirmationId"])
|
|
151
|
-
|
|
160
|
+
_log(args, "Deployment confirmed with Tracebit.")
|
|
152
161
|
except TracebitError as e:
|
|
153
162
|
print(f"Warning: Could not confirm deployment: {e}", file=sys.stderr)
|
|
154
163
|
|
|
@@ -170,7 +179,7 @@ def cmd_deploy_aws(args):
|
|
|
170
179
|
"access_key_id": aws["awsAccessKeyId"],
|
|
171
180
|
"expiration": aws["awsExpiration"],
|
|
172
181
|
}, indent=2))
|
|
173
|
-
|
|
182
|
+
elif not _quiet(args):
|
|
174
183
|
print(f"\nCanary deployed successfully!")
|
|
175
184
|
print(f" Profile: {profile}")
|
|
176
185
|
print(f" Region: {region}")
|
|
@@ -185,16 +194,23 @@ def cmd_refresh(args):
|
|
|
185
194
|
expiring = get_expiring_credentials(hours=hours)
|
|
186
195
|
|
|
187
196
|
if not expiring:
|
|
188
|
-
|
|
197
|
+
_log(args, "No credentials need refreshing.")
|
|
189
198
|
return
|
|
190
199
|
|
|
191
200
|
failures = 0
|
|
192
201
|
for cred in expiring:
|
|
193
202
|
if cred["type"] != "aws":
|
|
194
|
-
|
|
203
|
+
_log(args, f"Skipping non-AWS credential: {cred['name']}")
|
|
195
204
|
continue
|
|
196
205
|
|
|
197
|
-
|
|
206
|
+
exp = cred.get("expiration", "")
|
|
207
|
+
try:
|
|
208
|
+
exp_dt = datetime.fromisoformat(exp.replace("Z", "+00:00"))
|
|
209
|
+
remaining = (exp_dt - datetime.now(timezone.utc)).total_seconds() / 3600
|
|
210
|
+
_log(args, f"Refreshing AWS credential '{cred['name']}' "
|
|
211
|
+
f"(expires in {remaining:.1f}h, threshold {hours}h)...")
|
|
212
|
+
except (ValueError, TypeError):
|
|
213
|
+
_log(args, f"Refreshing AWS credential '{cred['name']}'...")
|
|
198
214
|
try:
|
|
199
215
|
result = client.issue_credentials(
|
|
200
216
|
name=cred["name"], types=["aws"], source="tracebit-python",
|
|
@@ -235,7 +251,7 @@ def cmd_refresh(args):
|
|
|
235
251
|
"confirmation_id": aws["awsConfirmationId"],
|
|
236
252
|
"labels": cred.get("labels", {}),
|
|
237
253
|
})
|
|
238
|
-
|
|
254
|
+
_log(args, f" Refreshed. New expiration: {aws['awsExpiration']}")
|
|
239
255
|
|
|
240
256
|
if failures:
|
|
241
257
|
print(f"\n{failures} credential(s) failed to refresh.", file=sys.stderr)
|
|
@@ -262,7 +278,7 @@ def cmd_trigger_aws(args):
|
|
|
262
278
|
cred = aws_creds[0]
|
|
263
279
|
|
|
264
280
|
profile = cred["profile"]
|
|
265
|
-
|
|
281
|
+
_log(args, f"Triggering canary credential (profile={profile})...")
|
|
266
282
|
|
|
267
283
|
try:
|
|
268
284
|
result = subprocess.run(
|
|
@@ -270,12 +286,12 @@ def cmd_trigger_aws(args):
|
|
|
270
286
|
capture_output=True, text=True, timeout=10,
|
|
271
287
|
)
|
|
272
288
|
if result.returncode == 0 and "arn:aws:sts::" in result.stdout:
|
|
273
|
-
|
|
274
|
-
|
|
289
|
+
_log(args, "Canary triggered successfully!")
|
|
290
|
+
_log(args, result.stdout.strip())
|
|
275
291
|
else:
|
|
276
|
-
|
|
292
|
+
_log(args, "Trigger command ran but output was unexpected:")
|
|
277
293
|
if result.stdout:
|
|
278
|
-
|
|
294
|
+
_log(args, result.stdout.strip())
|
|
279
295
|
if result.stderr:
|
|
280
296
|
print(result.stderr.strip(), file=sys.stderr)
|
|
281
297
|
except FileNotFoundError:
|
|
@@ -293,7 +309,7 @@ def cmd_show(args):
|
|
|
293
309
|
"""Display deployed canary credentials."""
|
|
294
310
|
creds = load_credentials()
|
|
295
311
|
if not creds:
|
|
296
|
-
|
|
312
|
+
_log(args, "No canary credentials deployed.")
|
|
297
313
|
return
|
|
298
314
|
|
|
299
315
|
if args.json_output:
|
|
@@ -314,15 +330,15 @@ def cmd_show(args):
|
|
|
314
330
|
except ValueError:
|
|
315
331
|
pass
|
|
316
332
|
|
|
317
|
-
|
|
318
|
-
|
|
333
|
+
_log(args, f" Name: {c['name']}")
|
|
334
|
+
_log(args, f" Type: {c['type']}")
|
|
319
335
|
if c["type"] == "aws":
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
336
|
+
_log(args, f" Profile: {c.get('profile', 'n/a')}")
|
|
337
|
+
_log(args, f" Region: {c.get('region', 'n/a')}")
|
|
338
|
+
_log(args, f" Expires: {exp_str}{status}")
|
|
323
339
|
if c.get("labels"):
|
|
324
|
-
|
|
325
|
-
|
|
340
|
+
_log(args, f" Labels: {c['labels']}")
|
|
341
|
+
_log(args, "")
|
|
326
342
|
|
|
327
343
|
|
|
328
344
|
def cmd_remove(args):
|
|
@@ -336,33 +352,39 @@ def cmd_remove(args):
|
|
|
336
352
|
matches = creds
|
|
337
353
|
|
|
338
354
|
if not matches:
|
|
339
|
-
|
|
355
|
+
_log(args, "No matching credentials found.")
|
|
340
356
|
return
|
|
341
357
|
|
|
342
358
|
for c in matches:
|
|
343
359
|
# expire server-side
|
|
344
360
|
try:
|
|
345
361
|
client.remove_credentials(c["name"], c["type"])
|
|
346
|
-
|
|
362
|
+
_log(args, f"Expired '{c['name']}' ({c['type']}) on Tracebit.")
|
|
347
363
|
except TracebitError as e:
|
|
348
364
|
print(f"Warning: Could not expire server-side: {e}", file=sys.stderr)
|
|
349
365
|
|
|
350
366
|
if c["type"] == "aws":
|
|
351
367
|
remove_aws_credentials(c.get("profile", ""))
|
|
352
|
-
|
|
368
|
+
_log(args, f"Removed AWS profile '{c.get('profile')}' from ~/.aws/")
|
|
353
369
|
remove_credential(c["name"], c["type"])
|
|
354
|
-
|
|
370
|
+
_log(args, f"Removed credential '{c['name']}' ({c['type']}) from state.")
|
|
355
371
|
|
|
356
372
|
|
|
357
373
|
def main():
|
|
374
|
+
from . import __version__
|
|
375
|
+
|
|
358
376
|
parser = argparse.ArgumentParser(
|
|
359
377
|
prog="tracebit",
|
|
360
378
|
description="Manage Tracebit canary credentials",
|
|
361
379
|
)
|
|
380
|
+
parser.add_argument("--version", action="version",
|
|
381
|
+
version=f"%(prog)s {__version__}")
|
|
362
382
|
parser.add_argument("--token", help="API token (overrides env/config)")
|
|
363
383
|
parser.add_argument("--base-url", help="Override Tracebit API base URL")
|
|
364
384
|
parser.add_argument("--json", dest="json_output", action="store_true",
|
|
365
385
|
help="Output in JSON format where supported")
|
|
386
|
+
parser.add_argument("-q", "--quiet", action="store_true",
|
|
387
|
+
help="Suppress informational output (errors still print to stderr)")
|
|
366
388
|
|
|
367
389
|
sub = parser.add_subparsers(dest="command")
|
|
368
390
|
|
|
@@ -387,8 +409,8 @@ def main():
|
|
|
387
409
|
|
|
388
410
|
# refresh
|
|
389
411
|
p_refresh = sub.add_parser("refresh", help="Refresh expiring credentials")
|
|
390
|
-
p_refresh.add_argument("--hours", type=float, default=
|
|
391
|
-
help="Refresh credentials expiring within this many hours (default:
|
|
412
|
+
p_refresh.add_argument("--hours", type=float, default=2,
|
|
413
|
+
help="Refresh credentials expiring within this many hours (default: 2)")
|
|
392
414
|
|
|
393
415
|
# trigger
|
|
394
416
|
p_trigger = sub.add_parser("trigger", help="Test-fire a canary credential")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tracebit-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: CLI for managing Tracebit canary credentials on headless servers
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Repository, https://github.com/SiteRelEnby/tracebit-python
|
|
@@ -21,6 +21,8 @@ Requires-Python: >=3.8
|
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: requests>=2.20
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
26
|
Dynamic: license-file
|
|
25
27
|
|
|
26
28
|
# tracebit-python
|
|
@@ -96,11 +98,11 @@ see an alert on the Tracebit dashboard within a few minutes.
|
|
|
96
98
|
|
|
97
99
|
### 5. Keep credentials fresh
|
|
98
100
|
|
|
99
|
-
Canary credentials expire after ~
|
|
101
|
+
Canary credentials expire after ~12 hours. Set up a cron job to refresh them:
|
|
100
102
|
|
|
101
103
|
```bash
|
|
102
104
|
# crontab -e
|
|
103
|
-
0 */
|
|
105
|
+
0 */6 * * * /path/to/tracebit refresh --hours 4
|
|
104
106
|
```
|
|
105
107
|
|
|
106
108
|
## Commands
|
|
@@ -132,10 +134,7 @@ from cron.
|
|
|
132
134
|
|
|
133
135
|
| Option | Default | Description |
|
|
134
136
|
|--------|---------|-------------|
|
|
135
|
-
| `--hours` | `
|
|
136
|
-
|
|
137
|
-
With 24h credentials and a 12h cron, the default of 13 hours ensures every
|
|
138
|
-
cron run refreshes credentials.
|
|
137
|
+
| `--hours` | `2` | Refresh credentials expiring within this many hours |
|
|
139
138
|
|
|
140
139
|
### `tracebit trigger aws`
|
|
141
140
|
|
|
@@ -12,4 +12,8 @@ src/tracebit_python.egg-info/SOURCES.txt
|
|
|
12
12
|
src/tracebit_python.egg-info/dependency_links.txt
|
|
13
13
|
src/tracebit_python.egg-info/entry_points.txt
|
|
14
14
|
src/tracebit_python.egg-info/requires.txt
|
|
15
|
-
src/tracebit_python.egg-info/top_level.txt
|
|
15
|
+
src/tracebit_python.egg-info/top_level.txt
|
|
16
|
+
tests/test_api.py
|
|
17
|
+
tests/test_aws.py
|
|
18
|
+
tests/test_config.py
|
|
19
|
+
tests/test_state.py
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from unittest.mock import MagicMock, patch
|
|
2
|
+
import pytest
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from tracebit.api import TracebitClient, TracebitError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def client():
|
|
10
|
+
return TracebitClient("test-token", base_url="https://example.com")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def mock_response(status_code=200, json_data=None, text=""):
|
|
14
|
+
resp = MagicMock()
|
|
15
|
+
resp.status_code = status_code
|
|
16
|
+
resp.json.return_value = json_data or {}
|
|
17
|
+
resp.text = text
|
|
18
|
+
resp.raise_for_status = MagicMock()
|
|
19
|
+
if status_code >= 400:
|
|
20
|
+
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
|
|
21
|
+
return resp
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_client_sets_auth_header(client):
|
|
25
|
+
assert client.session.headers["Authorization"] == "Bearer test-token"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_generate_metadata(client):
|
|
29
|
+
data = {"awsProfileName": "staging", "awsRegion": "us-east-1"}
|
|
30
|
+
with patch.object(client.session, "get", return_value=mock_response(json_data=data)) as m:
|
|
31
|
+
result = client.generate_metadata()
|
|
32
|
+
m.assert_called_once_with("https://example.com/api/v1/credentials/generate-metadata")
|
|
33
|
+
assert result["awsRegion"] == "us-east-1"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_issue_credentials_sends_correct_body(client):
|
|
37
|
+
resp_data = {"aws": {"awsAccessKeyId": "KEY", "awsConfirmationId": "abc"}}
|
|
38
|
+
with patch.object(client.session, "post", return_value=mock_response(json_data=resp_data)) as m:
|
|
39
|
+
client.issue_credentials(name="myserver", types=["aws"], labels={"env": "prod"})
|
|
40
|
+
body = m.call_args.kwargs["json"]
|
|
41
|
+
assert body["name"] == "myserver"
|
|
42
|
+
assert body["credentialTypes"] == ["aws"]
|
|
43
|
+
assert body["source"] == "tracebit-python"
|
|
44
|
+
assert {"name": "env", "value": "prod"} in body["labels"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_issue_credentials_no_labels(client):
|
|
48
|
+
with patch.object(client.session, "post", return_value=mock_response()) as m:
|
|
49
|
+
client.issue_credentials(name="myserver", types=["aws"])
|
|
50
|
+
body = m.call_args.kwargs["json"]
|
|
51
|
+
assert "labels" not in body
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_confirm_credentials(client):
|
|
55
|
+
with patch.object(client.session, "post", return_value=mock_response(status_code=204)) as m:
|
|
56
|
+
client.confirm_credentials("my-guid")
|
|
57
|
+
body = m.call_args.kwargs["json"]
|
|
58
|
+
assert body["id"] == "my-guid"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_confirm_credentials_404_raises(client):
|
|
62
|
+
with patch.object(client.session, "post", return_value=mock_response(status_code=404)):
|
|
63
|
+
with pytest.raises(TracebitError, match="not found"):
|
|
64
|
+
client.confirm_credentials("bad-guid")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_remove_credentials(client):
|
|
68
|
+
with patch.object(client.session, "post", return_value=mock_response(status_code=204)) as m:
|
|
69
|
+
client.remove_credentials("myserver", "aws")
|
|
70
|
+
body = m.call_args.kwargs["json"]
|
|
71
|
+
assert body["name"] == "myserver"
|
|
72
|
+
assert body["type"] == "aws"
|
|
73
|
+
m.assert_called_once_with(
|
|
74
|
+
"https://example.com/api/_internal/v1/cli/remove",
|
|
75
|
+
json=body,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_401_raises_tracebit_error(client):
|
|
80
|
+
with patch.object(client.session, "get", return_value=mock_response(status_code=401)):
|
|
81
|
+
with pytest.raises(TracebitError, match="Authentication failed"):
|
|
82
|
+
client.generate_metadata()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_400_raises_tracebit_error(client):
|
|
86
|
+
with patch.object(client.session, "post",
|
|
87
|
+
return_value=mock_response(status_code=400, text="bad request")):
|
|
88
|
+
with pytest.raises(TracebitError, match="bad request"):
|
|
89
|
+
client.issue_credentials("x", ["aws"])
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import pytest
|
|
3
|
+
from tracebit import aws as aws_mod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture(autouse=True)
|
|
7
|
+
def tmp_aws(tmp_path, monkeypatch):
|
|
8
|
+
"""Redirect ~/.aws/ to a temp directory for every test."""
|
|
9
|
+
aws_dir = tmp_path / ".aws"
|
|
10
|
+
monkeypatch.setattr(aws_mod, "AWS_DIR", aws_dir)
|
|
11
|
+
monkeypatch.setattr(aws_mod, "CREDENTIALS_FILE", aws_dir / "credentials")
|
|
12
|
+
monkeypatch.setattr(aws_mod, "CONFIG_FILE", aws_dir / "config")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def deploy(**kwargs):
|
|
16
|
+
defaults = dict(
|
|
17
|
+
profile="staging",
|
|
18
|
+
region="us-east-1",
|
|
19
|
+
access_key_id="AKIAIOSFODNN7EXAMPLE",
|
|
20
|
+
secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
21
|
+
session_token="AQoXnyc//token",
|
|
22
|
+
)
|
|
23
|
+
defaults.update(kwargs)
|
|
24
|
+
aws_mod.deploy_aws_credentials(**defaults)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_deploy_creates_credentials_file():
|
|
28
|
+
deploy()
|
|
29
|
+
assert aws_mod.CREDENTIALS_FILE.exists()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_deploy_writes_profile():
|
|
33
|
+
deploy(profile="myprofile", access_key_id="MYKEY")
|
|
34
|
+
creds = configparser.ConfigParser()
|
|
35
|
+
creds.read(str(aws_mod.CREDENTIALS_FILE))
|
|
36
|
+
assert creds.has_section("myprofile")
|
|
37
|
+
assert creds.get("myprofile", "aws_access_key_id") == "MYKEY"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_deploy_writes_session_token():
|
|
41
|
+
deploy(session_token="mysessiontoken")
|
|
42
|
+
creds = configparser.ConfigParser()
|
|
43
|
+
creds.read(str(aws_mod.CREDENTIALS_FILE))
|
|
44
|
+
assert creds.get("staging", "aws_session_token") == "mysessiontoken"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_deploy_writes_config():
|
|
48
|
+
deploy(profile="myprofile", region="eu-west-1")
|
|
49
|
+
cfg = configparser.ConfigParser()
|
|
50
|
+
cfg.read(str(aws_mod.CONFIG_FILE))
|
|
51
|
+
assert cfg.has_section("profile myprofile")
|
|
52
|
+
assert cfg.get("profile myprofile", "region") == "eu-west-1"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_deploy_file_permissions():
|
|
56
|
+
deploy()
|
|
57
|
+
assert oct(aws_mod.CREDENTIALS_FILE.stat().st_mode)[-3:] == "600"
|
|
58
|
+
assert oct(aws_mod.CONFIG_FILE.stat().st_mode)[-3:] == "600"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_deploy_dir_permissions():
|
|
62
|
+
deploy()
|
|
63
|
+
assert oct(aws_mod.AWS_DIR.stat().st_mode)[-3:] == "700"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_deploy_overwrites_existing_profile():
|
|
67
|
+
deploy(access_key_id="OLDKEY")
|
|
68
|
+
deploy(access_key_id="NEWKEY")
|
|
69
|
+
creds = configparser.ConfigParser()
|
|
70
|
+
creds.read(str(aws_mod.CREDENTIALS_FILE))
|
|
71
|
+
assert creds.get("staging", "aws_access_key_id") == "NEWKEY"
|
|
72
|
+
# should only be one section
|
|
73
|
+
assert len(creds.sections()) == 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_deploy_preserves_other_profiles():
|
|
77
|
+
deploy(profile="staging")
|
|
78
|
+
deploy(profile="production")
|
|
79
|
+
creds = configparser.ConfigParser()
|
|
80
|
+
creds.read(str(aws_mod.CREDENTIALS_FILE))
|
|
81
|
+
assert creds.has_section("staging")
|
|
82
|
+
assert creds.has_section("production")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_profile_exists_true():
|
|
86
|
+
deploy(profile="staging")
|
|
87
|
+
assert aws_mod.profile_exists("staging") is True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_profile_exists_false():
|
|
91
|
+
assert aws_mod.profile_exists("nonexistent") is False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_remove_credentials():
|
|
95
|
+
deploy(profile="staging")
|
|
96
|
+
aws_mod.remove_aws_credentials("staging")
|
|
97
|
+
creds = configparser.ConfigParser()
|
|
98
|
+
creds.read(str(aws_mod.CREDENTIALS_FILE))
|
|
99
|
+
assert not creds.has_section("staging")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_remove_leaves_other_profiles():
|
|
103
|
+
deploy(profile="staging")
|
|
104
|
+
deploy(profile="production")
|
|
105
|
+
aws_mod.remove_aws_credentials("staging")
|
|
106
|
+
creds = configparser.ConfigParser()
|
|
107
|
+
creds.read(str(aws_mod.CREDENTIALS_FILE))
|
|
108
|
+
assert not creds.has_section("staging")
|
|
109
|
+
assert creds.has_section("production")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_remove_nonexistent_profile_is_safe():
|
|
113
|
+
deploy(profile="staging")
|
|
114
|
+
aws_mod.remove_aws_credentials("nonexistent") # should not raise
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_get_aws_credentials():
|
|
118
|
+
deploy(profile="staging", access_key_id="MYKEY", secret_access_key="MYSECRET")
|
|
119
|
+
cred = aws_mod.get_aws_credentials("staging")
|
|
120
|
+
assert cred["aws_access_key_id"] == "MYKEY"
|
|
121
|
+
assert cred["aws_secret_access_key"] == "MYSECRET"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_get_aws_credentials_missing():
|
|
125
|
+
assert aws_mod.get_aws_credentials("missing") is None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from tracebit import config as config_mod
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@pytest.fixture(autouse=True)
|
|
6
|
+
def tmp_config(tmp_path, monkeypatch):
|
|
7
|
+
monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
|
|
8
|
+
monkeypatch.setattr(config_mod, "TOKEN_FILE", tmp_path / "token")
|
|
9
|
+
monkeypatch.delenv("TRACEBIT_API_TOKEN", raising=False)
|
|
10
|
+
monkeypatch.delenv("TRACEBIT_URL", raising=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_load_token_from_env(monkeypatch):
|
|
14
|
+
monkeypatch.setenv("TRACEBIT_API_TOKEN", "my-token")
|
|
15
|
+
assert config_mod.load_token() == "my-token"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_load_token_strips_whitespace(monkeypatch):
|
|
19
|
+
monkeypatch.setenv("TRACEBIT_API_TOKEN", " my-token ")
|
|
20
|
+
assert config_mod.load_token() == "my-token"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_load_token_from_file(tmp_path):
|
|
24
|
+
token_file = tmp_path / "token"
|
|
25
|
+
token_file.write_text("file-token\n")
|
|
26
|
+
config_mod.TOKEN_FILE.__class__ # already patched via fixture
|
|
27
|
+
import tracebit.config as cm
|
|
28
|
+
cm.TOKEN_FILE = token_file
|
|
29
|
+
assert cm.load_token() == "file-token"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_load_token_env_takes_priority(monkeypatch, tmp_path):
|
|
33
|
+
token_file = tmp_path / "token"
|
|
34
|
+
token_file.write_text("file-token\n")
|
|
35
|
+
import tracebit.config as cm
|
|
36
|
+
cm.TOKEN_FILE = token_file
|
|
37
|
+
monkeypatch.setenv("TRACEBIT_API_TOKEN", "env-token")
|
|
38
|
+
assert cm.load_token() == "env-token"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_load_token_missing():
|
|
42
|
+
assert config_mod.load_token() is None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_save_token(tmp_path):
|
|
46
|
+
import tracebit.config as cm
|
|
47
|
+
cm.CONFIG_DIR = tmp_path
|
|
48
|
+
cm.TOKEN_FILE = tmp_path / "token"
|
|
49
|
+
cm.save_token("saved-token")
|
|
50
|
+
assert cm.TOKEN_FILE.read_text().strip() == "saved-token"
|
|
51
|
+
assert oct(cm.TOKEN_FILE.stat().st_mode)[-3:] == "600"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_get_base_url_default():
|
|
55
|
+
assert config_mod.get_base_url() == "https://community.tracebit.com"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_get_base_url_from_env(monkeypatch):
|
|
59
|
+
monkeypatch.setenv("TRACEBIT_URL", "https://my-tracebit.example.com/")
|
|
60
|
+
assert config_mod.get_base_url() == "https://my-tracebit.example.com"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone, timedelta
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from tracebit.state import (
|
|
8
|
+
save_credential,
|
|
9
|
+
load_credentials,
|
|
10
|
+
remove_credential,
|
|
11
|
+
get_credential,
|
|
12
|
+
get_expiring_credentials,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def tmp_state(tmp_path, monkeypatch):
|
|
18
|
+
"""Redirect state file to a temp directory for every test."""
|
|
19
|
+
import tracebit.state as state_mod
|
|
20
|
+
monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path)
|
|
21
|
+
monkeypatch.setattr(state_mod, "STATE_FILE", tmp_path / "state.json")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_cred(**kwargs):
|
|
25
|
+
base = {
|
|
26
|
+
"name": "test-server",
|
|
27
|
+
"type": "aws",
|
|
28
|
+
"profile": "staging",
|
|
29
|
+
"region": "us-east-1",
|
|
30
|
+
"expiration": "2099-01-01T00:00:00Z",
|
|
31
|
+
"confirmation_id": "abc-123",
|
|
32
|
+
"labels": {},
|
|
33
|
+
}
|
|
34
|
+
base.update(kwargs)
|
|
35
|
+
return base
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_save_and_load():
|
|
39
|
+
cred = make_cred()
|
|
40
|
+
save_credential(cred)
|
|
41
|
+
creds = load_credentials()
|
|
42
|
+
assert len(creds) == 1
|
|
43
|
+
assert creds[0]["name"] == "test-server"
|
|
44
|
+
assert creds[0]["profile"] == "staging"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_save_replaces_same_name_and_type():
|
|
48
|
+
save_credential(make_cred(profile="old"))
|
|
49
|
+
save_credential(make_cred(profile="new"))
|
|
50
|
+
creds = load_credentials()
|
|
51
|
+
assert len(creds) == 1
|
|
52
|
+
assert creds[0]["profile"] == "new"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_save_keeps_different_type():
|
|
56
|
+
save_credential(make_cred(type="aws"))
|
|
57
|
+
save_credential(make_cred(type="ssh"))
|
|
58
|
+
assert len(load_credentials()) == 2
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_remove_by_name_and_type():
|
|
62
|
+
save_credential(make_cred())
|
|
63
|
+
remove_credential("test-server", "aws")
|
|
64
|
+
assert load_credentials() == []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_remove_by_name_only():
|
|
68
|
+
save_credential(make_cred(type="aws"))
|
|
69
|
+
save_credential(make_cred(type="ssh"))
|
|
70
|
+
remove_credential("test-server")
|
|
71
|
+
assert load_credentials() == []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_remove_leaves_others():
|
|
75
|
+
save_credential(make_cred(name="server-a"))
|
|
76
|
+
save_credential(make_cred(name="server-b"))
|
|
77
|
+
remove_credential("server-a", "aws")
|
|
78
|
+
creds = load_credentials()
|
|
79
|
+
assert len(creds) == 1
|
|
80
|
+
assert creds[0]["name"] == "server-b"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_get_credential():
|
|
84
|
+
save_credential(make_cred())
|
|
85
|
+
c = get_credential("test-server", "aws")
|
|
86
|
+
assert c is not None
|
|
87
|
+
assert c["profile"] == "staging"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_get_credential_not_found():
|
|
91
|
+
assert get_credential("missing", "aws") is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_load_empty_state():
|
|
95
|
+
assert load_credentials() == []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_state_file_permissions(tmp_path, monkeypatch):
|
|
99
|
+
import tracebit.state as state_mod
|
|
100
|
+
state_file = tmp_path / "state.json"
|
|
101
|
+
monkeypatch.setattr(state_mod, "STATE_FILE", state_file)
|
|
102
|
+
save_credential(make_cred())
|
|
103
|
+
assert oct(state_file.stat().st_mode)[-3:] == "600"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_get_expiring_soon():
|
|
107
|
+
soon = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime(
|
|
108
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
109
|
+
)
|
|
110
|
+
save_credential(make_cred(expiration=soon))
|
|
111
|
+
expiring = get_expiring_credentials(hours=2)
|
|
112
|
+
assert len(expiring) == 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_get_not_expiring():
|
|
116
|
+
far = (datetime.now(timezone.utc) + timedelta(hours=10)).strftime(
|
|
117
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
118
|
+
)
|
|
119
|
+
save_credential(make_cred(expiration=far))
|
|
120
|
+
assert get_expiring_credentials(hours=2) == []
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_get_already_expired():
|
|
124
|
+
past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime(
|
|
125
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
126
|
+
)
|
|
127
|
+
save_credential(make_cred(expiration=past))
|
|
128
|
+
expiring = get_expiring_credentials(hours=2)
|
|
129
|
+
assert len(expiring) == 1
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
requests>=2.20
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|