vm-tool 1.0.32__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.
- examples/README.md +5 -0
- examples/__init__.py +1 -0
- examples/cloud/README.md +3 -0
- examples/cloud/__init__.py +1 -0
- examples/cloud/ssh_identity_file.py +27 -0
- examples/cloud/ssh_password.py +27 -0
- examples/cloud/template_cloud_setup.py +36 -0
- examples/deploy_full_setup.py +44 -0
- examples/docker-compose.example.yml +47 -0
- examples/ec2-setup.sh +95 -0
- examples/github-actions-ec2.yml +245 -0
- examples/github-actions-full-setup.yml +58 -0
- examples/local/.keep +1 -0
- examples/local/README.md +3 -0
- examples/local/__init__.py +1 -0
- examples/local/template_local_setup.py +27 -0
- examples/production-deploy.sh +70 -0
- examples/rollback.sh +52 -0
- examples/setup.sh +52 -0
- examples/ssh_key_management.py +22 -0
- examples/version_check.sh +3 -0
- vm_tool/__init__.py +0 -0
- vm_tool/alerting.py +274 -0
- vm_tool/audit.py +118 -0
- vm_tool/backup.py +125 -0
- vm_tool/benchmarking.py +200 -0
- vm_tool/cli.py +761 -0
- vm_tool/cloud.py +125 -0
- vm_tool/completion.py +200 -0
- vm_tool/compliance.py +104 -0
- vm_tool/config.py +92 -0
- vm_tool/drift.py +98 -0
- vm_tool/generator.py +462 -0
- vm_tool/health.py +197 -0
- vm_tool/history.py +131 -0
- vm_tool/kubernetes.py +89 -0
- vm_tool/metrics.py +183 -0
- vm_tool/notifications.py +152 -0
- vm_tool/plugins.py +119 -0
- vm_tool/policy.py +197 -0
- vm_tool/rbac.py +140 -0
- vm_tool/recovery.py +169 -0
- vm_tool/reporting.py +218 -0
- vm_tool/runner.py +445 -0
- vm_tool/secrets.py +285 -0
- vm_tool/ssh.py +150 -0
- vm_tool/state.py +122 -0
- vm_tool/strategies/__init__.py +16 -0
- vm_tool/strategies/ab_testing.py +258 -0
- vm_tool/strategies/blue_green.py +227 -0
- vm_tool/strategies/canary.py +277 -0
- vm_tool/validation.py +267 -0
- vm_tool/vm_setup/cleanup.yml +27 -0
- vm_tool/vm_setup/docker/create_docker_service.yml +63 -0
- vm_tool/vm_setup/docker/docker_setup.yml +7 -0
- vm_tool/vm_setup/docker/install_docker_and_compose.yml +92 -0
- vm_tool/vm_setup/docker/login_to_docker_hub.yml +6 -0
- vm_tool/vm_setup/github/git_configuration.yml +68 -0
- vm_tool/vm_setup/inventory.yml +1 -0
- vm_tool/vm_setup/k8s.yml +15 -0
- vm_tool/vm_setup/main.yml +27 -0
- vm_tool/vm_setup/monitoring.yml +42 -0
- vm_tool/vm_setup/project_service.yml +17 -0
- vm_tool/vm_setup/push_code.yml +40 -0
- vm_tool/vm_setup/setup.yml +17 -0
- vm_tool/vm_setup/setup_project_env.yml +7 -0
- vm_tool/webhooks.py +83 -0
- vm_tool-1.0.32.dist-info/METADATA +213 -0
- vm_tool-1.0.32.dist-info/RECORD +73 -0
- vm_tool-1.0.32.dist-info/WHEEL +5 -0
- vm_tool-1.0.32.dist-info/entry_points.txt +2 -0
- vm_tool-1.0.32.dist-info/licenses/LICENSE +21 -0
- vm_tool-1.0.32.dist-info/top_level.txt +2 -0
vm_tool/history.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Deployment history tracking and rollback functionality."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeploymentHistory:
|
|
13
|
+
"""Manages deployment history for rollback capability."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, history_dir: Optional[Path] = None):
|
|
16
|
+
if history_dir is None:
|
|
17
|
+
history_dir = Path.home() / ".vm_tool"
|
|
18
|
+
self.history_dir = history_dir
|
|
19
|
+
self.history_file = self.history_dir / "deployment_history.json"
|
|
20
|
+
self._ensure_history_dir()
|
|
21
|
+
|
|
22
|
+
def _ensure_history_dir(self):
|
|
23
|
+
"""Create history directory if it doesn't exist."""
|
|
24
|
+
self.history_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
def _load_history(self) -> List[Dict[str, Any]]:
|
|
27
|
+
"""Load deployment history from file."""
|
|
28
|
+
if not self.history_file.exists():
|
|
29
|
+
return []
|
|
30
|
+
try:
|
|
31
|
+
with open(self.history_file, "r") as f:
|
|
32
|
+
return json.load(f)
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
logger.warning("Invalid history file, returning empty history")
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
def _save_history(self, history: List[Dict[str, Any]]):
|
|
38
|
+
"""Save deployment history to file."""
|
|
39
|
+
with open(self.history_file, "w") as f:
|
|
40
|
+
json.dump(history, f, indent=2)
|
|
41
|
+
|
|
42
|
+
def record_deployment(
|
|
43
|
+
self,
|
|
44
|
+
host: str,
|
|
45
|
+
compose_file: str,
|
|
46
|
+
compose_hash: str,
|
|
47
|
+
git_commit: Optional[str] = None,
|
|
48
|
+
service_name: str = "default",
|
|
49
|
+
status: str = "success",
|
|
50
|
+
error: Optional[str] = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Record a deployment in history and return deployment ID."""
|
|
53
|
+
history = self._load_history()
|
|
54
|
+
|
|
55
|
+
deployment_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
56
|
+
|
|
57
|
+
deployment_record = {
|
|
58
|
+
"id": deployment_id,
|
|
59
|
+
"timestamp": datetime.now().isoformat(),
|
|
60
|
+
"host": host,
|
|
61
|
+
"service_name": service_name,
|
|
62
|
+
"compose_file": compose_file,
|
|
63
|
+
"compose_hash": compose_hash,
|
|
64
|
+
"git_commit": git_commit,
|
|
65
|
+
"status": status,
|
|
66
|
+
"error": error,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
history.append(deployment_record)
|
|
70
|
+
|
|
71
|
+
# Keep only last 100 deployments
|
|
72
|
+
if len(history) > 100:
|
|
73
|
+
history = history[-100:]
|
|
74
|
+
|
|
75
|
+
self._save_history(history)
|
|
76
|
+
logger.info(f"Recorded deployment: {deployment_id}")
|
|
77
|
+
|
|
78
|
+
return deployment_id
|
|
79
|
+
|
|
80
|
+
def get_history(
|
|
81
|
+
self, host: Optional[str] = None, limit: int = 10
|
|
82
|
+
) -> List[Dict[str, Any]]:
|
|
83
|
+
"""Get deployment history, optionally filtered by host."""
|
|
84
|
+
history = self._load_history()
|
|
85
|
+
|
|
86
|
+
if host:
|
|
87
|
+
history = [d for d in history if d.get("host") == host]
|
|
88
|
+
|
|
89
|
+
# Return most recent first
|
|
90
|
+
history.reverse()
|
|
91
|
+
|
|
92
|
+
return history[:limit]
|
|
93
|
+
|
|
94
|
+
def get_deployment(self, deployment_id: str) -> Optional[Dict[str, Any]]:
|
|
95
|
+
"""Get a specific deployment by ID."""
|
|
96
|
+
history = self._load_history()
|
|
97
|
+
for deployment in history:
|
|
98
|
+
if deployment.get("id") == deployment_id:
|
|
99
|
+
return deployment
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def get_previous_deployment(
|
|
103
|
+
self, host: str, service_name: str = "default"
|
|
104
|
+
) -> Optional[Dict[str, Any]]:
|
|
105
|
+
"""Get the previous successful deployment for a host/service."""
|
|
106
|
+
history = self._load_history()
|
|
107
|
+
|
|
108
|
+
# Filter by host and service, get successful deployments
|
|
109
|
+
matching = [
|
|
110
|
+
d
|
|
111
|
+
for d in history
|
|
112
|
+
if d.get("host") == host
|
|
113
|
+
and d.get("service_name") == service_name
|
|
114
|
+
and d.get("status") == "success"
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
if len(matching) >= 2:
|
|
118
|
+
# Return second-to-last (previous deployment)
|
|
119
|
+
return matching[-2]
|
|
120
|
+
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def get_rollback_info(
|
|
124
|
+
self, host: str, deployment_id: Optional[str] = None
|
|
125
|
+
) -> Optional[Dict[str, Any]]:
|
|
126
|
+
"""Get rollback information for a deployment."""
|
|
127
|
+
if deployment_id:
|
|
128
|
+
return self.get_deployment(deployment_id)
|
|
129
|
+
else:
|
|
130
|
+
# Get previous deployment
|
|
131
|
+
return self.get_previous_deployment(host)
|
vm_tool/kubernetes.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Kubernetes native support framework."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KubernetesDeployment:
|
|
10
|
+
"""Kubernetes deployment manager (requires kubectl/kubernetes python client)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, kubeconfig: Optional[str] = None, namespace: str = "default"):
|
|
13
|
+
self.kubeconfig = kubeconfig
|
|
14
|
+
self.namespace = namespace
|
|
15
|
+
logger.info(f"Kubernetes deployment initialized (namespace: {namespace})")
|
|
16
|
+
# TODO: Initialize kubernetes client
|
|
17
|
+
|
|
18
|
+
def deploy_helm_chart(
|
|
19
|
+
self, chart_name: str, release_name: str, values: Dict[str, Any]
|
|
20
|
+
) -> bool:
|
|
21
|
+
"""Deploy Helm chart."""
|
|
22
|
+
logger.info(f"Deploying Helm chart: {chart_name} as {release_name}")
|
|
23
|
+
# TODO: Implement with helm or kubernetes client
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
def deploy_manifest(self, manifest_file: str) -> bool:
|
|
27
|
+
"""Deploy Kubernetes manifest."""
|
|
28
|
+
logger.info(f"Deploying manifest: {manifest_file}")
|
|
29
|
+
# TODO: Implement with kubectl or kubernetes client
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def get_pod_status(self, pod_name: str) -> str:
|
|
33
|
+
"""Get pod status."""
|
|
34
|
+
# TODO: Implement
|
|
35
|
+
return "Running"
|
|
36
|
+
|
|
37
|
+
def scale_deployment(self, deployment_name: str, replicas: int) -> bool:
|
|
38
|
+
"""Scale deployment."""
|
|
39
|
+
logger.info(f"Scaling {deployment_name} to {replicas} replicas")
|
|
40
|
+
# TODO: Implement
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
def rollback_deployment(
|
|
44
|
+
self, deployment_name: str, revision: Optional[int] = None
|
|
45
|
+
) -> bool:
|
|
46
|
+
"""Rollback deployment."""
|
|
47
|
+
logger.info(f"Rolling back deployment: {deployment_name}")
|
|
48
|
+
# TODO: Implement
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ServiceMeshIntegration:
|
|
53
|
+
"""Service mesh integration (Istio/Linkerd)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, mesh_type: str = "istio"):
|
|
56
|
+
self.mesh_type = mesh_type
|
|
57
|
+
logger.info(f"Service mesh integration: {mesh_type}")
|
|
58
|
+
|
|
59
|
+
def configure_traffic_split(self, service: str, versions: Dict[str, int]) -> bool:
|
|
60
|
+
"""Configure traffic splitting between versions."""
|
|
61
|
+
logger.info(f"Configuring traffic split for {service}: {versions}")
|
|
62
|
+
# TODO: Implement Istio VirtualService or Linkerd TrafficSplit
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
def enable_mtls(self, namespace: str) -> bool:
|
|
66
|
+
"""Enable mutual TLS."""
|
|
67
|
+
logger.info(f"Enabling mTLS for namespace: {namespace}")
|
|
68
|
+
# TODO: Implement
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class GitOpsIntegration:
|
|
73
|
+
"""GitOps integration (ArgoCD/Flux)."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, gitops_tool: str = "argocd"):
|
|
76
|
+
self.gitops_tool = gitops_tool
|
|
77
|
+
logger.info(f"GitOps integration: {gitops_tool}")
|
|
78
|
+
|
|
79
|
+
def create_application(self, name: str, repo_url: str, path: str) -> bool:
|
|
80
|
+
"""Create GitOps application."""
|
|
81
|
+
logger.info(f"Creating GitOps app: {name} from {repo_url}/{path}")
|
|
82
|
+
# TODO: Implement ArgoCD or Flux application creation
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def sync_application(self, name: str) -> bool:
|
|
86
|
+
"""Sync GitOps application."""
|
|
87
|
+
logger.info(f"Syncing GitOps app: {name}")
|
|
88
|
+
# TODO: Implement
|
|
89
|
+
return True
|
vm_tool/metrics.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Metrics collection and export for deployments."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DeploymentMetrics:
|
|
15
|
+
"""Metrics for a deployment."""
|
|
16
|
+
|
|
17
|
+
deployment_id: str
|
|
18
|
+
host: str
|
|
19
|
+
start_time: float = field(default_factory=time.time)
|
|
20
|
+
end_time: Optional[float] = None
|
|
21
|
+
duration: Optional[float] = None
|
|
22
|
+
success: bool = False
|
|
23
|
+
error: Optional[str] = None
|
|
24
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def finish(self, success: bool = True, error: Optional[str] = None):
|
|
27
|
+
"""Mark deployment as finished."""
|
|
28
|
+
self.end_time = time.time()
|
|
29
|
+
self.duration = self.end_time - self.start_time
|
|
30
|
+
self.success = success
|
|
31
|
+
self.error = error
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
34
|
+
"""Convert to dictionary."""
|
|
35
|
+
return {
|
|
36
|
+
"deployment_id": self.deployment_id,
|
|
37
|
+
"host": self.host,
|
|
38
|
+
"start_time": datetime.fromtimestamp(self.start_time).isoformat(),
|
|
39
|
+
"end_time": (
|
|
40
|
+
datetime.fromtimestamp(self.end_time).isoformat()
|
|
41
|
+
if self.end_time
|
|
42
|
+
else None
|
|
43
|
+
),
|
|
44
|
+
"duration_seconds": self.duration,
|
|
45
|
+
"success": self.success,
|
|
46
|
+
"error": self.error,
|
|
47
|
+
**self.metadata,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MetricsCollector:
|
|
52
|
+
"""Collect and export deployment metrics."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, export_dir: str = ".vm_tool/metrics"):
|
|
55
|
+
self.export_dir = Path(export_dir)
|
|
56
|
+
self.export_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
self.current_deployment: Optional[DeploymentMetrics] = None
|
|
58
|
+
|
|
59
|
+
def start_deployment(
|
|
60
|
+
self, deployment_id: str, host: str, **metadata
|
|
61
|
+
) -> DeploymentMetrics:
|
|
62
|
+
"""Start tracking a deployment."""
|
|
63
|
+
self.current_deployment = DeploymentMetrics(
|
|
64
|
+
deployment_id=deployment_id, host=host, metadata=metadata
|
|
65
|
+
)
|
|
66
|
+
logger.info(f"📊 Started tracking deployment: {deployment_id}")
|
|
67
|
+
return self.current_deployment
|
|
68
|
+
|
|
69
|
+
def finish_deployment(self, success: bool = True, error: Optional[str] = None):
|
|
70
|
+
"""Finish tracking current deployment."""
|
|
71
|
+
if self.current_deployment:
|
|
72
|
+
self.current_deployment.finish(success, error)
|
|
73
|
+
self._export_metrics(self.current_deployment)
|
|
74
|
+
logger.info(
|
|
75
|
+
f"📊 Deployment metrics recorded: {self.current_deployment.duration:.2f}s"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _export_metrics(self, metrics: DeploymentMetrics):
|
|
79
|
+
"""Export metrics to file."""
|
|
80
|
+
import json
|
|
81
|
+
|
|
82
|
+
# Export to JSON
|
|
83
|
+
metrics_file = self.export_dir / f"{metrics.deployment_id}.json"
|
|
84
|
+
with open(metrics_file, "w") as f:
|
|
85
|
+
json.dump(metrics.to_dict(), f, indent=2)
|
|
86
|
+
|
|
87
|
+
# Append to metrics log
|
|
88
|
+
log_file = self.export_dir / "deployments.jsonl"
|
|
89
|
+
with open(log_file, "a") as f:
|
|
90
|
+
f.write(json.dumps(metrics.to_dict()) + "\n")
|
|
91
|
+
|
|
92
|
+
def export_prometheus(
|
|
93
|
+
self, metrics: DeploymentMetrics, output_file: str = "metrics.prom"
|
|
94
|
+
):
|
|
95
|
+
"""Export metrics in Prometheus format."""
|
|
96
|
+
prom_file = self.export_dir / output_file
|
|
97
|
+
|
|
98
|
+
with open(prom_file, "w") as f:
|
|
99
|
+
# Deployment duration
|
|
100
|
+
f.write(f"# HELP deployment_duration_seconds Time taken for deployment\n")
|
|
101
|
+
f.write(f"# TYPE deployment_duration_seconds gauge\n")
|
|
102
|
+
f.write(
|
|
103
|
+
f'deployment_duration_seconds{{host="{metrics.host}",deployment_id="{metrics.deployment_id}"}} {metrics.duration or 0}\n\n'
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Deployment success
|
|
107
|
+
f.write(
|
|
108
|
+
f"# HELP deployment_success Whether deployment succeeded (1) or failed (0)\n"
|
|
109
|
+
)
|
|
110
|
+
f.write(f"# TYPE deployment_success gauge\n")
|
|
111
|
+
f.write(
|
|
112
|
+
f'deployment_success{{host="{metrics.host}",deployment_id="{metrics.deployment_id}"}} {1 if metrics.success else 0}\n\n'
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Deployment timestamp
|
|
116
|
+
f.write(f"# HELP deployment_timestamp_seconds Deployment start time\n")
|
|
117
|
+
f.write(f"# TYPE deployment_timestamp_seconds gauge\n")
|
|
118
|
+
f.write(
|
|
119
|
+
f'deployment_timestamp_seconds{{host="{metrics.host}",deployment_id="{metrics.deployment_id}"}} {metrics.start_time}\n'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
logger.info(f"📊 Prometheus metrics exported to {prom_file}")
|
|
123
|
+
|
|
124
|
+
def get_stats(self, limit: int = 100) -> Dict[str, Any]:
|
|
125
|
+
"""Get deployment statistics."""
|
|
126
|
+
import json
|
|
127
|
+
|
|
128
|
+
log_file = self.export_dir / "deployments.jsonl"
|
|
129
|
+
if not log_file.exists():
|
|
130
|
+
return {"total": 0, "success": 0, "failed": 0}
|
|
131
|
+
|
|
132
|
+
deployments = []
|
|
133
|
+
with open(log_file) as f:
|
|
134
|
+
for line in f:
|
|
135
|
+
deployments.append(json.loads(line))
|
|
136
|
+
|
|
137
|
+
# Get recent deployments
|
|
138
|
+
recent = deployments[-limit:]
|
|
139
|
+
|
|
140
|
+
total = len(recent)
|
|
141
|
+
success = sum(1 for d in recent if d.get("success"))
|
|
142
|
+
failed = total - success
|
|
143
|
+
|
|
144
|
+
durations = [d["duration_seconds"] for d in recent if d.get("duration_seconds")]
|
|
145
|
+
avg_duration = sum(durations) / len(durations) if durations else 0
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"total_deployments": total,
|
|
149
|
+
"successful": success,
|
|
150
|
+
"failed": failed,
|
|
151
|
+
"success_rate": (success / total * 100) if total > 0 else 0,
|
|
152
|
+
"average_duration_seconds": avg_duration,
|
|
153
|
+
"recent_deployments": recent[-10:], # Last 10
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def print_stats(self):
|
|
157
|
+
"""Print deployment statistics."""
|
|
158
|
+
stats = self.get_stats()
|
|
159
|
+
|
|
160
|
+
print("\n📊 Deployment Statistics")
|
|
161
|
+
print("=" * 50)
|
|
162
|
+
print(f"Total Deployments: {stats['total_deployments']}")
|
|
163
|
+
print(f"Successful: {stats['successful']} ({stats['success_rate']:.1f}%)")
|
|
164
|
+
print(f"Failed: {stats['failed']}")
|
|
165
|
+
print(f"Average Duration: {stats['average_duration_seconds']:.2f}s")
|
|
166
|
+
print("\nRecent Deployments:")
|
|
167
|
+
for d in stats["recent_deployments"]:
|
|
168
|
+
status = "✅" if d["success"] else "❌"
|
|
169
|
+
print(
|
|
170
|
+
f" {status} {d['deployment_id']} - {d['host']} - {d['duration_seconds']:.2f}s"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Global metrics collector instance
|
|
175
|
+
_metrics_collector = None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_metrics_collector() -> MetricsCollector:
|
|
179
|
+
"""Get global metrics collector instance."""
|
|
180
|
+
global _metrics_collector
|
|
181
|
+
if _metrics_collector is None:
|
|
182
|
+
_metrics_collector = MetricsCollector()
|
|
183
|
+
return _metrics_collector
|
vm_tool/notifications.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Email notification support for deployment events."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import smtplib
|
|
5
|
+
from email.mime.text import MIMEText
|
|
6
|
+
from email.mime.multipart import MIMEMultipart
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EmailNotifier:
|
|
14
|
+
"""Send email notifications for deployment events."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
smtp_host: str = "localhost",
|
|
19
|
+
smtp_port: int = 587,
|
|
20
|
+
smtp_user: Optional[str] = None,
|
|
21
|
+
smtp_password: Optional[str] = None,
|
|
22
|
+
from_email: str = "vm-tool@localhost",
|
|
23
|
+
use_tls: bool = True,
|
|
24
|
+
):
|
|
25
|
+
self.smtp_host = smtp_host
|
|
26
|
+
self.smtp_port = smtp_port
|
|
27
|
+
self.smtp_user = smtp_user
|
|
28
|
+
self.smtp_password = smtp_password
|
|
29
|
+
self.from_email = from_email
|
|
30
|
+
self.use_tls = use_tls
|
|
31
|
+
|
|
32
|
+
def send_email(
|
|
33
|
+
self, to_emails: List[str], subject: str, body: str, html: bool = False
|
|
34
|
+
) -> bool:
|
|
35
|
+
"""Send email notification.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
to_emails: List of recipient email addresses
|
|
39
|
+
subject: Email subject
|
|
40
|
+
body: Email body
|
|
41
|
+
html: If True, send as HTML email
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if email sent successfully, False otherwise
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
msg = MIMEMultipart("alternative")
|
|
48
|
+
msg["Subject"] = subject
|
|
49
|
+
msg["From"] = self.from_email
|
|
50
|
+
msg["To"] = ", ".join(to_emails)
|
|
51
|
+
msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|
52
|
+
|
|
53
|
+
if html:
|
|
54
|
+
msg.attach(MIMEText(body, "html"))
|
|
55
|
+
else:
|
|
56
|
+
msg.attach(MIMEText(body, "plain"))
|
|
57
|
+
|
|
58
|
+
# Connect to SMTP server
|
|
59
|
+
if self.use_tls:
|
|
60
|
+
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
|
|
61
|
+
server.starttls()
|
|
62
|
+
else:
|
|
63
|
+
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
|
|
64
|
+
|
|
65
|
+
# Login if credentials provided
|
|
66
|
+
if self.smtp_user and self.smtp_password:
|
|
67
|
+
server.login(self.smtp_user, self.smtp_password)
|
|
68
|
+
|
|
69
|
+
# Send email
|
|
70
|
+
server.sendmail(self.from_email, to_emails, msg.as_string())
|
|
71
|
+
server.quit()
|
|
72
|
+
|
|
73
|
+
logger.info(f"Email sent successfully to {', '.join(to_emails)}")
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.error(f"Failed to send email: {e}")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def deployment_success(
|
|
81
|
+
self, to_emails: List[str], host: str, duration: float, **kwargs
|
|
82
|
+
):
|
|
83
|
+
"""Send deployment success notification."""
|
|
84
|
+
subject = f"✅ Deployment Successful - {host}"
|
|
85
|
+
body = f"""
|
|
86
|
+
Deployment completed successfully!
|
|
87
|
+
|
|
88
|
+
Host: {host}
|
|
89
|
+
Duration: {duration:.2f} seconds
|
|
90
|
+
Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}
|
|
91
|
+
|
|
92
|
+
Additional Details:
|
|
93
|
+
{self._format_kwargs(kwargs)}
|
|
94
|
+
"""
|
|
95
|
+
return self.send_email(to_emails, subject, body)
|
|
96
|
+
|
|
97
|
+
def deployment_failed(self, to_emails: List[str], host: str, error: str, **kwargs):
|
|
98
|
+
"""Send deployment failure notification."""
|
|
99
|
+
subject = f"❌ Deployment Failed - {host}"
|
|
100
|
+
body = f"""
|
|
101
|
+
Deployment failed!
|
|
102
|
+
|
|
103
|
+
Host: {host}
|
|
104
|
+
Error: {error}
|
|
105
|
+
Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}
|
|
106
|
+
|
|
107
|
+
Additional Details:
|
|
108
|
+
{self._format_kwargs(kwargs)}
|
|
109
|
+
|
|
110
|
+
Please check the logs for more information.
|
|
111
|
+
"""
|
|
112
|
+
return self.send_email(to_emails, subject, body)
|
|
113
|
+
|
|
114
|
+
def rollback_success(self, to_emails: List[str], host: str, **kwargs):
|
|
115
|
+
"""Send rollback success notification."""
|
|
116
|
+
subject = f"🔄 Rollback Successful - {host}"
|
|
117
|
+
body = f"""
|
|
118
|
+
Rollback completed successfully!
|
|
119
|
+
|
|
120
|
+
Host: {host}
|
|
121
|
+
Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}
|
|
122
|
+
|
|
123
|
+
Additional Details:
|
|
124
|
+
{self._format_kwargs(kwargs)}
|
|
125
|
+
"""
|
|
126
|
+
return self.send_email(to_emails, subject, body)
|
|
127
|
+
|
|
128
|
+
def health_check_failed(
|
|
129
|
+
self, to_emails: List[str], host: str, check_type: str, **kwargs
|
|
130
|
+
):
|
|
131
|
+
"""Send health check failure notification."""
|
|
132
|
+
subject = f"⚠️ Health Check Failed - {host}"
|
|
133
|
+
body = f"""
|
|
134
|
+
Health check failed!
|
|
135
|
+
|
|
136
|
+
Host: {host}
|
|
137
|
+
Check Type: {check_type}
|
|
138
|
+
Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}
|
|
139
|
+
|
|
140
|
+
Additional Details:
|
|
141
|
+
{self._format_kwargs(kwargs)}
|
|
142
|
+
|
|
143
|
+
Please investigate immediately.
|
|
144
|
+
"""
|
|
145
|
+
return self.send_email(to_emails, subject, body)
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _format_kwargs(kwargs: dict) -> str:
|
|
149
|
+
"""Format kwargs as readable string."""
|
|
150
|
+
if not kwargs:
|
|
151
|
+
return "None"
|
|
152
|
+
return "\n".join(f" {k}: {v}" for k, v in kwargs.items())
|
vm_tool/plugins.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Plugin system for extensibility."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Any, Callable, List
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import importlib.util
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Plugin:
|
|
12
|
+
"""Base plugin class."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, name: str, version: str):
|
|
15
|
+
self.name = name
|
|
16
|
+
self.version = version
|
|
17
|
+
|
|
18
|
+
def on_deployment_start(self, context: Dict[str, Any]):
|
|
19
|
+
"""Called before deployment starts."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def on_deployment_success(self, context: Dict[str, Any]):
|
|
23
|
+
"""Called after successful deployment."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def on_deployment_failure(self, context: Dict[str, Any]):
|
|
27
|
+
"""Called after failed deployment."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PluginManager:
|
|
32
|
+
"""Manage plugins."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, plugin_dir: str = ".vm_tool/plugins"):
|
|
35
|
+
self.plugin_dir = Path(plugin_dir)
|
|
36
|
+
self.plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.plugins: List[Plugin] = []
|
|
38
|
+
|
|
39
|
+
def load_plugin(self, plugin_path: str):
|
|
40
|
+
"""Load a plugin from file."""
|
|
41
|
+
logger.info(f"Loading plugin: {plugin_path}")
|
|
42
|
+
|
|
43
|
+
# TODO: Implement plugin loading
|
|
44
|
+
# - Load Python module
|
|
45
|
+
# - Instantiate plugin class
|
|
46
|
+
# - Register hooks
|
|
47
|
+
|
|
48
|
+
def register_plugin(self, plugin: Plugin):
|
|
49
|
+
"""Register a plugin."""
|
|
50
|
+
self.plugins.append(plugin)
|
|
51
|
+
logger.info(f"Registered plugin: {plugin.name} v{plugin.version}")
|
|
52
|
+
|
|
53
|
+
def trigger_hook(self, hook_name: str, context: Dict[str, Any]):
|
|
54
|
+
"""Trigger plugin hooks."""
|
|
55
|
+
for plugin in self.plugins:
|
|
56
|
+
try:
|
|
57
|
+
hook = getattr(plugin, hook_name, None)
|
|
58
|
+
if hook and callable(hook):
|
|
59
|
+
hook(context)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.error(f"Plugin {plugin.name} hook {hook_name} failed: {e}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Integration stubs for nice-to-have features
|
|
65
|
+
class SlackBot:
|
|
66
|
+
"""Slack bot integration."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, bot_token: str):
|
|
69
|
+
self.bot_token = bot_token
|
|
70
|
+
logger.info("Slack bot initialized")
|
|
71
|
+
|
|
72
|
+
def send_message(self, channel: str, message: str):
|
|
73
|
+
"""Send message to Slack channel."""
|
|
74
|
+
logger.info(f"Sending to #{channel}: {message}")
|
|
75
|
+
# TODO: Implement with slack_sdk
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DiscordIntegration:
|
|
79
|
+
"""Discord integration."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, webhook_url: str):
|
|
82
|
+
self.webhook_url = webhook_url
|
|
83
|
+
logger.info("Discord integration initialized")
|
|
84
|
+
|
|
85
|
+
def send_notification(self, message: str):
|
|
86
|
+
"""Send notification to Discord."""
|
|
87
|
+
logger.info(f"Discord notification: {message}")
|
|
88
|
+
# TODO: Implement with discord.py
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TerraformProvider:
|
|
92
|
+
"""Terraform provider stub."""
|
|
93
|
+
|
|
94
|
+
def __init__(self):
|
|
95
|
+
logger.info("Terraform provider initialized")
|
|
96
|
+
# TODO: Implement Terraform provider
|
|
97
|
+
# This would be a separate Go project
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class GitHubApp:
|
|
101
|
+
"""GitHub App integration."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, app_id: str, private_key: str):
|
|
104
|
+
self.app_id = app_id
|
|
105
|
+
logger.info(f"GitHub App initialized: {app_id}")
|
|
106
|
+
# TODO: Implement with PyGithub
|
|
107
|
+
|
|
108
|
+
def create_deployment(self, repo: str, ref: str):
|
|
109
|
+
"""Create GitHub deployment."""
|
|
110
|
+
logger.info(f"Creating GitHub deployment: {repo}@{ref}")
|
|
111
|
+
# TODO: Implement
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class VSCodeExtension:
|
|
115
|
+
"""VS Code extension stub."""
|
|
116
|
+
|
|
117
|
+
# This would be a separate TypeScript/JavaScript project
|
|
118
|
+
# Placeholder for documentation
|
|
119
|
+
pass
|