provision-stack 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.
- provision_stack-0.1.0/.gitignore +34 -0
- provision_stack-0.1.0/PKG-INFO +86 -0
- provision_stack-0.1.0/README.md +60 -0
- provision_stack-0.1.0/provision_stack/__init__.py +38 -0
- provision_stack-0.1.0/provision_stack/client.py +435 -0
- provision_stack-0.1.0/provision_stack/config.py +101 -0
- provision_stack-0.1.0/provision_stack/tools.py +161 -0
- provision_stack-0.1.0/pyproject.toml +51 -0
- provision_stack-0.1.0/tests/__init__.py +1 -0
- provision_stack-0.1.0/tests/test_client.py +238 -0
- provision_stack-0.1.0/tests/test_config.py +43 -0
- provision_stack-0.1.0/tests/test_tools.py +109 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
node_modules
|
|
2
|
+
dist
|
|
3
|
+
build
|
|
4
|
+
coverage
|
|
5
|
+
|
|
6
|
+
.env
|
|
7
|
+
.env.*
|
|
8
|
+
!.env.example
|
|
9
|
+
|
|
10
|
+
.turbo
|
|
11
|
+
|
|
12
|
+
.DS_Store
|
|
13
|
+
|
|
14
|
+
.vscode/settings.json
|
|
15
|
+
|
|
16
|
+
*.log
|
|
17
|
+
|
|
18
|
+
# Generated Terraform deployment artifacts (written to user's cwd, not repo)
|
|
19
|
+
terraform/
|
|
20
|
+
|
|
21
|
+
# Local SQLite state (DATABASE_PATH default location)
|
|
22
|
+
data/
|
|
23
|
+
|
|
24
|
+
# Local deploy secrets (NEVER commit — deploy-time env lives here)
|
|
25
|
+
.env.local
|
|
26
|
+
deploy/.env
|
|
27
|
+
|
|
28
|
+
# Host SSH keys + terraform state (local artifacts)
|
|
29
|
+
deploy/hosting/provision-stack-host-key
|
|
30
|
+
deploy/hosting/provision-stack-host-key.pub
|
|
31
|
+
deploy/hosting/.terraform/
|
|
32
|
+
deploy/hosting/terraform.tfstate*
|
|
33
|
+
deploy/hosting/.terraform.lock.hcl
|
|
34
|
+
__pycache__/
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provision-stack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Provision Stack Execution API — deploy cloud infrastructure via AI agents.
|
|
5
|
+
Project-URL: Homepage, https://provision-stack.com
|
|
6
|
+
Project-URL: Documentation, https://docs.provision-stack.com
|
|
7
|
+
Project-URL: Repository, https://github.com/TravisLinkey/provision-stack
|
|
8
|
+
Author: Provision Stack
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai-agents,aws,azure,cloud,gcp,infrastructure,mcp,oracle,terraform
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: System :: Systems Administration
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest-mock>=3.12; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# provision-stack
|
|
28
|
+
|
|
29
|
+
> Python client for the Provision Stack Execution API — deploy cloud infrastructure via AI agents.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install provision-stack
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from provision_stack import ProvisionStackClient
|
|
41
|
+
|
|
42
|
+
client = ProvisionStackClient(api_key="your-token", sandbox=True)
|
|
43
|
+
|
|
44
|
+
# Get suggestions
|
|
45
|
+
suggestions = client.suggest("deploy a production web app")
|
|
46
|
+
|
|
47
|
+
# Deploy
|
|
48
|
+
result = client.deploy(outcome="deploy a production web app", tier="MVP")
|
|
49
|
+
|
|
50
|
+
# Check status
|
|
51
|
+
status = client.status(result["deploymentId"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
client = ProvisionStackClient(
|
|
58
|
+
api_key="your-token", # or PROVISION_STACK_API_KEY env var
|
|
59
|
+
base_url="https://...", # or PROVISION_STACK_API_URL env var
|
|
60
|
+
sandbox=True, # use sandbox (mock Terraform)
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
### `suggest(outcome, **kwargs)`
|
|
67
|
+
Get deployment suggestions with tier options and cost estimates.
|
|
68
|
+
|
|
69
|
+
### `deploy(outcome, **kwargs)`
|
|
70
|
+
Deploy infrastructure. Fee held from balance, captured after verification.
|
|
71
|
+
|
|
72
|
+
### `status(deployment_id)`
|
|
73
|
+
Check deployment status and verification results.
|
|
74
|
+
|
|
75
|
+
### `destroy(deployment_id)`
|
|
76
|
+
Destroy all resources owned by a deployment.
|
|
77
|
+
|
|
78
|
+
### `balance()`
|
|
79
|
+
Check account credit balance.
|
|
80
|
+
|
|
81
|
+
### `topup_stripe(amount_usd)` / `topup_xrp(amount_usd)`
|
|
82
|
+
Add credits via Stripe or XRP.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# provision-stack
|
|
2
|
+
|
|
3
|
+
> Python client for the Provision Stack Execution API — deploy cloud infrastructure via AI agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install provision-stack
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from provision_stack import ProvisionStackClient
|
|
15
|
+
|
|
16
|
+
client = ProvisionStackClient(api_key="your-token", sandbox=True)
|
|
17
|
+
|
|
18
|
+
# Get suggestions
|
|
19
|
+
suggestions = client.suggest("deploy a production web app")
|
|
20
|
+
|
|
21
|
+
# Deploy
|
|
22
|
+
result = client.deploy(outcome="deploy a production web app", tier="MVP")
|
|
23
|
+
|
|
24
|
+
# Check status
|
|
25
|
+
status = client.status(result["deploymentId"])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
client = ProvisionStackClient(
|
|
32
|
+
api_key="your-token", # or PROVISION_STACK_API_KEY env var
|
|
33
|
+
base_url="https://...", # or PROVISION_STACK_API_URL env var
|
|
34
|
+
sandbox=True, # use sandbox (mock Terraform)
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## API
|
|
39
|
+
|
|
40
|
+
### `suggest(outcome, **kwargs)`
|
|
41
|
+
Get deployment suggestions with tier options and cost estimates.
|
|
42
|
+
|
|
43
|
+
### `deploy(outcome, **kwargs)`
|
|
44
|
+
Deploy infrastructure. Fee held from balance, captured after verification.
|
|
45
|
+
|
|
46
|
+
### `status(deployment_id)`
|
|
47
|
+
Check deployment status and verification results.
|
|
48
|
+
|
|
49
|
+
### `destroy(deployment_id)`
|
|
50
|
+
Destroy all resources owned by a deployment.
|
|
51
|
+
|
|
52
|
+
### `balance()`
|
|
53
|
+
Check account credit balance.
|
|
54
|
+
|
|
55
|
+
### `topup_stripe(amount_usd)` / `topup_xrp(amount_usd)`
|
|
56
|
+
Add credits via Stripe or XRP.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provision Stack — Python client and tool wrappers.
|
|
3
|
+
|
|
4
|
+
This package provides a thin Python client for the Provision Stack
|
|
5
|
+
Execution API, plus framework-specific integrations (LangChain,
|
|
6
|
+
CrewAI, Composio).
|
|
7
|
+
|
|
8
|
+
Branding
|
|
9
|
+
--------
|
|
10
|
+
All user-visible strings (product name, package name, URLs) are
|
|
11
|
+
defined in ``config.py``. To rebrand:
|
|
12
|
+
|
|
13
|
+
1. Edit ``config.py`` — change ``PRODUCT_NAME``, ``PYPI_PACKAGE``,
|
|
14
|
+
``API_BASE_URL_DEFAULT``, and the description strings.
|
|
15
|
+
2. Rename this directory and update ``pyproject.toml`` name/description.
|
|
16
|
+
3. Search-replace the old name in README files.
|
|
17
|
+
|
|
18
|
+
That's it. All runtime strings flow from ``config.py``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from provision_stack.config import (
|
|
22
|
+
PRODUCT_NAME,
|
|
23
|
+
API_BASE_URL_DEFAULT,
|
|
24
|
+
SANDBOX_URL,
|
|
25
|
+
)
|
|
26
|
+
from provision_stack.client import ProvisionStackClient
|
|
27
|
+
from provision_stack.tools import BaseTool, ToolResult
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"PRODUCT_NAME",
|
|
33
|
+
"API_BASE_URL_DEFAULT",
|
|
34
|
+
"SANDBOX_URL",
|
|
35
|
+
"ProvisionStackClient",
|
|
36
|
+
"BaseTool",
|
|
37
|
+
"ToolResult",
|
|
38
|
+
]
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thin Python client for the Provision Stack Execution API.
|
|
3
|
+
|
|
4
|
+
All API methods return parsed dicts. No third-party dependencies
|
|
5
|
+
beyond ``requests`` (or ``httpx`` if available).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from provision_stack.config import (
|
|
14
|
+
API_BASE_URL_DEFAULT,
|
|
15
|
+
SANDBOX_URL,
|
|
16
|
+
DEFAULT_REGION,
|
|
17
|
+
DEFAULT_TIER,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _http_post(
|
|
22
|
+
url: str,
|
|
23
|
+
json: dict[str, Any] | None = None,
|
|
24
|
+
headers: dict[str, str] | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""POST with requests, falling back to httpx, then urllib."""
|
|
27
|
+
try:
|
|
28
|
+
import requests
|
|
29
|
+
|
|
30
|
+
resp = requests.post(url, json=json, headers=headers or {}, timeout=30)
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
return resp.json()
|
|
33
|
+
except ImportError:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import httpx
|
|
38
|
+
|
|
39
|
+
with httpx.Client(timeout=30) as client:
|
|
40
|
+
resp = client.post(url, json=json, headers=headers or {})
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
return resp.json()
|
|
43
|
+
except ImportError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
import urllib.request
|
|
47
|
+
import json as _json
|
|
48
|
+
|
|
49
|
+
req = urllib.request.Request(
|
|
50
|
+
url,
|
|
51
|
+
data=_json.dumps(json or {}).encode(),
|
|
52
|
+
headers={"Content-Type": "application/json", **(headers or {})},
|
|
53
|
+
method="POST",
|
|
54
|
+
)
|
|
55
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
56
|
+
return _json.loads(resp.read())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _http_get(
|
|
60
|
+
url: str,
|
|
61
|
+
headers: dict[str, str] | None = None,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""GET with requests, falling back to httpx, then urllib."""
|
|
64
|
+
try:
|
|
65
|
+
import requests
|
|
66
|
+
|
|
67
|
+
resp = requests.get(url, headers=headers or {}, timeout=30)
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
return resp.json()
|
|
70
|
+
except ImportError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
import httpx
|
|
75
|
+
|
|
76
|
+
with httpx.Client(timeout=30) as client:
|
|
77
|
+
resp = client.get(url, headers=headers or {})
|
|
78
|
+
resp.raise_for_status()
|
|
79
|
+
return resp.json()
|
|
80
|
+
except ImportError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
import urllib.request
|
|
84
|
+
import json as _json
|
|
85
|
+
|
|
86
|
+
req = urllib.request.Request(
|
|
87
|
+
url,
|
|
88
|
+
headers=headers or {},
|
|
89
|
+
method="GET",
|
|
90
|
+
)
|
|
91
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
92
|
+
return _json.loads(resp.read())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _http_delete(
|
|
96
|
+
url: str,
|
|
97
|
+
headers: dict[str, str] | None = None,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""DELETE with requests, falling back to httpx, then urllib."""
|
|
100
|
+
try:
|
|
101
|
+
import requests
|
|
102
|
+
|
|
103
|
+
resp = requests.delete(url, headers=headers or {}, timeout=30)
|
|
104
|
+
resp.raise_for_status()
|
|
105
|
+
return resp.json()
|
|
106
|
+
except ImportError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
import httpx
|
|
111
|
+
|
|
112
|
+
with httpx.Client(timeout=30) as client:
|
|
113
|
+
resp = client.delete(url, headers=headers or {})
|
|
114
|
+
resp.raise_for_status()
|
|
115
|
+
return resp.json()
|
|
116
|
+
except ImportError:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
import urllib.request
|
|
120
|
+
import json as _json
|
|
121
|
+
|
|
122
|
+
req = urllib.request.Request(
|
|
123
|
+
url,
|
|
124
|
+
headers=headers or {},
|
|
125
|
+
method="DELETE",
|
|
126
|
+
)
|
|
127
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
128
|
+
return _json.loads(resp.read())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ProvisionStackClient:
|
|
132
|
+
"""
|
|
133
|
+
Python client for the Provision Stack Execution API.
|
|
134
|
+
|
|
135
|
+
Usage::
|
|
136
|
+
|
|
137
|
+
from provision_stack import ProvisionStackClient
|
|
138
|
+
|
|
139
|
+
client = ProvisionStackClient(api_key="your-token")
|
|
140
|
+
|
|
141
|
+
# Get suggestions
|
|
142
|
+
suggestions = client.suggest("deploy a production web app")
|
|
143
|
+
|
|
144
|
+
# Deploy
|
|
145
|
+
deploy = client.deploy(
|
|
146
|
+
outcome="deploy a production web app",
|
|
147
|
+
resources=suggestions["options"][0]["resources"],
|
|
148
|
+
tier="MVP",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Check status
|
|
152
|
+
status = client.status(deploy["deploymentId"])
|
|
153
|
+
|
|
154
|
+
# Check balance
|
|
155
|
+
balance = client.balance()
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
api_key : str, optional
|
|
160
|
+
Bearer token for authentication. Falls back to
|
|
161
|
+
``PROVISION_STACK_API_KEY`` env var, then ``PS_API_KEY``.
|
|
162
|
+
base_url : str, optional
|
|
163
|
+
API base URL. Falls back to ``PROVISION_STACK_API_URL`` env var,
|
|
164
|
+
then the default production URL.
|
|
165
|
+
sandbox : bool, optional
|
|
166
|
+
If True, use the sandbox URL (mock Terraform, no real deploys).
|
|
167
|
+
Defaults to False.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
api_key: str | None = None,
|
|
173
|
+
base_url: str | None = None,
|
|
174
|
+
sandbox: bool = False,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.api_key = (
|
|
177
|
+
api_key
|
|
178
|
+
or os.environ.get("PROVISION_STACK_API_KEY")
|
|
179
|
+
or os.environ.get("PS_API_KEY")
|
|
180
|
+
or ""
|
|
181
|
+
)
|
|
182
|
+
if sandbox:
|
|
183
|
+
self.base_url = SANDBOX_URL
|
|
184
|
+
else:
|
|
185
|
+
self.base_url = (
|
|
186
|
+
base_url
|
|
187
|
+
or os.environ.get("PROVISION_STACK_API_URL")
|
|
188
|
+
or API_BASE_URL_DEFAULT
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def _headers(self) -> dict[str, str]:
|
|
192
|
+
headers: dict[str, str] = {}
|
|
193
|
+
if self.api_key:
|
|
194
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
195
|
+
return headers
|
|
196
|
+
|
|
197
|
+
def _url(self, path: str) -> str:
|
|
198
|
+
return f"{self.base_url.rstrip('/')}{path}"
|
|
199
|
+
|
|
200
|
+
# ── Auth ──────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def signup(self, agent_id: str, scopes: list[str] | None = None) -> dict:
|
|
203
|
+
"""
|
|
204
|
+
Create a new API token (self-serve signup).
|
|
205
|
+
|
|
206
|
+
Returns the token and next-step guidance.
|
|
207
|
+
"""
|
|
208
|
+
return _http_post(
|
|
209
|
+
self._url("/auth/tokens"),
|
|
210
|
+
json={"agentId": agent_id, "scopes": scopes or []},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# ── Suggestions ───────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
def suggest(
|
|
216
|
+
self,
|
|
217
|
+
outcome: str,
|
|
218
|
+
region: str | None = None,
|
|
219
|
+
provider: str | None = None,
|
|
220
|
+
cross_provider: bool = False,
|
|
221
|
+
max_monthly_cost_usd: float | None = None,
|
|
222
|
+
requirements: dict[str, Any] | None = None,
|
|
223
|
+
) -> dict:
|
|
224
|
+
"""
|
|
225
|
+
Get deployment suggestions for a desired outcome.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
outcome : str
|
|
230
|
+
Natural language description (e.g. "deploy a production web app").
|
|
231
|
+
region : str, optional
|
|
232
|
+
AWS/GCP/Azure region (e.g. "us-east-1").
|
|
233
|
+
provider : str, optional
|
|
234
|
+
Restrict to one provider ("aws", "gcp", "azure", "oracle").
|
|
235
|
+
cross_provider : bool
|
|
236
|
+
If True, compare across all providers.
|
|
237
|
+
max_monthly_cost_usd : float, optional
|
|
238
|
+
Filter out options above this monthly cost.
|
|
239
|
+
requirements : dict, optional
|
|
240
|
+
Business requirements: budget, availability, compliance,
|
|
241
|
+
trafficPattern, computePreference.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
dict
|
|
246
|
+
Suggestions with options, cost estimates, and optional
|
|
247
|
+
clarificationRequest.
|
|
248
|
+
"""
|
|
249
|
+
body: dict[str, Any] = {"outcome": outcome}
|
|
250
|
+
if region:
|
|
251
|
+
body["region"] = region
|
|
252
|
+
if provider:
|
|
253
|
+
body["provider"] = provider
|
|
254
|
+
if cross_provider:
|
|
255
|
+
body["crossProvider"] = True
|
|
256
|
+
if max_monthly_cost_usd is not None:
|
|
257
|
+
body["maxMonthlyCostUsd"] = max_monthly_cost_usd
|
|
258
|
+
if requirements:
|
|
259
|
+
body["requirements"] = requirements
|
|
260
|
+
|
|
261
|
+
return _http_post(
|
|
262
|
+
self._url("/api/v1/suggestions"),
|
|
263
|
+
json=body,
|
|
264
|
+
headers=self._headers(),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# ── Quote ─────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
def quote(
|
|
270
|
+
self,
|
|
271
|
+
outcome: str,
|
|
272
|
+
region: str | None = None,
|
|
273
|
+
tier: str | None = None,
|
|
274
|
+
max_monthly_cost_usd: float | None = None,
|
|
275
|
+
) -> dict:
|
|
276
|
+
"""
|
|
277
|
+
Get a pricing quote for a deployment.
|
|
278
|
+
|
|
279
|
+
Returns a quote ID and estimated monthly cost.
|
|
280
|
+
"""
|
|
281
|
+
body: dict[str, Any] = {"outcome": outcome}
|
|
282
|
+
if region:
|
|
283
|
+
body["region"] = region
|
|
284
|
+
if tier:
|
|
285
|
+
body["tier"] = tier
|
|
286
|
+
if max_monthly_cost_usd is not None:
|
|
287
|
+
body["maxMonthlyCostUsd"] = max_monthly_cost_usd
|
|
288
|
+
|
|
289
|
+
return _http_post(
|
|
290
|
+
self._url("/api/v1/quote"),
|
|
291
|
+
json=body,
|
|
292
|
+
headers=self._headers(),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# ── Deploy ────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
def deploy(
|
|
298
|
+
self,
|
|
299
|
+
outcome: str,
|
|
300
|
+
resources: list[dict[str, Any]] | None = None,
|
|
301
|
+
tier: str | None = None,
|
|
302
|
+
region: str | None = None,
|
|
303
|
+
max_monthly_cost_usd: float | None = None,
|
|
304
|
+
requirements: dict[str, Any] | None = None,
|
|
305
|
+
) -> dict:
|
|
306
|
+
"""
|
|
307
|
+
Deploy infrastructure for a desired outcome.
|
|
308
|
+
|
|
309
|
+
The deploy fee is held from your balance and captured only
|
|
310
|
+
after verification passes (failed deployments are free).
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
outcome : str
|
|
315
|
+
Natural language description.
|
|
316
|
+
resources : list[dict], optional
|
|
317
|
+
Resource configs from a suggestion option.
|
|
318
|
+
tier : str, optional
|
|
319
|
+
Deployment tier (Starter/MVP/Startup/Enterprise).
|
|
320
|
+
region : str, optional
|
|
321
|
+
Target region.
|
|
322
|
+
max_monthly_cost_usd : float, optional
|
|
323
|
+
Monthly cost cap.
|
|
324
|
+
requirements : dict, optional
|
|
325
|
+
Business requirements for pattern selection.
|
|
326
|
+
|
|
327
|
+
Returns
|
|
328
|
+
-------
|
|
329
|
+
dict
|
|
330
|
+
Deployment ID, status, and provider info.
|
|
331
|
+
"""
|
|
332
|
+
body: dict[str, Any] = {
|
|
333
|
+
"outcome": outcome,
|
|
334
|
+
}
|
|
335
|
+
if resources:
|
|
336
|
+
body["resources"] = resources
|
|
337
|
+
if tier:
|
|
338
|
+
body["tier"] = tier
|
|
339
|
+
if region:
|
|
340
|
+
body["region"] = region
|
|
341
|
+
if max_monthly_cost_usd is not None:
|
|
342
|
+
body["maxMonthlyCostUsd"] = max_monthly_cost_usd
|
|
343
|
+
if requirements:
|
|
344
|
+
body["requirements"] = requirements
|
|
345
|
+
|
|
346
|
+
return _http_post(
|
|
347
|
+
self._url("/api/v1/deployments"),
|
|
348
|
+
json=body,
|
|
349
|
+
headers=self._headers(),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# ── Status ────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
def status(self, deployment_id: str) -> dict:
|
|
355
|
+
"""
|
|
356
|
+
Check deployment status and verification results.
|
|
357
|
+
|
|
358
|
+
Returns status (pending/provisioning/active/failed),
|
|
359
|
+
verification details, and terraform files.
|
|
360
|
+
"""
|
|
361
|
+
return _http_get(
|
|
362
|
+
self._url(f"/api/v1/deployments/{deployment_id}"),
|
|
363
|
+
headers=self._headers(),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# ── Destroy ───────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
def destroy(self, deployment_id: str) -> dict:
|
|
369
|
+
"""
|
|
370
|
+
Destroy all resources owned by a deployment.
|
|
371
|
+
"""
|
|
372
|
+
return _http_delete(
|
|
373
|
+
self._url(f"/api/v1/deployments/{deployment_id}"),
|
|
374
|
+
headers=self._headers(),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# ── List deployments ──────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
def list_deployments(self) -> dict:
|
|
380
|
+
"""
|
|
381
|
+
List all tracked deployments.
|
|
382
|
+
"""
|
|
383
|
+
return _http_get(
|
|
384
|
+
self._url("/api/v1/deployments"),
|
|
385
|
+
headers=self._headers(),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# ── Billing ───────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
def balance(self) -> dict:
|
|
391
|
+
"""
|
|
392
|
+
Check account credit balance.
|
|
393
|
+
|
|
394
|
+
Returns total, held, and available USD.
|
|
395
|
+
"""
|
|
396
|
+
return _http_get(
|
|
397
|
+
self._url("/api/v1/billing/balance"),
|
|
398
|
+
headers=self._headers(),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def topup_stripe(self, amount_usd: float) -> dict:
|
|
402
|
+
"""
|
|
403
|
+
Create a Stripe Checkout session for adding credits.
|
|
404
|
+
|
|
405
|
+
Returns a checkout URL for the user to complete payment.
|
|
406
|
+
"""
|
|
407
|
+
return _http_post(
|
|
408
|
+
self._url("/api/v1/billing/topup/stripe"),
|
|
409
|
+
json={"amountUsd": amount_usd},
|
|
410
|
+
headers=self._headers(),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def topup_xrp(self, amount_usd: float) -> dict:
|
|
414
|
+
"""
|
|
415
|
+
Get XRP deposit instructions for adding credits.
|
|
416
|
+
|
|
417
|
+
Returns deposit address, destination tag, and exact amount.
|
|
418
|
+
"""
|
|
419
|
+
return _http_post(
|
|
420
|
+
self._url("/api/v1/billing/topup/xrp"),
|
|
421
|
+
json={"amountUsd": amount_usd},
|
|
422
|
+
headers=self._headers(),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def topup_sandbox(self, amount_usd: float) -> dict:
|
|
426
|
+
"""
|
|
427
|
+
Add free sandbox credits (sandbox mode only).
|
|
428
|
+
|
|
429
|
+
Returns the credited amount.
|
|
430
|
+
"""
|
|
431
|
+
return _http_post(
|
|
432
|
+
self._url("/api/v1/billing/topup/sandbox"),
|
|
433
|
+
json={"amountUsd": amount_usd},
|
|
434
|
+
headers=self._headers(),
|
|
435
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Branding and configuration — single source of truth.
|
|
3
|
+
|
|
4
|
+
This is the ONLY file you need to edit when rebranding.
|
|
5
|
+
All user-visible strings (product name, package name, URLs,
|
|
6
|
+
descriptions) are defined here.
|
|
7
|
+
|
|
8
|
+
Current brand: **Provision Stack** (finalized 2026-07-20).
|
|
9
|
+
|
|
10
|
+
If the brand ever changes again:
|
|
11
|
+
|
|
12
|
+
1. Update PRODUCT_NAME below
|
|
13
|
+
2. Update PYPI_PACKAGE to match the new PyPI package name
|
|
14
|
+
3. Update API_BASE_URL_DEFAULT and SANDBOX_URL to the new domain
|
|
15
|
+
4. Update the description strings (short_description, long_description)
|
|
16
|
+
5. Rename the plugin directories:
|
|
17
|
+
plugins/python/provision-stack/ → plugins/python/<new-name>-sdk/
|
|
18
|
+
plugins/python/provision-stack-langchain/ → plugins/python/<new-name>-langchain/
|
|
19
|
+
plugins/python/provision-stack-crewai/ → plugins/python/<new-name>-crewai/
|
|
20
|
+
plugins/python/provision-stack-composio/ → plugins/python/<new-name>-composio/
|
|
21
|
+
6. Rename Python package directories:
|
|
22
|
+
provision_stack/ → <new_name>/
|
|
23
|
+
provision_stack_langchain/ → <new_name>_langchain/
|
|
24
|
+
provision_stack_crewai/ → <new_name>_crewai/
|
|
25
|
+
provision_stack_composio/ → <new_name>_composio/
|
|
26
|
+
7. Update pyproject.toml name/description in each package
|
|
27
|
+
8. Update all import statements across the plugin packages
|
|
28
|
+
|
|
29
|
+
This is the ONLY file that needs to change for steps 1-4.
|
|
30
|
+
Steps 5-8 are mechanical find-replace.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# ── Product identity ──────────────────────────────────────────────
|
|
34
|
+
# Change these when rebranding. Everything else derives from them.
|
|
35
|
+
|
|
36
|
+
PRODUCT_NAME = "Provision Stack"
|
|
37
|
+
PYPI_PACKAGE = "provision-stack" # PyPI distribution name
|
|
38
|
+
|
|
39
|
+
# ── API endpoints ─────────────────────────────────────────────────
|
|
40
|
+
# These are the live API URLs. Sandbox uses SANDBOX_URL.
|
|
41
|
+
|
|
42
|
+
API_BASE_URL_DEFAULT = "https://api.provision-stack.com"
|
|
43
|
+
SANDBOX_URL = "https://sandbox.provision-stack.com"
|
|
44
|
+
|
|
45
|
+
# ── User-visible strings ──────────────────────────────────────────
|
|
46
|
+
# Used in tool descriptions, error messages, and docs.
|
|
47
|
+
|
|
48
|
+
SHORT_DESCRIPTION = (
|
|
49
|
+
"Deploy production infrastructure via AI agents — "
|
|
50
|
+
"AWS, GCP, Azure, and Oracle Cloud."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
LONG_DESCRIPTION = (
|
|
54
|
+
"{product} lets AI agents provision and manage cloud "
|
|
55
|
+
"infrastructure through natural language. Describe your outcome "
|
|
56
|
+
"(e.g. 'deploy a production web application') and receive "
|
|
57
|
+
"verified, best-practice infrastructure on your own cloud account. "
|
|
58
|
+
"Supports AWS, Google Cloud, Azure, and Oracle Cloud."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
TOOL_DESCRIPTION_SUGGEST = (
|
|
62
|
+
"Get deployment suggestions for a desired outcome. "
|
|
63
|
+
"Returns tier options (Starter/MVP/Startup/Enterprise) with "
|
|
64
|
+
"monthly cost estimates across multiple cloud providers."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
TOOL_DESCRIPTION_DEPLOY = (
|
|
68
|
+
"Deploy infrastructure for a selected suggestion. "
|
|
69
|
+
"The deploy fee is held from your account balance and captured "
|
|
70
|
+
"only after verification passes (failed deployments are free)."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
TOOL_DESCRIPTION_STATUS = (
|
|
74
|
+
"Check the status and verification results of a deployment."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
TOOL_DESCRIPTION_DESTROY = (
|
|
78
|
+
"Destroy all resources owned by a deployment."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
TOOL_DESCRIPTION_BALANCE = (
|
|
82
|
+
"Check your account credit balance."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
TOOL_DESCRIPTION_TOPUP = (
|
|
86
|
+
"Add credits to your account via Stripe (card) or XRP (crypto)."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# ── Defaults ──────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
DEFAULT_REGION = "us-east-1"
|
|
92
|
+
DEFAULT_TIER = "MVP"
|
|
93
|
+
|
|
94
|
+
# ── Sandbox note ──────────────────────────────────────────────────
|
|
95
|
+
# Shown to users when connecting to the sandbox.
|
|
96
|
+
|
|
97
|
+
SANDBOX_NOTE = (
|
|
98
|
+
"Sandbox mode: infrastructure is mocked (no real cloud resources "
|
|
99
|
+
"are created). Top up with POST /api/v1/billing/topup/sandbox "
|
|
100
|
+
"to get free credits."
|
|
101
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base tool definitions for the Provision Stack client.
|
|
3
|
+
|
|
4
|
+
These are framework-agnostic tool definitions that can be wrapped
|
|
5
|
+
by LangChain, CrewAI, Composio, or any other agent framework.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable, Optional
|
|
12
|
+
|
|
13
|
+
from provision_stack.client import ProvisionStackClient
|
|
14
|
+
from provision_stack.config import (
|
|
15
|
+
PRODUCT_NAME,
|
|
16
|
+
TOOL_DESCRIPTION_SUGGEST,
|
|
17
|
+
TOOL_DESCRIPTION_DEPLOY,
|
|
18
|
+
TOOL_DESCRIPTION_STATUS,
|
|
19
|
+
TOOL_DESCRIPTION_DESTROY,
|
|
20
|
+
TOOL_DESCRIPTION_BALANCE,
|
|
21
|
+
TOOL_DESCRIPTION_TOPUP,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ToolResult:
|
|
27
|
+
"""Standardized result from a tool call."""
|
|
28
|
+
|
|
29
|
+
success: bool
|
|
30
|
+
data: dict[str, Any]
|
|
31
|
+
error: str | None = None
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, Any]:
|
|
34
|
+
result = {"success": self.success, **self.data}
|
|
35
|
+
if self.error:
|
|
36
|
+
result["error"] = self.error
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class BaseTool:
|
|
42
|
+
"""
|
|
43
|
+
Base class for framework-specific tool wrappers.
|
|
44
|
+
|
|
45
|
+
Each tool has a name, description, and a ``run`` method that
|
|
46
|
+
delegates to the ProvisionStackClient.
|
|
47
|
+
|
|
48
|
+
Framework plugins (LangChain, CrewAI, Composio) subclass this
|
|
49
|
+
and add framework-specific metadata (args schemas, return types).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
description: str
|
|
54
|
+
client: ProvisionStackClient
|
|
55
|
+
_run: Callable[..., Any]
|
|
56
|
+
|
|
57
|
+
def run(self, **kwargs: Any) -> ToolResult:
|
|
58
|
+
"""Execute the tool with the given arguments."""
|
|
59
|
+
try:
|
|
60
|
+
data = self._run(**kwargs)
|
|
61
|
+
return ToolResult(success=True, data=data)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
return ToolResult(success=False, data={}, error=str(e))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_suggest_tool(client: ProvisionStackClient) -> BaseTool:
|
|
67
|
+
"""Create a 'suggest' tool bound to the given client."""
|
|
68
|
+
return BaseTool(
|
|
69
|
+
name="suggest",
|
|
70
|
+
description=TOOL_DESCRIPTION_SUGGEST,
|
|
71
|
+
client=client,
|
|
72
|
+
_run=lambda outcome, region=None, provider=None,
|
|
73
|
+
cross_provider=False, max_monthly_cost_usd=None,
|
|
74
|
+
requirements=None, **kw: client.suggest(
|
|
75
|
+
outcome=outcome,
|
|
76
|
+
region=region,
|
|
77
|
+
provider=provider,
|
|
78
|
+
cross_provider=cross_provider,
|
|
79
|
+
max_monthly_cost_usd=max_monthly_cost_usd,
|
|
80
|
+
requirements=requirements,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def create_deploy_tool(client: ProvisionStackClient) -> BaseTool:
|
|
86
|
+
"""Create a 'deploy' tool bound to the given client."""
|
|
87
|
+
return BaseTool(
|
|
88
|
+
name="deploy",
|
|
89
|
+
description=TOOL_DESCRIPTION_DEPLOY,
|
|
90
|
+
client=client,
|
|
91
|
+
_run=lambda outcome, resources=None, tier=None,
|
|
92
|
+
region=None, max_monthly_cost_usd=None,
|
|
93
|
+
requirements=None, **kw: client.deploy(
|
|
94
|
+
outcome=outcome,
|
|
95
|
+
resources=resources,
|
|
96
|
+
tier=tier,
|
|
97
|
+
region=region,
|
|
98
|
+
max_monthly_cost_usd=max_monthly_cost_usd,
|
|
99
|
+
requirements=requirements,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def create_status_tool(client: ProvisionStackClient) -> BaseTool:
|
|
105
|
+
"""Create a 'status' tool bound to the given client."""
|
|
106
|
+
return BaseTool(
|
|
107
|
+
name="status",
|
|
108
|
+
description=TOOL_DESCRIPTION_STATUS,
|
|
109
|
+
client=client,
|
|
110
|
+
_run=lambda deployment_id, **kw: client.status(deployment_id),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create_destroy_tool(client: ProvisionStackClient) -> BaseTool:
|
|
115
|
+
"""Create a 'destroy' tool bound to the given client."""
|
|
116
|
+
return BaseTool(
|
|
117
|
+
name="destroy",
|
|
118
|
+
description=TOOL_DESCRIPTION_DESTROY,
|
|
119
|
+
client=client,
|
|
120
|
+
_run=lambda deployment_id, **kw: client.destroy(deployment_id),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def create_balance_tool(client: ProvisionStackClient) -> BaseTool:
|
|
125
|
+
"""Create a 'balance' tool bound to the given client."""
|
|
126
|
+
return BaseTool(
|
|
127
|
+
name="balance",
|
|
128
|
+
description=TOOL_DESCRIPTION_BALANCE,
|
|
129
|
+
client=client,
|
|
130
|
+
_run=lambda **kw: client.balance(),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def create_topup_tool(client: ProvisionStackClient) -> BaseTool:
|
|
135
|
+
"""Create a 'topup' tool bound to the given client."""
|
|
136
|
+
return BaseTool(
|
|
137
|
+
name="topup",
|
|
138
|
+
description=TOOL_DESCRIPTION_TOPUP,
|
|
139
|
+
client=client,
|
|
140
|
+
_run=lambda amount_usd, rail="stripe", **kw: (
|
|
141
|
+
client.topup_stripe(amount_usd) if rail == "stripe"
|
|
142
|
+
else client.topup_xrp(amount_usd)
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_all_tools(client: ProvisionStackClient) -> list[BaseTool]:
|
|
148
|
+
"""
|
|
149
|
+
Get all available tools bound to the given client.
|
|
150
|
+
|
|
151
|
+
Returns a list of BaseTool instances that can be used by any
|
|
152
|
+
framework plugin.
|
|
153
|
+
"""
|
|
154
|
+
return [
|
|
155
|
+
create_suggest_tool(client),
|
|
156
|
+
create_deploy_tool(client),
|
|
157
|
+
create_status_tool(client),
|
|
158
|
+
create_destroy_tool(client),
|
|
159
|
+
create_balance_tool(client),
|
|
160
|
+
create_topup_tool(client),
|
|
161
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "provision-stack"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the Provision Stack Execution API — deploy cloud infrastructure via AI agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Provision Stack" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"ai-agents",
|
|
17
|
+
"infrastructure",
|
|
18
|
+
"aws",
|
|
19
|
+
"gcp",
|
|
20
|
+
"azure",
|
|
21
|
+
"oracle",
|
|
22
|
+
"terraform",
|
|
23
|
+
"mcp",
|
|
24
|
+
"cloud",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
"Topic :: System :: Systems Administration",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://provision-stack.com"
|
|
41
|
+
Documentation = "https://docs.provision-stack.com"
|
|
42
|
+
Repository = "https://github.com/TravisLinkey/provision-stack"
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
test = [
|
|
46
|
+
"pytest>=8.0",
|
|
47
|
+
"pytest-mock>=3.12",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared test fixtures for provision-stack tests."""
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Tests for provision_stack.client — REST API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.request
|
|
7
|
+
from unittest.mock import MagicMock, patch, MagicMock
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from provision_stack.client import ProvisionStackClient, _http_post, _http_get, _http_delete
|
|
11
|
+
from provision_stack.config import API_BASE_URL_DEFAULT, SANDBOX_URL
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Fixtures ──────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def client():
|
|
19
|
+
return ProvisionStackClient(api_key="test-token-123")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def sandbox_client():
|
|
24
|
+
return ProvisionStackClient(api_key="test-token-123", sandbox=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def mock_response():
|
|
29
|
+
"""Create a mock response object for requests/httpx."""
|
|
30
|
+
resp = MagicMock()
|
|
31
|
+
resp.json.return_value = {"success": True, "data": "test"}
|
|
32
|
+
resp.raise_for_status = MagicMock()
|
|
33
|
+
return resp
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Client initialization ─────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestClientInit:
|
|
40
|
+
def test_default_url(self, client):
|
|
41
|
+
assert client.base_url == API_BASE_URL_DEFAULT
|
|
42
|
+
|
|
43
|
+
def test_sandbox_url(self, sandbox_client):
|
|
44
|
+
assert sandbox_client.base_url == SANDBOX_URL
|
|
45
|
+
|
|
46
|
+
def test_custom_url(self):
|
|
47
|
+
c = ProvisionStackClient(base_url="https://custom.api.com")
|
|
48
|
+
assert c.base_url == "https://custom.api.com"
|
|
49
|
+
|
|
50
|
+
def test_api_key_from_param(self):
|
|
51
|
+
c = ProvisionStackClient(api_key="my-key")
|
|
52
|
+
assert c.api_key == "my-key"
|
|
53
|
+
|
|
54
|
+
def test_api_key_from_env(self, monkeypatch):
|
|
55
|
+
monkeypatch.setenv("PROVISION_STACK_API_KEY", "env-key")
|
|
56
|
+
c = ProvisionStackClient()
|
|
57
|
+
assert c.api_key == "env-key"
|
|
58
|
+
|
|
59
|
+
def test_api_key_legacy_env(self, monkeypatch):
|
|
60
|
+
monkeypatch.setenv("PS_API_KEY", "legacy-key")
|
|
61
|
+
c = ProvisionStackClient()
|
|
62
|
+
assert c.api_key == "legacy-key"
|
|
63
|
+
|
|
64
|
+
def test_param_overrides_env(self, monkeypatch):
|
|
65
|
+
monkeypatch.setenv("PROVISION_STACK_API_KEY", "env-key")
|
|
66
|
+
c = ProvisionStackClient(api_key="param-key")
|
|
67
|
+
assert c.api_key == "param-key"
|
|
68
|
+
|
|
69
|
+
def test_sandbox_overrides_custom_url(self):
|
|
70
|
+
c = ProvisionStackClient(base_url="https://custom.api.com", sandbox=True)
|
|
71
|
+
assert c.base_url == SANDBOX_URL
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Headers ───────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestHeaders:
|
|
78
|
+
def test_auth_header(self, client):
|
|
79
|
+
headers = client._headers()
|
|
80
|
+
assert headers["Authorization"] == "Bearer test-token-123"
|
|
81
|
+
|
|
82
|
+
def test_no_auth_without_key(self):
|
|
83
|
+
c = ProvisionStackClient()
|
|
84
|
+
headers = c._headers()
|
|
85
|
+
assert "Authorization" not in headers
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── URL construction ──────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestUrl:
|
|
92
|
+
def test_url_path(self, client):
|
|
93
|
+
assert client._url("/api/v1/test") == f"{API_BASE_URL_DEFAULT}/api/v1/test"
|
|
94
|
+
|
|
95
|
+
def test_url_strips_trailing_slash(self):
|
|
96
|
+
c = ProvisionStackClient(base_url="https://api.example.com/")
|
|
97
|
+
assert c._url("/test") == "https://api.example.com/test"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Suggest ───────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestSuggest:
|
|
104
|
+
@patch("provision_stack.client._http_post")
|
|
105
|
+
def test_suggest_minimal(self, mock_post, client):
|
|
106
|
+
mock_post.return_value = {"options": []}
|
|
107
|
+
result = client.suggest("deploy a web app")
|
|
108
|
+
mock_post.assert_called_once()
|
|
109
|
+
call_args = mock_post.call_args
|
|
110
|
+
assert "suggestions" in call_args[0][0]
|
|
111
|
+
assert call_args[1]["json"]["outcome"] == "deploy a web app"
|
|
112
|
+
|
|
113
|
+
@patch("provision_stack.client._http_post")
|
|
114
|
+
def test_suggest_with_options(self, mock_post, client):
|
|
115
|
+
mock_post.return_value = {"options": []}
|
|
116
|
+
client.suggest(
|
|
117
|
+
"deploy a web app",
|
|
118
|
+
region="us-west-2",
|
|
119
|
+
provider="aws",
|
|
120
|
+
cross_provider=True,
|
|
121
|
+
max_monthly_cost_usd=50.0,
|
|
122
|
+
requirements={"budget": 100},
|
|
123
|
+
)
|
|
124
|
+
call_args = mock_post.call_args
|
|
125
|
+
body = call_args[1]["json"]
|
|
126
|
+
assert body["region"] == "us-west-2"
|
|
127
|
+
assert body["provider"] == "aws"
|
|
128
|
+
assert body["crossProvider"] is True
|
|
129
|
+
assert body["maxMonthlyCostUsd"] == 50.0
|
|
130
|
+
assert body["requirements"] == {"budget": 100}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── Deploy ────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestDeploy:
|
|
137
|
+
@patch("provision_stack.client._http_post")
|
|
138
|
+
def test_deploy_minimal(self, mock_post, client):
|
|
139
|
+
mock_post.return_value = {"deploymentId": "d-123"}
|
|
140
|
+
result = client.deploy("deploy a web app")
|
|
141
|
+
body = mock_post.call_args[1]["json"]
|
|
142
|
+
assert body["outcome"] == "deploy a web app"
|
|
143
|
+
|
|
144
|
+
@patch("provision_stack.client._http_post")
|
|
145
|
+
def test_deploy_with_tier(self, mock_post, client):
|
|
146
|
+
mock_post.return_value = {"deploymentId": "d-123"}
|
|
147
|
+
client.deploy("deploy a web app", tier="MVP", region="eu-west-1")
|
|
148
|
+
body = mock_post.call_args[1]["json"]
|
|
149
|
+
assert body["tier"] == "MVP"
|
|
150
|
+
assert body["region"] == "eu-west-1"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Status ────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestStatus:
|
|
157
|
+
@patch("provision_stack.client._http_get")
|
|
158
|
+
def test_status(self, mock_get, client):
|
|
159
|
+
mock_get.return_value = {"status": "active"}
|
|
160
|
+
result = client.status("d-123")
|
|
161
|
+
mock_get.assert_called_once()
|
|
162
|
+
assert "d-123" in mock_get.call_args[0][0]
|
|
163
|
+
assert result["status"] == "active"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Destroy ───────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestDestroy:
|
|
170
|
+
@patch("provision_stack.client._http_delete")
|
|
171
|
+
def test_destroy(self, mock_delete, client):
|
|
172
|
+
mock_delete.return_value = {"status": "destroyed"}
|
|
173
|
+
result = client.destroy("d-123")
|
|
174
|
+
mock_delete.assert_called_once()
|
|
175
|
+
assert "d-123" in mock_delete.call_args[0][0]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── Balance ───────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestBalance:
|
|
182
|
+
@patch("provision_stack.client._http_get")
|
|
183
|
+
def test_balance(self, mock_get, client):
|
|
184
|
+
mock_get.return_value = {"availableUsd": 25.0}
|
|
185
|
+
result = client.balance()
|
|
186
|
+
assert "billing/balance" in mock_get.call_args[0][0]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ── Topup ─────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class TestTopup:
|
|
193
|
+
@patch("provision_stack.client._http_post")
|
|
194
|
+
def test_topup_stripe(self, mock_post, client):
|
|
195
|
+
mock_post.return_value = {"checkoutUrl": "https://checkout.stripe.com/..."}
|
|
196
|
+
result = client.topup_stripe(20.0)
|
|
197
|
+
body = mock_post.call_args[1]["json"]
|
|
198
|
+
assert body["amountUsd"] == 20.0
|
|
199
|
+
assert "topup/stripe" in mock_post.call_args[0][0]
|
|
200
|
+
|
|
201
|
+
@patch("provision_stack.client._http_post")
|
|
202
|
+
def test_topup_xrp(self, mock_post, client):
|
|
203
|
+
mock_post.return_value = {"address": "rXXX", "tag": 123}
|
|
204
|
+
result = client.topup_xrp(10.0)
|
|
205
|
+
body = mock_post.call_args[1]["json"]
|
|
206
|
+
assert body["amountUsd"] == 10.0
|
|
207
|
+
assert "topup/xrp" in mock_post.call_args[0][0]
|
|
208
|
+
|
|
209
|
+
@patch("provision_stack.client._http_post")
|
|
210
|
+
def test_topup_sandbox(self, mock_post, sandbox_client):
|
|
211
|
+
mock_post.return_value = {"creditedUsd": 25.0}
|
|
212
|
+
result = sandbox_client.topup_sandbox(25.0)
|
|
213
|
+
body = mock_post.call_args[1]["json"]
|
|
214
|
+
assert body["amountUsd"] == 25.0
|
|
215
|
+
assert "topup/sandbox" in mock_post.call_args[0][0]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── Signup ────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TestSignup:
|
|
222
|
+
@patch("provision_stack.client._http_post")
|
|
223
|
+
def test_signup(self, mock_post, client):
|
|
224
|
+
mock_post.return_value = {"token": "new-token"}
|
|
225
|
+
result = client.signup("my-agent")
|
|
226
|
+
body = mock_post.call_args[1]["json"]
|
|
227
|
+
assert body["agentId"] == "my-agent"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ── List deployments ──────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TestListDeployments:
|
|
234
|
+
@patch("provision_stack.client._http_get")
|
|
235
|
+
def test_list_deployments(self, mock_get, client):
|
|
236
|
+
mock_get.return_value = {"items": []}
|
|
237
|
+
result = client.list_deployments()
|
|
238
|
+
assert "deployments" in mock_get.call_args[0][0]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Tests for provision_stack.config — branding configuration."""
|
|
2
|
+
|
|
3
|
+
from provision_stack.config import (
|
|
4
|
+
PRODUCT_NAME,
|
|
5
|
+
PYPI_PACKAGE,
|
|
6
|
+
API_BASE_URL_DEFAULT,
|
|
7
|
+
SANDBOX_URL,
|
|
8
|
+
SHORT_DESCRIPTION,
|
|
9
|
+
LONG_DESCRIPTION,
|
|
10
|
+
TOOL_DESCRIPTION_SUGGEST,
|
|
11
|
+
DEFAULT_REGION,
|
|
12
|
+
DEFAULT_TIER,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestConfig:
|
|
17
|
+
def test_product_name_is_string(self):
|
|
18
|
+
assert isinstance(PRODUCT_NAME, str)
|
|
19
|
+
assert len(PRODUCT_NAME) > 0
|
|
20
|
+
|
|
21
|
+
def test_pypi_package_is_string(self):
|
|
22
|
+
assert isinstance(PYPI_PACKAGE, str)
|
|
23
|
+
assert len(PYPI_PACKAGE) > 0
|
|
24
|
+
|
|
25
|
+
def test_urls_are_valid(self):
|
|
26
|
+
assert API_BASE_URL_DEFAULT.startswith("https://")
|
|
27
|
+
assert SANDBOX_URL.startswith("https://")
|
|
28
|
+
|
|
29
|
+
def test_descriptions_are_strings(self):
|
|
30
|
+
assert isinstance(SHORT_DESCRIPTION, str)
|
|
31
|
+
assert isinstance(LONG_DESCRIPTION, str)
|
|
32
|
+
assert len(SHORT_DESCRIPTION) > 10
|
|
33
|
+
assert len(LONG_DESCRIPTION) > 50
|
|
34
|
+
|
|
35
|
+
def test_tool_descriptions_exist(self):
|
|
36
|
+
assert isinstance(TOOL_DESCRIPTION_SUGGEST, str)
|
|
37
|
+
assert len(TOOL_DESCRIPTION_SUGGEST) > 10
|
|
38
|
+
|
|
39
|
+
def test_defaults_are_set(self):
|
|
40
|
+
assert isinstance(DEFAULT_REGION, str)
|
|
41
|
+
assert isinstance(DEFAULT_TIER, str)
|
|
42
|
+
assert len(DEFAULT_REGION) > 0
|
|
43
|
+
assert len(DEFAULT_TIER) > 0
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Tests for provision_stack.tools — framework-agnostic tool definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from provision_stack.tools import (
|
|
9
|
+
BaseTool,
|
|
10
|
+
ToolResult,
|
|
11
|
+
create_suggest_tool,
|
|
12
|
+
create_deploy_tool,
|
|
13
|
+
create_status_tool,
|
|
14
|
+
create_destroy_tool,
|
|
15
|
+
create_balance_tool,
|
|
16
|
+
create_topup_tool,
|
|
17
|
+
get_all_tools,
|
|
18
|
+
)
|
|
19
|
+
from provision_stack.client import ProvisionStackClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── Fixtures ──────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def client():
|
|
27
|
+
return ProvisionStackClient(api_key="test-token")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── ToolResult ────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestToolResult:
|
|
34
|
+
def test_success(self):
|
|
35
|
+
r = ToolResult(success=True, data={"key": "value"})
|
|
36
|
+
d = r.to_dict()
|
|
37
|
+
assert d["success"] is True
|
|
38
|
+
assert d["key"] == "value"
|
|
39
|
+
assert "error" not in d
|
|
40
|
+
|
|
41
|
+
def test_failure(self):
|
|
42
|
+
r = ToolResult(success=False, data={}, error="something broke")
|
|
43
|
+
d = r.to_dict()
|
|
44
|
+
assert d["success"] is False
|
|
45
|
+
assert d["error"] == "something broke"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── BaseTool ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestBaseTool:
|
|
52
|
+
def test_run_success(self, client):
|
|
53
|
+
def my_fn(x):
|
|
54
|
+
return {"result": x * 2}
|
|
55
|
+
|
|
56
|
+
tool = BaseTool(name="test", description="desc", client=client, _run=my_fn)
|
|
57
|
+
result = tool.run(x=5)
|
|
58
|
+
assert result.success is True
|
|
59
|
+
assert result.data["result"] == 10
|
|
60
|
+
|
|
61
|
+
def test_run_exception(self, client):
|
|
62
|
+
def my_fn():
|
|
63
|
+
raise ValueError("bad input")
|
|
64
|
+
|
|
65
|
+
tool = BaseTool(name="test", description="desc", client=client, _run=my_fn)
|
|
66
|
+
result = tool.run()
|
|
67
|
+
assert result.success is False
|
|
68
|
+
assert "bad input" in result.error
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── Factory functions ─────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestFactoryFunctions:
|
|
75
|
+
def test_suggest_tool(self, client):
|
|
76
|
+
tool = create_suggest_tool(client)
|
|
77
|
+
assert tool.name == "suggest"
|
|
78
|
+
assert tool.client is client
|
|
79
|
+
|
|
80
|
+
def test_deploy_tool(self, client):
|
|
81
|
+
tool = create_deploy_tool(client)
|
|
82
|
+
assert tool.name == "deploy"
|
|
83
|
+
|
|
84
|
+
def test_status_tool(self, client):
|
|
85
|
+
tool = create_status_tool(client)
|
|
86
|
+
assert tool.name == "status"
|
|
87
|
+
|
|
88
|
+
def test_destroy_tool(self, client):
|
|
89
|
+
tool = create_destroy_tool(client)
|
|
90
|
+
assert tool.name == "destroy"
|
|
91
|
+
|
|
92
|
+
def test_balance_tool(self, client):
|
|
93
|
+
tool = create_balance_tool(client)
|
|
94
|
+
assert tool.name == "balance"
|
|
95
|
+
|
|
96
|
+
def test_topup_tool(self, client):
|
|
97
|
+
tool = create_topup_tool(client)
|
|
98
|
+
assert tool.name == "topup"
|
|
99
|
+
|
|
100
|
+
def test_get_all_tools(self, client):
|
|
101
|
+
tools = get_all_tools(client)
|
|
102
|
+
assert len(tools) == 6
|
|
103
|
+
names = {t.name for t in tools}
|
|
104
|
+
assert names == {"suggest", "deploy", "status", "destroy", "balance", "topup"}
|
|
105
|
+
|
|
106
|
+
def test_all_tools_share_client(self, client):
|
|
107
|
+
tools = get_all_tools(client)
|
|
108
|
+
for tool in tools:
|
|
109
|
+
assert tool.client is client
|