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.
Files changed (73) hide show
  1. examples/README.md +5 -0
  2. examples/__init__.py +1 -0
  3. examples/cloud/README.md +3 -0
  4. examples/cloud/__init__.py +1 -0
  5. examples/cloud/ssh_identity_file.py +27 -0
  6. examples/cloud/ssh_password.py +27 -0
  7. examples/cloud/template_cloud_setup.py +36 -0
  8. examples/deploy_full_setup.py +44 -0
  9. examples/docker-compose.example.yml +47 -0
  10. examples/ec2-setup.sh +95 -0
  11. examples/github-actions-ec2.yml +245 -0
  12. examples/github-actions-full-setup.yml +58 -0
  13. examples/local/.keep +1 -0
  14. examples/local/README.md +3 -0
  15. examples/local/__init__.py +1 -0
  16. examples/local/template_local_setup.py +27 -0
  17. examples/production-deploy.sh +70 -0
  18. examples/rollback.sh +52 -0
  19. examples/setup.sh +52 -0
  20. examples/ssh_key_management.py +22 -0
  21. examples/version_check.sh +3 -0
  22. vm_tool/__init__.py +0 -0
  23. vm_tool/alerting.py +274 -0
  24. vm_tool/audit.py +118 -0
  25. vm_tool/backup.py +125 -0
  26. vm_tool/benchmarking.py +200 -0
  27. vm_tool/cli.py +761 -0
  28. vm_tool/cloud.py +125 -0
  29. vm_tool/completion.py +200 -0
  30. vm_tool/compliance.py +104 -0
  31. vm_tool/config.py +92 -0
  32. vm_tool/drift.py +98 -0
  33. vm_tool/generator.py +462 -0
  34. vm_tool/health.py +197 -0
  35. vm_tool/history.py +131 -0
  36. vm_tool/kubernetes.py +89 -0
  37. vm_tool/metrics.py +183 -0
  38. vm_tool/notifications.py +152 -0
  39. vm_tool/plugins.py +119 -0
  40. vm_tool/policy.py +197 -0
  41. vm_tool/rbac.py +140 -0
  42. vm_tool/recovery.py +169 -0
  43. vm_tool/reporting.py +218 -0
  44. vm_tool/runner.py +445 -0
  45. vm_tool/secrets.py +285 -0
  46. vm_tool/ssh.py +150 -0
  47. vm_tool/state.py +122 -0
  48. vm_tool/strategies/__init__.py +16 -0
  49. vm_tool/strategies/ab_testing.py +258 -0
  50. vm_tool/strategies/blue_green.py +227 -0
  51. vm_tool/strategies/canary.py +277 -0
  52. vm_tool/validation.py +267 -0
  53. vm_tool/vm_setup/cleanup.yml +27 -0
  54. vm_tool/vm_setup/docker/create_docker_service.yml +63 -0
  55. vm_tool/vm_setup/docker/docker_setup.yml +7 -0
  56. vm_tool/vm_setup/docker/install_docker_and_compose.yml +92 -0
  57. vm_tool/vm_setup/docker/login_to_docker_hub.yml +6 -0
  58. vm_tool/vm_setup/github/git_configuration.yml +68 -0
  59. vm_tool/vm_setup/inventory.yml +1 -0
  60. vm_tool/vm_setup/k8s.yml +15 -0
  61. vm_tool/vm_setup/main.yml +27 -0
  62. vm_tool/vm_setup/monitoring.yml +42 -0
  63. vm_tool/vm_setup/project_service.yml +17 -0
  64. vm_tool/vm_setup/push_code.yml +40 -0
  65. vm_tool/vm_setup/setup.yml +17 -0
  66. vm_tool/vm_setup/setup_project_env.yml +7 -0
  67. vm_tool/webhooks.py +83 -0
  68. vm_tool-1.0.32.dist-info/METADATA +213 -0
  69. vm_tool-1.0.32.dist-info/RECORD +73 -0
  70. vm_tool-1.0.32.dist-info/WHEEL +5 -0
  71. vm_tool-1.0.32.dist-info/entry_points.txt +2 -0
  72. vm_tool-1.0.32.dist-info/licenses/LICENSE +21 -0
  73. 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
+ ]