clonebox 1.1.3__py3-none-any.whl → 1.1.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.
clonebox/secrets.py ADDED
@@ -0,0 +1,331 @@
1
+ """
2
+ Secrets management for CloneBox.
3
+ Supports multiple backends: env, vault, sops, age.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import secrets
9
+ import string
10
+ import subprocess
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+
17
+ @dataclass
18
+ class SecretValue:
19
+ """Represents a secret value with metadata."""
20
+
21
+ key: str
22
+ value: str
23
+ source: str # 'env', 'vault', 'sops', 'generated'
24
+ expires_at: Optional[str] = None
25
+
26
+ def __repr__(self) -> str:
27
+ return f"SecretValue(key={self.key}, source={self.source}, value=***)"
28
+
29
+ def redacted(self) -> str:
30
+ """Return redacted version for logging."""
31
+ return f"{self.value[:2]}***{self.value[-2:]}" if len(self.value) > 4 else "***"
32
+
33
+
34
+ class SecretsProvider(ABC):
35
+ """Abstract base class for secrets providers."""
36
+
37
+ @abstractmethod
38
+ def get_secret(self, key: str) -> Optional[SecretValue]:
39
+ """Retrieve a secret by key."""
40
+ pass
41
+
42
+ @abstractmethod
43
+ def list_secrets(self) -> List[str]:
44
+ """List available secret keys."""
45
+ pass
46
+
47
+ @abstractmethod
48
+ def is_available(self) -> bool:
49
+ """Check if this provider is configured and available."""
50
+ pass
51
+
52
+
53
+ class EnvSecretsProvider(SecretsProvider):
54
+ """Load secrets from environment variables and .env files."""
55
+
56
+ def __init__(self, env_file: Optional[Path] = None):
57
+ self.env_file = env_file or Path(".env")
58
+ self._cache: Dict[str, str] = {}
59
+ self._load_env_file()
60
+
61
+ def _load_env_file(self) -> None:
62
+ if self.env_file.exists():
63
+ with open(self.env_file) as f:
64
+ for line in f:
65
+ line = line.strip()
66
+ if line and not line.startswith("#") and "=" in line:
67
+ key, _, value = line.partition("=")
68
+ self._cache[key.strip()] = value.strip().strip("'\"")
69
+
70
+ def get_secret(self, key: str) -> Optional[SecretValue]:
71
+ # Check environment first, then cache from file
72
+ value = os.environ.get(key) or self._cache.get(key)
73
+ if value:
74
+ return SecretValue(key=key, value=value, source="env")
75
+ return None
76
+
77
+ def list_secrets(self) -> List[str]:
78
+ return list(
79
+ set(
80
+ list(self._cache.keys())
81
+ + [
82
+ k
83
+ for k in os.environ.keys()
84
+ if k.startswith("VM_") or k.startswith("CLONEBOX_")
85
+ ]
86
+ )
87
+ )
88
+
89
+ def is_available(self) -> bool:
90
+ return True
91
+
92
+
93
+ class VaultSecretsProvider(SecretsProvider):
94
+ """Load secrets from HashiCorp Vault."""
95
+
96
+ def __init__(
97
+ self,
98
+ addr: Optional[str] = None,
99
+ token: Optional[str] = None,
100
+ path_prefix: str = "secret/clonebox",
101
+ ):
102
+ self.addr = addr or os.environ.get("VAULT_ADDR", "http://127.0.0.1:8200")
103
+ self.token = token or os.environ.get("VAULT_TOKEN")
104
+ self.path_prefix = path_prefix
105
+ self._client = None
106
+
107
+ def _get_client(self):
108
+ if self._client is None:
109
+ try:
110
+ import hvac
111
+
112
+ self._client = hvac.Client(url=self.addr, token=self.token)
113
+ except ImportError:
114
+ raise RuntimeError(
115
+ "hvac package required for Vault support: pip install hvac"
116
+ )
117
+ return self._client
118
+
119
+ def get_secret(self, key: str) -> Optional[SecretValue]:
120
+ try:
121
+ client = self._get_client()
122
+ path = f"{self.path_prefix}/{key}"
123
+ response = client.secrets.kv.v2.read_secret_version(path=path)
124
+ value = response["data"]["data"].get("value")
125
+ if value:
126
+ return SecretValue(key=key, value=value, source="vault")
127
+ except Exception:
128
+ pass
129
+ return None
130
+
131
+ def list_secrets(self) -> List[str]:
132
+ try:
133
+ client = self._get_client()
134
+ response = client.secrets.kv.v2.list_secrets(path=self.path_prefix)
135
+ return response["data"]["keys"]
136
+ except Exception:
137
+ return []
138
+
139
+ def is_available(self) -> bool:
140
+ try:
141
+ client = self._get_client()
142
+ return client.is_authenticated()
143
+ except Exception:
144
+ return False
145
+
146
+
147
+ class SOPSSecretsProvider(SecretsProvider):
148
+ """Load secrets from SOPS-encrypted files."""
149
+
150
+ def __init__(self, secrets_file: Optional[Path] = None):
151
+ self.secrets_file = secrets_file or Path(".clonebox.secrets.yaml")
152
+ self._cache: Optional[Dict[str, Any]] = None
153
+
154
+ def _decrypt(self) -> Dict[str, Any]:
155
+ if self._cache is not None:
156
+ return self._cache
157
+
158
+ if not self.secrets_file.exists():
159
+ self._cache = {}
160
+ return self._cache
161
+
162
+ try:
163
+ result = subprocess.run(
164
+ ["sops", "-d", str(self.secrets_file)],
165
+ capture_output=True,
166
+ text=True,
167
+ check=True,
168
+ )
169
+ import yaml
170
+
171
+ self._cache = yaml.safe_load(result.stdout) or {}
172
+ except (subprocess.CalledProcessError, FileNotFoundError):
173
+ self._cache = {}
174
+
175
+ return self._cache
176
+
177
+ def get_secret(self, key: str) -> Optional[SecretValue]:
178
+ data = self._decrypt()
179
+ # Support nested keys: "vm.password" -> data['vm']['password']
180
+ parts = key.split(".")
181
+ value = data
182
+ for part in parts:
183
+ if isinstance(value, dict) and part in value:
184
+ value = value[part]
185
+ else:
186
+ return None
187
+
188
+ if isinstance(value, str):
189
+ return SecretValue(key=key, value=value, source="sops")
190
+ return None
191
+
192
+ def list_secrets(self) -> List[str]:
193
+ data = self._decrypt()
194
+ return list(data.keys())
195
+
196
+ def is_available(self) -> bool:
197
+ try:
198
+ subprocess.run(["sops", "--version"], capture_output=True, check=True)
199
+ return self.secrets_file.exists()
200
+ except (subprocess.CalledProcessError, FileNotFoundError):
201
+ return False
202
+
203
+
204
+ class SecretsManager:
205
+ """
206
+ Unified secrets management with multiple provider fallback.
207
+
208
+ Usage:
209
+ secrets = SecretsManager()
210
+ password = secrets.get('VM_PASSWORD')
211
+
212
+ # Or with explicit provider
213
+ secrets = SecretsManager(provider='vault')
214
+ """
215
+
216
+ PROVIDERS = {
217
+ "env": EnvSecretsProvider,
218
+ "vault": VaultSecretsProvider,
219
+ "sops": SOPSSecretsProvider,
220
+ }
221
+
222
+ def __init__(self, provider: Optional[str] = None, **kwargs):
223
+ self._providers: List[SecretsProvider] = []
224
+
225
+ if provider:
226
+ # Use specific provider
227
+ if provider not in self.PROVIDERS:
228
+ raise ValueError(
229
+ f"Unknown provider: {provider}. Available: {list(self.PROVIDERS.keys())}"
230
+ )
231
+ self._providers = [self.PROVIDERS[provider](**kwargs)]
232
+ else:
233
+ # Auto-detect and use all available providers
234
+ for name, cls in self.PROVIDERS.items():
235
+ try:
236
+ instance = cls(**kwargs)
237
+ if instance.is_available():
238
+ self._providers.append(instance)
239
+ except Exception:
240
+ pass
241
+
242
+ # Always have env as fallback
243
+ if not any(isinstance(p, EnvSecretsProvider) for p in self._providers):
244
+ self._providers.append(EnvSecretsProvider())
245
+
246
+ def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
247
+ """Get a secret value, trying each provider in order."""
248
+ for provider in self._providers:
249
+ secret = provider.get_secret(key)
250
+ if secret:
251
+ return secret.value
252
+ return default
253
+
254
+ def get_secret(self, key: str) -> Optional[SecretValue]:
255
+ """Get a SecretValue with metadata."""
256
+ for provider in self._providers:
257
+ secret = provider.get_secret(key)
258
+ if secret:
259
+ return secret
260
+ return None
261
+
262
+ def require(self, key: str) -> str:
263
+ """Get a secret or raise an error if not found."""
264
+ value = self.get(key)
265
+ if value is None:
266
+ raise ValueError(f"Required secret '{key}' not found in any provider")
267
+ return value
268
+
269
+ @staticmethod
270
+ def generate_password(length: int = 24) -> str:
271
+ """Generate a secure random password."""
272
+ alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
273
+ return "".join(secrets.choice(alphabet) for _ in range(length))
274
+
275
+ @staticmethod
276
+ def generate_one_time_password() -> Tuple[str, str]:
277
+ """
278
+ Generate a one-time password that expires on first login.
279
+ Returns (password, cloud-init chpasswd config)
280
+ """
281
+ password = SecretsManager.generate_password(16)
282
+ # Force password change on first login
283
+ chpasswd_config = f"""
284
+ chpasswd:
285
+ expire: true
286
+ users:
287
+ - name: ubuntu
288
+ password: {password}
289
+ type: text
290
+ """
291
+ return password, chpasswd_config
292
+
293
+
294
+ # SSH Key Management
295
+ @dataclass
296
+ class SSHKeyPair:
297
+ """SSH key pair for VM authentication."""
298
+
299
+ private_key: str
300
+ public_key: str
301
+ key_type: str = "ed25519"
302
+
303
+ @classmethod
304
+ def generate(cls, key_type: str = "ed25519") -> "SSHKeyPair":
305
+ """Generate a new SSH key pair."""
306
+ import tempfile
307
+
308
+ with tempfile.TemporaryDirectory() as tmpdir:
309
+ key_path = Path(tmpdir) / "key"
310
+ subprocess.run(
311
+ ["ssh-keygen", "-t", key_type, "-N", "", "-f", str(key_path), "-q"],
312
+ check=True,
313
+ )
314
+
315
+ private_key = key_path.read_text()
316
+ public_key = key_path.with_suffix(".pub").read_text()
317
+
318
+ return cls(private_key=private_key, public_key=public_key, key_type=key_type)
319
+
320
+ @classmethod
321
+ def from_file(cls, private_key_path: Path) -> "SSHKeyPair":
322
+ """Load existing SSH key pair."""
323
+ private_key = private_key_path.read_text()
324
+ public_key = private_key_path.with_suffix(".pub").read_text()
325
+ return cls(private_key=private_key, public_key=public_key)
326
+
327
+ def save(self, private_key_path: Path) -> None:
328
+ """Save key pair to files."""
329
+ private_key_path.write_text(self.private_key)
330
+ private_key_path.chmod(0o600)
331
+ private_key_path.with_suffix(".pub").write_text(self.public_key)
@@ -0,0 +1,12 @@
1
+ """Snapshot management for CloneBox VMs."""
2
+
3
+ from .models import Snapshot, SnapshotType, SnapshotState, SnapshotPolicy
4
+ from .manager import SnapshotManager
5
+
6
+ __all__ = [
7
+ "Snapshot",
8
+ "SnapshotType",
9
+ "SnapshotState",
10
+ "SnapshotPolicy",
11
+ "SnapshotManager",
12
+ ]
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env python3
2
+ """Snapshot manager for CloneBox VMs."""
3
+
4
+ import json
5
+ import subprocess
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from .models import Snapshot, SnapshotPolicy, SnapshotState, SnapshotType
11
+
12
+ try:
13
+ import libvirt
14
+ except ImportError:
15
+ libvirt = None
16
+
17
+
18
+ class SnapshotManager:
19
+ """Manage VM snapshots via libvirt."""
20
+
21
+ def __init__(self, conn_uri: str = "qemu:///session"):
22
+ self.conn_uri = conn_uri
23
+ self._conn = None
24
+ self._snapshots_dir = Path.home() / ".local/share/clonebox/snapshots"
25
+ self._snapshots_dir.mkdir(parents=True, exist_ok=True)
26
+
27
+ @property
28
+ def conn(self):
29
+ if self._conn is None:
30
+ if libvirt is None:
31
+ raise RuntimeError("libvirt-python not installed")
32
+ self._conn = libvirt.open(self.conn_uri)
33
+ return self._conn
34
+
35
+ def create(
36
+ self,
37
+ vm_name: str,
38
+ name: str,
39
+ description: Optional[str] = None,
40
+ snapshot_type: SnapshotType = SnapshotType.DISK_ONLY,
41
+ tags: Optional[List[str]] = None,
42
+ auto_policy: Optional[str] = None,
43
+ expires_in_days: Optional[int] = None,
44
+ ) -> Snapshot:
45
+ """Create a new snapshot.
46
+
47
+ Args:
48
+ vm_name: Name of VM to snapshot
49
+ name: Snapshot name
50
+ description: Optional description
51
+ snapshot_type: Type of snapshot (disk, full, external)
52
+ tags: Optional tags for categorization
53
+ auto_policy: If auto-created, the policy name
54
+ expires_in_days: Auto-expire after N days
55
+ """
56
+ domain = self.conn.lookupByName(vm_name)
57
+
58
+ # Generate snapshot XML
59
+ snapshot_xml = self._generate_snapshot_xml(
60
+ name=name,
61
+ description=description,
62
+ snapshot_type=snapshot_type,
63
+ )
64
+
65
+ # Create snapshot
66
+ flags = 0
67
+ if snapshot_type == SnapshotType.DISK_ONLY:
68
+ flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY
69
+ elif snapshot_type == SnapshotType.FULL:
70
+ flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC
71
+
72
+ try:
73
+ snap = domain.snapshotCreateXML(snapshot_xml, flags)
74
+ except libvirt.libvirtError as e:
75
+ raise RuntimeError(f"Failed to create snapshot: {e}")
76
+
77
+ # Build snapshot object
78
+ snapshot = Snapshot(
79
+ name=name,
80
+ vm_name=vm_name,
81
+ snapshot_type=snapshot_type,
82
+ state=SnapshotState.READY,
83
+ created_at=datetime.now(),
84
+ description=description,
85
+ tags=tags or [],
86
+ auto_created=auto_policy is not None,
87
+ auto_policy=auto_policy,
88
+ expires_at=(
89
+ datetime.now() + timedelta(days=expires_in_days) if expires_in_days else None
90
+ ),
91
+ )
92
+
93
+ # Save metadata
94
+ self._save_snapshot_metadata(snapshot)
95
+
96
+ return snapshot
97
+
98
+ def restore(
99
+ self,
100
+ vm_name: str,
101
+ name: str,
102
+ force: bool = False,
103
+ ) -> bool:
104
+ """Restore VM to a snapshot.
105
+
106
+ Args:
107
+ vm_name: Name of VM
108
+ name: Snapshot name to restore
109
+ force: Force restore even if VM is running
110
+ """
111
+ domain = self.conn.lookupByName(vm_name)
112
+
113
+ # Check if VM is running
114
+ if domain.isActive() and not force:
115
+ raise RuntimeError(f"VM '{vm_name}' is running. Stop it first or use --force")
116
+
117
+ try:
118
+ snap = domain.snapshotLookupByName(name)
119
+ except libvirt.libvirtError:
120
+ raise RuntimeError(f"Snapshot '{name}' not found for VM '{vm_name}'")
121
+
122
+ # Revert to snapshot
123
+ flags = libvirt.VIR_DOMAIN_SNAPSHOT_REVERT_FORCE if force else 0
124
+ try:
125
+ domain.revertToSnapshot(snap, flags)
126
+ except libvirt.libvirtError as e:
127
+ raise RuntimeError(f"Failed to restore snapshot: {e}")
128
+
129
+ return True
130
+
131
+ def delete(
132
+ self,
133
+ vm_name: str,
134
+ name: str,
135
+ delete_children: bool = False,
136
+ ) -> bool:
137
+ """Delete a snapshot.
138
+
139
+ Args:
140
+ vm_name: Name of VM
141
+ name: Snapshot name to delete
142
+ delete_children: Also delete child snapshots
143
+ """
144
+ domain = self.conn.lookupByName(vm_name)
145
+
146
+ try:
147
+ snap = domain.snapshotLookupByName(name)
148
+ except libvirt.libvirtError:
149
+ raise RuntimeError(f"Snapshot '{name}' not found for VM '{vm_name}'")
150
+
151
+ flags = 0
152
+ if delete_children:
153
+ flags = libvirt.VIR_DOMAIN_SNAPSHOT_DELETE_CHILDREN
154
+
155
+ try:
156
+ snap.delete(flags)
157
+ except libvirt.libvirtError as e:
158
+ raise RuntimeError(f"Failed to delete snapshot: {e}")
159
+
160
+ # Remove metadata
161
+ self._delete_snapshot_metadata(vm_name, name)
162
+
163
+ return True
164
+
165
+ def list(self, vm_name: str) -> List[Snapshot]:
166
+ """List all snapshots for a VM."""
167
+ domain = self.conn.lookupByName(vm_name)
168
+ snapshots = []
169
+
170
+ try:
171
+ snap_names = domain.snapshotListNames()
172
+ except libvirt.libvirtError:
173
+ return []
174
+
175
+ for snap_name in snap_names:
176
+ try:
177
+ snap = domain.snapshotLookupByName(snap_name)
178
+ snap_xml = snap.getXMLDesc()
179
+
180
+ # Parse XML for details
181
+ import xml.etree.ElementTree as ET
182
+
183
+ root = ET.fromstring(snap_xml)
184
+
185
+ name = root.findtext("name", snap_name)
186
+ description = root.findtext("description", "")
187
+ creation_time = root.findtext("creationTime", "0")
188
+
189
+ # Check for saved metadata
190
+ metadata = self._load_snapshot_metadata(vm_name, name)
191
+
192
+ snapshot = Snapshot(
193
+ name=name,
194
+ vm_name=vm_name,
195
+ snapshot_type=SnapshotType(
196
+ metadata.get("type", "disk") if metadata else "disk"
197
+ ),
198
+ state=SnapshotState.READY,
199
+ created_at=(
200
+ datetime.fromtimestamp(int(creation_time))
201
+ if creation_time != "0"
202
+ else datetime.now()
203
+ ),
204
+ description=description or None,
205
+ tags=metadata.get("tags", []) if metadata else [],
206
+ auto_created=metadata.get("auto_created", False) if metadata else False,
207
+ auto_policy=metadata.get("auto_policy") if metadata else None,
208
+ expires_at=(
209
+ datetime.fromisoformat(metadata["expires_at"])
210
+ if metadata and metadata.get("expires_at")
211
+ else None
212
+ ),
213
+ )
214
+ snapshots.append(snapshot)
215
+
216
+ except Exception:
217
+ continue
218
+
219
+ return sorted(snapshots, key=lambda s: s.created_at, reverse=True)
220
+
221
+ def get(self, vm_name: str, name: str) -> Optional[Snapshot]:
222
+ """Get a specific snapshot."""
223
+ snapshots = self.list(vm_name)
224
+ for snap in snapshots:
225
+ if snap.name == name:
226
+ return snap
227
+ return None
228
+
229
+ def cleanup_expired(self, vm_name: str) -> List[str]:
230
+ """Delete expired snapshots for a VM."""
231
+ deleted = []
232
+ for snapshot in self.list(vm_name):
233
+ if snapshot.is_expired:
234
+ try:
235
+ self.delete(vm_name, snapshot.name)
236
+ deleted.append(snapshot.name)
237
+ except Exception:
238
+ pass
239
+ return deleted
240
+
241
+ def apply_policy(self, vm_name: str, policy: SnapshotPolicy) -> List[str]:
242
+ """Apply retention policy to VM snapshots."""
243
+ if not policy.auto_cleanup:
244
+ return []
245
+
246
+ snapshots = self.list(vm_name)
247
+ auto_snapshots = [s for s in snapshots if s.auto_policy == policy.name]
248
+
249
+ deleted = []
250
+
251
+ # Sort by age (oldest first)
252
+ auto_snapshots.sort(key=lambda s: s.created_at)
253
+
254
+ # Delete if over max count
255
+ while len(auto_snapshots) > policy.max_snapshots:
256
+ if len(auto_snapshots) <= policy.min_snapshots:
257
+ break
258
+ oldest = auto_snapshots.pop(0)
259
+ try:
260
+ self.delete(vm_name, oldest.name)
261
+ deleted.append(oldest.name)
262
+ except Exception:
263
+ pass
264
+
265
+ # Delete if over max age
266
+ max_age = timedelta(days=policy.max_age_days)
267
+ for snap in auto_snapshots[:]:
268
+ if snap.age > max_age:
269
+ if len(auto_snapshots) <= policy.min_snapshots:
270
+ break
271
+ try:
272
+ self.delete(vm_name, snap.name)
273
+ deleted.append(snap.name)
274
+ auto_snapshots.remove(snap)
275
+ except Exception:
276
+ pass
277
+
278
+ return deleted
279
+
280
+ def create_auto_snapshot(
281
+ self,
282
+ vm_name: str,
283
+ operation: str,
284
+ policy: Optional[SnapshotPolicy] = None,
285
+ ) -> Snapshot:
286
+ """Create automatic snapshot before operation."""
287
+ policy = policy or SnapshotPolicy(name="default")
288
+
289
+ name = policy.generate_snapshot_name(operation)
290
+
291
+ return self.create(
292
+ vm_name=vm_name,
293
+ name=name,
294
+ description=f"Auto-snapshot before {operation}",
295
+ snapshot_type=SnapshotType.DISK_ONLY,
296
+ auto_policy=policy.name,
297
+ expires_in_days=policy.max_age_days,
298
+ )
299
+
300
+ def _generate_snapshot_xml(
301
+ self,
302
+ name: str,
303
+ description: Optional[str],
304
+ snapshot_type: SnapshotType,
305
+ ) -> str:
306
+ """Generate libvirt snapshot XML."""
307
+ desc_xml = f"<description>{description}</description>" if description else ""
308
+
309
+ if snapshot_type == SnapshotType.DISK_ONLY:
310
+ disks_xml = "<disks><disk name='vda' snapshot='internal'/></disks>"
311
+ else:
312
+ disks_xml = ""
313
+
314
+ return f"""
315
+ <domainsnapshot>
316
+ <name>{name}</name>
317
+ {desc_xml}
318
+ {disks_xml}
319
+ </domainsnapshot>
320
+ """
321
+
322
+ def _save_snapshot_metadata(self, snapshot: Snapshot) -> None:
323
+ """Save snapshot metadata to disk."""
324
+ vm_dir = self._snapshots_dir / snapshot.vm_name
325
+ vm_dir.mkdir(parents=True, exist_ok=True)
326
+
327
+ meta_file = vm_dir / f"{snapshot.name}.json"
328
+ meta_file.write_text(json.dumps(snapshot.to_dict(), indent=2))
329
+
330
+ def _load_snapshot_metadata(self, vm_name: str, name: str) -> Optional[Dict[str, Any]]:
331
+ """Load snapshot metadata from disk."""
332
+ meta_file = self._snapshots_dir / vm_name / f"{name}.json"
333
+ if meta_file.exists():
334
+ try:
335
+ return json.loads(meta_file.read_text())
336
+ except Exception:
337
+ return None
338
+ return None
339
+
340
+ def _delete_snapshot_metadata(self, vm_name: str, name: str) -> None:
341
+ """Delete snapshot metadata from disk."""
342
+ meta_file = self._snapshots_dir / vm_name / f"{name}.json"
343
+ if meta_file.exists():
344
+ meta_file.unlink()
345
+
346
+ def close(self) -> None:
347
+ if self._conn is not None:
348
+ self._conn.close()
349
+ self._conn = None