From a45f41bf03cf848191be180ee04a59a51ea44238 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 14 Mar 2026 19:21:54 -0400 Subject: [PATCH] =?UTF-8?q?Ajoute=20le=20store=20d=E2=80=99intrants=20et?= =?UTF-8?q?=20la=20CLI=20Life-NOC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ansible/roles/life_noc/handlers/main.yml | 9 ++ ansible/roles/life_noc/tasks/main.yml | 25 +++++ .../templates/life-noc-input-api.service.j2 | 15 +++ data/inputs/revue.yaml | 9 +- lib/__init__.py | 0 lib/life_noc_inputs.py | 70 ++++++++++++++ revue-hebdomadaire-priorites | 0 scripts/life_noc_input.py | 91 +++++++++++++++++++ scripts/life_noc_input_api.py | 61 +++++++++++++ 9 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 ansible/roles/life_noc/templates/life-noc-input-api.service.j2 create mode 100644 lib/__init__.py create mode 100644 lib/life_noc_inputs.py create mode 100644 revue-hebdomadaire-priorites create mode 100755 scripts/life_noc_input.py create mode 100644 scripts/life_noc_input_api.py diff --git a/ansible/roles/life_noc/handlers/main.yml b/ansible/roles/life_noc/handlers/main.yml index cd19a24..a2b2e54 100644 --- a/ansible/roles/life_noc/handlers/main.yml +++ b/ansible/roles/life_noc/handlers/main.yml @@ -9,3 +9,12 @@ state: reloaded when: life_noc_reload_icinga | bool listen: validate and reload icinga + +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: restart life-noc input api + ansible.builtin.service: + name: life-noc-input-api + state: restarted diff --git a/ansible/roles/life_noc/tasks/main.yml b/ansible/roles/life_noc/tasks/main.yml index 8492778..e4b1f83 100644 --- a/ansible/roles/life_noc/tasks/main.yml +++ b/ansible/roles/life_noc/tasks/main.yml @@ -221,3 +221,28 @@ when: - life_noc_bpm_deploy_enabled | bool - life_noc_bpm_destination_dir is defined + +- name: install api dependencies for inputs service + ansible.builtin.apt: + name: + - python3-fastapi + - python3-uvicorn + state: present + update_cache: true + +- name: deploy life-noc input api systemd unit + ansible.builtin.template: + src: life-noc-input-api.service.j2 + dest: /etc/systemd/system/life-noc-input-api.service + owner: root + group: root + mode: "0644" + notify: + - reload systemd + - restart life-noc input api + +- name: enable and start life-noc input api + ansible.builtin.service: + name: life-noc-input-api + enabled: true + state: started diff --git a/ansible/roles/life_noc/templates/life-noc-input-api.service.j2 b/ansible/roles/life_noc/templates/life-noc-input-api.service.j2 new file mode 100644 index 0000000..efdd2fc --- /dev/null +++ b/ansible/roles/life_noc/templates/life-noc-input-api.service.j2 @@ -0,0 +1,15 @@ +[Unit] +Description=Life-NOC Input API +After=network.target + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory={{ life_noc_project_root }} +ExecStart=/usr/bin/python3 -m uvicorn scripts.life_noc_input_api:app --host 127.0.0.1 --port 8787 +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/data/inputs/revue.yaml b/data/inputs/revue.yaml index 050778d..b496f1c 100644 --- a/data/inputs/revue.yaml +++ b/data/inputs/revue.yaml @@ -1,9 +1,8 @@ revue-quotidienne-life-noc: - value: "2026-03-14" - captured_at: "2026-03-14T08:00:00Z" + value: '2026-03-14' + captured_at: '2026-03-14T08:00:00Z' origin: manual - revue-hebdomadaire-priorites: - value: "2026-03-10" - captured_at: "2026-03-14T08:00:00Z" + value: '2026-03-14' + captured_at: '2026-03-14T23:16:41Z' origin: manual diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/life_noc_inputs.py b/lib/life_noc_inputs.py new file mode 100644 index 0000000..9439943 --- /dev/null +++ b/lib/life_noc_inputs.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +import yaml + +INPUTS_DIR = Path("data/inputs") + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def utc_today_date() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +def domain_file(domain: str) -> Path: + return INPUTS_DIR / f"{domain}.yaml" + + +def load_domain(domain: str) -> dict: + path = domain_file(domain) + if not path.exists(): + return {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if data is None: + return {} + if not isinstance(data, dict): + raise ValueError(f"Fichier invalide: {path}") + return data + + +def save_domain(domain: str, data: dict) -> Path: + path = domain_file(domain) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(data, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + return path + + +def list_items(domain: str) -> list[str]: + return list(load_domain(domain).keys()) + + +def get_item(domain: str, item_key: str) -> dict: + data = load_domain(domain) + if item_key not in data: + raise KeyError(item_key) + item = data[item_key] + if not isinstance(item, dict): + raise ValueError(f"Entrée invalide pour {item_key}") + return item + + +def set_item(domain: str, item_key: str, value: str, origin: str = "manual") -> dict: + data = load_domain(domain) + data[item_key] = { + "value": value, + "captured_at": utc_now_iso(), + "origin": origin, + } + save_domain(domain, data) + return data[item_key] + + +def complete_item(domain: str, item_key: str, origin: str = "manual") -> dict: + return set_item(domain, item_key, utc_today_date(), origin=origin) diff --git a/revue-hebdomadaire-priorites b/revue-hebdomadaire-priorites new file mode 100644 index 0000000..e69de29 diff --git a/scripts/life_noc_input.py b/scripts/life_noc_input.py new file mode 100755 index 0000000..4853146 --- /dev/null +++ b/scripts/life_noc_input.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from pathlib import Path +import sys +import argparse +import yaml + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from lib.life_noc_inputs import list_items, get_item, set_item, complete_item + +def cmd_set(args: argparse.Namespace) -> int: + set_item(args.domain, args.item_key, args.value, origin=args.origin) + print(f"Mis à jour: {args.domain} -> {args.item_key}") + return 0 + + +def cmd_complete(args: argparse.Namespace) -> int: + complete_item(args.domain, args.item_key, origin=args.origin) + print(f"Complété: {args.domain} -> {args.item_key}") + return 0 + + +def cmd_get(args: argparse.Namespace) -> int: + try: + item = get_item(args.domain, args.item_key) + except KeyError: + print(f"Item introuvable: {args.item_key}", file=sys.stderr) + return 1 + + print( + yaml.safe_dump( + {args.item_key: item}, + sort_keys=False, + allow_unicode=True, + ).strip() + ) + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + for key in list_items(args.domain): + print(key) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Gestion des intrants Life-NOC") + sub = parser.add_subparsers(dest="command", required=True) + + p_set = sub.add_parser("set", help="Met à jour un intrant") + p_set.add_argument("domain") + p_set.add_argument("item_key") + p_set.add_argument("value") + p_set.add_argument("--origin", default="manual") + p_set.set_defaults(func=cmd_set) + + p_complete = sub.add_parser("complete", help="Marque un item comme complété maintenant") + p_complete.add_argument("domain") + p_complete.add_argument("item_key") + p_complete.add_argument("--origin", default="manual") + p_complete.set_defaults(func=cmd_complete) + + p_get = sub.add_parser("get", help="Lit un intrant") + p_get.add_argument("domain") + p_get.add_argument("item_key") + p_get.set_defaults(func=cmd_get) + + p_list = sub.add_parser("list", help="Liste les intrants d’un domaine") + p_list.add_argument("domain") + p_list.set_defaults(func=cmd_list) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + return args.func(args) + except ValueError as exc: + print(f"Erreur: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/life_noc_input_api.py b/scripts/life_noc_input_api.py new file mode 100644 index 0000000..b10d118 --- /dev/null +++ b/scripts/life_noc_input_api.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from pathlib import Path +import sys + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from lib.life_noc_inputs import list_items, get_item, set_item, complete_item # noqa: E402 + + +class SetInputRequest(BaseModel): + value: str + origin: str = "manual" + + +class CompleteInputRequest(BaseModel): + origin: str = "manual" + + +app = FastAPI(title="Life-NOC Input API", version="1.0.0") + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} + + +@app.get("/inputs/{domain}") +def api_list_inputs(domain: str) -> dict: + return {"domain": domain, "items": list_items(domain)} + + +@app.get("/inputs/{domain}/{item_key}") +def api_get_input(domain: str, item_key: str) -> dict: + try: + item = get_item(domain, item_key) + except KeyError: + raise HTTPException(status_code=404, detail="item introuvable") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"domain": domain, "item_key": item_key, **item} + + +@app.post("/inputs/{domain}/{item_key}") +def api_set_input(domain: str, item_key: str, req: SetInputRequest) -> dict: + item = set_item(domain, item_key, req.value, origin=req.origin) + return {"domain": domain, "item_key": item_key, **item} + + +@app.post("/inputs/{domain}/{item_key}/complete") +def api_complete_input(domain: str, item_key: str, req: CompleteInputRequest | None = None) -> dict: + origin = "manual" if req is None else req.origin + item = complete_item(domain, item_key, origin=origin) + return {"domain": domain, "item_key": item_key, **item}