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.
Files changed (76) hide show
  1. supervaizer/__init__.py +97 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +308 -0
  4. supervaizer/account_service.py +93 -0
  5. supervaizer/admin/routes.py +1293 -0
  6. supervaizer/admin/static/js/job-start-form.js +373 -0
  7. supervaizer/admin/templates/agent_detail.html +145 -0
  8. supervaizer/admin/templates/agents.html +249 -0
  9. supervaizer/admin/templates/agents_grid.html +82 -0
  10. supervaizer/admin/templates/base.html +233 -0
  11. supervaizer/admin/templates/case_detail.html +230 -0
  12. supervaizer/admin/templates/cases_list.html +182 -0
  13. supervaizer/admin/templates/cases_table.html +134 -0
  14. supervaizer/admin/templates/console.html +389 -0
  15. supervaizer/admin/templates/dashboard.html +153 -0
  16. supervaizer/admin/templates/job_detail.html +192 -0
  17. supervaizer/admin/templates/job_start_test.html +109 -0
  18. supervaizer/admin/templates/jobs_list.html +180 -0
  19. supervaizer/admin/templates/jobs_table.html +122 -0
  20. supervaizer/admin/templates/navigation.html +163 -0
  21. supervaizer/admin/templates/recent_activity.html +81 -0
  22. supervaizer/admin/templates/server.html +105 -0
  23. supervaizer/admin/templates/server_status_cards.html +121 -0
  24. supervaizer/admin/templates/supervaize_instructions.html +212 -0
  25. supervaizer/agent.py +956 -0
  26. supervaizer/case.py +432 -0
  27. supervaizer/cli.py +395 -0
  28. supervaizer/common.py +324 -0
  29. supervaizer/deploy/__init__.py +16 -0
  30. supervaizer/deploy/cli.py +305 -0
  31. supervaizer/deploy/commands/__init__.py +9 -0
  32. supervaizer/deploy/commands/clean.py +294 -0
  33. supervaizer/deploy/commands/down.py +119 -0
  34. supervaizer/deploy/commands/local.py +460 -0
  35. supervaizer/deploy/commands/plan.py +167 -0
  36. supervaizer/deploy/commands/status.py +169 -0
  37. supervaizer/deploy/commands/up.py +281 -0
  38. supervaizer/deploy/docker.py +377 -0
  39. supervaizer/deploy/driver_factory.py +42 -0
  40. supervaizer/deploy/drivers/__init__.py +39 -0
  41. supervaizer/deploy/drivers/aws_app_runner.py +607 -0
  42. supervaizer/deploy/drivers/base.py +196 -0
  43. supervaizer/deploy/drivers/cloud_run.py +570 -0
  44. supervaizer/deploy/drivers/do_app_platform.py +504 -0
  45. supervaizer/deploy/health.py +404 -0
  46. supervaizer/deploy/state.py +210 -0
  47. supervaizer/deploy/templates/Dockerfile.template +44 -0
  48. supervaizer/deploy/templates/debug_env.py +69 -0
  49. supervaizer/deploy/templates/docker-compose.yml.template +37 -0
  50. supervaizer/deploy/templates/dockerignore.template +66 -0
  51. supervaizer/deploy/templates/entrypoint.sh +20 -0
  52. supervaizer/deploy/utils.py +52 -0
  53. supervaizer/event.py +181 -0
  54. supervaizer/examples/controller_template.py +196 -0
  55. supervaizer/instructions.py +145 -0
  56. supervaizer/job.py +392 -0
  57. supervaizer/job_service.py +156 -0
  58. supervaizer/lifecycle.py +417 -0
  59. supervaizer/parameter.py +233 -0
  60. supervaizer/protocol/__init__.py +11 -0
  61. supervaizer/protocol/a2a/__init__.py +21 -0
  62. supervaizer/protocol/a2a/model.py +227 -0
  63. supervaizer/protocol/a2a/routes.py +99 -0
  64. supervaizer/py.typed +1 -0
  65. supervaizer/routes.py +917 -0
  66. supervaizer/server.py +553 -0
  67. supervaizer/server_utils.py +54 -0
  68. supervaizer/storage.py +462 -0
  69. supervaizer/telemetry.py +81 -0
  70. supervaizer/utils/__init__.py +16 -0
  71. supervaizer/utils/version_check.py +56 -0
  72. supervaizer-0.10.5.dist-info/METADATA +317 -0
  73. supervaizer-0.10.5.dist-info/RECORD +76 -0
  74. supervaizer-0.10.5.dist-info/WHEEL +4 -0
  75. supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
  76. supervaizer-0.10.5.dist-info/licenses/LICENSE.md +346 -0
@@ -0,0 +1,570 @@
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
+ GCP Cloud Run Driver
9
+
10
+ This module implements deployment to Google Cloud Platform Cloud Run.
11
+ """
12
+
13
+ import subprocess
14
+ import time
15
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
16
+
17
+ from rich.console import Console
18
+
19
+ from supervaizer.common import log
20
+ from supervaizer.deploy.drivers.base import (
21
+ ActionType,
22
+ BaseDriver,
23
+ DeploymentPlan,
24
+ DeploymentResult,
25
+ ResourceAction,
26
+ ResourceType,
27
+ )
28
+
29
+ console = Console()
30
+
31
+ # Conditional imports for Google Cloud libraries
32
+ if TYPE_CHECKING:
33
+ from google.cloud import artifactregistry_v1
34
+ from google.cloud import run_v2
35
+ from google.cloud import secretmanager
36
+ from google.cloud.exceptions import NotFound
37
+
38
+ GOOGLE_CLOUD_AVAILABLE = True
39
+ else:
40
+ try:
41
+ from google.cloud import artifactregistry_v1
42
+ from google.cloud import run_v2
43
+ from google.cloud import secretmanager
44
+ from google.cloud.exceptions import NotFound
45
+
46
+ GOOGLE_CLOUD_AVAILABLE = True
47
+ except ImportError:
48
+ GOOGLE_CLOUD_AVAILABLE = False
49
+
50
+ # Create dummy classes for type hints when not available
51
+ class NotFound(Exception):
52
+ pass
53
+
54
+ class artifactregistry_v1:
55
+ class ArtifactRegistryClient:
56
+ pass
57
+
58
+ class run_v2:
59
+ class ServicesClient:
60
+ pass
61
+
62
+ class secretmanager:
63
+ class SecretManagerServiceClient:
64
+ pass
65
+
66
+
67
+ class CloudRunDriver(BaseDriver):
68
+ """Driver for deploying to GCP Cloud Run."""
69
+
70
+ def __init__(self, region: str, project_id: str):
71
+ """Initialize Cloud Run driver."""
72
+ if not GOOGLE_CLOUD_AVAILABLE:
73
+ raise ImportError(
74
+ "Google Cloud libraries not available. Install with: "
75
+ "pip install google-cloud-run google-cloud-secret-manager google-cloud-artifact-registry"
76
+ )
77
+
78
+ super().__init__(region, project_id)
79
+ self.project_id = project_id
80
+
81
+ # Initialize clients
82
+ self.run_client = run_v2.ServicesClient()
83
+ self.secret_client = secretmanager.SecretManagerServiceClient()
84
+ self.registry_client = artifactregistry_v1.ArtifactRegistryClient()
85
+
86
+ # Service configuration
87
+ self.service_parent = f"projects/{project_id}/locations/{region}"
88
+ self.registry_location = f"projects/{project_id}/locations/{region}"
89
+
90
+ def plan_deployment(
91
+ self,
92
+ service_name: str,
93
+ environment: str,
94
+ image_tag: str,
95
+ port: int = 8000,
96
+ env_vars: Optional[Dict[str, str]] = None,
97
+ secrets: Optional[Dict[str, str]] = None,
98
+ ) -> DeploymentPlan:
99
+ """Plan deployment changes without applying them."""
100
+ full_service_name = self.get_service_key(service_name, environment)
101
+ service_path = f"{self.service_parent}/services/{full_service_name}"
102
+
103
+ actions = []
104
+ current_image = None
105
+ current_url = None
106
+ current_status = None
107
+
108
+ # Check if service exists
109
+ try:
110
+ service = self.run_client.get_service(name=service_path)
111
+ current_image = service.template.containers[0].image
112
+ current_url = service.uri
113
+ current_status = "running"
114
+
115
+ # Check if update is needed
116
+ if current_image != image_tag:
117
+ actions.append(
118
+ ResourceAction(
119
+ resource_type=ResourceType.SERVICE,
120
+ action_type=ActionType.UPDATE,
121
+ resource_name=full_service_name,
122
+ description=f"Update service image from {current_image} to {image_tag}",
123
+ )
124
+ )
125
+ else:
126
+ actions.append(
127
+ ResourceAction(
128
+ resource_type=ResourceType.SERVICE,
129
+ action_type=ActionType.NOOP,
130
+ resource_name=full_service_name,
131
+ description="Service image is up to date",
132
+ )
133
+ )
134
+ except NotFound:
135
+ # Service doesn't exist, need to create
136
+ actions.append(
137
+ ResourceAction(
138
+ resource_type=ResourceType.SERVICE,
139
+ action_type=ActionType.CREATE,
140
+ resource_name=full_service_name,
141
+ description=f"Create new Cloud Run service with image {image_tag}",
142
+ )
143
+ )
144
+
145
+ # Check secrets
146
+ if secrets:
147
+ for secret_name, secret_value in secrets.items():
148
+ secret_path = f"projects/{self.project_id}/secrets/{secret_name}"
149
+ try:
150
+ self.secret_client.get_secret(name=secret_path)
151
+ actions.append(
152
+ ResourceAction(
153
+ resource_type=ResourceType.SECRET,
154
+ action_type=ActionType.UPDATE,
155
+ resource_name=secret_name,
156
+ description=f"Update secret {secret_name}",
157
+ )
158
+ )
159
+ except NotFound:
160
+ actions.append(
161
+ ResourceAction(
162
+ resource_type=ResourceType.SECRET,
163
+ action_type=ActionType.CREATE,
164
+ resource_name=secret_name,
165
+ description=f"Create secret {secret_name}",
166
+ )
167
+ )
168
+
169
+ return DeploymentPlan(
170
+ platform="cloud-run",
171
+ service_name=service_name,
172
+ environment=environment,
173
+ region=self.region,
174
+ project_id=self.project_id,
175
+ actions=actions,
176
+ current_image=current_image,
177
+ current_url=current_url,
178
+ current_status=current_status,
179
+ target_image=image_tag,
180
+ target_port=port,
181
+ target_env_vars=env_vars or {},
182
+ target_secrets=secrets or {},
183
+ )
184
+
185
+ def deploy_service(
186
+ self,
187
+ service_name: str,
188
+ environment: str,
189
+ image_tag: str,
190
+ port: int = 8000,
191
+ env_vars: Optional[Dict[str, str]] = None,
192
+ secrets: Optional[Dict[str, str]] = None,
193
+ timeout: int = 300,
194
+ ) -> DeploymentResult:
195
+ """Deploy or update the service."""
196
+ start_time = time.time()
197
+ full_service_name = self.get_service_key(service_name, environment)
198
+ service_path = f"{self.service_parent}/services/{full_service_name}"
199
+
200
+ try:
201
+ # Create/update secrets first
202
+ if secrets:
203
+ self._create_or_update_secrets(secrets)
204
+
205
+ # Create/update service
206
+ service = self._create_or_update_service(
207
+ full_service_name,
208
+ image_tag,
209
+ port,
210
+ env_vars or {},
211
+ secrets or {},
212
+ )
213
+
214
+ # Wait for service to be ready
215
+ service_url = self._wait_for_service_ready(service_path, timeout)
216
+
217
+ # Set SUPERVAIZER_PUBLIC_URL
218
+ if service_url:
219
+ self._set_public_url(service_path, service_url)
220
+
221
+ # Verify health
222
+ health_status = (
223
+ "healthy"
224
+ if service_url and self.verify_health(service_url)
225
+ else "unhealthy"
226
+ )
227
+
228
+ deployment_time = time.time() - start_time
229
+
230
+ return DeploymentResult(
231
+ success=True,
232
+ service_url=service_url,
233
+ service_id=service.name,
234
+ revision=service.latest_ready_revision,
235
+ status="running",
236
+ health_status=health_status,
237
+ deployment_time=deployment_time,
238
+ )
239
+
240
+ except Exception as e:
241
+ log.error(f"Deployment failed: {e}")
242
+ return DeploymentResult(
243
+ success=False,
244
+ error_message=str(e),
245
+ error_details={"exception_type": type(e).__name__},
246
+ )
247
+
248
+ def destroy_service(
249
+ self,
250
+ service_name: str,
251
+ environment: str,
252
+ keep_secrets: bool = False,
253
+ ) -> DeploymentResult:
254
+ """Destroy the service and cleanup resources."""
255
+ full_service_name = self.get_service_key(service_name, environment)
256
+ service_path = f"{self.service_parent}/services/{full_service_name}"
257
+
258
+ try:
259
+ # Delete service
260
+ self.run_client.delete_service(name=service_path)
261
+ log.info(f"Deleted Cloud Run service: {full_service_name}")
262
+
263
+ # Delete secrets if requested
264
+ if not keep_secrets:
265
+ self._delete_secrets(full_service_name)
266
+
267
+ return DeploymentResult(
268
+ success=True,
269
+ status="deleted",
270
+ )
271
+
272
+ except NotFound:
273
+ log.warning(f"Service {full_service_name} not found")
274
+ return DeploymentResult(
275
+ success=True,
276
+ status="not_found",
277
+ )
278
+ except Exception as e:
279
+ log.error(f"Failed to destroy service: {e}")
280
+ return DeploymentResult(
281
+ success=False,
282
+ error_message=str(e),
283
+ )
284
+
285
+ def get_service_status(
286
+ self,
287
+ service_name: str,
288
+ environment: str,
289
+ ) -> DeploymentResult:
290
+ """Get current service status and health."""
291
+ full_service_name = self.get_service_key(service_name, environment)
292
+ service_path = f"{self.service_parent}/services/{full_service_name}"
293
+
294
+ try:
295
+ service = self.run_client.get_service(name=service_path)
296
+
297
+ # Check health
298
+ health_status = "unknown"
299
+ if service.uri:
300
+ health_status = (
301
+ "healthy" if self.verify_health(service.uri) else "unhealthy"
302
+ )
303
+
304
+ return DeploymentResult(
305
+ success=True,
306
+ service_url=service.uri,
307
+ service_id=service.name,
308
+ revision=service.latest_ready_revision,
309
+ status="running",
310
+ health_status=health_status,
311
+ )
312
+
313
+ except NotFound:
314
+ return DeploymentResult(
315
+ success=False,
316
+ status="not_found",
317
+ error_message="Service not found",
318
+ )
319
+ except Exception as e:
320
+ return DeploymentResult(
321
+ success=False,
322
+ error_message=str(e),
323
+ )
324
+
325
+ def verify_health(self, service_url: str, timeout: int = 60) -> bool:
326
+ """Verify service health by checking the health endpoint."""
327
+ return self.verify_health_enhanced(service_url, timeout=timeout)
328
+
329
+ def check_prerequisites(self) -> List[str]:
330
+ """Check prerequisites and return list of missing requirements."""
331
+ errors = []
332
+
333
+ # Check gcloud CLI
334
+ try:
335
+ result = subprocess.run(
336
+ ["gcloud", "version"], capture_output=True, text=True, check=True
337
+ )
338
+ log.debug(f"gcloud version: {result.stdout}")
339
+ except (subprocess.CalledProcessError, FileNotFoundError):
340
+ errors.append("gcloud CLI not found or not working")
341
+
342
+ # Check authentication
343
+ try:
344
+ result = subprocess.run(
345
+ [
346
+ "gcloud",
347
+ "auth",
348
+ "list",
349
+ "--filter=status:ACTIVE",
350
+ "--format=value(account)",
351
+ ],
352
+ capture_output=True,
353
+ text=True,
354
+ check=True,
355
+ )
356
+ if not result.stdout.strip():
357
+ errors.append("No active gcloud authentication found")
358
+ except subprocess.CalledProcessError:
359
+ errors.append("gcloud authentication check failed")
360
+
361
+ # Check project
362
+ try:
363
+ result = subprocess.run(
364
+ ["gcloud", "config", "get-value", "project"],
365
+ capture_output=True,
366
+ text=True,
367
+ check=True,
368
+ )
369
+ if not result.stdout.strip():
370
+ errors.append("No gcloud project configured")
371
+ except subprocess.CalledProcessError:
372
+ errors.append("gcloud project configuration check failed")
373
+
374
+ # Check APIs
375
+ required_apis = [
376
+ "run.googleapis.com",
377
+ "secretmanager.googleapis.com",
378
+ "artifactregistry.googleapis.com",
379
+ ]
380
+
381
+ for api in required_apis:
382
+ try:
383
+ result = subprocess.run(
384
+ ["gcloud", "services", "list", "--enabled", f"--filter=name:{api}"],
385
+ capture_output=True,
386
+ text=True,
387
+ check=True,
388
+ )
389
+ if api not in result.stdout:
390
+ errors.append(f"API {api} not enabled")
391
+ except subprocess.CalledProcessError:
392
+ errors.append(f"Failed to check API {api}")
393
+
394
+ return errors
395
+
396
+ def _create_or_update_secrets(self, secrets: Dict[str, str]) -> None:
397
+ """Create or update secrets in Secret Manager."""
398
+ for secret_name, secret_value in secrets.items():
399
+ secret_path = f"projects/{self.project_id}/secrets/{secret_name}"
400
+
401
+ try:
402
+ # Check if secret exists
403
+ self.secret_client.get_secret(name=secret_path)
404
+
405
+ # Update existing secret
406
+ parent = f"projects/{self.project_id}"
407
+ self.secret_client.add_secret_version(
408
+ request={
409
+ "parent": secret_path,
410
+ "payload": {"data": secret_value.encode("utf-8")},
411
+ }
412
+ )
413
+ log.info(f"Updated secret {secret_name}")
414
+
415
+ except NotFound:
416
+ # Create new secret
417
+ parent = f"projects/{self.project_id}"
418
+ secret = self.secret_client.create_secret(
419
+ request={
420
+ "parent": parent,
421
+ "secret_id": secret_name,
422
+ "secret": {"replication": {"automatic": {}}},
423
+ }
424
+ )
425
+
426
+ # Add secret version
427
+ self.secret_client.add_secret_version(
428
+ request={
429
+ "parent": secret.name,
430
+ "payload": {"data": secret_value.encode("utf-8")},
431
+ }
432
+ )
433
+ log.info(f"Created secret {secret_name}")
434
+
435
+ def _create_or_update_service(
436
+ self,
437
+ service_name: str,
438
+ image_tag: str,
439
+ port: int,
440
+ env_vars: Dict[str, str],
441
+ secrets: Dict[str, str],
442
+ ) -> Any:
443
+ """Create or update Cloud Run service."""
444
+ service_path = f"{self.service_parent}/services/{service_name}"
445
+
446
+ # Build environment variables
447
+ env_vars_list = []
448
+ for key, value in env_vars.items():
449
+ env_vars_list.append({"name": key, "value": value})
450
+
451
+ # Build secret references
452
+ secret_refs = []
453
+ for secret_name in secrets.keys():
454
+ secret_refs.append(
455
+ {
456
+ "name": secret_name,
457
+ "value_source": {
458
+ "secret_key_ref": {
459
+ "secret": f"projects/{self.project_id}/secrets/{secret_name}",
460
+ "version": "latest",
461
+ }
462
+ },
463
+ }
464
+ )
465
+
466
+ # Service configuration
467
+ service_config = {
468
+ "template": {
469
+ "containers": [
470
+ {
471
+ "image": image_tag,
472
+ "ports": [{"container_port": port}],
473
+ "env": env_vars_list + secret_refs,
474
+ "resources": {
475
+ "limits": {"cpu": "1", "memory": "512Mi"},
476
+ },
477
+ }
478
+ ],
479
+ "scaling": {
480
+ "min_instance_count": 1,
481
+ "max_instance_count": 10,
482
+ },
483
+ },
484
+ "traffic": [
485
+ {"percent": 100, "type": "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"}
486
+ ],
487
+ }
488
+
489
+ try:
490
+ # Try to update existing service
491
+ service = self.run_client.update_service(
492
+ request={"service": service_config, "name": service_path}
493
+ )
494
+ log.info(f"Updated Cloud Run service: {service_name}")
495
+ return service
496
+
497
+ except NotFound:
498
+ # Create new service
499
+ service_config["name"] = service_path
500
+ service = self.run_client.create_service(
501
+ request={"parent": self.service_parent, "service": service_config}
502
+ )
503
+ log.info(f"Created Cloud Run service: {service_name}")
504
+ return service
505
+
506
+ def _wait_for_service_ready(self, service_path: str, timeout: int) -> Optional[str]:
507
+ """Wait for service to be ready and return URL."""
508
+ start_time = time.time()
509
+
510
+ while time.time() - start_time < timeout:
511
+ try:
512
+ service = self.run_client.get_service(name=service_path)
513
+ if service.uri:
514
+ log.info(f"Service ready at: {service.uri}")
515
+ return service.uri
516
+ except Exception as e:
517
+ log.debug(f"Waiting for service to be ready: {e}")
518
+
519
+ time.sleep(5)
520
+
521
+ raise TimeoutError(f"Service did not become ready within {timeout} seconds")
522
+
523
+ def _set_public_url(self, service_path: str, public_url: str) -> None:
524
+ """Set SUPERVAIZER_PUBLIC_URL environment variable."""
525
+ try:
526
+ service = self.run_client.get_service(name=service_path)
527
+
528
+ # Update environment variables
529
+ env_vars = []
530
+ for env_var in service.template.containers[0].env:
531
+ if env_var.name != "SUPERVAIZER_PUBLIC_URL":
532
+ env_vars.append(env_var)
533
+
534
+ # Add the public URL
535
+ env_vars.append(
536
+ {
537
+ "name": "SUPERVAIZER_PUBLIC_URL",
538
+ "value": public_url,
539
+ }
540
+ )
541
+
542
+ # Update service
543
+ service.template.containers[0].env = env_vars
544
+ self.run_client.update_service(
545
+ request={"service": service, "name": service_path}
546
+ )
547
+
548
+ log.info(f"Set SUPERVAIZER_PUBLIC_URL to {public_url}")
549
+
550
+ except Exception as e:
551
+ log.error(f"Failed to set SUPERVAIZER_PUBLIC_URL: {e}")
552
+
553
+ def _delete_secrets(self, service_name: str) -> None:
554
+ """Delete secrets associated with the service."""
555
+ # This is a simplified implementation
556
+ # In practice, you might want to be more selective about which secrets to delete
557
+ common_secrets = [
558
+ f"{service_name}-api-key",
559
+ f"{service_name}-rsa-key",
560
+ ]
561
+
562
+ for secret_name in common_secrets:
563
+ secret_path = f"projects/{self.project_id}/secrets/{secret_name}"
564
+ try:
565
+ self.secret_client.delete_secret(name=secret_path)
566
+ log.info(f"Deleted secret {secret_name}")
567
+ except NotFound:
568
+ pass # Secret doesn't exist, that's fine
569
+ except Exception as e:
570
+ log.warning(f"Failed to delete secret {secret_name}: {e}")