[ADD] make: add SSH remote deployment targets and todo.py integration

Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.

- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
  ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
  ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
  SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py

Generated by Claude Code 2.1.101 model claude-sonnet-4-6

Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
This commit is contained in:
Mathieu Benoit 2026-04-10 22:53:07 -04:00
parent cd14e128ff
commit e03befb28e
4 changed files with 418 additions and 0 deletions

View file

@ -47,6 +47,7 @@ endif
-include ./conf/make.installation.Makefile
-include ./conf/make.installation.poetry.Makefile
-include ./conf/make.test.Makefile
-include ./conf/make.ssh.Makefile
-include ./conf/make.todo.Makefile
# Include private Makefile

130
conf/make.ssh.Makefile Normal file
View file

@ -0,0 +1,130 @@
#######
# SSH #
#######
# Connection variables (override on command line or via environment)
SSH_HOST ?=
SSH_USER ?= erplibre
SSH_PORT ?= 22
SSH_KEY ?=
SSH_PATH ?= ~/erplibre_deploy_2
# Target to run remotely via ssh_make
SSH_TARGET ?= run
# Build SSH/rsync options from variables
_SSH_KEY_OPT = $(if $(SSH_KEY),-i $(SSH_KEY),)
_SSH_CMD = ssh -p $(SSH_PORT) -o StrictHostKeyChecking=accept-new $(_SSH_KEY_OPT)
_RSYNC_SSH = ssh -p $(SSH_PORT) -o StrictHostKeyChecking=accept-new $(_SSH_KEY_OPT)
define _require_host
@test -n "$(SSH_HOST)" || \
(echo "Error: SSH_HOST is required. Usage: make $@ SSH_HOST=hostname [SSH_USER=erplibre] [SSH_PORT=22] [SSH_KEY=~/.ssh/id_rsa]" && exit 1)
endef
# Test SSH connectivity
.PHONY: ssh_check
ssh_check:
$(call _require_host)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "echo 'SSH connection to $(SSH_HOST) OK && uname -a'"
# Sync project files to remote (excludes venvs, addons, odoo sources, git)
.PHONY: ssh_push
ssh_push:
$(call _require_host)
rsync -avz --delete \
--exclude='.venv.*/' \
--exclude='addons/' \
--exclude='odoo12.0/' \
--exclude='odoo13.0/' \
--exclude='odoo14.0/' \
--exclude='odoo15.0/' \
--exclude='odoo16.0/' \
--exclude='odoo17.0/' \
--exclude='odoo18.0/' \
--exclude='.git/' \
--exclude='private/' \
--exclude='*.pyc' \
--exclude='__pycache__/' \
-e "$(_RSYNC_SSH)" \
./ $(SSH_USER)@$(SSH_HOST):$(SSH_PATH)/
# Install ERPLibre on remote server (Odoo 18 default)
.PHONY: ssh_install
ssh_install:
$(call _require_host)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_PATH) && make install_odoo_18"
# Start Odoo on remote server
.PHONY: ssh_run
ssh_run:
$(call _require_host)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_PATH) && make run"
# Stop Odoo on remote server (via systemd if available, else kill process)
.PHONY: ssh_stop
ssh_stop:
$(call _require_host)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
"systemctl --user stop erplibre 2>/dev/null || \
sudo systemctl stop erplibre 2>/dev/null || \
cd $(SSH_PATH) && make process_kill_odoo"
# Restart Odoo on remote server
.PHONY: ssh_restart
ssh_restart:
$(call _require_host)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
"systemctl --user restart erplibre 2>/dev/null || \
sudo systemctl restart erplibre 2>/dev/null || \
(cd $(SSH_PATH) && make process_kill_odoo && make run)"
# Show service status on remote server
.PHONY: ssh_status
ssh_status:
$(call _require_host)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
"systemctl --user status erplibre 2>/dev/null || \
sudo systemctl status erplibre 2>/dev/null || \
ps aux | grep odoo | grep -v grep"
# Stream logs from remote server
.PHONY: ssh_logs
ssh_logs:
$(call _require_host)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
"journalctl --user -u erplibre -f 2>/dev/null || \
sudo journalctl -u erplibre -f"
# Execute any make target on remote server (e.g.: make ssh_make SSH_HOST=host SSH_TARGET=db_create_db_test)
.PHONY: ssh_make
ssh_make:
$(call _require_host)
@test -n "$(SSH_TARGET)" || \
(echo "Error: SSH_TARGET is required. Usage: make ssh_make SSH_HOST=hostname SSH_TARGET=make_target" && exit 1)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_PATH) && make $(SSH_TARGET)"
# Install systemd service on remote server
.PHONY: ssh_install_systemd
ssh_install_systemd:
$(call _require_host)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
"cd $(SSH_PATH) && python3 script/systemd/install_daemon.py \
--user $(SSH_USER) \
--home-erplibre $(SSH_PATH) \
--port 8069"
# Configure nginx + SSL on remote server (requires SSH_DOMAIN)
SSH_DOMAIN ?=
SSH_ADMIN_EMAIL ?=
.PHONY: ssh_install_nginx
ssh_install_nginx:
$(call _require_host)
@test -n "$(SSH_DOMAIN)" || \
(echo "Error: SSH_DOMAIN is required. Usage: make ssh_install_nginx SSH_HOST=hostname SSH_DOMAIN=example.com [SSH_ADMIN_EMAIL=admin@example.com]" && exit 1)
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
"cd $(SSH_PATH) && sudo python3 script/nginx/deploy_nginx_and_certbot.py \
--generate_nginx \
--run_certbot \
--domain $(SSH_DOMAIN) \
$(if $(SSH_ADMIN_EMAIL),--admin_email $(SSH_ADMIN_EMAIL),)"

