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
|
@@ -0,0 +1,504 @@
|
|
|
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
|
+
DigitalOcean App Platform Driver
|
|
9
|
+
|
|
10
|
+
This module implements deployment to DigitalOcean App Platform.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from supervaizer.common import log
|
|
22
|
+
from supervaizer.deploy.drivers.base import (
|
|
23
|
+
ActionType,
|
|
24
|
+
BaseDriver,
|
|
25
|
+
DeploymentPlan,
|
|
26
|
+
DeploymentResult,
|
|
27
|
+
ResourceAction,
|
|
28
|
+
ResourceType,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DOAppPlatformDriver(BaseDriver):
|
|
35
|
+
"""Driver for deploying to DigitalOcean App Platform."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, region: str, project_id: Optional[str] = None):
|
|
38
|
+
"""Initialize DigitalOcean App Platform driver."""
|
|
39
|
+
super().__init__(region, project_id)
|
|
40
|
+
self.project_id = project_id
|
|
41
|
+
|
|
42
|
+
def plan_deployment(
|
|
43
|
+
self,
|
|
44
|
+
service_name: str,
|
|
45
|
+
environment: str,
|
|
46
|
+
image_tag: str,
|
|
47
|
+
port: int = 8000,
|
|
48
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
49
|
+
secrets: Optional[Dict[str, str]] = None,
|
|
50
|
+
) -> DeploymentPlan:
|
|
51
|
+
"""Plan deployment changes without applying them."""
|
|
52
|
+
full_service_name = self.get_service_key(service_name, environment)
|
|
53
|
+
|
|
54
|
+
actions = []
|
|
55
|
+
current_image = None
|
|
56
|
+
current_url = None
|
|
57
|
+
current_status = None
|
|
58
|
+
|
|
59
|
+
# Check if app exists
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["doctl", "apps", "get", full_service_name, "--format", "json"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
check=True,
|
|
66
|
+
)
|
|
67
|
+
app_data = json.loads(result.stdout)
|
|
68
|
+
current_url = (
|
|
69
|
+
app_data.get("spec", {}).get("ingress", {}).get("default_route")
|
|
70
|
+
)
|
|
71
|
+
current_status = app_data.get("last_deployment_active_at")
|
|
72
|
+
|
|
73
|
+
# Check if update is needed
|
|
74
|
+
actions.append(
|
|
75
|
+
ResourceAction(
|
|
76
|
+
resource_type=ResourceType.SERVICE,
|
|
77
|
+
action_type=ActionType.UPDATE,
|
|
78
|
+
resource_name=full_service_name,
|
|
79
|
+
description=f"Update App Platform app with image {image_tag}",
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
except subprocess.CalledProcessError:
|
|
83
|
+
# App doesn't exist, need to create
|
|
84
|
+
actions.append(
|
|
85
|
+
ResourceAction(
|
|
86
|
+
resource_type=ResourceType.SERVICE,
|
|
87
|
+
action_type=ActionType.CREATE,
|
|
88
|
+
resource_name=full_service_name,
|
|
89
|
+
description=f"Create new App Platform app with image {image_tag}",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Check registry
|
|
94
|
+
registry_name = f"{service_name}-{environment}"
|
|
95
|
+
try:
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
["doctl", "registry", "get", registry_name, "--format", "json"],
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
check=True,
|
|
101
|
+
)
|
|
102
|
+
actions.append(
|
|
103
|
+
ResourceAction(
|
|
104
|
+
resource_type=ResourceType.REGISTRY,
|
|
105
|
+
action_type=ActionType.NOOP,
|
|
106
|
+
resource_name=registry_name,
|
|
107
|
+
description="Container registry exists",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
except subprocess.CalledProcessError:
|
|
111
|
+
actions.append(
|
|
112
|
+
ResourceAction(
|
|
113
|
+
resource_type=ResourceType.REGISTRY,
|
|
114
|
+
action_type=ActionType.CREATE,
|
|
115
|
+
resource_name=registry_name,
|
|
116
|
+
description=f"Create container registry {registry_name}",
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return DeploymentPlan(
|
|
121
|
+
platform="do-app-platform",
|
|
122
|
+
service_name=service_name,
|
|
123
|
+
environment=environment,
|
|
124
|
+
region=self.region,
|
|
125
|
+
project_id=self.project_id,
|
|
126
|
+
actions=actions,
|
|
127
|
+
current_image=current_image,
|
|
128
|
+
current_url=current_url,
|
|
129
|
+
current_status=current_status,
|
|
130
|
+
target_image=image_tag,
|
|
131
|
+
target_port=port,
|
|
132
|
+
target_env_vars=env_vars or {},
|
|
133
|
+
target_secrets=secrets or {},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def deploy_service(
|
|
137
|
+
self,
|
|
138
|
+
service_name: str,
|
|
139
|
+
environment: str,
|
|
140
|
+
image_tag: str,
|
|
141
|
+
port: int = 8000,
|
|
142
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
143
|
+
secrets: Optional[Dict[str, str]] = None,
|
|
144
|
+
timeout: int = 300,
|
|
145
|
+
) -> DeploymentResult:
|
|
146
|
+
"""Deploy or update the service."""
|
|
147
|
+
start_time = time.time()
|
|
148
|
+
full_service_name = self.get_service_key(service_name, environment)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
# Ensure registry exists
|
|
152
|
+
registry_name = f"{service_name}-{environment}"
|
|
153
|
+
self._ensure_registry(registry_name)
|
|
154
|
+
|
|
155
|
+
# Create/update app spec
|
|
156
|
+
app_spec_path = self._create_app_spec(
|
|
157
|
+
full_service_name,
|
|
158
|
+
registry_name,
|
|
159
|
+
image_tag,
|
|
160
|
+
port,
|
|
161
|
+
env_vars or {},
|
|
162
|
+
secrets or {},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Deploy app
|
|
166
|
+
service_url = self._deploy_app(full_service_name, app_spec_path, timeout)
|
|
167
|
+
|
|
168
|
+
# Set SUPERVAIZER_PUBLIC_URL
|
|
169
|
+
if service_url:
|
|
170
|
+
self._set_public_url(full_service_name, service_url, app_spec_path)
|
|
171
|
+
|
|
172
|
+
# Verify health
|
|
173
|
+
health_status = (
|
|
174
|
+
"healthy"
|
|
175
|
+
if service_url and self.verify_health(service_url)
|
|
176
|
+
else "unhealthy"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
deployment_time = time.time() - start_time
|
|
180
|
+
|
|
181
|
+
return DeploymentResult(
|
|
182
|
+
success=True,
|
|
183
|
+
service_url=service_url,
|
|
184
|
+
service_id=full_service_name,
|
|
185
|
+
status="running",
|
|
186
|
+
health_status=health_status,
|
|
187
|
+
deployment_time=deployment_time,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
log.error(f"Deployment failed: {e}")
|
|
192
|
+
return DeploymentResult(
|
|
193
|
+
success=False,
|
|
194
|
+
error_message=str(e),
|
|
195
|
+
error_details={"exception_type": type(e).__name__},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def destroy_service(
|
|
199
|
+
self,
|
|
200
|
+
service_name: str,
|
|
201
|
+
environment: str,
|
|
202
|
+
keep_secrets: bool = False,
|
|
203
|
+
) -> DeploymentResult:
|
|
204
|
+
"""Destroy the service and cleanup resources."""
|
|
205
|
+
full_service_name = self.get_service_key(service_name, environment)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
# Delete app
|
|
209
|
+
subprocess.run(
|
|
210
|
+
["doctl", "apps", "delete", full_service_name, "--force"], check=True
|
|
211
|
+
)
|
|
212
|
+
log.info(f"Deleted App Platform app: {full_service_name}")
|
|
213
|
+
|
|
214
|
+
# Delete registry
|
|
215
|
+
registry_name = f"{service_name}-{environment}"
|
|
216
|
+
try:
|
|
217
|
+
subprocess.run(
|
|
218
|
+
["doctl", "registry", "delete", registry_name, "--force"],
|
|
219
|
+
check=True,
|
|
220
|
+
)
|
|
221
|
+
log.info(f"Deleted container registry: {registry_name}")
|
|
222
|
+
except subprocess.CalledProcessError:
|
|
223
|
+
log.warning(f"Failed to delete registry {registry_name}")
|
|
224
|
+
|
|
225
|
+
return DeploymentResult(
|
|
226
|
+
success=True,
|
|
227
|
+
status="deleted",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
except subprocess.CalledProcessError as e:
|
|
231
|
+
log.error(f"Failed to destroy service: {e}")
|
|
232
|
+
return DeploymentResult(
|
|
233
|
+
success=False,
|
|
234
|
+
error_message=str(e),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def get_service_status(
|
|
238
|
+
self,
|
|
239
|
+
service_name: str,
|
|
240
|
+
environment: str,
|
|
241
|
+
) -> DeploymentResult:
|
|
242
|
+
"""Get current service status and health."""
|
|
243
|
+
full_service_name = self.get_service_key(service_name, environment)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
result = subprocess.run(
|
|
247
|
+
["doctl", "apps", "get", full_service_name, "--format", "json"],
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
check=True,
|
|
251
|
+
)
|
|
252
|
+
app_data = json.loads(result.stdout)
|
|
253
|
+
|
|
254
|
+
service_url = (
|
|
255
|
+
app_data.get("spec", {}).get("ingress", {}).get("default_route")
|
|
256
|
+
)
|
|
257
|
+
status = app_data.get("last_deployment_active_at", "unknown")
|
|
258
|
+
|
|
259
|
+
# Check health
|
|
260
|
+
health_status = "unknown"
|
|
261
|
+
if service_url:
|
|
262
|
+
health_status = (
|
|
263
|
+
"healthy" if self.verify_health(service_url) else "unhealthy"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return DeploymentResult(
|
|
267
|
+
success=True,
|
|
268
|
+
service_url=service_url,
|
|
269
|
+
service_id=full_service_name,
|
|
270
|
+
status=status,
|
|
271
|
+
health_status=health_status,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
except subprocess.CalledProcessError:
|
|
275
|
+
return DeploymentResult(
|
|
276
|
+
success=False,
|
|
277
|
+
status="not_found",
|
|
278
|
+
error_message="App not found",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def verify_health(self, service_url: str, timeout: int = 60) -> bool:
|
|
282
|
+
"""Verify service health by checking the health endpoint."""
|
|
283
|
+
return self.verify_health_enhanced(service_url, timeout=timeout)
|
|
284
|
+
|
|
285
|
+
def check_prerequisites(self) -> List[str]:
|
|
286
|
+
"""Check prerequisites and return list of missing requirements."""
|
|
287
|
+
errors = []
|
|
288
|
+
|
|
289
|
+
# Check doctl CLI
|
|
290
|
+
try:
|
|
291
|
+
result = subprocess.run(
|
|
292
|
+
["doctl", "version"], capture_output=True, text=True, check=True
|
|
293
|
+
)
|
|
294
|
+
log.debug(f"doctl version: {result.stdout}")
|
|
295
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
296
|
+
errors.append("doctl CLI not found or not working")
|
|
297
|
+
|
|
298
|
+
# Check authentication
|
|
299
|
+
try:
|
|
300
|
+
result = subprocess.run(
|
|
301
|
+
["doctl", "account", "get", "--output", "json"],
|
|
302
|
+
capture_output=True,
|
|
303
|
+
text=True,
|
|
304
|
+
check=True,
|
|
305
|
+
)
|
|
306
|
+
account_data = json.loads(result.stdout)
|
|
307
|
+
if not account_data.get("email"):
|
|
308
|
+
errors.append("doctl authentication not configured")
|
|
309
|
+
except (subprocess.CalledProcessError, json.JSONDecodeError):
|
|
310
|
+
errors.append("doctl authentication check failed")
|
|
311
|
+
|
|
312
|
+
return errors
|
|
313
|
+
|
|
314
|
+
def _ensure_registry(self, registry_name: str) -> None:
|
|
315
|
+
"""Ensure container registry exists."""
|
|
316
|
+
try:
|
|
317
|
+
subprocess.run(
|
|
318
|
+
["doctl", "registry", "get", registry_name],
|
|
319
|
+
capture_output=True,
|
|
320
|
+
check=True,
|
|
321
|
+
)
|
|
322
|
+
log.info(f"Container registry {registry_name} exists")
|
|
323
|
+
except subprocess.CalledProcessError:
|
|
324
|
+
# Create registry
|
|
325
|
+
subprocess.run(
|
|
326
|
+
["doctl", "registry", "create", registry_name, "--region", self.region],
|
|
327
|
+
check=True,
|
|
328
|
+
)
|
|
329
|
+
log.info(f"Created container registry: {registry_name}")
|
|
330
|
+
|
|
331
|
+
def _create_app_spec(
|
|
332
|
+
self,
|
|
333
|
+
app_name: str,
|
|
334
|
+
registry_name: str,
|
|
335
|
+
image_tag: str,
|
|
336
|
+
port: int,
|
|
337
|
+
env_vars: Dict[str, str],
|
|
338
|
+
secrets: Dict[str, str],
|
|
339
|
+
) -> Path:
|
|
340
|
+
"""Create App Platform specification file."""
|
|
341
|
+
# Build environment variables
|
|
342
|
+
env_vars_list = []
|
|
343
|
+
for key, value in env_vars.items():
|
|
344
|
+
env_vars_list.append({"key": key, "value": value})
|
|
345
|
+
|
|
346
|
+
# Build secret references
|
|
347
|
+
secret_refs = []
|
|
348
|
+
for secret_name in secrets.keys():
|
|
349
|
+
secret_refs.append(
|
|
350
|
+
{
|
|
351
|
+
"key": secret_name,
|
|
352
|
+
"scope": "RUN_TIME",
|
|
353
|
+
"type": "SECRET",
|
|
354
|
+
}
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# App spec
|
|
358
|
+
app_spec = {
|
|
359
|
+
"name": app_name,
|
|
360
|
+
"services": [
|
|
361
|
+
{
|
|
362
|
+
"name": "web",
|
|
363
|
+
"source_dir": "/",
|
|
364
|
+
"github": {
|
|
365
|
+
"repo": "supervaizer",
|
|
366
|
+
"branch": "main",
|
|
367
|
+
"deploy_on_push": False,
|
|
368
|
+
},
|
|
369
|
+
"dockerfile_path": "Dockerfile",
|
|
370
|
+
"http_port": port,
|
|
371
|
+
"instance_count": 1,
|
|
372
|
+
"instance_size_slug": "basic-xxs",
|
|
373
|
+
"routes": [{"path": "/"}],
|
|
374
|
+
"health_check": {
|
|
375
|
+
"http_path": "/.well-known/health",
|
|
376
|
+
"initial_delay_seconds": 10,
|
|
377
|
+
"period_seconds": 10,
|
|
378
|
+
"timeout_seconds": 5,
|
|
379
|
+
"success_threshold": 1,
|
|
380
|
+
"failure_threshold": 3,
|
|
381
|
+
},
|
|
382
|
+
"envs": env_vars_list + secret_refs,
|
|
383
|
+
}
|
|
384
|
+
],
|
|
385
|
+
"region": self.region,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
# Write spec to file
|
|
389
|
+
spec_path = Path(".deployment") / "do-app-spec.yaml"
|
|
390
|
+
spec_path.parent.mkdir(exist_ok=True)
|
|
391
|
+
|
|
392
|
+
import yaml # type: ignore[import-untyped]
|
|
393
|
+
|
|
394
|
+
with open(spec_path, "w") as f:
|
|
395
|
+
yaml.dump(app_spec, f, default_flow_style=False)
|
|
396
|
+
|
|
397
|
+
log.info(f"Created app spec at {spec_path}")
|
|
398
|
+
return spec_path
|
|
399
|
+
|
|
400
|
+
def _deploy_app(
|
|
401
|
+
self, app_name: str, spec_path: Path, timeout: int
|
|
402
|
+
) -> Optional[str]:
|
|
403
|
+
"""Deploy app using doctl."""
|
|
404
|
+
try:
|
|
405
|
+
# Check if app exists
|
|
406
|
+
try:
|
|
407
|
+
subprocess.run(
|
|
408
|
+
["doctl", "apps", "get", app_name], capture_output=True, check=True
|
|
409
|
+
)
|
|
410
|
+
# App exists, update it
|
|
411
|
+
subprocess.run(
|
|
412
|
+
["doctl", "apps", "update", app_name, "--spec", str(spec_path)],
|
|
413
|
+
capture_output=True,
|
|
414
|
+
text=True,
|
|
415
|
+
check=True,
|
|
416
|
+
)
|
|
417
|
+
log.info(f"Updated App Platform app: {app_name}")
|
|
418
|
+
except subprocess.CalledProcessError:
|
|
419
|
+
# App doesn't exist, create it
|
|
420
|
+
subprocess.run(
|
|
421
|
+
["doctl", "apps", "create", "--spec", str(spec_path)],
|
|
422
|
+
capture_output=True,
|
|
423
|
+
text=True,
|
|
424
|
+
check=True,
|
|
425
|
+
)
|
|
426
|
+
log.info(f"Created App Platform app: {app_name}")
|
|
427
|
+
|
|
428
|
+
# Wait for deployment to complete
|
|
429
|
+
return self._wait_for_deployment(app_name, timeout)
|
|
430
|
+
|
|
431
|
+
except subprocess.CalledProcessError as e:
|
|
432
|
+
log.error(f"Failed to deploy app: {e}")
|
|
433
|
+
raise RuntimeError(f"App deployment failed: {e}") from e
|
|
434
|
+
|
|
435
|
+
def _wait_for_deployment(self, app_name: str, timeout: int) -> Optional[str]:
|
|
436
|
+
"""Wait for deployment to complete and return URL."""
|
|
437
|
+
start_time = time.time()
|
|
438
|
+
|
|
439
|
+
while time.time() - start_time < timeout:
|
|
440
|
+
try:
|
|
441
|
+
result = subprocess.run(
|
|
442
|
+
["doctl", "apps", "get", app_name, "--format", "json"],
|
|
443
|
+
capture_output=True,
|
|
444
|
+
text=True,
|
|
445
|
+
check=True,
|
|
446
|
+
)
|
|
447
|
+
app_data = json.loads(result.stdout)
|
|
448
|
+
|
|
449
|
+
# Check if deployment is active
|
|
450
|
+
if app_data.get("last_deployment_active_at"):
|
|
451
|
+
service_url = (
|
|
452
|
+
app_data.get("spec", {}).get("ingress", {}).get("default_route")
|
|
453
|
+
)
|
|
454
|
+
if service_url:
|
|
455
|
+
log.info(f"App deployed at: {service_url}")
|
|
456
|
+
return service_url
|
|
457
|
+
|
|
458
|
+
# Check for failed deployment
|
|
459
|
+
if app_data.get("last_deployment_created_at") and not app_data.get(
|
|
460
|
+
"last_deployment_active_at"
|
|
461
|
+
):
|
|
462
|
+
raise RuntimeError("Deployment failed")
|
|
463
|
+
|
|
464
|
+
except Exception as e:
|
|
465
|
+
log.debug(f"Waiting for deployment: {e}")
|
|
466
|
+
|
|
467
|
+
time.sleep(10)
|
|
468
|
+
|
|
469
|
+
raise TimeoutError(f"Deployment did not complete within {timeout} seconds")
|
|
470
|
+
|
|
471
|
+
def _set_public_url(self, app_name: str, public_url: str, spec_path: Path) -> None:
|
|
472
|
+
"""Set SUPERVAIZER_PUBLIC_URL environment variable."""
|
|
473
|
+
try:
|
|
474
|
+
# Read current spec
|
|
475
|
+
import yaml
|
|
476
|
+
|
|
477
|
+
with open(spec_path, "r") as f:
|
|
478
|
+
spec = yaml.safe_load(f)
|
|
479
|
+
|
|
480
|
+
# Update environment variables
|
|
481
|
+
envs = spec["services"][0].get("envs", [])
|
|
482
|
+
|
|
483
|
+
# Remove existing SUPERVAIZER_PUBLIC_URL
|
|
484
|
+
envs = [env for env in envs if env.get("key") != "SUPERVAIZER_PUBLIC_URL"]
|
|
485
|
+
|
|
486
|
+
# Add the public URL
|
|
487
|
+
envs.append({"key": "SUPERVAIZER_PUBLIC_URL", "value": public_url})
|
|
488
|
+
|
|
489
|
+
spec["services"][0]["envs"] = envs
|
|
490
|
+
|
|
491
|
+
# Write updated spec
|
|
492
|
+
with open(spec_path, "w") as f:
|
|
493
|
+
yaml.dump(spec, f, default_flow_style=False)
|
|
494
|
+
|
|
495
|
+
# Update app
|
|
496
|
+
subprocess.run(
|
|
497
|
+
["doctl", "apps", "update", app_name, "--spec", str(spec_path)],
|
|
498
|
+
check=True,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
log.info(f"Set SUPERVAIZER_PUBLIC_URL to {public_url}")
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
log.error(f"Failed to set SUPERVAIZER_PUBLIC_URL: {e}")
|