5 Commits

Author SHA1 Message Date
e48a3443f1 [IMP] maintenance_service_http_monitoring : modify logic to avoid false-positive http alerts
Some checks failed
pre-commit / pre-commit (pull_request) Has been cancelled
2026-08-18 18:07:26 +02:00
Stéphan Sainléger
6e19575012 [ADD] maintenance_user_ssh_key: create add-on 2026-07-07 12:01:42 +02:00
Stéphan Sainléger
ae5977ef81 [IMP] maintenance_server_data: add unique constraint rule on some equipement fields 2026-07-07 10:41:58 +02:00
Stéphan Sainléger
4581b3d019 [FIX] maintenance_server_data: add code protection if fr_FR language not installed 2026-07-07 10:41:58 +02:00
Stéphan Sainléger
bb6f12945f [IMP] maintenance_server_data: add ssh_target field in equipments 2026-07-07 10:41:45 +02:00
18 changed files with 341 additions and 90 deletions

View File

@@ -7,11 +7,11 @@ class MaintenanceEquipment(models.Model):
_sql_constraints = [ _sql_constraints = [
('unique_name', 'UNIQUE(name)', 'Name must be unique.'), ('unique_name', 'UNIQUE(name)', 'Name must be unique.'),
('unique_server_ip', 'UNIQUE(server_ip)', 'Server IP must be unique.'), ('unique_server_ip', 'UNIQUE(server_ip)', 'Server IP must be unique.'),
('unique_main_domain_name', 'UNIQUE(main_domain_name)', 'Main Domain Name must be unique.'), ('unique_ssh_target', 'UNIQUE(ssh_target)', 'Main Domain Name must be unique.'),
] ]
server_ip = fields.Char("Server Ip Address") server_ip = fields.Char("Server Ip Address")
main_domain_name = fields.Char("Main Domain Name", ) ssh_target = fields.Char("SSH Target", )
distribution_id = fields.Many2one("os.distribution", string="Distribution") distribution_id = fields.Many2one("os.distribution", string="Distribution")
service_ids = fields.One2many("service.instance", "equipment_id", string="Services") service_ids = fields.One2many("service.instance", "equipment_id", string="Services")
hosting_city = fields.Char("Hosting City") hosting_city = fields.Char("Hosting City")
@@ -28,8 +28,8 @@ class MaintenanceEquipment(models.Model):
default = dict(default or {}) default = dict(default or {})
if "server_ip" not in default: if "server_ip" not in default:
default["server_ip"] = False default["server_ip"] = False
if "main_domain_name" not in default: if "ssh_target" not in default:
default["main_domain_name"] = False default["ssh_target"] = False
vals_list = super().copy_data(default=default) vals_list = super().copy_data(default=default)
if "name" not in default: if "name" not in default:
for equipment, vals in zip(self, vals_list): for equipment, vals in zip(self, vals_list):

View File

@@ -8,7 +8,7 @@
<xpath expr="//field[@name='effective_date']/.." position="after"> <xpath expr="//field[@name='effective_date']/.." position="after">
<group name="server_data" string="Server data"> <group name="server_data" string="Server data">
<field name="server_ip" /> <field name="server_ip" />
<field name="main_domain_name" /> <field name="ssh_target" />
<field name="hosting_city" /> <field name="hosting_city" />
<field name="distribution_id" /> <field name="distribution_id" />
<field name="nb_cores" /> <field name="nb_cores" />
@@ -46,7 +46,7 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after"> <xpath expr="//field[@name='category_id']" position="after">
<field name="server_ip" optional="hide" /> <field name="server_ip" optional="hide" />
<field name="main_domain_name" optional="hide" /> <field name="ssh_target" optional="hide" />
<field name="hosting_city" optional="hide" /> <field name="hosting_city" optional="hide" />
<field name="distribution_id" optional="hide" /> <field name="distribution_id" optional="hide" />
<field name="nb_cores" optional="hide" /> <field name="nb_cores" optional="hide" />

