spore-host 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.
- spore_host-0.1.0/.gitignore +58 -0
- spore_host-0.1.0/PKG-INFO +83 -0
- spore_host-0.1.0/README.md +50 -0
- spore_host-0.1.0/examples/jupyter_example.ipynb +197 -0
- spore_host-0.1.0/examples/marimo_example.py +149 -0
- spore_host-0.1.0/examples/script_example.py +73 -0
- spore_host-0.1.0/pyproject.toml +44 -0
- spore_host-0.1.0/spore/__init__.py +35 -0
- spore_host-0.1.0/spore/client.py +89 -0
- spore_host-0.1.0/spore/spawn.py +279 -0
- spore_host-0.1.0/spore/truffle.py +166 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Binaries
|
|
2
|
+
*.exe
|
|
3
|
+
*.exe~
|
|
4
|
+
*.dll
|
|
5
|
+
*.so
|
|
6
|
+
*.dylib
|
|
7
|
+
/bin/
|
|
8
|
+
truffle/bin/
|
|
9
|
+
spawn/bin/
|
|
10
|
+
|
|
11
|
+
# Lambda binaries
|
|
12
|
+
bootstrap
|
|
13
|
+
function.zip
|
|
14
|
+
**/lambda/*/bootstrap
|
|
15
|
+
**/lambda/*-orchestrator
|
|
16
|
+
**/lambda/*-handler
|
|
17
|
+
|
|
18
|
+
# Test binary, built with `go test -c`
|
|
19
|
+
*.test
|
|
20
|
+
|
|
21
|
+
# Output of the go coverage tool
|
|
22
|
+
*.out
|
|
23
|
+
|
|
24
|
+
# Go workspace file
|
|
25
|
+
go.work
|
|
26
|
+
|
|
27
|
+
# Dependency directories
|
|
28
|
+
vendor/
|
|
29
|
+
|
|
30
|
+
# Build artifacts
|
|
31
|
+
dist/
|
|
32
|
+
|
|
33
|
+
# IDE
|
|
34
|
+
.vscode/
|
|
35
|
+
.idea/
|
|
36
|
+
*.swp
|
|
37
|
+
*.swo
|
|
38
|
+
*~
|
|
39
|
+
|
|
40
|
+
# OS
|
|
41
|
+
.DS_Store
|
|
42
|
+
Thumbs.db
|
|
43
|
+
|
|
44
|
+
# Temporary files
|
|
45
|
+
*.log
|
|
46
|
+
/tmp/
|
|
47
|
+
|
|
48
|
+
# Release artifacts
|
|
49
|
+
*.tar.gz
|
|
50
|
+
*.zip
|
|
51
|
+
checksums.txt
|
|
52
|
+
|
|
53
|
+
# Configuration (credentials)
|
|
54
|
+
/config/
|
|
55
|
+
|
|
56
|
+
# Local archive (not pushed to GitHub)
|
|
57
|
+
.local-archive/
|
|
58
|
+
spawn/lambda/rest-api/rest-api
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spore-host
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for spore.host — ephemeral EC2 compute for researchers
|
|
5
|
+
Project-URL: Homepage, https://spore.host
|
|
6
|
+
Project-URL: Documentation, https://docs.spore.host
|
|
7
|
+
Project-URL: Repository, https://github.com/scttfrdmn/spore-host
|
|
8
|
+
Project-URL: Issues, https://github.com/scttfrdmn/spore-host/issues
|
|
9
|
+
Author-email: Scott Friedman <scttfrdmn@gmail.com>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: aws,compute,ec2,hpc,research
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: boto3>=1.34
|
|
23
|
+
Requires-Dist: requests>=2.31
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: black; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
29
|
+
Provides-Extra: jupyter
|
|
30
|
+
Requires-Dist: ipython>=8.0; extra == 'jupyter'
|
|
31
|
+
Requires-Dist: ipywidgets>=8.0; extra == 'jupyter'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# spore-host
|
|
35
|
+
|
|
36
|
+
Python SDK for [spore.host](https://spore.host) — ephemeral EC2 compute for researchers.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install spore-host
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import spore
|
|
46
|
+
|
|
47
|
+
# Find instances
|
|
48
|
+
results = spore.truffle.find("amd epyc genoa", region="us-east-1")
|
|
49
|
+
for r in results:
|
|
50
|
+
print(r.instance_type, f"${r.on_demand_price:.4f}/hr")
|
|
51
|
+
|
|
52
|
+
# Check Spot prices
|
|
53
|
+
prices = spore.truffle.spot("c8a.2xlarge", region="us-east-1")
|
|
54
|
+
cheapest = min(prices, key=lambda p: p.spot_price)
|
|
55
|
+
|
|
56
|
+
# Manage running instances
|
|
57
|
+
instances = spore.spawn.list()
|
|
58
|
+
inst = spore.spawn.status("sim-run-42")
|
|
59
|
+
inst.extend("2h") # push the TTL deadline forward
|
|
60
|
+
inst.wait("terminated", on_status=lambda i: print(i.state))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Works in Jupyter notebooks, [marimo](https://marimo.io), and plain Python scripts.
|
|
64
|
+
|
|
65
|
+
## Documentation
|
|
66
|
+
|
|
67
|
+
Full documentation at **[docs.spore.host/guides/python-sdk](https://docs.spore.host/guides/python-sdk)**
|
|
68
|
+
|
|
69
|
+
- [Jupyter example notebook](examples/jupyter_example.ipynb)
|
|
70
|
+
- [marimo example](examples/marimo_example.py)
|
|
71
|
+
- [Script example](examples/script_example.py)
|
|
72
|
+
|
|
73
|
+
## Requirements
|
|
74
|
+
|
|
75
|
+
- Python 3.9+
|
|
76
|
+
- AWS credentials configured (`~/.aws/credentials` or environment variables)
|
|
77
|
+
- `boto3`, `requests`
|
|
78
|
+
|
|
79
|
+
Optional notebook extras: `pip install "spore-host[jupyter]"`
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
Apache 2.0 — see [LICENSE](../../LICENSE)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# spore-host
|
|
2
|
+
|
|
3
|
+
Python SDK for [spore.host](https://spore.host) — ephemeral EC2 compute for researchers.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install spore-host
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import spore
|
|
13
|
+
|
|
14
|
+
# Find instances
|
|
15
|
+
results = spore.truffle.find("amd epyc genoa", region="us-east-1")
|
|
16
|
+
for r in results:
|
|
17
|
+
print(r.instance_type, f"${r.on_demand_price:.4f}/hr")
|
|
18
|
+
|
|
19
|
+
# Check Spot prices
|
|
20
|
+
prices = spore.truffle.spot("c8a.2xlarge", region="us-east-1")
|
|
21
|
+
cheapest = min(prices, key=lambda p: p.spot_price)
|
|
22
|
+
|
|
23
|
+
# Manage running instances
|
|
24
|
+
instances = spore.spawn.list()
|
|
25
|
+
inst = spore.spawn.status("sim-run-42")
|
|
26
|
+
inst.extend("2h") # push the TTL deadline forward
|
|
27
|
+
inst.wait("terminated", on_status=lambda i: print(i.state))
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Works in Jupyter notebooks, [marimo](https://marimo.io), and plain Python scripts.
|
|
31
|
+
|
|
32
|
+
## Documentation
|
|
33
|
+
|
|
34
|
+
Full documentation at **[docs.spore.host/guides/python-sdk](https://docs.spore.host/guides/python-sdk)**
|
|
35
|
+
|
|
36
|
+
- [Jupyter example notebook](examples/jupyter_example.ipynb)
|
|
37
|
+
- [marimo example](examples/marimo_example.py)
|
|
38
|
+
- [Script example](examples/script_example.py)
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python 3.9+
|
|
43
|
+
- AWS credentials configured (`~/.aws/credentials` or environment variables)
|
|
44
|
+
- `boto3`, `requests`
|
|
45
|
+
|
|
46
|
+
Optional notebook extras: `pip install "spore-host[jupyter]"`
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
Apache 2.0 — see [LICENSE](../../LICENSE)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cells": [
|
|
3
|
+
{
|
|
4
|
+
"cell_type": "markdown",
|
|
5
|
+
"metadata": {},
|
|
6
|
+
"source": [
|
|
7
|
+
"# spore.host — Jupyter Notebook Example\n",
|
|
8
|
+
"\n",
|
|
9
|
+
"This notebook shows how to use the `spore-host` Python SDK to find, launch, and manage EC2 instances directly from a notebook.\n",
|
|
10
|
+
"\n",
|
|
11
|
+
"**Prerequisites:**\n",
|
|
12
|
+
"```sh\n",
|
|
13
|
+
"pip install spore-host\n",
|
|
14
|
+
"export SPORE_API_KEY=sk_... # or AWS credentials already configured\n",
|
|
15
|
+
"```"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"cell_type": "code",
|
|
20
|
+
"execution_count": null,
|
|
21
|
+
"metadata": {},
|
|
22
|
+
"outputs": [],
|
|
23
|
+
"source": [
|
|
24
|
+
"import spore\n",
|
|
25
|
+
"print(f\"spore-host {spore.__version__}\")"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"cell_type": "markdown",
|
|
30
|
+
"metadata": {},
|
|
31
|
+
"source": [
|
|
32
|
+
"## 1. Find the right instance\n",
|
|
33
|
+
"\n",
|
|
34
|
+
"`spore.truffle.find()` searches EC2 instance types using natural language."
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"cell_type": "code",
|
|
39
|
+
"execution_count": null,
|
|
40
|
+
"metadata": {},
|
|
41
|
+
"outputs": [],
|
|
42
|
+
"source": [
|
|
43
|
+
"results = spore.truffle.find(\"amd epyc genoa\", region=\"us-east-1\")\n",
|
|
44
|
+
"\n",
|
|
45
|
+
"# Rich display in Jupyter\n",
|
|
46
|
+
"import pandas as pd\n",
|
|
47
|
+
"pd.DataFrame([\n",
|
|
48
|
+
" {\n",
|
|
49
|
+
" \"Instance\": r.instance_type,\n",
|
|
50
|
+
" \"vCPUs\": r.vcpus,\n",
|
|
51
|
+
" \"Memory (GiB)\": r.memory_gib,\n",
|
|
52
|
+
" \"$/hr\": f\"${r.on_demand_price:.4f}\",\n",
|
|
53
|
+
" \"AZs\": \", \".join(r.available_azs),\n",
|
|
54
|
+
" }\n",
|
|
55
|
+
" for r in results\n",
|
|
56
|
+
"])"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"cell_type": "markdown",
|
|
61
|
+
"metadata": {},
|
|
62
|
+
"source": [
|
|
63
|
+
"## 2. Check Spot prices"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"cell_type": "code",
|
|
68
|
+
"execution_count": null,
|
|
69
|
+
"metadata": {},
|
|
70
|
+
"outputs": [],
|
|
71
|
+
"source": [
|
|
72
|
+
"prices = spore.truffle.spot(\"c8a.2xlarge\", region=\"us-east-1\")\n",
|
|
73
|
+
"cheapest = min(prices, key=lambda p: p.spot_price)\n",
|
|
74
|
+
"print(f\"Cheapest Spot: {cheapest.availability_zone} @ ${cheapest.spot_price:.4f}/hr ({cheapest.savings_pct:.0f}% savings)\")"
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"cell_type": "markdown",
|
|
79
|
+
"metadata": {},
|
|
80
|
+
"source": [
|
|
81
|
+
"## 3. Check your account's quota"
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"cell_type": "code",
|
|
86
|
+
"execution_count": null,
|
|
87
|
+
"metadata": {},
|
|
88
|
+
"outputs": [],
|
|
89
|
+
"source": [
|
|
90
|
+
"quota = spore.truffle.quota(\"c8a.2xlarge\", region=\"us-east-1\")\n",
|
|
91
|
+
"status = \"✅ Can launch\" if quota.can_launch else \"❌ Quota insufficient\"\n",
|
|
92
|
+
"print(f\"{status}: {quota.message}\")"
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"cell_type": "markdown",
|
|
97
|
+
"metadata": {},
|
|
98
|
+
"source": [
|
|
99
|
+
"## 4. Check running instances"
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"cell_type": "code",
|
|
104
|
+
"execution_count": null,
|
|
105
|
+
"metadata": {},
|
|
106
|
+
"outputs": [],
|
|
107
|
+
"source": [
|
|
108
|
+
"instances = spore.spawn.list()\n",
|
|
109
|
+
"if not instances:\n",
|
|
110
|
+
" print(\"No running instances\")\n",
|
|
111
|
+
"else:\n",
|
|
112
|
+
" for inst in instances:\n",
|
|
113
|
+
" display(inst) # uses _repr_html_"
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"cell_type": "markdown",
|
|
118
|
+
"metadata": {},
|
|
119
|
+
"source": [
|
|
120
|
+
"## 5. Manage an existing instance\n",
|
|
121
|
+
"\n",
|
|
122
|
+
"Replace `\"sim-run-42\"` with your instance name."
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"cell_type": "code",
|
|
127
|
+
"execution_count": null,
|
|
128
|
+
"metadata": {},
|
|
129
|
+
"outputs": [],
|
|
130
|
+
"source": [
|
|
131
|
+
"inst = spore.spawn.status(\"sim-run-42\")\n",
|
|
132
|
+
"display(inst)\n",
|
|
133
|
+
"\n",
|
|
134
|
+
"# Extend TTL if needed\n",
|
|
135
|
+
"# inst.extend(\"2h\")\n",
|
|
136
|
+
"\n",
|
|
137
|
+
"# Wake a stopped instance\n",
|
|
138
|
+
"# inst.start()\n",
|
|
139
|
+
"\n",
|
|
140
|
+
"# Wait for job to finish (non-blocking poll with callback)\n",
|
|
141
|
+
"# inst.wait(\"terminated\", poll_interval=60, on_status=lambda i: print(f\"{i.state}\"))"
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"cell_type": "markdown",
|
|
146
|
+
"metadata": {},
|
|
147
|
+
"source": [
|
|
148
|
+
"## 6. Launch and wait (full workflow)\n",
|
|
149
|
+
"\n",
|
|
150
|
+
"> **Note:** `spawn.launch()` is coming in the next SDK release. Use the CLI for now:\n",
|
|
151
|
+
"> ```sh\n",
|
|
152
|
+
"> spawn launch --name my-job --instance-type c8a.2xlarge --ttl 8h --idle-timeout 30m\n",
|
|
153
|
+
"> ```\n",
|
|
154
|
+
"\n",
|
|
155
|
+
"Once launch is available, the full pattern will be:"
|
|
156
|
+
]
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"cell_type": "code",
|
|
160
|
+
"execution_count": null,
|
|
161
|
+
"metadata": {},
|
|
162
|
+
"outputs": [],
|
|
163
|
+
"source": [
|
|
164
|
+
"# Full workflow (launch available in next release)\n",
|
|
165
|
+
"#\n",
|
|
166
|
+
"# inst = spore.spawn.launch(\n",
|
|
167
|
+
"# \"c8a.2xlarge\",\n",
|
|
168
|
+
"# name=\"notebook-job\",\n",
|
|
169
|
+
"# ttl=\"8h\",\n",
|
|
170
|
+
"# idle_timeout=\"30m\",\n",
|
|
171
|
+
"# on_complete=\"terminate\",\n",
|
|
172
|
+
"# )\n",
|
|
173
|
+
"#\n",
|
|
174
|
+
"# # Poll with progress bar\n",
|
|
175
|
+
"# from tqdm.notebook import tqdm\n",
|
|
176
|
+
"# with tqdm(desc=\"Waiting for job\", unit=\"poll\") as pbar:\n",
|
|
177
|
+
"# inst.wait(\"terminated\", on_status=lambda i: pbar.set_postfix(state=i.state))\n",
|
|
178
|
+
"#\n",
|
|
179
|
+
"# print(f\"Done. Instance {inst.name} terminated.\")\n",
|
|
180
|
+
"print(\"launch() coming soon — use spawn CLI to launch instances\")"
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
"metadata": {
|
|
185
|
+
"kernelspec": {
|
|
186
|
+
"display_name": "Python 3",
|
|
187
|
+
"language": "python",
|
|
188
|
+
"name": "python3"
|
|
189
|
+
},
|
|
190
|
+
"language_info": {
|
|
191
|
+
"name": "python",
|
|
192
|
+
"version": "3.11.0"
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
"nbformat": 4,
|
|
196
|
+
"nbformat_minor": 5
|
|
197
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
spore.host — marimo notebook example
|
|
3
|
+
|
|
4
|
+
Run with:
|
|
5
|
+
pip install marimo spore-host
|
|
6
|
+
marimo edit marimo_example.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import marimo as mo
|
|
10
|
+
|
|
11
|
+
app = mo.App(width="medium")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.cell
|
|
15
|
+
def _():
|
|
16
|
+
import spore
|
|
17
|
+
mo.md(f"**spore-host** {spore.__version__} — ephemeral EC2 compute")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.cell
|
|
21
|
+
def _(mo):
|
|
22
|
+
mo.md("""
|
|
23
|
+
## Find instances
|
|
24
|
+
|
|
25
|
+
Search for EC2 instance types using natural language.
|
|
26
|
+
""")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.cell
|
|
30
|
+
def _(mo):
|
|
31
|
+
query = mo.ui.text(
|
|
32
|
+
value="amd epyc genoa",
|
|
33
|
+
label="Search query",
|
|
34
|
+
placeholder="e.g. nvidia h100, arm64 32gb, amd genoa",
|
|
35
|
+
)
|
|
36
|
+
region = mo.ui.dropdown(
|
|
37
|
+
options=["us-east-1", "us-west-2", "eu-west-1", "ap-northeast-1"],
|
|
38
|
+
value="us-east-1",
|
|
39
|
+
label="Region",
|
|
40
|
+
)
|
|
41
|
+
mo.hstack([query, region])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.cell
|
|
45
|
+
def _(mo, query, region):
|
|
46
|
+
import spore
|
|
47
|
+
|
|
48
|
+
results = spore.truffle.find(query.value, region=region.value)
|
|
49
|
+
|
|
50
|
+
if not results:
|
|
51
|
+
mo.callout(mo.md("No results. Try a different query."), kind="warn")
|
|
52
|
+
else:
|
|
53
|
+
mo.table([
|
|
54
|
+
{
|
|
55
|
+
"Instance": r.instance_type,
|
|
56
|
+
"vCPUs": r.vcpus,
|
|
57
|
+
"Memory (GiB)": f"{r.memory_gib:.0f}",
|
|
58
|
+
"$/hr": f"${r.on_demand_price:.4f}" if r.on_demand_price else "—",
|
|
59
|
+
"AZs": len(r.available_azs),
|
|
60
|
+
}
|
|
61
|
+
for r in results
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.cell
|
|
66
|
+
def _(mo):
|
|
67
|
+
mo.md("""
|
|
68
|
+
## Running instances
|
|
69
|
+
|
|
70
|
+
Current spawn-managed instances in your account.
|
|
71
|
+
""")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.cell
|
|
75
|
+
def _(mo):
|
|
76
|
+
refresh_btn = mo.ui.button(label="Refresh", kind="neutral")
|
|
77
|
+
refresh_btn
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.cell
|
|
81
|
+
def _(mo, refresh_btn):
|
|
82
|
+
import spore
|
|
83
|
+
_ = refresh_btn.value # re-run when button clicked
|
|
84
|
+
|
|
85
|
+
instances = spore.spawn.list(state="all")
|
|
86
|
+
|
|
87
|
+
if not instances:
|
|
88
|
+
mo.callout(mo.md("No instances found."), kind="info")
|
|
89
|
+
else:
|
|
90
|
+
rows = []
|
|
91
|
+
for inst in instances:
|
|
92
|
+
rows.append({
|
|
93
|
+
"Name": inst.name,
|
|
94
|
+
"Type": inst.instance_type,
|
|
95
|
+
"State": inst.state,
|
|
96
|
+
"Region": inst.region,
|
|
97
|
+
"IP": inst.public_ip or "—",
|
|
98
|
+
"TTL": inst.ttl or "—",
|
|
99
|
+
})
|
|
100
|
+
mo.table(rows)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.cell
|
|
104
|
+
def _(mo):
|
|
105
|
+
mo.md("""
|
|
106
|
+
## Manage an instance
|
|
107
|
+
|
|
108
|
+
Enter an instance name or ID to manage it.
|
|
109
|
+
""")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.cell
|
|
113
|
+
def _(mo):
|
|
114
|
+
inst_input = mo.ui.text(label="Instance name or ID", placeholder="sim-run-42")
|
|
115
|
+
inst_input
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.cell
|
|
119
|
+
def _(inst_input, mo):
|
|
120
|
+
import spore
|
|
121
|
+
|
|
122
|
+
if not inst_input.value:
|
|
123
|
+
mo.stop(True, mo.md("Enter an instance name above."))
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
inst = spore.spawn.status(inst_input.value)
|
|
127
|
+
state_color = {
|
|
128
|
+
"running": "green", "stopped": "orange",
|
|
129
|
+
"terminated": "gray", "pending": "blue",
|
|
130
|
+
}.get(inst.state, "gray")
|
|
131
|
+
|
|
132
|
+
mo.md(f"""
|
|
133
|
+
**{inst.name}** `{inst.instance_id}`
|
|
134
|
+
|
|
135
|
+
| Field | Value |
|
|
136
|
+
|-------|-------|
|
|
137
|
+
| Type | {inst.instance_type} |
|
|
138
|
+
| State | **{inst.state}** |
|
|
139
|
+
| Region | {inst.region} |
|
|
140
|
+
| IP | {inst.public_ip or "—"} |
|
|
141
|
+
| TTL | {inst.ttl or "—"} |
|
|
142
|
+
| Idle timeout | {inst.idle_timeout or "—"} |
|
|
143
|
+
""")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
mo.callout(mo.md(f"Error: {e}"), kind="danger")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
app.run()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
spore.host — plain Python script example
|
|
3
|
+
|
|
4
|
+
Shows how to use spore-host in a data pipeline or batch script
|
|
5
|
+
without a notebook environment.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
pip install spore-host
|
|
9
|
+
export SPORE_API_KEY=sk_...
|
|
10
|
+
python script_example.py
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import spore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_best_instance(query: str, region: str = "us-east-1"):
|
|
17
|
+
"""Find the best instance type for a workload."""
|
|
18
|
+
print(f"Searching for: {query!r} in {region}")
|
|
19
|
+
results = spore.truffle.find(query, region=region)
|
|
20
|
+
|
|
21
|
+
if not results:
|
|
22
|
+
print("No results found.")
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
# Sort by price
|
|
26
|
+
results.sort(key=lambda r: r.on_demand_price)
|
|
27
|
+
|
|
28
|
+
print(f"\nFound {len(results)} instance type(s):\n")
|
|
29
|
+
for r in results[:5]:
|
|
30
|
+
gpu_info = f" {r.gpus}× {r.gpu_model}" if r.gpus else ""
|
|
31
|
+
print(f" {r.instance_type:<16} {r.vcpus:>3} vCPU {r.memory_gib:>6.0f} GiB{gpu_info} ${r.on_demand_price:.4f}/hr")
|
|
32
|
+
|
|
33
|
+
return results[0]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_running_instances():
|
|
37
|
+
"""Print all currently running instances and their costs."""
|
|
38
|
+
instances = spore.spawn.list(state="running")
|
|
39
|
+
|
|
40
|
+
if not instances:
|
|
41
|
+
print("No running instances.")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
print(f"\n{len(instances)} running instance(s):\n")
|
|
45
|
+
for inst in instances:
|
|
46
|
+
print(f" {inst.name:<20} {inst.instance_type:<14} {inst.region} TTL: {inst.ttl or '—'}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def extend_if_needed(name: str, min_ttl_hours: float = 1.0):
|
|
50
|
+
"""Extend an instance's TTL if less than min_ttl_hours remain."""
|
|
51
|
+
inst = spore.spawn.status(name)
|
|
52
|
+
print(f"{inst.name}: {inst.state} TTL: {inst.ttl or 'none'}")
|
|
53
|
+
|
|
54
|
+
# In a real script you'd parse the TTL and check remaining time
|
|
55
|
+
# For demo: just show how to extend
|
|
56
|
+
if inst.state == "running":
|
|
57
|
+
inst.extend("2h")
|
|
58
|
+
print(f" → Extended TTL by 2h")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
print("=" * 60)
|
|
63
|
+
print("spore.host Python SDK — script example")
|
|
64
|
+
print("=" * 60)
|
|
65
|
+
|
|
66
|
+
# 1. Find instances
|
|
67
|
+
best = find_best_instance("amd epyc genoa", region="us-east-1")
|
|
68
|
+
|
|
69
|
+
# 2. Check what's running
|
|
70
|
+
print("\n" + "=" * 60)
|
|
71
|
+
check_running_instances()
|
|
72
|
+
|
|
73
|
+
print("\nDone. Use `spawn launch` CLI to launch instances.")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "spore-host"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for spore.host — ephemeral EC2 compute for researchers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Scott Friedman", email = "scttfrdmn@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["aws", "ec2", "compute", "research", "hpc"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Scientific/Engineering",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
dependencies = [
|
|
29
|
+
"boto3>=1.34",
|
|
30
|
+
"requests>=2.31",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
jupyter = ["ipywidgets>=8.0", "IPython>=8.0"]
|
|
35
|
+
dev = ["pytest>=7", "pytest-asyncio", "black", "ruff"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://spore.host"
|
|
39
|
+
Documentation = "https://docs.spore.host"
|
|
40
|
+
Repository = "https://github.com/scttfrdmn/spore-host"
|
|
41
|
+
Issues = "https://github.com/scttfrdmn/spore-host/issues"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["spore"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
spore-host — ephemeral EC2 compute for researchers.
|
|
3
|
+
|
|
4
|
+
Quick start:
|
|
5
|
+
import spore
|
|
6
|
+
results = spore.truffle.find("nvidia h100", region="us-east-1")
|
|
7
|
+
instance = spore.spawn.launch("c8a.2xlarge", ttl="8h")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import Client
|
|
11
|
+
from . import truffle, spawn
|
|
12
|
+
|
|
13
|
+
# Module-level convenience: a default client using ambient AWS credentials
|
|
14
|
+
_default: Client | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_default() -> Client:
|
|
18
|
+
global _default
|
|
19
|
+
if _default is None:
|
|
20
|
+
_default = Client()
|
|
21
|
+
return _default
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __getattr__(name: str):
|
|
25
|
+
# Allow `spore.truffle.find(...)` and `spore.spawn.launch(...)` at module level
|
|
26
|
+
default = _get_default()
|
|
27
|
+
if name == "truffle":
|
|
28
|
+
return default.truffle
|
|
29
|
+
if name == "spawn":
|
|
30
|
+
return default.spawn
|
|
31
|
+
raise AttributeError(f"module 'spore' has no attribute {name!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
__all__ = ["Client", "truffle", "spawn"]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Top-level Client — holds credentials and spawns sub-clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Client:
|
|
13
|
+
"""
|
|
14
|
+
spore.host client. Uses standard AWS credential chain by default.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
api_key: spore.host API key (sk_...). If omitted, reads SPORE_API_KEY
|
|
18
|
+
env var, then falls back to calling the REST API with
|
|
19
|
+
SigV4-signed requests using ambient AWS credentials.
|
|
20
|
+
api_url: Override the REST API base URL (useful for self-hosted).
|
|
21
|
+
profile: AWS profile name (e.g. "my-research-account").
|
|
22
|
+
region: Default AWS region.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
DEFAULT_API_URL = "https://v7ochiyks4uknie3u4s7tiix7a0ejduj.lambda-url.us-east-1.on.aws"
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: Optional[str] = None,
|
|
30
|
+
api_url: Optional[str] = None,
|
|
31
|
+
profile: Optional[str] = None,
|
|
32
|
+
region: Optional[str] = None,
|
|
33
|
+
):
|
|
34
|
+
self._api_key = api_key or os.environ.get("SPORE_API_KEY")
|
|
35
|
+
self._api_url = (api_url or os.environ.get("SPORE_API_URL") or self.DEFAULT_API_URL).rstrip("/")
|
|
36
|
+
self._profile = profile or os.environ.get("AWS_PROFILE")
|
|
37
|
+
self._region = region or os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
|
38
|
+
|
|
39
|
+
# Lazy boto3 session — created on first use
|
|
40
|
+
self._session: Optional[boto3.Session] = None
|
|
41
|
+
|
|
42
|
+
# Sub-clients
|
|
43
|
+
from .truffle import TruffleClient
|
|
44
|
+
from .spawn import SpawnClient
|
|
45
|
+
self.truffle = TruffleClient(self)
|
|
46
|
+
self.spawn = SpawnClient(self)
|
|
47
|
+
|
|
48
|
+
# ── HTTP helpers ──────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def _headers(self) -> dict:
|
|
51
|
+
h = {"Content-Type": "application/json"}
|
|
52
|
+
if self._api_key:
|
|
53
|
+
h["X-API-Key"] = self._api_key
|
|
54
|
+
return h
|
|
55
|
+
|
|
56
|
+
def get(self, path: str, params: dict = None) -> dict:
|
|
57
|
+
resp = requests.get(
|
|
58
|
+
f"{self._api_url}{path}",
|
|
59
|
+
headers=self._headers(),
|
|
60
|
+
params=params or {},
|
|
61
|
+
timeout=30,
|
|
62
|
+
)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
return resp.json()
|
|
65
|
+
|
|
66
|
+
def post(self, path: str, body: dict = None) -> dict:
|
|
67
|
+
resp = requests.post(
|
|
68
|
+
f"{self._api_url}{path}",
|
|
69
|
+
headers=self._headers(),
|
|
70
|
+
json=body or {},
|
|
71
|
+
timeout=30,
|
|
72
|
+
)
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
return resp.json()
|
|
75
|
+
|
|
76
|
+
# ── AWS session (for direct SDK calls when needed) ────────────────────────
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def boto_session(self) -> boto3.Session:
|
|
80
|
+
if self._session is None:
|
|
81
|
+
kwargs = {"region_name": self._region}
|
|
82
|
+
if self._profile:
|
|
83
|
+
kwargs["profile_name"] = self._profile
|
|
84
|
+
self._session = boto3.Session(**kwargs)
|
|
85
|
+
return self._session
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
key_hint = f"api_key={self._api_key[:8]}..." if self._api_key else "no api_key"
|
|
89
|
+
return f"<spore.Client {key_hint} region={self._region}>"
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""spawn — EC2 instance lifecycle: launch, status, stop, extend, terminate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Callable, List, Optional, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .client import Client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Instance:
|
|
17
|
+
"""A spawn-managed EC2 instance."""
|
|
18
|
+
|
|
19
|
+
instance_id: str
|
|
20
|
+
name: str
|
|
21
|
+
instance_type: str
|
|
22
|
+
state: str
|
|
23
|
+
region: str
|
|
24
|
+
public_ip: str = ""
|
|
25
|
+
dns: str = ""
|
|
26
|
+
launch_time: Optional[datetime] = None
|
|
27
|
+
ttl: str = ""
|
|
28
|
+
idle_timeout: str = ""
|
|
29
|
+
_client: Optional["SpawnClient"] = field(default=None, repr=False)
|
|
30
|
+
|
|
31
|
+
# ── Actions ───────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
def stop(self, hibernate: bool = False) -> "Instance":
|
|
34
|
+
"""Stop the instance (preserves it; use start() to resume)."""
|
|
35
|
+
action = "hibernate" if hibernate else "stop"
|
|
36
|
+
self._client._action(self.instance_id, action, self.region)
|
|
37
|
+
self.state = "stopping"
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def start(self) -> "Instance":
|
|
41
|
+
"""Start a stopped instance."""
|
|
42
|
+
self._client._action(self.instance_id, "start", self.region)
|
|
43
|
+
self.state = "pending"
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def terminate(self) -> "Instance":
|
|
47
|
+
"""Permanently terminate the instance."""
|
|
48
|
+
self._client._action(self.instance_id, "terminate", self.region)
|
|
49
|
+
self.state = "shutting-down"
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def extend(self, duration: str) -> "Instance":
|
|
53
|
+
"""
|
|
54
|
+
Extend the TTL deadline.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
duration: Duration to add, e.g. "2h", "30m", "1d".
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> instance.extend("4h")
|
|
61
|
+
"""
|
|
62
|
+
self._client._action(self.instance_id, "extend", self.region, {"duration": duration})
|
|
63
|
+
self.ttl = duration
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def refresh(self) -> "Instance":
|
|
67
|
+
"""Fetch current state from the API."""
|
|
68
|
+
updated = self._client.status(self.instance_id)
|
|
69
|
+
self.state = updated.state
|
|
70
|
+
self.public_ip = updated.public_ip
|
|
71
|
+
self.ttl = updated.ttl
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
# ── Waiting ───────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def wait(
|
|
77
|
+
self,
|
|
78
|
+
state: str = "terminated",
|
|
79
|
+
poll_interval: int = 30,
|
|
80
|
+
timeout: int = 43200,
|
|
81
|
+
on_status: Optional[Callable[["Instance"], None]] = None,
|
|
82
|
+
) -> "Instance":
|
|
83
|
+
"""
|
|
84
|
+
Block until the instance reaches a target state.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
state: Target state: "running", "stopped", "terminated".
|
|
88
|
+
poll_interval: Seconds between polls (default 30).
|
|
89
|
+
timeout: Max seconds to wait (default 12h).
|
|
90
|
+
on_status: Optional callback called on each poll with the Instance.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
>>> instance.wait("terminated", on_status=lambda i: print(i.state))
|
|
94
|
+
"""
|
|
95
|
+
deadline = time.time() + timeout
|
|
96
|
+
while time.time() < deadline:
|
|
97
|
+
self.refresh()
|
|
98
|
+
if on_status:
|
|
99
|
+
on_status(self)
|
|
100
|
+
if self.state == state:
|
|
101
|
+
return self
|
|
102
|
+
if self.state in ("terminated", "shutting-down") and state != "terminated":
|
|
103
|
+
raise RuntimeError(f"Instance terminated before reaching {state!r}")
|
|
104
|
+
time.sleep(poll_interval)
|
|
105
|
+
raise TimeoutError(f"Instance did not reach {state!r} within {timeout}s")
|
|
106
|
+
|
|
107
|
+
def wait_running(self, **kwargs) -> "Instance":
|
|
108
|
+
"""Block until running."""
|
|
109
|
+
return self.wait("running", **kwargs)
|
|
110
|
+
|
|
111
|
+
def wait_done(self, **kwargs) -> "Instance":
|
|
112
|
+
"""Block until terminated (job complete or TTL fired)."""
|
|
113
|
+
return self.wait("terminated", **kwargs)
|
|
114
|
+
|
|
115
|
+
# ── Notebook display ──────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def _repr_html_(self) -> str:
|
|
118
|
+
state_colour = {
|
|
119
|
+
"running": "#059669", "stopped": "#d97706",
|
|
120
|
+
"terminated": "#6b7280", "pending": "#4059E5",
|
|
121
|
+
}.get(self.state, "#6b7280")
|
|
122
|
+
return (
|
|
123
|
+
f'<div style="font-family:monospace;font-size:0.9rem;padding:8px;'
|
|
124
|
+
f'border:1px solid #e5e7eb;border-radius:6px;background:#f8f9fa">'
|
|
125
|
+
f'<b>{self.name}</b> <code style="font-size:0.8em">{self.instance_id}</code><br>'
|
|
126
|
+
f'Type: {self.instance_type} | '
|
|
127
|
+
f'State: <span style="color:{state_colour};font-weight:600">{self.state}</span><br>'
|
|
128
|
+
f'Region: {self.region} | IP: {self.public_ip or "—"}<br>'
|
|
129
|
+
f'TTL: {self.ttl or "—"} | Idle timeout: {self.idle_timeout or "—"}'
|
|
130
|
+
f'</div>'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def __repr__(self) -> str:
|
|
134
|
+
return f"<Instance {self.name} ({self.instance_id}) {self.state} {self.region}>"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SpawnClient:
|
|
138
|
+
def __init__(self, client: "Client"):
|
|
139
|
+
self._c = client
|
|
140
|
+
|
|
141
|
+
def launch(
|
|
142
|
+
self,
|
|
143
|
+
instance_type: str,
|
|
144
|
+
*,
|
|
145
|
+
name: Optional[str] = None,
|
|
146
|
+
region: Optional[str] = None,
|
|
147
|
+
ttl: str = "4h",
|
|
148
|
+
idle_timeout: Optional[str] = None,
|
|
149
|
+
spot: bool = False,
|
|
150
|
+
on_complete: str = "terminate",
|
|
151
|
+
slack_workspace: Optional[str] = None,
|
|
152
|
+
active_processes: Optional[List[str]] = None,
|
|
153
|
+
phone: Optional[str] = None,
|
|
154
|
+
wait: bool = False,
|
|
155
|
+
) -> Instance:
|
|
156
|
+
"""
|
|
157
|
+
Launch an EC2 instance.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
instance_type: EC2 instance type, e.g. "c8a.2xlarge".
|
|
161
|
+
name: Instance name (auto-generated if omitted).
|
|
162
|
+
region: AWS region (default: client region).
|
|
163
|
+
ttl: Time-to-live, e.g. "8h", "2d". Hard termination deadline.
|
|
164
|
+
idle_timeout: Stop if idle for this duration, e.g. "30m".
|
|
165
|
+
spot: Use Spot pricing.
|
|
166
|
+
on_complete: Action on SPAWN_COMPLETE: "terminate", "stop", "hibernate".
|
|
167
|
+
slack_workspace: Slack workspace ID for lifecycle notifications.
|
|
168
|
+
active_processes: Process names that indicate active work (e.g. ["rsession"]).
|
|
169
|
+
phone: Phone number for SMS notifications (+1XXXXXXXXXX).
|
|
170
|
+
wait: If True, block until instance is running.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Instance object.
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
>>> inst = spore.spawn.launch(
|
|
177
|
+
... "c8a.2xlarge",
|
|
178
|
+
... name="my-analysis",
|
|
179
|
+
... ttl="12h",
|
|
180
|
+
... idle_timeout="30m",
|
|
181
|
+
... )
|
|
182
|
+
"""
|
|
183
|
+
raise NotImplementedError(
|
|
184
|
+
"spawn.launch() requires the spore.host CLI or spored on an EC2 instance. "
|
|
185
|
+
"Use the CLI: spawn launch --instance-type c8a.2xlarge --ttl 12h\n"
|
|
186
|
+
"This SDK method will be implemented when the REST API launch endpoint is complete."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def list(
|
|
190
|
+
self,
|
|
191
|
+
state: str = "running",
|
|
192
|
+
region: Optional[str] = None,
|
|
193
|
+
) -> List[Instance]:
|
|
194
|
+
"""
|
|
195
|
+
List spawn-managed instances.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
state: Filter by state: "running", "stopped", "all".
|
|
199
|
+
region: AWS region (all regions if omitted).
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> running = spore.spawn.list()
|
|
203
|
+
>>> for inst in running:
|
|
204
|
+
... print(inst.name, inst.state)
|
|
205
|
+
"""
|
|
206
|
+
params: dict = {"state": state}
|
|
207
|
+
if region:
|
|
208
|
+
params["region"] = region
|
|
209
|
+
data = self._c.get("/v1/instances", params=params)
|
|
210
|
+
return [self._parse(i) for i in data.get("instances", [])]
|
|
211
|
+
|
|
212
|
+
def status(self, instance_id_or_name: str) -> Instance:
|
|
213
|
+
"""
|
|
214
|
+
Get detailed status for a single instance.
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
>>> inst = spore.spawn.status("sim-run-42")
|
|
218
|
+
>>> print(inst.state, inst.ttl)
|
|
219
|
+
"""
|
|
220
|
+
data = self._c.get(f"/v1/instances/{instance_id_or_name}")
|
|
221
|
+
return self._parse(data)
|
|
222
|
+
|
|
223
|
+
def stop(self, instance_id_or_name: str, hibernate: bool = False) -> Instance:
|
|
224
|
+
"""Stop a running instance."""
|
|
225
|
+
action = "hibernate" if hibernate else "stop"
|
|
226
|
+
data = self._action(instance_id_or_name, action)
|
|
227
|
+
return self.status(instance_id_or_name)
|
|
228
|
+
|
|
229
|
+
def start(self, instance_id_or_name: str) -> Instance:
|
|
230
|
+
"""Start a stopped instance."""
|
|
231
|
+
self._action(instance_id_or_name, "start")
|
|
232
|
+
return self.status(instance_id_or_name)
|
|
233
|
+
|
|
234
|
+
def terminate(self, instance_id_or_name: str) -> dict:
|
|
235
|
+
"""Permanently terminate an instance."""
|
|
236
|
+
return self._action(instance_id_or_name, "terminate")
|
|
237
|
+
|
|
238
|
+
def extend(self, instance_id_or_name: str, duration: str) -> dict:
|
|
239
|
+
"""
|
|
240
|
+
Extend an instance's TTL deadline.
|
|
241
|
+
|
|
242
|
+
Example:
|
|
243
|
+
>>> spore.spawn.extend("sim-run-42", "4h")
|
|
244
|
+
"""
|
|
245
|
+
return self._action(instance_id_or_name, "extend", body={"duration": duration})
|
|
246
|
+
|
|
247
|
+
# ── Internal ──────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def _action(
|
|
250
|
+
self,
|
|
251
|
+
instance_id_or_name: str,
|
|
252
|
+
action: str,
|
|
253
|
+
region: Optional[str] = None,
|
|
254
|
+
body: Optional[dict] = None,
|
|
255
|
+
) -> dict:
|
|
256
|
+
path = f"/v1/instances/{instance_id_or_name}/{action}"
|
|
257
|
+
return self._c.post(path, body or {})
|
|
258
|
+
|
|
259
|
+
def _parse(self, d: dict) -> Instance:
|
|
260
|
+
launch_time = None
|
|
261
|
+
if d.get("launch_time"):
|
|
262
|
+
try:
|
|
263
|
+
launch_time = datetime.fromisoformat(d["launch_time"].replace("Z", "+00:00"))
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
inst = Instance(
|
|
267
|
+
instance_id=d.get("instance_id", ""),
|
|
268
|
+
name=d.get("name", ""),
|
|
269
|
+
instance_type=d.get("instance_type", ""),
|
|
270
|
+
state=d.get("state", ""),
|
|
271
|
+
region=d.get("region", ""),
|
|
272
|
+
public_ip=d.get("public_ip", ""),
|
|
273
|
+
dns=d.get("dns", ""),
|
|
274
|
+
launch_time=launch_time,
|
|
275
|
+
ttl=d.get("ttl", ""),
|
|
276
|
+
idle_timeout=d.get("idle_timeout", ""),
|
|
277
|
+
)
|
|
278
|
+
inst._client = self
|
|
279
|
+
return inst
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""truffle — EC2 instance discovery: search, spot prices, quota checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .client import Client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class InstanceType:
|
|
14
|
+
instance_type: str
|
|
15
|
+
region: str
|
|
16
|
+
vcpus: int
|
|
17
|
+
memory_gib: float
|
|
18
|
+
architecture: str
|
|
19
|
+
on_demand_price: float = 0.0
|
|
20
|
+
gpus: int = 0
|
|
21
|
+
gpu_model: str = ""
|
|
22
|
+
gpu_memory_gib: float = 0.0
|
|
23
|
+
available_azs: List[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def memory_gb(self) -> float:
|
|
27
|
+
return self.memory_gib
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
gpu = f" {self.gpus}×{self.gpu_model}" if self.gpus else ""
|
|
31
|
+
price = f" ${self.on_demand_price:.4f}/hr" if self.on_demand_price else ""
|
|
32
|
+
return f"<InstanceType {self.instance_type} {self.vcpus}vCPU {self.memory_gib:.0f}GiB{gpu}{price}>"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SpotPrice:
|
|
37
|
+
instance_type: str
|
|
38
|
+
region: str
|
|
39
|
+
availability_zone: str
|
|
40
|
+
spot_price: float
|
|
41
|
+
on_demand_price: float
|
|
42
|
+
savings_pct: float
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class QuotaInfo:
|
|
47
|
+
instance_type: str
|
|
48
|
+
region: str
|
|
49
|
+
vcpus: int
|
|
50
|
+
can_launch: bool
|
|
51
|
+
message: str
|
|
52
|
+
spot: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TruffleClient:
|
|
56
|
+
def __init__(self, client: "Client"):
|
|
57
|
+
self._c = client
|
|
58
|
+
|
|
59
|
+
def find(
|
|
60
|
+
self,
|
|
61
|
+
query: str,
|
|
62
|
+
region: Optional[str] = None,
|
|
63
|
+
regions: Optional[List[str]] = None,
|
|
64
|
+
) -> List[InstanceType]:
|
|
65
|
+
"""
|
|
66
|
+
Find EC2 instance types matching a natural language query.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
query: Natural language description, e.g. "nvidia h100 8gpu",
|
|
70
|
+
"amd epyc genoa", "arm64 64gb memory".
|
|
71
|
+
region: Single region to search (e.g. "us-east-1").
|
|
72
|
+
regions: Multiple regions (overrides region).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of InstanceType objects sorted by price.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
>>> results = spore.truffle.find("amd epyc genoa", region="us-east-1")
|
|
79
|
+
>>> for r in results:
|
|
80
|
+
... print(r.instance_type, f"${r.on_demand_price:.4f}/hr")
|
|
81
|
+
"""
|
|
82
|
+
params: dict = {"q": query}
|
|
83
|
+
if regions:
|
|
84
|
+
params["region"] = ",".join(regions)
|
|
85
|
+
elif region:
|
|
86
|
+
params["region"] = region
|
|
87
|
+
|
|
88
|
+
data = self._c.get("/v1/search", params=params)
|
|
89
|
+
return [self._parse(r) for r in data.get("results", [])]
|
|
90
|
+
|
|
91
|
+
def spot(
|
|
92
|
+
self,
|
|
93
|
+
instance_type: str,
|
|
94
|
+
region: Optional[str] = None,
|
|
95
|
+
regions: Optional[List[str]] = None,
|
|
96
|
+
) -> List[SpotPrice]:
|
|
97
|
+
"""
|
|
98
|
+
Get current Spot prices for an instance type across regions/AZs.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
>>> prices = spore.truffle.spot("c8a.2xlarge", region="us-east-1")
|
|
102
|
+
>>> cheapest = min(prices, key=lambda p: p.spot_price)
|
|
103
|
+
"""
|
|
104
|
+
params: dict = {"type": instance_type}
|
|
105
|
+
if regions:
|
|
106
|
+
params["region"] = ",".join(regions)
|
|
107
|
+
elif region:
|
|
108
|
+
params["region"] = region
|
|
109
|
+
|
|
110
|
+
data = self._c.get("/v1/spot", params=params)
|
|
111
|
+
return [
|
|
112
|
+
SpotPrice(
|
|
113
|
+
instance_type=p["instance_type"],
|
|
114
|
+
region=p["region"],
|
|
115
|
+
availability_zone=p["availability_zone"],
|
|
116
|
+
spot_price=float(p["spot_price"]),
|
|
117
|
+
on_demand_price=float(p.get("on_demand_price", 0)),
|
|
118
|
+
savings_pct=float(p.get("savings_percent", 0)),
|
|
119
|
+
)
|
|
120
|
+
for p in data.get("prices", [])
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
def quota(
|
|
124
|
+
self,
|
|
125
|
+
instance_type: str,
|
|
126
|
+
region: str,
|
|
127
|
+
spot: bool = False,
|
|
128
|
+
) -> QuotaInfo:
|
|
129
|
+
"""
|
|
130
|
+
Check whether your AWS account has enough quota to launch an instance type.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> q = spore.truffle.quota("p4d.24xlarge", region="us-east-1")
|
|
134
|
+
>>> if not q.can_launch:
|
|
135
|
+
... print(q.message)
|
|
136
|
+
"""
|
|
137
|
+
params = {"type": instance_type, "region": region, "spot": str(spot).lower()}
|
|
138
|
+
data = self._c.get("/v1/quota", params=params)
|
|
139
|
+
return QuotaInfo(
|
|
140
|
+
instance_type=instance_type,
|
|
141
|
+
region=region,
|
|
142
|
+
vcpus=int(data.get("vcpus", 0)),
|
|
143
|
+
can_launch=bool(data.get("can_launch", False)),
|
|
144
|
+
message=data.get("message", ""),
|
|
145
|
+
spot=spot,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _parse(self, r: dict) -> InstanceType:
|
|
149
|
+
return InstanceType(
|
|
150
|
+
instance_type=r.get("instance_type", ""),
|
|
151
|
+
region=r.get("region", ""),
|
|
152
|
+
vcpus=int(r.get("v_cp_us", r.get("vcpus", 0))),
|
|
153
|
+
memory_gib=float(r.get("memory_mi_b", r.get("memory_gib", 0))) / 1024
|
|
154
|
+
if r.get("memory_mi_b") else float(r.get("memory_gib", 0)),
|
|
155
|
+
architecture=r.get("architecture", ""),
|
|
156
|
+
on_demand_price=float(r.get("on_demand_price", 0)),
|
|
157
|
+
gpus=int(r.get("gp_us", r.get("gpus", 0))),
|
|
158
|
+
gpu_model=r.get("gpu_model", ""),
|
|
159
|
+
gpu_memory_gib=float(r.get("gpu_memory_mi_b", 0)) / 1024,
|
|
160
|
+
available_azs=r.get("available_a_zs", r.get("available_azs", [])),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# ── Notebook display ──────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _repr_html_(self) -> str:
|
|
166
|
+
return "<em>spore.truffle — use .find(), .spot(), .quota()</em>"
|