rocketdoo 3.2.0__tar.gz → 3.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.
Files changed (117) hide show
  1. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/LICENSE +2 -2
  2. {rocketdoo-3.2.0/rocketdoo.egg-info → rocketdoo-3.3.0}/PKG-INFO +9 -5
  3. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/README.md +6 -2
  4. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/pyproject.toml +1 -1
  5. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/deploy/vps.py +30 -16
  6. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/instance/deployer_docker.py +6 -6
  7. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/instance/deployer_native.py +10 -8
  8. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/instance/ssh_utils.py +19 -27
  9. rocketdoo-3.3.0/rocketdoo/core/odoo_db.py +191 -0
  10. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/ssh_manager.py +13 -0
  11. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/__init__.py +2 -0
  12. rocketdoo-3.3.0/rocketdoo/gui/api/odoo.py +80 -0
  13. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/server.py +80 -42
  14. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/static/index.html +118 -10
  15. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui_cli.py +11 -4
  16. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/mail_cli.py +268 -21
  17. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/pack_environment.py +14 -40
  18. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/Dockerfile.jinja +22 -5
  19. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/config/odoo.conf +1 -1
  20. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/config/odoo.conf.jinja +1 -1
  21. {rocketdoo-3.2.0 → rocketdoo-3.3.0/rocketdoo.egg-info}/PKG-INFO +9 -5
  22. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo.egg-info/SOURCES.txt +5 -0
  23. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_gui_api.py +245 -0
  24. rocketdoo-3.3.0/tests/test_mail_cli.py +617 -0
  25. rocketdoo-3.3.0/tests/test_odoo_db.py +325 -0
  26. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_pack_environment.py +49 -0
  27. rocketdoo-3.3.0/tests/test_secrets_hardening.py +618 -0
  28. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_ssh_manager.py +34 -0
  29. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_templates.py +137 -0
  30. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/MANIFEST.in +0 -0
  31. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/__init__.py +0 -0
  32. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/cli.py +0 -0
  33. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/__init__.py +0 -0
  34. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/compose.py +0 -0
  35. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/deploy/__init__.py +0 -0
  36. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/deploy/base.py +0 -0
  37. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/deploy/config_manager.py +0 -0
  38. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/deploy/module_packager.py +0 -0
  39. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/deploy/odoo_sh.py +0 -0
  40. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/edition_setup.py +0 -0
  41. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/generator.py +0 -0
  42. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/gitignore_manager.py +0 -0
  43. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/gitman_config.py +0 -0
  44. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/instance/__init__.py +0 -0
  45. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/instance/config_manager.py +0 -0
  46. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/instance/secrets_store.py +0 -0
  47. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/models/__init__.py +0 -0
  48. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/models/deploy.py +0 -0
  49. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/models/instance.py +0 -0
  50. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/models/profiles.py +0 -0
  51. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/models/project.py +0 -0
  52. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/module_scanner.py +0 -0
  53. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/port_validation.py +0 -0
  54. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/core/utils.py +0 -0
  55. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/deploy_cli.py +0 -0
  56. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/docker_cli.py +0 -0
  57. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/__init__.py +0 -0
  58. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/deploy_ops.py +0 -0
  59. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/docker_ops.py +0 -0
  60. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/gitman.py +0 -0
  61. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/instances.py +0 -0
  62. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/mail.py +0 -0
  63. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/modules.py +0 -0
  64. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/pack_ops.py +0 -0
  65. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/project.py +0 -0
  66. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/setup.py +0 -0
  67. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/traefik_ops.py +0 -0
  68. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/gui/api/workspace.py +0 -0
  69. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/init_project.py +0 -0
  70. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/instance_cli.py +0 -0
  71. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/project_info.py +0 -0
  72. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/scaffold.py +0 -0
  73. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/.devcontainer/devcontainer.json +0 -0
  74. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/.devcontainer/docker-compose.yaml +0 -0
  75. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/.gitignore.jinja +0 -0
  76. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/.vscode/launch.json +0 -0
  77. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/.vscode/launch.json.jinja +0 -0
  78. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/.vscode/settings.json +0 -0
  79. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/addons/.gitkeep +0 -0
  80. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/deploy/.deployignore +0 -0
  81. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/deploy/deploy.yaml +0 -0
  82. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/deploy/deploy.yaml.jinja +0 -0
  83. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/docker-compose.yaml.jinja +0 -0
  84. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/install_dependencies.sh +0 -0
  85. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/instance/Dockerfile.jinja +0 -0
  86. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/instance/docker-compose.jinja +0 -0
  87. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/instance/odoo.conf.jinja +0 -0
  88. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/odoo_pg_pass +0 -0
  89. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/README.md +0 -0
  90. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo15-ce.yaml +0 -0
  91. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo15-ee.yaml +0 -0
  92. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo16-ce.yaml +0 -0
  93. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo16-ee.yaml +0 -0
  94. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo17-ce.yaml +0 -0
  95. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo17-ee.yaml +0 -0
  96. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo18-ce.yaml +0 -0
  97. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo18-ee.yaml +0 -0
  98. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo19-ce.yaml +0 -0
  99. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/templates/profiles/odoo19-ee.yaml +0 -0
  100. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/traefik_cli.py +0 -0
  101. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/unpack_environment.py +0 -0
  102. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo/welcome.py +0 -0
  103. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo.egg-info/dependency_links.txt +0 -0
  104. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo.egg-info/entry_points.txt +0 -0
  105. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo.egg-info/requires.txt +0 -0
  106. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/rocketdoo.egg-info/top_level.txt +0 -0
  107. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/setup.cfg +0 -0
  108. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_compose.py +0 -0
  109. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_deploy_config_manager.py +0 -0
  110. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_e2e_scaffold.py +0 -0
  111. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_edition_setup.py +0 -0
  112. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_gitignore_manager.py +0 -0
  113. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_imports.py +0 -0
  114. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_models.py +0 -0
  115. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_module_scanner.py +0 -0
  116. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/tests/test_port_validation.py +0 -0
  117. {rocketdoo-3.2.0 → rocketdoo-3.3.0}/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.0"
