Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

509 lines
18 KiB
Python

"""Local integration credential store.
Integrations are stored in ~/.opensre/integrations.json.
File format (v2 — see ``_migrate_record_v1_to_v2`` for the v1 shape):
{
"version": 2,
"integrations": [
{
"id": "grafana-1",
"service": "grafana",
"status": "active",
"instances": [
{
"name": "prod",
"tags": {"env": "prod"},
"credentials": {"endpoint": "https://...", "api_key": "..."}
},
{
"name": "staging",
"tags": {"env": "staging"},
"credentials": {"endpoint": "https://...", "api_key": "..."}
}
]
}
]
}
v1 records are auto-migrated on load. Each record's ``credentials`` plus
any non-structural top-level fields (e.g. AWS ``role_arn``) are moved into
a single ``default`` instance. The migration is idempotent; the file is
rewritten with ``version: 2`` on first load.
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import tempfile
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import Any
from filelock import FileLock, Timeout
from config.constants.paths import INTEGRATIONS_STORE_PATH
logger = logging.getLogger(__name__)
STORE_PATH = INTEGRATIONS_STORE_PATH
_VERSION = 2
_LOCK_TIMEOUT_SECONDS = 10.0
# Structural fields on an integration record — everything else at the top
# level of a v1 record is migrated into the default instance's credentials.
_STRUCTURAL_RECORD_FIELDS = frozenset({"id", "service", "status", "instances"})
class IntegrationStoreLockTimeout(TimeoutError):
"""Raised when the integration store lock cannot be acquired in time."""
def _lock_timeout_error() -> IntegrationStoreLockTimeout:
return IntegrationStoreLockTimeout(
f"Integration store locked: {_lock_path()} (store: {STORE_PATH})"
)
def _migrate_record_v1_to_v2(record: dict[str, Any]) -> dict[str, Any]:
"""Migrate a single integration record from v1 shape to v2.
v1 records may carry credentials in ``record["credentials"]`` AND
additional top-level fields (AWS had ``role_arn`` and ``external_id``
at the record's top level with an often-empty ``credentials: {}``).
This migration consolidates EVERYTHING non-structural into
``instances[0].credentials`` so downstream code reads one uniform shape.
v2 records (already containing ``instances``) pass through untouched.
"""
if isinstance(record.get("instances"), list):
return record
credentials = dict(record.get("credentials", {}))
for key, value in record.items():
if key in _STRUCTURAL_RECORD_FIELDS or key == "credentials":
continue
credentials.setdefault(key, value)
return {
"id": record.get("id", ""),
"service": record.get("service", ""),
"status": record.get("status", "active"),
"instances": [{"name": "default", "tags": {}, "credentials": credentials}],
}
def _migrate_if_needed(data: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Return (possibly-migrated) data and whether migration happened."""
if data.get("version") == _VERSION:
return data, False
records = data.get("integrations", [])
if not isinstance(records, list):
records = []
migrated_records = [_migrate_record_v1_to_v2(r) if isinstance(r, dict) else r for r in records]
return {"version": _VERSION, "integrations": migrated_records}, True
def _lock_path() -> Path:
"""Return the file lock path derived from the current STORE_PATH."""
return STORE_PATH.with_suffix(".lock")
def _acquire_lock() -> FileLock:
"""Create and return a FileLock for the current STORE_PATH."""
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
return FileLock(str(_lock_path()), timeout=_LOCK_TIMEOUT_SECONDS)
def _atomic_write(dest: Path, data: dict[str, Any]) -> None:
"""Write ``data`` to ``dest`` atomically via a temp file + fsync + replace."""
dest.parent.mkdir(parents=True, exist_ok=True)
serialized = json.dumps(data, indent=2) + "\n"
fd: int | None = None
tmp_path_str: str | None = None
try:
fd, tmp_path_str = tempfile.mkstemp(
dir=dest.parent,
prefix=dest.name + ".tmp",
)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(serialized)
f.flush()
os.fsync(f.fileno())
# os.replace is atomic on POSIX and Windows (Python >=3.3)
os.replace(tmp_path_str, dest)
except Exception:
if tmp_path_str:
with contextlib.suppress(OSError):
os.unlink(tmp_path_str)
raise
with contextlib.suppress(OSError):
dest.chmod(0o600)
def _save_unlocked(data: dict[str, Any]) -> None:
"""Persist ``data`` to STORE_PATH without acquiring a lock.
Callers must already hold the store lock.
"""
_atomic_write(STORE_PATH, data)
def _load_raw_unlocked() -> tuple[dict[str, Any], bool]:
"""Read the store from disk and migrate in memory.
Returns ``(data, did_migrate)``. This helper does **not** write back
migrations and does **not** acquire any lock.
"""
if not STORE_PATH.exists():
return {"version": _VERSION, "integrations": []}, False
try:
text = STORE_PATH.read_text(encoding="utf-8")
data = json.loads(text)
except (json.JSONDecodeError, OSError):
logger.warning("Failed to read integrations store at %s", STORE_PATH, exc_info=True)
return {"version": _VERSION, "integrations": []}, False
if not isinstance(data, dict) or "integrations" not in data:
return {"version": _VERSION, "integrations": []}, False
return _migrate_if_needed(data)
def _load_raw() -> dict[str, Any]:
"""Read the store, migrating on disk if necessary.
Lock-free for the common v2 path; acquires the store lock only when a
v1 migration needs to be persisted.
"""
data, did_migrate = _load_raw_unlocked()
if did_migrate:
try:
with _acquire_lock():
# Re-read under lock in case another process already migrated
data2, did_migrate2 = _load_raw_unlocked()
if did_migrate2:
try:
_save_unlocked(data2)
except OSError:
logger.warning(
"Failed to persist v2 migration; continuing with in-memory v2",
exc_info=True,
)
return data2
except Timeout:
logger.warning(
"Timed out acquiring integration store lock for v2 migration persist",
exc_info=True,
)
return data
return data
def _save(data: dict[str, Any]) -> None:
"""Persist ``data`` to STORE_PATH, acquiring the store lock."""
try:
with _acquire_lock():
_save_unlocked(data)
except Timeout as exc:
raise _lock_timeout_error() from exc
def _locked_update(mutator: Callable[[dict[str, Any]], bool]) -> tuple[dict[str, Any], bool]:
"""Acquire the store lock, load, mutate, and save atomically.
The mutator receives the current store data and must return ``True``
when it actually modified the data so the change is persisted. A v1-to-v2
migration is persisted even when the mutator itself is a no-op.
Returns ``(data, changed)``.
"""
try:
with _acquire_lock():
data, did_migrate = _load_raw_unlocked()
changed = mutator(data)
if changed or did_migrate:
_save_unlocked(data)
return data, changed
except Timeout as exc:
raise _lock_timeout_error() from exc
def load_integrations() -> list[dict[str, Any]]:
"""Return all active local integrations (v2 shape)."""
return list(_load_raw().get("integrations", []))
def _record_with_flat_credentials_view(record: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of ``record`` with a backward-compat ``credentials`` key.
v2 records store credentials inside ``instances[0].credentials``. Many
existing callers (``azure_sql.py``, ``mysql.py``, ``postgresql.py``,
``surfaces/cli/wizard/flow.py``) read ``record["credentials"]`` directly. This
helper synthesises a top-level ``credentials`` view from the default
(first) instance so those callers continue to work unchanged.
"""
instances = record.get("instances")
if not isinstance(instances, list) or not instances:
return record
first = instances[0] if isinstance(instances[0], dict) else {}
creds = first.get("credentials", {}) if isinstance(first, dict) else {}
if not isinstance(creds, dict):
return record
view = dict(record)
view.setdefault("credentials", creds)
return view
def get_integration(service: str) -> dict[str, Any] | None:
"""Return the first active integration record for a service, or None.
The returned dict has the v2 shape (``instances`` list) AND a
synthesised top-level ``credentials`` field mirroring ``instances[0]
.credentials`` for backward compatibility with callers that read
``record["credentials"]`` directly.
"""
for i in load_integrations():
if i.get("service") == service and i.get("status") == "active":
return _record_with_flat_credentials_view(i)
return None
def _wrap_as_instances(entry: dict[str, Any]) -> list[dict[str, Any]]:
"""Accept a caller's ``entry`` and normalize it to a v2 ``instances`` list.
- If ``entry`` already has ``instances``, use them.
- Else, wrap ``entry["credentials"]`` (plus any extra non-structural
top-level keys) as a single ``default`` instance.
"""
if isinstance(entry.get("instances"), list):
return [inst for inst in entry["instances"] if isinstance(inst, dict)]
credentials = dict(entry.get("credentials", {}))
for key, value in entry.items():
if key in _STRUCTURAL_RECORD_FIELDS or key == "credentials":
continue
credentials.setdefault(key, value)
return [{"name": "default", "tags": {}, "credentials": credentials}]
def upsert_integration(service: str, entry: dict[str, Any]) -> None:
"""Add or replace the integration record for a service.
Accepts v1-shaped entries (``{"credentials": {...}}``) and v2-shaped
entries (``{"instances": [...]}``) transparently. v1 entries are
wrapped into a single ``default`` instance.
"""
def _mutate(data: dict[str, Any]) -> bool:
integrations: list[dict[str, Any]] = data.get("integrations", [])
integrations = [i for i in integrations if i.get("service") != service]
record: dict[str, Any] = {
"id": entry.get("id") or f"{service}-{uuid.uuid4().hex[:8]}",
"service": service,
"status": entry.get("status", "active"),
"instances": _wrap_as_instances(entry),
}
integrations.append(record)
data["integrations"] = integrations
return True
_locked_update(_mutate)
def remove_integration(service: str) -> bool:
"""Remove integration for a service. Returns True if something was removed."""
def _mutate(data: dict[str, Any]) -> bool:
before = len(data.get("integrations", []))
data["integrations"] = [
i for i in data.get("integrations", []) if i.get("service") != service
]
return len(data["integrations"]) < before
_, removed = _locked_update(_mutate)
return removed
def list_integrations() -> list[dict[str, Any]]:
"""Return summary info for all stored integrations."""
summaries: list[dict[str, Any]] = []
for i in load_integrations():
summaries.append(
{
"service": i.get("service"),
"status": i.get("status"),
"id": i.get("id"),
"instance_names": [
inst.get("name", "default")
for inst in i.get("instances", [])
if isinstance(inst, dict)
],
}
)
return summaries
# ──────────────── Instance-level APIs ──────────────────────────────────────
def get_instances(service: str) -> list[dict[str, Any]]:
"""Return all instance dicts across every record for ``service``.
Each returned dict has the instance's own ``name``, ``tags``, and
``credentials`` plus an ``integration_id`` pointer to its parent record.
"""
out: list[dict[str, Any]] = []
for record in load_integrations():
if record.get("service") != service or record.get("status") != "active":
continue
record_id = str(record.get("id", ""))
for inst in record.get("instances", []):
if not isinstance(inst, dict):
continue
out.append(
{
"name": str(inst.get("name", "default")),
"tags": inst.get("tags", {}) or {},
"credentials": inst.get("credentials", {}) or {},
"integration_id": record_id,
}
)
return out
def _tags_match(inst_tags: dict[str, str], filter_tags: dict[str, str]) -> bool:
return all(inst_tags.get(key) == value for key, value in filter_tags.items())
def get_instance(
service: str,
*,
name: str | None = None,
tags: dict[str, str] | None = None,
) -> dict[str, Any] | None:
"""Return the first instance matching ``name`` and/or ``tags``, or None.
Returns ONLY the matching instance — never leaks sibling instances from
the same parent record (PR #527 bug #3).
"""
normalized_name = name.strip().lower() if name else None
normalized_tags = tags or {}
for inst in get_instances(service):
if normalized_name and inst.get("name", "").lower() != normalized_name:
continue
if normalized_tags and not _tags_match(inst.get("tags", {}), normalized_tags):
continue
return inst
return None
def upsert_instance(
service: str,
instance: dict[str, Any],
*,
record_id: str | None = None,
) -> None:
"""Add or update an instance by name within a specific record.
If ``record_id`` matches an existing record for ``service``, the instance
is appended or updated by name within that record. Otherwise, a new
record is created containing only this instance.
"""
def _mutate(data: dict[str, Any]) -> bool:
integrations: list[dict[str, Any]] = data.get("integrations", [])
target: dict[str, Any] | None = None
for record in integrations:
if record.get("service") != service:
continue
if record_id is not None and record.get("id") != record_id:
continue
target = record
break
normalized_instance = {
"name": str(instance.get("name", "default")).strip().lower() or "default",
"tags": instance.get("tags", {}) or {},
"credentials": instance.get("credentials", {}) or {},
}
if target is None:
integrations.append(
{
"id": record_id or f"{service}-{uuid.uuid4().hex[:8]}",
"service": service,
"status": "active",
"instances": [normalized_instance],
}
)
else:
existing_instances = target.get("instances", [])
if not isinstance(existing_instances, list):
existing_instances = []
replaced = False
for idx, existing in enumerate(existing_instances):
if (
isinstance(existing, dict)
and existing.get("name", "").lower() == normalized_instance["name"]
):
existing_instances[idx] = normalized_instance
replaced = True
break
if not replaced:
existing_instances.append(normalized_instance)
target["instances"] = existing_instances
data["integrations"] = integrations
return True
_locked_update(_mutate)
def remove_instance(service: str, name: str) -> bool:
"""Remove one named instance from any record for ``service``.
If removing the instance empties its parent record, the record itself
is removed. Always persists the change when something was removed
(PR #527 P2 regression fix).
Returns True if something was removed.
"""
normalized_name = name.strip().lower()
if not normalized_name:
return False
def _mutate(data: dict[str, Any]) -> bool:
integrations: list[dict[str, Any]] = data.get("integrations", [])
remaining: list[dict[str, Any]] = []
changed = False
for record in integrations:
if record.get("service") != service:
remaining.append(record)
continue
instances = record.get("instances", [])
if not isinstance(instances, list):
remaining.append(record)
continue
kept = [
inst
for inst in instances
if isinstance(inst, dict) and inst.get("name", "").lower() != normalized_name
]
if len(kept) == len(instances):
remaining.append(record)
continue
changed = True
if not kept:
continue # drop the whole record
record = dict(record)
record["instances"] = kept
remaining.append(record)
data["integrations"] = remaining
return changed
_, changed = _locked_update(_mutate)
return changed