diff --git a/CLAUDE.md b/CLAUDE.md index ec6a6e4..2ec93e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ input/ # Oracle DMP files for import - **Monitorizare UPS pe serverul Oracle de producție 10.0.20.36 (înlocuiește ViewPower)**: `docs/ups-server36-monitorizare.md` - **Acces SSH la clienți cu chei publice (angajați noi)**: `docs/acces-ssh-chei-angajati.md` - **Acces administrativ la server client prin Tailscale + SSH (fără forward pe router)**: `docs/acces-client-tailscale-ssh.md` +- **Conectarea unui container headless la Google Drive prin rclone (fără cont de serviciu)**: `docs/rclone-google-drive-headless.md` - **Cazuri clienți (depanare DB)**: `proxmox/lxc108-oracle/clienti/README.md` - **ROMPETROL ENERGY — recreare PDB Oracle XE 21c după ORA-12954**: `proxmox/lxc108-oracle/clienti/oracle-xe-21c/README.md` diff --git a/docs/rclone-google-drive-headless.md b/docs/rclone-google-drive-headless.md new file mode 100644 index 0000000..8f3e335 --- /dev/null +++ b/docs/rclone-google-drive-headless.md @@ -0,0 +1,117 @@ +# rclone + Google Drive pe un container headless (fără cont de serviciu) + +Procedura de conectare a unui container Linux fără browser la Google Drive. +Folosită prima dată pentru depozitul de documente al Mariei (LXC 171, +`maria-whatsapp-bridge`), dar e generală — se aplică oricărui container din cluster. + +**Ideea:** autorizarea OAuth are nevoie de un browser, containerul nu are. Deci +autorizezi pe o mașină care are browser (stația Windows), iar rclone îți dă un token +pe care îl muți pe container. Nu e nevoie de cont de serviciu și nu se atinge deloc +consola Google Cloud. + +## De ce nu contul de serviciu + +Prima variantă documentată era un service account în Google Cloud Console, cu dosarul +din Drive partajat către adresa lui. Funcționează, dar cere: proiect în Google Cloud, +activare Drive API, creare cont, descărcare JSON, partajare manuală a dosarului. Șapte +pași în două interfețe. `rclone authorize` face același lucru în doi pași, dintr-o +fereastră de browser deja autentificată. + +Contul de serviciu rămâne varianta mai bună **doar** dacă ai nevoie de acces care nu +depinde de niciun cont de om (ex. un serviciu care trebuie să meargă și după ce omul +respectiv pleacă din firmă). + +## Pasul 1 — autorizezi pe Windows + +Descarcă arhiva portabilă de la (`rclone-vX-windows-amd64.zip`), +dezarhiveaz-o oriunde — nu cere instalare. În acel dosar: + +``` +rclone.exe authorize "drive" --drive-scope=drive.readonly +``` + +`drive.readonly` fiindcă sincronizarea doar citește. Dacă ai nevoie și de scriere, +folosește `drive`, dar atunci un `rclone sync` greșit poate șterge în Drive. + +Se deschide browserul. Dacă nu se deschide, rclone scrie linkul în consolă. Cum arată +o rulare reușită: + +``` +C:\Users\mmari\Downloads\rclone-v1.75.0-windows-amd64>rclone.exe authorize "drive" --drive-scope=drive.readonly +NOTICE: Config file "C:\\Users\\mmari\\AppData\\Roaming\\rclone\\rclone.conf" not found - using defaults +NOTICE: Make sure your Redirect URL is set to "http://127.0.0.1:53682/" in your custom config. +NOTICE: If your browser doesn't open automatically go to the following link: http://127.0.0.1:53682/auth?state=... +NOTICE: Log in and authorize rclone for access +NOTICE: Waiting for code... +NOTICE: Got code +Paste the following into your remote machine ---> +{"access_token":"ya29...","token_type":"Bearer","refresh_token":"1//03...","expiry":"...","expires_in":3599} +<---End paste +``` + +Cele două mesaje despre „Config file not found" și „Redirect URL" **nu sunt erori** — +`authorize` nu scrie niciun config pe Windows, doar obține tokenul. Dosarul rclone de +pe Windows poate fi șters după. + +Copiază tot JSON-ul dintre `--->` și `<---`, inclusiv acoladele. + +## Pasul 2 — creezi remote-ul pe container + +```bash +rclone config create gdrive drive \ + scope=drive.readonly \ + token='' \ + --non-interactive +``` + +Ghilimelele simple sunt obligatorii — JSON-ul conține ghilimele duble. + +Pentru Maria există un script care face pasul ăsta plus verificarea, scrierea în env și +prima sincronizare: `maria-whatsapp-bridge/ops/setup-drive.sh ''`. + +## Pasul 3 — fixezi dosarul după ID, nu după nume + +```bash +rclone lsf 'gdrive,root_folder_id=:' +``` + +`` e din URL-ul dosarului în Drive +(`https://drive.google.com/drive/folders/`). + +Sintaxa `remote,parametru=valoare:` e „connection string"-ul rclone. **Folosește-o în loc +de căi pe nume** (`gdrive:romfast/document_store`): calea pe nume se rupe dacă dosarul e +mutat sau redenumit în Drive, iar `rclone sync` nu dă eroare — pur și simplu nu găsește +nimic și, dacă e sincronizare bidirecțională, poate goli destinația. + +## Unde stau secretele + +Tokenul ajunge în `~/.config/rclone/rclone.conf` pe container. Fișierul conține +`refresh_token`, adică acces continuu la Drive-ul contului — **nu ajunge niciodată în +git** și nu se copiază între containere. + +`access_token` expiră într-o oră; `refresh_token` îl reînnoiește singur, la nesfârșit, +cât timp aplicația rămâne autorizată în contul Google. Se poate revoca oricând din +. + +Dacă tokenul e revocat sau expiră, sincronizarea începe să eșueze — se reiau pașii 1-2. + +## Capcane + +- **`rclone sync` e distructiv pe destinație.** Șterge local ce nu mai există în sursă. + Depozitul devine o oglindă a dosarului din Drive, nu o colecție care crește: un fișier + pus manual acolo dispare la prima sincronizare. Dacă vrei doar adăugare, e `rclone copy`. +- **Filtrează ce aduci.** `--include '*.md' --include '*.xml'` etc. Un dosar de Drive + strânge în timp tot felul de lucruri (`.json`, `.ps1`, `.docx`) care n-au ce căuta în + depozit. +- **`rclone authorize` nu merge pe container**, nici prin Tailscale: pornește un server + local pe `127.0.0.1:53682` la care redirectează Google, deci browserul trebuie să fie + pe aceeași mașină. +- **Versiunea de pe container poate fi mai veche** decât cea de pe Windows (aici: 1.60.1 + vs 1.75.0). Nu contează pentru token — formatul e stabil de mult. + +## Vezi și + +- `proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md`, secțiunea + „Sincronizare cu Google Drive" — configurarea concretă pentru Maria. +- `proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/setup-drive.sh` — scriptul + care face pașii 2-3 plus prima sincronizare. diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py index bf69cef..bfea5a9 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py @@ -7,6 +7,8 @@ model de citit pentru cine intretine ambele punti pe acest container. from __future__ import annotations +import contextlib +import fcntl import os import pathlib @@ -16,6 +18,7 @@ _DEFAULT_DIR = pathlib.Path.home() / ".maria-bridge" STATE_DIR: pathlib.Path = pathlib.Path(os.environ.get("MARIA_BRIDGE_DIR") or _DEFAULT_DIR) DOCS_DIR: pathlib.Path = STATE_DIR / "documents" INDEX_FILE: pathlib.Path = STATE_DIR / "rag_index.json" +LOCK_FILE: pathlib.Path = STATE_DIR / ".rag.lock" LOG_DIR: pathlib.Path = STATE_DIR / "logs" ENV_FILE: pathlib.Path = STATE_DIR / "env" AUTH_DIR: pathlib.Path = STATE_DIR / "whatsapp-auth" @@ -70,12 +73,13 @@ def parse_env(text: str) -> dict[str, str]: def reload(base_dir: str | os.PathLike | None = None) -> dict[str, str]: """Recalculeaza caile si reciteste env-ul. Returneaza dictionarul incarcat.""" - global STATE_DIR, DOCS_DIR, INDEX_FILE, LOG_DIR, ENV_FILE, AUTH_DIR, _env + global STATE_DIR, DOCS_DIR, INDEX_FILE, LOCK_FILE, LOG_DIR, ENV_FILE, AUTH_DIR, _env if base_dir is None: base_dir = os.environ.get("MARIA_BRIDGE_DIR") or _DEFAULT_DIR STATE_DIR = pathlib.Path(base_dir) DOCS_DIR = STATE_DIR / "documents" INDEX_FILE = STATE_DIR / "rag_index.json" + LOCK_FILE = STATE_DIR / ".rag.lock" LOG_DIR = STATE_DIR / "logs" ENV_FILE = STATE_DIR / "env" AUTH_DIR = STATE_DIR / "whatsapp-auth" @@ -102,3 +106,30 @@ def get_int(key: str, default: int) -> int: reload() + + +class Busy(RuntimeError): + """Alta reindexare e deja in curs.""" + + +@contextlib.contextmanager +def exclusive(): + """Lacat intre PROCESE pentru reindexare (timer vs dashboard vs rulare manuala). + + Reindexarea dureaza minute (embeddings pe CPU). Fara lacat, `maria-sync.timer` + poate porni peste o sincronizare manuala si ambele scriu acelasi rag_index.json. + Nu asteptam: a doua rulare se anuleaza curat, fiindca oricum ar reface acelasi + lucru imediat dupa. + """ + STATE_DIR.mkdir(parents=True, exist_ok=True) + fh = open(LOCK_FILE, "w", encoding="utf-8") + try: + try: + fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + raise Busy("o reindexare e deja in curs") + fh.write(str(os.getpid())) + fh.flush() + yield + finally: + fh.close() diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/indexer.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/indexer.py index 392ca5b..a92402f 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/indexer.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/indexer.py @@ -13,6 +13,7 @@ Doua strategii de taiere in chunk-uri: from __future__ import annotations import json +import os import re import sys import xml.etree.ElementTree as ET @@ -132,19 +133,36 @@ def embed(text: str) -> list[float]: def build() -> dict: entries = [] + warnings: list[str] = [] docs = store.documents_for_index() for doc in docs: text = store.read_document(doc["name"]) + if doc["name"].endswith(".xml") and chunk_xml(text) is None: + warnings.append(f"{doc['name']}: XML invalid, indexat ca text simplu") for i, chunk in enumerate(chunk_document(doc["name"], text)): vec = embed(chunk) entries.append({"source": doc["name"], "chunk": i, "text": chunk, "embedding": vec}) config.STATE_DIR.mkdir(parents=True, exist_ok=True) - config.INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False), encoding="utf-8") - return {"documents": len(docs), "chunks": len(entries)} + # Scriere atomica: consumer-ul reciteste fisierul la 30s si ar putea prinde + # un JSON pe jumatate scris daca am scrie direct peste el. + tmp = config.INDEX_FILE.with_suffix(".json.tmp") + tmp.write_text(json.dumps(entries, ensure_ascii=False), encoding="utf-8") + os.replace(tmp, config.INDEX_FILE) + out = {"documents": len(docs), "chunks": len(entries)} + if warnings: + out["warnings"] = warnings + return out if __name__ == "__main__": - result = build() + try: + with config.exclusive(): + result = build() + except config.Busy as exc: + print(f"[indexer] {exc}, ies fara sa fac nimic", file=sys.stderr) + raise SystemExit(0) + for w in result.get("warnings", []): + print(f"[indexer] ATENTIE {w}", file=sys.stderr) print( f"[indexer] {result['documents']} documente, {result['chunks']} chunk-uri -> {config.INDEX_FILE}", file=sys.stderr, diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/sync.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/sync.py index 22c0dd4..3fd57d6 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/sync.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/sync.py @@ -89,5 +89,9 @@ def sync_and_reindex(force: bool = False) -> dict: if __name__ == "__main__": - out = sync_and_reindex(force="--force" in sys.argv[1:]) + try: + with config.exclusive(): + out = sync_and_reindex(force="--force" in sys.argv[1:]) + except config.Busy as exc: + out = {"skipped": str(exc)} print(json.dumps(out, ensure_ascii=False), file=sys.stderr) diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_store_si_chunking.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_store_si_chunking.py index 4158c37..a559ef3 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_store_si_chunking.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_store_si_chunking.py @@ -112,3 +112,70 @@ def test_md_foloseste_taierea_pe_paragrafe(): text = "primul paragraf\n\n" + "al doilea paragraf " * 20 assert len(indexer.chunk_document("x.md", text)) >= 1 assert indexer.chunk_document("x.md", text) == indexer.chunk_text(text) + + +# --------------------------------------------- concurenta intre reindexari +def test_lacatul_refuza_a_doua_reindexare(): + """Timer-ul nu trebuie sa porneasca peste o sincronizare manuala.""" + import config + + with config.exclusive(): + with pytest.raises(config.Busy): + with config.exclusive(): + pass + + +def test_lacatul_se_elibereaza_dupa_iesire(): + import config + + with config.exclusive(): + pass + with config.exclusive(): # trebuie sa mearga din nou + pass + + +def test_lacatul_se_elibereaza_si_la_exceptie(): + import config + + with pytest.raises(ValueError): + with config.exclusive(): + raise ValueError("ceva") + with config.exclusive(): + pass + + +def test_indexul_se_scrie_atomic(write, monkeypatch): + """Consumer-ul reciteste indexul la 30s; nu are voie sa prinda JSON pe jumatate.""" + import config + import indexer + + write("x.md", "un paragraf oarecare") + monkeypatch.setattr(indexer, "embed", lambda text: [0.1, 0.2]) + vazute = [] + real_replace = indexer.os.replace + + def spion(src, dst): + vazute.append((str(src), str(dst))) + return real_replace(src, dst) + + monkeypatch.setattr(indexer.os, "replace", spion) + indexer.build() + assert vazute and vazute[0][1] == str(config.INDEX_FILE) + assert not config.INDEX_FILE.with_suffix(".json.tmp").exists() + + +def test_xml_invalid_e_raportat_ca_avertisment(write, monkeypatch): + import indexer + + write("stricat.xml", "x") + monkeypatch.setattr(indexer, "embed", lambda text: [0.0]) + out = indexer.build() + assert any("stricat.xml" in w for w in out.get("warnings", [])) + + +def test_xml_valid_nu_produce_avertismente(write, monkeypatch): + import indexer + + write("bun.xml", "x") + monkeypatch.setattr(indexer, "embed", lambda text: [0.0]) + assert "warnings" not in indexer.build()