View file

@ -651,6 +651,17 @@ class TODO:
choices = [
{"prompt_description": t("Clone ERPLibre locally (git clone)")},
{"prompt_description": t("Configure sshfs")},
{"prompt_description": t("SSH - Check connection")},
{"prompt_description": t("SSH - Sync files (rsync)")},
{"prompt_description": t("SSH - Install ERPLibre")},
{"prompt_description": t("SSH - Start Odoo")},
{"prompt_description": t("SSH - Stop Odoo")},
{"prompt_description": t("SSH - Restart Odoo")},
{"prompt_description": t("SSH - Service status")},
{"prompt_description": t("SSH - View logs")},
{"prompt_description": t("SSH - Run make target")},
{"prompt_description": t("SSH - Install systemd service")},
{"prompt_description": t("SSH - Configure nginx + SSL")},
]
help_info = self.fill_help_info(choices)
@ -663,6 +674,28 @@ class TODO:
self._deploy_clone_erplibre()
elif status == "2":
self._configure_sshfs()
elif status == "3":
self._deploy_ssh_check()
elif status == "4":
self._deploy_ssh_push()
elif status == "5":
self._deploy_ssh_install()
elif status == "6":
self._deploy_ssh_run()
elif status == "7":
self._deploy_ssh_stop()
elif status == "8":
self._deploy_ssh_restart()
elif status == "9":
self._deploy_ssh_status()
elif status == "10":
self._deploy_ssh_logs()
elif status == "11":
self._deploy_ssh_make()
elif status == "12":
self._deploy_ssh_install_systemd()
elif status == "13":
self._deploy_ssh_install_nginx()
else:
print(t("Command not found !"))
@ -793,6 +826,171 @@ class TODO:
except Exception as e:
print(f"{t('Error mounting sshfs: ')}{e}")
def _get_ssh_params(self):
"""Prompt for SSH connection parameters. Returns dict or None on cancel."""
host = click.prompt(t("Remote host (user@hostname or hostname): ")).strip()
if not host:
print(t("SSH host is required!"))
return None
user = (
click.prompt(t("SSH user (default: erplibre): ")).strip()
or "erplibre"
)
port = click.prompt(t("SSH port (default: 22): ")).strip() or "22"
key = (
click.prompt(
t("SSH key path (default: ~/.ssh/id_rsa, empty for none): ")
).strip()
)
path = (
click.prompt(t("Remote path (default: ~/erplibre_deploy_2): ")).strip()
or "~/erplibre_deploy_2"
)
return {
"SSH_HOST": host,
"SSH_USER": user,
"SSH_PORT": port,
"SSH_KEY": key,
"SSH_PATH": path,
}
def _build_ssh_make_cmd(self, target, params, extra=None):
"""Build a make SSH command string from params dict."""
parts = [f"make {target}"]
for k, v in params.items():
if v:
parts.append(f'{k}="{v}"')
if extra:
for k, v in extra.items():
if v:
parts.append(f'{k}="{v}"')
return " ".join(parts)
def _deploy_ssh_check(self):
params = self._get_ssh_params()
if not params:
return
cmd = self._build_ssh_make_cmd("ssh_check", params)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_push(self):
params = self._get_ssh_params()
if not params:
return
cmd = self._build_ssh_make_cmd("ssh_push", params)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_install(self):
params = self._get_ssh_params()
if not params:
return
cmd = self._build_ssh_make_cmd("ssh_install", params)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_run(self):
params = self._get_ssh_params()
if not params:
return
cmd = self._build_ssh_make_cmd("ssh_run", params)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_stop(self):
params = self._get_ssh_params()
if not params:
return
cmd = self._build_ssh_make_cmd("ssh_stop", params)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_restart(self):
params = self._get_ssh_params()
if not params:
return
cmd = self._build_ssh_make_cmd("ssh_restart", params)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_status(self):
params = self._get_ssh_params()
if not params:
return
cmd = self._build_ssh_make_cmd("ssh_status", params)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_logs(self):
params = self._get_ssh_params()
if not params:
return
cmd = self._build_ssh_make_cmd("ssh_logs", params)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_make(self):
params = self._get_ssh_params()
if not params:
return
target = click.prompt(t("Make target to run remotely: ")).strip()
if not target:
print(t("SSH host is required!"))
return
cmd = self._build_ssh_make_cmd(
"ssh_make", params, extra={"SSH_TARGET": target}
)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_install_systemd(self):
params = self._get_ssh_params()
if not params:
return
cmd = self._build_ssh_make_cmd("ssh_install_systemd", params)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def _deploy_ssh_install_nginx(self):
params = self._get_ssh_params()
if not params:
return
domain = click.prompt(t("Domain name (e.g.: example.com): ")).strip()
if not domain:
print(t("SSH host is required!"))
return
email = click.prompt(t("Admin email for SSL certificate: ")).strip()
cmd = self._build_ssh_make_cmd(
"ssh_install_nginx",
params,
extra={"SSH_DOMAIN": domain, "SSH_ADMIN_EMAIL": email},
)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(
cmd, source_erplibre=False, single_source_erplibre=True
)
def prompt_execute_code(self):
print(f"🤖 {t('What do you need for development?')}")
# help_info = """Commande :

