clonebox 0.1.25__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/__init__.py +14 -0
- clonebox/__main__.py +7 -0
- clonebox/cli.py +2932 -0
- clonebox/cloner.py +2081 -0
- clonebox/container.py +190 -0
- clonebox/dashboard.py +133 -0
- clonebox/detector.py +705 -0
- clonebox/models.py +201 -0
- clonebox/profiles.py +66 -0
- clonebox/templates/profiles/ml-dev.yaml +6 -0
- clonebox/validator.py +841 -0
- clonebox-0.1.25.dist-info/METADATA +1382 -0
- clonebox-0.1.25.dist-info/RECORD +17 -0
- clonebox-0.1.25.dist-info/WHEEL +5 -0
- clonebox-0.1.25.dist-info/entry_points.txt +2 -0
- clonebox-0.1.25.dist-info/licenses/LICENSE +201 -0
- clonebox-0.1.25.dist-info/top_level.txt +1 -0
clonebox/models.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Pydantic models for CloneBox configuration validation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VMSettings(BaseModel):
|
|
14
|
+
"""VM-specific settings."""
|
|
15
|
+
|
|
16
|
+
name: str = Field(default="clonebox-vm", description="VM name")
|
|
17
|
+
ram_mb: int = Field(default=4096, ge=512, le=131072, description="RAM in MB")
|
|
18
|
+
vcpus: int = Field(default=4, ge=1, le=128, description="Number of vCPUs")
|
|
19
|
+
disk_size_gb: int = Field(default=20, ge=1, le=2048, description="Disk size in GB")
|
|
20
|
+
gui: bool = Field(default=True, description="Enable SPICE graphics")
|
|
21
|
+
base_image: Optional[str] = Field(default=None, description="Path to base qcow2 image")
|
|
22
|
+
network_mode: str = Field(default="auto", description="Network mode: auto|default|user")
|
|
23
|
+
username: str = Field(default="ubuntu", description="VM default username")
|
|
24
|
+
password: str = Field(default="ubuntu", description="VM default password")
|
|
25
|
+
|
|
26
|
+
@field_validator("name")
|
|
27
|
+
@classmethod
|
|
28
|
+
def name_must_be_valid(cls, v: str) -> str:
|
|
29
|
+
if not v or not v.strip():
|
|
30
|
+
raise ValueError("VM name cannot be empty")
|
|
31
|
+
if len(v) > 64:
|
|
32
|
+
raise ValueError("VM name must be <= 64 characters")
|
|
33
|
+
return v.strip()
|
|
34
|
+
|
|
35
|
+
@field_validator("network_mode")
|
|
36
|
+
@classmethod
|
|
37
|
+
def network_mode_must_be_valid(cls, v: str) -> str:
|
|
38
|
+
valid_modes = {"auto", "default", "user"}
|
|
39
|
+
if v not in valid_modes:
|
|
40
|
+
raise ValueError(f"network_mode must be one of: {valid_modes}")
|
|
41
|
+
return v
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CloneBoxConfig(BaseModel):
|
|
45
|
+
"""Complete CloneBox configuration with validation."""
|
|
46
|
+
|
|
47
|
+
version: str = Field(default="1", description="Config version")
|
|
48
|
+
generated: Optional[str] = Field(default=None, description="Generation timestamp")
|
|
49
|
+
vm: VMSettings = Field(default_factory=VMSettings, description="VM settings")
|
|
50
|
+
paths: Dict[str, str] = Field(default_factory=dict, description="Host:Guest path mappings")
|
|
51
|
+
app_data_paths: Dict[str, str] = Field(
|
|
52
|
+
default_factory=dict, description="Application data paths"
|
|
53
|
+
)
|
|
54
|
+
packages: List[str] = Field(default_factory=list, description="APT packages to install")
|
|
55
|
+
snap_packages: List[str] = Field(default_factory=list, description="Snap packages to install")
|
|
56
|
+
services: List[str] = Field(default_factory=list, description="Services to enable")
|
|
57
|
+
post_commands: List[str] = Field(default_factory=list, description="Post-setup commands")
|
|
58
|
+
detected: Optional[Dict[str, Any]] = Field(
|
|
59
|
+
default=None, description="Auto-detected system info"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@field_validator("paths", "app_data_paths")
|
|
63
|
+
@classmethod
|
|
64
|
+
def paths_must_be_absolute(cls, v: Dict[str, str]) -> Dict[str, str]:
|
|
65
|
+
for host_path, guest_path in v.items():
|
|
66
|
+
if not host_path.startswith("/"):
|
|
67
|
+
raise ValueError(f"Host path must be absolute: {host_path}")
|
|
68
|
+
if not guest_path.startswith("/"):
|
|
69
|
+
raise ValueError(f"Guest path must be absolute: {guest_path}")
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def handle_nested_vm(cls, data: Any) -> Any:
|
|
75
|
+
"""Handle both dict and nested vm structures."""
|
|
76
|
+
if isinstance(data, dict):
|
|
77
|
+
if "vm" in data and isinstance(data["vm"], dict):
|
|
78
|
+
return data
|
|
79
|
+
vm_fields = {"name", "ram_mb", "vcpus", "disk_size_gb", "gui", "base_image",
|
|
80
|
+
"network_mode", "username", "password"}
|
|
81
|
+
vm_data = {k: v for k, v in data.items() if k in vm_fields}
|
|
82
|
+
if vm_data:
|
|
83
|
+
data = {k: v for k, v in data.items() if k not in vm_fields}
|
|
84
|
+
data["vm"] = vm_data
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
def save(self, path: Path) -> None:
|
|
88
|
+
"""Save configuration to YAML file."""
|
|
89
|
+
import yaml
|
|
90
|
+
|
|
91
|
+
config_dict = self.model_dump(exclude_none=True)
|
|
92
|
+
path.write_text(yaml.dump(config_dict, default_flow_style=False, sort_keys=False))
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def load(cls, path: Path) -> "CloneBoxConfig":
|
|
96
|
+
"""Load configuration from YAML file."""
|
|
97
|
+
import yaml
|
|
98
|
+
|
|
99
|
+
if path.is_dir():
|
|
100
|
+
path = path / ".clonebox.yaml"
|
|
101
|
+
if not path.exists():
|
|
102
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
103
|
+
data = yaml.safe_load(path.read_text())
|
|
104
|
+
return cls.model_validate(data)
|
|
105
|
+
|
|
106
|
+
def to_vm_config(self) -> "VMConfigDataclass":
|
|
107
|
+
"""Convert to legacy VMConfig dataclass for compatibility."""
|
|
108
|
+
from clonebox.cloner import VMConfig as VMConfigDataclass
|
|
109
|
+
|
|
110
|
+
# Merge paths and app_data_paths
|
|
111
|
+
all_paths = dict(self.paths)
|
|
112
|
+
all_paths.update(self.app_data_paths)
|
|
113
|
+
|
|
114
|
+
return VMConfigDataclass(
|
|
115
|
+
name=self.vm.name,
|
|
116
|
+
ram_mb=self.vm.ram_mb,
|
|
117
|
+
vcpus=self.vm.vcpus,
|
|
118
|
+
disk_size_gb=self.vm.disk_size_gb,
|
|
119
|
+
gui=self.vm.gui,
|
|
120
|
+
base_image=self.vm.base_image,
|
|
121
|
+
paths=all_paths,
|
|
122
|
+
packages=self.packages,
|
|
123
|
+
snap_packages=self.snap_packages,
|
|
124
|
+
services=self.services,
|
|
125
|
+
post_commands=self.post_commands,
|
|
126
|
+
network_mode=self.vm.network_mode,
|
|
127
|
+
username=self.vm.username,
|
|
128
|
+
password=self.vm.password,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ContainerConfig(BaseModel):
|
|
133
|
+
name: str = Field(default_factory=lambda: f"clonebox-{uuid4().hex[:8]}")
|
|
134
|
+
engine: Literal["auto", "podman", "docker"] = "auto"
|
|
135
|
+
image: str = "ubuntu:22.04"
|
|
136
|
+
workspace: Path = Path(".")
|
|
137
|
+
extra_mounts: Dict[str, str] = Field(default_factory=dict)
|
|
138
|
+
env_from_dotenv: bool = True
|
|
139
|
+
packages: List[str] = Field(default_factory=list)
|
|
140
|
+
ports: List[str] = Field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
@field_validator("name")
|
|
143
|
+
@classmethod
|
|
144
|
+
def name_must_be_valid(cls, v: str) -> str:
|
|
145
|
+
if not v or not v.strip():
|
|
146
|
+
raise ValueError("Container name cannot be empty")
|
|
147
|
+
if len(v) > 64:
|
|
148
|
+
raise ValueError("Container name must be <= 64 characters")
|
|
149
|
+
return v.strip()
|
|
150
|
+
|
|
151
|
+
@field_validator("extra_mounts")
|
|
152
|
+
@classmethod
|
|
153
|
+
def extra_mounts_must_be_absolute(cls, v: Dict[str, str]) -> Dict[str, str]:
|
|
154
|
+
for host_path, container_path in v.items():
|
|
155
|
+
if not str(host_path).startswith("/"):
|
|
156
|
+
raise ValueError(f"Host path must be absolute: {host_path}")
|
|
157
|
+
if not str(container_path).startswith("/"):
|
|
158
|
+
raise ValueError(f"Container path must be absolute: {container_path}")
|
|
159
|
+
return v
|
|
160
|
+
|
|
161
|
+
@field_validator("ports")
|
|
162
|
+
@classmethod
|
|
163
|
+
def ports_must_be_valid(cls, v: List[str]) -> List[str]:
|
|
164
|
+
for p in v:
|
|
165
|
+
if not isinstance(p, str) or not p.strip():
|
|
166
|
+
raise ValueError("Port mapping cannot be empty")
|
|
167
|
+
if ":" in p:
|
|
168
|
+
host, container = p.split(":", 1)
|
|
169
|
+
if not host.isdigit() or not container.isdigit():
|
|
170
|
+
raise ValueError(f"Invalid port mapping: {p}")
|
|
171
|
+
else:
|
|
172
|
+
if not p.isdigit():
|
|
173
|
+
raise ValueError(f"Invalid port value: {p}")
|
|
174
|
+
return v
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def mounts(self) -> Dict[str, str]:
|
|
178
|
+
mounts: Dict[str, str] = {
|
|
179
|
+
str(self.workspace.resolve()): "/workspace",
|
|
180
|
+
}
|
|
181
|
+
mounts.update(self.extra_mounts)
|
|
182
|
+
return mounts
|
|
183
|
+
|
|
184
|
+
def to_docker_run_cmd(self) -> List[str]:
|
|
185
|
+
if self.engine == "auto":
|
|
186
|
+
raise ValueError("engine must be resolved before generating run command")
|
|
187
|
+
|
|
188
|
+
cmd: List[str] = [self.engine, "run", "-it", "--rm", "--name", self.name]
|
|
189
|
+
|
|
190
|
+
for src, dst in self.mounts.items():
|
|
191
|
+
cmd.extend(["-v", f"{src}:{dst}"])
|
|
192
|
+
|
|
193
|
+
for p in self.ports:
|
|
194
|
+
cmd.extend(["-p", p])
|
|
195
|
+
|
|
196
|
+
cmd.append(self.image)
|
|
197
|
+
return cmd
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Backwards compatibility alias
|
|
201
|
+
VMConfigModel = CloneBoxConfig
|
clonebox/profiles.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import pkgutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_profile(profile_name: str, search_paths: list[Path]) -> Optional[Dict[str, Any]]:
|
|
9
|
+
"""Load profile YAML from ~/.clonebox.d/, .clonebox.d/, templates/profiles/"""
|
|
10
|
+
search_paths = search_paths or []
|
|
11
|
+
|
|
12
|
+
profile_paths = [
|
|
13
|
+
Path.home() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
14
|
+
Path.cwd() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
for base in search_paths:
|
|
18
|
+
profile_paths.insert(0, base / "templates" / "profiles" / f"{profile_name}.yaml")
|
|
19
|
+
profile_paths.insert(0, base / f"{profile_name}.yaml")
|
|
20
|
+
|
|
21
|
+
for profile_path in profile_paths:
|
|
22
|
+
if profile_path.exists():
|
|
23
|
+
return yaml.safe_load(profile_path.read_text())
|
|
24
|
+
|
|
25
|
+
data = pkgutil.get_data("clonebox", f"templates/profiles/{profile_name}.yaml")
|
|
26
|
+
if data is not None:
|
|
27
|
+
return yaml.safe_load(data.decode())
|
|
28
|
+
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
33
|
+
merged: Dict[str, Any] = dict(base)
|
|
34
|
+
for key, value in override.items():
|
|
35
|
+
if (
|
|
36
|
+
key in merged
|
|
37
|
+
and isinstance(merged[key], dict)
|
|
38
|
+
and isinstance(value, dict)
|
|
39
|
+
):
|
|
40
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
41
|
+
else:
|
|
42
|
+
merged[key] = value
|
|
43
|
+
return merged
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def merge_with_profile(
|
|
47
|
+
base_config: Dict[str, Any],
|
|
48
|
+
profile_name: Optional[str] = None,
|
|
49
|
+
*,
|
|
50
|
+
profile: Optional[Dict[str, Any]] = None,
|
|
51
|
+
search_paths: Optional[list[Path]] = None,
|
|
52
|
+
) -> Dict[str, Any]:
|
|
53
|
+
"""Merge profile OVER base config (profile wins)."""
|
|
54
|
+
if profile is not None:
|
|
55
|
+
if not isinstance(profile, dict):
|
|
56
|
+
return base_config
|
|
57
|
+
return _deep_merge(base_config, profile)
|
|
58
|
+
|
|
59
|
+
if not profile_name:
|
|
60
|
+
return base_config
|
|
61
|
+
|
|
62
|
+
loaded = load_profile(profile_name, search_paths or [])
|
|
63
|
+
if not loaded or not isinstance(loaded, dict):
|
|
64
|
+
return base_config
|
|
65
|
+
|
|
66
|
+
return _deep_merge(base_config, loaded)
|