View File

@@ -0,0 +1,2 @@
*.*~
*pyc

View File

@@ -5,7 +5,7 @@
"license": "AGPL-3", "license": "AGPL-3",
"category": "Tools", "category": "Tools",
"summary": "Monitor HTTP availability of services", "summary": "Monitor HTTP availability of services",
"depends": ["base", "maintenance", "hr_maintenance", "maintenance_server_data"], "depends": ["base", "maintenance", "base_maintenance", "hr_maintenance", "maintenance_server_data"],
"external_dependencies": {"python": ["requests"]}, "external_dependencies": {"python": ["requests"]},
"data": [ "data": [
"data/ir_config_parameter.xml", "data/ir_config_parameter.xml",

View File

@@ -5,7 +5,7 @@
<field name="model_id" ref="maintenance_server_data.model_service_instance" /> <field name="model_id" ref="maintenance_server_data.model_service_instance" />
<field name="state">code</field> <field name="state">code</field>
<field name="code">model.cron_check_http_services()</field> <field name="code">model.cron_check_http_services()</field>
<field name="interval_number">15</field> <field name="interval_number">10</field>
<field name="interval_type">minutes</field> <field name="interval_type">minutes</field>
</record> </record>
<record id="ir_cron_maintenance_mode_expiry" model="ir.cron"> <record id="ir_cron_maintenance_mode_expiry" model="ir.cron">

View File

@@ -1,5 +1,5 @@
import logging import logging
import time from datetime import timedelta
from odoo import api, fields, models from odoo import api, fields, models
@@ -10,8 +10,8 @@ except ImportError:
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
HTTP_CHECK_TIMEOUT = 10 # seconds HTTP_CHECK_TIMEOUT = 20 # seconds
HTTP_RETRY_DELAY = 2 # seconds between pass 1 and pass 2 HTTP_KO_CONFIRMATION_DELAY = timedelta(minutes=5)
class ServiceInstance(models.Model): class ServiceInstance(models.Model):
@@ -36,14 +36,20 @@ class ServiceInstance(models.Model):
string="HTTP Maintenance Request", string="HTTP Maintenance Request",
readonly=True, readonly=True,
) )
http_first_ko_at = fields.Datetime(
string="First HTTP KO Check (current streak)",
readonly=True,
)
def check_http_status(self): def check_http_status(self):
""" """
Perform HTTP check for each record and return the KO recordset. Perform HTTP check for each record and return the KO recordset.
Writes last_http_status_code, last_http_check_date and http_status_ok on every Writes last_http_status_code, last_http_check_date and http_status_ok on every
checked record. Does NOT create maintenance.request — that decision belongs to checked record. Does NOT create maintenance.request — the cron only opens one
the caller (cron) after optional retry logic. once the service has been continuously KO for at least
HTTP_KO_CONFIRMATION_DELAY, to avoid flagging transient outages (e.g. a short
server overload) as real incidents.
""" """
ko_records = self.browse() ko_records = self.browse()
for rec in self: for rec in self:
@@ -63,13 +69,16 @@ class ServiceInstance(models.Model):
status_ok = status_code == 200 status_ok = status_code == 200
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
_logger.warning("HTTP check failed for %s: %s", rec.service_url, e) _logger.warning("HTTP check failed for %s: %s", rec.service_url, e)
rec.write( vals = {
{ "last_http_status_code": status_code,
"last_http_status_code": status_code, "last_http_check_date": now,
"last_http_check_date": now, "http_status_ok": status_ok,
"http_status_ok": status_ok, }
} if status_ok:
) vals["http_first_ko_at"] = False
elif not rec.http_first_ko_at:
vals["http_first_ko_at"] = now
rec.write(vals)
if not status_ok: if not status_ok:
ko_records |= rec ko_records |= rec
return ko_records return ko_records
@@ -106,13 +115,12 @@ class ServiceInstance(models.Model):
@api.model @api.model
def cron_check_http_services(self): def cron_check_http_services(self):
""" """
Check all active services with a URL, with one retry on failure. Check all active services with a URL.
Pass 1: test every eligible service. A service must be continuously KO for at least HTTP_KO_CONFIRMATION_DELAY
- Services that had an open request and are now OK are auto-resolved. before a maintenance.request is created — this tolerates transient outages
- Services still KO after pass 1 are retested after HTTP_RETRY_DELAY seconds. (e.g. a temporary server overload) regardless of how often this cron runs.
maintenance.request is created only for services that fail both passes, Services that had an open request and are now OK are auto-resolved.
reducing noise from transient HTTP errors.
""" """
domain = [ domain = [
("active", "=", True), ("active", "=", True),
@@ -123,25 +131,22 @@ class ServiceInstance(models.Model):
lambda s: not s.equipment_id.maintenance_mode lambda s: not s.equipment_id.maintenance_mode
) )
# Snapshot services that currently have an open request before pass 1 # Snapshot services that currently have an open request before the check
services_with_open_request = services.filtered( services_with_open_request = services.filtered(
lambda s: s.http_maintenance_request lambda s: s.http_maintenance_request
and not s.http_maintenance_request.stage_id.done and not s.http_maintenance_request.stage_id.done
) )
ko_after_pass1 = services.check_http_status() ko_services = services.check_http_status()
# Auto-resolve services that recovered during pass 1 # Auto-resolve services that recovered
recovered = services_with_open_request.filtered(lambda s: s.http_status_ok) recovered = services_with_open_request.filtered(lambda s: s.http_status_ok)
if recovered: if recovered:
recovered._close_http_maintenance_request() recovered._close_http_maintenance_request()
if not ko_after_pass1: confirmed_ko = ko_services.filtered(
return lambda s: s.last_http_check_date - s.http_first_ko_at
>= HTTP_KO_CONFIRMATION_DELAY
time.sleep(HTTP_RETRY_DELAY) )
for service in confirmed_ko:
ko_confirmed = ko_after_pass1.check_http_status()
for service in ko_confirmed:
service.equipment_id.create_http_maintenance_request(service) service.equipment_id.create_http_maintenance_request(service)

