crunr 0.1.0__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.
- crunr-0.1.0/PKG-INFO +153 -0
- crunr-0.1.0/README.md +124 -0
- crunr-0.1.0/crunr/__init__.py +1 -0
- crunr-0.1.0/crunr/auth.py +230 -0
- crunr-0.1.0/crunr/cli.py +538 -0
- crunr-0.1.0/crunr/config.py +191 -0
- crunr-0.1.0/crunr/console.py +228 -0
- crunr-0.1.0/crunr/jobs.py +97 -0
- crunr-0.1.0/crunr/provision.py +517 -0
- crunr-0.1.0/crunr/remote.py +503 -0
- crunr-0.1.0/crunr.egg-info/PKG-INFO +153 -0
- crunr-0.1.0/crunr.egg-info/SOURCES.txt +20 -0
- crunr-0.1.0/crunr.egg-info/dependency_links.txt +1 -0
- crunr-0.1.0/crunr.egg-info/entry_points.txt +2 -0
- crunr-0.1.0/crunr.egg-info/requires.txt +11 -0
- crunr-0.1.0/crunr.egg-info/top_level.txt +1 -0
- crunr-0.1.0/pyproject.toml +63 -0
- crunr-0.1.0/setup.cfg +4 -0
- crunr-0.1.0/tests/test_cli.py +95 -0
- crunr-0.1.0/tests/test_config.py +47 -0
- crunr-0.1.0/tests/test_jobs.py +78 -0
- crunr-0.1.0/tests/test_remote.py +39 -0
crunr-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crunr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run any compute job on AWS with a single command
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: aws,ec2,cloud,gpu,machine-learning,spot
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: boto3>=1.34
|
|
20
|
+
Requires-Dist: botocore>=1.34
|
|
21
|
+
Requires-Dist: rich>=13.7
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
25
|
+
Requires-Dist: moto[ec2,sts]>=5.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.3; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
28
|
+
Requires-Dist: boto3-stubs[ec2,sts]>=1.34; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# crunr
|
|
31
|
+
|
|
32
|
+
Run any compute job on AWS EC2 with a single command — no DevOps required.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
crunr run train.py --gpu
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
crunr provisions an instance, uploads your code, streams live output, downloads results, and terminates the instance automatically. Zero idle cost.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install crunr
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.10+ and an AWS account.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# 1. Configure AWS credentials (one-time)
|
|
52
|
+
crunr auth
|
|
53
|
+
|
|
54
|
+
# 2. Run a script on the cheapest CPU instance
|
|
55
|
+
crunr run script.py
|
|
56
|
+
|
|
57
|
+
# 3. Run on a GPU instance
|
|
58
|
+
crunr run train.py --gpu
|
|
59
|
+
|
|
60
|
+
# 4. Specify minimum VRAM
|
|
61
|
+
crunr run train.py --gpu --memory 24
|
|
62
|
+
|
|
63
|
+
# 5. Pass environment variables
|
|
64
|
+
crunr run train.py --env EPOCHS=50 --env LR=0.001
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## How it works
|
|
68
|
+
|
|
69
|
+
1. **Provision** — selects the cheapest matching spot instance, falls back to on-demand
|
|
70
|
+
2. **Sync** — uploads your local directory to the instance (rsync or scp+tar)
|
|
71
|
+
3. **Execute** — runs your command with live log streaming
|
|
72
|
+
4. **Collect** — downloads any `outputs/` directory back to your machine
|
|
73
|
+
5. **Terminate** — instance is always destroyed, even on Ctrl+C or crash
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
| Command | Description |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `crunr auth` | Configure AWS credentials |
|
|
80
|
+
| `crunr run <script>` | Run a job on EC2 |
|
|
81
|
+
| `crunr jobs` | Show job history |
|
|
82
|
+
| `crunr ps` | List running instances |
|
|
83
|
+
| `crunr clean` | Terminate all orphaned instances |
|
|
84
|
+
|
|
85
|
+
## `crunr run` options
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
--gpu Request a GPU instance (cheapest available)
|
|
89
|
+
--memory GB Minimum GPU VRAM or RAM in GB
|
|
90
|
+
--instance TYPE Exact EC2 instance type (e.g. g5.xlarge)
|
|
91
|
+
--disk GB Root EBS volume size (default: 8 GB CPU, 100 GB GPU)
|
|
92
|
+
--env KEY=VALUE Environment variable passed to the job (repeatable)
|
|
93
|
+
--dir PATH Local directory to sync (default: current directory)
|
|
94
|
+
--on-demand Use on-demand pricing instead of spot
|
|
95
|
+
--profile NAME AWS credential profile
|
|
96
|
+
--region REGION Override AWS region
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Saving outputs
|
|
100
|
+
|
|
101
|
+
Your script can write files to an `outputs/` directory. crunr automatically downloads it after the job finishes:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
import os
|
|
105
|
+
os.makedirs("outputs", exist_ok=True)
|
|
106
|
+
with open("outputs/result.txt", "w") as f:
|
|
107
|
+
f.write("done")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## AWS IAM permissions
|
|
111
|
+
|
|
112
|
+
The IAM user needs these permissions:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"Version": "2012-10-17",
|
|
117
|
+
"Statement": [
|
|
118
|
+
{
|
|
119
|
+
"Effect": "Allow",
|
|
120
|
+
"Action": [
|
|
121
|
+
"ec2:RunInstances", "ec2:TerminateInstances", "ec2:DescribeInstances",
|
|
122
|
+
"ec2:DescribeImages", "ec2:DescribeSpotPriceHistory",
|
|
123
|
+
"ec2:CreateKeyPair", "ec2:DeleteKeyPair", "ec2:DescribeKeyPairs",
|
|
124
|
+
"ec2:CreateSecurityGroup", "ec2:DescribeSecurityGroups",
|
|
125
|
+
"ec2:AuthorizeSecurityGroupIngress",
|
|
126
|
+
"ec2:CreateTags",
|
|
127
|
+
"sts:GetCallerIdentity"
|
|
128
|
+
],
|
|
129
|
+
"Resource": "*"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"Effect": "Allow",
|
|
133
|
+
"Action": "iam:CreateServiceLinkedRole",
|
|
134
|
+
"Resource": "arn:aws:iam::*:role/aws-service-role/spot.amazonaws.com/*"
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Cost
|
|
141
|
+
|
|
142
|
+
You only pay for the time the instance runs. Spot instances are typically **60–90% cheaper** than on-demand.
|
|
143
|
+
|
|
144
|
+
Example costs (spot, us-east-1):
|
|
145
|
+
- `t3.micro` CPU job — ~$0.003/hr
|
|
146
|
+
- `g4dn.xlarge` GPU (T4) — ~$0.16/hr
|
|
147
|
+
- `g5.xlarge` GPU (A10G) — ~$0.34/hr
|
|
148
|
+
|
|
149
|
+
Data transfer into EC2 is free. Transfer out costs ~$0.09/GB.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
crunr-0.1.0/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# crunr
|
|
2
|
+
|
|
3
|
+
Run any compute job on AWS EC2 with a single command — no DevOps required.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
crunr run train.py --gpu
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
crunr provisions an instance, uploads your code, streams live output, downloads results, and terminates the instance automatically. Zero idle cost.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install crunr
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.10+ and an AWS account.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 1. Configure AWS credentials (one-time)
|
|
23
|
+
crunr auth
|
|
24
|
+
|
|
25
|
+
# 2. Run a script on the cheapest CPU instance
|
|
26
|
+
crunr run script.py
|
|
27
|
+
|
|
28
|
+
# 3. Run on a GPU instance
|
|
29
|
+
crunr run train.py --gpu
|
|
30
|
+
|
|
31
|
+
# 4. Specify minimum VRAM
|
|
32
|
+
crunr run train.py --gpu --memory 24
|
|
33
|
+
|
|
34
|
+
# 5. Pass environment variables
|
|
35
|
+
crunr run train.py --env EPOCHS=50 --env LR=0.001
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
1. **Provision** — selects the cheapest matching spot instance, falls back to on-demand
|
|
41
|
+
2. **Sync** — uploads your local directory to the instance (rsync or scp+tar)
|
|
42
|
+
3. **Execute** — runs your command with live log streaming
|
|
43
|
+
4. **Collect** — downloads any `outputs/` directory back to your machine
|
|
44
|
+
5. **Terminate** — instance is always destroyed, even on Ctrl+C or crash
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
| Command | Description |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `crunr auth` | Configure AWS credentials |
|
|
51
|
+
| `crunr run <script>` | Run a job on EC2 |
|
|
52
|
+
| `crunr jobs` | Show job history |
|
|
53
|
+
| `crunr ps` | List running instances |
|
|
54
|
+
| `crunr clean` | Terminate all orphaned instances |
|
|
55
|
+
|
|
56
|
+
## `crunr run` options
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
--gpu Request a GPU instance (cheapest available)
|
|
60
|
+
--memory GB Minimum GPU VRAM or RAM in GB
|
|
61
|
+
--instance TYPE Exact EC2 instance type (e.g. g5.xlarge)
|
|
62
|
+
--disk GB Root EBS volume size (default: 8 GB CPU, 100 GB GPU)
|
|
63
|
+
--env KEY=VALUE Environment variable passed to the job (repeatable)
|
|
64
|
+
--dir PATH Local directory to sync (default: current directory)
|
|
65
|
+
--on-demand Use on-demand pricing instead of spot
|
|
66
|
+
--profile NAME AWS credential profile
|
|
67
|
+
--region REGION Override AWS region
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Saving outputs
|
|
71
|
+
|
|
72
|
+
Your script can write files to an `outputs/` directory. crunr automatically downloads it after the job finishes:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import os
|
|
76
|
+
os.makedirs("outputs", exist_ok=True)
|
|
77
|
+
with open("outputs/result.txt", "w") as f:
|
|
78
|
+
f.write("done")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## AWS IAM permissions
|
|
82
|
+
|
|
83
|
+
The IAM user needs these permissions:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"Version": "2012-10-17",
|
|
88
|
+
"Statement": [
|
|
89
|
+
{
|
|
90
|
+
"Effect": "Allow",
|
|
91
|
+
"Action": [
|
|
92
|
+
"ec2:RunInstances", "ec2:TerminateInstances", "ec2:DescribeInstances",
|
|
93
|
+
"ec2:DescribeImages", "ec2:DescribeSpotPriceHistory",
|
|
94
|
+
"ec2:CreateKeyPair", "ec2:DeleteKeyPair", "ec2:DescribeKeyPairs",
|
|
95
|
+
"ec2:CreateSecurityGroup", "ec2:DescribeSecurityGroups",
|
|
96
|
+
"ec2:AuthorizeSecurityGroupIngress",
|
|
97
|
+
"ec2:CreateTags",
|
|
98
|
+
"sts:GetCallerIdentity"
|
|
99
|
+
],
|
|
100
|
+
"Resource": "*"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"Effect": "Allow",
|
|
104
|
+
"Action": "iam:CreateServiceLinkedRole",
|
|
105
|
+
"Resource": "arn:aws:iam::*:role/aws-service-role/spot.amazonaws.com/*"
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Cost
|
|
112
|
+
|
|
113
|
+
You only pay for the time the instance runs. Spot instances are typically **60–90% cheaper** than on-demand.
|
|
114
|
+
|
|
115
|
+
Example costs (spot, us-east-1):
|
|
116
|
+
- `t3.micro` CPU job — ~$0.003/hr
|
|
117
|
+
- `g4dn.xlarge` GPU (T4) — ~$0.16/hr
|
|
118
|
+
- `g5.xlarge` GPU (A10G) — ~$0.34/hr
|
|
119
|
+
|
|
120
|
+
Data transfer into EC2 is free. Transfer out costs ~$0.09/GB.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""AWS credential management: interactive wizard, list, verify, set-default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
import botocore.exceptions
|
|
14
|
+
|
|
15
|
+
from . import console
|
|
16
|
+
from .config import REGIONS
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# File paths
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
AWS_DIR = Path.home() / ".aws"
|
|
23
|
+
CREDS_FILE = AWS_DIR / "credentials"
|
|
24
|
+
CONFIG_FILE = AWS_DIR / "config"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Public API
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def cmd_auth(profile_name: str = "default") -> None:
|
|
32
|
+
"""Interactive wizard to add/update an AWS profile."""
|
|
33
|
+
console.header(
|
|
34
|
+
"crunr auth",
|
|
35
|
+
f"Configure AWS credentials → profile: [bold]{profile_name}[/bold]",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
console.step("Enter your AWS credentials")
|
|
39
|
+
console.info(" Find these at: AWS Console → IAM → Users → Security credentials")
|
|
40
|
+
|
|
41
|
+
key_id = console.prompt("AWS Access Key ID")
|
|
42
|
+
if not key_id:
|
|
43
|
+
console.error("Access Key ID cannot be empty.")
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
secret = console.prompt_secret("AWS Secret Access Key")
|
|
47
|
+
if not secret:
|
|
48
|
+
console.error("Secret Access Key cannot be empty.")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
region = console.choose("Select a region", REGIONS)
|
|
52
|
+
|
|
53
|
+
_ensure_aws_dir()
|
|
54
|
+
_write_credentials(profile_name, key_id, secret)
|
|
55
|
+
_write_config(profile_name, region)
|
|
56
|
+
|
|
57
|
+
console.ok(f"Credentials written to {CREDS_FILE}")
|
|
58
|
+
|
|
59
|
+
with console.spinner("Verifying credentials with AWS STS…"):
|
|
60
|
+
ok, identity, err = _verify(profile_name)
|
|
61
|
+
|
|
62
|
+
if ok:
|
|
63
|
+
console.ok(f"Authenticated as: [bold]{identity}[/bold]")
|
|
64
|
+
if profile_name != "default":
|
|
65
|
+
console.info(f" Use with: crunr run script.py --profile {profile_name}")
|
|
66
|
+
else:
|
|
67
|
+
console.error(f"Credential verification failed: {err}")
|
|
68
|
+
console.warn("Credentials were saved but may be invalid. Double-check them with:")
|
|
69
|
+
console.info(f" crunr auth --verify {profile_name}")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_list_profiles() -> None:
|
|
74
|
+
"""Print a table of all configured profiles."""
|
|
75
|
+
profiles = _read_all_profiles()
|
|
76
|
+
default = _read_default_profile_name()
|
|
77
|
+
for p in profiles:
|
|
78
|
+
p["is_default"] = (p["name"] == default)
|
|
79
|
+
console.profiles_table(profiles)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_verify(profile_name: str) -> None:
|
|
83
|
+
"""Test that a profile's credentials still work."""
|
|
84
|
+
with console.spinner(f"Verifying profile [bold]{profile_name}[/bold]…"):
|
|
85
|
+
ok, identity, err = _verify(profile_name)
|
|
86
|
+
if ok:
|
|
87
|
+
console.ok(f"Profile [bold]{profile_name}[/bold] is valid — authenticated as [bold]{identity}[/bold]")
|
|
88
|
+
else:
|
|
89
|
+
console.error(f"Profile [bold]{profile_name}[/bold] failed: {err}")
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_set_default(profile_name: str) -> None:
|
|
94
|
+
"""Copy an existing profile's settings into the [default] slot."""
|
|
95
|
+
profiles = {p["name"]: p for p in _read_all_profiles()}
|
|
96
|
+
if profile_name not in profiles:
|
|
97
|
+
console.error(f"Profile '{profile_name}' not found. Run 'crunr auth --list' to see available profiles.")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
p = profiles[profile_name]
|
|
101
|
+
_ensure_aws_dir()
|
|
102
|
+
|
|
103
|
+
# Read key and secret from the existing profile entry
|
|
104
|
+
creds = _read_configparser(CREDS_FILE)
|
|
105
|
+
section = profile_name if profile_name != "default" else "default"
|
|
106
|
+
if not creds.has_section(section):
|
|
107
|
+
console.error(f"No credentials found for profile '{profile_name}'.")
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
key_id = creds.get(section, "aws_access_key_id", fallback="")
|
|
111
|
+
secret = creds.get(section, "aws_secret_access_key", fallback="")
|
|
112
|
+
_write_credentials("default", key_id, secret)
|
|
113
|
+
_write_config("default", p["region"])
|
|
114
|
+
|
|
115
|
+
console.ok(f"Default profile now points to [bold]{profile_name}[/bold] ({p['region']})")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_boto3_session(profile: Optional[str] = None, region: Optional[str] = None) -> boto3.Session:
|
|
119
|
+
"""Return a boto3 Session using the given profile (or default)."""
|
|
120
|
+
try:
|
|
121
|
+
session = boto3.Session(profile_name=profile, region_name=region)
|
|
122
|
+
# Force credential resolution now so errors surface early
|
|
123
|
+
creds = session.get_credentials()
|
|
124
|
+
if creds is None:
|
|
125
|
+
raise RuntimeError("No AWS credentials found.")
|
|
126
|
+
return session
|
|
127
|
+
except botocore.exceptions.ProfileNotFound:
|
|
128
|
+
console.error(
|
|
129
|
+
f"AWS profile '{profile}' not found.\n"
|
|
130
|
+
" Run 'crunr auth' to configure credentials, or 'crunr auth --list' to see profiles."
|
|
131
|
+
)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
except botocore.exceptions.NoCredentialsError:
|
|
134
|
+
console.error(
|
|
135
|
+
"No AWS credentials found.\n"
|
|
136
|
+
" Run 'crunr auth' to set up your AWS credentials."
|
|
137
|
+
)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Internal helpers
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def _ensure_aws_dir() -> None:
|
|
146
|
+
AWS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _read_configparser(path: Path) -> configparser.ConfigParser:
|
|
150
|
+
cp = configparser.ConfigParser()
|
|
151
|
+
if path.exists():
|
|
152
|
+
cp.read(path)
|
|
153
|
+
return cp
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _write_credentials(profile_name: str, key_id: str, secret: str) -> None:
|
|
157
|
+
cp = _read_configparser(CREDS_FILE)
|
|
158
|
+
section = profile_name # credentials file uses bare profile names
|
|
159
|
+
if not cp.has_section(section):
|
|
160
|
+
cp.add_section(section)
|
|
161
|
+
cp.set(section, "aws_access_key_id", key_id)
|
|
162
|
+
cp.set(section, "aws_secret_access_key", secret)
|
|
163
|
+
|
|
164
|
+
with CREDS_FILE.open("w") as f:
|
|
165
|
+
cp.write(f)
|
|
166
|
+
|
|
167
|
+
_set_private(CREDS_FILE)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _write_config(profile_name: str, region: str) -> None:
|
|
171
|
+
cp = _read_configparser(CONFIG_FILE)
|
|
172
|
+
# config file uses "profile <name>" for non-default profiles
|
|
173
|
+
section = "default" if profile_name == "default" else f"profile {profile_name}"
|
|
174
|
+
if not cp.has_section(section):
|
|
175
|
+
cp.add_section(section)
|
|
176
|
+
cp.set(section, "region", region)
|
|
177
|
+
cp.set(section, "output", "json")
|
|
178
|
+
|
|
179
|
+
with CONFIG_FILE.open("w") as f:
|
|
180
|
+
cp.write(f)
|
|
181
|
+
|
|
182
|
+
_set_private(CONFIG_FILE)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _set_private(path: Path) -> None:
|
|
186
|
+
try:
|
|
187
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
|
|
188
|
+
except OSError:
|
|
189
|
+
pass # Windows — best effort
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _verify(profile_name: str) -> tuple[bool, str, str]:
|
|
193
|
+
try:
|
|
194
|
+
session = boto3.Session(profile_name=profile_name)
|
|
195
|
+
sts = session.client("sts")
|
|
196
|
+
identity = sts.get_caller_identity()
|
|
197
|
+
arn = identity.get("Arn", "unknown")
|
|
198
|
+
return True, arn, ""
|
|
199
|
+
except botocore.exceptions.ProfileNotFound as e:
|
|
200
|
+
return False, "", str(e)
|
|
201
|
+
except botocore.exceptions.ClientError as e:
|
|
202
|
+
return False, "", str(e)
|
|
203
|
+
except botocore.exceptions.NoCredentialsError as e:
|
|
204
|
+
return False, "", str(e)
|
|
205
|
+
except Exception as e: # noqa: BLE001
|
|
206
|
+
return False, "", str(e)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_all_profiles() -> list[dict]: # type: ignore[type-arg]
|
|
210
|
+
cp_creds = _read_configparser(CREDS_FILE)
|
|
211
|
+
cp_conf = _read_configparser(CONFIG_FILE)
|
|
212
|
+
|
|
213
|
+
profiles = []
|
|
214
|
+
for section in cp_creds.sections():
|
|
215
|
+
key_id = cp_creds.get(section, "aws_access_key_id", fallback="")
|
|
216
|
+
# Mask key for display
|
|
217
|
+
masked = key_id[:4] + "****" + key_id[-4:] if len(key_id) >= 8 else "****"
|
|
218
|
+
|
|
219
|
+
# Find region from config file
|
|
220
|
+
conf_section = "default" if section == "default" else f"profile {section}"
|
|
221
|
+
region = cp_conf.get(conf_section, "region", fallback="us-east-1")
|
|
222
|
+
|
|
223
|
+
profiles.append({"name": section, "region": region, "key_id": masked})
|
|
224
|
+
|
|
225
|
+
return profiles
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _read_default_profile_name() -> str:
|
|
229
|
+
"""Returns the name of the current default profile (always 'default')."""
|
|
230
|
+
return "default"
|