clonebox 1.1.2__py3-none-any.whl → 1.1.3__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/cli.py +217 -0
- clonebox/exporter.py +189 -0
- clonebox/importer.py +220 -0
- clonebox/p2p.py +184 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.3.dist-info}/METADATA +28 -2
- {clonebox-1.1.2.dist-info → clonebox-1.1.3.dist-info}/RECORD +10 -7
- {clonebox-1.1.2.dist-info → clonebox-1.1.3.dist-info}/WHEEL +0 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.3.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.3.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.3.dist-info}/top_level.txt +0 -0
clonebox/cli.py
CHANGED
|
@@ -28,6 +28,9 @@ from clonebox.container import ContainerCloner
|
|
|
28
28
|
from clonebox.detector import SystemDetector
|
|
29
29
|
from clonebox.models import ContainerConfig
|
|
30
30
|
from clonebox.profiles import merge_with_profile
|
|
31
|
+
from clonebox.exporter import SecureExporter, VMExporter
|
|
32
|
+
from clonebox.importer import SecureImporter, VMImporter
|
|
33
|
+
from clonebox.p2p import P2PManager
|
|
31
34
|
|
|
32
35
|
# Custom questionary style
|
|
33
36
|
custom_style = Style(
|
|
@@ -2635,6 +2638,127 @@ def cmd_detect(args):
|
|
|
2635
2638
|
console.print(table)
|
|
2636
2639
|
|
|
2637
2640
|
|
|
2641
|
+
def cmd_keygen(args) -> None:
|
|
2642
|
+
"""Generate encryption key for secure P2P transfers."""
|
|
2643
|
+
key_path = SecureExporter.generate_key()
|
|
2644
|
+
console.print(f"[green]🔑 Encryption key generated: {key_path}[/]")
|
|
2645
|
+
console.print("[dim]Share this key with team members for encrypted transfers.[/]")
|
|
2646
|
+
|
|
2647
|
+
|
|
2648
|
+
def cmd_export_encrypted(args) -> None:
|
|
2649
|
+
"""Export VM with AES-256 encryption."""
|
|
2650
|
+
vm_name, config_file = _resolve_vm_name_and_config_file(args.name)
|
|
2651
|
+
conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
|
|
2652
|
+
output = Path(args.output) if args.output else Path(f"{vm_name}.enc")
|
|
2653
|
+
|
|
2654
|
+
console.print(f"[cyan]🔒 Exporting encrypted: {vm_name} → {output}[/]")
|
|
2655
|
+
|
|
2656
|
+
try:
|
|
2657
|
+
exporter = SecureExporter(conn_uri)
|
|
2658
|
+
exporter.export_encrypted(
|
|
2659
|
+
vm_name=vm_name,
|
|
2660
|
+
output_path=output,
|
|
2661
|
+
include_user_data=getattr(args, "user_data", False),
|
|
2662
|
+
include_app_data=getattr(args, "include_data", False),
|
|
2663
|
+
)
|
|
2664
|
+
console.print(f"[green]✅ Encrypted export complete: {output}[/]")
|
|
2665
|
+
except FileNotFoundError as e:
|
|
2666
|
+
console.print(f"[red]❌ {e}[/]")
|
|
2667
|
+
console.print("[yellow]Run: clonebox keygen[/]")
|
|
2668
|
+
finally:
|
|
2669
|
+
exporter.close()
|
|
2670
|
+
|
|
2671
|
+
|
|
2672
|
+
def cmd_import_encrypted(args) -> None:
|
|
2673
|
+
"""Import VM with AES-256 decryption."""
|
|
2674
|
+
archive = Path(args.archive)
|
|
2675
|
+
conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
|
|
2676
|
+
|
|
2677
|
+
console.print(f"[cyan]🔓 Importing encrypted: {archive}[/]")
|
|
2678
|
+
|
|
2679
|
+
try:
|
|
2680
|
+
importer = SecureImporter(conn_uri)
|
|
2681
|
+
vm_name = importer.import_decrypted(
|
|
2682
|
+
encrypted_path=archive,
|
|
2683
|
+
import_user_data=getattr(args, "user_data", False),
|
|
2684
|
+
import_app_data=getattr(args, "include_data", False),
|
|
2685
|
+
new_name=getattr(args, "name", None),
|
|
2686
|
+
)
|
|
2687
|
+
console.print(f"[green]✅ Import complete: {vm_name}[/]")
|
|
2688
|
+
except FileNotFoundError as e:
|
|
2689
|
+
console.print(f"[red]❌ {e}[/]")
|
|
2690
|
+
finally:
|
|
2691
|
+
importer.close()
|
|
2692
|
+
|
|
2693
|
+
|
|
2694
|
+
def cmd_export_remote(args) -> None:
|
|
2695
|
+
"""Export VM from remote host."""
|
|
2696
|
+
p2p = P2PManager()
|
|
2697
|
+
|
|
2698
|
+
console.print(f"[cyan]📤 Remote export: {args.host}:{args.vm_name}[/]")
|
|
2699
|
+
|
|
2700
|
+
try:
|
|
2701
|
+
output = Path(args.output)
|
|
2702
|
+
p2p.export_remote(
|
|
2703
|
+
host=args.host,
|
|
2704
|
+
vm_name=args.vm_name,
|
|
2705
|
+
output=output,
|
|
2706
|
+
encrypted=getattr(args, "encrypted", False),
|
|
2707
|
+
include_user_data=getattr(args, "user_data", False),
|
|
2708
|
+
include_app_data=getattr(args, "include_data", False),
|
|
2709
|
+
)
|
|
2710
|
+
console.print(f"[green]✅ Remote export complete: {output}[/]")
|
|
2711
|
+
except RuntimeError as e:
|
|
2712
|
+
console.print(f"[red]❌ {e}[/]")
|
|
2713
|
+
|
|
2714
|
+
|
|
2715
|
+
def cmd_import_remote(args) -> None:
|
|
2716
|
+
"""Import VM to remote host."""
|
|
2717
|
+
p2p = P2PManager()
|
|
2718
|
+
archive = Path(args.archive)
|
|
2719
|
+
|
|
2720
|
+
console.print(f"[cyan]📥 Remote import: {archive} → {args.host}[/]")
|
|
2721
|
+
|
|
2722
|
+
try:
|
|
2723
|
+
p2p.import_remote(
|
|
2724
|
+
host=args.host,
|
|
2725
|
+
archive_path=archive,
|
|
2726
|
+
encrypted=getattr(args, "encrypted", False),
|
|
2727
|
+
import_user_data=getattr(args, "user_data", False),
|
|
2728
|
+
new_name=getattr(args, "name", None),
|
|
2729
|
+
)
|
|
2730
|
+
console.print(f"[green]✅ Remote import complete[/]")
|
|
2731
|
+
except RuntimeError as e:
|
|
2732
|
+
console.print(f"[red]❌ {e}[/]")
|
|
2733
|
+
|
|
2734
|
+
|
|
2735
|
+
def cmd_sync_key(args) -> None:
|
|
2736
|
+
"""Sync encryption key to remote host."""
|
|
2737
|
+
p2p = P2PManager()
|
|
2738
|
+
|
|
2739
|
+
console.print(f"[cyan]🔑 Syncing key to: {args.host}[/]")
|
|
2740
|
+
|
|
2741
|
+
try:
|
|
2742
|
+
p2p.sync_key(args.host)
|
|
2743
|
+
console.print(f"[green]✅ Key synced to {args.host}[/]")
|
|
2744
|
+
except (FileNotFoundError, RuntimeError) as e:
|
|
2745
|
+
console.print(f"[red]❌ {e}[/]")
|
|
2746
|
+
|
|
2747
|
+
|
|
2748
|
+
def cmd_list_remote(args) -> None:
|
|
2749
|
+
"""List VMs on remote host."""
|
|
2750
|
+
p2p = P2PManager()
|
|
2751
|
+
|
|
2752
|
+
console.print(f"[cyan]🔍 Listing VMs on: {args.host}[/]")
|
|
2753
|
+
|
|
2754
|
+
vms = p2p.list_remote_vms(args.host)
|
|
2755
|
+
if vms:
|
|
2756
|
+
for vm in vms:
|
|
2757
|
+
console.print(f" • {vm}")
|
|
2758
|
+
else:
|
|
2759
|
+
console.print("[yellow]No VMs found on remote host.[/]")
|
|
2760
|
+
|
|
2761
|
+
|
|
2638
2762
|
def main():
|
|
2639
2763
|
"""Main entry point."""
|
|
2640
2764
|
parser = argparse.ArgumentParser(
|
|
@@ -3074,6 +3198,99 @@ def main():
|
|
|
3074
3198
|
)
|
|
3075
3199
|
test_parser.set_defaults(func=cmd_test)
|
|
3076
3200
|
|
|
3201
|
+
# === P2P Secure Transfer Commands ===
|
|
3202
|
+
|
|
3203
|
+
# Keygen command - generate encryption key
|
|
3204
|
+
keygen_parser = subparsers.add_parser("keygen", help="Generate encryption key for secure transfers")
|
|
3205
|
+
keygen_parser.set_defaults(func=cmd_keygen)
|
|
3206
|
+
|
|
3207
|
+
# Export-encrypted command
|
|
3208
|
+
export_enc_parser = subparsers.add_parser(
|
|
3209
|
+
"export-encrypted", help="Export VM with AES-256 encryption"
|
|
3210
|
+
)
|
|
3211
|
+
export_enc_parser.add_argument(
|
|
3212
|
+
"name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
|
|
3213
|
+
)
|
|
3214
|
+
export_enc_parser.add_argument(
|
|
3215
|
+
"-u", "--user", action="store_true", help="Use user session (qemu:///session)"
|
|
3216
|
+
)
|
|
3217
|
+
export_enc_parser.add_argument(
|
|
3218
|
+
"-o", "--output", help="Output file (default: <vmname>.enc)"
|
|
3219
|
+
)
|
|
3220
|
+
export_enc_parser.add_argument(
|
|
3221
|
+
"--user-data", action="store_true", help="Include user data (SSH keys, configs)"
|
|
3222
|
+
)
|
|
3223
|
+
export_enc_parser.add_argument(
|
|
3224
|
+
"--include-data", "-d", action="store_true", help="Include app data"
|
|
3225
|
+
)
|
|
3226
|
+
export_enc_parser.set_defaults(func=cmd_export_encrypted)
|
|
3227
|
+
|
|
3228
|
+
# Import-encrypted command
|
|
3229
|
+
import_enc_parser = subparsers.add_parser(
|
|
3230
|
+
"import-encrypted", help="Import VM with AES-256 decryption"
|
|
3231
|
+
)
|
|
3232
|
+
import_enc_parser.add_argument("archive", help="Path to encrypted archive (.enc)")
|
|
3233
|
+
import_enc_parser.add_argument(
|
|
3234
|
+
"-u", "--user", action="store_true", help="Use user session (qemu:///session)"
|
|
3235
|
+
)
|
|
3236
|
+
import_enc_parser.add_argument("--name", "-n", help="New name for imported VM")
|
|
3237
|
+
import_enc_parser.add_argument(
|
|
3238
|
+
"--user-data", action="store_true", help="Import user data"
|
|
3239
|
+
)
|
|
3240
|
+
import_enc_parser.add_argument(
|
|
3241
|
+
"--include-data", "-d", action="store_true", help="Import app data"
|
|
3242
|
+
)
|
|
3243
|
+
import_enc_parser.set_defaults(func=cmd_import_encrypted)
|
|
3244
|
+
|
|
3245
|
+
# Export-remote command
|
|
3246
|
+
export_remote_parser = subparsers.add_parser(
|
|
3247
|
+
"export-remote", help="Export VM from remote host via SSH"
|
|
3248
|
+
)
|
|
3249
|
+
export_remote_parser.add_argument("host", help="Remote host (user@hostname)")
|
|
3250
|
+
export_remote_parser.add_argument("vm_name", help="VM name on remote host")
|
|
3251
|
+
export_remote_parser.add_argument(
|
|
3252
|
+
"-o", "--output", required=True, help="Local output file"
|
|
3253
|
+
)
|
|
3254
|
+
export_remote_parser.add_argument(
|
|
3255
|
+
"--encrypted", "-e", action="store_true", help="Use encrypted export"
|
|
3256
|
+
)
|
|
3257
|
+
export_remote_parser.add_argument(
|
|
3258
|
+
"--user-data", action="store_true", help="Include user data"
|
|
3259
|
+
)
|
|
3260
|
+
export_remote_parser.add_argument(
|
|
3261
|
+
"--include-data", "-d", action="store_true", help="Include app data"
|
|
3262
|
+
)
|
|
3263
|
+
export_remote_parser.set_defaults(func=cmd_export_remote)
|
|
3264
|
+
|
|
3265
|
+
# Import-remote command
|
|
3266
|
+
import_remote_parser = subparsers.add_parser(
|
|
3267
|
+
"import-remote", help="Import VM to remote host via SSH"
|
|
3268
|
+
)
|
|
3269
|
+
import_remote_parser.add_argument("archive", help="Local archive to upload")
|
|
3270
|
+
import_remote_parser.add_argument("host", help="Remote host (user@hostname)")
|
|
3271
|
+
import_remote_parser.add_argument("--name", "-n", help="New name for VM on remote")
|
|
3272
|
+
import_remote_parser.add_argument(
|
|
3273
|
+
"--encrypted", "-e", action="store_true", help="Use encrypted import"
|
|
3274
|
+
)
|
|
3275
|
+
import_remote_parser.add_argument(
|
|
3276
|
+
"--user-data", action="store_true", help="Import user data"
|
|
3277
|
+
)
|
|
3278
|
+
import_remote_parser.set_defaults(func=cmd_import_remote)
|
|
3279
|
+
|
|
3280
|
+
# Sync-key command
|
|
3281
|
+
sync_key_parser = subparsers.add_parser(
|
|
3282
|
+
"sync-key", help="Sync encryption key to remote host"
|
|
3283
|
+
)
|
|
3284
|
+
sync_key_parser.add_argument("host", help="Remote host (user@hostname)")
|
|
3285
|
+
sync_key_parser.set_defaults(func=cmd_sync_key)
|
|
3286
|
+
|
|
3287
|
+
# List-remote command
|
|
3288
|
+
list_remote_parser = subparsers.add_parser(
|
|
3289
|
+
"list-remote", help="List VMs on remote host"
|
|
3290
|
+
)
|
|
3291
|
+
list_remote_parser.add_argument("host", help="Remote host (user@hostname)")
|
|
3292
|
+
list_remote_parser.set_defaults(func=cmd_list_remote)
|
|
3293
|
+
|
|
3077
3294
|
args = parser.parse_args()
|
|
3078
3295
|
|
|
3079
3296
|
if hasattr(args, "func"):
|
clonebox/exporter.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
VM Exporter - Export VM with all data and optional AES-256 encryption.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from cryptography.fernet import Fernet
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import libvirt
|
|
17
|
+
except ImportError:
|
|
18
|
+
libvirt = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VMExporter:
|
|
22
|
+
"""Export VM with disks, app data, and user data."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, conn_uri: str = "qemu:///system"):
|
|
25
|
+
self.conn_uri = conn_uri
|
|
26
|
+
self._conn = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def conn(self):
|
|
30
|
+
if self._conn is None:
|
|
31
|
+
if libvirt is None:
|
|
32
|
+
raise RuntimeError("libvirt-python not installed")
|
|
33
|
+
self._conn = libvirt.open(self.conn_uri)
|
|
34
|
+
return self._conn
|
|
35
|
+
|
|
36
|
+
def export_vm(
|
|
37
|
+
self,
|
|
38
|
+
vm_name: str,
|
|
39
|
+
output_path: Path,
|
|
40
|
+
include_user_data: bool = False,
|
|
41
|
+
include_app_data: bool = False,
|
|
42
|
+
) -> Path:
|
|
43
|
+
"""Full export of VM with disks and optional data."""
|
|
44
|
+
vm = self.conn.lookupByName(vm_name)
|
|
45
|
+
vm_xml = vm.XMLDesc()
|
|
46
|
+
root = ET.fromstring(vm_xml)
|
|
47
|
+
|
|
48
|
+
# Find all disk files
|
|
49
|
+
disks: List[Path] = []
|
|
50
|
+
for disk in root.findall(".//disk[@type='file']"):
|
|
51
|
+
source = disk.find(".//source")
|
|
52
|
+
if source is not None and source.get("file"):
|
|
53
|
+
disk_path = Path(source.get("file"))
|
|
54
|
+
if disk_path.exists():
|
|
55
|
+
disks.append(disk_path)
|
|
56
|
+
|
|
57
|
+
# Create archive
|
|
58
|
+
with tarfile.open(output_path, "w:gz") as tar:
|
|
59
|
+
# Add XML config
|
|
60
|
+
xml_tmp = Path(tempfile.gettempdir()) / f"{vm_name}.xml"
|
|
61
|
+
xml_tmp.write_text(vm_xml)
|
|
62
|
+
tar.add(xml_tmp, arcname=f"{vm_name}.xml")
|
|
63
|
+
xml_tmp.unlink()
|
|
64
|
+
|
|
65
|
+
# Add disks
|
|
66
|
+
for disk in disks:
|
|
67
|
+
arcname = f"disks/{disk.name}"
|
|
68
|
+
tar.add(disk, arcname=arcname)
|
|
69
|
+
print(f" 💾 Added disk: {disk}")
|
|
70
|
+
|
|
71
|
+
# Add app data
|
|
72
|
+
if include_app_data:
|
|
73
|
+
self._export_app_data(tar)
|
|
74
|
+
|
|
75
|
+
# Add user data
|
|
76
|
+
if include_user_data:
|
|
77
|
+
self._export_user_data(tar)
|
|
78
|
+
|
|
79
|
+
return output_path
|
|
80
|
+
|
|
81
|
+
def _export_app_data(self, tar: tarfile.TarFile) -> None:
|
|
82
|
+
"""Export common application data paths."""
|
|
83
|
+
common_paths = [
|
|
84
|
+
Path.home() / "projects",
|
|
85
|
+
Path.home() / ".docker",
|
|
86
|
+
Path("/opt/myapp"),
|
|
87
|
+
Path("/var/www"),
|
|
88
|
+
Path("/srv/docker"),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
for path in common_paths:
|
|
92
|
+
if path.exists():
|
|
93
|
+
arcname = f"app-data/{path.name}"
|
|
94
|
+
try:
|
|
95
|
+
tar.add(path, arcname=arcname, recursive=True)
|
|
96
|
+
print(f" 📁 App data: {path}")
|
|
97
|
+
except PermissionError:
|
|
98
|
+
print(f" ⚠️ Permission denied: {path}")
|
|
99
|
+
|
|
100
|
+
def _export_user_data(self, tar: tarfile.TarFile) -> None:
|
|
101
|
+
"""Export user data (home, SSH keys)."""
|
|
102
|
+
user_paths = [
|
|
103
|
+
Path.home() / ".ssh",
|
|
104
|
+
Path.home() / ".gitconfig",
|
|
105
|
+
Path.home() / ".bashrc",
|
|
106
|
+
Path.home() / ".zshrc",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
for path in user_paths:
|
|
110
|
+
if path.exists():
|
|
111
|
+
arcname = f"user-data/{path.name}"
|
|
112
|
+
try:
|
|
113
|
+
tar.add(path, arcname=arcname, recursive=True)
|
|
114
|
+
print(f" 👤 User data: {path}")
|
|
115
|
+
except PermissionError:
|
|
116
|
+
print(f" ⚠️ Permission denied: {path}")
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
if self._conn is not None:
|
|
120
|
+
self._conn.close()
|
|
121
|
+
self._conn = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SecureExporter:
|
|
125
|
+
"""AES-256 encrypted VM export."""
|
|
126
|
+
|
|
127
|
+
KEY_PATH = Path.home() / ".clonebox.key"
|
|
128
|
+
|
|
129
|
+
def __init__(self, conn_uri: str = "qemu:///system"):
|
|
130
|
+
self.exporter = VMExporter(conn_uri)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def generate_key(cls) -> Path:
|
|
134
|
+
"""Generate and save team encryption key."""
|
|
135
|
+
key = Fernet.generate_key()
|
|
136
|
+
cls.KEY_PATH.write_bytes(key)
|
|
137
|
+
os.chmod(str(cls.KEY_PATH), 0o600)
|
|
138
|
+
return cls.KEY_PATH
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def load_key(cls) -> Optional[bytes]:
|
|
142
|
+
"""Load encryption key from file."""
|
|
143
|
+
if cls.KEY_PATH.exists():
|
|
144
|
+
return cls.KEY_PATH.read_bytes()
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def export_encrypted(
|
|
148
|
+
self,
|
|
149
|
+
vm_name: str,
|
|
150
|
+
output_path: Path,
|
|
151
|
+
include_user_data: bool = False,
|
|
152
|
+
include_app_data: bool = False,
|
|
153
|
+
) -> Path:
|
|
154
|
+
"""Export VM with AES-256 encryption."""
|
|
155
|
+
key = self.load_key()
|
|
156
|
+
if key is None:
|
|
157
|
+
raise FileNotFoundError(
|
|
158
|
+
f"No encryption key found at {self.KEY_PATH}. Run: clonebox keygen"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
fernet = Fernet(key)
|
|
162
|
+
|
|
163
|
+
# Create temporary unencrypted archive
|
|
164
|
+
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
|
165
|
+
tmp_path = Path(tmp.name)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
# Export to temp file
|
|
169
|
+
self.exporter.export_vm(
|
|
170
|
+
vm_name=vm_name,
|
|
171
|
+
output_path=tmp_path,
|
|
172
|
+
include_user_data=include_user_data,
|
|
173
|
+
include_app_data=include_app_data,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Encrypt
|
|
177
|
+
data = tmp_path.read_bytes()
|
|
178
|
+
encrypted = fernet.encrypt(data)
|
|
179
|
+
output_path.write_bytes(encrypted)
|
|
180
|
+
|
|
181
|
+
finally:
|
|
182
|
+
# Cleanup temp file
|
|
183
|
+
if tmp_path.exists():
|
|
184
|
+
tmp_path.unlink()
|
|
185
|
+
|
|
186
|
+
return output_path
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
self.exporter.close()
|
clonebox/importer.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
VM Importer - Import VM with path reconfiguration and decryption.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tarfile
|
|
9
|
+
import tempfile
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from cryptography.fernet import Fernet
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import libvirt
|
|
18
|
+
except ImportError:
|
|
19
|
+
libvirt = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VMImporter:
|
|
23
|
+
"""Import VM with disk path reconfiguration."""
|
|
24
|
+
|
|
25
|
+
DEFAULT_DISK_DIR = Path("/var/lib/libvirt/images")
|
|
26
|
+
|
|
27
|
+
def __init__(self, conn_uri: str = "qemu:///system"):
|
|
28
|
+
self.conn_uri = conn_uri
|
|
29
|
+
self._conn = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def conn(self):
|
|
33
|
+
if self._conn is None:
|
|
34
|
+
if libvirt is None:
|
|
35
|
+
raise RuntimeError("libvirt-python not installed")
|
|
36
|
+
self._conn = libvirt.open(self.conn_uri)
|
|
37
|
+
return self._conn
|
|
38
|
+
|
|
39
|
+
def import_vm(
|
|
40
|
+
self,
|
|
41
|
+
archive_path: Path,
|
|
42
|
+
import_user_data: bool = False,
|
|
43
|
+
import_app_data: bool = False,
|
|
44
|
+
new_name: Optional[str] = None,
|
|
45
|
+
disk_dir: Optional[Path] = None,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Import VM from archive with full path reconfiguration."""
|
|
48
|
+
disk_dir = disk_dir or self.DEFAULT_DISK_DIR
|
|
49
|
+
|
|
50
|
+
with tempfile.TemporaryDirectory(prefix="clonebox-import-") as tmp_dir:
|
|
51
|
+
tmp_path = Path(tmp_dir)
|
|
52
|
+
|
|
53
|
+
# Extract archive
|
|
54
|
+
with tarfile.open(archive_path) as tar:
|
|
55
|
+
tar.extractall(tmp_path)
|
|
56
|
+
|
|
57
|
+
# Find XML file
|
|
58
|
+
xml_files = list(tmp_path.glob("*.xml"))
|
|
59
|
+
if not xml_files:
|
|
60
|
+
raise FileNotFoundError("No XML configuration found in archive")
|
|
61
|
+
xml_file = xml_files[0]
|
|
62
|
+
vm_name = xml_file.stem
|
|
63
|
+
|
|
64
|
+
# Move disks to libvirt images directory
|
|
65
|
+
disks_dir = tmp_path / "disks"
|
|
66
|
+
disk_mapping = {}
|
|
67
|
+
if disks_dir.exists():
|
|
68
|
+
for disk_file in disks_dir.iterdir():
|
|
69
|
+
dest = disk_dir / disk_file.name
|
|
70
|
+
shutil.copy2(disk_file, dest)
|
|
71
|
+
disk_mapping[disk_file.name] = dest
|
|
72
|
+
print(f" 💾 Copied disk: {dest}")
|
|
73
|
+
|
|
74
|
+
# Reconfigure disk paths in XML
|
|
75
|
+
vm_xml = self._reconfigure_paths(xml_file, disk_mapping, new_name)
|
|
76
|
+
|
|
77
|
+
# Define and create VM
|
|
78
|
+
vm = self.conn.defineXML(vm_xml)
|
|
79
|
+
final_name = new_name or vm_name
|
|
80
|
+
print(f" ✅ VM defined: {final_name}")
|
|
81
|
+
|
|
82
|
+
# Import user/app data
|
|
83
|
+
if import_user_data:
|
|
84
|
+
self._import_user_data(tmp_path / "user-data")
|
|
85
|
+
if import_app_data:
|
|
86
|
+
self._import_app_data(tmp_path / "app-data")
|
|
87
|
+
|
|
88
|
+
# Start VM
|
|
89
|
+
vm.create()
|
|
90
|
+
print(f" 🚀 VM started: {final_name}")
|
|
91
|
+
|
|
92
|
+
return final_name
|
|
93
|
+
|
|
94
|
+
def _reconfigure_paths(
|
|
95
|
+
self,
|
|
96
|
+
xml_file: Path,
|
|
97
|
+
disk_mapping: dict,
|
|
98
|
+
new_name: Optional[str] = None,
|
|
99
|
+
) -> str:
|
|
100
|
+
"""Update disk paths and optionally rename VM."""
|
|
101
|
+
tree = ET.parse(xml_file)
|
|
102
|
+
root = tree.getroot()
|
|
103
|
+
|
|
104
|
+
# Update name if requested
|
|
105
|
+
if new_name:
|
|
106
|
+
name_elem = root.find("name")
|
|
107
|
+
if name_elem is not None:
|
|
108
|
+
name_elem.text = new_name
|
|
109
|
+
|
|
110
|
+
# Update disk paths
|
|
111
|
+
for disk in root.findall(".//disk[@type='file']"):
|
|
112
|
+
source = disk.find(".//source")
|
|
113
|
+
if source is not None:
|
|
114
|
+
old_path = source.get("file")
|
|
115
|
+
if old_path:
|
|
116
|
+
disk_name = Path(old_path).name
|
|
117
|
+
if disk_name in disk_mapping:
|
|
118
|
+
source.set("file", str(disk_mapping[disk_name]))
|
|
119
|
+
print(f" 🔄 Remapped: {disk_name} → {disk_mapping[disk_name]}")
|
|
120
|
+
|
|
121
|
+
return ET.tostring(root, encoding="unicode")
|
|
122
|
+
|
|
123
|
+
def _import_user_data(self, user_data_dir: Path) -> None:
|
|
124
|
+
"""Restore user data."""
|
|
125
|
+
if user_data_dir.exists():
|
|
126
|
+
for item in user_data_dir.iterdir():
|
|
127
|
+
dest = Path.home() / item.name
|
|
128
|
+
if item.is_dir():
|
|
129
|
+
shutil.copytree(item, dest, dirs_exist_ok=True)
|
|
130
|
+
else:
|
|
131
|
+
shutil.copy2(item, dest)
|
|
132
|
+
print(f" 👤 Restored: {dest}")
|
|
133
|
+
|
|
134
|
+
def _import_app_data(self, app_data_dir: Path) -> None:
|
|
135
|
+
"""Restore application data."""
|
|
136
|
+
if app_data_dir.exists():
|
|
137
|
+
for item in app_data_dir.iterdir():
|
|
138
|
+
# Map back to original paths
|
|
139
|
+
dest_map = {
|
|
140
|
+
"projects": Path.home() / "projects",
|
|
141
|
+
".docker": Path.home() / ".docker",
|
|
142
|
+
"myapp": Path("/opt/myapp"),
|
|
143
|
+
"www": Path("/var/www"),
|
|
144
|
+
"docker": Path("/srv/docker"),
|
|
145
|
+
}
|
|
146
|
+
dest = dest_map.get(item.name, Path.home() / item.name)
|
|
147
|
+
try:
|
|
148
|
+
if item.is_dir():
|
|
149
|
+
shutil.copytree(item, dest, dirs_exist_ok=True)
|
|
150
|
+
else:
|
|
151
|
+
shutil.copy2(item, dest)
|
|
152
|
+
print(f" 📁 Restored: {dest}")
|
|
153
|
+
except PermissionError:
|
|
154
|
+
print(f" ⚠️ Permission denied: {dest}")
|
|
155
|
+
|
|
156
|
+
def close(self) -> None:
|
|
157
|
+
if self._conn is not None:
|
|
158
|
+
self._conn.close()
|
|
159
|
+
self._conn = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SecureImporter:
|
|
163
|
+
"""AES-256 decrypting VM importer."""
|
|
164
|
+
|
|
165
|
+
KEY_PATH = Path.home() / ".clonebox.key"
|
|
166
|
+
|
|
167
|
+
def __init__(self, conn_uri: str = "qemu:///system"):
|
|
168
|
+
self.importer = VMImporter(conn_uri)
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def load_key(cls) -> Optional[bytes]:
|
|
172
|
+
"""Load decryption key from file."""
|
|
173
|
+
if cls.KEY_PATH.exists():
|
|
174
|
+
return cls.KEY_PATH.read_bytes()
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def import_decrypted(
|
|
178
|
+
self,
|
|
179
|
+
encrypted_path: Path,
|
|
180
|
+
import_user_data: bool = False,
|
|
181
|
+
import_app_data: bool = False,
|
|
182
|
+
new_name: Optional[str] = None,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Import VM with AES-256 decryption."""
|
|
185
|
+
key = self.load_key()
|
|
186
|
+
if key is None:
|
|
187
|
+
raise FileNotFoundError(
|
|
188
|
+
f"No decryption key found at {self.KEY_PATH}. "
|
|
189
|
+
"Copy the team key to this location."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
fernet = Fernet(key)
|
|
193
|
+
|
|
194
|
+
# Create temporary decrypted archive
|
|
195
|
+
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
|
196
|
+
tmp_path = Path(tmp.name)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
# Decrypt
|
|
200
|
+
encrypted_data = encrypted_path.read_bytes()
|
|
201
|
+
decrypted = fernet.decrypt(encrypted_data)
|
|
202
|
+
tmp_path.write_bytes(decrypted)
|
|
203
|
+
|
|
204
|
+
# Import
|
|
205
|
+
vm_name = self.importer.import_vm(
|
|
206
|
+
archive_path=tmp_path,
|
|
207
|
+
import_user_data=import_user_data,
|
|
208
|
+
import_app_data=import_app_data,
|
|
209
|
+
new_name=new_name,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
finally:
|
|
213
|
+
# Cleanup
|
|
214
|
+
if tmp_path.exists():
|
|
215
|
+
tmp_path.unlink()
|
|
216
|
+
|
|
217
|
+
return vm_name
|
|
218
|
+
|
|
219
|
+
def close(self) -> None:
|
|
220
|
+
self.importer.close()
|
clonebox/p2p.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
P2P Manager - Transfer VMs between workstations via SSH/SCP.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class P2PManager:
|
|
13
|
+
"""Manage P2P VM transfers between workstations."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, ssh_options: Optional[list] = None):
|
|
16
|
+
self.ssh_options = ssh_options or [
|
|
17
|
+
"-o", "StrictHostKeyChecking=no",
|
|
18
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def _run_ssh(self, host: str, command: str) -> subprocess.CompletedProcess:
|
|
22
|
+
"""Execute command on remote host via SSH."""
|
|
23
|
+
cmd = ["ssh"] + self.ssh_options + [host, command]
|
|
24
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
25
|
+
|
|
26
|
+
def _run_scp(
|
|
27
|
+
self,
|
|
28
|
+
source: str,
|
|
29
|
+
destination: str,
|
|
30
|
+
recursive: bool = False,
|
|
31
|
+
) -> subprocess.CompletedProcess:
|
|
32
|
+
"""Copy files via SCP."""
|
|
33
|
+
cmd = ["scp"] + self.ssh_options
|
|
34
|
+
if recursive:
|
|
35
|
+
cmd.append("-r")
|
|
36
|
+
cmd.extend([source, destination])
|
|
37
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
38
|
+
|
|
39
|
+
def export_remote(
|
|
40
|
+
self,
|
|
41
|
+
host: str,
|
|
42
|
+
vm_name: str,
|
|
43
|
+
output: Path,
|
|
44
|
+
encrypted: bool = False,
|
|
45
|
+
include_user_data: bool = False,
|
|
46
|
+
include_app_data: bool = False,
|
|
47
|
+
) -> Path:
|
|
48
|
+
"""Export VM from remote host to local file.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
host: Remote host in format user@hostname
|
|
52
|
+
vm_name: Name of VM to export
|
|
53
|
+
output: Local output path
|
|
54
|
+
encrypted: Use encrypted export
|
|
55
|
+
include_user_data: Include user data
|
|
56
|
+
include_app_data: Include app data
|
|
57
|
+
"""
|
|
58
|
+
remote_tmp = f"/tmp/clonebox-{vm_name}.tar.gz"
|
|
59
|
+
if encrypted:
|
|
60
|
+
remote_tmp = f"/tmp/clonebox-{vm_name}.enc"
|
|
61
|
+
|
|
62
|
+
# Build export command
|
|
63
|
+
export_cmd = f"clonebox export {vm_name} -o {remote_tmp}"
|
|
64
|
+
if encrypted:
|
|
65
|
+
export_cmd = f"clonebox export-encrypted {vm_name} -o {remote_tmp}"
|
|
66
|
+
if include_user_data:
|
|
67
|
+
export_cmd += " --user"
|
|
68
|
+
if include_app_data:
|
|
69
|
+
export_cmd += " --include-data"
|
|
70
|
+
|
|
71
|
+
print(f"📤 Exporting {vm_name} from {host}...")
|
|
72
|
+
|
|
73
|
+
# Execute remote export
|
|
74
|
+
result = self._run_ssh(host, export_cmd)
|
|
75
|
+
if result.returncode != 0:
|
|
76
|
+
raise RuntimeError(f"Remote export failed: {result.stderr}")
|
|
77
|
+
|
|
78
|
+
# Download file
|
|
79
|
+
print(f"⬇️ Downloading to {output}...")
|
|
80
|
+
result = self._run_scp(f"{host}:{remote_tmp}", str(output))
|
|
81
|
+
if result.returncode != 0:
|
|
82
|
+
raise RuntimeError(f"SCP download failed: {result.stderr}")
|
|
83
|
+
|
|
84
|
+
# Cleanup remote temp file
|
|
85
|
+
self._run_ssh(host, f"rm -f {remote_tmp}")
|
|
86
|
+
|
|
87
|
+
print(f"✅ Downloaded: {output}")
|
|
88
|
+
return output
|
|
89
|
+
|
|
90
|
+
def import_remote(
|
|
91
|
+
self,
|
|
92
|
+
host: str,
|
|
93
|
+
archive_path: Path,
|
|
94
|
+
encrypted: bool = False,
|
|
95
|
+
import_user_data: bool = False,
|
|
96
|
+
new_name: Optional[str] = None,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Upload and import VM on remote host.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
host: Remote host in format user@hostname
|
|
102
|
+
archive_path: Local archive to upload
|
|
103
|
+
encrypted: Use decrypted import
|
|
104
|
+
import_user_data: Import user data
|
|
105
|
+
new_name: New name for VM on remote
|
|
106
|
+
"""
|
|
107
|
+
remote_tmp = f"/tmp/{archive_path.name}"
|
|
108
|
+
|
|
109
|
+
print(f"⬆️ Uploading {archive_path} to {host}...")
|
|
110
|
+
|
|
111
|
+
# Upload file
|
|
112
|
+
result = self._run_scp(str(archive_path), f"{host}:{remote_tmp}")
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
raise RuntimeError(f"SCP upload failed: {result.stderr}")
|
|
115
|
+
|
|
116
|
+
# Build import command
|
|
117
|
+
if encrypted:
|
|
118
|
+
import_cmd = f"clonebox import-encrypted {remote_tmp}"
|
|
119
|
+
else:
|
|
120
|
+
import_cmd = f"clonebox import {remote_tmp}"
|
|
121
|
+
|
|
122
|
+
if import_user_data:
|
|
123
|
+
import_cmd += " --user"
|
|
124
|
+
if new_name:
|
|
125
|
+
import_cmd += f" --name {new_name}"
|
|
126
|
+
|
|
127
|
+
print(f"📥 Importing on {host}...")
|
|
128
|
+
|
|
129
|
+
# Execute remote import
|
|
130
|
+
result = self._run_ssh(host, import_cmd)
|
|
131
|
+
if result.returncode != 0:
|
|
132
|
+
raise RuntimeError(f"Remote import failed: {result.stderr}")
|
|
133
|
+
|
|
134
|
+
# Cleanup remote temp file
|
|
135
|
+
self._run_ssh(host, f"rm -f {remote_tmp}")
|
|
136
|
+
|
|
137
|
+
print(f"✅ Import complete on {host}")
|
|
138
|
+
return new_name or archive_path.stem
|
|
139
|
+
|
|
140
|
+
def sync_key(self, host: str) -> bool:
|
|
141
|
+
"""Sync encryption key to remote host.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
host: Remote host in format user@hostname
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if key was synced successfully
|
|
148
|
+
"""
|
|
149
|
+
key_path = Path.home() / ".clonebox.key"
|
|
150
|
+
if not key_path.exists():
|
|
151
|
+
raise FileNotFoundError(f"No local key found at {key_path}")
|
|
152
|
+
|
|
153
|
+
print(f"🔑 Syncing encryption key to {host}...")
|
|
154
|
+
|
|
155
|
+
result = self._run_scp(str(key_path), f"{host}:~/.clonebox.key")
|
|
156
|
+
if result.returncode != 0:
|
|
157
|
+
raise RuntimeError(f"Key sync failed: {result.stderr}")
|
|
158
|
+
|
|
159
|
+
# Set proper permissions on remote
|
|
160
|
+
self._run_ssh(host, "chmod 600 ~/.clonebox.key")
|
|
161
|
+
|
|
162
|
+
print(f"✅ Key synced to {host}")
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def list_remote_vms(self, host: str) -> list:
|
|
166
|
+
"""List VMs on remote host.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
host: Remote host in format user@hostname
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of VM names
|
|
173
|
+
"""
|
|
174
|
+
result = self._run_ssh(host, "virsh list --all --name")
|
|
175
|
+
if result.returncode != 0:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
vms = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
179
|
+
return vms
|
|
180
|
+
|
|
181
|
+
def check_clonebox_installed(self, host: str) -> bool:
|
|
182
|
+
"""Check if clonebox is installed on remote host."""
|
|
183
|
+
result = self._run_ssh(host, "which clonebox || command -v clonebox")
|
|
184
|
+
return result.returncode == 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clonebox
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
4
4
|
Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
|
|
5
5
|
Author: CloneBox Team
|
|
6
6
|
License: Apache-2.0
|
|
@@ -104,7 +104,7 @@ CloneBox excels in scenarios where developers need:
|
|
|
104
104
|
|
|
105
105
|
## What's New in v1.1
|
|
106
106
|
|
|
107
|
-
**v1.1.
|
|
107
|
+
**v1.1.2** is production-ready with two full runtimes and P2P secure sharing:
|
|
108
108
|
|
|
109
109
|
| Feature | Status |
|
|
110
110
|
|---------|--------|
|
|
@@ -113,8 +113,34 @@ CloneBox excels in scenarios where developers need:
|
|
|
113
113
|
| 📊 Web Dashboard (FastAPI + HTMX + Tailwind) | ✅ Stable |
|
|
114
114
|
| 🎛️ Profiles System (`ml-dev`, `web-stack`) | ✅ Stable |
|
|
115
115
|
| 🔍 Auto-detection (services, apps, paths) | ✅ Stable |
|
|
116
|
+
| 🔒 P2P Secure Transfer (AES-256) | ✅ **NEW** |
|
|
116
117
|
| 🧪 95%+ Test Coverage | ✅ |
|
|
117
118
|
|
|
119
|
+
### P2P Secure VM Sharing
|
|
120
|
+
|
|
121
|
+
Share VMs between workstations with AES-256 encryption:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# Generate team encryption key (once per team)
|
|
125
|
+
clonebox keygen
|
|
126
|
+
# 🔑 Key saved: ~/.clonebox.key
|
|
127
|
+
|
|
128
|
+
# Export encrypted VM
|
|
129
|
+
clonebox export-encrypted my-dev-vm -o team-env.enc --user-data
|
|
130
|
+
|
|
131
|
+
# Transfer via SCP/SMB/USB
|
|
132
|
+
scp team-env.enc user@workstationB:~/
|
|
133
|
+
|
|
134
|
+
# Import on another machine (needs same key)
|
|
135
|
+
clonebox import-encrypted team-env.enc --name my-dev-copy
|
|
136
|
+
|
|
137
|
+
# Or use P2P commands directly
|
|
138
|
+
clonebox export-remote user@hostA my-vm -o local.enc --encrypted
|
|
139
|
+
clonebox import-remote local.enc user@hostB --encrypted
|
|
140
|
+
clonebox sync-key user@hostB # Sync encryption key
|
|
141
|
+
clonebox list-remote user@hostB # List remote VMs
|
|
142
|
+
```
|
|
143
|
+
|
|
118
144
|
### Roadmap
|
|
119
145
|
|
|
120
146
|
- **v1.2.0**: `clonebox exec` command, VM snapshots, snapshot restore
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
clonebox/__init__.py,sha256=CyfHVVq6KqBr4CNERBpXk_O6Q5B35q03YpdQbokVvvI,408
|
|
2
2
|
clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
|
|
3
|
-
clonebox/cli.py,sha256=
|
|
3
|
+
clonebox/cli.py,sha256=iPc2DWEDZaqBMXRJqpWKubmka9ZuJbAvs6mkMMEHAlw,122910
|
|
4
4
|
clonebox/cloner.py,sha256=2YQO4SHCv0xOsU1hL9IqdgmxxJN-2j75X9pe-LpTpJE,82696
|
|
5
5
|
clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
|
|
6
6
|
clonebox/dashboard.py,sha256=dMY6odvPq3j6FronhRRsX7aY3qdCwznB-aCWKEmHDNw,5768
|
|
7
7
|
clonebox/detector.py,sha256=vS65cvFNPmUBCX1Y_TMTnSRljw6r1Ae9dlVtACs5XFc,23075
|
|
8
|
+
clonebox/exporter.py,sha256=WIzVvmA0z_jjrpyXxvnXoLp9oaW6fKS7k0PGwzx_PIM,5629
|
|
9
|
+
clonebox/importer.py,sha256=Q9Uk1IOA41mgGhU4ynW2k-h9GEoGxRKI3c9wWE4uxcA,7097
|
|
8
10
|
clonebox/models.py,sha256=zwejkNtEEO_aPy_Q5UzXG5tszU-c7lkqh9LQus9eWMo,8307
|
|
11
|
+
clonebox/p2p.py,sha256=LPQQ7wNO84yDnpVrGkaRU-FDUzqmC4URdZXVeHsNOew,5889
|
|
9
12
|
clonebox/profiles.py,sha256=UP37fX_rhrG_O9ehNFJBUcULPmUtN1A8KsJ6cM44oK0,1986
|
|
10
13
|
clonebox/validator.py,sha256=CF4hMlY69-AGRH5HdG8HAA9_LNCwDKD4xPlYQPWJ9Rw,36647
|
|
11
14
|
clonebox/templates/profiles/ml-dev.yaml,sha256=w07MToGh31xtxpjbeXTBk9BkpAN8A3gv8HeA3ESKG9M,461
|
|
12
15
|
clonebox/templates/profiles/web-stack.yaml,sha256=EBnnGMzML5vAjXmIUbCpbTCwmRaNJiuWd3EcL43DOK8,485
|
|
13
|
-
clonebox-1.1.
|
|
14
|
-
clonebox-1.1.
|
|
15
|
-
clonebox-1.1.
|
|
16
|
-
clonebox-1.1.
|
|
17
|
-
clonebox-1.1.
|
|
18
|
-
clonebox-1.1.
|
|
16
|
+
clonebox-1.1.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
17
|
+
clonebox-1.1.3.dist-info/METADATA,sha256=16ilGCqzzvMBvEXxLZ-JlTyNxaI0u6ws74zM121Sw5o,47947
|
|
18
|
+
clonebox-1.1.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
19
|
+
clonebox-1.1.3.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
20
|
+
clonebox-1.1.3.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
21
|
+
clonebox-1.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|