View File

@@ -11,9 +11,9 @@ EQUIPMENT_HTTP_REQUESTS = (
"odoo.addons.maintenance_service_http_monitoring" "odoo.addons.maintenance_service_http_monitoring"
".models.maintenance_equipment.http_requests" ".models.maintenance_equipment.http_requests"
) )
SERVICE_INSTANCE_SLEEP = (
"odoo.addons.maintenance_service_http_monitoring.models.service_instance.time.sleep" # Comfortably past HTTP_KO_CONFIRMATION_DELAY (currently 10 minutes).
) PAST_CONFIRMATION_DELAY = timedelta(minutes=20)
def _mock_response(status_code): def _mock_response(status_code):
@@ -41,6 +41,13 @@ class TestHttpMonitoring(TransactionCase):
} }
) )
def _backdate_first_ko(self, *service_instances):
"""Simulate that the current KO streak started long enough ago to be confirmed."""
for service_instance in service_instances:
service_instance.write(
{"http_first_ko_at": fields.Datetime.now() - PAST_CONFIRMATION_DELAY}
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Test 1 -- HTTP 200 -> service marked OK # Test 1 -- HTTP 200 -> service marked OK
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -55,19 +62,26 @@ class TestHttpMonitoring(TransactionCase):
self.assertIsNotNone(self.service_instance.last_http_check_date) self.assertIsNotNone(self.service_instance.last_http_check_date)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Test 2 -- Two KO passes -> maintenance.request created on the service # Test 2 -- KO confirmed only once continuously KO for HTTP_KO_CONFIRMATION_DELAY,
# not on the first observed failure
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def test_http_500_creates_maintenance_request(self): def test_http_500_creates_request_after_confirmation_delay(self):
with ( with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests,
patch(SERVICE_INSTANCE_SLEEP),
):
mock_requests.get.return_value = _mock_response(500) mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() self.env["service.instance"].cron_check_http_services()
self.assertFalse(self.service_instance.http_status_ok) self.assertFalse(self.service_instance.http_status_ok)
self.assertEqual(self.service_instance.last_http_status_code, 500) self.assertEqual(self.service_instance.last_http_status_code, 500)
self.assertTrue(self.service_instance.http_first_ko_at)
self.assertFalse(self.service_instance.http_maintenance_request)
self._backdate_first_ko(self.service_instance)
with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services()
request = self.service_instance.http_maintenance_request request = self.service_instance.http_maintenance_request
self.assertTrue(request) self.assertTrue(request)
@@ -90,27 +104,29 @@ class TestHttpMonitoring(TransactionCase):
self.assertEqual(self.service_instance.last_http_status_code, -1) self.assertEqual(self.service_instance.last_http_status_code, -1)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Test 4 -- Two consecutive cron runs KO -> no duplicate request # Test 4 -- Repeated failure after confirmation -> a single request, no duplicate
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def test_no_duplicate_request_on_repeated_failure(self): def test_no_duplicate_request_on_repeated_failure(self):
with ( with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests,
patch(SERVICE_INSTANCE_SLEEP),
):
mock_requests.get.return_value = _mock_response(500) mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() self.env["service.instance"].cron_check_http_services() # first_ko_at set
self.assertFalse(self.service_instance.http_maintenance_request)
self._backdate_first_ko(self.service_instance)
with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() # confirmed, request created
request_1 = self.service_instance.http_maintenance_request request_1 = self.service_instance.http_maintenance_request
self.assertTrue(request_1) self.assertTrue(request_1)
with ( with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests,
patch(SERVICE_INSTANCE_SLEEP),
):
mock_requests.get.return_value = _mock_response(500) mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() self.env["service.instance"].cron_check_http_services() # still KO
self.assertEqual(self.service_instance.http_maintenance_request, request_1) self.assertEqual(self.service_instance.http_maintenance_request, request_1)
self.assertEqual( self.assertEqual(
@@ -181,7 +197,8 @@ class TestHttpMonitoring(TransactionCase):
self.assertEqual(self.service_instance.last_http_status_code, 404) self.assertEqual(self.service_instance.last_http_status_code, 404)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Test 9 -- Webhook called when a new maintenance.request is created # Test 9 -- Webhook called only once the request is actually created
# (after confirmation delay), not on the first observed failure
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def test_webhook_called_on_new_request(self): def test_webhook_called_on_new_request(self):
self.env["ir.config_parameter"].sudo().set_param( self.env["ir.config_parameter"].sudo().set_param(
@@ -190,7 +207,17 @@ class TestHttpMonitoring(TransactionCase):
) )
with ( with (
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, patch(SERVICE_INSTANCE_REQUESTS) as mock_requests,
patch(SERVICE_INSTANCE_SLEEP), patch(EQUIPMENT_HTTP_REQUESTS) as mock_http,
):
mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services()
mock_http.post.assert_not_called()
self._backdate_first_ko(self.service_instance)
with (
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests,
patch(EQUIPMENT_HTTP_REQUESTS) as mock_http, patch(EQUIPMENT_HTTP_REQUESTS) as mock_http,
): ):
mock_requests.get.return_value = _mock_response(500) mock_requests.get.return_value = _mock_response(500)
@@ -209,15 +236,22 @@ class TestHttpMonitoring(TransactionCase):
self.env["ir.config_parameter"].sudo().set_param( self.env["ir.config_parameter"].sudo().set_param(
"maintenance_service_http_monitoring.webhook_url", "" "maintenance_service_http_monitoring.webhook_url", ""
) )
with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services()
self._backdate_first_ko(self.service_instance)
with ( with (
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, patch(SERVICE_INSTANCE_REQUESTS) as mock_requests,
patch(SERVICE_INSTANCE_SLEEP),
patch(EQUIPMENT_HTTP_REQUESTS) as mock_http, patch(EQUIPMENT_HTTP_REQUESTS) as mock_http,
): ):
mock_requests.get.return_value = _mock_response(500) mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() self.env["service.instance"].cron_check_http_services()
self.assertTrue(self.service_instance.http_maintenance_request)
mock_http.post.assert_not_called() mock_http.post.assert_not_called()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -235,22 +269,25 @@ class TestHttpMonitoring(TransactionCase):
self.assertFalse(self.service_instance.last_http_check_date) self.assertFalse(self.service_instance.last_http_check_date)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Test 12 -- Transient failure (KO pass 1, OK pass 2) -> no request created # Test 12 -- Transient failure (KO, then back OK before confirmation) ->
# no request created, and the KO streak is reset
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def test_transient_failure_no_request_created(self): def test_transient_failure_no_request_created(self):
with ( with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, mock_requests.get.return_value = _mock_response(500)
patch(SERVICE_INSTANCE_SLEEP), mock_requests.exceptions.RequestException = Exception
): self.env["service.instance"].cron_check_http_services()
mock_requests.get.side_effect = [
_mock_response(500), # pass 1: KO self.assertTrue(self.service_instance.http_first_ko_at)
_mock_response(200), # pass 2 (retry): OK
] with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
mock_requests.get.return_value = _mock_response(200)
mock_requests.exceptions.RequestException = Exception mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() self.env["service.instance"].cron_check_http_services()
self.assertTrue(self.service_instance.http_status_ok) self.assertTrue(self.service_instance.http_status_ok)
self.assertEqual(self.service_instance.last_http_status_code, 200) self.assertEqual(self.service_instance.last_http_status_code, 200)
self.assertFalse(self.service_instance.http_first_ko_at)
self.assertFalse(self.service_instance.http_maintenance_request) self.assertFalse(self.service_instance.http_maintenance_request)
self.assertEqual( self.assertEqual(
self.env["maintenance.request"].search_count( self.env["maintenance.request"].search_count(
@@ -260,25 +297,33 @@ class TestHttpMonitoring(TransactionCase):
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Test 13 -- Confirmed failure (KO pass 1 and 2) -> request created # Test 13 -- Confirmed failure (KO continuously past the confirmation delay)
# -> request created
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def test_confirmed_failure_creates_request(self): def test_confirmed_failure_creates_request(self):
with ( with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests,
patch(SERVICE_INSTANCE_SLEEP) as mock_sleep,
):
mock_requests.get.return_value = _mock_response(503) mock_requests.get.return_value = _mock_response(503)
mock_requests.exceptions.RequestException = Exception mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() self.env["service.instance"].cron_check_http_services()
mock_sleep.assert_called_once_with(2) self.assertEqual(mock_requests.get.call_count, 1)
self.assertEqual(mock_requests.get.call_count, 2) self.assertFalse(self.service_instance.http_maintenance_request)
self._backdate_first_ko(self.service_instance)
with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
mock_requests.get.return_value = _mock_response(503)
mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services()
self.assertEqual(mock_requests.get.call_count, 1)
self.assertFalse(self.service_instance.http_status_ok) self.assertFalse(self.service_instance.http_status_ok)
self.assertEqual(self.service_instance.last_http_status_code, 503) self.assertEqual(self.service_instance.last_http_status_code, 503)
self.assertTrue(self.service_instance.http_maintenance_request) self.assertTrue(self.service_instance.http_maintenance_request)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Test 14 -- 2 KO services on same equipment -> 2 distinct requests # Test 14 -- 2 KO services on same equipment -> 2 distinct requests
# once both reach the confirmation delay
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def test_two_ko_services_same_equipment_create_two_requests(self): def test_two_ko_services_same_equipment_create_two_requests(self):
service2 = self.env["service"].create({"name": "Test Service 2"}) service2 = self.env["service"].create({"name": "Test Service 2"})
@@ -290,10 +335,14 @@ class TestHttpMonitoring(TransactionCase):
} }
) )
with ( with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, mock_requests.get.return_value = _mock_response(500)
patch(SERVICE_INSTANCE_SLEEP), mock_requests.exceptions.RequestException = Exception
): self.env["service.instance"].cron_check_http_services()
self._backdate_first_ko(self.service_instance, service_instance2)
with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
mock_requests.get.return_value = _mock_response(500) mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() self.env["service.instance"].cron_check_http_services()
@@ -317,11 +366,16 @@ class TestHttpMonitoring(TransactionCase):
# Test 15 -- Service recovery closes the open request and posts a note # Test 15 -- Service recovery closes the open request and posts a note
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def test_service_recovery_closes_request(self): def test_service_recovery_closes_request(self):
# First cron run: service is KO -> request created # First cron run: service is KO, streak just started -> no request yet
with ( with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, mock_requests.get.return_value = _mock_response(500)
patch(SERVICE_INSTANCE_SLEEP), mock_requests.exceptions.RequestException = Exception
): self.env["service.instance"].cron_check_http_services()
self._backdate_first_ko(self.service_instance)
# Second cron run: streak confirmed -> request created
with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
mock_requests.get.return_value = _mock_response(500) mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() self.env["service.instance"].cron_check_http_services()
@@ -330,11 +384,8 @@ class TestHttpMonitoring(TransactionCase):
self.assertTrue(request) self.assertTrue(request)
self.assertFalse(request.stage_id.done) self.assertFalse(request.stage_id.done)
# Second cron run: service is back OK -> request auto-closed # Next cron run: service is back OK -> request auto-closed
with ( with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
patch(SERVICE_INSTANCE_REQUESTS) as mock_requests,
patch(SERVICE_INSTANCE_SLEEP),
):
mock_requests.get.return_value = _mock_response(200) mock_requests.get.return_value = _mock_response(200)
mock_requests.exceptions.RequestException = Exception mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services() self.env["service.instance"].cron_check_http_services()
@@ -343,6 +394,8 @@ class TestHttpMonitoring(TransactionCase):
self.assertTrue(request.stage_id.done) self.assertTrue(request.stage_id.done)
# http_maintenance_request must be cleared on the service instance # http_maintenance_request must be cleared on the service instance
self.assertFalse(self.service_instance.http_maintenance_request) self.assertFalse(self.service_instance.http_maintenance_request)
# KO streak must be reset
self.assertFalse(self.service_instance.http_first_ko_at)
# A chatter note must have been posted mentioning the service URL # A chatter note must have been posted mentioning the service URL
notes = request.message_ids.filtered( notes = request.message_ids.filtered(
lambda m: self.service_instance.service_url in (m.body or "") lambda m: self.service_instance.service_url in (m.body or "")

View File

@@ -18,6 +18,7 @@
<field name="last_http_status_code" /> <field name="last_http_status_code" />
<field name="http_status_ok" /> <field name="http_status_ok" />
<field name="http_maintenance_request" optional="hide" /> <field name="http_maintenance_request" optional="hide" />
<field name="http_first_ko_at" optional="hide" />
</field> </field>
</field> </field>
</record> </record>

2
maintenance_user_ssh_key/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.*~
*pyc

View File

@@ -0,0 +1,63 @@
==============================
maintenance_user_ssh_key
==============================
This module adds SSH key management to Odoo users. It introduces a new
``ssh.key`` model and a ``ssh_key_ids`` One2many field on ``res.users``.
It allows administrators to associate SSH public keys with each user for
authentication purposes (e.g., remote server access, deployment).
Features:
- **SSH Key model**: Store public keys per user.
- **User integration**: SSH keys are displayed and editable directly on the
user form view.
- **Access control**: All internal users can view SSH keys; only users with
``Settings / Administration`` rights can create, edit, or delete them.
# Installation
Use Odoo normal module installation procedure to install
``maintenance_user_ssh_key``.
This module depends on ``base`` and requires no external Python dependencies.
# Configuration
No specific configuration is required. After installation:
1. Go to *Settings > Users & Companies > Users*.
2. Open any user and navigate to the **SSH Keys** notebook page.
3. Add SSH keys with the public key content.
# Usage
## Managing SSH Keys
- Open a user form and go to the *SSH Keys* tab.
- Click **Add a line** and paste the public key content.
# Bug Tracker
Bugs are tracked on
`our issues website <https://git.elabore.coop/Elabore/maintenance-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and
welcomed feedback.
# Credits
## Contributors
- Stéphan Sainléger
## Funders
The development of this module has been financially supported by:
- Elabore (https://elabore.coop)
## Maintainer
This module is maintained by Elabore.

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,22 @@
# Copyright 2026 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_user_ssh_key",
"version": "18.0.1.0.0",
"author": "Elabore",
"website": "https://git.elabore.coop/elabore/maintenance-tools",
"maintainer": "Stéphan Sainléger",
"license": "AGPL-3",
"category": "Tools",
"summary": "Manage SSH keys per user.",
"depends": ["base"],
"data": [
"security/ir.model.access.csv",
"views/ssh_key_views.xml",
"views/res_users_views.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View File

@@ -0,0 +1,2 @@
from . import ssh_key
from . import res_users

View File

@@ -0,0 +1,14 @@
# Copyright 2026 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResUsers(models.Model):
_inherit = "res.users"
ssh_key_ids = fields.One2many(
comodel_name="ssh.key",
inverse_name="user_id",
string="SSH Keys",
)

View File

@@ -0,0 +1,18 @@
# Copyright 2026 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class SshKey(models.Model):
_name = "ssh.key"
_description = "SSH Key"
key = fields.Text(string="Public Key", required=True)
user_id = fields.Many2one(
comodel_name="res.users",
string="User",
required=True,
ondelete="cascade",
index=True,
)

View File

@@ -0,0 +1,3 @@
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
access_ssh_key_user,ssh.key.user,model_ssh_key,base.group_user,1,0,0,0
access_ssh_key_manager,ssh.key.manager,model_ssh_key,base.group_system,1,1,1,1
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
2 access_ssh_key_user ssh.key.user model_ssh_key base.group_user 1 0 0 0
3 access_ssh_key_manager ssh.key.manager model_ssh_key base.group_system 1 1 1 1

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_users_form_ssh_key_inherit" model="ir.ui.view">
<field name="name">res.users.form.ssh.key</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="SSH Keys" name="ssh_keys">
<field name="ssh_key_ids">
<list editable="top">
<field name="key"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="ssh_key_view_tree" model="ir.ui.view">
<field name="name">ssh.key.tree</field>
<field name="model">ssh.key</field>
<field name="arch" type="xml">
<list editable="top">
<field name="key"/>
<field name="user_id"/>
</list>
</field>
</record>
<record id="ssh_key_view_form" model="ir.ui.view">
<field name="name">ssh.key.form</field>
<field name="model">ssh.key</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="user_id"/>
</group>
<group>
<field name="key" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="ssh_key_action" model="ir.actions.act_window">
<field name="name">SSH Keys</field>
<field name="res_model">ssh.key</field>
<field name="view_mode">list,form</field>
</record>
<menuitem
id="menu_ssh_key"
action="ssh_key_action"
parent="base.menu_security"
sequence="50"/>
</odoo>