View file

@ -722,6 +722,95 @@ TRANSLATIONS = {
"fr": "Erreur lors de l'ajout de l'automatisation : ",
"en": "Error adding automation: ",
},
# SSH Deploy section
"Deploy - Deploy ERPLibre via SSH": {
"fr": "Déploiement - Déployer ERPLibre via SSH",
"en": "Deploy - Deploy ERPLibre via SSH",
},
"SSH deployment tools!": {
"fr": "Outils de déploiement SSH!",
"en": "SSH deployment tools!",
},
"SSH - Check connection": {
"fr": "SSH - Vérifier la connexion",
"en": "SSH - Check connection",
},
"SSH - Sync files (rsync)": {
"fr": "SSH - Synchroniser les fichiers (rsync)",
"en": "SSH - Sync files (rsync)",
},
"SSH - Install ERPLibre": {
"fr": "SSH - Installer ERPLibre",
"en": "SSH - Install ERPLibre",
},
"SSH - Start Odoo": {
"fr": "SSH - Démarrer Odoo",
"en": "SSH - Start Odoo",
},
"SSH - Stop Odoo": {
"fr": "SSH - Arrêter Odoo",
"en": "SSH - Stop Odoo",
},
"SSH - Restart Odoo": {
"fr": "SSH - Redémarrer Odoo",
"en": "SSH - Restart Odoo",
},
"SSH - Service status": {
"fr": "SSH - Statut du service",
"en": "SSH - Service status",
},
"SSH - View logs": {
"fr": "SSH - Voir les logs",
"en": "SSH - View logs",
},
"SSH - Run make target": {
"fr": "SSH - Exécuter une cible make",
"en": "SSH - Run make target",
},
"SSH - Install systemd service": {
"fr": "SSH - Installer le service systemd",
"en": "SSH - Install systemd service",
},
"SSH - Configure nginx + SSL": {
"fr": "SSH - Configurer nginx + SSL",
"en": "SSH - Configure nginx + SSL",
},
"Remote host (user@hostname or hostname): ": {
"fr": "Hôte distant (user@hostname ou hostname) : ",
"en": "Remote host (user@hostname or hostname): ",
},
"SSH user (default: erplibre): ": {
"fr": "Utilisateur SSH (défaut: erplibre) : ",
"en": "SSH user (default: erplibre): ",
},
"SSH port (default: 22): ": {
"fr": "Port SSH (défaut: 22) : ",
"en": "SSH port (default: 22): ",
},
"SSH key path (default: ~/.ssh/id_rsa, empty for none): ": {
"fr": "Chemin de la clé SSH (défaut: ~/.ssh/id_rsa, vide pour aucune) : ",
"en": "SSH key path (default: ~/.ssh/id_rsa, empty for none): ",
},
"Remote path (default: ~/erplibre_deploy_2): ": {
"fr": "Chemin distant (défaut: ~/erplibre_deploy_2) : ",
"en": "Remote path (default: ~/erplibre_deploy_2): ",
},
"Make target to run remotely: ": {
"fr": "Cible make à exécuter à distance : ",
"en": "Make target to run remotely: ",
},
"Domain name (e.g.: example.com): ": {
"fr": "Nom de domaine (ex: example.com) : ",
"en": "Domain name (e.g.: example.com): ",
},
"Admin email for SSL certificate: ": {
"fr": "Email administrateur pour le certificat SSL : ",
"en": "Admin email for SSL certificate: ",
},
"SSH host is required!": {
"fr": "L'hôte SSH est requis!",
"en": "SSH host is required!",
},
# Language selection
"Choose language / Choisir la langue": {
"fr": "Choisir la langue / Choose language",