supervaizer 0.10.5__py3-none-any.whl
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.
- supervaizer/__init__.py +97 -0
- supervaizer/__version__.py +10 -0
- supervaizer/account.py +308 -0
- supervaizer/account_service.py +93 -0
- supervaizer/admin/routes.py +1293 -0
- supervaizer/admin/static/js/job-start-form.js +373 -0
- supervaizer/admin/templates/agent_detail.html +145 -0
- supervaizer/admin/templates/agents.html +249 -0
- supervaizer/admin/templates/agents_grid.html +82 -0
- supervaizer/admin/templates/base.html +233 -0
- supervaizer/admin/templates/case_detail.html +230 -0
- supervaizer/admin/templates/cases_list.html +182 -0
- supervaizer/admin/templates/cases_table.html +134 -0
- supervaizer/admin/templates/console.html +389 -0
- supervaizer/admin/templates/dashboard.html +153 -0
- supervaizer/admin/templates/job_detail.html +192 -0
- supervaizer/admin/templates/job_start_test.html +109 -0
- supervaizer/admin/templates/jobs_list.html +180 -0
- supervaizer/admin/templates/jobs_table.html +122 -0
- supervaizer/admin/templates/navigation.html +163 -0
- supervaizer/admin/templates/recent_activity.html +81 -0
- supervaizer/admin/templates/server.html +105 -0
- supervaizer/admin/templates/server_status_cards.html +121 -0
- supervaizer/admin/templates/supervaize_instructions.html +212 -0
- supervaizer/agent.py +956 -0
- supervaizer/case.py +432 -0
- supervaizer/cli.py +395 -0
- supervaizer/common.py +324 -0
- supervaizer/deploy/__init__.py +16 -0
- supervaizer/deploy/cli.py +305 -0
- supervaizer/deploy/commands/__init__.py +9 -0
- supervaizer/deploy/commands/clean.py +294 -0
- supervaizer/deploy/commands/down.py +119 -0
- supervaizer/deploy/commands/local.py +460 -0
- supervaizer/deploy/commands/plan.py +167 -0
- supervaizer/deploy/commands/status.py +169 -0
- supervaizer/deploy/commands/up.py +281 -0
- supervaizer/deploy/docker.py +377 -0
- supervaizer/deploy/driver_factory.py +42 -0
- supervaizer/deploy/drivers/__init__.py +39 -0
- supervaizer/deploy/drivers/aws_app_runner.py +607 -0
- supervaizer/deploy/drivers/base.py +196 -0
- supervaizer/deploy/drivers/cloud_run.py +570 -0
- supervaizer/deploy/drivers/do_app_platform.py +504 -0
- supervaizer/deploy/health.py +404 -0
- supervaizer/deploy/state.py +210 -0
- supervaizer/deploy/templates/Dockerfile.template +44 -0
- supervaizer/deploy/templates/debug_env.py +69 -0
- supervaizer/deploy/templates/docker-compose.yml.template +37 -0
- supervaizer/deploy/templates/dockerignore.template +66 -0
- supervaizer/deploy/templates/entrypoint.sh +20 -0
- supervaizer/deploy/utils.py +52 -0
- supervaizer/event.py +181 -0
- supervaizer/examples/controller_template.py +196 -0
- supervaizer/instructions.py +145 -0
- supervaizer/job.py +392 -0
- supervaizer/job_service.py +156 -0
- supervaizer/lifecycle.py +417 -0
- supervaizer/parameter.py +233 -0
- supervaizer/protocol/__init__.py +11 -0
- supervaizer/protocol/a2a/__init__.py +21 -0
- supervaizer/protocol/a2a/model.py +227 -0
- supervaizer/protocol/a2a/routes.py +99 -0
- supervaizer/py.typed +1 -0
- supervaizer/routes.py +917 -0
- supervaizer/server.py +553 -0
- supervaizer/server_utils.py +54 -0
- supervaizer/storage.py +462 -0
- supervaizer/telemetry.py +81 -0
- supervaizer/utils/__init__.py +16 -0
- supervaizer/utils/version_check.py +56 -0
- supervaizer-0.10.5.dist-info/METADATA +317 -0
- supervaizer-0.10.5.dist-info/RECORD +76 -0
- supervaizer-0.10.5.dist-info/WHEEL +4 -0
- supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
- supervaizer-0.10.5.dist-info/licenses/LICENSE.md +346 -0
supervaizer/common.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
|
+
# If a copy of the MPL was not distributed with this file, you can obtain one at
|
|
5
|
+
# https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import traceback
|
|
12
|
+
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
13
|
+
|
|
14
|
+
import demjson3
|
|
15
|
+
from cryptography.hazmat.primitives import hashes
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
17
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
18
|
+
from loguru import logger
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
log = logger.bind(module="supervaize")
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SvBaseModel(BaseModel):
|
|
27
|
+
"""
|
|
28
|
+
Base model for all Supervaize models.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def serialize_value(value: Any) -> Any:
|
|
33
|
+
"""Recursively serialize values, converting type objects and datetimes to strings."""
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
|
|
36
|
+
if isinstance(value, type):
|
|
37
|
+
# Convert type objects to their string name
|
|
38
|
+
return value.__name__
|
|
39
|
+
elif isinstance(value, datetime):
|
|
40
|
+
# Convert datetime to ISO format string
|
|
41
|
+
return value.isoformat()
|
|
42
|
+
elif isinstance(value, dict):
|
|
43
|
+
# Recursively process dictionaries
|
|
44
|
+
return {k: SvBaseModel.serialize_value(v) for k, v in value.items()}
|
|
45
|
+
elif isinstance(value, (list, tuple)):
|
|
46
|
+
# Recursively process lists and tuples
|
|
47
|
+
return [SvBaseModel.serialize_value(item) for item in value]
|
|
48
|
+
else:
|
|
49
|
+
# Return value as-is for other types
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
54
|
+
"""
|
|
55
|
+
Convert the model to a dictionary.
|
|
56
|
+
|
|
57
|
+
Note: Handles datetime serialization and type objects by converting them
|
|
58
|
+
to their string representation.
|
|
59
|
+
Tested in tests/test_common.test_sv_base_model_json_conversion
|
|
60
|
+
"""
|
|
61
|
+
# Use mode="python" to avoid Pydantic's JSON serialization errors with type objects
|
|
62
|
+
# Then post-process to handle type objects and datetimes
|
|
63
|
+
data = self.model_dump(mode="python")
|
|
64
|
+
return self.serialize_value(data)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def to_json(self) -> str:
|
|
68
|
+
return json.dumps(self.to_dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ApiResult:
|
|
72
|
+
def __init__(self, message: str, detail: Optional[Dict[str, Any]], code: str):
|
|
73
|
+
self.message = message
|
|
74
|
+
self.code = str(code)
|
|
75
|
+
self.detail = detail
|
|
76
|
+
|
|
77
|
+
def __str__(self) -> str:
|
|
78
|
+
return f"{self.json_return}"
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return f"{self.__class__.__name__} ({self.message})"
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def dict(self) -> Dict[str, Any]:
|
|
85
|
+
return {key: value for key, value in self.__dict__.items()}
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def json_return(self) -> str:
|
|
89
|
+
return json.dumps(self.dict)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ApiSuccess(ApiResult):
|
|
93
|
+
"""
|
|
94
|
+
ApiSuccess is a class that extends ApiResult.
|
|
95
|
+
It is used to return a success response from the API.
|
|
96
|
+
|
|
97
|
+
If the detail is a string, it is decoded as a JSON object: Expects a JSON object with a
|
|
98
|
+
key "object" and a value of the JSON object to return.
|
|
99
|
+
If the detail is a dictionary, it is used as is.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
Tested in tests/test_common.py
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self, message: str, detail: Optional[Dict[str, Any] | str], code: int = 200
|
|
107
|
+
):
|
|
108
|
+
log_message = "✅ "
|
|
109
|
+
if isinstance(detail, str):
|
|
110
|
+
result = demjson3.decode(detail, return_errors=True)
|
|
111
|
+
detail = {"object": result.object}
|
|
112
|
+
id = result.object.get("id") or None
|
|
113
|
+
if id is not None:
|
|
114
|
+
log_message += f"{message} : {id}"
|
|
115
|
+
else:
|
|
116
|
+
log_message += f"{message}"
|
|
117
|
+
else:
|
|
118
|
+
id = None
|
|
119
|
+
detail = detail
|
|
120
|
+
log_message += f"{message}"
|
|
121
|
+
|
|
122
|
+
super().__init__(
|
|
123
|
+
message=message,
|
|
124
|
+
detail=detail,
|
|
125
|
+
code=str(code),
|
|
126
|
+
)
|
|
127
|
+
self.id: Optional[str] = id
|
|
128
|
+
self.log_message = log_message
|
|
129
|
+
log.debug(f"[API Success] {self.log_message}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ApiError(ApiResult):
|
|
133
|
+
"""
|
|
134
|
+
ApiError is a class that extends ApiResult.
|
|
135
|
+
It can be used to return an error response from the API.
|
|
136
|
+
Note : not really useful for the moment, as API errors raise exception.
|
|
137
|
+
|
|
138
|
+
Tested in tests/test_common.py
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
message: str,
|
|
144
|
+
code: str = "",
|
|
145
|
+
detail: Optional[Dict[str, Any]] = None,
|
|
146
|
+
exception: Optional[Exception] = None,
|
|
147
|
+
url: str = "",
|
|
148
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
149
|
+
):
|
|
150
|
+
super().__init__(message, detail, code)
|
|
151
|
+
self.exception = exception
|
|
152
|
+
self.url = url
|
|
153
|
+
self.payload = payload
|
|
154
|
+
self.log_message = f"❌ {message} : {self.exception}"
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def dict(self) -> Dict[str, Any]:
|
|
158
|
+
if self.exception:
|
|
159
|
+
exception_dict: Dict[str, Any] = {
|
|
160
|
+
"type": type(self.exception).__name__,
|
|
161
|
+
"message": str(self.exception),
|
|
162
|
+
"traceback": traceback.format_exc(),
|
|
163
|
+
"attributes": {},
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
response := hasattr(self.exception, "response")
|
|
167
|
+
and self.exception.response
|
|
168
|
+
):
|
|
169
|
+
self.code = str(response.status_code) or ""
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
response_text = self.exception.response.text
|
|
173
|
+
exception_dict["response"] = json.loads(response_text)
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
pass
|
|
176
|
+
for attr in dir(self.exception):
|
|
177
|
+
try:
|
|
178
|
+
if (
|
|
179
|
+
not attr.startswith("__")
|
|
180
|
+
and not callable(attribute := getattr(self.exception, attr))
|
|
181
|
+
and getattr(self.exception, attr)
|
|
182
|
+
):
|
|
183
|
+
try:
|
|
184
|
+
exception_dict["attributes"][attr] = json.loads(
|
|
185
|
+
str(attribute)
|
|
186
|
+
)
|
|
187
|
+
except json.JSONDecodeError:
|
|
188
|
+
pass
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
result: Dict[str, Any] = {
|
|
193
|
+
"message": self.message,
|
|
194
|
+
"code": self.code,
|
|
195
|
+
"url": self.url,
|
|
196
|
+
"payload": self.payload,
|
|
197
|
+
"detail": self.detail,
|
|
198
|
+
}
|
|
199
|
+
if self.exception:
|
|
200
|
+
result["exception"] = exception_dict
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def singleton(cls: type[T]) -> Callable[..., T]:
|
|
205
|
+
"""Decorator to create a singleton class
|
|
206
|
+
Tested in tests/test_common.py
|
|
207
|
+
"""
|
|
208
|
+
instances: Dict[type[T], T] = {}
|
|
209
|
+
|
|
210
|
+
def get_instance(*args: Any, **kwargs: Any) -> T:
|
|
211
|
+
if cls not in instances:
|
|
212
|
+
instances[cls] = cls(*args, **kwargs)
|
|
213
|
+
return instances[cls]
|
|
214
|
+
|
|
215
|
+
return get_instance
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def encrypt_value(value_to_encrypt: str, public_key: rsa.RSAPublicKey) -> str:
|
|
219
|
+
"""Encrypt using hybrid RSA+AES encryption to handle messages of any size.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
value_to_encrypt (str): Value to encrypt
|
|
223
|
+
public_key (rsa.RSAPublicKey): RSA public key
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
str: Base64 encoded encrypted value containing both the encrypted AES key and encrypted data
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
ValueError: If encryption fails
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
# Generate random AES key and IV
|
|
233
|
+
aes_key = os.urandom(32) # 256-bit key
|
|
234
|
+
iv = os.urandom(16)
|
|
235
|
+
|
|
236
|
+
# Encrypt the AES key with RSA
|
|
237
|
+
encrypted_key = public_key.encrypt(
|
|
238
|
+
aes_key,
|
|
239
|
+
padding.OAEP(
|
|
240
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
241
|
+
algorithm=hashes.SHA256(),
|
|
242
|
+
label=None,
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Create AES cipher
|
|
247
|
+
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
|
|
248
|
+
encryptor = cipher.encryptor()
|
|
249
|
+
|
|
250
|
+
# Pad data to block size
|
|
251
|
+
value_bytes = value_to_encrypt.encode("utf-8")
|
|
252
|
+
pad_length = 16 - (len(value_bytes) % 16)
|
|
253
|
+
value_bytes += bytes([pad_length]) * pad_length
|
|
254
|
+
|
|
255
|
+
# Encrypt data with AES
|
|
256
|
+
encrypted_data = encryptor.update(value_bytes) + encryptor.finalize()
|
|
257
|
+
|
|
258
|
+
# Combine encrypted key, IV and data
|
|
259
|
+
combined = encrypted_key + iv + encrypted_data
|
|
260
|
+
|
|
261
|
+
# Return base64 encoded result
|
|
262
|
+
return base64.b64encode(combined).decode("utf-8")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def decrypt_value(encrypted_value: str, private_key: rsa.RSAPrivateKey) -> str:
|
|
266
|
+
"""Decrypt using hybrid RSA+AES decryption.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
encrypted_value (str): Base64 encoded encrypted value
|
|
270
|
+
private_key (rsa.RSAPrivateKey): RSA private key
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
str: Decrypted value as string
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
ValueError: If decryption fails
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
# Basic validation
|
|
280
|
+
if not encrypted_value:
|
|
281
|
+
raise ValueError("Empty encrypted value")
|
|
282
|
+
|
|
283
|
+
# Clean the string
|
|
284
|
+
encrypted_value = encrypted_value.strip()
|
|
285
|
+
|
|
286
|
+
# Decode base64
|
|
287
|
+
try:
|
|
288
|
+
combined = base64.b64decode(encrypted_value)
|
|
289
|
+
except Exception as e:
|
|
290
|
+
raise ValueError(f"Base64 decode failed: {str(e)}")
|
|
291
|
+
|
|
292
|
+
# Validate combined data structure
|
|
293
|
+
if len(combined) < 272:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"Invalid encrypted data structure: too short ({len(combined)} bytes)"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Extract components - first 256 bytes are RSA encrypted key
|
|
299
|
+
encrypted_key = combined[:256] # RSA-2048 output is 256 bytes
|
|
300
|
+
iv = combined[256:272] # 16 bytes IV
|
|
301
|
+
encrypted_data = combined[272:] # Rest is encrypted data
|
|
302
|
+
|
|
303
|
+
# Decrypt AES key
|
|
304
|
+
aes_key = private_key.decrypt(
|
|
305
|
+
encrypted_key,
|
|
306
|
+
padding.OAEP(
|
|
307
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
308
|
+
algorithm=hashes.SHA256(),
|
|
309
|
+
label=None,
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Create AES cipher
|
|
314
|
+
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
|
|
315
|
+
decryptor = cipher.decryptor()
|
|
316
|
+
|
|
317
|
+
# Decrypt data
|
|
318
|
+
decrypted_padded = decryptor.update(encrypted_data) + decryptor.finalize()
|
|
319
|
+
|
|
320
|
+
# Remove padding
|
|
321
|
+
pad_length = decrypted_padded[-1]
|
|
322
|
+
decrypted = decrypted_padded[:-pad_length]
|
|
323
|
+
|
|
324
|
+
return decrypted.decode("utf-8")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
|
+
# If a copy of the MPL was not distributed with this file, you can obtain one at
|
|
5
|
+
# https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
Supervaizer Deployment CLI
|
|
9
|
+
|
|
10
|
+
This module provides automated deployment capabilities for Supervaizer agents
|
|
11
|
+
to cloud platforms including GCP Cloud Run, AWS App Runner, and DigitalOcean App Platform.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from supervaizer.__version__ import VERSION
|
|
15
|
+
|
|
16
|
+
__version__ = VERSION
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
|
+
# If a copy of the MPL was not distributed with this file, you can obtain one at
|
|
5
|
+
# https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
Deployment CLI Commands
|
|
9
|
+
|
|
10
|
+
This module contains the main CLI commands for the deploy subcommand.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from supervaizer.deploy.commands.plan import plan_deployment
|
|
18
|
+
from supervaizer.deploy.commands.up import deploy_up
|
|
19
|
+
from supervaizer.deploy.commands.down import deploy_down
|
|
20
|
+
from supervaizer.deploy.commands.status import deploy_status
|
|
21
|
+
from supervaizer.deploy.commands.local import local_docker
|
|
22
|
+
from supervaizer.deploy.commands.clean import (
|
|
23
|
+
clean_deployment,
|
|
24
|
+
clean_docker_artifacts,
|
|
25
|
+
clean_state_only,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
# Create the deploy subcommand
|
|
31
|
+
deploy_app = typer.Typer(
|
|
32
|
+
name="deploy",
|
|
33
|
+
help="Deploy Supervaizer agents to cloud platforms. Python dependencies must be managed in pyproject.toml file.",
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Common parameters
|
|
38
|
+
platform_option = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--platform",
|
|
41
|
+
"-p",
|
|
42
|
+
help="Target platform (cloud-run|aws-app-runner|do-app-platform)",
|
|
43
|
+
)
|
|
44
|
+
name_option = typer.Option(
|
|
45
|
+
...,
|
|
46
|
+
"--name",
|
|
47
|
+
"-n",
|
|
48
|
+
help="Service name. Required for local command.",
|
|
49
|
+
prompt="Service name (e.g. my-service)",
|
|
50
|
+
)
|
|
51
|
+
env_option = typer.Option("dev", "--env", "-e", help="Environment (dev|staging|prod)")
|
|
52
|
+
region_option = typer.Option(None, "--region", "-r", help="Provider region")
|
|
53
|
+
project_id_option = typer.Option(
|
|
54
|
+
None, "--project-id", help="GCP project / AWS account / DO project"
|
|
55
|
+
)
|
|
56
|
+
verbose_option = typer.Option(False, "--verbose", "-v", help="Show detailed output")
|
|
57
|
+
|
|
58
|
+
# Additional parameters for specific commands
|
|
59
|
+
image_option = typer.Option(None, "--image", help="Container image (registry/repo:tag)")
|
|
60
|
+
port_option = typer.Option(8000, "--port", help="Application port")
|
|
61
|
+
generate_api_key_option = typer.Option(
|
|
62
|
+
False, "--generate-api-key", help="Generate secure API key"
|
|
63
|
+
)
|
|
64
|
+
generate_rsa_option = typer.Option(
|
|
65
|
+
False, "--generate-rsa", help="Generate RSA private key"
|
|
66
|
+
)
|
|
67
|
+
yes_option = typer.Option(False, "--yes", "-y", help="Non-interactive mode")
|
|
68
|
+
no_rollback_option = typer.Option(False, "--no-rollback", help="Keep failed revision")
|
|
69
|
+
timeout_option = typer.Option(300, "--timeout", help="Deployment timeout in seconds")
|
|
70
|
+
docker_files_only_option = typer.Option(
|
|
71
|
+
False, "--docker-files-only", help="Only generate Docker files without running them"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
controller_file_option = typer.Option(
|
|
75
|
+
"supervaizer_control.py",
|
|
76
|
+
"--controller-file",
|
|
77
|
+
help="Controller file name (default: supervaizer_control.py)",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Clean command options
|
|
81
|
+
force_option = typer.Option(False, "--force", "-f", help="Skip confirmation prompts")
|
|
82
|
+
verbose_option_clean = typer.Option(
|
|
83
|
+
False, "--verbose", "-v", help="Show detailed output"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check_pyproject_toml() -> Path:
|
|
88
|
+
"""Check if pyproject.toml exists in current directory or parent directories."""
|
|
89
|
+
current_dir = Path.cwd()
|
|
90
|
+
|
|
91
|
+
# Check current directory first
|
|
92
|
+
pyproject_path = current_dir / "pyproject.toml"
|
|
93
|
+
if pyproject_path.exists():
|
|
94
|
+
return current_dir
|
|
95
|
+
|
|
96
|
+
# Check parent directories up to 3 levels
|
|
97
|
+
for _ in range(3):
|
|
98
|
+
current_dir = current_dir.parent
|
|
99
|
+
pyproject_path = current_dir / "pyproject.toml"
|
|
100
|
+
if pyproject_path.exists():
|
|
101
|
+
return current_dir
|
|
102
|
+
|
|
103
|
+
# If not found, show help and exit
|
|
104
|
+
_show_pyproject_toml_help()
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _show_pyproject_toml_help() -> None:
|
|
109
|
+
"""Show help message when pyproject.toml is not found."""
|
|
110
|
+
console.print("[bold red]Error:[/] pyproject.toml file not found")
|
|
111
|
+
console.print(
|
|
112
|
+
"The supervaizer deploy command must be run from a directory containing pyproject.toml"
|
|
113
|
+
)
|
|
114
|
+
console.print("or from a subdirectory of such a directory.")
|
|
115
|
+
console.print("\n[bold]Current directory:[/] " + str(Path.cwd()))
|
|
116
|
+
console.print("\n[bold]Please ensure:[/]")
|
|
117
|
+
console.print(" • You are in the correct project directory")
|
|
118
|
+
console.print(" • The pyproject.toml file exists in the project root")
|
|
119
|
+
console.print(" • Python dependencies are properly defined in pyproject.toml")
|
|
120
|
+
console.print("\n[bold]Available deploy commands:[/]")
|
|
121
|
+
console.print(
|
|
122
|
+
" • [bold]supervaizer deploy local[/] - Test deployment locally using Docker Compose"
|
|
123
|
+
)
|
|
124
|
+
console.print(" • [bold]supervaizer deploy up[/] - Deploy or update the service")
|
|
125
|
+
console.print(
|
|
126
|
+
" • [bold]supervaizer deploy down[/] - Destroy the service and cleanup resources"
|
|
127
|
+
)
|
|
128
|
+
console.print(
|
|
129
|
+
" • [bold]supervaizer deploy status[/] - Show deployment status and health information"
|
|
130
|
+
)
|
|
131
|
+
console.print(
|
|
132
|
+
" • [bold]supervaizer deploy plan[/] - Plan deployment changes without applying them"
|
|
133
|
+
)
|
|
134
|
+
console.print(
|
|
135
|
+
" • [bold]supervaizer deploy clean[/] - Clean up deployment artifacts and generated files"
|
|
136
|
+
)
|
|
137
|
+
console.print(
|
|
138
|
+
"\nUse [bold]supervaizer deploy <command> --help[/] for more information about each command."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@deploy_app.callback()
|
|
143
|
+
def deploy_callback() -> None:
|
|
144
|
+
"""Deploy Supervaizer agents to cloud platforms."""
|
|
145
|
+
# This callback is called when no subcommand is provided
|
|
146
|
+
# The no_args_is_help=True will automatically show help
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_platform_required(platform: str, command_name: str) -> None:
|
|
151
|
+
"""Check if platform is provided and show helpful error if not."""
|
|
152
|
+
if platform is None:
|
|
153
|
+
console.print("[bold red]Error:[/] --platform is required")
|
|
154
|
+
console.print(
|
|
155
|
+
f"Use [bold]supervaizer deploy {command_name} --help[/] for more information"
|
|
156
|
+
)
|
|
157
|
+
raise typer.Exit(1)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@deploy_app.command(no_args_is_help=True)
|
|
161
|
+
def plan(
|
|
162
|
+
platform: str = platform_option,
|
|
163
|
+
name: str = name_option,
|
|
164
|
+
env: str = env_option,
|
|
165
|
+
region: str = region_option,
|
|
166
|
+
project_id: str = project_id_option,
|
|
167
|
+
verbose: bool = verbose_option,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Plan deployment changes without applying them."""
|
|
170
|
+
# Check if deploy extras are installed (e.g., docker, cloud SDKs)
|
|
171
|
+
try:
|
|
172
|
+
import docker
|
|
173
|
+
except ImportError:
|
|
174
|
+
console.print(
|
|
175
|
+
"[bold red]Error:[/] 'deploy' extra requirements are not installed. "
|
|
176
|
+
"Install them with: [bold]pip install supervaizer[deploy][/]"
|
|
177
|
+
)
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
_check_platform_required(platform, "plan")
|
|
180
|
+
source_dir = _check_pyproject_toml()
|
|
181
|
+
plan_deployment(platform, name, env, region, project_id, verbose, source_dir)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@deploy_app.command(no_args_is_help=True)
|
|
185
|
+
def up(
|
|
186
|
+
platform: str = platform_option,
|
|
187
|
+
name: str = name_option,
|
|
188
|
+
env: str = env_option,
|
|
189
|
+
region: str = region_option,
|
|
190
|
+
project_id: str = project_id_option,
|
|
191
|
+
image: str = image_option,
|
|
192
|
+
port: int = port_option,
|
|
193
|
+
generate_api_key: bool = generate_api_key_option,
|
|
194
|
+
generate_rsa: bool = generate_rsa_option,
|
|
195
|
+
yes: bool = yes_option,
|
|
196
|
+
no_rollback: bool = no_rollback_option,
|
|
197
|
+
timeout: int = timeout_option,
|
|
198
|
+
verbose: bool = verbose_option,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Deploy or update the service."""
|
|
201
|
+
_check_platform_required(platform, "up")
|
|
202
|
+
source_dir = _check_pyproject_toml()
|
|
203
|
+
deploy_up(
|
|
204
|
+
platform,
|
|
205
|
+
name,
|
|
206
|
+
env,
|
|
207
|
+
region,
|
|
208
|
+
project_id,
|
|
209
|
+
image,
|
|
210
|
+
port,
|
|
211
|
+
generate_api_key,
|
|
212
|
+
generate_rsa,
|
|
213
|
+
yes,
|
|
214
|
+
no_rollback,
|
|
215
|
+
timeout,
|
|
216
|
+
verbose,
|
|
217
|
+
source_dir,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@deploy_app.command(no_args_is_help=True)
|
|
222
|
+
def down(
|
|
223
|
+
platform: str = platform_option,
|
|
224
|
+
name: str = name_option,
|
|
225
|
+
env: str = env_option,
|
|
226
|
+
region: str = region_option,
|
|
227
|
+
project_id: str = project_id_option,
|
|
228
|
+
yes: bool = yes_option,
|
|
229
|
+
verbose: bool = verbose_option,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Destroy the service and cleanup resources."""
|
|
232
|
+
_check_platform_required(platform, "down")
|
|
233
|
+
source_dir = _check_pyproject_toml()
|
|
234
|
+
deploy_down(platform, name, env, region, project_id, yes, verbose, source_dir)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@deploy_app.command(no_args_is_help=True)
|
|
238
|
+
def status(
|
|
239
|
+
platform: str = platform_option,
|
|
240
|
+
name: str = name_option,
|
|
241
|
+
env: str = env_option,
|
|
242
|
+
region: str = region_option,
|
|
243
|
+
project_id: str = project_id_option,
|
|
244
|
+
verbose: bool = verbose_option,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Show deployment status and health information."""
|
|
247
|
+
_check_platform_required(platform, "status")
|
|
248
|
+
source_dir = _check_pyproject_toml()
|
|
249
|
+
deploy_status(platform, name, env, region, project_id, verbose, source_dir)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@deploy_app.command()
|
|
253
|
+
def local(
|
|
254
|
+
name: str = name_option,
|
|
255
|
+
env: str = env_option,
|
|
256
|
+
port: int = port_option,
|
|
257
|
+
generate_api_key: bool = generate_api_key_option,
|
|
258
|
+
generate_rsa: bool = generate_rsa_option,
|
|
259
|
+
timeout: int = timeout_option,
|
|
260
|
+
verbose: bool = verbose_option,
|
|
261
|
+
docker_files_only: bool = docker_files_only_option,
|
|
262
|
+
controller_file: str = controller_file_option,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Test deployment locally using Docker Compose. Requires --name."""
|
|
265
|
+
source_dir = _check_pyproject_toml()
|
|
266
|
+
local_docker(
|
|
267
|
+
name,
|
|
268
|
+
env,
|
|
269
|
+
port,
|
|
270
|
+
generate_api_key,
|
|
271
|
+
generate_rsa,
|
|
272
|
+
timeout,
|
|
273
|
+
verbose,
|
|
274
|
+
docker_files_only,
|
|
275
|
+
str(source_dir),
|
|
276
|
+
controller_file,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@deploy_app.command()
|
|
281
|
+
def clean(
|
|
282
|
+
force: bool = force_option,
|
|
283
|
+
verbose: bool = verbose_option_clean,
|
|
284
|
+
docker_only: bool = typer.Option(
|
|
285
|
+
False, "--docker-only", help="Clean only Docker artifacts"
|
|
286
|
+
),
|
|
287
|
+
state_only: bool = typer.Option(
|
|
288
|
+
False, "--state-only", help="Clean only deployment state"
|
|
289
|
+
),
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Clean up deployment artifacts and generated files."""
|
|
292
|
+
_check_pyproject_toml()
|
|
293
|
+
|
|
294
|
+
if docker_only and state_only:
|
|
295
|
+
console.print(
|
|
296
|
+
"[bold red]Error:[/] Cannot use both --docker-only and --state-only"
|
|
297
|
+
)
|
|
298
|
+
raise typer.Exit(1)
|
|
299
|
+
|
|
300
|
+
if docker_only:
|
|
301
|
+
clean_docker_artifacts(force=force, verbose=verbose)
|
|
302
|
+
elif state_only:
|
|
303
|
+
clean_state_only(force=force, verbose=verbose)
|
|
304
|
+
else:
|
|
305
|
+
clean_deployment(force=force, verbose=verbose)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
|
+
# If a copy of the MPL was not distributed with this file, you can obtain one at
|
|
5
|
+
# https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
from . import plan, up, down, status
|
|
8
|
+
|
|
9
|
+
__all__ = ["plan", "up", "down", "status"]
|