60
+ Versión: "3.3.0"
61
61
  Autor: Horacio Montaño
62
- Fecha: 29/08/2026
62
+ Fecha: 14/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.0
3
+ Version: 3.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,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.0"
65
+ Versión: "3.3.0"
66
66
  Autor: Horacio Montaño
67
- Fecha: 29/08/2026
67
+ Fecha: 14/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.0"
100
+ - "3.3.0"
101
101
 
102
102
  ----------------------------------------------------------------------------------------------------------------------------------------------------------
103
103
 
@@ -472,6 +472,10 @@ rkd mail open # Open http://localhost:8025 in your browser
472
472
 
473
473
  Mailpit is toggled by commenting/uncommenting its service block in `docker-compose.yaml` using special markers (`# rkd:mailpit`). When enabled, Odoo's SMTP configuration is automatically updated to route emails through Mailpit.
474
474
 
475
+ `rkd mail on`/`off` also write a `Mailpit (rkd)` record straight into the project's `ir.mail_server` table (Odoo's technical settings, *Outgoing Mail Servers*), so emails are captured with no extra manual setup. `on` creates or reactivates that record with `sequence = 1`; `off` archives it (never deletes it), leaving any of your own mail servers untouched. With a single database this is automatic; with more than one, pass `--db NAME` to `on`, `off`, or `status` — with several databases and no `--db`, nothing is written or read and the command tells you to re-run with it. If the `db` container isn't reachable (project stopped, Docker not running), the rest of the command still runs and it reports the reason; re-running `rkd mail on` once the project is up fixes it.
476
+
477
+ `rkd mail status` shows this record's state (active, archived, not created yet, or not checked, with the reason) plus any other active `ir.mail_server` in the database. **A `sequence = 1` on the Mailpit record does not guarantee it captures every email**: Odoo's server selection filters by `from_filter` before it sorts by sequence, so a server you (or a restored production dump) already have, with a `from_filter` matching the sender, can still win even with a higher sequence number. `mail status` warns loudly when another active server has `sequence <= 1` (it can win the sequence tie-break outright), and with a softer note whenever any other server is active at all, since `from_filter` can override sequence regardless.
478
+
475
479
  ---
