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/secrets.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Secrets management with support for multiple backends."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecretsBackend(ABC):
|
|
12
|
+
"""Abstract base class for secrets backends."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def get_secret(self, key: str) -> Optional[str]:
|
|
16
|
+
"""Get secret value by key."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def set_secret(self, key: str, value: str) -> bool:
|
|
21
|
+
"""Set secret value."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def delete_secret(self, key: str) -> bool:
|
|
26
|
+
"""Delete secret."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def list_secrets(self) -> list:
|
|
31
|
+
"""List all secret keys."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class VaultBackend(SecretsBackend):
|
|
36
|
+
"""HashiCorp Vault secrets backend."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
vault_url: str,
|
|
41
|
+
vault_token: Optional[str] = None,
|
|
42
|
+
mount_point: str = "secret",
|
|
43
|
+
):
|
|
44
|
+
self.vault_url = vault_url.rstrip("/")
|
|
45
|
+
self.vault_token = vault_token or os.getenv("VAULT_TOKEN")
|
|
46
|
+
self.mount_point = mount_point
|
|
47
|
+
|
|
48
|
+
if not self.vault_token:
|
|
49
|
+
raise ValueError("Vault token required (set VAULT_TOKEN env var)")
|
|
50
|
+
|
|
51
|
+
def get_secret(self, key: str) -> Optional[str]:
|
|
52
|
+
"""Get secret from Vault."""
|
|
53
|
+
import requests
|
|
54
|
+
|
|
55
|
+
url = f"{self.vault_url}/v1/{self.mount_point}/data/{key}"
|
|
56
|
+
headers = {"X-Vault-Token": self.vault_token}
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
response = requests.get(url, headers=headers)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
data = response.json()
|
|
62
|
+
return data.get("data", {}).get("data", {}).get("value")
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Failed to get secret from Vault: {e}")
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def set_secret(self, key: str, value: str) -> bool:
|
|
68
|
+
"""Set secret in Vault."""
|
|
69
|
+
import requests
|
|
70
|
+
|
|
71
|
+
url = f"{self.vault_url}/v1/{self.mount_point}/data/{key}"
|
|
72
|
+
headers = {"X-Vault-Token": self.vault_token}
|
|
73
|
+
payload = {"data": {"value": value}}
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
response = requests.post(url, headers=headers, json=payload)
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
logger.info(f"Secret '{key}' stored in Vault")
|
|
79
|
+
return True
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(f"Failed to set secret in Vault: {e}")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def delete_secret(self, key: str) -> bool:
|
|
85
|
+
"""Delete secret from Vault."""
|
|
86
|
+
import requests
|
|
87
|
+
|
|
88
|
+
url = f"{self.vault_url}/v1/{self.mount_point}/metadata/{key}"
|
|
89
|
+
headers = {"X-Vault-Token": self.vault_token}
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
response = requests.delete(url, headers=headers)
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
logger.info(f"Secret '{key}' deleted from Vault")
|
|
95
|
+
return True
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Failed to delete secret from Vault: {e}")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def list_secrets(self) -> list:
|
|
101
|
+
"""List all secrets in Vault."""
|
|
102
|
+
import requests
|
|
103
|
+
|
|
104
|
+
url = f"{self.vault_url}/v1/{self.mount_point}/metadata"
|
|
105
|
+
headers = {"X-Vault-Token": self.vault_token}
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
response = requests.get(url, headers=headers, params={"list": "true"})
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
data = response.json()
|
|
111
|
+
return data.get("data", {}).get("keys", [])
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(f"Failed to list secrets from Vault: {e}")
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AWSSecretsBackend(SecretsBackend):
|
|
118
|
+
"""AWS Secrets Manager backend."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, region: str = "us-east-1"):
|
|
121
|
+
try:
|
|
122
|
+
import boto3
|
|
123
|
+
|
|
124
|
+
self.client = boto3.client("secretsmanager", region_name=region)
|
|
125
|
+
except ImportError:
|
|
126
|
+
raise ImportError(
|
|
127
|
+
"boto3 required for AWS Secrets Manager (pip install boto3)"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def get_secret(self, key: str) -> Optional[str]:
|
|
131
|
+
"""Get secret from AWS Secrets Manager."""
|
|
132
|
+
try:
|
|
133
|
+
response = self.client.get_secret_value(SecretId=key)
|
|
134
|
+
return response.get("SecretString")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.error(f"Failed to get secret from AWS: {e}")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def set_secret(self, key: str, value: str) -> bool:
|
|
140
|
+
"""Set secret in AWS Secrets Manager."""
|
|
141
|
+
try:
|
|
142
|
+
# Try to update existing secret first
|
|
143
|
+
try:
|
|
144
|
+
self.client.update_secret(SecretId=key, SecretString=value)
|
|
145
|
+
except self.client.exceptions.ResourceNotFoundException:
|
|
146
|
+
# Create new secret if it doesn't exist
|
|
147
|
+
self.client.create_secret(Name=key, SecretString=value)
|
|
148
|
+
|
|
149
|
+
logger.info(f"Secret '{key}' stored in AWS Secrets Manager")
|
|
150
|
+
return True
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Failed to set secret in AWS: {e}")
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def delete_secret(self, key: str) -> bool:
|
|
156
|
+
"""Delete secret from AWS Secrets Manager."""
|
|
157
|
+
try:
|
|
158
|
+
self.client.delete_secret(SecretId=key, ForceDeleteWithoutRecovery=True)
|
|
159
|
+
logger.info(f"Secret '{key}' deleted from AWS Secrets Manager")
|
|
160
|
+
return True
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error(f"Failed to delete secret from AWS: {e}")
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def list_secrets(self) -> list:
|
|
166
|
+
"""List all secrets in AWS Secrets Manager."""
|
|
167
|
+
try:
|
|
168
|
+
response = self.client.list_secrets()
|
|
169
|
+
return [s["Name"] for s in response.get("SecretList", [])]
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"Failed to list secrets from AWS: {e}")
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class EncryptedFileBackend(SecretsBackend):
|
|
176
|
+
"""Encrypted file-based secrets backend."""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self, secrets_file: str = ".secrets.enc", encryption_key: Optional[str] = None
|
|
180
|
+
):
|
|
181
|
+
self.secrets_file = secrets_file
|
|
182
|
+
self.encryption_key = encryption_key or os.getenv("SECRETS_KEY")
|
|
183
|
+
|
|
184
|
+
if not self.encryption_key:
|
|
185
|
+
raise ValueError("Encryption key required (set SECRETS_KEY env var)")
|
|
186
|
+
|
|
187
|
+
self.secrets = self._load_secrets()
|
|
188
|
+
|
|
189
|
+
def get_secret(self, key: str) -> Optional[str]:
|
|
190
|
+
"""Get secret from encrypted file."""
|
|
191
|
+
return self.secrets.get(key)
|
|
192
|
+
|
|
193
|
+
def set_secret(self, key: str, value: str) -> bool:
|
|
194
|
+
"""Set secret in encrypted file."""
|
|
195
|
+
self.secrets[key] = value
|
|
196
|
+
return self._save_secrets()
|
|
197
|
+
|
|
198
|
+
def delete_secret(self, key: str) -> bool:
|
|
199
|
+
"""Delete secret from encrypted file."""
|
|
200
|
+
if key in self.secrets:
|
|
201
|
+
del self.secrets[key]
|
|
202
|
+
return self._save_secrets()
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
def list_secrets(self) -> list:
|
|
206
|
+
"""List all secret keys."""
|
|
207
|
+
return list(self.secrets.keys())
|
|
208
|
+
|
|
209
|
+
def _load_secrets(self) -> Dict[str, str]:
|
|
210
|
+
"""Load and decrypt secrets from file."""
|
|
211
|
+
if not os.path.exists(self.secrets_file):
|
|
212
|
+
return {}
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
from cryptography.fernet import Fernet
|
|
216
|
+
|
|
217
|
+
with open(self.secrets_file, "rb") as f:
|
|
218
|
+
encrypted_data = f.read()
|
|
219
|
+
|
|
220
|
+
fernet = Fernet(self.encryption_key.encode())
|
|
221
|
+
decrypted_data = fernet.decrypt(encrypted_data)
|
|
222
|
+
|
|
223
|
+
import json
|
|
224
|
+
|
|
225
|
+
return json.loads(decrypted_data.decode())
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.error(f"Failed to load secrets: {e}")
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
def _save_secrets(self) -> bool:
|
|
231
|
+
"""Encrypt and save secrets to file."""
|
|
232
|
+
try:
|
|
233
|
+
from cryptography.fernet import Fernet
|
|
234
|
+
import json
|
|
235
|
+
|
|
236
|
+
fernet = Fernet(self.encryption_key.encode())
|
|
237
|
+
data = json.dumps(self.secrets).encode()
|
|
238
|
+
encrypted_data = fernet.encrypt(data)
|
|
239
|
+
|
|
240
|
+
with open(self.secrets_file, "wb") as f:
|
|
241
|
+
f.write(encrypted_data)
|
|
242
|
+
|
|
243
|
+
return True
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.error(f"Failed to save secrets: {e}")
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class SecretsManager:
|
|
250
|
+
"""Unified secrets manager supporting multiple backends."""
|
|
251
|
+
|
|
252
|
+
def __init__(self, backend: SecretsBackend):
|
|
253
|
+
self.backend = backend
|
|
254
|
+
|
|
255
|
+
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
256
|
+
"""Get secret value."""
|
|
257
|
+
value = self.backend.get_secret(key)
|
|
258
|
+
return value if value is not None else default
|
|
259
|
+
|
|
260
|
+
def set(self, key: str, value: str) -> bool:
|
|
261
|
+
"""Set secret value."""
|
|
262
|
+
return self.backend.set_secret(key, value)
|
|
263
|
+
|
|
264
|
+
def delete(self, key: str) -> bool:
|
|
265
|
+
"""Delete secret."""
|
|
266
|
+
return self.backend.delete_secret(key)
|
|
267
|
+
|
|
268
|
+
def list(self) -> list:
|
|
269
|
+
"""List all secret keys."""
|
|
270
|
+
return self.backend.list_secrets()
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def from_vault(cls, vault_url: str, **kwargs):
|
|
274
|
+
"""Create secrets manager with Vault backend."""
|
|
275
|
+
return cls(VaultBackend(vault_url, **kwargs))
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def from_aws(cls, region: str = "us-east-1"):
|
|
279
|
+
"""Create secrets manager with AWS backend."""
|
|
280
|
+
return cls(AWSSecretsBackend(region))
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def from_file(cls, secrets_file: str = ".secrets.enc", **kwargs):
|
|
284
|
+
"""Create secrets manager with encrypted file backend."""
|
|
285
|
+
return cls(EncryptedFileBackend(secrets_file, **kwargs))
|
vm_tool/ssh.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import paramiko
|
|
6
|
+
|
|
7
|
+
# Configure logging
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SSHSetup:
|
|
12
|
+
"""
|
|
13
|
+
A class to set up SSH configuration and keys for a VM.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
hostname (str): The hostname of the VM.
|
|
17
|
+
username (str): The username for SSH login.
|
|
18
|
+
password (str): The password for SSH login.
|
|
19
|
+
email (str): The email for SSH key generation.
|
|
20
|
+
private_key_path (str): The path to the private SSH key.
|
|
21
|
+
client (paramiko.SSHClient): The SSH client.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, hostname, username, password, email):
|
|
25
|
+
"""
|
|
26
|
+
The constructor for SSHSetup class.
|
|
27
|
+
|
|
28
|
+
Parameters:
|
|
29
|
+
hostname (str): The hostname of the VM.
|
|
30
|
+
username (str): The username for SSH login.
|
|
31
|
+
password (str): The password for SSH login.
|
|
32
|
+
email (str): The email for SSH key generation.
|
|
33
|
+
"""
|
|
34
|
+
self.hostname = hostname
|
|
35
|
+
self.username = username
|
|
36
|
+
self.password = password
|
|
37
|
+
self.email = email
|
|
38
|
+
self.private_key_path = os.path.expanduser("~/.ssh/id_rsa")
|
|
39
|
+
self.client = paramiko.SSHClient()
|
|
40
|
+
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # nosec B507
|
|
41
|
+
|
|
42
|
+
def __generate_ssh_key(self, email):
|
|
43
|
+
"""
|
|
44
|
+
Generates an SSH key pair.
|
|
45
|
+
|
|
46
|
+
Parameters:
|
|
47
|
+
email (str): The email for SSH key generation.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
logger.info(f"Generating SSH key for email: {email}")
|
|
51
|
+
try:
|
|
52
|
+
subprocess.run(
|
|
53
|
+
[
|
|
54
|
+
"ssh-keygen",
|
|
55
|
+
"-t",
|
|
56
|
+
"rsa",
|
|
57
|
+
"-b",
|
|
58
|
+
"4096",
|
|
59
|
+
"-C",
|
|
60
|
+
email,
|
|
61
|
+
"-f",
|
|
62
|
+
self.private_key_path,
|
|
63
|
+
"-N",
|
|
64
|
+
"",
|
|
65
|
+
],
|
|
66
|
+
check=True,
|
|
67
|
+
capture_output=True,
|
|
68
|
+
)
|
|
69
|
+
logger.info("SSH key generated successfully.")
|
|
70
|
+
except subprocess.CalledProcessError as e:
|
|
71
|
+
logger.error(f"Failed to generate SSH key: {e.stderr.decode()}")
|
|
72
|
+
raise RuntimeError(f"Failed to generate SSH key: {e.stderr.decode()}")
|
|
73
|
+
|
|
74
|
+
def __read_or_generate_public_key(self, email) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Reads the public SSH key or generates a new one if it doesn't exist.
|
|
77
|
+
|
|
78
|
+
Parameters:
|
|
79
|
+
email (str): The email for SSH key generation.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
str: The public SSH key.
|
|
83
|
+
"""
|
|
84
|
+
public_key_path = f"{self.private_key_path}.pub"
|
|
85
|
+
if not os.path.exists(public_key_path):
|
|
86
|
+
self.__generate_ssh_key(email)
|
|
87
|
+
with open(public_key_path, "r") as file:
|
|
88
|
+
logger.info(f"Reading public key from {public_key_path}")
|
|
89
|
+
return file.read()
|
|
90
|
+
|
|
91
|
+
def __configure_vm(self, vm_ip, vm_password, public_key):
|
|
92
|
+
"""
|
|
93
|
+
Configures the VM by adding the public SSH key to the authorized keys.
|
|
94
|
+
|
|
95
|
+
Parameters:
|
|
96
|
+
vm_ip (str): The IP address of the VM.
|
|
97
|
+
vm_password (str): The password for the VM.
|
|
98
|
+
public_key (str): The public SSH key.
|
|
99
|
+
"""
|
|
100
|
+
logger.info(f"Configuring VM at {vm_ip} with provided public key.")
|
|
101
|
+
self.client.connect(vm_ip, username=self.username, password=vm_password)
|
|
102
|
+
self.client.exec_command(
|
|
103
|
+
f'echo "{public_key}" >> ~/.ssh/authorized_keys'
|
|
104
|
+
) # nosec B601
|
|
105
|
+
self.client.close()
|
|
106
|
+
|
|
107
|
+
def __update_ssh_config(self):
|
|
108
|
+
"""
|
|
109
|
+
Updates the local SSH config file with the VM details.
|
|
110
|
+
"""
|
|
111
|
+
config = f"""
|
|
112
|
+
Host {self.hostname}
|
|
113
|
+
HostName {self.hostname}
|
|
114
|
+
User {self.username}
|
|
115
|
+
StrictHostKeyChecking no
|
|
116
|
+
IdentityFile {self.private_key_path}
|
|
117
|
+
ForwardAgent yes
|
|
118
|
+
"""
|
|
119
|
+
logger.info(f"Updating SSH config for host {self.hostname}.")
|
|
120
|
+
with open(f'{os.path.expanduser("~")}/.ssh/config', "a") as file:
|
|
121
|
+
file.write(config)
|
|
122
|
+
|
|
123
|
+
def __establish_connection(self):
|
|
124
|
+
"""
|
|
125
|
+
Establishes an SSH connection to the VM.
|
|
126
|
+
"""
|
|
127
|
+
logger.info(f"Establishing SSH connection to {self.hostname}.")
|
|
128
|
+
self.client.connect(
|
|
129
|
+
self.hostname, username=self.username, key_filename=self.private_key_path
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def __close_connection(self):
|
|
133
|
+
"""
|
|
134
|
+
Closes the SSH connection.
|
|
135
|
+
"""
|
|
136
|
+
if self.client:
|
|
137
|
+
logger.info("Closing SSH connection.")
|
|
138
|
+
self.client.close()
|
|
139
|
+
|
|
140
|
+
def setup(self):
|
|
141
|
+
"""
|
|
142
|
+
Sets up the SSH configuration and keys for the VM.
|
|
143
|
+
"""
|
|
144
|
+
logger.info("Starting SSH setup.")
|
|
145
|
+
public_key = self.__read_or_generate_public_key(self.email)
|
|
146
|
+
self.__configure_vm(self.hostname, self.password, public_key)
|
|
147
|
+
self.__update_ssh_config()
|
|
148
|
+
self.__establish_connection()
|
|
149
|
+
self.__close_connection()
|
|
150
|
+
logger.info("SSH setup completed.")
|
vm_tool/state.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""State management for idempotent deployments."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeploymentState:
|
|
14
|
+
"""Manages deployment state for idempotency."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, state_dir: Optional[Path] = None):
|
|
17
|
+
if state_dir is None:
|
|
18
|
+
state_dir = Path.home() / ".vm_tool"
|
|
19
|
+
self.state_dir = state_dir
|
|
20
|
+
self.state_file = self.state_dir / "deployment_state.json"
|
|
21
|
+
self._ensure_state_dir()
|
|
22
|
+
|
|
23
|
+
def _ensure_state_dir(self):
|
|
24
|
+
"""Create state directory if it doesn't exist."""
|
|
25
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
def _load_state(self) -> Dict[str, Any]:
|
|
28
|
+
"""Load deployment state from file."""
|
|
29
|
+
if not self.state_file.exists():
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
with open(self.state_file, "r") as f:
|
|
33
|
+
return json.load(f)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
logger.warning("Invalid state file, returning empty state")
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
def _save_state(self, state: Dict[str, Any]):
|
|
39
|
+
"""Save deployment state to file."""
|
|
40
|
+
with open(self.state_file, "w") as f:
|
|
41
|
+
json.dump(state, f, indent=2)
|
|
42
|
+
|
|
43
|
+
def compute_hash(self, compose_file: str) -> str:
|
|
44
|
+
"""Compute hash of docker-compose file for change detection."""
|
|
45
|
+
try:
|
|
46
|
+
with open(compose_file, "rb") as f:
|
|
47
|
+
return hashlib.sha256(f.read()).hexdigest()
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
logger.warning(f"Compose file not found: {compose_file}")
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
def record_deployment(
|
|
53
|
+
self,
|
|
54
|
+
host: str,
|
|
55
|
+
compose_file: str,
|
|
56
|
+
compose_hash: str,
|
|
57
|
+
service_name: str = "default",
|
|
58
|
+
):
|
|
59
|
+
"""Record a successful deployment."""
|
|
60
|
+
state = self._load_state()
|
|
61
|
+
|
|
62
|
+
if host not in state:
|
|
63
|
+
state[host] = {}
|
|
64
|
+
|
|
65
|
+
state[host][service_name] = {
|
|
66
|
+
"compose_file": compose_file,
|
|
67
|
+
"compose_hash": compose_hash,
|
|
68
|
+
"deployed_at": datetime.now().isoformat(),
|
|
69
|
+
"status": "deployed",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
self._save_state(state)
|
|
73
|
+
logger.info(f"Recorded deployment: {host}/{service_name}")
|
|
74
|
+
|
|
75
|
+
def get_deployment(
|
|
76
|
+
self, host: str, service_name: str = "default"
|
|
77
|
+
) -> Optional[Dict]:
|
|
78
|
+
"""Get deployment info for a host/service."""
|
|
79
|
+
state = self._load_state()
|
|
80
|
+
return state.get(host, {}).get(service_name)
|
|
81
|
+
|
|
82
|
+
def needs_update(
|
|
83
|
+
self, host: str, compose_hash: str, service_name: str = "default"
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""Check if deployment needs update based on compose file hash."""
|
|
86
|
+
deployment = self.get_deployment(host, service_name)
|
|
87
|
+
|
|
88
|
+
if not deployment:
|
|
89
|
+
logger.info(f"No previous deployment found for {host}/{service_name}")
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
previous_hash = deployment.get("compose_hash")
|
|
93
|
+
if previous_hash != compose_hash:
|
|
94
|
+
logger.info(
|
|
95
|
+
f"Compose file changed for {host}/{service_name} "
|
|
96
|
+
f"(old: {previous_hash[:8]}, new: {compose_hash[:8]})"
|
|
97
|
+
)
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
logger.info(f"No changes detected for {host}/{service_name}")
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def mark_failed(self, host: str, service_name: str = "default", error: str = ""):
|
|
104
|
+
"""Mark a deployment as failed."""
|
|
105
|
+
state = self._load_state()
|
|
106
|
+
|
|
107
|
+
if host not in state:
|
|
108
|
+
state[host] = {}
|
|
109
|
+
|
|
110
|
+
if service_name in state[host]:
|
|
111
|
+
state[host][service_name]["status"] = "failed"
|
|
112
|
+
state[host][service_name]["error"] = error
|
|
113
|
+
state[host][service_name]["failed_at"] = datetime.now().isoformat()
|
|
114
|
+
else:
|
|
115
|
+
state[host][service_name] = {
|
|
116
|
+
"status": "failed",
|
|
117
|
+
"error": error,
|
|
118
|
+
"failed_at": datetime.now().isoformat(),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
self._save_state(state)
|
|
122
|
+
logger.error(f"Marked deployment as failed: {host}/{service_name}")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Deployment strategies package."""
|
|
2
|
+
|
|
3
|
+
from vm_tool.strategies.blue_green import BlueGreenDeployment, BlueGreenConfig
|
|
4
|
+
from vm_tool.strategies.canary import CanaryDeployment, CanaryConfig, ProgressiveRollout
|
|
5
|
+
from vm_tool.strategies.ab_testing import ABTestDeployment, TrafficSplitter, Variant
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BlueGreenDeployment",
|
|
9
|
+
"BlueGreenConfig",
|
|
10
|
+
"CanaryDeployment",
|
|
11
|
+
"CanaryConfig",
|
|
12
|
+
"ProgressiveRollout",
|
|
13
|
+
"ABTestDeployment",
|
|
14
|
+
"TrafficSplitter",
|
|
15
|
+
"Variant",
|
|
16
|
+
]
|