rocketdoo 3.2.0__tar.gz → 3.2.1__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-3.2.0 → rocketdoo-3.2.1}/LICENSE +2 -2
- {rocketdoo-3.2.0/rocketdoo.egg-info → rocketdoo-3.2.1}/PKG-INFO +5 -5
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/README.md +2 -2
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/pyproject.toml +1 -1
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/deploy/vps.py +30 -16
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/instance/deployer_docker.py +6 -6
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/instance/deployer_native.py +10 -8
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/instance/ssh_utils.py +19 -27
- rocketdoo-3.2.1/rocketdoo/core/odoo_db.py +118 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/ssh_manager.py +13 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/__init__.py +2 -0
- rocketdoo-3.2.1/rocketdoo/gui/api/odoo.py +80 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/server.py +70 -39
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/static/index.html +118 -10
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui_cli.py +11 -4
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/pack_environment.py +14 -40
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/Dockerfile.jinja +22 -5
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/config/odoo.conf +1 -1
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/config/odoo.conf.jinja +1 -1
- {rocketdoo-3.2.0 → rocketdoo-3.2.1/rocketdoo.egg-info}/PKG-INFO +5 -5
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo.egg-info/SOURCES.txt +4 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_gui_api.py +204 -0
- rocketdoo-3.2.1/tests/test_odoo_db.py +112 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_pack_environment.py +49 -0
- rocketdoo-3.2.1/tests/test_secrets_hardening.py +618 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_ssh_manager.py +34 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_templates.py +137 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/MANIFEST.in +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/__init__.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/cli.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/__init__.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/compose.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/deploy/__init__.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/deploy/base.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/deploy/config_manager.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/deploy/module_packager.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/deploy/odoo_sh.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/edition_setup.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/generator.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/gitignore_manager.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/gitman_config.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/instance/__init__.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/instance/config_manager.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/instance/secrets_store.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/models/__init__.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/models/deploy.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/models/instance.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/models/profiles.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/models/project.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/module_scanner.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/port_validation.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/core/utils.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/deploy_cli.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/docker_cli.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/__init__.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/deploy_ops.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/docker_ops.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/gitman.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/instances.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/mail.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/modules.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/pack_ops.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/project.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/setup.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/traefik_ops.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/gui/api/workspace.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/init_project.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/instance_cli.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/mail_cli.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/project_info.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/scaffold.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/.devcontainer/devcontainer.json +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/.devcontainer/docker-compose.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/.gitignore.jinja +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/.vscode/launch.json +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/.vscode/launch.json.jinja +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/.vscode/settings.json +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/addons/.gitkeep +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/deploy/.deployignore +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/deploy/deploy.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/deploy/deploy.yaml.jinja +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/docker-compose.yaml.jinja +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/install_dependencies.sh +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/instance/Dockerfile.jinja +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/instance/docker-compose.jinja +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/instance/odoo.conf.jinja +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/odoo_pg_pass +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/README.md +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo15-ce.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo15-ee.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo16-ce.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo16-ee.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo17-ce.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo17-ee.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo18-ce.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo18-ee.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo19-ce.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/templates/profiles/odoo19-ee.yaml +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/traefik_cli.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/unpack_environment.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo/welcome.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo.egg-info/dependency_links.txt +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo.egg-info/entry_points.txt +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo.egg-info/requires.txt +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/rocketdoo.egg-info/top_level.txt +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/setup.cfg +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_compose.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_deploy_config_manager.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_e2e_scaffold.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_edition_setup.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_gitignore_manager.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_imports.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_models.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_module_scanner.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_port_validation.py +0 -0
- {rocketdoo-3.2.0 → rocketdoo-3.2.1}/tests/test_profiles.py +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 V3=""></div>
|
|
58
58
|
|
|
59
59
|
Licencia: LGPL-3.0+
|
|
60
|
-
Versión: "3.2.
|
|
60
|
+
Versión: "3.2.1"
|
|
61
61
|
Autor: Horacio Montaño
|
|
62
|
-
Fecha:
|
|
62
|
+
Fecha: 13/09/2026
|
|
63
63
|
Descripción: Framework to development Odoo
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rocketdoo
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.1
|
|
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,9 +62,9 @@ License: GNU LESSER GENERAL PUBLIC LICENSE
|
|
|
62
62
|
<div RKD as ROCKETDOO V3=""></div>
|
|
63
63
|
|
|
64
64
|
Licencia: LGPL-3.0+
|
|
65
|
-
Versión: "3.2.
|
|
65
|
+
Versión: "3.2.1"
|
|
66
66
|
Autor: Horacio Montaño
|
|
67
|
-
Fecha:
|
|
67
|
+
Fecha: 13/09/2026
|
|
68
68
|
Descripción: Framework to development Odoo
|
|
69
69
|
Requires-Python: >=3.10
|
|
70
70
|
Description-Content-Type: text/markdown
|
|
@@ -97,7 +97,7 @@ Odoo Development Framework
|
|
|
97
97
|
- "Horacio Montaño"
|
|
98
98
|
|
|
99
99
|
## Version:
|
|
100
|
-
- "3.2.
|
|
100
|
+
- "3.2.1"
|
|
101
101
|
|
|
102
102
|
----------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
103
103
|
|
|
@@ -517,7 +517,7 @@ rkd instance status # Show configured environments
|
|
|
517
517
|
- Stage: 2 workers, `log_level = info`, 1.5 GB memory limit
|
|
518
518
|
- Production: 4 workers, `log_level = warn`, 2.5 GB memory limit, `proxy_mode = True`, `list_db = False`
|
|
519
519
|
|
|
520
|
-
> **Note:** Docker deployment requires a running SSH agent with the relevant keys loaded (`ssh-add -l`). Password authentication requires `sshpass` installed on your local machine.
|
|
520
|
+
> **Note:** Docker deployment requires a running SSH agent with the relevant keys loaded (`ssh-add -l`). Password authentication requires `sshpass` installed on your local machine; the password itself travels through the `SSHPASS` environment variable (`sshpass -e`), never as a command-line argument visible to other processes on the same host.
|
|
521
521
|
|
|
522
522
|
------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
523
523
|
|
|
@@ -15,7 +15,7 @@ Odoo Development Framework
|
|
|
15
15
|
- "Horacio Montaño"
|
|
16
16
|
|
|
17
17
|
## Version:
|
|
18
|
-
- "3.2.
|
|
18
|
+
- "3.2.1"
|
|
19
19
|
|
|
20
20
|
----------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
21
21
|
|
|
@@ -435,7 +435,7 @@ rkd instance status # Show configured environments
|
|
|
435
435
|
- Stage: 2 workers, `log_level = info`, 1.5 GB memory limit
|
|
436
436
|
- Production: 4 workers, `log_level = warn`, 2.5 GB memory limit, `proxy_mode = True`, `list_db = False`
|
|
437
437
|
|
|
438
|
-
> **Note:** Docker deployment requires a running SSH agent with the relevant keys loaded (`ssh-add -l`). Password authentication requires `sshpass` installed on your local machine.
|
|
438
|
+
> **Note:** Docker deployment requires a running SSH agent with the relevant keys loaded (`ssh-add -l`). Password authentication requires `sshpass` installed on your local machine; the password itself travels through the `SSHPASS` environment variable (`sshpass -e`), never as a command-line argument visible to other processes on the same host.
|
|
439
439
|
|
|
440
440
|
------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
441
441
|
|
|
@@ -13,7 +13,7 @@ from typing import Dict, List
|
|
|
13
13
|
from rich.console import Console
|
|
14
14
|
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
|
15
15
|
|
|
16
|
-
from rocketdoo.core.ssh_manager import check_sshpass, env_ref_name, resolve_env_ref
|
|
16
|
+
from rocketdoo.core.ssh_manager import check_sshpass, env_ref_name, resolve_env_ref, sshpass_wrap
|
|
17
17
|
|
|
18
18
|
from .base import BaseDeployer, DeploymentResult
|
|
19
19
|
from .module_packager import ModulePackager
|
|
@@ -110,6 +110,11 @@ class VPSDeployer(BaseDeployer):
|
|
|
110
110
|
|
|
111
111
|
console.print(f"[green]✔ Password stored securely at {secret_file}[/green]")
|
|
112
112
|
|
|
113
|
+
@property
|
|
114
|
+
def _sshpass_password(self) -> str | None:
|
|
115
|
+
"""The password to feed sshpass, or None when authenticating with a key."""
|
|
116
|
+
return self.password if self.auth_method == "password" else None
|
|
117
|
+
|
|
113
118
|
def validate_config(self) -> List[str]:
|
|
114
119
|
"""
|
|
115
120
|
Validate VPS configuration
|
|
@@ -388,11 +393,23 @@ class VPSDeployer(BaseDeployer):
|
|
|
388
393
|
Returns:
|
|
389
394
|
CompletedProcess with result
|
|
390
395
|
"""
|
|
391
|
-
# Handle sudo with password authentication
|
|
396
|
+
# Handle sudo with password authentication. The password never touches
|
|
397
|
+
# the command string: it travels through the stdin of subprocess.run,
|
|
398
|
+
# which ssh forwards over the encrypted channel to the remote sudo -S.
|
|
399
|
+
# This keeps the password out of `ps aux` on both ends and, unlike the
|
|
400
|
+
# old `echo '{password}' | sudo -S` interpolation, means the password's
|
|
401
|
+
# content can never alter the structure of the remote command.
|
|
402
|
+
# The password goes to sudo's stdin, so a remote command that reads
|
|
403
|
+
# stdin itself cannot be run with use_sudo=True: it would consume the
|
|
404
|
+
# password line. Today none of the callers do (the only two are
|
|
405
|
+
# `mkdir` and `systemctl restart`).
|
|
406
|
+
sudo_input = None
|
|
392
407
|
if use_sudo:
|
|
393
408
|
if self.auth_method == "password" and self.password:
|
|
394
|
-
|
|
395
|
-
|
|
409
|
+
command = f"sudo -S -p '' {command}"
|
|
410
|
+
# Only the first line reaches sudo; a multi-line password would
|
|
411
|
+
# leave the rest in the remote command's stdin.
|
|
412
|
+
sudo_input = self.password.splitlines()[0] + "\n" if self.password else None
|
|
396
413
|
else:
|
|
397
414
|
# Try passwordless sudo or rely on SSH key having sudo access
|
|
398
415
|
command = f"sudo {command}"
|
|
@@ -406,9 +423,6 @@ class VPSDeployer(BaseDeployer):
|
|
|
406
423
|
if self.auth_method == "ssh_key" and self.ssh_key:
|
|
407
424
|
key_path = os.path.expanduser(self.ssh_key)
|
|
408
425
|
ssh_cmd.extend(["-i", key_path])
|
|
409
|
-
elif self.auth_method == "password" and self.password:
|
|
410
|
-
# Use sshpass for password authentication
|
|
411
|
-
ssh_cmd = ["sshpass", "-p", self.password] + ssh_cmd
|
|
412
426
|
|
|
413
427
|
# Add user@host
|
|
414
428
|
ssh_cmd.append(f"{self.user}@{self.host}")
|
|
@@ -416,9 +430,11 @@ class VPSDeployer(BaseDeployer):
|
|
|
416
430
|
# Add command
|
|
417
431
|
ssh_cmd.append(command)
|
|
418
432
|
|
|
433
|
+
ssh_cmd, env = sshpass_wrap(self._sshpass_password, ssh_cmd)
|
|
434
|
+
|
|
419
435
|
# Execute
|
|
420
436
|
try:
|
|
421
|
-
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=timeout)
|
|
437
|
+
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=timeout, env=env, input=sudo_input)
|
|
422
438
|
return result
|
|
423
439
|
except FileNotFoundError as e:
|
|
424
440
|
if "sshpass" in str(e):
|
|
@@ -447,9 +463,6 @@ class VPSDeployer(BaseDeployer):
|
|
|
447
463
|
if self.auth_method == "ssh_key" and self.ssh_key:
|
|
448
464
|
key_path = os.path.expanduser(self.ssh_key)
|
|
449
465
|
ssh_opts += f" -i {key_path}"
|
|
450
|
-
elif self.auth_method == "password" and self.password:
|
|
451
|
-
# Use sshpass for rsync with password
|
|
452
|
-
rsync_cmd = ["sshpass", "-p", self.password] + rsync_cmd
|
|
453
466
|
|
|
454
467
|
rsync_cmd.extend(["-e", f"ssh {ssh_opts}"])
|
|
455
468
|
|
|
@@ -457,8 +470,10 @@ class VPSDeployer(BaseDeployer):
|
|
|
457
470
|
rsync_cmd.append(f"{local_path}/")
|
|
458
471
|
rsync_cmd.append(f"{self.user}@{self.host}:{remote_path}/")
|
|
459
472
|
|
|
473
|
+
rsync_cmd, env = sshpass_wrap(self._sshpass_password, rsync_cmd)
|
|
474
|
+
|
|
460
475
|
# Execute rsync
|
|
461
|
-
result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=600)
|
|
476
|
+
result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=600, env=env)
|
|
462
477
|
|
|
463
478
|
if result.returncode != 0:
|
|
464
479
|
self.log(f"rsync failed: {result.stderr}", "error")
|
|
@@ -500,16 +515,15 @@ class VPSDeployer(BaseDeployer):
|
|
|
500
515
|
if self.auth_method == "ssh_key" and self.ssh_key:
|
|
501
516
|
key_path = os.path.expanduser(self.ssh_key)
|
|
502
517
|
scp_cmd.extend(["-i", key_path])
|
|
503
|
-
elif self.auth_method == "password" and self.password:
|
|
504
|
-
# Use sshpass for password authentication
|
|
505
|
-
scp_cmd = ["sshpass", "-p", self.password] + scp_cmd
|
|
506
518
|
|
|
507
519
|
# Add source and destination
|
|
508
520
|
scp_cmd.append(str(local_path))
|
|
509
521
|
scp_cmd.append(f"{self.user}@{self.host}:{remote_path}")
|
|
510
522
|
|
|
523
|
+
scp_cmd, env = sshpass_wrap(self._sshpass_password, scp_cmd)
|
|
524
|
+
|
|
511
525
|
# Execute
|
|
512
|
-
result = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=300)
|
|
526
|
+
result = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=300, env=env)
|
|
513
527
|
|
|
514
528
|
return result.returncode == 0
|
|
515
529
|
|
|
@@ -140,7 +140,7 @@ class DockerInstanceDeployer:
|
|
|
140
140
|
def _read_remote_pg_pass(self) -> str | None:
|
|
141
141
|
"""Return odoo_pg_pass from a previous deploy, or None if unavailable."""
|
|
142
142
|
remote_file = f"{self.remote_path}/{self.env}/odoo_pg_pass"
|
|
143
|
-
cmd = build_ssh_cmd(
|
|
143
|
+
cmd, env = build_ssh_cmd(
|
|
144
144
|
self.auth,
|
|
145
145
|
self.port,
|
|
146
146
|
self.user,
|
|
@@ -148,7 +148,7 @@ class DockerInstanceDeployer:
|
|
|
148
148
|
f"cat {remote_file} 2>/dev/null",
|
|
149
149
|
)
|
|
150
150
|
try:
|
|
151
|
-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
151
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=env)
|
|
152
152
|
except (subprocess.SubprocessError, OSError):
|
|
153
153
|
return None
|
|
154
154
|
return result.stdout.strip() or None
|
|
@@ -228,12 +228,12 @@ class DockerInstanceDeployer:
|
|
|
228
228
|
# ─── SSH / transfer helpers ───────────────────────────────────────────────
|
|
229
229
|
|
|
230
230
|
def _ssh(self, command: str, agent_forward: bool = False, timeout: int = 600) -> bool:
|
|
231
|
-
cmd = build_ssh_cmd(self.auth, self.port, self.user, self.host, command, agent_forward=agent_forward)
|
|
232
|
-
return subprocess.run(cmd, timeout=timeout).returncode == 0
|
|
231
|
+
cmd, env = build_ssh_cmd(self.auth, self.port, self.user, self.host, command, agent_forward=agent_forward)
|
|
232
|
+
return subprocess.run(cmd, timeout=timeout, env=env).returncode == 0
|
|
233
233
|
|
|
234
234
|
def _rsync(self, local: str, remote: str, extra_opts: list[str] | None = None) -> bool:
|
|
235
|
-
cmd = build_rsync_cmd(self.auth, self.port, self.user, self.host, local, remote, extra_opts)
|
|
236
|
-
return subprocess.run(cmd).returncode == 0
|
|
235
|
+
cmd, env = build_rsync_cmd(self.auth, self.port, self.user, self.host, local, remote, extra_opts)
|
|
236
|
+
return subprocess.run(cmd, env=env).returncode == 0
|
|
237
237
|
|
|
238
238
|
# ─── deployment steps ────────────────────────────────────────────────────
|
|
239
239
|
|
|
@@ -160,9 +160,11 @@ class NativeInstanceDeployer:
|
|
|
160
160
|
"list_db = False\n"
|
|
161
161
|
)
|
|
162
162
|
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
|
|
163
|
+
# The conf body (including admin_passwd) travels through stdin rather
|
|
164
|
+
# than being embedded in the command string: it never touches argv,
|
|
165
|
+
# so it cannot appear in `ps aux` and cannot be affected by shell
|
|
166
|
+
# quoting rules regardless of what a value contains.
|
|
167
|
+
return self._ssh(f"sudo tee {self.odoo_conf_path} > /dev/null", input=conf)
|
|
166
168
|
|
|
167
169
|
def _sync_addons(self) -> bool:
|
|
168
170
|
addons_dir = self.project_path / "addons"
|
|
@@ -171,8 +173,8 @@ class NativeInstanceDeployer:
|
|
|
171
173
|
return True
|
|
172
174
|
|
|
173
175
|
self._ssh(f"sudo mkdir -p {self.remote_addons} && sudo chown {self.user} {self.remote_addons}")
|
|
174
|
-
cmd = build_rsync_cmd(self.auth, self.port, self.user, self.host, f"{addons_dir}/", f"{self.remote_addons}/")
|
|
175
|
-
result = subprocess.run(cmd)
|
|
176
|
+
cmd, env = build_rsync_cmd(self.auth, self.port, self.user, self.host, f"{addons_dir}/", f"{self.remote_addons}/")
|
|
177
|
+
result = subprocess.run(cmd, env=env)
|
|
176
178
|
return result.returncode == 0
|
|
177
179
|
|
|
178
180
|
def _enable_service(self) -> bool:
|
|
@@ -180,9 +182,9 @@ class NativeInstanceDeployer:
|
|
|
180
182
|
|
|
181
183
|
# ─── helpers ─────────────────────────────────────────────────────────────
|
|
182
184
|
|
|
183
|
-
def _ssh(self, command: str, timeout: int = 300) -> bool:
|
|
184
|
-
cmd = build_ssh_cmd(self.auth, self.port, self.user, self.host, command)
|
|
185
|
-
return subprocess.run(cmd, timeout=timeout).returncode == 0
|
|
185
|
+
def _ssh(self, command: str, timeout: int = 300, input: str | None = None) -> bool:
|
|
186
|
+
cmd, env = build_ssh_cmd(self.auth, self.port, self.user, self.host, command)
|
|
187
|
+
return subprocess.run(cmd, timeout=timeout, env=env, input=input, text=True).returncode == 0
|
|
186
188
|
|
|
187
189
|
def _show_plan(self):
|
|
188
190
|
console.print("\n[bold]DRY-RUN — Steps that would be executed:[/bold]")
|
|
@@ -4,7 +4,7 @@ Shared by DockerInstanceDeployer and NativeInstanceDeployer.
|
|
|
4
4
|
|
|
5
5
|
Mirrors the auth pattern from core/deploy/vps.py:
|
|
6
6
|
- ssh_key → -i key_path
|
|
7
|
-
- password → sshpass -
|
|
7
|
+
- password → sshpass -e (password read from the SSHPASS env var, requires sshpass installed)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import os
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
|
|
15
15
|
from rich.console import Console
|
|
16
16
|
|
|
17
|
-
from rocketdoo.core.ssh_manager import check_sshpass, env_ref_name, resolve_env_ref
|
|
17
|
+
from rocketdoo.core.ssh_manager import check_sshpass, env_ref_name, resolve_env_ref, sshpass_wrap
|
|
18
18
|
|
|
19
19
|
console = Console()
|
|
20
20
|
|
|
@@ -75,24 +75,8 @@ def resolve_auth(vps_cfg: dict, env: str, project_path: Path) -> dict:
|
|
|
75
75
|
return {"method": "password", "key_path": None, "password": password}
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
def ssh_prefix(auth: dict) -> list[str]:
|
|
79
|
-
"""
|
|
80
|
-
Return the command prefix for SSH/rsync based on auth method.
|
|
81
|
-
|
|
82
|
-
ssh_key → [] (key added via ssh_opts)
|
|
83
|
-
password → ['sshpass', '-p', '<pass>']
|
|
84
|
-
"""
|
|
85
|
-
if auth["method"] == "password":
|
|
86
|
-
return ["sshpass", "-p", auth["password"]]
|
|
87
|
-
return []
|
|
88
|
-
|
|
89
|
-
|
|
90
78
|
def ssh_opts(auth: dict, port: int, agent_forward: bool = False) -> list[str]:
|
|
91
|
-
"""
|
|
92
|
-
Return SSH -o/-i/-A options (without the leading 'ssh').
|
|
93
|
-
|
|
94
|
-
Combine with ssh_prefix() for password auth.
|
|
95
|
-
"""
|
|
79
|
+
"""Return SSH -o/-i/-A options (without the leading 'ssh')."""
|
|
96
80
|
opts = [
|
|
97
81
|
"-p",
|
|
98
82
|
str(port),
|
|
@@ -108,24 +92,28 @@ def ssh_opts(auth: dict, port: int, agent_forward: bool = False) -> list[str]:
|
|
|
108
92
|
return opts
|
|
109
93
|
|
|
110
94
|
|
|
111
|
-
def build_ssh_cmd(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
95
|
+
def build_ssh_cmd(
|
|
96
|
+
auth: dict, port: int, user: str, host: str, command: str, agent_forward: bool = False
|
|
97
|
+
) -> tuple[list[str], dict | None]:
|
|
98
|
+
"""Full (cmd, env) pair ready for subprocess.run()."""
|
|
99
|
+
argv = [
|
|
115
100
|
"ssh",
|
|
116
101
|
*ssh_opts(auth, port, agent_forward),
|
|
117
102
|
f"{user}@{host}",
|
|
118
103
|
command,
|
|
119
104
|
]
|
|
105
|
+
# Keyed on the method, as ssh_prefix() was: a dict built by hand without
|
|
106
|
+
# a "password" key should not silently fall through to key auth.
|
|
107
|
+
password = auth["password"] if auth["method"] == "password" else None
|
|
108
|
+
return sshpass_wrap(password, argv)
|
|
120
109
|
|
|
121
110
|
|
|
122
111
|
def build_rsync_cmd(
|
|
123
112
|
auth: dict, port: int, user: str, host: str, local: str, remote: str, extra_opts: list[str] | None = None
|
|
124
|
-
) -> list[str]:
|
|
125
|
-
"""rsync
|
|
113
|
+
) -> tuple[list[str], dict | None]:
|
|
114
|
+
"""rsync (cmd, env) pair ready for subprocess.run()."""
|
|
126
115
|
ssh_e = "ssh " + " ".join(ssh_opts(auth, port))
|
|
127
|
-
|
|
128
|
-
*ssh_prefix(auth),
|
|
116
|
+
argv = [
|
|
129
117
|
"rsync",
|
|
130
118
|
"-avz",
|
|
131
119
|
"--delete",
|
|
@@ -135,3 +123,7 @@ def build_rsync_cmd(
|
|
|
135
123
|
local,
|
|
136
124
|
f"{user}@{host}:{remote}",
|
|
137
125
|
]
|
|
126
|
+
# Keyed on the method, as ssh_prefix() was: a dict built by hand without
|
|
127
|
+
# a "password" key should not silently fall through to key auth.
|
|
128
|
+
password = auth["password"] if auth["method"] == "password" else None
|
|
129
|
+
return sshpass_wrap(password, argv)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Read-only access to the project's PostgreSQL container.
|
|
2
|
+
|
|
3
|
+
The `pg_database` query and the container lookup used to live only in
|
|
4
|
+
`pack_environment`; the GUI module-update feature needs the same data, so
|
|
5
|
+
this consolidates them into a single place (like `core/compose.py` did for
|
|
6
|
+
`docker compose` in #137) instead of growing a third copy.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
from rocketdoo.project_info import read_docker_compose
|
|
12
|
+
|
|
13
|
+
PSQL_USER = "root" # POSTGRES_USER set by the docker-compose template
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def db_container(compose_data: dict | None = None) -> str | None:
|
|
17
|
+
"""Return the `db` service's container name, or None if it can't be read."""
|
|
18
|
+
if compose_data is None:
|
|
19
|
+
compose_data = read_docker_compose()
|
|
20
|
+
try:
|
|
21
|
+
return compose_data["services"]["db"]["container_name"]
|
|
22
|
+
except (KeyError, TypeError):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def databases_result(compose_data: dict | None = None) -> tuple[list[str], str]:
|
|
27
|
+
"""List of user databases plus the reason it came back empty, if any.
|
|
28
|
+
|
|
29
|
+
Excludes `postgres` and the templates, matching what `rkd pack` already
|
|
30
|
+
queried. Any failure to reach the container yields an empty list.
|
|
31
|
+
"""
|
|
32
|
+
container = db_container(compose_data)
|
|
33
|
+
if not container:
|
|
34
|
+
return [], "no database container configured"
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
[
|
|
39
|
+
"docker",
|
|
40
|
+
"exec",
|
|
41
|
+
container,
|
|
42
|
+
"psql",
|
|
43
|
+
"-U",
|
|
44
|
+
PSQL_USER,
|
|
45
|
+
"-d",
|
|
46
|
+
"postgres",
|
|
47
|
+
"-t",
|
|
48
|
+
"-c",
|
|
49
|
+
"SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres';",
|
|
50
|
+
],
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
timeout=15,
|
|
54
|
+
)
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return [], "docker not found"
|
|
57
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
58
|
+
return [], str(exc)
|
|
59
|
+
|
|
60
|
+
if result.returncode != 0:
|
|
61
|
+
return [], result.stderr.strip()
|
|
62
|
+
|
|
63
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()], ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def list_databases(compose_data: dict | None = None) -> list[str]:
|
|
67
|
+
"""List of user databases. Use `databases_result` when the reason matters."""
|
|
68
|
+
return databases_result(compose_data)[0]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def module_states(db: str) -> tuple[dict[str, str], str]:
|
|
72
|
+
"""Map `{module_name: state}` read from `ir_module_module` in `db`.
|
|
73
|
+
|
|
74
|
+
`db` is not validated here: that is the trust boundary's job (the GUI
|
|
75
|
+
layer), and keeping it in one place avoids the validation being
|
|
76
|
+
duplicated with different criteria. If `db` isn't an Odoo database,
|
|
77
|
+
psql fails and ({}, stderr) is returned.
|
|
78
|
+
"""
|
|
79
|
+
container = db_container()
|
|
80
|
+
if not container:
|
|
81
|
+
return {}, "no database container configured"
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = subprocess.run(
|
|
85
|
+
[
|
|
86
|
+
"docker",
|
|
87
|
+
"exec",
|
|
88
|
+
container,
|
|
89
|
+
"psql",
|
|
90
|
+
"-U",
|
|
91
|
+
PSQL_USER,
|
|
92
|
+
"-d",
|
|
93
|
+
db,
|
|
94
|
+
"-t",
|
|
95
|
+
"-A",
|
|
96
|
+
"-F",
|
|
97
|
+
"\t",
|
|
98
|
+
"-c",
|
|
99
|
+
"SELECT name, state FROM ir_module_module;",
|
|
100
|
+
],
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
timeout=15,
|
|
104
|
+
)
|
|
105
|
+
except FileNotFoundError:
|
|
106
|
+
return {}, "docker not found"
|
|
107
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
108
|
+
return {}, str(exc)
|
|
109
|
+
|
|
110
|
+
if result.returncode != 0:
|
|
111
|
+
return {}, result.stderr.strip()
|
|
112
|
+
|
|
113
|
+
states = {}
|
|
114
|
+
for line in result.stdout.splitlines():
|
|
115
|
+
name, _, state = line.strip().partition("\t")
|
|
116
|
+
if name and state:
|
|
117
|
+
states[name] = state
|
|
118
|
+
return states, ""
|
|
@@ -58,3 +58,16 @@ def resolve_env_ref(value: str) -> str:
|
|
|
58
58
|
if name is None:
|
|
59
59
|
return value
|
|
60
60
|
return os.environ.get(name, "")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sshpass_wrap(password: str | None, argv: list[str]) -> tuple[list[str], dict | None]:
|
|
64
|
+
"""Return (argv, env) for a command that may need password authentication.
|
|
65
|
+
|
|
66
|
+
The password travels in the SSHPASS environment variable of the child
|
|
67
|
+
process, never as an argv element readable through `ps`. Without a
|
|
68
|
+
password the command is returned untouched and env is None, which makes
|
|
69
|
+
subprocess inherit the parent environment exactly as before.
|
|
70
|
+
"""
|
|
71
|
+
if not password:
|
|
72
|
+
return argv, None
|
|
73
|
+
return ["sshpass", "-e", *argv], {**os.environ, "SSHPASS": password}
|
|
@@ -6,6 +6,7 @@ from .gitman import router as gitman_router
|
|
|
6
6
|
from .instances import router as instances_router
|
|
7
7
|
from .mail import router as mail_router
|
|
8
8
|
from .modules import router as modules_router
|
|
9
|
+
from .odoo import router as odoo_router
|
|
9
10
|
from .pack_ops import router as pack_router
|
|
10
11
|
from .project import router as project_router
|
|
11
12
|
from .setup import router as setup_router
|
|
@@ -17,6 +18,7 @@ router.include_router(workspace_router, prefix="/workspace", tags=["workspace"])
|
|
|
17
18
|
router.include_router(project_router, prefix="/project", tags=["project"])
|
|
18
19
|
router.include_router(docker_router, prefix="/docker", tags=["docker"])
|
|
19
20
|
router.include_router(modules_router, prefix="/modules", tags=["modules"])
|
|
21
|
+
router.include_router(odoo_router, prefix="/odoo", tags=["odoo"])
|
|
20
22
|
router.include_router(gitman_router, prefix="/gitman", tags=["gitman"])
|
|
21
23
|
router.include_router(mail_router, prefix="/mail", tags=["mail"])
|
|
22
24
|
router.include_router(traefik_router, prefix="/traefik", tags=["traefik"])
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Odoo database and module-state endpoints for the GUI.
|
|
2
|
+
|
|
3
|
+
Backs the Modules view's per-database Update button: lists the databases in
|
|
4
|
+
the project's PostgreSQL container, the install state of each module in one
|
|
5
|
+
of them, and builds the `docker compose exec` command that runs the update.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter
|
|
11
|
+
|
|
12
|
+
from rocketdoo.core.module_scanner import ModuleScanner
|
|
13
|
+
from rocketdoo.core.odoo_db import databases_result, list_databases, module_states
|
|
14
|
+
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
ODOO_SERVICE = "web" # service name from the docker-compose template
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Not `async def`: these call subprocess.run, which would block the event
|
|
21
|
+
# loop. Starlette runs sync handlers in a threadpool instead.
|
|
22
|
+
@router.get("/databases")
|
|
23
|
+
def get_databases():
|
|
24
|
+
databases, error = databases_result()
|
|
25
|
+
if error:
|
|
26
|
+
return {"databases": databases, "error": error}
|
|
27
|
+
return {"databases": databases}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/module-states")
|
|
31
|
+
def get_module_states(db: str):
|
|
32
|
+
if db not in list_databases():
|
|
33
|
+
return {"states": {}, "error": "unknown database"}
|
|
34
|
+
states, error = module_states(db)
|
|
35
|
+
if error:
|
|
36
|
+
return {"states": states, "error": error}
|
|
37
|
+
return {"states": states}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _known_modules() -> set[str]:
|
|
41
|
+
"""Every module name found under addons/.
|
|
42
|
+
|
|
43
|
+
Same scanner and addons_path as GET /api/modules, but without its
|
|
44
|
+
`installable` filter: whether a module can be updated is decided by its
|
|
45
|
+
state in ir_module_module, not by its manifest.
|
|
46
|
+
"""
|
|
47
|
+
scanner = ModuleScanner(addons_path=Path.cwd() / "addons")
|
|
48
|
+
return {m.name for m in scanner.scan(force_rescan=True)}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_update_command(module: str, db: str) -> tuple[list[str] | None, str]:
|
|
52
|
+
"""Build the `docker compose exec` argv for updating `module` in `db`.
|
|
53
|
+
|
|
54
|
+
Validates both by membership in the real lists, never by regex or
|
|
55
|
+
escaping: this is the only barrier between the browser and the Odoo CLI.
|
|
56
|
+
"""
|
|
57
|
+
# A directory literally named "--load-language=es" would otherwise pass
|
|
58
|
+
# the membership check below and reach the Odoo CLI as a flag. Membership
|
|
59
|
+
# is still the real barrier; this only closes the one case where an
|
|
60
|
+
# attacker also controls addons/.
|
|
61
|
+
if module.startswith("-") or db.startswith("-"):
|
|
62
|
+
return None, "invalid name"
|
|
63
|
+
if db not in list_databases():
|
|
64
|
+
return None, "unknown database"
|
|
65
|
+
if module not in _known_modules():
|
|
66
|
+
return None, "unknown module"
|
|
67
|
+
return [
|
|
68
|
+
"docker",
|
|
69
|
+
"compose",
|
|
70
|
+
"exec",
|
|
71
|
+
"-T",
|
|
72
|
+
ODOO_SERVICE,
|
|
73
|
+
"odoo",
|
|
74
|
+
"-d",
|
|
75
|
+
db,
|
|
76
|
+
"-u",
|
|
77
|
+
module,
|
|
78
|
+
"--stop-after-init",
|
|
79
|
+
"--log-level=info",
|
|
80
|
+
], ""
|