476
480
 
477
481
  #### `rkd traefik` — Traefik Reverse Proxy
@@ -517,7 +521,7 @@ rkd instance status # Show configured environments
517
521
  - Stage: 2 workers, `log_level = info`, 1.5 GB memory limit
518
522
  - Production: 4 workers, `log_level = warn`, 2.5 GB memory limit, `proxy_mode = True`, `list_db = False`
519
523
 
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.
524
+ > **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
525
 
522
526
  ------------------------------------------------------------------------------------------------------------------------------------------------------
523
527
 
@@ -15,7 +15,7 @@ Odoo Development Framework
15
15
  - "Horacio Montaño"
16
16
 
17
17
  ## Version:
18
- - "3.2.0"
18
+ - "3.3.0"
19
19
 
20
20
  ----------------------------------------------------------------------------------------------------------------------------------------------------------
21
21
 
@@ -390,6 +390,10 @@ rkd mail open # Open http://localhost:8025 in your browser
390
390
 
391
391
  Mailpit is toggled by commenting/uncommenting its service block in `docker-compose.yaml` using special markers (`# rkd:mailpit`). When enabled, Odoo's SMTP configuration is automatically updated to route emails through Mailpit.
392
392
 
393
+ `rkd mail on`/`off` also write a `Mailpit (rkd)` record straight into the project's `ir.mail_server` table (Odoo's technical settings, *Outgoing Mail Servers*), so emails are captured with no extra manual setup. `on` creates or reactivates that record with `sequence = 1`; `off` archives it (never deletes it), leaving any of your own mail servers untouched. With a single database this is automatic; with more than one, pass `--db NAME` to `on`, `off`, or `status` — with several databases and no `--db`, nothing is written or read and the command tells you to re-run with it. If the `db` container isn't reachable (project stopped, Docker not running), the rest of the command still runs and it reports the reason; re-running `rkd mail on` once the project is up fixes it.
394
+
395
+ `rkd mail status` shows this record's state (active, archived, not created yet, or not checked, with the reason) plus any other active `ir.mail_server` in the database. **A `sequence = 1` on the Mailpit record does not guarantee it captures every email**: Odoo's server selection filters by `from_filter` before it sorts by sequence, so a server you (or a restored production dump) already have, with a `from_filter` matching the sender, can still win even with a higher sequence number. `mail status` warns loudly when another active server has `sequence <= 1` (it can win the sequence tie-break outright), and with a softer note whenever any other server is active at all, since `from_filter` can override sequence regardless.
396
+
393
397
  ---
394
398
 
395
399
  #### `rkd traefik` — Traefik Reverse Proxy
@@ -435,7 +439,7 @@ rkd instance status # Show configured environments
435
439
  - Stage: 2 workers, `log_level = info`, 1.5 GB memory limit
436
440
  - Production: 4 workers, `log_level = warn`, 2.5 GB memory limit, `proxy_mode = True`, `list_db = False`
437
441
 
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.
442
+ > **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
443
 
440
444
  ------------------------------------------------------------------------------------------------------------------------------------------------------
441
445
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rocketdoo"
3
- version = "3.2.0"
3
+ version = "3.3.0"
4
4
  description = "Framework for creating Odoo development environments with Docker and custom templates."
