From aa4f501c840feab3efe6c738599e669a9793e366 Mon Sep 17 00:00:00 2001 From: Guillermo Rojo Date: Thu, 8 Oct 2026 15:45:40 +0200 Subject: [PATCH] fix: robustness, i18n upkeep, verifiers that verify, Python 3.9+ - A step runs in its own process group: Ctrl+C terminates (then kills) the whole step and waits for it. - A last-resort handler names an unexpected error and returns to the menu; the language prompt, the file picker (out-of-range numbers, unreadable directories) and the health probe (non-HTTP answers) are hardened; --help and --version work without root. - i18n: posture and path labels translated, 86 unused entries removed, a typo fixed; tests fail on missing and on unused entries. - Verifiers fail when they verify nothing (no --odoo / --clone, no nginx binary, root in CI); Ctrl+C is checked against a real step. - Python 3.9+: the one 3.12-only line fixed, requires-python >=3.9, ruff targets py39, CI runs the suite on 3.9. - Scheduled backups refuse a remote or missing database and show the last run's result. - Release runs the CI workflow first, publishes only tags on main, marks pre-releases, and pins ruff, build and twine. - Docs: AGENTS.md (modules, checks), CONTRIBUTING, README, server-audit sections, platforms; the manage menu leaves a deleted instance. --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 22 +++- AGENTS.md | 15 ++- CHANGELOG.md | 17 +++ CONTRIBUTING.md | 7 +- README.md | 8 +- docs/installation.md | 3 +- docs/platforms.md | 5 +- docs/server-audit.md | 35 ++--- instance_manager/i18n.py | 121 +++++------------- instance_manager/planners.py | 4 +- instance_manager/prompts.py | 20 ++- instance_manager/system.py | 30 ++++- instance_manager/workflows/health.py | 4 +- instance_manager/workflows/manage.py | 3 + .../workflows/scheduled_backup.py | 24 +++- odoo_instance_manager.py | 42 +++++- .../proposal.md | 52 ++++++++ .../specs/execution-safety/spec.md | 26 ++++ .../specs/scheduled-backups/spec.md | 54 ++++++++ .../specs/ui-localization/spec.md | 41 ++++++ .../2026-10-08-robustness-and-upkeep/tasks.md | 11 ++ openspec/specs/execution-safety/spec.md | 18 ++- openspec/specs/scheduled-backups/spec.md | 7 +- openspec/specs/ui-localization/spec.md | 6 + pyproject.toml | 11 +- tests/test_i18n.py | 46 +++++++ tests/test_robustness.py | 57 +++++++++ tools/verify_data_safety.py | 31 +++++ tools/verify_install_runtime.py | 8 +- tools/verify_neutralisation.py | 4 + tools/verify_ops_configs.py | 7 +- 32 files changed, 607 insertions(+), 141 deletions(-) create mode 100644 openspec/changes/archive/2026-10-08-robustness-and-upkeep/proposal.md create mode 100644 openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/execution-safety/spec.md create mode 100644 openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/scheduled-backups/spec.md create mode 100644 openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/ui-localization/spec.md create mode 100644 openspec/changes/archive/2026-10-08-robustness-and-upkeep/tasks.md create mode 100644 tests/test_robustness.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78071ab..88a06c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [main] pull_request: branches: [main] + # The release runs the same checks first. + workflow_call: jobs: checks: @@ -37,12 +39,17 @@ jobs: - name: Build the wheel and import it from a clean venv run: | - pip install build + pip install build==1.6.1 python -m build --wheel --outdir dist python -m venv /tmp/wheel-check /tmp/wheel-check/bin/pip install dist/*.whl cd /tmp && /tmp/wheel-check/bin/python -c "import odoo_instance_manager, instance_manager.workflows.manage, instance_manager.support, instance_manager.neutralise" + - name: Unit tests on the oldest supported Python (3.9, Debian 11) + run: | + python3.9 -m pip install -q pytest + python3.9 -m pytest -q + - name: Verify the generated data commands (stubs + a throwaway PostgreSQL) run: python tools/verify_data_safety.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb7eb62..0382a54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,12 @@ permissions: contents: write # create the release and upload assets jobs: + # Every check CI runs (lint, tests on 3.9 and 3.12, the verify tools, specs, docs). + ci: + uses: ./.github/workflows/ci.yml + release: + needs: ci runs-on: ubuntu-latest steps: - name: Checkout project @@ -23,7 +28,15 @@ jobs: python-version: "3.12" - name: Install project (with dev deps) + build tooling - run: python -m pip install --upgrade pip && pip install -e ".[dev]" build twine + run: python -m pip install --upgrade pip && pip install -e ".[dev]" build==1.6.1 twine==7.0.0 + + - name: Verify the tag is on main + run: | + git fetch --quiet origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::Tag $GITHUB_REF_NAME is not on main" + exit 1 + fi - name: Verify the tag matches the pyproject version run: | @@ -81,9 +94,14 @@ jobs: gh release upload "$GITHUB_REF_NAME" dist/* --clobber gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md else + # A pre-release (v1.3.0-rc1) never becomes the latest release. + case "$GITHUB_REF_NAME" in + *-*) kind="--prerelease" ;; + *) kind="--latest" ;; + esac gh release create "$GITHUB_REF_NAME" dist/* \ --title "$GITHUB_REF_NAME" \ --notes-file release_notes.md \ - --latest \ + "$kind" \ --verify-tag fi diff --git a/AGENTS.md b/AGENTS.md index 1a7e427..6fdf969 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,9 @@ applies it only after confirmation. This file is the single authored source of t - `odoo_instance_manager.py` — entry point (root check, UTF-8 console, main menu). - `instance_manager/` — the package, in strict layers (see `docs/architecture.md`): - `models.py` — `InstanceConfig`, identifier validation, path derivation. + - `support.py` — per-version facts (Python range and its evidence, setuptools, uv pin, PostgreSQL floor, + cores). Pure data, declared once. + - `neutralise.py` — the catalogue of a copy's neutralisation, each rule cited to its source. Builds SQL only. - `planners.py` — **pure** builders returning `list[Command]` (odoo.conf, systemd, nginx, fail2ban, TLS). No I/O, no execution. - `system.py` — execution primitives, existence probes, `preview_commands`/`apply_commands`. @@ -34,7 +37,8 @@ applies it only after confirmation. This file is the single authored source of t ## Conventions -- Python 3.12+, `from __future__ import annotations`, **standard library only** (no third-party runtime deps). +- Python 3.9+ (the host's `python3`, down to Debian 11), `from __future__ import annotations`, **standard library + only** (no third-party runtime deps). - Files are UTF-8, newlines LF, final newline at EOF. - Conventional Commits, imperative mood, one logical change per commit. No AI-attribution trailers. - Match the surrounding code: small functions, early returns, type hints. @@ -86,6 +90,13 @@ applies it only after confirmation. This file is the single authored source of t Structural, read-only gates (run from the project root): ```bash -openspec validate --specs # every capability spec is well-formed +python -m ruff check . # lint (target py39: the manager runs on the host's python3) +python -m pytest -q # unit tests (also on Python 3.9 in CI) +openspec validate --specs # every capability spec is well-formed (CI pins openspec 1.4.1) # eunomai docs-check / provenance-check via the installed plugin/CLI ``` + +Text that runs is verified by running it: the `tools/verify_*.py` scripts execute what the planners generate +(against stubs, throwaway PostgreSQL clusters, real nginx / fail2ban / logrotate / openssl, and real Odoo for the +manual ones). Which run in CI, what each needs and when to run it is stated once, in +[`CONTRIBUTING.md`](CONTRIBUTING.md). A verifier that verifies nothing fails. diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a8f42..39a3514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ All notable changes to this project are documented here. The format is based on ### Changed +- **The release workflow** runs every CI check first and publishes only tags on `main`. A pre-release tag is not + marked as the latest release. ruff, build and twine are pinned. +- `odoo-instance-manager --help` and `--version` work without root, and other arguments are refused. + - **Docs refreshed against the code**, with diagrams in shared semantic colours (`docs/architecture.md`, *Diagram conventions*): the main menu, the layers, the plan flow (the phrase is asked before the preview), the per-version install, the neutralised copy, and what each fail2ban jail blocks. Pages follow the menu's @@ -43,6 +47,19 @@ All notable changes to this project are documented here. The format is based on ### Fixed +- **Ctrl+C during a long step** (apt, pip, a restore) returned to the menu while the step kept running behind + it. The whole step is now stopped and waited for. +- **Unexpected errors no longer end in a traceback:** an unreadable file, a port that does not speak HTTP, a + missing picker directory, or Ctrl+C at the language prompt now return to the menu with the error named. An + out-of-range number in the file picker is no longer taken as a path. +- **The tool did not start on the `python3` of Ubuntu 22.04 or Debian 11/12** (one line of Python 3.12 syntax). + It now needs Python 3.9+, and CI runs the suite on 3.9. +- **Scheduled backups** of an instance with a remote database dumped the local server; they are refused now. The + status also shows whether the last run made a backup. +- **Spanish interface.** The posture and paths tables are translated; 86 unused catalog entries are gone, and a + test now catches both. +- **Verify tools** fail when they verify nothing (no `--odoo`, no `--clone`, no nginx binary, run as root in CI). + - **fail2ban web and Odoo bans never applied on Ubuntu 22.04 and Debian 11.** fail2ban 0.11.2 split the `Nginx Full` profile name, and ufw refused it. They now use the tool's own `ufw-odoo-web` action (ports 80 and 443), checked on fail2ban 0.11.2, 1.0.2 and 1.1.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3355c8a..31b188f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,10 +34,13 @@ are held to a careful, spec-first standard. ## Conventions -- Python 3.12+, `from __future__ import annotations`, standard library only (no third-party runtime deps). +- Python 3.9+ (the host's `python3` on Debian 11), `from __future__ import annotations`, standard library only + (no third-party runtime deps). Ruff targets py39, and CI runs the unit suite on 3.9. - Files are UTF-8 with LF newlines and a final newline. - Conventional Commits in the imperative mood; one logical change per commit. No AI-attribution trailers. -- Match the surrounding style: small functions, early returns, type hints, Spanish operator-facing strings. +- Match the surrounding style: small functions, early returns, type hints. Operator-facing strings are English + keys passed through `t`/`tf`, with their Spanish in `instance_manager/i18n.py`; a test fails on a missing or an + unused entry. ## Dev loop diff --git a/README.md b/README.md index 1e5ad8c..43f26e8 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,16 @@ tool is for you. If you use Odoo's SaaS/Online or a managed host, you don't need - **You approve every change.** Each action shows the exact list of commands and runs them only after you confirm; anything destructive also requires typing a confirmation phrase. -- **Nothing to install.** Pure **Python 3.12 standard library** — no pip packages, no runtime dependencies. -- **Self-cleaning.** If an install fails partway, it rolls back its own residues so you can retry cleanly. +- **Nothing to install.** Pure **Python standard library** (3.9+) — no pip packages, no runtime dependencies. +- **Undo of a failed install.** If an install fails partway, it shows the steps that undo what that run made + and asks before running them, so you can retry cleanly. Copies are one step: done whole, or dropped. ## Requirements - A **Debian/Ubuntu (apt) server**, run as **root** (`sudo`) — validated on **Ubuntu 24.04**, and version-adaptive across other releases (see [Supported platforms](docs/platforms.md)). -- **Python 3.12+** (ships with 24.04). Nothing else. +- **Python 3.9+** as `python3` — what Debian 11 (3.9), Ubuntu 22.04 (3.10), Debian 12 (3.11) and Ubuntu 24.04 + (3.12) ship. Nothing else. The interface is available in **English** (default) or **Spanish** — chosen at startup or via `OIM_LANG=en|es` (see [Interface language](docs/language.md)). diff --git a/docs/installation.md b/docs/installation.md index b98e0a1..6b35284 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -16,7 +16,8 @@ plan before applying — see [Architecture](architecture.md). - A **Debian/Ubuntu (apt) family** server, validated on Ubuntu 24.04. Configuration is version-adaptive, so other apt releases work too — see [supported platforms](platforms.md). -- Python 3.12+ available as `python3` (the tool uses 3.12 syntax). +- Python 3.9+ available as `python3` — every supported release ships one (Debian 11: 3.9, Ubuntu 22.04: 3.10, + Debian 12: 3.11, Ubuntu 24.04: 3.12). The interpreter each Odoo version needs is chosen separately. - Run as root: the tool refuses to start otherwise. ```bash diff --git a/docs/platforms.md b/docs/platforms.md index 7ab394e..169bbce 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -43,8 +43,9 @@ assuming a single stack: | Component | Supported | Notes | |-----------|-----------|-------| | OS family | Debian/Ubuntu (**apt**) | Package steps target apt. A non-apt OS is detected and warned, not driven blindly. | -| Ubuntu | 22.04 (jammy), 24.04 (noble) | Validated primary targets. Newer releases work; the wkhtmltopdf table maps noble → the jammy build. | -| Debian | 11 (bullseye), 12 (bookworm) | wkhtmltopdf assets pinned for both. | +| Ubuntu | 22.04 (jammy), 24.04 (noble) | 24.04 is validated end to end. For 22.04 the version-specific parts are checked against its own tools (fail2ban 0.11.2, nginx 1.18 rules, Python 3.10). The wkhtmltopdf table maps noble → the jammy build. | +| Debian | 11 (bullseye), 12 (bookworm) | Same checks against their tools (fail2ban 0.11.2 / 1.0.2, PostgreSQL 13's md5 default, no `auth.log` on 12). wkhtmltopdf assets pinned for both. | +| Python (to run the manager) | 3.9+ | The host's `python3`: 3.9 on Debian 11, 3.10 on Ubuntu 22.04, 3.11 on Debian 12, 3.12 on Ubuntu 24.04. CI runs the unit suite on 3.9 and 3.12. | | nginx | 1.18 → 1.25+ | HTTP/2 directive form chosen by detected version; `proxy_cookie_flags` written from 1.19.3. The version is the installed nginx's, or the one apt would install when the plan installs it; when neither is known the vhost is written for an older nginx. `tools/verify_ops_configs.py` runs `nginx -t` on the generated vhosts with the nginx the host distribution ships (1.24 on Ubuntu 24.04). | | PostgreSQL | 13+ | Local drops use `dropdb --force` (13+); Odoo's own floor per version is below. Every supported Ubuntu/Debian ships 13 or newer. | | Odoo Community | 12 → 19 | Per-version interpreter, setuptools, config keys — see the table below. Official Odoo or OCB. | diff --git a/docs/server-audit.md b/docs/server-audit.md index 0216415..e168f91 100644 --- a/docs/server-audit.md +++ b/docs/server-audit.md @@ -15,24 +15,28 @@ server you are auditing or preparing to hand off. ## What it reports -- **System overview** — hostname, OS, kernel, architecture, virtualization, uptime, IPs, and the - versions/paths of Python, the PostgreSQL client, Nginx, and the host **wkhtmltopdf** version, plus the - run/boot state of the PostgreSQL and Nginx services and a read-only `nginx -t` config probe. Missing probes - show a "not detected" marker. -- **Per-instance detail** — discovered by cross-referencing: - - systemd units whose content references Odoo (`odoo-bin` / `Description=Odoo`), - - valid Odoo config files (must not be backup-like and must carry at least two expected Odoo keys), - - Odoo-related Nginx vhosts, +The report's sections, in the order it prints them: + +- **Server summary** — hostname, OS, kernel, architecture, virtualization, uptime, IPs, and the versions and + paths of Python, the PostgreSQL client, Nginx and **wkhtmltopdf**. It also gives the run and boot state of the + PostgreSQL and Nginx services and a read-only `nginx -t` probe. A missing probe shows "not detected". +- **Detected Odoo services**, **Artifacts found by search**, **Detected Odoo configuration files**, + **Odoo-related Nginx configs**, **Detected filestores** — what discovery cross-references: + - systemd units whose content references Odoo (`odoo-bin` / `Description=Odoo`); + - valid Odoo config files (not backup-like, with at least two expected Odoo keys); + - Odoo-related Nginx vhosts; - filestore roots (excluding backup-like and `.ssh` paths). - - Each instance's home, config, and Python are derived from the service `ExecStart` when possible. -- **TLS posture** — classifies each instance's certificate (self-signed / Let's Encrypt / custom / incomplete - / none) and reports certificate metadata and expiry status (OK / WARN if expiring within the threshold / - MISSING / ERROR). -- **Odoo version** — read from `/odoo/odoo/release.py` (`version_info`) when available. +- **Instances: Odoo / paths / DB**, **Instances: Python**, **Instances: Nginx / ports** — per instance: + - home, config and paths, derived from the service's `ExecStart` when possible; + - the Odoo version, read from `/odoo/odoo/release.py`; + - the venv's Python, read from its `pyvenv.cfg`, never by running the instance's interpreter as root; + - the instance's databases, its vhosts and its ports. - **Production posture** — a per-instance table of the checks listed under [security & production posture](operations/instance-management.md#security--production-posture), computed from each instance's `odoo.conf` plus host facts; read-only. +- **TLS certificate detail** — each instance's certificate mode (self-signed / Let's Encrypt / custom / + incomplete / none) with its metadata and expiry (OK / WARN within the threshold / MISSING / ERROR). +- **Active TLS checks** — only when you opt in: read-only `openssl` checks with an expiry threshold. Instance discovery also recognizes the legacy config path `/etc//odoo.conf` in addition to the default `/etc/odoo//.conf`. @@ -46,7 +50,8 @@ configuration, or deletion command — see the Two operator-initiated extras are the only things it may write or actively probe, both harmless: - **Export the report** — when you opt in, it writes a single report file to the path you choose (default under - `./reports/`, relative to where you launched the tool as root). This is the only file the audit creates. + `./reports/`, relative to where you launched the tool as root). This is the only file the audit creates: a new + file (an existing file, or a link in its place, is refused) readable by root only. - **Active TLS checks** — when you opt in with an expiry threshold, it runs read-only `openssl` certificate checks; no certificate or service is modified. diff --git a/instance_manager/i18n.py b/instance_manager/i18n.py index 998f265..57c55d0 100644 --- a/instance_manager/i18n.py +++ b/instance_manager/i18n.py @@ -65,10 +65,9 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "\n[ERROR] The operation did not complete: {}": "\n[ERROR] La operación no se completó: {}", # startup / main menu "- Interactive per-instance installation": "- Instalación interactiva por instancia", - "- Supports an Odoo instance + PostgreSQL user, PostgreSQL, or both": "- Soporta instancia Odoo + usuario de PostgreSQL, PosgreSQL o ambos", + "- Supports an Odoo instance + PostgreSQL user, PostgreSQL, or both": "- Soporta instancia Odoo + usuario de PostgreSQL, PostgreSQL o ambos", "- Shows the list of commands to run for each action": "- Muestra el listado de comandos a ejecutar para cada acción", "This manager requires administrative privileges.": "Este gestor requiere permisos de administración.", - "Run with: sudo python3 odoo_instance_manager.py": "Ejecuta con: sudo python3 odoo_instance_manager.py", "Idioma / Language": "Idioma / Language", "\nWhat do you want to do?": "\n¿Qué quieres hacer?", "Instance services": "Servicios instancias", @@ -83,7 +82,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "\nExiting.": "\nSaliendo.", "\nOperation interrupted. Returning to the menu.": "\nOperación interrumpida. Volviendo al menú.", "\nInput closed. Exiting.": "\nEntrada cerrada. Saliendo.", - "[ERROR] The operation did not complete: {}": "[ERROR] La operación no se completó: {}", # generic menu chrome "Back": "Volver", "Cancel": "Cancelar", @@ -135,11 +133,8 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Internal Odoo gevent port": "Puerto gevent interno Odoo", "DB name (optional, for validation)": "DB nombre (opcional para validaciones)", "App-server IP for the pg_hba rule": "IP servidor app para regla pg_hba", - "Odoo admin_passwd": "admin_passwd Odoo", "Re-enter the installation data using a safe format.": "Vuelve a introducir los datos de instalación con un formato seguro.", "Ports suggested automatically by availability: HTTP={}, gevent={}": "Puertos sugeridos automáticamente por disponibilidad: HTTP={}, gevent={}", - "The installation {}. Running automatic cleanup of the instance residues...": "La instalación {}. Ejecutando limpieza automática de residuos de la instancia...", - "Automatic cleanup finished. Review the messages above.": "Limpieza automática finalizada. Revisa los mensajes anteriores.", "Enable the Odoo service to start on boot?": "¿Habilitar autoarranque del servicio Odoo al iniciar el servidor?", "Configure listen_addresses and pg_hba for remote access?": "¿Configurar listen_addresses y pg_hba para acceso remoto?", "Set up log rotation for the instance (recommended)?": "¿Configurar rotación de logs de la instancia (recomendado)?", @@ -160,7 +155,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Do you have an intermediate file?": "¿Tienes archivo intermedio?", # manage-instance menu "\nSafe instance management: {}": "\nGestión segura de instancia: {}", - "Show locations / current config": "Consultar ubicaciones/config actual", "Health check": "Comprobar salud (health check)", "Update existing configuration": "Actualizar configuración existente", "Repair instance Nginx logs": "Reparar logs Nginx de instancia", @@ -180,7 +174,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Source and target databases must differ.": "La base de datos origen y destino deben ser distintas.", "The target DB {} exists — overwrite it?": "La BD destino {} existe — ¿sobrescribirla?", "Delete instance": "Eliminar instancia", - "\nFull instance status:": "\nEstado completo de la instancia:", # status view "Expected locations and names": "Ubicaciones y nombres esperados", "Detected state": "Estado detectado", @@ -334,23 +327,10 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Create the empty target DB (owned by postgres until it is handed over)": "Crear la BD destino vacía (de postgres hasta que se entrega)", "Seed target DB via pg_dump | pg_restore (re-owned by the target role)": "Sembrar BD destino con pg_dump | pg_restore (reasignada al rol destino)", "Drop the target DB (if it exists), closing its sessions": "Eliminar la BD destino (si existe), cerrando sus sesiones", - "Create the target filestore base path": "Crear ruta base del filestore destino", - "Remove the previous target filestore": "Eliminar el filestore destino previo", - "Own the target filestore": "Dar propiedad del filestore destino", - "Own the target data dir (filestore, sessions, …)": "Dar propiedad del data dir destino (filestore, sessions, …)", - "Restrict DB access to its owner (revoke PUBLIC connect)": "Restringir el acceso a la BD a su dueño (revocar connect de PUBLIC)", - "Grant DB connect to the owner role": "Conceder connect a la BD al rol dueño", - "New target instance": "Nueva instancia destino", - "New target DB": "Nueva DB destino", - "Already exists: {}": "Ya existe {}", - "systemd service already exists: {}": "Ya existe servicio systemd: {}", "Target DB already exists: {}": "Ya existe DB destino: {}", "Duplication mode": "Modo de duplicación", "Neutralize the duplicated DB?": "¿Neutralizar DB duplicada?", "Also duplicate the filestore?": "¿Duplicar también filestore?", - "Target filestore already exists: {}": "Ya existe filestore destino: {}", - "Duplicate DB via template": "Duplicar DB por plantilla", - "Duplicate DB via template (frees the source of active sessions)": "Duplicar DB por plantilla (libera de sesiones activas el origen)", "Invalid database name. Use letters, digits, '_', '.' and '-' (2-63 characters, starting with a letter or digit).": "Nombre de base de datos no válido. Usa letras, dígitos, '_', '.' y '-' (2-63 caracteres, empezando por letra o dígito).", "Duplicate the filestore": "Duplicar filestore", "Sensitive duplication action detected.": "Acción sensible de duplicación detectada.", @@ -371,7 +351,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Write {} and keep it only if fail2ban accepts it": "Escribir {} y conservarlo solo si fail2ban lo acepta", "Reload fail2ban": "Recargar fail2ban", "Write the odoo-auth filter and the {} jail; keep them only if fail2ban accepts them": "Escribir el filtro odoo-auth y la jail {}; conservarlos solo si fail2ban los acepta", - "Write the odoo-auth filter and keep it only if fail2ban accepts it": "Escribir el filtro odoo-auth y conservarlo solo si fail2ban lo acepta", "Test the Odoo filter against the login-failure line of Odoo 12-18 and 19": "Probar el filtro de Odoo con la línea de login fallido de Odoo 12-18 y 19", "Enable the instance HTTP vhost (kept only if nginx -t accepts it)": "Activar el vhost HTTP de la instancia (se conserva solo si nginx -t lo acepta)", "Enable the instance HTTPS vhost (kept only if nginx -t accepts it)": "Activar el vhost HTTPS de la instancia (se conserva solo si nginx -t lo acepta)", @@ -499,7 +478,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Could not list databases: {}": "No se pudo listar DBs: {}", "Reuse the previous DB credentials ({}@{}:{})?": "¿Usar credenciales DB anteriores ({}@{}:{})?", "{} (quick pick)": "{} (selección rápida)", - "Instances detected in {}:": "Instancias detectadas en {}:", "Select a detected instance": "Selecciona instancia detectada", # report titles "Server summary": "Resumen del servidor", @@ -536,26 +514,13 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Installed version": "Versión instalada", "Service": "Servicio", "Startup": "Arranque", - "Action": "Acción", - "Command": "Comando", # list prints "\nAvailable databases:": "\nDBs disponibles:", "\nCandidate databases for deletion:": "\nDBs candidatas para eliminación:", " No instance folders were detected in the base directory.": " No se detectaron carpetas de instancia en el directorio base.", " You can type the name of a known instance": " Puedes escribir el nombre de una instancia conocida", " to remove other residual data such as configs, services, logs, etc.": " para eliminar otros datos residuales como configs, servicios, logs, etc.", - "No databases detected/specified for deletion.": "No hay DBs detectadas/indicadas para eliminar.", - "Local cleanup (service/config/store) will proceed. Dropping DBs/roles is skipped for lack of an admin connection to the DB server.": "Se continuará con limpieza local (servicio/config/store). El borrado de DB/roles se omite por falta de conexión admin al servidor DB.", - "Could not use sudo -u postgres on this server.": "No fue posible usar sudo -u postgres en este servidor.", - "Local admin access to PostgreSQL detected (sudo -u postgres).": "Acceso admin local a PostgreSQL detectado (sudo -u postgres).", - "We will try an admin connection with user/password on the DB server.": "Intentaremos conexión admin con usuario/contraseña en el servidor DB.", - "Remote admin connection validated.": "Conexión admin remota validada.", - "You must allow the connection to the PostgreSQL server and use a full-privilege user (superuser/admin).": "Debes permitir la conexión al servidor PostgreSQL y usar un usuario con permisos completos (superuser/admin).", - "Could not connect to the DB server with admin credentials: {}": "No se pudo conectar al servidor DB con credenciales admin: {}", - "Could not detect DBs by prefix '{}': {}": "No se pudieron detectar DBs por prefijo '{}': {}", - "Failed to query databases: {}": "Fallo al consultar DBs: {}", # --- displayed strings: descriptions, table cells, status labels --- - "echo '[INFO] Existing self-signed certificate, reusing it.'; ": "echo '[INFO] Certificado autofirmado existente, se reutiliza.'; ", "Do not select": "No seleccionar", "(does not exist)": "(no existe)", "Autostart": "Autoarranque", @@ -581,20 +546,15 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Odoo log": "Log de Odoo", "(not measurable)": "(no medible)", "Manual package list": "Lista manual de paquetes", - "Permissions {} on {}": "Permisos {} en {}", "Permissions on config folder": "Permisos carpeta config", - "Permissions on fullchain": "Permisos fullchain", "Reload systemd": "Recargar systemd", "Reload Nginx": "Recargar Nginx", "[Cleanup] Reload systemd": "[Cleanup] Recargar systemd", "[Cleanup] Reload Nginx": "[Cleanup] Recargar Nginx", "Wait for the fail2ban socket to be ready": "Esperar socket fail2ban listo", "Remote DB role info": "Info rol DB remoto", - "Build fullchain (cert + intermediate)": "Construir fullchain (cert + intermediate)", - "Use server.crt as fullchain": "Usar server.crt como fullchain", "Reopen Nginx logs": "Reabrir logs Nginx", "Show final log permissions": "Mostrar permisos finales de logs", - "Write {}": "Escribir {}", "Backups directory": "Directorio de backups", "unknown": "desconocido", "Log does not exist: {}": "No existe el log: {}", @@ -603,13 +563,9 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "valid > {} days": "vigente > {} días", "expires in less than {} days ({})": "caduca antes de {} días ({})", 'Ensure logrotate is installed': 'Asegurar logrotate instalado', - 'Validate logrotate configuration (dry-run)': 'Validar configuración logrotate (dry-run)', 'Update packages': 'Actualizar paquetes', - 'Install fail2ban': 'Instalar fail2ban', - 'Create /etc/fail2ban/jail.d': 'Crear /etc/fail2ban/jail.d', 'Ensure fail2ban is installed': 'Asegurar fail2ban instalado', 'Create fail2ban directories': 'Crear directorios fail2ban', - 'Create fail2ban filters directory': 'Crear directorio filtros fail2ban', 'Install Odoo base dependencies': 'Instalar dependencias base Odoo', 'Create system user (if missing)': 'Crear usuario de sistema (si falta)', 'Create instance directories': 'Crear directorios instancia', @@ -620,9 +576,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: 'Validate DB user login': 'Validar login del usuario DB', 'Install Nginx': 'Instalar Nginx', 'Enable Nginx': 'Habilitar Nginx', - 'Create dedicated SSL directory': 'Crear directorio SSL dedicado', - 'Copy server.crt': 'Copiar server.crt', - 'Copy server.key': 'Copiar server.key', 'Ensure dedicated SSL directory': 'Asegurar directorio SSL dedicado', 'Generate self-signed if missing (server.key/fullchain)': 'Generar autofirmado si falta (server.key/fullchain)', 'Adjust self-signed certificate permissions': 'Ajustar permisos de certificados autofirmados', @@ -633,33 +586,16 @@ def tf(template: str, *args: object, **kwargs: object) -> str: 'Default policy: deny incoming': 'Política por defecto: denegar entrante', 'Default policy: allow outgoing': 'Política por defecto: permitir saliente', "Remove obsolete 'logrotate' key from odoo.conf (Odoo ≥13 ignores it)": "Eliminar clave 'logrotate' obsoleta del odoo.conf (Odoo ≥13 la ignora)", - 'Validate fail2ban configuration': 'Validar configuración fail2ban', 'Enable and start fail2ban': 'Habilitar y arrancar fail2ban', - 'Restart fail2ban': 'Reiniciar fail2ban', 'Validate instance log': 'Validar log de instancia', - 'Test fail2ban regex against the Odoo log': 'Probar regex fail2ban con log Odoo', - 'Enable and start the Odoo service': 'Habilitar y arrancar servicio Odoo', 'Enable Odoo service autostart': 'Habilitar autoarranque del servicio Odoo', 'Start the Odoo service': 'Arrancar servicio Odoo', 'Ensure local PostgreSQL role (create if missing)': 'Asegurar rol PostgreSQL local (crear si falta)', - 'Disable the instance HTTPS vhost': 'Desactivar vhost HTTPS de la instancia', - 'Enable the instance HTTP vhost': 'Activar vhost HTTP de la instancia', 'Validate Nginx': 'Validar Nginx', - 'Disable the instance HTTP vhost': 'Desactivar vhost HTTP de la instancia', - 'Enable the instance HTTPS vhost': 'Activar vhost HTTPS de la instancia', - 'Copy intermediate.crt': 'Copiar intermediate.crt', - 'Validate TLS private key': 'Validar clave privada TLS', - 'Validate main TLS certificate': 'Validar certificado principal TLS', - 'Validate KEY/CRT match': 'Validar correspondencia KEY/CRT', - 'Validate TLS fullchain': 'Validar fullchain TLS', 'Allow HTTP (80)': 'Permitir HTTP (80)', 'Allow HTTPS (443)': 'Permitir HTTPS (443)', - 'Write ': 'Escribir ', 'Disable Odoo service autostart': 'Deshabilitar autoarranque de servicio Odoo', - 'Start the Odoo service (without autostart)': 'Arrancar servicio Odoo (sin autoarranque)', - "Allow listen_addresses='*'": "Permitir listen_addresses='*'", 'Add pg_hba rule for the app-server IP': 'Añadir regla pg_hba por IP app server', - 'Restart PostgreSQL': 'Reiniciar PostgreSQL', '[Cleanup] Stop the Odoo service': '[Cleanup] Detener servicio Odoo', '[Cleanup] Disable the Odoo service': '[Cleanup] Deshabilitar servicio Odoo', '[Cleanup] Remove unit file': '[Cleanup] Eliminar unit file', @@ -688,19 +624,13 @@ def tf(template: str, *args: object, **kwargs: object) -> str: 'Install packages into the venv': 'Instalar paquetes en venv', 'Remove filestore': 'Eliminar filestore', 'Remove the instance Linux user': 'Eliminar usuario Linux de instancia', - 'Remove the instance Odoo/Nginx logs': 'Eliminar logs Odoo/Nginx de instancia', - 'Remove the instance filestore root': 'Eliminar raíz filestore de instancia', 'Validate/reload Nginx (best effort)': 'Validar/recargar Nginx (best effort)', - "for i in $(seq 1 15); do fail2ban-client ping >/dev/null 2>&1 && exit 0; sleep 1; done; echo '[ERROR] fail2ban is active but its socket is unavailable after waiting.'; systemctl status fail2ban --no-pager -n 50 || true; exit 1": "for i in $(seq 1 15); do fail2ban-client ping >/dev/null 2>&1 && exit 0; sleep 1; done; echo '[ERROR] fail2ban activo pero socket no disponible tras espera.'; systemctl status fail2ban --no-pager -n 50 || true; exit 1", "echo '[INFO] Remote DB host: role creation is skipped without remote admin credentials; the given user login will be validated.'": "echo '[INFO] DB host remoto: se omite creación de rol sin credenciales admin remotas; se validará login del usuario indicado.'", - "|| (echo '[ERROR] The private key does not match the selected public certificate.' && exit 1)": "|| (echo '[ERROR] La clave privada no corresponde con el certificado público seleccionado.' && exit 1)", - " already exists; reusing it without changing the password.'; END IF; END $$;": " ya existe; se reutiliza sin cambiar contraseña.'; END IF; END $$;", '[Unit]\nDescription=Scheduled Odoo backup (': '[Unit]\nDescription=Backup programado de Odoo (', '[Unit]\nDescription=Odoo backup timer (': '[Unit]\nDescription=Timer de backup de Odoo (', 'invalid instance. Use the format: start with a lowercase letter and only [a-z0-9_] (max 32).': 'instance inválida. Usa formato: inicia con letra minúscula y solo [a-z0-9_] (máx 32).', 'invalid db_user for PostgreSQL. Use the format: start with [a-z_] and only [a-z0-9_] (max 63).': 'db_user inválido para PostgreSQL. Usa formato: inicia con [a-z_] y solo [a-z0-9_] (máx 63).', 'To apply system changes, run as root (sudo).': 'Para aplicar cambios en sistema ejecuta como root (sudo).', - 'Failed running: ': 'Fallo ejecutando: ', 'Type the name manually': 'Escribir nombre manualmente', '(no backups)': '(sin backups)', '(no output)': '(sin salida)', @@ -714,8 +644,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: 'Could not read the log to evaluate IPs.': 'No se pudo leer el log para evaluar IPs.', 'No valid IPs detected in the last log lines.': 'No se detectaron IPs válidas en las últimas líneas del log.', 'Active service': 'Servicio activo', - 'Log does not exist: ': 'No existe el log: ', - 'Public IPs detected in log (e.g.: ': 'IPs públicas detectadas en log (ej.: ', 'Public certificate (CRT)': 'Certificado público (CRT)', 'failed': 'falló', 'no response on 127.0.0.1': 'sin respuesta en 127.0.0.1', @@ -733,8 +661,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: 'DELETE ': 'ELIMINAR ', 'Incomplete TLS configuration': 'Configuración TLS incompleta', 'not provided, validation skipped': 'no indicada, se omite validación', - 'connection/query failure -> ': 'fallo de conexión/consulta -> ', - ' DB(s) detected': ' DB(s) detectada(s)', '[OK] Local PostgreSQL admin access detected (sudo -u postgres).': '[OK] Acceso admin local a PostgreSQL detectado (sudo -u postgres).', 'DELETE-ALL ': 'ELIMINAR-TODO ', 'Instance': 'Instancia', @@ -744,9 +670,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: 'Nginx HTTP detected': 'Nginx HTTP detectado', 'Nginx HTTPS detected': 'Nginx HTTPS detectado', 'SSL detected': 'SSL detectado', - 'Filestore root detected': 'Filestore raíz detectado', - 'DBs by filestore': 'DBs por filestore', - 'DBs by prefix': 'DBs por prefijo', 'DBs by owner/name': 'DBs por dueño/nombre', 'Instance DB user (to find associated databases)': 'Usuario de BD de la instancia (para encontrar bases asociadas)', 'DBs to remove': 'DBs a eliminar', @@ -765,8 +688,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: 'Artifacts detected by search\n': 'Artefactos detectados por búsqueda\n', 'Detected Odoo configuration files\n': 'Archivos de configuración Odoo detectados\n', 'Odoo-related Nginx configs\n': 'Configs Nginx relacionadas con Odoo\n', - ' days': ' días', - ' days (': ' días (', 'unavailable': 'no disponible', # --- production defaults, wkhtmltopdf, version detection, status audit --- # status split + posture @@ -802,10 +723,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "wkhtmltopdf not installed: PDF report generation will fail until it is.": "wkhtmltopdf no instalado: la generación de informes PDF fallará hasta instalarlo.", "Install distro wkhtmltopdf (un-patched, reduced fidelity)": "Instalar wkhtmltopdf de la distribución (sin parchear, fidelidad reducida)", "Ensure curl is available": "Asegurar que curl está disponible", - "Download patched wkhtmltopdf ({})": "Descargar wkhtmltopdf parcheado ({})", - "Verify wkhtmltopdf SHA-256 (aborts on mismatch)": "Verificar SHA-256 de wkhtmltopdf (aborta si no coincide)", - "Install verified wkhtmltopdf .deb": "Instalar el .deb de wkhtmltopdf verificado", - "Remove downloaded wkhtmltopdf .deb": "Eliminar el .deb de wkhtmltopdf descargado", # in-code labels migrated to English-canonical "Disk (home)": "Disco (home)", "Disk (data dir)": "Disco (data dir)", @@ -838,7 +755,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Odoo {} supports Python {}; the host python3 ({}) will build the venv.": "Odoo {} admite Python {}; el python3 del host ({}) construirá el venv.", "Odoo {} supports Python {}; the host python3 ({}) cannot build it, so Python {} will be installed with uv {} into {}.": "Odoo {} admite Python {}; el python3 del host ({}) no puede construirlo, así que se instalará Python {} con uv {} en {}.", "No pinned uv build for this architecture ({}).": "No hay un uv fijado para esta arquitectura ({}).", - "[Cleanup] Remove the new data dir (if it holds no filestore)": "[Limpieza] Eliminar el data dir nuevo (si no tiene filestore)", "was interrupted (Ctrl+C)": "se interrumpió (Ctrl+C)", "Create the data dir {} (filestores, sessions)": "Crear el data dir {} (filestores, sesiones)", "Clone {} {} if missing": "Clonar {} {} si falta", @@ -846,14 +762,12 @@ def tf(template: str, *args: object, **kwargs: object) -> str: "Create the venv with the host python3": "Crear el venv con el python3 del host", "Install pip, wheel and {} in the venv": "Instalar pip, wheel y {} en el venv", "Install the Odoo {} requirements": "Instalar los requirements de Odoo {}", - "Install uv {} (checksum-verified) if missing": "Instalar uv {} (verificado por checksum) si falta", "Install Python {} with uv into {}": "Instalar Python {} con uv en {}", "Check PostgreSQL is {} or newer (Odoo {} requirement)": "Comprobar que PostgreSQL es {} o posterior (requisito de Odoo {})", "Set the new password on the local PostgreSQL role": "Poner la nueva contraseña en el rol local de PostgreSQL", "Restart the Odoo service to load the configuration": "Reiniciar el servicio Odoo para cargar la configuración", "Set a new DB password?": "¿Poner una nueva contraseña de DB?", "Set a new Odoo master password (admin_passwd)?": "¿Poner una nueva contraseña maestra de Odoo (admin_passwd)?", - "The DB is remote: set the same password on its role there before the next restart.": "La DB es remota: pon la misma contraseña en su rol allí antes del próximo reinicio.", "Source": "Origen", "Backup destination": "Destino del backup", "invalid core. Use odoo or ocb.": "core no válido. Usa odoo u ocb.", @@ -865,7 +779,6 @@ def tf(template: str, *args: object, **kwargs: object) -> str: 'Additional package': 'Paquete adicional', 'Owner config Odoo': 'Propietario del config de Odoo', 'Owner config dir': 'Propietario del directorio de config', - 'Owner fullchain': 'Propietario del fullchain', '{} (detected {} CPU → suggested {})': '{} (detectadas {} CPU → sugerido {})', 'Only internal/private IPs were detected in the log (e.g.: {}). Risk of banning the gateway/proxy.': 'Solo se detectaron IPs internas/privadas en el log (ej.: {}). Riesgo de banear el gateway/proxy.', 'IP': 'IP', @@ -1002,4 +915,36 @@ def tf(template: str, *args: object, **kwargs: object) -> str: 'Choose a mode that provides a certificate.': 'Elige un modo que aporte un certificado.', 'Obtain it first (for example: certbot certonly --webroot or --standalone for the domain).': 'Obténlo antes (por ejemplo: certbot certonly --webroot o --standalone para el dominio).', 'The DB is remote: the service is not restarted. Set the same password on its role there, then restart the service.': 'La BD es remota: no se reinicia el servicio. Pon la misma contraseña a su rol allí y luego reinicia el servicio.', + 'Run it as root: sudo odoo-instance-manager (or sudo python3 odoo_instance_manager.py from a checkout).': 'Ejecútalo como root: sudo odoo-instance-manager (o sudo python3 odoo_instance_manager.py desde el repositorio).', + '\n[ERROR] Unexpected error ({}): {}. Returning to the menu.': '\n[ERROR] Error inesperado ({}): {}. Volviendo al menú.', + 'Cannot list {}: {}': 'No se puede listar {}: {}', + '0 (threaded/dev mode) — set > 0 for production': '0 (modo multihilo/desarrollo) — pon > 0 en producción', + '{} (un-patched — reports may be degraded)': '{} (sin parchear — los informes pueden degradarse)', + 'Database manager (list_db)': 'Gestor de bases de datos (list_db)', + 'Master password (admin_passwd)': 'Contraseña maestra (admin_passwd)', + 'db_sslmode (remote DB)': 'db_sslmode (BD remota)', + '{} — remote DB traffic may be unencrypted; use require or stricter': '{} — el tráfico con la BD remota puede ir sin cifrar; usa require o más estricto', + 'disabled (list_db = False)': 'desactivado (list_db = False)', + 'equals the instance name — guessable': 'igual al nombre de la instancia — adivinable', + 'exposed — set list_db = False for production (and a dbfilter)': 'expuesto — pon list_db = False en producción (y un dbfilter)', + 'hashed': 'cifrada (hash)', + 'local DB (SSL mode not required)': 'BD local (no requiere modo SSL)', + 'not True — required behind Nginx': 'no es True — necesario detrás de Nginx', + 'not found in config': 'no está en la configuración', + 'not installed — PDF reports will fail': 'no instalado — los informes PDF fallarán', + 'not set': 'sin definir', + 'not set — bind the instance to its database(s)': 'sin definir — vincula la instancia a su(s) base(s) de datos', + 'set (non-default)': 'definida (no por defecto)', + ' (default)': ' (por defecto)', + 'HTTP local': 'HTTP local', + 'Odoo user': 'Usuario de Odoo', + 'Odoo conf': 'Configuración de Odoo', + 'Nginx HTTP conf': 'Configuración HTTP de Nginx', + 'Nginx HTTPS conf': 'Configuración HTTPS de Nginx', + 'SSL dir': 'Directorio SSL', + 'DB name': 'Nombre de la BD', + 'proxy_mode': 'proxy_mode', + 'The instance uses a remote database ({}): schedule the backup on that server, or use Create backup.': 'La instancia usa una base de datos remota ({}): programa la copia en ese servidor, o usa Crear backup.', + 'No database {} on the local server.': 'No hay ninguna base de datos {} en el servidor local.', + 'Last run: {} (result {}, exit {}).': 'Última ejecución: {} (resultado {}, salida {}).', } diff --git a/instance_manager/planners.py b/instance_manager/planners.py index cf5a1a6..13b0da9 100644 --- a/instance_manager/planners.py +++ b/instance_manager/planners.py @@ -1712,7 +1712,7 @@ def posture_rows( rows.append(("WARN", "wkhtmltopdf", "not installed — PDF reports will fail")) else: patched = "with patched qt" in wkhtmltopdf_ver.lower() - detail = wkhtmltopdf_ver if patched else f"{wkhtmltopdf_ver} (un-patched — reports may be degraded)" + detail = wkhtmltopdf_ver if patched else tf('{} (un-patched — reports may be degraded)', wkhtmltopdf_ver) rows.append(("OK" if patched else "WARN", "wkhtmltopdf", detail)) workers_raw = conf_values.get("workers", "").strip() @@ -1740,7 +1740,7 @@ def posture_rows( rows.append(( "WARN", "db_sslmode (remote DB)", - f"{sslmode or 'unset'} — remote DB traffic may be unencrypted; use require or stricter", + tf('{} — remote DB traffic may be unencrypted; use require or stricter', sslmode or 'unset'), )) if conf_values.get("proxy_mode", "").strip().lower() in {"true", "1", "yes"}: diff --git a/instance_manager/prompts.py b/instance_manager/prompts.py index b543045..289ab4e 100644 --- a/instance_manager/prompts.py +++ b/instance_manager/prompts.py @@ -147,7 +147,7 @@ def choose(label: str, options: list[str], default_index: int | None = None) -> for index, option in enumerate(indexed_options, start=1): default_tag = ( - " (default)" + t(" (default)") if default_index is not None and options[default_index] == option else "" ) @@ -190,9 +190,15 @@ def select_file_path( print(f"{title('Current directory')}: {sanitize(str(current))}") if _last_selected_dir and _last_selected_dir.is_dir(): print(level_text("INFO", tf('Last folder used: {}', _last_selected_dir))) - entries = sorted( - current.iterdir(), key=lambda item: (item.is_file(), item.name.lower()) - ) + try: + entries = sorted( + current.iterdir(), key=lambda item: (item.is_file(), item.name.lower()) + ) + except OSError as error: + # Gone, or not readable: back to the parent. + print(level_text("ERROR", tf('Cannot list {}: {}', current, error.strerror or error))) + current = current.parent if current != current.parent else Path("/") + continue print(t(' 0) Choose this directory')) print(t(' ..) Up one level')) print(t(' q) Cancel')) @@ -200,7 +206,8 @@ def select_file_path( marker = "/" if entry.is_dir() else "" print(f" {index}) {sanitize(entry.name)}{marker}") - raw = input(f"{prompt_label("Choose a number, '..', 'q' or a manual path")}: ").strip() + label = prompt_label("Choose a number, '..', 'q' or a manual path") + raw = input(f"{label}: ").strip() if raw.lower() in {"q", "cancelar"}: return "" if raw == "0": @@ -231,6 +238,9 @@ def select_file_path( if raw.isdigit(): idx = int(raw) + if not 1 <= idx <= len(entries): + print(level_text("ERROR", 'Option out of range.')) + continue if 1 <= idx <= len(entries): selected = entries[idx - 1] if selected.is_dir(): diff --git a/instance_manager/system.py b/instance_manager/system.py index bef0362..63f0869 100644 --- a/instance_manager/system.py +++ b/instance_manager/system.py @@ -6,6 +6,7 @@ import re import shlex import shutil +import signal import subprocess from dataclasses import dataclass, field @@ -71,6 +72,21 @@ def run( return result +def _stop_group(process: subprocess.Popen[str]) -> None: + """TERM the step's process group (a staged write restores on it), then KILL it + if it has not ended after 10 seconds.""" + for sig, wait in ((signal.SIGTERM, 10), (signal.SIGKILL, 5)): + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + return + try: + process.wait(timeout=wait) + return + except subprocess.TimeoutExpired: + continue + + def run_streaming(command: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: """Run a command, forwarding its output live while also capturing it. @@ -79,6 +95,9 @@ def run_streaming(command: str, env: dict[str, str] | None = None) -> subprocess would otherwise sit silent. stdin is closed so a command never blocks waiting for input. Returns a ``CompletedProcess`` with the accumulated output. """ + # Its own process group, so Ctrl+C here stops the whole step (bash and what it + # started), and waits for it: a step left running behind the menu could still + # be writing when the next plan starts. process = subprocess.Popen( ["bash", "-lc", command], stdin=subprocess.DEVNULL, @@ -89,12 +108,17 @@ def run_streaming(command: str, env: dict[str, str] | None = None) -> subprocess errors="replace", bufsize=1, env=_environment(env), + start_new_session=True, ) captured: list[str] = [] assert process.stdout is not None - for line in process.stdout: - print(sanitize(line, keep_cr=True), end="", flush=True) - captured.append(line) + try: + for line in process.stdout: + print(sanitize(line, keep_cr=True), end="", flush=True) + captured.append(line) + except KeyboardInterrupt: + _stop_group(process) + raise process.stdout.close() returncode = process.wait() return subprocess.CompletedProcess(process.args, returncode, "".join(captured), "") diff --git a/instance_manager/workflows/health.py b/instance_manager/workflows/health.py index 1eea430..5b16d9a 100644 --- a/instance_manager/workflows/health.py +++ b/instance_manager/workflows/health.py @@ -2,6 +2,7 @@ from __future__ import annotations +import http.client import urllib.error import urllib.request @@ -31,7 +32,8 @@ def _http_probe(port: str) -> tuple[bool, str]: except urllib.error.HTTPError as error: # A 3xx/4xx still means the server answered. return 200 <= error.code < 500, tf('HTTP {} on {}', error.code, path) - except (urllib.error.URLError, OSError): + except (urllib.error.URLError, OSError, http.client.HTTPException): + # Refused, timed out, or something that is not HTTP on the port. continue return False, 'no response on 127.0.0.1' diff --git a/instance_manager/workflows/manage.py b/instance_manager/workflows/manage.py index 390a5b2..980bc58 100644 --- a/instance_manager/workflows/manage.py +++ b/instance_manager/workflows/manage.py @@ -688,6 +688,9 @@ def manage_existing_instance() -> None: return if group == 'Delete instance': db_creds = _delete_instance(config, db_creds) + if not path_exists(config.odoo_home) and not path_exists(config.odoo_conf_dir): + # Deleted: nothing is left to manage under this name. + return continue while True: diff --git a/instance_manager/workflows/scheduled_backup.py b/instance_manager/workflows/scheduled_backup.py index 1ce1bc1..74d961e 100644 --- a/instance_manager/workflows/scheduled_backup.py +++ b/instance_manager/workflows/scheduled_backup.py @@ -3,10 +3,10 @@ from __future__ import annotations from ..i18n import tf -from ..models import InstanceConfig, is_valid_db_name +from ..models import LOCAL_DB_HOSTS, InstanceConfig, is_valid_db_name from ..planners import plan_remove_scheduled_backup, plan_scheduled_backup from ..prompts import ask_bool, ask_int, ask_text, choose -from ..system import run +from ..system import database_exists, read_odoo_conf, run from ..ui import level_text, title from .common import _backup_dir_error, _execute_plan, _filestore_path @@ -25,10 +25,21 @@ def _configure_schedule(config: InstanceConfig) -> None: "The backup runs as root using 'sudo -u postgres pg_dump' (local DB, no password).", ) ) + db_host = read_odoo_conf(config.odoo_conf_file).get("db_host", "").strip().lower() + if db_host not in LOCAL_DB_HOSTS: + # The script dumps the local server: it would back up nothing of a remote one. + print(level_text("ERROR", tf( + 'The instance uses a remote database ({}): schedule the backup on that server, or use Create backup.', + db_host, + ))) + return db_name = ask_text('Database to back up', config.db_name or config.instance, required=True) if not is_valid_db_name(db_name): print(level_text("ERROR", 'Invalid database name.')) return + if not database_exists(db_name): + print(level_text("ERROR", tf('No database {} on the local server.', db_name))) + return backup_dir = ask_text( 'Destination directory', f"/var/backups/{config.instance}", @@ -61,6 +72,15 @@ def _show_schedule_status(config: InstanceConfig) -> None: nxt = run(f"systemctl list-timers {name}.timer --no-pager 2>&1", check=False) if nxt.stdout.strip(): print("\n" + nxt.stdout.strip()) + # Whether the last run made a backup: the timer firing says nothing about that. + last = run(f"systemctl show {name}.service -p Result -p ExecMainStatus -p ExecMainExitTimestamp 2>&1", + check=False).stdout + values = dict(line.split("=", 1) for line in last.splitlines() if "=" in line) + if values.get("ExecMainExitTimestamp"): + ok = values.get("Result") == "success" and values.get("ExecMainStatus") == "0" + print(level_text("OK" if ok else "ERROR", tf('Last run: {} (result {}, exit {}).', + values["ExecMainExitTimestamp"], values.get("Result", "?"), + values.get("ExecMainStatus", "?")))) def _remove_schedule(config: InstanceConfig) -> None: diff --git a/odoo_instance_manager.py b/odoo_instance_manager.py index 42030c1..54b6fa4 100644 --- a/odoo_instance_manager.py +++ b/odoo_instance_manager.py @@ -2,9 +2,11 @@ import os import sys +from importlib import metadata from instance_manager.i18n import set_language, t, tf from instance_manager.prompts import choose, clear_screen +from instance_manager.ui import sanitize from instance_manager.workflows import ( external_server_report, install_db_only, @@ -57,16 +59,44 @@ def _select_language() -> None: set_language("es" if lang == "Español" else "en") -def main() -> int: +_USAGE = """usage: odoo-instance-manager [--help | --version] + +Interactive installer and manager of Odoo instances on a Debian/Ubuntu host. Run it +as root, with no arguments: every action is a plan you preview and confirm. +OIM_LANG=es (or en) chooses the language without asking.""" + + +def _version() -> str: + try: + return metadata.version("odoo-instance-manager") + except metadata.PackageNotFoundError: + return "unknown (not installed)" + + +def main(argv: list[str] | None = None) -> int: _configure_utf8_console() + args = sys.argv[1:] if argv is None else argv + if args and args[0] in {"-h", "--help"}: + print(_USAGE) + return 0 + if args and args[0] == "--version": + print(f"odoo-instance-manager {_version()}") + return 0 + if args: + print(_USAGE, file=sys.stderr) + return 2 if os.geteuid() != 0: print(t('This manager requires administrative privileges.')) - print(t('Run with: sudo python3 odoo_instance_manager.py')) + print(t('Run it as root: sudo odoo-instance-manager (or sudo python3 odoo_instance_manager.py from a checkout).')) return 1 clear_screen() - _select_language() + try: + _select_language() + except (KeyboardInterrupt, EOFError): + print("\nExiting.") + return 0 print(t("Odoo Instance Manager")) print(t('- Interactive per-instance installation')) print(t('- Supports an Odoo instance + PostgreSQL user, PostgreSQL, or both')) @@ -125,6 +155,12 @@ def main() -> int: # surface it and return to the menu instead of crashing the CLI. print(tf('\n[ERROR] The operation did not complete: {}', error)) continue + except Exception as error: # noqa: BLE001 - the last resort keeps the session + # Anything else (an unreadable file, a host answering unexpectedly) is + # reported by name and the menu stays, rather than a traceback. + print(tf('\n[ERROR] Unexpected error ({}): {}. Returning to the menu.', + type(error).__name__, sanitize(str(error)))) + continue if __name__ == "__main__": diff --git a/openspec/changes/archive/2026-10-08-robustness-and-upkeep/proposal.md b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/proposal.md new file mode 100644 index 0000000..3e9d8d8 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/proposal.md @@ -0,0 +1,52 @@ +# Robustness and upkeep + +## Why + +The last group of the second audit: + +- **Interruptions and unexpected errors.** + - Ctrl+C during a long step returned to the menu while the step (bash and what it started) kept running. + - An unreadable file, a host answering something that is not HTTP, a missing picker directory, or Ctrl+C at + the language prompt ended in a traceback. +- **Translations.** The posture and paths tables showed English labels in Spanish. 86 catalog entries no code + used any more, and one Spanish string had a typo. +- **Verifiers that passed while verifying nothing.** + - `verify_neutralisation` with no `--odoo` and `verify_install_runtime` with no `--clone`; + - `verify_ops_configs` when the nginx package had no binary; + - `verify_data_safety` when run as root. +- **Python requirement.** `docs/platforms.md` listed Ubuntu 22.04 and Debian 11/12, but one line used Python 3.12 + syntax, so the tool did not start on their `python3`. +- **The file picker** took an out-of-range number as a path. +- **Scheduled backups** of an instance with a remote database dumped the local server, and the status did not + show whether the last run made a backup. +- **Release workflow.** + - It published any `v*` tag from any branch, always as the latest release, and did not run the verifiers. + - ruff and build were unpinned. +- **Docs and CLI.** + - `AGENTS.md` missed two modules and its checks; CONTRIBUTING asked for Spanish strings. + - The README overstated the cleanup; the audit page named sections the report does not print. + - The CLI ignored `--help`. + +## What changes + +- **Steps.** A step runs in its own process group: Ctrl+C terminates it (then kills it) and waits for it. +- **Errors.** A last-resort handler names an error and returns to the menu. The language prompt, picker and + health probe are hardened. `--help` and `--version` work without root. +- **Translations.** Translated posture and path labels, dead entries removed, the typo fixed. Tests fail on a + missing or an unused entry. +- **Verifiers.** They fail when they verify nothing; a skip in CI is a failure. A new check runs Ctrl+C against a + real step. +- **Python 3.9+.** The one 3.12-only line is fixed; `requires-python >=3.9`, ruff targets py39, and CI runs the + suite on 3.9. +- **Scheduled backups.** A remote database or a missing one is refused, and the last run's result is shown. +- **Release.** It runs the CI workflow first, publishes only tags on `main`, marks pre-releases as such, and pins + ruff, build and twine. +- **Docs and the delete flow.** AGENTS.md, CONTRIBUTING, README, server-audit and platforms are brought in line + with the code, and the manage menu leaves an instance once it is deleted. + +## Impact + +- Specs: `execution-safety`, `scheduled-backups`, `ui-localization`. +- Code: `system.py`, `prompts.py`, `odoo_instance_manager.py`, `planners.py`, `i18n.py`, + `workflows/{health,manage,scheduled_backup}.py`. +- Tools, CI and release: `tools/verify_*`, `.github/workflows/{ci,release}.yml`, `pyproject.toml`. diff --git a/openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/execution-safety/spec.md b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/execution-safety/spec.md new file mode 100644 index 0000000..af425a8 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/execution-safety/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: Graceful recovery from a failed command + +The tool SHALL report a command that fails outside the install flow, or anything else that goes wrong in an +action (an unreadable file, a host answering unexpectedly), by name, and return to the menu rather than terminate +with a traceback. Ctrl+C during a step SHALL stop the whole step — the shell and everything it +started — and wait for it before returning, so no step keeps running behind the menu. `--help` and `--version` +SHALL answer without root; any other argument is refused. + +#### Scenario: A failed command returns to the menu + +- **WHEN** applying a plan (e.g. delete, backup, restore, duplicate, fail2ban, config update, or log rotation) + raises an error on a failing command +- **THEN** the tool reports the failure and returns to the main menu, keeping the session alive + +#### Scenario: An unexpected error returns to the menu + +- **WHEN** an action raises anything else +- **THEN** the tool names the error and returns to the menu + +#### Scenario: Ctrl+C stops the step it interrupts + +- **WHEN** the operator presses Ctrl+C while a step runs +- **THEN** the step's process group is terminated (killed if it does not end), and control returns once it has + ended diff --git a/openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/scheduled-backups/spec.md b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/scheduled-backups/spec.md new file mode 100644 index 0000000..a6b5614 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/scheduled-backups/spec.md @@ -0,0 +1,54 @@ +## MODIFIED Requirements + +### Requirement: Configure a scheduled backup + +The tool SHALL install a systemd service and timer that back up an instance's database (via local +`sudo -u postgres pg_dump`) and, optionally, its filestore, applying retention, on an operator-chosen schedule. +An instance whose database is remote SHALL be refused (the local dump would back up nothing of it), and so SHALL +a database the local server does not have. +The script SHALL take every value shell-quoted, SHALL write private files (`umask 077`, directory `700`, unit +`UMask=0077`), SHALL leave no partial file behind, and SHALL exit non-zero when the dump fails, when the dump has +no readable table of contents (`pg_restore --list`), when the filestore directory is missing, or when the +archive fails — so the unit shows failed instead of reporting a backup that is not there. The destination +SHALL be an absolute, dedicated directory (a shared system directory such as `/` or `/tmp` is refused), and +retention SHALL keep the newest backups by the timestamp in their names. + +#### Scenario: A timer and backup script are installed + +- **WHEN** the operator configures a scheduled backup with a database, destination, schedule, and retention +- **THEN** the plan writes `/usr/local/sbin/odoo-backup-.sh`, an `odoo-backup-.service`, and + an `odoo-backup-.timer` with the chosen `OnCalendar`, reloads systemd, and enables and starts the + timer + +#### Scenario: A failed dump fails the run + +- **WHEN** `pg_dump` fails or produces a dump `pg_restore --list` cannot read +- **THEN** the script exits non-zero, removes the partial file, and writes no `.dump` + +#### Scenario: Filestore is optional + +- **WHEN** the operator declines including the filestore +- **THEN** the backup script dumps only the database (no filestore archive) + +#### Scenario: Retention prunes old backups + +- **WHEN** the scheduled backup runs +- **THEN** it keeps the chosen number of newest dumps (and filestore archives) named + `----` and removes the older ones, never touching other instances' or other + databases' backups + +### Requirement: Inspect and remove a scheduled backup + +The tool SHALL show the timer status, its next run and the result of the last run (a timer that fires says +nothing about whether a backup was made), and SHALL remove the schedule (disable the timer, delete the units and +script). + +#### Scenario: Status shows the timer and next run + +- **WHEN** the operator views the scheduled-backup status +- **THEN** the tool shows the timer's systemd status and its next scheduled run (read-only) + +#### Scenario: Removal disables the timer and deletes the files + +- **WHEN** the operator removes the schedule +- **THEN** the plan disables/stops the timer and deletes the timer, service, and script, then reloads systemd diff --git a/openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/ui-localization/spec.md b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/ui-localization/spec.md new file mode 100644 index 0000000..662f0a5 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/specs/ui-localization/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: UI language selection + +The tool SHALL treat English as the canonical source language (English string literals in the code) and SHALL let the operator choose the interface language (English or Spanish) at startup, or via the `OIM_LANG` environment variable, defaulting to English, and SHALL render every user-facing surface (menus, prompts, titles, table headers and string row cells, command/plan descriptions, interpolated status messages, and one-off prints) in the chosen language, falling back to the English source for any string without a Spanish translation. + +#### Scenario: Language is chosen at startup + +- **WHEN** the tool starts and `OIM_LANG` is not set +- **THEN** it asks the operator to choose Español or English before showing the main menu + +#### Scenario: Environment variable selects the language non-interactively + +- **WHEN** `OIM_LANG` is `en` or `es` +- **THEN** the tool uses that language without prompting + +#### Scenario: English is the default and the identity + +- **WHEN** English is selected (including by default) +- **THEN** every string is shown as written in the code, with no catalog lookup required + +#### Scenario: Spanish is a complete translation + +- **WHEN** Spanish is selected +- **THEN** menus, prompts, titles, table headers and string cells, command/plan descriptions, and interpolated status messages are all shown in Spanish + +#### Scenario: Menu selections are language-independent + +- **WHEN** a menu is shown in Spanish +- **THEN** each option is displayed translated but the value the tool acts on is the original English option, so behavior does not depend on the chosen language + +#### Scenario: The catalog stays complete and current + +- **WHEN** the test suite runs +- **THEN** it fails on a UI string without a Spanish entry — including the labels of rows built in code (posture, + paths) — and on a catalog entry no code names any more + +#### Scenario: Untranslated strings fall back to English + +- **WHEN** a UI string has no Spanish catalog entry +- **THEN** the original English string is shown (no error, graceful degrade) diff --git a/openspec/changes/archive/2026-10-08-robustness-and-upkeep/tasks.md b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/tasks.md new file mode 100644 index 0000000..922ebe8 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-robustness-and-upkeep/tasks.md @@ -0,0 +1,11 @@ +# Tasks + +- [x] 1. Spec deltas; openspec 1.4.1 validates. +- [x] 2. Process group per step; Ctrl+C terminates and waits (verified against a real step). +- [x] 3. Last-resort handler, language prompt, picker, health probe; --help / --version. +- [x] 4. i18n: built-row labels, dead entries, typo; tests for both. +- [x] 5. Verifiers fail when they verify nothing or skip in CI. +- [x] 6. Python 3.9+: the 3.12-only line, requires-python, ruff py39, CI on 3.9. +- [x] 7. Scheduled backups: remote and missing databases refused, last run shown. +- [x] 8. Release: runs CI, tags on main only, pre-releases, pinned tools. +- [x] 9. Docs: AGENTS.md, CONTRIBUTING, README, server-audit, platforms; manage leaves a deleted instance. diff --git a/openspec/specs/execution-safety/spec.md b/openspec/specs/execution-safety/spec.md index 500d8b7..63ea671 100644 --- a/openspec/specs/execution-safety/spec.md +++ b/openspec/specs/execution-safety/spec.md @@ -178,8 +178,11 @@ Password prompts that have no visible default SHALL read the secret without echo ### Requirement: Graceful recovery from a failed command -When a command in an applied plan fails outside the install flow, the tool SHALL report the failure and return -to the menu rather than terminating with an uncaught error. +The tool SHALL report a command that fails outside the install flow, or anything else that goes wrong in an +action (an unreadable file, a host answering unexpectedly), by name, and return to the menu rather than terminate +with a traceback. Ctrl+C during a step SHALL stop the whole step — the shell and everything it +started — and wait for it before returning, so no step keeps running behind the menu. `--help` and `--version` +SHALL answer without root; any other argument is refused. #### Scenario: A failed command returns to the menu @@ -187,6 +190,17 @@ to the menu rather than terminating with an uncaught error. raises an error on a failing command - **THEN** the tool reports the failure and returns to the main menu, keeping the session alive +#### Scenario: An unexpected error returns to the menu + +- **WHEN** an action raises anything else +- **THEN** the tool names the error and returns to the menu + +#### Scenario: Ctrl+C stops the step it interrupts + +- **WHEN** the operator presses Ctrl+C while a step runs +- **THEN** the step's process group is terminated (killed if it does not end), and control returns once it has + ended + ### Requirement: Host text cannot drive the terminal The tool SHALL print text that comes from the host (database, file and service names, a command's output, diff --git a/openspec/specs/scheduled-backups/spec.md b/openspec/specs/scheduled-backups/spec.md index e8e5524..32cf971 100644 --- a/openspec/specs/scheduled-backups/spec.md +++ b/openspec/specs/scheduled-backups/spec.md @@ -9,6 +9,8 @@ Back up an instance's database, and optionally its filestore, on a systemd timer The tool SHALL install a systemd service and timer that back up an instance's database (via local `sudo -u postgres pg_dump`) and, optionally, its filestore, applying retention, on an operator-chosen schedule. +An instance whose database is remote SHALL be refused (the local dump would back up nothing of it), and so SHALL +a database the local server does not have. The script SHALL take every value shell-quoted, SHALL write private files (`umask 077`, directory `700`, unit `UMask=0077`), SHALL leave no partial file behind, and SHALL exit non-zero when the dump fails, when the dump has no readable table of contents (`pg_restore --list`), when the filestore directory is missing, or when the @@ -42,8 +44,9 @@ retention SHALL keep the newest backups by the timestamp in their names. ### Requirement: Inspect and remove a scheduled backup -The tool SHALL show the timer status and next run, and SHALL remove the schedule (disable the timer, delete the -units and script). +The tool SHALL show the timer status, its next run and the result of the last run (a timer that fires says +nothing about whether a backup was made), and SHALL remove the schedule (disable the timer, delete the units and +script). #### Scenario: Status shows the timer and next run diff --git a/openspec/specs/ui-localization/spec.md b/openspec/specs/ui-localization/spec.md index 085c89c..dd726a0 100644 --- a/openspec/specs/ui-localization/spec.md +++ b/openspec/specs/ui-localization/spec.md @@ -32,6 +32,12 @@ The tool SHALL treat English as the canonical source language (English string li - **WHEN** a menu is shown in Spanish - **THEN** each option is displayed translated but the value the tool acts on is the original English option, so behavior does not depend on the chosen language +#### Scenario: The catalog stays complete and current + +- **WHEN** the test suite runs +- **THEN** it fails on a UI string without a Spanish entry — including the labels of rows built in code (posture, + paths) — and on a catalog entry no code names any more + #### Scenario: Untranslated strings fall back to English - **WHEN** a UI string has no Spanish catalog entry diff --git a/pyproject.toml b/pyproject.toml index 250a204..827d399 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "odoo-instance-manager" version = "1.2.0" description = "Interactive, root-run CLI to install, maintain, and audit multi-instance Odoo Community servers on Debian/Ubuntu." readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.9" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] authors = [{ name = "Guillermo Rojo" }] @@ -16,13 +16,17 @@ classifiers = [ "Environment :: Console", "Intended Audience :: System Administrators", "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: System :: Systems Administration", ] dependencies = [] [project.optional-dependencies] -dev = ["pytest>=8", "ruff>=0.6"] +# ruff pinned: a new release adds rules, and CI must not change verdict on its own. +dev = ["pytest>=8", "ruff==0.16.8"] [project.scripts] odoo-instance-manager = "odoo_instance_manager:main" @@ -42,7 +46,8 @@ include = ["instance_manager*"] [tool.ruff] line-length = 100 -target-version = "py312" +# The manager runs on the host's python3: 3.9 is Debian 11's. +target-version = "py39" [tool.ruff.lint] # Value rules: real bugs (F, B), imports (I), modernization (UP), style (E, W). diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 119bf1e..4131d82 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -9,6 +9,7 @@ import builtins import contextlib import io +import re import unittest from instance_manager import prompts @@ -134,3 +135,48 @@ def test_the_catalog_is_authored_in_the_direction_it_is_read(self) -> None: spanish_only = {"á", "é", "í", "ó", "ú", "¿", "¡", "ñ"} suspicious = [k for k in i18n._ES if k != "Español" and any(ch in k for ch in spanish_only)] self.assertFalse(suspicious, f"these keys look Spanish: {suspicious}") + + +class BuiltRowsTests(unittest.TestCase): + """Rows built in code reach render_table, which translates each cell: every + fixed label must be a catalog key (values from the host are data).""" + + def test_posture_rows_and_paths_are_translatable(self) -> None: + from instance_manager import i18n, planners + from instance_manager.models import InstanceConfig + + data = {"^x$", "True", "5", "disable", "require", "0.12.6 (with patched qt)"} + cells: set[str] = set() + for conf in ({}, {"list_db": "True", "admin_passwd": "shop", "db_password": "shop", "workers": "0", + "db_host": "10.0.0.5", "db_sslmode": "disable", "proxy_mode": "False"}, + {"list_db": "False", "admin_passwd": "$x", "db_password": "y", "dbfilter": "^x$", + "workers": "5", "db_host": "10.0.0.5", "db_sslmode": "require", "proxy_mode": "True"}): + for version in (None, "0.12.6 (with patched qt)"): + for _state, check, detail in planners.posture_rows( + instance="shop", conf_values=conf, wkhtmltopdf_ver=version, cpu_count=None): + cells.update({check, detail}) + cells.update(label for label, _value in planners.pretty_paths(InstanceConfig(instance="shop"))) + # A cell built with tf() fills a catalog template. + templates = [re.compile(re.escape(key).replace(r"\{\}", ".*")) for key in i18n._ES if "{}" in key] + missing = sorted(cell for cell in cells - data + if cell not in i18n._ES and not any(t.fullmatch(cell) for t in templates)) + self.assertEqual(missing, []) + + +class DeadEntriesTests(unittest.TestCase): + def test_every_catalog_key_is_still_used(self) -> None: + # A key no code names any more is a translation nobody sees. + import ast + from pathlib import Path + + from instance_manager import i18n + + root = Path(__file__).resolve().parent.parent + literals: set[str] = set() + for path in [*root.joinpath("instance_manager").rglob("*.py"), root / "odoo_instance_manager.py"]: + if path.name == "i18n.py": + continue + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + literals.add(node.value) + self.assertEqual(sorted(key for key in i18n._ES if key not in literals), []) diff --git a/tests/test_robustness.py b/tests/test_robustness.py new file mode 100644 index 0000000..7f102a9 --- /dev/null +++ b/tests/test_robustness.py @@ -0,0 +1,57 @@ +"""Robustness: the entry point's arguments, the file picker, the health probe and +scheduled backups of a remote database.""" + +from __future__ import annotations + +import http.client +import io +import unittest +from contextlib import redirect_stdout +from unittest import mock + +import odoo_instance_manager +from instance_manager import prompts +from instance_manager.models import InstanceConfig +from instance_manager.workflows import health, scheduled_backup + + +class EntryPointTests(unittest.TestCase): + def test_help_and_version_need_no_root(self) -> None: + with mock.patch.object(odoo_instance_manager.os, "geteuid", return_value=1000): + for args in (["--help"], ["-h"], ["--version"]): + out = io.StringIO() + with redirect_stdout(out): + self.assertEqual(odoo_instance_manager.main(args), 0, args) + self.assertIn("odoo-instance-manager", out.getvalue()) + + def test_unknown_arguments_are_refused(self) -> None: + with mock.patch("sys.stderr", io.StringIO()): + self.assertEqual(odoo_instance_manager.main(["--purge-everything"]), 2) + + +class PickerTests(unittest.TestCase): + def test_an_out_of_range_number_is_not_a_path(self) -> None: + answers = iter(["99", "q"]) + with mock.patch("builtins.input", lambda *_: next(answers)), redirect_stdout(io.StringIO()): + self.assertEqual(prompts.select_file_path("/"), "") + + +class HealthProbeTests(unittest.TestCase): + def test_a_port_that_does_not_speak_http_is_not_a_crash(self) -> None: + with mock.patch.object(health.urllib.request, "urlopen", side_effect=http.client.BadStatusLine("x")): + ok, detail = health._http_probe("8069") + self.assertFalse(ok) + + +class ScheduledBackupTests(unittest.TestCase): + def test_a_remote_database_is_refused(self) -> None: + with mock.patch.object(scheduled_backup, "read_odoo_conf", return_value={"db_host": "db.example.com"}), \ + mock.patch.object(scheduled_backup, "ask_text") as ask, \ + mock.patch.object(scheduled_backup, "_execute_plan") as execute, redirect_stdout(io.StringIO()): + scheduled_backup._configure_schedule(InstanceConfig(instance="shop")) + ask.assert_not_called() + execute.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/verify_data_safety.py b/tools/verify_data_safety.py index 50b12d9..30b824a 100644 --- a/tools/verify_data_safety.py +++ b/tools/verify_data_safety.py @@ -16,6 +16,7 @@ import os import shutil +import signal import socket import stat import subprocess @@ -354,6 +355,33 @@ def _delete_without_nginx(root: Path) -> None: check("and when nginx -t fails", result.returncode == 0, result.stderr) +def _streaming_interrupt(root: Path) -> None: + """Ctrl+C during a step stops the whole step — bash and what it started — and + waits for it, so nothing keeps running behind the menu.""" + marker = root / "still-running" + repo = str(Path(__file__).resolve().parent.parent) + child = ( + f"import sys; sys.path.insert(0, {repo!r})\n" + "from instance_manager import system\n" + "try:\n" + f" system.run_streaming('sleep 30 & sleep 30; touch {marker}')\n" + "except KeyboardInterrupt:\n" + " print('interrupted', flush=True)\n" + ) + process = subprocess.Popen([sys.executable, "-c", child], stdout=subprocess.PIPE, text=True) + time.sleep(1.5) + process.send_signal(signal.SIGINT) + try: + out, _ = process.communicate(timeout=20) + except subprocess.TimeoutExpired: + process.kill() + out = "" + processes = subprocess.run(["ps", "-eo", "pid,args"], capture_output=True, text=True).stdout + left = [line for line in processes.splitlines() if line.split(None, 1)[-1].startswith("sleep 30")] + check("Ctrl+C during a step returns to the caller", "interrupted" in out, out) + check("and leaves none of the step's processes running", not left and not marker.exists(), str(left)) + + # --- against a PostgreSQL of our own ------------------------------------------ @@ -410,6 +438,7 @@ def _against_a_server(root: Path) -> None: print("skip PostgreSQL binaries not found — the server-backed checks need initdb") return if os.geteuid() == 0: + check("not running as root (initdb refuses root)", not os.environ.get("CI")) print("skip running as root — initdb refuses, so the server-backed checks are skipped") return stubs = root / "pgstubs" @@ -500,6 +529,8 @@ def main() -> int: _install_cleanup(root / "cleanup") _purge_user(root / "purge") _delete_without_nginx(root / "delete") + (root / "stream").mkdir() + _streaming_interrupt(root / "stream") _against_a_server(root / "pg") print(f"\n{len(FAILURES)} failure(s)" if FAILURES else "\nall checks passed") return 1 if FAILURES else 0 diff --git a/tools/verify_install_runtime.py b/tools/verify_install_runtime.py index 7bc1c05..f3f2495 100644 --- a/tools/verify_install_runtime.py +++ b/tools/verify_install_runtime.py @@ -120,7 +120,10 @@ def _venv(root: Path, major: int, clone: Path, env: dict[str, str]) -> None: subprocess.run([str(pip), "install", "-q", "--upgrade", "setuptools"], capture_output=True, env=env) found = subprocess.run([str(venv_python), "-c", "import importlib.metadata as m; print(m.version('setuptools'))"], capture_output=True, text=True, env=env).stdout.strip() - if int(found.split(".")[0] or 0) < 82: + if not found: + check(f"Odoo {major}: the venv's setuptools version can be read", False) + return + if int(found.split(".")[0]) < 82: # pip resolves the newest setuptools the interpreter supports: on # 3.8 that is 75.x, which still ships pkg_resources. print(f"info Odoo {major}: unpinned setuptools on Python {python} is {found}, " @@ -141,6 +144,9 @@ def main() -> int: for item in args.clone: major, _, path = item.partition("=") clones[int(major)] = Path(path).resolve() + if not clones: + print("error: give at least one --clone MAJOR=PATH; nothing was verified", file=sys.stderr) + return 2 with tempfile.TemporaryDirectory(prefix="oim-verify-runtime-") as tmp: root = Path(tmp) diff --git a/tools/verify_neutralisation.py b/tools/verify_neutralisation.py index aea73d5..460ca07 100644 --- a/tools/verify_neutralisation.py +++ b/tools/verify_neutralisation.py @@ -194,6 +194,10 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--odoo", action="append", default=[], metavar="MAJOR=PYTHON:CHECKOUT") args = parser.parse_args() + if not args.odoo: + # Nothing to run is not a pass. + print("error: give at least one --odoo MAJOR=PYTHON:CHECKOUT; nothing was verified", file=sys.stderr) + return 2 bindir = _postgres_bindir() if bindir is None or os.geteuid() == 0: print("skip needs initdb and a non-root user") diff --git a/tools/verify_ops_configs.py b/tools/verify_ops_configs.py index 8288450..3645b19 100644 --- a/tools/verify_ops_configs.py +++ b/tools/verify_ops_configs.py @@ -85,7 +85,12 @@ def _nginx_binary(root: Path, given: str | None) -> Path | None: return None _run(["dpkg-deb", "-x", str(debs[0]), str(root / "pkg")]) binary = root / "pkg" / "usr" / "sbin" / "nginx" - return binary if binary.exists() else None + if not binary.exists(): + # Some releases split the binary into nginx-core / nginx-common. + check("the nginx package holds the nginx binary", not os.environ.get("CI"), str(debs[0])) + print(f"skip {debs[0].name} has no usr/sbin/nginx") + return None + return binary def _nginx_version(binary: Path) -> tuple[int, int, int] | None: