rocketdoo 2.2.3__tar.gz → 2.3.0__tar.gz
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.
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/LICENSE +1 -1
- {rocketdoo-2.2.3/rocketdoo.egg-info → rocketdoo-2.3.0}/PKG-INFO +3 -3
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/README.md +1 -1
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/pyproject.toml +1 -1
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/cli.py +8 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/deploy/vps.py +40 -6
- rocketdoo-2.3.0/rocketdoo/pack_environment.py +429 -0
- rocketdoo-2.3.0/rocketdoo/unpack_environment.py +477 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0/rocketdoo.egg-info}/PKG-INFO +3 -3
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo.egg-info/SOURCES.txt +2 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/MANIFEST.in +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/__init__.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/config.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/__init__.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/config_loader.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/deploy/__init__.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/deploy/base.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/deploy/config_manager.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/deploy/module_packager.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/deploy/odoo_sh.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/edition_setup.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/generator.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/gitman_config.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/module_scanner.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/port_validation.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/ssh_manager.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/core/utils.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/delete_identifiers.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/deploy_cli.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/docker_cli.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/init_project.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/main.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/project_info.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/rocketdoo.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/scaffold.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/.devcontainer/devcontainer.json +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/.devcontainer/docker-compose.yaml +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/.vscode/launch.json +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/.vscode/launch.json.jinja +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/.vscode/settings.json +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/Dockerfile.jinja +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/addons/.gitkeep +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/config/odoo.conf +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/config/odoo.conf.jinja +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/deploy/.deployignore +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/deploy/deploy.yaml +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/deploy/deploy.yaml.jinja +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/docker-compose.yaml.jinja +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/install_dependencies.sh +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/templates/odoo_pg_pass +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo/welcome.py +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo.egg-info/dependency_links.txt +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo.egg-info/entry_points.txt +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo.egg-info/requires.txt +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/rocketdoo.egg-info/top_level.txt +0 -0
- {rocketdoo-2.2.3 → rocketdoo-2.3.0}/setup.cfg +0 -0
|
@@ -57,7 +57,7 @@ If the Library as you received it specifies that a proxy can decide whether futu
|
|
|
57
57
|
<div RKD as ROCKETDOO V2=""></div>
|
|
58
58
|
|
|
59
59
|
Licencia: LGPL-3.0+
|
|
60
|
-
Versión: "2.
|
|
60
|
+
Versión: "2.3.0"
|
|
61
61
|
Autor: Horacio Montaño, Elias Braceras
|
|
62
62
|
Fecha: 16/10/2024
|
|
63
63
|
Descripción: Framework to development Odoo
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rocketdoo
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Framework for creating Odoo development environments with Docker and custom templates.
|
|
5
5
|
Author-email: Horacio Montaño <horaciomontano@hdmsoft.com.ar>
|
|
6
6
|
License: GNU LESSER GENERAL PUBLIC LICENSE
|
|
@@ -62,7 +62,7 @@ License: GNU LESSER GENERAL PUBLIC LICENSE
|
|
|
62
62
|
<div RKD as ROCKETDOO V2=""></div>
|
|
63
63
|
|
|
64
64
|
Licencia: LGPL-3.0+
|
|
65
|
-
Versión: "2.
|
|
65
|
+
Versión: "2.3.0"
|
|
66
66
|
Autor: Horacio Montaño, Elias Braceras
|
|
67
67
|
Fecha: 16/10/2024
|
|
68
68
|
Descripción: Framework to development Odoo
|
|
@@ -95,7 +95,7 @@ Odoo Development Framework
|
|
|
95
95
|
- "Horacio Montaño" and "Elias Braceras"
|
|
96
96
|
|
|
97
97
|
## Version:
|
|
98
|
-
- "2.
|
|
98
|
+
- "2.3.0"
|
|
99
99
|
|
|
100
100
|
----------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
101
101
|
|
|
@@ -15,7 +15,7 @@ Odoo Development Framework
|
|
|
15
15
|
- "Horacio Montaño" and "Elias Braceras"
|
|
16
16
|
|
|
17
17
|
## Version:
|
|
18
|
-
- "2.
|
|
18
|
+
- "2.3.0"
|
|
19
19
|
|
|
20
20
|
----------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
21
21
|
|
|
@@ -19,6 +19,8 @@ from rocketdoo.deploy_cli import (
|
|
|
19
19
|
deploy_run,
|
|
20
20
|
validate_modules
|
|
21
21
|
)
|
|
22
|
+
from rocketdoo.pack_environment import pack_environment
|
|
23
|
+
from rocketdoo.unpack_environment import unpack_environment
|
|
22
24
|
|
|
23
25
|
# Detect the command name used to invoke the CLI
|
|
24
26
|
PROG_NAME = "rkd" if "rkd" in sys.argv[0] else "rocketdoo"
|
|
@@ -425,6 +427,12 @@ main.add_command(logs)
|
|
|
425
427
|
main.add_command(build)
|
|
426
428
|
main.add_command(delete_command)
|
|
427
429
|
|
|
430
|
+
# ============================================================
|
|
431
|
+
# 📦 Register Share/Unpack commands
|
|
432
|
+
# ============================================================
|
|
433
|
+
main.add_command(pack_environment, name="pack")
|
|
434
|
+
main.add_command(unpack_environment, name="unpack")
|
|
435
|
+
|
|
428
436
|
# ============================================================
|
|
429
437
|
# 🚀 Register Deploy commands as Rocketdoo subcommands
|
|
430
438
|
# ============================================================
|
|
@@ -179,6 +179,7 @@ class VPSDeployer(BaseDeployer):
|
|
|
179
179
|
- SSH connectivity
|
|
180
180
|
- Remote paths exist
|
|
181
181
|
- Required commands available
|
|
182
|
+
- Sudo permissions (if needed)
|
|
182
183
|
|
|
183
184
|
Returns:
|
|
184
185
|
True if all checks pass
|
|
@@ -194,6 +195,29 @@ class VPSDeployer(BaseDeployer):
|
|
|
194
195
|
|
|
195
196
|
self.log("✓ SSH connection established", "success")
|
|
196
197
|
|
|
198
|
+
# Check sudo permissions if using native deployment with restart enabled
|
|
199
|
+
if (self.deployment_type == 'native' and
|
|
200
|
+
self.post_deploy_config.get('restart_service', False)):
|
|
201
|
+
self.log("Checking sudo permissions...", "info")
|
|
202
|
+
|
|
203
|
+
# Test sudo without password (passwordless sudo)
|
|
204
|
+
result = self._run_ssh_command("sudo -n true 2>&1")
|
|
205
|
+
|
|
206
|
+
if result.returncode != 0:
|
|
207
|
+
if self.auth_method == 'password':
|
|
208
|
+
self.log("✓ Sudo requires password (will use authentication password)", "info")
|
|
209
|
+
else:
|
|
210
|
+
self.log(
|
|
211
|
+
"⚠️ Sudo requires password but SSH key authentication is used.\n"
|
|
212
|
+
" Configure passwordless sudo for service restart or disable 'restart_service'.\n"
|
|
213
|
+
" Run on server: sudo visudo\n"
|
|
214
|
+
f" Add: {self.user} ALL=(ALL) NOPASSWD: /bin/systemctl restart {self.service_name}",
|
|
215
|
+
"warning"
|
|
216
|
+
)
|
|
217
|
+
# Don't fail, just warn
|
|
218
|
+
else:
|
|
219
|
+
self.log("✓ Passwordless sudo available", "success")
|
|
220
|
+
|
|
197
221
|
# Check if remote path exists
|
|
198
222
|
if self.deployment_type == 'docker':
|
|
199
223
|
remote_path = f"{self.compose_path}"
|
|
@@ -206,7 +230,7 @@ class VPSDeployer(BaseDeployer):
|
|
|
206
230
|
self.log(f"Warning: Remote path {remote_path} does not exist", "warning")
|
|
207
231
|
# Try to create it
|
|
208
232
|
self.log(f"Attempting to create {remote_path}...", "info")
|
|
209
|
-
create_result = self._run_ssh_command(f"sudo mkdir -p {remote_path}")
|
|
233
|
+
create_result = self._run_ssh_command(f"sudo mkdir -p {remote_path}", use_sudo=True)
|
|
210
234
|
if create_result.returncode != 0:
|
|
211
235
|
self.log(f"Failed to create remote path: {create_result.stderr}", "error")
|
|
212
236
|
return False
|
|
@@ -268,7 +292,6 @@ class VPSDeployer(BaseDeployer):
|
|
|
268
292
|
local_path = temp_dir / module_name
|
|
269
293
|
remote_module_path = f"{target_path}/{module_name}"
|
|
270
294
|
|
|
271
|
-
# ✅ SIMPLIFICADO: Siempre actualiza (rsync con --delete ya hace esto)
|
|
272
295
|
# Upload module
|
|
273
296
|
success = self._upload_directory(local_path, remote_module_path)
|
|
274
297
|
|
|
@@ -332,9 +355,10 @@ class VPSDeployer(BaseDeployer):
|
|
|
332
355
|
message="Failed to restart Docker container"
|
|
333
356
|
)
|
|
334
357
|
else:
|
|
335
|
-
# Restart systemd service
|
|
358
|
+
# Restart systemd service with sudo (handles password if needed)
|
|
336
359
|
result = self._run_ssh_command(
|
|
337
|
-
f"
|
|
360
|
+
f"systemctl restart {self.service_name}",
|
|
361
|
+
use_sudo=True
|
|
338
362
|
)
|
|
339
363
|
|
|
340
364
|
if result.returncode != 0:
|
|
@@ -397,17 +421,27 @@ class VPSDeployer(BaseDeployer):
|
|
|
397
421
|
message=f"Post-deploy error: {e}"
|
|
398
422
|
)
|
|
399
423
|
|
|
400
|
-
def _run_ssh_command(self, command: str, timeout: int = 300) -> subprocess.CompletedProcess:
|
|
424
|
+
def _run_ssh_command(self, command: str, timeout: int = 300, use_sudo: bool = False) -> subprocess.CompletedProcess:
|
|
401
425
|
"""
|
|
402
426
|
Execute command on remote server via SSH
|
|
403
427
|
|
|
404
428
|
Args:
|
|
405
429
|
command: Command to execute
|
|
406
430
|
timeout: Command timeout in seconds
|
|
431
|
+
use_sudo: If True, prepend sudo to command and handle password if needed
|
|
407
432
|
|
|
408
433
|
Returns:
|
|
409
434
|
CompletedProcess with result
|
|
410
435
|
"""
|
|
436
|
+
# Handle sudo with password authentication
|
|
437
|
+
if use_sudo:
|
|
438
|
+
if self.auth_method == 'password' and self.password:
|
|
439
|
+
# Use echo password | sudo -S for password-based sudo
|
|
440
|
+
command = f"echo '{self.password}' | sudo -S {command}"
|
|
441
|
+
else:
|
|
442
|
+
# Try passwordless sudo or rely on SSH key having sudo access
|
|
443
|
+
command = f"sudo {command}"
|
|
444
|
+
|
|
411
445
|
ssh_cmd = ['ssh']
|
|
412
446
|
|
|
413
447
|
# Add SSH options
|
|
@@ -606,7 +640,7 @@ class VPSDeployer(BaseDeployer):
|
|
|
606
640
|
if self.deployment_type == 'docker':
|
|
607
641
|
self._run_ssh_command(f"cd {self.compose_path} && docker-compose restart {self.container_name}")
|
|
608
642
|
else:
|
|
609
|
-
self._run_ssh_command(f"
|
|
643
|
+
self._run_ssh_command(f"systemctl restart {self.service_name}", use_sudo=True)
|
|
610
644
|
|
|
611
645
|
self.log("✓ Rollback completed", "success")
|
|
612
646
|
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# rocketdoo/pack_environment.py
|
|
2
|
+
"""
|
|
3
|
+
rkd pack — Packages the development environment to share with another developer.
|
|
4
|
+
|
|
5
|
+
Steps:
|
|
6
|
+
1. Validates that a Rocketdoo project exists in the current directory.
|
|
7
|
+
2. Backs up the active database + filestore via pg_dump inside the container.
|
|
8
|
+
3. Sanitizes the Dockerfile: comments out SSH key lines to avoid exposing them.
|
|
9
|
+
4. Excludes the .ssh/ directory from the ZIP (must never be shared).
|
|
10
|
+
5. Creates rkd-shared.json with environment metadata (flags private repo usage).
|
|
11
|
+
6. Compresses everything into a shareable ZIP file.
|
|
12
|
+
7. Restores the original Dockerfile after compression.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import json
|
|
17
|
+
import subprocess
|
|
18
|
+
import zipfile
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
import questionary
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.panel import Panel
|
|
26
|
+
from rich import box
|
|
27
|
+
|
|
28
|
+
from rocketdoo.project_info import get_project_info, project_exists, read_docker_compose
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
# ─────────────────────────────────────────────────────────────
|
|
33
|
+
# Internal helpers
|
|
34
|
+
# ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def _get_db_container(compose_data: dict) -> str | None:
|
|
37
|
+
"""Returns the database container name from docker-compose data."""
|
|
38
|
+
try:
|
|
39
|
+
return compose_data["services"]["db"]["container_name"]
|
|
40
|
+
except (KeyError, TypeError):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_odoo_container(compose_data: dict) -> str | None:
|
|
45
|
+
"""Returns the Odoo web container name from docker-compose data."""
|
|
46
|
+
try:
|
|
47
|
+
return compose_data["services"]["web"]["container_name"]
|
|
48
|
+
except (KeyError, TypeError):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_container_running(container_name: str) -> bool:
|
|
53
|
+
"""Checks whether a Docker container is currently running."""
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["docker", "inspect", "--format", "{{.State.Running}}", container_name],
|
|
57
|
+
capture_output=True, text=True
|
|
58
|
+
)
|
|
59
|
+
return result.stdout.strip() == "true"
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _list_odoo_databases(db_container: str) -> list[str]:
|
|
65
|
+
"""Lists available databases in the PostgreSQL container."""
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
[
|
|
69
|
+
"docker", "exec", db_container,
|
|
70
|
+
"psql", "-U", "root", "-d", "postgres",
|
|
71
|
+
"-t", "-c",
|
|
72
|
+
"SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres';"
|
|
73
|
+
],
|
|
74
|
+
capture_output=True, text=True
|
|
75
|
+
)
|
|
76
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
77
|
+
except Exception:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _backup_database(db_container: str, db_name: str, output_path: Path) -> bool:
|
|
82
|
+
"""
|
|
83
|
+
Runs pg_dump inside the container and saves the result to output_path.
|
|
84
|
+
Returns True if the backup succeeded.
|
|
85
|
+
"""
|
|
86
|
+
console.print(f" [dim]Running pg_dump for '[cyan]{db_name}[/cyan]'...[/dim]")
|
|
87
|
+
try:
|
|
88
|
+
with open(output_path, "wb") as f:
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
[
|
|
91
|
+
"docker", "exec", db_container,
|
|
92
|
+
"pg_dump", "-U", "root", "--format=custom", db_name
|
|
93
|
+
],
|
|
94
|
+
stdout=f,
|
|
95
|
+
stderr=subprocess.PIPE
|
|
96
|
+
)
|
|
97
|
+
if result.returncode != 0:
|
|
98
|
+
console.print(f" [red]✗ pg_dump error:[/red] {result.stderr.decode()}")
|
|
99
|
+
return False
|
|
100
|
+
size_mb = output_path.stat().st_size / (1024 * 1024)
|
|
101
|
+
console.print(f" [green]✓[/green] Backup saved: [yellow]{output_path.name}[/yellow] ({size_mb:.1f} MB)")
|
|
102
|
+
return True
|
|
103
|
+
except Exception as e:
|
|
104
|
+
console.print(f" [red]✗ Exception during database backup:[/red] {e}")
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _backup_filestore(odoo_container: str, db_name: str, output_path: Path) -> bool:
|
|
109
|
+
"""
|
|
110
|
+
Copies and compresses the Odoo filestore from the container to the host.
|
|
111
|
+
Filestore path inside container: /var/lib/odoo/.local/share/Odoo/filestore/<db_name>/
|
|
112
|
+
"""
|
|
113
|
+
filestore_path = f"/var/lib/odoo/.local/share/Odoo/filestore/{db_name}"
|
|
114
|
+
console.print(f" [dim]Copying filestore from container...[/dim]")
|
|
115
|
+
try:
|
|
116
|
+
check = subprocess.run(
|
|
117
|
+
["docker", "exec", odoo_container, "test", "-d", filestore_path],
|
|
118
|
+
capture_output=True
|
|
119
|
+
)
|
|
120
|
+
if check.returncode != 0:
|
|
121
|
+
console.print(f" [yellow]⚠[/yellow] Filestore not found at [dim]{filestore_path}[/dim] — skipping.")
|
|
122
|
+
return True # Not a fatal error
|
|
123
|
+
|
|
124
|
+
with open(output_path, "wb") as f:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
[
|
|
127
|
+
"docker", "exec", odoo_container,
|
|
128
|
+
"tar", "-czf", "-", "-C",
|
|
129
|
+
"/var/lib/odoo/.local/share/Odoo/filestore/",
|
|
130
|
+
db_name
|
|
131
|
+
],
|
|
132
|
+
stdout=f,
|
|
133
|
+
stderr=subprocess.PIPE
|
|
134
|
+
)
|
|
135
|
+
if result.returncode != 0:
|
|
136
|
+
console.print(f" [red]✗ Error copying filestore:[/red] {result.stderr.decode()}")
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
size_mb = output_path.stat().st_size / (1024 * 1024)
|
|
140
|
+
console.print(f" [green]✓[/green] Filestore compressed: [yellow]{output_path.name}[/yellow] ({size_mb:.1f} MB)")
|
|
141
|
+
return True
|
|
142
|
+
except Exception as e:
|
|
143
|
+
console.print(f" [red]✗ Exception during filestore backup:[/red] {e}")
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _sanitize_dockerfile(dockerfile_path: Path) -> str:
|
|
148
|
+
"""
|
|
149
|
+
Reads the Dockerfile, comments out all SSH key related lines,
|
|
150
|
+
and returns the ORIGINAL content so it can be restored afterwards.
|
|
151
|
+
Writes the sanitized version to disk.
|
|
152
|
+
"""
|
|
153
|
+
original_content = dockerfile_path.read_text()
|
|
154
|
+
|
|
155
|
+
ssh_patterns = [
|
|
156
|
+
r"^RUN mkdir -p /root/\.ssh",
|
|
157
|
+
r"^COPY \./.ssh/",
|
|
158
|
+
r"^RUN chmod \d+ /root/\.ssh/",
|
|
159
|
+
r"^RUN echo .StrictHostKeyChecking",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
sanitized_lines = []
|
|
163
|
+
for line in original_content.splitlines():
|
|
164
|
+
stripped = line.strip()
|
|
165
|
+
is_ssh_line = any(re.match(pat, stripped) for pat in ssh_patterns)
|
|
166
|
+
if is_ssh_line:
|
|
167
|
+
sanitized_lines.append(f"# [RKD-SANITIZED] {line}")
|
|
168
|
+
else:
|
|
169
|
+
sanitized_lines.append(line)
|
|
170
|
+
|
|
171
|
+
dockerfile_path.write_text("\n".join(sanitized_lines))
|
|
172
|
+
return original_content
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _restore_dockerfile(dockerfile_path: Path, original_content: str):
|
|
176
|
+
"""Restores the Dockerfile to its original content."""
|
|
177
|
+
dockerfile_path.write_text(original_content)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _verify_no_ssh_in_zip(zip_path: Path) -> list[str]:
|
|
181
|
+
"""
|
|
182
|
+
Safety double-check: scans the ZIP for files that look like private SSH keys.
|
|
183
|
+
Returns a list of suspicious file names found.
|
|
184
|
+
"""
|
|
185
|
+
suspicious = []
|
|
186
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
187
|
+
for name in zf.namelist():
|
|
188
|
+
basename = Path(name).name
|
|
189
|
+
if any([
|
|
190
|
+
basename.startswith("id_rsa") and not basename.endswith(".pub"),
|
|
191
|
+
basename.startswith("id_ed25519") and not basename.endswith(".pub"),
|
|
192
|
+
basename.startswith("id_ecdsa") and not basename.endswith(".pub"),
|
|
193
|
+
"/.ssh/" in name and not name.endswith(".pub"),
|
|
194
|
+
]):
|
|
195
|
+
suspicious.append(name)
|
|
196
|
+
return suspicious
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _create_zip(project_dir: Path, zip_path: Path, backup_dir: Path, exclude_dirs: list[str]) -> int:
|
|
200
|
+
"""
|
|
201
|
+
Creates the ZIP archive of the full environment.
|
|
202
|
+
Excludes: .ssh/, __pycache__, .git/, node_modules, and any extra dirs provided.
|
|
203
|
+
Includes the backup directory contents under rkd_backups/.
|
|
204
|
+
Returns the number of files included.
|
|
205
|
+
"""
|
|
206
|
+
ALWAYS_EXCLUDE = {".ssh", "__pycache__", ".git", "node_modules", ".mypy_cache"}
|
|
207
|
+
excluded = ALWAYS_EXCLUDE | set(exclude_dirs)
|
|
208
|
+
|
|
209
|
+
file_count = 0
|
|
210
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
|
211
|
+
for item in project_dir.rglob("*"):
|
|
212
|
+
rel = item.relative_to(project_dir)
|
|
213
|
+
if set(rel.parts) & excluded:
|
|
214
|
+
continue
|
|
215
|
+
if item.is_file():
|
|
216
|
+
zf.write(item, rel)
|
|
217
|
+
file_count += 1
|
|
218
|
+
|
|
219
|
+
if backup_dir.exists() and not backup_dir.is_relative_to(project_dir):
|
|
220
|
+
for bf in backup_dir.iterdir():
|
|
221
|
+
if bf.is_file():
|
|
222
|
+
zf.write(bf, Path("rkd_backups") / bf.name)
|
|
223
|
+
file_count += 1
|
|
224
|
+
|
|
225
|
+
return file_count
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ─────────────────────────────────────────────────────────────
|
|
229
|
+
# Main command
|
|
230
|
+
# ─────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
@click.command(name="pack")
|
|
233
|
+
@click.option("--no-db", is_flag=True, default=False,
|
|
234
|
+
help="Skip the database and filestore backup (environment files only).")
|
|
235
|
+
@click.option("--output", "-o", default=None, type=click.Path(),
|
|
236
|
+
help="Output path for the ZIP file (default: parent directory, named after the project).")
|
|
237
|
+
@click.option("--db-name", default=None,
|
|
238
|
+
help="Name of the database to back up (useful when multiple databases exist).")
|
|
239
|
+
def pack_environment(no_db, output, db_name):
|
|
240
|
+
"""
|
|
241
|
+
📦 Package the development environment to share with another developer.
|
|
242
|
+
|
|
243
|
+
Generates a ZIP with the full environment directory, a database backup,
|
|
244
|
+
and the filestore — sanitizing SSH keys so they are never exposed.
|
|
245
|
+
|
|
246
|
+
\b
|
|
247
|
+
Examples:
|
|
248
|
+
rkd pack → full backup + ZIP
|
|
249
|
+
rkd pack --no-db → environment only (no DB backup)
|
|
250
|
+
rkd pack -o /tmp/my.zip → specify output path
|
|
251
|
+
"""
|
|
252
|
+
console.print()
|
|
253
|
+
console.print(Panel(
|
|
254
|
+
"[bold cyan]📦 RKD Pack — Prepare environment for sharing[/bold cyan]\n\n"
|
|
255
|
+
"[dim]This process will:[/dim]\n"
|
|
256
|
+
" [green]✓[/green] Back up the database and filestore\n"
|
|
257
|
+
" [green]✓[/green] Sanitize SSH keys from the Dockerfile\n"
|
|
258
|
+
" [green]✓[/green] Generate a shareable ZIP file",
|
|
259
|
+
border_style="cyan",
|
|
260
|
+
box=box.ROUNDED
|
|
261
|
+
))
|
|
262
|
+
console.print()
|
|
263
|
+
|
|
264
|
+
# ── 1. Validate project ──
|
|
265
|
+
if not project_exists():
|
|
266
|
+
console.print("[red]✗[/red] No Rocketdoo project detected in this directory.")
|
|
267
|
+
console.print("[dim]💡 Run [cyan]rkd init[/cyan] first.[/dim]")
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
project_dir = Path.cwd()
|
|
271
|
+
project_info = get_project_info()
|
|
272
|
+
project_name = project_info.get("project_name") or project_dir.name
|
|
273
|
+
compose_data = read_docker_compose()
|
|
274
|
+
|
|
275
|
+
console.print(f"[bold]Project detected:[/bold] [cyan]{project_name}[/cyan]")
|
|
276
|
+
console.print(f"[bold]Odoo:[/bold] {project_info.get('odoo_version', 'unknown')} "
|
|
277
|
+
f"({project_info.get('odoo_edition', 'Community')})")
|
|
278
|
+
console.print()
|
|
279
|
+
|
|
280
|
+
# ── 2. Database and filestore backup ──
|
|
281
|
+
backup_dir = project_dir / "rkd_backups"
|
|
282
|
+
db_backup_path = None
|
|
283
|
+
fs_backup_path = None
|
|
284
|
+
|
|
285
|
+
if not no_db:
|
|
286
|
+
db_container = _get_db_container(compose_data) if compose_data else None
|
|
287
|
+
odoo_container = _get_odoo_container(compose_data) if compose_data else None
|
|
288
|
+
|
|
289
|
+
if not db_container:
|
|
290
|
+
console.print("[yellow]⚠[/yellow] Could not detect the database container.")
|
|
291
|
+
console.print("[dim] Continuing without DB backup. Use [cyan]--no-db[/cyan] to suppress this warning.[/dim]")
|
|
292
|
+
elif not _is_container_running(db_container):
|
|
293
|
+
console.print(f"[yellow]⚠[/yellow] Container [cyan]{db_container}[/cyan] is not running.")
|
|
294
|
+
console.print("[dim] Start the environment with [cyan]rkd up -d[/cyan] before running pack with backup.[/dim]")
|
|
295
|
+
if not questionary.confirm("Continue anyway without DB backup?", default=False).ask():
|
|
296
|
+
console.print("[yellow]Operation cancelled.[/yellow]")
|
|
297
|
+
return
|
|
298
|
+
else:
|
|
299
|
+
console.print("[bold]💾 Database backup:[/bold]")
|
|
300
|
+
available_dbs = _list_odoo_databases(db_container)
|
|
301
|
+
|
|
302
|
+
if not available_dbs:
|
|
303
|
+
console.print(" [yellow]⚠[/yellow] No Odoo databases found.")
|
|
304
|
+
else:
|
|
305
|
+
if db_name and db_name in available_dbs:
|
|
306
|
+
selected_db = db_name
|
|
307
|
+
elif len(available_dbs) == 1:
|
|
308
|
+
selected_db = available_dbs[0]
|
|
309
|
+
console.print(f" Database detected: [cyan]{selected_db}[/cyan]")
|
|
310
|
+
else:
|
|
311
|
+
selected_db = questionary.select(
|
|
312
|
+
"Select the database to back up:",
|
|
313
|
+
choices=available_dbs
|
|
314
|
+
).ask()
|
|
315
|
+
|
|
316
|
+
backup_dir.mkdir(exist_ok=True)
|
|
317
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
318
|
+
db_backup_path = backup_dir / f"db_{selected_db}_{timestamp}.dump"
|
|
319
|
+
fs_backup_path = backup_dir / f"filestore_{selected_db}_{timestamp}.tar.gz"
|
|
320
|
+
|
|
321
|
+
db_ok = _backup_database(db_container, selected_db, db_backup_path)
|
|
322
|
+
if not db_ok:
|
|
323
|
+
db_backup_path = None
|
|
324
|
+
|
|
325
|
+
if odoo_container and _is_container_running(odoo_container):
|
|
326
|
+
_backup_filestore(odoo_container, selected_db, fs_backup_path)
|
|
327
|
+
else:
|
|
328
|
+
console.print(f" [yellow]⚠[/yellow] Odoo container [cyan]{odoo_container}[/cyan] is not running — filestore skipped.")
|
|
329
|
+
fs_backup_path = None
|
|
330
|
+
|
|
331
|
+
# ── 3. Detect SSH key usage ──
|
|
332
|
+
uses_ssh = project_info.get("use_private_repos", False)
|
|
333
|
+
ssh_key_name = project_info.get("ssh_key")
|
|
334
|
+
|
|
335
|
+
if uses_ssh:
|
|
336
|
+
console.print()
|
|
337
|
+
console.print("[bold]🔐 SSH keys detected:[/bold]")
|
|
338
|
+
console.print(f" Key in use: [yellow]{ssh_key_name or 'detected in Dockerfile'}[/yellow]")
|
|
339
|
+
console.print(" [dim]SSH keys will be excluded from the ZIP.[/dim]")
|
|
340
|
+
|
|
341
|
+
# ── 4. Sanitize Dockerfile ──
|
|
342
|
+
dockerfile_path = project_dir / "Dockerfile"
|
|
343
|
+
original_dockerfile = None
|
|
344
|
+
|
|
345
|
+
if dockerfile_path.exists() and uses_ssh:
|
|
346
|
+
console.print()
|
|
347
|
+
console.print("[bold]🧹 Sanitizing Dockerfile...[/bold]")
|
|
348
|
+
original_dockerfile = _sanitize_dockerfile(dockerfile_path)
|
|
349
|
+
console.print(" [green]✓[/green] SSH lines commented out in the Dockerfile for the ZIP.")
|
|
350
|
+
|
|
351
|
+
# ── 5. Write rkd-shared.json metadata ──
|
|
352
|
+
shared_meta = {
|
|
353
|
+
"rkd_shared": True,
|
|
354
|
+
"packed_at": datetime.now().isoformat(),
|
|
355
|
+
"project_name": project_name,
|
|
356
|
+
"odoo_version": project_info.get("odoo_version"),
|
|
357
|
+
"odoo_edition": project_info.get("odoo_edition", "Community"),
|
|
358
|
+
"db_version": project_info.get("db_version"),
|
|
359
|
+
"odoo_port": project_info.get("odoo_port"),
|
|
360
|
+
"vsc_port": project_info.get("vsc_port"),
|
|
361
|
+
"uses_private_repos": uses_ssh,
|
|
362
|
+
"ssh_key_name": ssh_key_name,
|
|
363
|
+
"has_db_backup": db_backup_path is not None and db_backup_path.exists(),
|
|
364
|
+
"has_filestore_backup": fs_backup_path is not None and fs_backup_path.exists(),
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
meta_path = project_dir / "rkd-shared.json"
|
|
368
|
+
meta_path.write_text(json.dumps(shared_meta, indent=2, ensure_ascii=False))
|
|
369
|
+
console.print()
|
|
370
|
+
console.print("[dim]📋 Environment metadata written to rkd-shared.json[/dim]")
|
|
371
|
+
|
|
372
|
+
# ── 6. Create ZIP ──
|
|
373
|
+
console.print()
|
|
374
|
+
console.print("[bold]🗜️ Creating ZIP...[/bold]")
|
|
375
|
+
|
|
376
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
377
|
+
if output:
|
|
378
|
+
zip_path = Path(output)
|
|
379
|
+
else:
|
|
380
|
+
zip_path = project_dir.parent / f"{project_name}_rkd_shared_{timestamp}.zip"
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
file_count = _create_zip(
|
|
384
|
+
project_dir=project_dir,
|
|
385
|
+
zip_path=zip_path,
|
|
386
|
+
backup_dir=backup_dir,
|
|
387
|
+
exclude_dirs=[".ssh"]
|
|
388
|
+
)
|
|
389
|
+
except Exception as e:
|
|
390
|
+
console.print(f"[red]✗ Error creating ZIP:[/red] {e}")
|
|
391
|
+
if original_dockerfile:
|
|
392
|
+
_restore_dockerfile(dockerfile_path, original_dockerfile)
|
|
393
|
+
meta_path.unlink(missing_ok=True)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
# ── 7. Restore original Dockerfile ──
|
|
397
|
+
if original_dockerfile:
|
|
398
|
+
_restore_dockerfile(dockerfile_path, original_dockerfile)
|
|
399
|
+
console.print("[dim] Original Dockerfile restored.[/dim]")
|
|
400
|
+
|
|
401
|
+
# Clean up temporary metadata from working directory
|
|
402
|
+
meta_path.unlink(missing_ok=True)
|
|
403
|
+
|
|
404
|
+
# ── 8. SSH safety verification ──
|
|
405
|
+
suspicious = _verify_no_ssh_in_zip(zip_path)
|
|
406
|
+
if suspicious:
|
|
407
|
+
console.print()
|
|
408
|
+
console.print("[bold red]⚠️ SECURITY WARNING:[/bold red]")
|
|
409
|
+
console.print("[red]Possible private SSH keys detected inside the ZIP:[/red]")
|
|
410
|
+
for s in suspicious:
|
|
411
|
+
console.print(f" [red]• {s}[/red]")
|
|
412
|
+
console.print("[dim]Please review the ZIP contents before sharing.[/dim]")
|
|
413
|
+
|
|
414
|
+
# ── 9. Final summary ──
|
|
415
|
+
zip_size_mb = zip_path.stat().st_size / (1024 * 1024)
|
|
416
|
+
console.print()
|
|
417
|
+
console.print(Panel(
|
|
418
|
+
f"[bold green]✅ Environment packaged successfully[/bold green]\n\n"
|
|
419
|
+
f"[bold]📁 File:[/bold] [cyan]{zip_path}[/cyan]\n"
|
|
420
|
+
f"[bold]📦 Files included:[/bold] {file_count}\n"
|
|
421
|
+
f"[bold]💾 Size:[/bold] {zip_size_mb:.1f} MB\n"
|
|
422
|
+
f"[bold]🔐 SSH Keys:[/bold] {'excluded ✓' if uses_ssh else 'not applicable'}\n"
|
|
423
|
+
f"[bold]💿 DB Backup:[/bold] {'included ✓' if db_backup_path else ('skipped (--no-db)' if no_db else 'not available')}\n\n"
|
|
424
|
+
f"[dim]Share the ZIP with the other developer.\n"
|
|
425
|
+
f"The recipient should run: [cyan]rkd unpack[/cyan][/dim]",
|
|
426
|
+
border_style="green",
|
|
427
|
+
box=box.ROUNDED
|
|
428
|
+
))
|
|
429
|
+
console.print()
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
# rocketdoo/unpack_environment.py
|
|
2
|
+
"""
|
|
3
|
+
rkd unpack — Starts a development environment shared by another developer.
|
|
4
|
+
|
|
5
|
+
Steps:
|
|
6
|
+
1. Detects rkd-shared.json to confirm this is a shared environment.
|
|
7
|
+
2. Validates that the environment's ports are available; suggests alternatives if not.
|
|
8
|
+
3. If the environment used private repos (SSH), lists the recipient's keys and
|
|
9
|
+
configures the Dockerfile with the chosen one.
|
|
10
|
+
4. Starts the environment with docker compose up -d.
|
|
11
|
+
5. If a database backup is present, automatically restores the DB and filestore.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
import questionary
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
from rich import box
|
|
26
|
+
|
|
27
|
+
from rocketdoo.core.port_validation import is_port_in_use, find_available_port
|
|
28
|
+
from rocketdoo.core.ssh_manager import list_private_keys, copy_key_to_build_context, inject_ssh_into_dockerfile
|
|
29
|
+
from rocketdoo.project_info import project_exists, read_docker_compose
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ─────────────────────────────────────────────────────────────
|
|
35
|
+
# Internal helpers
|
|
36
|
+
# ─────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def _load_shared_meta(project_dir: Path) -> dict | None:
|
|
39
|
+
"""Loads the rkd-shared.json file if it exists."""
|
|
40
|
+
meta_path = project_dir / "rkd-shared.json"
|
|
41
|
+
if meta_path.exists():
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(meta_path.read_text())
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _find_backup_files(project_dir: Path) -> tuple[Path | None, Path | None]:
|
|
50
|
+
"""
|
|
51
|
+
Searches for backup files inside the rkd_backups directory.
|
|
52
|
+
Returns (db_dump_path, filestore_tar_path) — None if not found.
|
|
53
|
+
"""
|
|
54
|
+
backup_dir = project_dir / "rkd_backups"
|
|
55
|
+
if not backup_dir.exists():
|
|
56
|
+
return None, None
|
|
57
|
+
|
|
58
|
+
dumps = sorted(backup_dir.glob("db_*.dump"), reverse=True)
|
|
59
|
+
filestores = sorted(backup_dir.glob("filestore_*.tar.gz"), reverse=True)
|
|
60
|
+
|
|
61
|
+
return (dumps[0] if dumps else None), (filestores[0] if filestores else None)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _check_ports(meta: dict) -> tuple[int, int, bool]:
|
|
65
|
+
"""
|
|
66
|
+
Verifies whether the environment's ports are available.
|
|
67
|
+
Returns (final_odoo_port, final_vsc_port, had_changes).
|
|
68
|
+
"""
|
|
69
|
+
odoo_port = int(meta.get("odoo_port", 8069))
|
|
70
|
+
vsc_port = int(meta.get("vsc_port", 8888))
|
|
71
|
+
changed = False
|
|
72
|
+
|
|
73
|
+
console.print("[bold]🔍 Checking port availability...[/bold]")
|
|
74
|
+
|
|
75
|
+
if is_port_in_use(odoo_port):
|
|
76
|
+
suggested = find_available_port(odoo_port + 1)
|
|
77
|
+
console.print(f" [yellow]⚠[/yellow] Odoo port [cyan]{odoo_port}[/cyan] is already in use.")
|
|
78
|
+
console.print(f" [dim]Suggested port: [green]{suggested}[/green][/dim]")
|
|
79
|
+
use_suggested = questionary.confirm(
|
|
80
|
+
f"Use port {suggested} for Odoo instead of {odoo_port}?", default=True
|
|
81
|
+
).ask()
|
|
82
|
+
odoo_port = suggested if use_suggested else click.prompt("Enter Odoo port to use", type=int, default=suggested)
|
|
83
|
+
changed = True
|
|
84
|
+
else:
|
|
85
|
+
console.print(f" [green]✓[/green] Odoo port [cyan]{odoo_port}[/cyan] is available.")
|
|
86
|
+
|
|
87
|
+
if is_port_in_use(vsc_port):
|
|
88
|
+
suggested_vsc = find_available_port(vsc_port + 1)
|
|
89
|
+
console.print(f" [yellow]⚠[/yellow] VSCode port [cyan]{vsc_port}[/cyan] is already in use.")
|
|
90
|
+
console.print(f" [dim]Suggested port: [green]{suggested_vsc}[/green][/dim]")
|
|
91
|
+
use_suggested_vsc = questionary.confirm(
|
|
92
|
+
f"Use port {suggested_vsc} for VSCode instead of {vsc_port}?", default=True
|
|
93
|
+
).ask()
|
|
94
|
+
vsc_port = suggested_vsc if use_suggested_vsc else click.prompt("Enter VSCode port to use", type=int, default=suggested_vsc)
|
|
95
|
+
changed = True
|
|
96
|
+
else:
|
|
97
|
+
console.print(f" [green]✓[/green] VSCode port [cyan]{vsc_port}[/cyan] is available.")
|
|
98
|
+
|
|
99
|
+
return odoo_port, vsc_port, changed
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _update_ports_in_compose(project_dir: Path, new_odoo_port: int, new_vsc_port: int):
|
|
103
|
+
"""
|
|
104
|
+
Updates port mappings in docker-compose.yaml using plain text replacement
|
|
105
|
+
to preserve the original file format.
|
|
106
|
+
"""
|
|
107
|
+
compose_path = project_dir / "docker-compose.yaml"
|
|
108
|
+
if not compose_path.exists():
|
|
109
|
+
compose_path = project_dir / "docker-compose.yml"
|
|
110
|
+
if not compose_path.exists():
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
content = compose_path.read_text()
|
|
114
|
+
content = re.sub(r'"\d+:8069"', f'"{new_odoo_port}:8069"', content)
|
|
115
|
+
content = re.sub(r'"\d+:8888"', f'"{new_vsc_port}:8888"', content)
|
|
116
|
+
compose_path.write_text(content)
|
|
117
|
+
console.print(f" [green]✓[/green] docker-compose.yaml updated with new ports.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _configure_ssh(project_dir: Path, meta: dict) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
Guides the recipient through configuring their own SSH key for the environment.
|
|
123
|
+
Returns True if configured successfully, False if skipped or failed.
|
|
124
|
+
"""
|
|
125
|
+
console.print()
|
|
126
|
+
console.print("[bold]🔐 SSH configuration for private repositories:[/bold]")
|
|
127
|
+
|
|
128
|
+
original_key = meta.get("ssh_key_name")
|
|
129
|
+
if original_key:
|
|
130
|
+
console.print(f" [dim]The original environment used key: [yellow]{original_key}[/yellow][/dim]")
|
|
131
|
+
|
|
132
|
+
console.print()
|
|
133
|
+
available_keys = list_private_keys()
|
|
134
|
+
|
|
135
|
+
if not available_keys:
|
|
136
|
+
console.print(" [yellow]⚠[/yellow] No SSH keys found in ~/.ssh/")
|
|
137
|
+
console.print(" [dim]Generate one with: [cyan]ssh-keygen -t rsa -b 4096[/cyan][/dim]")
|
|
138
|
+
skip = questionary.confirm(
|
|
139
|
+
"Continue without SSH? (private repos will not work)", default=False
|
|
140
|
+
).ask()
|
|
141
|
+
return not skip
|
|
142
|
+
|
|
143
|
+
console.print(f" [dim]Found {len(available_keys)} SSH key(s) available.[/dim]")
|
|
144
|
+
|
|
145
|
+
selected_key = questionary.select(
|
|
146
|
+
"Select your SSH key for private repositories:",
|
|
147
|
+
choices=available_keys
|
|
148
|
+
).ask()
|
|
149
|
+
|
|
150
|
+
if not selected_key:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
dockerfile_path = project_dir / "Dockerfile"
|
|
155
|
+
console.print(f" [dim]Copying key [cyan]{selected_key}[/cyan] to build context...[/dim]")
|
|
156
|
+
copy_key_to_build_context(selected_key, project_dir)
|
|
157
|
+
|
|
158
|
+
if dockerfile_path.exists():
|
|
159
|
+
inject_ssh_into_dockerfile(dockerfile_path, selected_key)
|
|
160
|
+
console.print(f" [green]✓[/green] Dockerfile configured with key [cyan]{selected_key}[/cyan]")
|
|
161
|
+
else:
|
|
162
|
+
console.print(" [yellow]⚠[/yellow] Dockerfile not found.")
|
|
163
|
+
|
|
164
|
+
return True
|
|
165
|
+
except Exception as e:
|
|
166
|
+
console.print(f" [red]✗ Error configuring SSH:[/red] {e}")
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _get_db_container_name(project_dir: Path) -> str | None:
|
|
171
|
+
"""Reads docker-compose and returns the database container name."""
|
|
172
|
+
compose_data = read_docker_compose()
|
|
173
|
+
if compose_data:
|
|
174
|
+
try:
|
|
175
|
+
return compose_data["services"]["db"]["container_name"]
|
|
176
|
+
except (KeyError, TypeError):
|
|
177
|
+
pass
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _get_odoo_container_name(project_dir: Path) -> str | None:
|
|
182
|
+
"""Reads docker-compose and returns the web container name."""
|
|
183
|
+
compose_data = read_docker_compose()
|
|
184
|
+
if compose_data:
|
|
185
|
+
try:
|
|
186
|
+
return compose_data["services"]["web"]["container_name"]
|
|
187
|
+
except (KeyError, TypeError):
|
|
188
|
+
pass
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _wait_for_postgres(db_container: str, max_wait: int = 60) -> bool:
|
|
193
|
+
"""Waits until PostgreSQL is ready to accept connections."""
|
|
194
|
+
console.print(f" [dim]Waiting for PostgreSQL to be ready (max {max_wait}s)...[/dim]")
|
|
195
|
+
for i in range(max_wait):
|
|
196
|
+
result = subprocess.run(
|
|
197
|
+
["docker", "exec", db_container, "pg_isready", "-U", "root"],
|
|
198
|
+
capture_output=True
|
|
199
|
+
)
|
|
200
|
+
if result.returncode == 0:
|
|
201
|
+
console.print(f" [green]✓[/green] PostgreSQL is ready.")
|
|
202
|
+
return True
|
|
203
|
+
time.sleep(1)
|
|
204
|
+
if i % 10 == 9:
|
|
205
|
+
console.print(f" [dim] ...{i + 1}s[/dim]")
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _restore_database(db_container: str, dump_path: Path) -> str | None:
|
|
210
|
+
"""
|
|
211
|
+
Restores the PostgreSQL dump into the container.
|
|
212
|
+
Returns the restored database name, or None if it failed.
|
|
213
|
+
"""
|
|
214
|
+
stem = dump_path.stem
|
|
215
|
+
parts = stem.split("_")
|
|
216
|
+
db_name = "_".join(parts[1:-2]) if len(parts) >= 4 else (parts[1] if len(parts) > 1 else "odoo_restored")
|
|
217
|
+
|
|
218
|
+
console.print(f" [dim]Restoring database [cyan]{db_name}[/cyan]...[/dim]")
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
copy_result = subprocess.run(
|
|
222
|
+
["docker", "cp", str(dump_path), f"{db_container}:/tmp/rkd_restore.dump"],
|
|
223
|
+
capture_output=True, text=True
|
|
224
|
+
)
|
|
225
|
+
if copy_result.returncode != 0:
|
|
226
|
+
console.print(f" [red]✗ Error copying dump:[/red] {copy_result.stderr}")
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
subprocess.run(
|
|
230
|
+
["docker", "exec", db_container,
|
|
231
|
+
"psql", "-U", "root", "-d", "postgres", "-c",
|
|
232
|
+
f"DROP DATABASE IF EXISTS \"{db_name}\";"],
|
|
233
|
+
capture_output=True
|
|
234
|
+
)
|
|
235
|
+
create_result = subprocess.run(
|
|
236
|
+
["docker", "exec", db_container,
|
|
237
|
+
"psql", "-U", "root", "-d", "postgres", "-c",
|
|
238
|
+
f"CREATE DATABASE \"{db_name}\" OWNER root;"],
|
|
239
|
+
capture_output=True, text=True
|
|
240
|
+
)
|
|
241
|
+
if create_result.returncode != 0:
|
|
242
|
+
console.print(f" [red]✗ Error creating database:[/red] {create_result.stderr}")
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
restore_result = subprocess.run(
|
|
246
|
+
["docker", "exec", db_container,
|
|
247
|
+
"pg_restore", "-U", "root", "-d", db_name,
|
|
248
|
+
"--no-owner", "--role=root",
|
|
249
|
+
"/tmp/rkd_restore.dump"],
|
|
250
|
+
capture_output=True, text=True
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# pg_restore may return warnings (returncode 1) but still succeed
|
|
254
|
+
if restore_result.returncode not in (0, 1):
|
|
255
|
+
console.print(f" [red]✗ Restore error:[/red] {restore_result.stderr[:500]}")
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
console.print(f" [green]✓[/green] Database [cyan]{db_name}[/cyan] restored successfully.")
|
|
259
|
+
return db_name
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
console.print(f" [red]✗ Exception during restore:[/red] {e}")
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _restore_filestore(odoo_container: str, filestore_tar: Path, db_name: str) -> bool:
|
|
267
|
+
"""Restores the Odoo filestore into the web container."""
|
|
268
|
+
console.print(f" [dim]Restoring filestore for [cyan]{db_name}[/cyan]...[/dim]")
|
|
269
|
+
try:
|
|
270
|
+
copy_result = subprocess.run(
|
|
271
|
+
["docker", "cp", str(filestore_tar), f"{odoo_container}:/tmp/rkd_filestore.tar.gz"],
|
|
272
|
+
capture_output=True, text=True
|
|
273
|
+
)
|
|
274
|
+
if copy_result.returncode != 0:
|
|
275
|
+
console.print(f" [yellow]⚠[/yellow] Could not copy filestore: {copy_result.stderr}")
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
filestore_base = "/var/lib/odoo/.local/share/Odoo/filestore"
|
|
279
|
+
subprocess.run(
|
|
280
|
+
["docker", "exec", odoo_container, "mkdir", "-p", filestore_base],
|
|
281
|
+
capture_output=True
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
extract_result = subprocess.run(
|
|
285
|
+
["docker", "exec", odoo_container,
|
|
286
|
+
"tar", "-xzf", "/tmp/rkd_filestore.tar.gz", "-C", filestore_base],
|
|
287
|
+
capture_output=True, text=True
|
|
288
|
+
)
|
|
289
|
+
if extract_result.returncode != 0:
|
|
290
|
+
console.print(f" [yellow]⚠[/yellow] Error extracting filestore: {extract_result.stderr}")
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
console.print(f" [green]✓[/green] Filestore restored inside the container.")
|
|
294
|
+
return True
|
|
295
|
+
except Exception as e:
|
|
296
|
+
console.print(f" [yellow]⚠[/yellow] Exception while restoring filestore: {e}")
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _launch_environment(build: bool = False):
|
|
301
|
+
"""Runs docker compose up -d (with optional --build flag)."""
|
|
302
|
+
cmd = ["docker", "compose", "up", "-d"]
|
|
303
|
+
if build:
|
|
304
|
+
cmd.append("--build")
|
|
305
|
+
console.print()
|
|
306
|
+
console.print(f"[bold]🚀 Starting environment:[/bold] [dim]{' '.join(cmd)}[/dim]")
|
|
307
|
+
result = subprocess.run(cmd)
|
|
308
|
+
return result.returncode == 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _launch_db_only():
|
|
312
|
+
"""Starts only the db service to allow database restoration."""
|
|
313
|
+
console.print("[dim] Starting database service only...[/dim]")
|
|
314
|
+
result = subprocess.run(
|
|
315
|
+
["docker", "compose", "up", "-d", "db"],
|
|
316
|
+
capture_output=True, text=True
|
|
317
|
+
)
|
|
318
|
+
return result.returncode == 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ─────────────────────────────────────────────────────────────
|
|
322
|
+
# Main command
|
|
323
|
+
# ─────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
@click.command(name="unpack")
|
|
326
|
+
@click.option("--no-restore", is_flag=True, default=False,
|
|
327
|
+
help="Skip automatic database restoration.")
|
|
328
|
+
@click.option("--build", is_flag=True, default=False,
|
|
329
|
+
help="Rebuild the Docker image before starting (recommended on first run).")
|
|
330
|
+
def unpack_environment(no_restore, build):
|
|
331
|
+
"""
|
|
332
|
+
📥 Start a development environment shared by another developer.
|
|
333
|
+
|
|
334
|
+
Run this inside the unzipped environment directory.
|
|
335
|
+
Automatically detects shared environments, validates ports,
|
|
336
|
+
configures your own SSH keys, and restores the database.
|
|
337
|
+
|
|
338
|
+
\b
|
|
339
|
+
Examples:
|
|
340
|
+
rkd unpack → full setup (recommended)
|
|
341
|
+
rkd unpack --no-restore → skip DB restore
|
|
342
|
+
rkd unpack --build → rebuild Docker image
|
|
343
|
+
"""
|
|
344
|
+
console.print()
|
|
345
|
+
console.print(Panel(
|
|
346
|
+
"[bold cyan]📥 RKD Unpack — Start shared environment[/bold cyan]\n\n"
|
|
347
|
+
"[dim]This process will:[/dim]\n"
|
|
348
|
+
" [green]✓[/green] Detect and validate the shared environment\n"
|
|
349
|
+
" [green]✓[/green] Check port availability\n"
|
|
350
|
+
" [green]✓[/green] Configure your SSH key if using private repos\n"
|
|
351
|
+
" [green]✓[/green] Start the environment and restore the database",
|
|
352
|
+
border_style="cyan",
|
|
353
|
+
box=box.ROUNDED
|
|
354
|
+
))
|
|
355
|
+
console.print()
|
|
356
|
+
|
|
357
|
+
project_dir = Path.cwd()
|
|
358
|
+
|
|
359
|
+
# ── 1. Detect project and metadata ──
|
|
360
|
+
if not project_exists():
|
|
361
|
+
console.print("[red]✗[/red] No Rocketdoo project found in this directory.")
|
|
362
|
+
console.print("[dim]Make sure you are inside the unzipped environment directory.[/dim]")
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
meta = _load_shared_meta(project_dir)
|
|
366
|
+
|
|
367
|
+
if meta and meta.get("rkd_shared"):
|
|
368
|
+
console.print("[green]✓[/green] Shared environment detected ([dim]rkd-shared.json[/dim])")
|
|
369
|
+
console.print()
|
|
370
|
+
|
|
371
|
+
info_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))
|
|
372
|
+
info_table.add_column("", style="cyan bold", width=22)
|
|
373
|
+
info_table.add_column("", style="green")
|
|
374
|
+
info_table.add_row("📦 Project", meta.get("project_name", "unknown"))
|
|
375
|
+
info_table.add_row("🐳 Odoo", f"{meta.get('odoo_version', '?')} ({meta.get('odoo_edition', 'Community')})")
|
|
376
|
+
info_table.add_row("🗄️ PostgreSQL", str(meta.get("db_version", "?")))
|
|
377
|
+
info_table.add_row("🔐 Private repos", "Yes" if meta.get("uses_private_repos") else "No")
|
|
378
|
+
info_table.add_row("💾 DB Backup", "Included ✓" if meta.get("has_db_backup") else "Not included")
|
|
379
|
+
console.print(Panel(info_table, title="[bold]📋 Environment to start[/bold]",
|
|
380
|
+
border_style="dim", box=box.ROUNDED))
|
|
381
|
+
console.print()
|
|
382
|
+
else:
|
|
383
|
+
console.print("[yellow]⚠[/yellow] rkd-shared.json not found.")
|
|
384
|
+
console.print("[dim]This looks like a Rocketdoo project but was not packaged with [cyan]rkd pack[/cyan].[/dim]")
|
|
385
|
+
if not questionary.confirm("Continue anyway?", default=False).ask():
|
|
386
|
+
return
|
|
387
|
+
meta = {}
|
|
388
|
+
|
|
389
|
+
# ── 2. Check and adjust ports ──
|
|
390
|
+
console.print()
|
|
391
|
+
new_odoo_port, new_vsc_port, ports_changed = _check_ports(meta)
|
|
392
|
+
|
|
393
|
+
if ports_changed:
|
|
394
|
+
console.print()
|
|
395
|
+
console.print("[dim] Updating docker-compose.yaml with new ports...[/dim]")
|
|
396
|
+
_update_ports_in_compose(project_dir, new_odoo_port, new_vsc_port)
|
|
397
|
+
|
|
398
|
+
# ── 3. Configure SSH if the environment used private repos ──
|
|
399
|
+
uses_private_repos = meta.get("uses_private_repos", False)
|
|
400
|
+
|
|
401
|
+
if uses_private_repos:
|
|
402
|
+
console.print()
|
|
403
|
+
console.print(Panel(
|
|
404
|
+
"This environment was set up with [bold]private repositories[/bold].\n"
|
|
405
|
+
"You need to configure [bold]your own SSH key[/bold] for it to work correctly.",
|
|
406
|
+
border_style="yellow",
|
|
407
|
+
box=box.ROUNDED
|
|
408
|
+
))
|
|
409
|
+
wants_ssh = questionary.confirm(
|
|
410
|
+
"Do you use private repositories and want to configure your SSH key?", default=True
|
|
411
|
+
).ask()
|
|
412
|
+
|
|
413
|
+
if wants_ssh:
|
|
414
|
+
ssh_ok = _configure_ssh(project_dir, meta)
|
|
415
|
+
if not ssh_ok:
|
|
416
|
+
console.print("[yellow]⚠[/yellow] SSH not configured. Private repos may not work.")
|
|
417
|
+
|
|
418
|
+
elif not meta:
|
|
419
|
+
wants_ssh = questionary.confirm(
|
|
420
|
+
"Does this environment use private repositories? (requires SSH key)", default=False
|
|
421
|
+
).ask()
|
|
422
|
+
if wants_ssh:
|
|
423
|
+
_configure_ssh(project_dir, {})
|
|
424
|
+
|
|
425
|
+
# ── 4. Locate backup files ──
|
|
426
|
+
db_dump, filestore_tar = _find_backup_files(project_dir)
|
|
427
|
+
has_backup = db_dump is not None
|
|
428
|
+
|
|
429
|
+
# ── 5. Start the environment ──
|
|
430
|
+
console.print()
|
|
431
|
+
if has_backup and not no_restore:
|
|
432
|
+
console.print("[bold]💾 Database backup found.[/bold]")
|
|
433
|
+
console.print("[dim] Strategy: start DB → restore → start full environment[/dim]")
|
|
434
|
+
console.print()
|
|
435
|
+
|
|
436
|
+
db_up = _launch_db_only()
|
|
437
|
+
if not db_up:
|
|
438
|
+
console.print("[red]✗ Could not start the database service.[/red]")
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
db_container = _get_db_container_name(project_dir)
|
|
442
|
+
if db_container:
|
|
443
|
+
pg_ready = _wait_for_postgres(db_container)
|
|
444
|
+
if pg_ready:
|
|
445
|
+
console.print()
|
|
446
|
+
console.print("[bold]💾 Restoring database:[/bold]")
|
|
447
|
+
restored_db = _restore_database(db_container, db_dump)
|
|
448
|
+
|
|
449
|
+
if restored_db and filestore_tar:
|
|
450
|
+
console.print()
|
|
451
|
+
console.print("[bold]🗂️ Restoring filestore:[/bold]")
|
|
452
|
+
console.print("[dim] Starting web service to restore filestore...[/dim]")
|
|
453
|
+
subprocess.run(["docker", "compose", "up", "-d", "web"], capture_output=True)
|
|
454
|
+
time.sleep(5)
|
|
455
|
+
|
|
456
|
+
odoo_container = _get_odoo_container_name(project_dir)
|
|
457
|
+
if odoo_container:
|
|
458
|
+
_restore_filestore(odoo_container, filestore_tar, restored_db)
|
|
459
|
+
|
|
460
|
+
_launch_environment(build=build)
|
|
461
|
+
else:
|
|
462
|
+
_launch_environment(build=build)
|
|
463
|
+
|
|
464
|
+
# ── 6. Final summary ──
|
|
465
|
+
console.print()
|
|
466
|
+
console.print(Panel(
|
|
467
|
+
f"[bold green]✅ Environment is ready[/bold green]\n\n"
|
|
468
|
+
f"[bold]🌐 Odoo:[/bold] [cyan underline]http://localhost:{new_odoo_port}[/cyan underline]\n"
|
|
469
|
+
f"[bold]🐛 Debug:[/bold] port [cyan]{new_vsc_port}[/cyan]\n\n"
|
|
470
|
+
f"[dim]Useful commands:\n"
|
|
471
|
+
f" [cyan]rkd status[/cyan] → check container status\n"
|
|
472
|
+
f" [cyan]rkd logs[/cyan] → view logs\n"
|
|
473
|
+
f" [cyan]rkd info[/cyan] → project information[/dim]",
|
|
474
|
+
border_style="green",
|
|
475
|
+
box=box.ROUNDED
|
|
476
|
+
))
|
|
477
|
+
console.print()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rocketdoo
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Framework for creating Odoo development environments with Docker and custom templates.
|
|
5
5
|
Author-email: Horacio Montaño <horaciomontano@hdmsoft.com.ar>
|
|
6
6
|
License: GNU LESSER GENERAL PUBLIC LICENSE
|
|
@@ -62,7 +62,7 @@ License: GNU LESSER GENERAL PUBLIC LICENSE
|
|
|
62
62
|
<div RKD as ROCKETDOO V2=""></div>
|
|
63
63
|
|
|
64
64
|
Licencia: LGPL-3.0+
|
|
65
|
-
Versión: "2.
|
|
65
|
+
Versión: "2.3.0"
|
|
66
66
|
Autor: Horacio Montaño, Elias Braceras
|
|
67
67
|
Fecha: 16/10/2024
|
|
68
68
|
Descripción: Framework to development Odoo
|
|
@@ -95,7 +95,7 @@ Odoo Development Framework
|
|
|
95
95
|
- "Horacio Montaño" and "Elias Braceras"
|
|
96
96
|
|
|
97
97
|
## Version:
|
|
98
|
-
- "2.
|
|
98
|
+
- "2.3.0"
|
|
99
99
|
|
|
100
100
|
----------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
101
101
|
|
|
@@ -10,9 +10,11 @@ rocketdoo/deploy_cli.py
|
|
|
10
10
|
rocketdoo/docker_cli.py
|
|
11
11
|
rocketdoo/init_project.py
|
|
12
12
|
rocketdoo/main.py
|
|
13
|
+
rocketdoo/pack_environment.py
|
|
13
14
|
rocketdoo/project_info.py
|
|
14
15
|
rocketdoo/rocketdoo.py
|
|
15
16
|
rocketdoo/scaffold.py
|
|
17
|
+
rocketdoo/unpack_environment.py
|
|
16
18
|
rocketdoo/welcome.py
|
|
17
19
|
rocketdoo.egg-info/PKG-INFO
|
|
18
20
|
rocketdoo.egg-info/SOURCES.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|