5
5
  authors = [
6
6
  { name = "Horacio Montaño", email = "horaciomontano@hdmsoft.com.ar" }
@@ -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
- # Use echo password | sudo -S for password-based sudo
395
- command = f"echo '{self.password}' | sudo -S {command}"
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
- # Write conf via heredoc on remote. The heredoc delimiter is quoted
164
- # (<< \'ENDCONF\'), so the shell performs no expansion on the body.
165
- return self._ssh(f"sudo bash -c 'cat > {self.odoo_conf_path} << \\'ENDCONF\\'\n{conf}\nENDCONF'")
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 -p <pass> (requires sshpass installed)
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(auth: dict, port: int, user: str, host: str, command: str, agent_forward: bool = False) -> list[str]:
112
- """Full SSH command list ready for subprocess.run()."""
113
- return [
114
- *ssh_prefix(auth),
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 command list ready for subprocess.run()."""
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
- return [
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,191 @@
1
+ """Access to the project's PostgreSQL container: reads and Mailpit writes.
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. `rkd mail on/off`
7
+ also writes the Rocketdoo `ir.mail_server` row here (#176), so this module is
8
+ no longer read-only.
9
+ """
10
+
11
+ import subprocess
12
+
13
+ from rocketdoo.project_info import read_docker_compose
14
+
15
+ PSQL_USER = "root" # POSTGRES_USER set by the docker-compose template
16
+
17
+ MAILPIT_SERVER_NAME = "Mailpit (rkd)"
18
+ MAILPIT_SMTP_HOST = "mailpit"
19
+ MAILPIT_SMTP_PORT = 1025
20
+ MAILPIT_SEQUENCE = 1 # Odoo's default is 10: Mailpit must win the tie
21
+
22
+
23
+ def db_container(compose_data: dict | None = None) -> str | None:
24
+ """Return the `db` service's container name, or None if it can't be read."""
25
+ if compose_data is None:
26
+ compose_data = read_docker_compose()
27
+ try:
28
+ return compose_data["services"]["db"]["container_name"]
29
+ except (KeyError, TypeError):
30
+ return None
31
+
32
+
33
+ def _psql(db: str, sql: str, *flags: str, compose_data: dict | None = None) -> tuple[str, str]:
34
+ """Run `sql` against `db` via `docker exec ... psql`. Returns (stdout, error).
35
+
36
+ A non-empty error means the query did not run: stdout is not meaningful.
37
+ Shared by every query/write in this module so the argv and the error
38
+ translation (missing docker, unreachable container, psql failure) live in
39
+ one place.
40
+ """
41
+ container = db_container(compose_data)
42
+ if not container:
43
+ return "", "no database container configured"
44
+
45
+ try:
46
+ result = subprocess.run(
47
+ ["docker", "exec", container, "psql", "-U", PSQL_USER, "-d", db, *flags, "-c", sql],
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=15,
51
+ )
52
+ except FileNotFoundError:
53
+ return "", "docker not found"
54
+ except (OSError, subprocess.SubprocessError) as exc:
55
+ return "", str(exc)
56
+
57
+ if result.returncode != 0:
58
+ return "", result.stderr.strip()
59
+
60
+ return result.stdout, ""
61
+
62
+
63
+ def databases_result(compose_data: dict | None = None) -> tuple[list[str], str]:
64
+ """List of user databases plus the reason it came back empty, if any.
65
+
66
+ Excludes `postgres` and the templates, matching what `rkd pack` already
67
+ queried. Any failure to reach the container yields an empty list.
68
+ """
69
+ stdout, error = _psql(
70
+ "postgres",
71
+ "SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres';",
72
+ "-t",
73
+ compose_data=compose_data,
74
+ )
75
+ if error:
76
+ return [], error
77
+
78
+ return [line.strip() for line in stdout.splitlines() if line.strip()], ""
79
+
80
+
81
+ def list_databases(compose_data: dict | None = None) -> list[str]:
82
+ """List of user databases. Use `databases_result` when the reason matters."""
83
+ return databases_result(compose_data)[0]
84
+
85
+
86
+ def module_states(db: str) -> tuple[dict[str, str], str]:
87
+ """Map `{module_name: state}` read from `ir_module_module` in `db`.
88
+
89
+ `db` is not validated here: that is the trust boundary's job (the GUI
90
+ layer), and keeping it in one place avoids the validation being
91
+ duplicated with different criteria. If `db` isn't an Odoo database,
92
+ psql fails and ({}, stderr) is returned.
93
+ """
94
+ stdout, error = _psql(db, "SELECT name, state FROM ir_module_module;", "-t", "-A", "-F", "\t")
95
+ if error:
96
+ return {}, error
97
+
98
+ states = {}
99
+ for line in stdout.splitlines():
100
+ name, _, state = line.strip().partition("\t")
101
+ if name and state:
102
+ states[name] = state
103
+ return states, ""
104
+
105
+
106
+ def mail_servers(db: str) -> tuple[list[dict], str]:
107
+ """Every ir.mail_server row in `db`, ordered the way Odoo orders them.
108
+
109
+ All rows are returned, active or not, so `rkd mail status` can tell
110
+ "archived" from "never created" with a single query.
111
+ """
112
+ stdout, error = _psql(
113
+ db,
114
+ "SELECT id, name, sequence, smtp_host, active FROM ir_mail_server ORDER BY sequence, id;",
115
+ "-t",
116
+ "-A",
117
+ "-F",
118
+ "\t",
119
+ )
120
+ if error:
121
+ return [], error
122
+
123
+ servers = []
124
+ for line in stdout.splitlines():
125
+ fields = line.split("\t")
126
+ if len(fields) != 5:
127
+ continue
128
+ raw_id, name, raw_sequence, smtp_host, raw_active = fields
129
+ try:
130
+ server_id = int(raw_id)
131
+ sequence = int(raw_sequence)
132
+ except ValueError:
133
+ continue
134
+ servers.append(
135
+ {
136
+ "id": server_id,
137
+ "name": name,
138
+ "sequence": sequence,
139
+ "smtp_host": smtp_host,
140
+ "active": raw_active == "t",
141
+ }
142
+ )
143
+ return servers, ""
144
+
145
+
146
+ def enable_mailpit_server(db: str) -> str:
147
+ """Create or reactivate the Rocketdoo mail server. Returns '' on success.
148
+
149
+ Matched by name + smtp_host, not by id: there is no UNIQUE constraint on
150
+ name to key an UPSERT off. Idempotent by construction, so callers can run
151
+ it unconditionally instead of tracking local state.
152
+ """
153
+ sql = (
154
+ "WITH updated AS ("
155
+ "UPDATE ir_mail_server "
156
+ f"SET active = true, smtp_port = {MAILPIT_SMTP_PORT}, sequence = {MAILPIT_SEQUENCE}, write_date = now() "
157
+ f"WHERE name = '{MAILPIT_SERVER_NAME}' AND smtp_host = '{MAILPIT_SMTP_HOST}' "
158
+ "RETURNING id"
159
+ ") "
160
+ "INSERT INTO ir_mail_server "
161
+ "(name, smtp_host, smtp_port, smtp_encryption, smtp_authentication, sequence, active, create_date, write_date) "
162
+ f"SELECT '{MAILPIT_SERVER_NAME}', '{MAILPIT_SMTP_HOST}', {MAILPIT_SMTP_PORT}, 'none', 'login', "
163
+ f"{MAILPIT_SEQUENCE}, true, now(), now() "
164
+ "WHERE NOT EXISTS (SELECT 1 FROM updated);"
165
+ )
166
+ _, error = _psql(db, sql)
167
+ return error
168
+
169
+
170
+ def disable_mailpit_server(db: str) -> tuple[int, str]:
171
+ """Archive the Rocketdoo mail server. Returns (rows archived, error).
172
+
173
+ Only rows matching both name and smtp_host are touched, and only
174
+ archived: never DELETE, so a record a user renamed or repurposed is
175
+ never at risk even if it collides on one of the two conditions.
176
+ """
177
+ sql = (
178
+ "UPDATE ir_mail_server SET active = false, write_date = now() "
179
+ f"WHERE name = '{MAILPIT_SERVER_NAME}' AND smtp_host = '{MAILPIT_SMTP_HOST}' "
180
+ "RETURNING id;"
181
+ )
182
+ # -q suppresses psql's status line: without it, `RETURNING id` with zero
183
+ # rows still prints "UPDATE 0", which counts as a row and makes the command
184
+ # report an archive that never happened.
185
+ stdout, error = _psql(db, sql, "-q", "-t", "-A")
186
+ if error:
187
+ return 0, error
188
+
189
+ # Count returned ids, not non-empty lines: psql's status line ("UPDATE 0")
190
+ # is not a row, and counting it reports an archive that never happened.
191
+ return len([line for line in stdout.splitlines() if line.strip().isdigit()]), ""
@@ -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"])