intermesh 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- intermesh/__init__.py +56 -0
- intermesh/adapters.py +90 -0
- intermesh/admin.py +733 -0
- intermesh/agent.py +520 -0
- intermesh/apikeys.py +273 -0
- intermesh/audit.py +153 -0
- intermesh/bridge.py +181 -0
- intermesh/cli.py +453 -0
- intermesh/config.py +145 -0
- intermesh/crypto.py +150 -0
- intermesh/egress.py +180 -0
- intermesh/escrow.py +208 -0
- intermesh/guardrails.py +235 -0
- intermesh/hardware.py +32 -0
- intermesh/health.py +56 -0
- intermesh/hub.py +996 -0
- intermesh/identity.py +94 -0
- intermesh/logger.py +90 -0
- intermesh/message.py +152 -0
- intermesh/metrics.py +49 -0
- intermesh/peering.py +103 -0
- intermesh/pipeline.py +218 -0
- intermesh/policy.py +35 -0
- intermesh/ratelimit.py +42 -0
- intermesh/schema.py +125 -0
- intermesh/secret.py +115 -0
- intermesh/signing.py +64 -0
- intermesh/snapshot.py +342 -0
- intermesh/store.py +177 -0
- intermesh/task.py +122 -0
- intermesh-0.3.0.dist-info/METADATA +198 -0
- intermesh-0.3.0.dist-info/RECORD +35 -0
- intermesh-0.3.0.dist-info/WHEEL +5 -0
- intermesh-0.3.0.dist-info/entry_points.txt +2 -0
- intermesh-0.3.0.dist-info/top_level.txt +1 -0
intermesh/apikeys.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comptes de service : magasin de clés d'API du Hub.
|
|
3
|
+
|
|
4
|
+
Les clés étaient auparavant écrites en clair dans le code source du Hub,
|
|
5
|
+
donc publiées avec le dépôt. Une clé versionnée dans Git est une clé
|
|
6
|
+
compromise : elle reste lisible dans l'historique même après suppression.
|
|
7
|
+
|
|
8
|
+
Ce module les charge depuis une source externe et ne conserve jamais que
|
|
9
|
+
leur empreinte SHA-256. Le Hub ne peut donc pas révéler une clé, même si
|
|
10
|
+
sa configuration fuit — il peut seulement vérifier qu'une clé présentée
|
|
11
|
+
correspond à une empreinte connue.
|
|
12
|
+
|
|
13
|
+
Sources, par ordre de priorité :
|
|
14
|
+
|
|
15
|
+
1. INTERMESH_API_KEYS — JSON inline (Kubernetes, CI)
|
|
16
|
+
2. INTERMESH_API_KEYS_FILE — chemin d'un fichier JSON
|
|
17
|
+
3. ~/.intermesh/api_keys.json
|
|
18
|
+
4. aucune — les comptes de service sont simplement désactivés
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import secrets
|
|
27
|
+
import stat
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
ENV_INLINE = "INTERMESH_API_KEYS"
|
|
32
|
+
ENV_FILE = "INTERMESH_API_KEYS_FILE"
|
|
33
|
+
|
|
34
|
+
# Clés de démonstration, activées uniquement sur demande explicite
|
|
35
|
+
# (--dev-api-keys). Elles sont publiques par construction : ne jamais
|
|
36
|
+
# s'en servir ailleurs que pour une démo ou un test.
|
|
37
|
+
DEV_API_KEYS = {
|
|
38
|
+
"nx_dev_acme_demo_key": {
|
|
39
|
+
"org_id": "acme",
|
|
40
|
+
"roles": ["admin", "service_account"],
|
|
41
|
+
"permissions": ["admin:*"],
|
|
42
|
+
},
|
|
43
|
+
"nx_dev_globex_demo_key": {
|
|
44
|
+
"org_id": "globex",
|
|
45
|
+
"roles": ["worker", "service_account"],
|
|
46
|
+
"permissions": ["compute:execute"],
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def default_keys_path() -> Path:
|
|
52
|
+
base = os.environ.get("INTERMESH_HOME")
|
|
53
|
+
return (Path(base) if base else Path.home() / ".intermesh") / "api_keys.json"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def hash_key(raw_key: str) -> str:
|
|
57
|
+
"""Empreinte SHA-256 d'une clé d'API."""
|
|
58
|
+
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def generate_key(prefix: str = "nx_live") -> str:
|
|
62
|
+
"""Génère une clé d'API cryptographiquement aléatoire."""
|
|
63
|
+
return f"{prefix}_{secrets.token_urlsafe(32)}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ApiKeyStore:
|
|
67
|
+
"""Vérifie les clés d'API sans jamais conserver leur valeur en clair."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
keys: dict[str, dict] | None = None,
|
|
72
|
+
source: str = "aucune",
|
|
73
|
+
path: Path | None = None,
|
|
74
|
+
):
|
|
75
|
+
"""
|
|
76
|
+
Args:
|
|
77
|
+
keys: Mapping clé_en_clair -> {org_id, roles, permissions}.
|
|
78
|
+
Seules les empreintes sont conservées.
|
|
79
|
+
source: Description de la provenance, pour la bannière de démarrage.
|
|
80
|
+
path: Fichier de sauvegarde, si les mutations sont autorisées.
|
|
81
|
+
"""
|
|
82
|
+
self._by_hash: dict[str, dict] = {}
|
|
83
|
+
self.source = source
|
|
84
|
+
self.path = path
|
|
85
|
+
|
|
86
|
+
for raw_key, info in (keys or {}).items():
|
|
87
|
+
self._by_hash[hash_key(raw_key)] = self._normalize(info)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _normalize(info: dict, label: str | None = None) -> dict:
|
|
91
|
+
entry = {
|
|
92
|
+
"org_id": info["org_id"],
|
|
93
|
+
"roles": list(info.get("roles", [])),
|
|
94
|
+
"permissions": list(info.get("permissions", [])),
|
|
95
|
+
}
|
|
96
|
+
if label or info.get("label"):
|
|
97
|
+
entry["label"] = label or info["label"]
|
|
98
|
+
if info.get("created_at"):
|
|
99
|
+
entry["created_at"] = info["created_at"]
|
|
100
|
+
return entry
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Mutations (console d'administration)
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def mutable(self) -> bool:
|
|
108
|
+
"""Les mutations exigent un fichier : une config injectée par
|
|
109
|
+
l'environnement appartient à l'orchestrateur, pas au Hub."""
|
|
110
|
+
return self.path is not None
|
|
111
|
+
|
|
112
|
+
def describe(self) -> list[dict]:
|
|
113
|
+
"""
|
|
114
|
+
Inventaire des clés, sans jamais exposer leur valeur.
|
|
115
|
+
|
|
116
|
+
L'empreinte est tronquée à 12 caractères : assez pour identifier
|
|
117
|
+
une clé dans une interface, trop peu pour aider une attaque hors
|
|
118
|
+
ligne si la page est capturée.
|
|
119
|
+
"""
|
|
120
|
+
return [
|
|
121
|
+
{
|
|
122
|
+
"fingerprint": h[:12],
|
|
123
|
+
"org_id": info["org_id"],
|
|
124
|
+
"roles": info["roles"],
|
|
125
|
+
"permissions": info["permissions"],
|
|
126
|
+
"label": info.get("label", ""),
|
|
127
|
+
"created_at": info.get("created_at"),
|
|
128
|
+
}
|
|
129
|
+
for h, info in sorted(self._by_hash.items())
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
def _persist(self) -> None:
|
|
133
|
+
if not self.path:
|
|
134
|
+
raise PermissionError(
|
|
135
|
+
"Les clés proviennent de l'environnement et sont en lecture seule. "
|
|
136
|
+
f"Utilisez un fichier ({ENV_FILE} ou ~/.intermesh/api_keys.json) "
|
|
137
|
+
"pour autoriser les mutations."
|
|
138
|
+
)
|
|
139
|
+
# Le fichier stocke déjà des empreintes : la valeur en clair n'a
|
|
140
|
+
# jamais existé côté Hub après création.
|
|
141
|
+
payload = {"__hashed__": True, "keys": self._by_hash}
|
|
142
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
143
|
+
tmp = self.path.with_suffix(".tmp")
|
|
144
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
145
|
+
try:
|
|
146
|
+
os.write(fd, json.dumps(payload, indent=2).encode("utf-8"))
|
|
147
|
+
finally:
|
|
148
|
+
os.close(fd)
|
|
149
|
+
os.replace(tmp, self.path) # remplacement atomique
|
|
150
|
+
|
|
151
|
+
def create(self, org_id: str, roles: list[str], permissions: list[str],
|
|
152
|
+
label: str = "") -> str:
|
|
153
|
+
"""
|
|
154
|
+
Crée une clé et retourne sa valeur en clair — la seule et unique
|
|
155
|
+
fois où elle existe. Seule l'empreinte est conservée ensuite.
|
|
156
|
+
"""
|
|
157
|
+
raw = generate_key()
|
|
158
|
+
self._by_hash[hash_key(raw)] = self._normalize(
|
|
159
|
+
{
|
|
160
|
+
"org_id": org_id,
|
|
161
|
+
"roles": roles,
|
|
162
|
+
"permissions": permissions,
|
|
163
|
+
"created_at": time.time(),
|
|
164
|
+
},
|
|
165
|
+
label=label,
|
|
166
|
+
)
|
|
167
|
+
self._persist()
|
|
168
|
+
return raw
|
|
169
|
+
|
|
170
|
+
def revoke(self, fingerprint_prefix: str) -> bool:
|
|
171
|
+
"""Révoque une clé désignée par le préfixe de son empreinte."""
|
|
172
|
+
matches = [h for h in self._by_hash if h.startswith(fingerprint_prefix)]
|
|
173
|
+
if len(matches) != 1:
|
|
174
|
+
return False
|
|
175
|
+
del self._by_hash[matches[0]]
|
|
176
|
+
self._persist()
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def __len__(self) -> int:
|
|
180
|
+
return len(self._by_hash)
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Instantanés
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def export_hashed(self) -> dict[str, dict]:
|
|
187
|
+
"""
|
|
188
|
+
Empreintes et privilèges, tels quels — la forme attendue par
|
|
189
|
+
`import_hashed`. Aucune clé en clair n'en sort, il n'y en a pas.
|
|
190
|
+
"""
|
|
191
|
+
return {h: dict(info) for h, info in self._by_hash.items()}
|
|
192
|
+
|
|
193
|
+
def import_hashed(self, keys: dict[str, dict]) -> None:
|
|
194
|
+
"""
|
|
195
|
+
Remplace intégralement le jeu de clés.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
PermissionError: si les clés viennent de l'environnement. Elles
|
|
199
|
+
appartiennent alors à l'orchestrateur (Kubernetes, CI), pas
|
|
200
|
+
au Hub : les écraser depuis un instantané ferait diverger la
|
|
201
|
+
mémoire du Hub de sa source de vérité.
|
|
202
|
+
"""
|
|
203
|
+
if not self.mutable:
|
|
204
|
+
raise PermissionError(
|
|
205
|
+
"Les clés proviennent de l'environnement et sont en lecture seule : "
|
|
206
|
+
"elles ne peuvent pas être restaurées depuis un instantané."
|
|
207
|
+
)
|
|
208
|
+
self._by_hash = {h: self._normalize(info) for h, info in keys.items()}
|
|
209
|
+
self._persist()
|
|
210
|
+
|
|
211
|
+
def lookup(self, raw_key: str) -> dict | None:
|
|
212
|
+
"""
|
|
213
|
+
Retourne les privilèges associés à une clé, ou None si inconnue.
|
|
214
|
+
|
|
215
|
+
La comparaison est à temps constant : comparer des empreintes avec
|
|
216
|
+
`==` laisse fuir, par le temps de réponse, le nombre d'octets
|
|
217
|
+
corrects en tête — de quoi reconstruire une empreinte valide
|
|
218
|
+
octet par octet.
|
|
219
|
+
"""
|
|
220
|
+
if not raw_key:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
candidate = hash_key(raw_key)
|
|
224
|
+
matched = None
|
|
225
|
+
for known_hash, info in self._by_hash.items():
|
|
226
|
+
if secrets.compare_digest(candidate, known_hash):
|
|
227
|
+
matched = info
|
|
228
|
+
return matched
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def load(cls, dev_keys: bool = False) -> "ApiKeyStore":
|
|
234
|
+
"""Charge les clés depuis la première source disponible."""
|
|
235
|
+
inline = os.environ.get(ENV_INLINE)
|
|
236
|
+
if inline:
|
|
237
|
+
try:
|
|
238
|
+
return cls(json.loads(inline), source=f"variable {ENV_INLINE}")
|
|
239
|
+
except json.JSONDecodeError as exc:
|
|
240
|
+
raise ValueError(f"{ENV_INLINE} n'est pas du JSON valide : {exc}") from exc
|
|
241
|
+
|
|
242
|
+
explicit = os.environ.get(ENV_FILE)
|
|
243
|
+
path = Path(explicit) if explicit else default_keys_path()
|
|
244
|
+
|
|
245
|
+
if path.is_file():
|
|
246
|
+
mode = path.stat().st_mode
|
|
247
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
248
|
+
print(
|
|
249
|
+
f"\033[33m⚠️ [SÉCURITÉ] {path} est lisible par d'autres utilisateurs.\033[0m\n"
|
|
250
|
+
f" Corrigez avec : chmod 600 {path}"
|
|
251
|
+
)
|
|
252
|
+
try:
|
|
253
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
254
|
+
except json.JSONDecodeError as exc:
|
|
255
|
+
raise ValueError(f"{path} n'est pas du JSON valide : {exc}") from exc
|
|
256
|
+
|
|
257
|
+
if data.get("__hashed__"):
|
|
258
|
+
# Format produit par la console : déjà haché, rien à recalculer.
|
|
259
|
+
store = cls({}, source=f"fichier {path}", path=path)
|
|
260
|
+
store._by_hash = {
|
|
261
|
+
h: cls._normalize(info) for h, info in data.get("keys", {}).items()
|
|
262
|
+
}
|
|
263
|
+
return store
|
|
264
|
+
|
|
265
|
+
# Format historique : clés en clair, hachées au chargement.
|
|
266
|
+
return cls(data, source=f"fichier {path}", path=path)
|
|
267
|
+
|
|
268
|
+
if dev_keys:
|
|
269
|
+
return cls(DEV_API_KEYS, source="clés de DÉMONSTRATION (publiques)")
|
|
270
|
+
|
|
271
|
+
# Aucun fichier : le chemin par défaut devient la cible d'écriture,
|
|
272
|
+
# pour qu'une première clé créée depuis la console soit persistée.
|
|
273
|
+
return cls({}, source="aucune — comptes de service désactivés", path=path)
|
intermesh/audit.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional, List, Dict, Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuditEntry:
|
|
8
|
+
"""
|
|
9
|
+
Une entrée immuable dans le journal d'audit InterMesh.
|
|
10
|
+
Chaque entrée est scellée par son empreinte SHA-256 incluant le hash de l'entrée précédente.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
index: int,
|
|
16
|
+
event_type: str,
|
|
17
|
+
sender: str,
|
|
18
|
+
target: Optional[str],
|
|
19
|
+
metadata: Dict[str, Any],
|
|
20
|
+
prev_hash: str,
|
|
21
|
+
timestamp: Optional[float] = None,
|
|
22
|
+
entry_hash: Optional[str] = None
|
|
23
|
+
):
|
|
24
|
+
self.index = index
|
|
25
|
+
self.timestamp = timestamp or time.time()
|
|
26
|
+
self.event_type = event_type
|
|
27
|
+
self.sender = sender
|
|
28
|
+
self.target = target
|
|
29
|
+
self.metadata = metadata
|
|
30
|
+
self.prev_hash = prev_hash
|
|
31
|
+
self.hash = entry_hash or self.compute_hash()
|
|
32
|
+
|
|
33
|
+
def compute_hash(self) -> str:
|
|
34
|
+
"""Calcule le hash SHA-256 de cette entrée lié à l'entrée précédente."""
|
|
35
|
+
payload = {
|
|
36
|
+
"index": self.index,
|
|
37
|
+
"timestamp": self.timestamp,
|
|
38
|
+
"event_type": self.event_type,
|
|
39
|
+
"sender": self.sender,
|
|
40
|
+
"target": self.target,
|
|
41
|
+
"metadata": self.metadata,
|
|
42
|
+
"prev_hash": self.prev_hash
|
|
43
|
+
}
|
|
44
|
+
raw = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
45
|
+
return hashlib.sha256(raw).hexdigest()
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
return {
|
|
49
|
+
"index": self.index,
|
|
50
|
+
"timestamp": self.timestamp,
|
|
51
|
+
"event_type": self.event_type,
|
|
52
|
+
"sender": self.sender,
|
|
53
|
+
"target": self.target,
|
|
54
|
+
"metadata": self.metadata,
|
|
55
|
+
"prev_hash": self.prev_hash,
|
|
56
|
+
"hash": self.hash
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, data: dict) -> "AuditEntry":
|
|
61
|
+
"""
|
|
62
|
+
Reconstruit une entrée depuis sa forme sérialisée.
|
|
63
|
+
|
|
64
|
+
Le hash stocké est conservé tel quel, sans être recalculé : c'est
|
|
65
|
+
précisément l'écart entre le hash stocké et le hash recalculé qui
|
|
66
|
+
révèle une altération lors de verify_integrity().
|
|
67
|
+
"""
|
|
68
|
+
return cls(
|
|
69
|
+
index=data["index"],
|
|
70
|
+
event_type=data["event_type"],
|
|
71
|
+
sender=data["sender"],
|
|
72
|
+
target=data.get("target"),
|
|
73
|
+
metadata=data.get("metadata", {}),
|
|
74
|
+
prev_hash=data["prev_hash"],
|
|
75
|
+
timestamp=data.get("timestamp"),
|
|
76
|
+
entry_hash=data.get("hash"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ImmutableAuditLog:
|
|
81
|
+
"""
|
|
82
|
+
Registre d'audit chaîné (Merkle-Chain).
|
|
83
|
+
Permet d'ajouter des événements et de vérifier l'intégrité globale du journal.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, entries: Optional[List[dict]] = None, on_append=None):
|
|
87
|
+
"""
|
|
88
|
+
Args:
|
|
89
|
+
entries: Chaîne existante à reprendre (forme sérialisée). Si elle
|
|
90
|
+
est vide ou absente, un bloc genesis est créé.
|
|
91
|
+
on_append: Callback appelé avec chaque nouvelle AuditEntry, pour
|
|
92
|
+
l'écrire dans un stockage durable.
|
|
93
|
+
"""
|
|
94
|
+
self.chain: List[AuditEntry] = []
|
|
95
|
+
self._on_append = on_append
|
|
96
|
+
|
|
97
|
+
if entries:
|
|
98
|
+
self.chain = [AuditEntry.from_dict(e) for e in entries]
|
|
99
|
+
else:
|
|
100
|
+
self._create_genesis_block()
|
|
101
|
+
|
|
102
|
+
def _create_genesis_block(self):
|
|
103
|
+
"""Crée le bloc racine (Genesis) de la chaîne d'audit."""
|
|
104
|
+
genesis = AuditEntry(
|
|
105
|
+
index=0,
|
|
106
|
+
event_type="GENESIS",
|
|
107
|
+
sender="intermesh_system",
|
|
108
|
+
target=None,
|
|
109
|
+
metadata={"system": "InterMesh Protocol Immutable Audit Log Initialized"},
|
|
110
|
+
prev_hash="0" * 64,
|
|
111
|
+
timestamp=time.time()
|
|
112
|
+
)
|
|
113
|
+
self.chain.append(genesis)
|
|
114
|
+
if self._on_append:
|
|
115
|
+
self._on_append(genesis)
|
|
116
|
+
|
|
117
|
+
def log(self, event_type: str, sender: str, target: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> AuditEntry:
|
|
118
|
+
"""Ajoute un nouvel événement au journal et le scelle cryptographiquement."""
|
|
119
|
+
prev_entry = self.chain[-1]
|
|
120
|
+
new_entry = AuditEntry(
|
|
121
|
+
index=len(self.chain),
|
|
122
|
+
event_type=event_type,
|
|
123
|
+
sender=sender,
|
|
124
|
+
target=target,
|
|
125
|
+
metadata=metadata or {},
|
|
126
|
+
prev_hash=prev_entry.hash
|
|
127
|
+
)
|
|
128
|
+
self.chain.append(new_entry)
|
|
129
|
+
if self._on_append:
|
|
130
|
+
self._on_append(new_entry)
|
|
131
|
+
return new_entry
|
|
132
|
+
|
|
133
|
+
def verify_integrity(self) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Vérifie l'intégrité mathématique complète de toute la chaîne d'audit.
|
|
136
|
+
Retourne False si une quelconque entrée a été modifiée ou supprimée.
|
|
137
|
+
"""
|
|
138
|
+
for i in range(1, len(self.chain)):
|
|
139
|
+
curr = self.chain[i]
|
|
140
|
+
prev = self.chain[i - 1]
|
|
141
|
+
|
|
142
|
+
# 1. Vérifier la liaison avec le bloc précédent
|
|
143
|
+
if curr.prev_hash != prev.hash:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
# 2. Vérifier que le hash correspond au contenu actuel
|
|
147
|
+
if curr.hash != curr.compute_hash():
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
def export_json(self) -> str:
|
|
153
|
+
return json.dumps([e.to_dict() for e in self.chain], indent=2)
|
intermesh/bridge.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pont universel : brancher un agent écrit dans n'importe quel langage.
|
|
3
|
+
|
|
4
|
+
Écrire un SDK natif par langage ne passe pas à l'échelle — il y en aurait
|
|
5
|
+
toujours un qui manque. Ce module prend le problème par l'autre bout : le
|
|
6
|
+
protocole reste du JSON, et l'agent étranger n'a qu'à savoir lire du JSON
|
|
7
|
+
sur son entrée standard, ou répondre à une requête HTTP. Go, Rust, Java,
|
|
8
|
+
PHP, C, un script shell : tout ce qui sait faire ça devient un agent
|
|
9
|
+
InterMesh sans une ligne de code d'intégration.
|
|
10
|
+
|
|
11
|
+
Deux modes, selon ce que le programme existant sait déjà faire :
|
|
12
|
+
|
|
13
|
+
* `exec` — un processus est lancé par tâche. Le JSON d'entrée arrive sur
|
|
14
|
+
stdin, la réponse est lue sur stdout. C'est le mode des
|
|
15
|
+
binaires, des scripts et des CLI.
|
|
16
|
+
* `http` — la tâche est POSTée en JSON sur une URL. C'est le mode des
|
|
17
|
+
services déjà en ligne, qu'on ne veut pas relancer.
|
|
18
|
+
|
|
19
|
+
Tolérance assumée en mode `exec` : si stdout n'est pas du JSON valide, le
|
|
20
|
+
texte brut est renvoyé sous `{"output": ...}`. Un `echo` en shell doit
|
|
21
|
+
suffire à faire un agent ; exiger du JSON parfait tuerait le cas d'usage.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import contextlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import shlex
|
|
31
|
+
import signal
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
from typing import Any, Optional, Sequence
|
|
35
|
+
|
|
36
|
+
DEFAULT_TIMEOUT = 30.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BridgeError(RuntimeError):
|
|
40
|
+
"""L'agent externe a échoué : code de sortie non nul, timeout, ou HTTP en erreur."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _decode_output(raw: str) -> Any:
|
|
44
|
+
text = raw.strip()
|
|
45
|
+
if not text:
|
|
46
|
+
return {}
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(text)
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
return {"output": text}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _kill_process_group(process) -> None:
|
|
54
|
+
"""Tue le programme et toute sa descendance.
|
|
55
|
+
|
|
56
|
+
Un agent qui dépasse son délai ne doit pas laisser d'orphelins derrière
|
|
57
|
+
lui : ils continueraient de consommer des ressources et de tenir les
|
|
58
|
+
tuyaux d'un appel qu'on a déjà abandonné.
|
|
59
|
+
"""
|
|
60
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
61
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
62
|
+
return
|
|
63
|
+
with contextlib.suppress(ProcessLookupError):
|
|
64
|
+
process.kill()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def run_command(command: str | Sequence[str], payload: Any,
|
|
68
|
+
timeout: float = DEFAULT_TIMEOUT) -> Any:
|
|
69
|
+
"""Exécute `command`, lui passe `payload` en JSON sur stdin, lit sa réponse.
|
|
70
|
+
|
|
71
|
+
`command` accepte une chaîne (interprétée par le shell, pratique pour
|
|
72
|
+
les pipelines) ou une liste d'arguments (sans shell, à préférer quand
|
|
73
|
+
la commande vient d'une source non maîtrisée).
|
|
74
|
+
"""
|
|
75
|
+
stdin_data = json.dumps(payload).encode("utf-8")
|
|
76
|
+
|
|
77
|
+
# start_new_session place le programme dans son propre groupe de processus.
|
|
78
|
+
# Sans ça, un timeout ne tue que le shell : ses enfants survivent, gardent
|
|
79
|
+
# les tuyaux ouverts, et l'appel reste bloqué jusqu'à leur propre fin — le
|
|
80
|
+
# délai maximal ne protégeait alors de rien.
|
|
81
|
+
spawn = dict(
|
|
82
|
+
stdin=asyncio.subprocess.PIPE,
|
|
83
|
+
stdout=asyncio.subprocess.PIPE,
|
|
84
|
+
stderr=asyncio.subprocess.PIPE,
|
|
85
|
+
start_new_session=True,
|
|
86
|
+
)
|
|
87
|
+
if isinstance(command, str):
|
|
88
|
+
process = await asyncio.create_subprocess_shell(command, **spawn)
|
|
89
|
+
shown = command
|
|
90
|
+
else:
|
|
91
|
+
process = await asyncio.create_subprocess_exec(*command, **spawn)
|
|
92
|
+
shown = shlex.join(command)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
stdout, stderr = await asyncio.wait_for(
|
|
96
|
+
process.communicate(input=stdin_data), timeout=timeout)
|
|
97
|
+
except asyncio.TimeoutError:
|
|
98
|
+
_kill_process_group(process)
|
|
99
|
+
with contextlib.suppress(Exception):
|
|
100
|
+
await asyncio.wait_for(process.wait(), timeout=5.0)
|
|
101
|
+
raise BridgeError(f"'{shown}' n'a pas répondu en {timeout}s.")
|
|
102
|
+
|
|
103
|
+
if process.returncode != 0:
|
|
104
|
+
detail = stderr.decode("utf-8", "replace").strip() or "(stderr vide)"
|
|
105
|
+
raise BridgeError(f"'{shown}' a terminé avec le code {process.returncode} : {detail}")
|
|
106
|
+
|
|
107
|
+
return _decode_output(stdout.decode("utf-8", "replace"))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _post_json(url: str, payload: Any, timeout: float) -> Any:
|
|
111
|
+
request = urllib.request.Request(
|
|
112
|
+
url, data=json.dumps(payload).encode("utf-8"),
|
|
113
|
+
headers={"Content-Type": "application/json"}, method="POST",
|
|
114
|
+
)
|
|
115
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
116
|
+
return _decode_output(response.read().decode("utf-8", "replace"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def post_task(url: str, payload: Any, timeout: float = DEFAULT_TIMEOUT) -> Any:
|
|
120
|
+
"""POSTe `payload` en JSON sur `url` et retourne la réponse décodée.
|
|
121
|
+
|
|
122
|
+
urllib plutôt qu'un client HTTP tiers : le pont ne doit pas imposer une
|
|
123
|
+
dépendance de plus à qui veut juste brancher un service existant.
|
|
124
|
+
"""
|
|
125
|
+
loop = asyncio.get_running_loop()
|
|
126
|
+
try:
|
|
127
|
+
return await loop.run_in_executor(None, _post_json, url, payload, timeout)
|
|
128
|
+
except urllib.error.HTTPError as exc:
|
|
129
|
+
raise BridgeError(f"{url} a répondu {exc.code} : {exc.reason}") from exc
|
|
130
|
+
except urllib.error.URLError as exc:
|
|
131
|
+
raise BridgeError(f"{url} injoignable : {exc.reason}") from exc
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def exec_handler(command: str | Sequence[str], timeout: float = DEFAULT_TIMEOUT):
|
|
135
|
+
"""Handler de tâche déléguant à un programme externe."""
|
|
136
|
+
async def handler(input_data: Any, task: Any):
|
|
137
|
+
return await run_command(command, input_data, timeout)
|
|
138
|
+
return handler
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def http_handler(url: str, timeout: float = DEFAULT_TIMEOUT):
|
|
142
|
+
"""Handler de tâche déléguant à un service HTTP."""
|
|
143
|
+
async def handler(input_data: Any, task: Any):
|
|
144
|
+
return await post_task(url, input_data, timeout)
|
|
145
|
+
return handler
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_agent(name: str, handler, capabilities, roles, org_id, hub_url,
|
|
149
|
+
encrypt, **kwargs):
|
|
150
|
+
from intermesh.agent import InterMeshAgent
|
|
151
|
+
|
|
152
|
+
agent = InterMeshAgent(
|
|
153
|
+
name=name, org_id=org_id, capabilities=capabilities or ["compute"],
|
|
154
|
+
roles=roles or ["worker"], hub_url=hub_url, encrypt=encrypt, **kwargs,
|
|
155
|
+
)
|
|
156
|
+
agent.on_task(handler)
|
|
157
|
+
return agent
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def from_command(command: str | Sequence[str], name: str,
|
|
161
|
+
capabilities: Optional[list] = None, roles: Optional[list] = None,
|
|
162
|
+
org_id: str = "default", hub_url: str = "ws://localhost:8765",
|
|
163
|
+
encrypt: bool = True, timeout: float = DEFAULT_TIMEOUT, **kwargs):
|
|
164
|
+
"""Transforme n'importe quel exécutable en agent InterMesh.
|
|
165
|
+
|
|
166
|
+
agent = from_command("./mon-agent-en-go", name="pricing")
|
|
167
|
+
"""
|
|
168
|
+
return _build_agent(name, exec_handler(command, timeout), capabilities, roles,
|
|
169
|
+
org_id, hub_url, encrypt, **kwargs)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def from_http(url: str, name: str,
|
|
173
|
+
capabilities: Optional[list] = None, roles: Optional[list] = None,
|
|
174
|
+
org_id: str = "default", hub_url: str = "ws://localhost:8765",
|
|
175
|
+
encrypt: bool = True, timeout: float = DEFAULT_TIMEOUT, **kwargs):
|
|
176
|
+
"""Transforme n'importe quel service HTTP en agent InterMesh.
|
|
177
|
+
|
|
178
|
+
agent = from_http("http://localhost:9000/task", name="scoring")
|
|
179
|
+
"""
|
|
180
|
+
return _build_agent(name, http_handler(url, timeout), capabilities, roles,
|
|
181
|
+
org_id, hub_url, encrypt, **kwargs)
|