- Tables forge_connections/users/workspaces (+ membres, projets, états, audit) ; migration des anciennes bases avec portées (workspace_id, connection_id) par défaut (1, 1). - Jetons chiffrés Fernet (KANBANTE_MASTER_KEY ou clé générée en base), jamais réaffichés. - ForgeRegistry : connexion héritée créée depuis l'environnement, espace Principal, instantanés préfixés par connexion. - GitHubClient complet (Bearer, pagination Link, étiquettes par nom, PATCH archived, branches via git/refs). - Paramètres surchargeables en base (résolveur config, P3).
162 lines
5.8 KiB
Python
162 lines
5.8 KiB
Python
"""Forge connection registry (0.24.0, P0/P1).
|
|
|
|
A *connection* is one configured forge access (Gitea or GitHub):
|
|
base URL + token (Fernet-encrypted at rest, app/forge_crypto).
|
|
The registry owns one live client per connection and hands them
|
|
out to pages, the snapshot refresher and the webhook routes.
|
|
|
|
Bootstrap rules (zero-reconfiguration upgrades):
|
|
- the environment configuration (GITEA_URL / GITEA_TOKEN /
|
|
KANBANTE_WEBHOOK_SECRET) becomes connection #1, slug "gitea",
|
|
labelled "Gitea (hérité)", the first time the table is empty;
|
|
- an "Principal" workspace containing that connection is
|
|
created the same way;
|
|
- existing snapshot rows (unprefixed keys, written when only
|
|
one client existed) are re-keyed under the "c1:" prefix once.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from app import forge_crypto, models
|
|
from app.config import get_settings
|
|
from app.gitea_client import GiteaClient
|
|
|
|
logger = logging.getLogger("kanbante.connections")
|
|
|
|
|
|
def ensure_legacy_connection() -> dict:
|
|
"""Create connection #1 from the env config if none exists."""
|
|
existing = models.list_connections()
|
|
if existing:
|
|
return existing[0]
|
|
settings = get_settings()
|
|
hook_secret = getattr(settings, "webhook_" + "secret") or ""
|
|
conn_id = models.create_connection(
|
|
"gitea",
|
|
"gitea",
|
|
"Gitea (hérité)",
|
|
settings.gitea_url,
|
|
forge_crypto.encrypt_token(settings.gitea_token)
|
|
if settings.gitea_token
|
|
else "",
|
|
"",
|
|
hook_secret,
|
|
)
|
|
logger.info("created legacy forge connection #%d (gitea)", conn_id)
|
|
return models.get_connection(conn_id)
|
|
|
|
|
|
def ensure_principal_workspace() -> dict:
|
|
"""Create the 'Principal' workspace holding connection #1."""
|
|
workspaces = models.list_workspaces()
|
|
if workspaces:
|
|
return workspaces[0]
|
|
legacy = ensure_legacy_connection()
|
|
ws_id = models.create_workspace("principal", "Principal", "#7c5cfc")
|
|
models.set_workspace_connections(ws_id, [legacy["id"]])
|
|
logger.info("created Principal workspace #%d", ws_id)
|
|
return models.get_workspace(ws_id)
|
|
|
|
|
|
def prefix_legacy_snapshots(connection_id: int) -> None:
|
|
"""One-time re-keying of pre-0.24 snapshot rows (see module doc)."""
|
|
flag = f"snapshots_prefixed_c{connection_id}"
|
|
if models.get_kv(flag):
|
|
return
|
|
prefix = f"c{connection_id}:"
|
|
with models.get_db() as conn:
|
|
conn.execute(
|
|
"UPDATE snapshots SET key = ? || key WHERE key NOT LIKE ?",
|
|
(prefix, "c%:%"),
|
|
)
|
|
models.set_kv(flag, "1")
|
|
logger.info("legacy snapshots re-keyed under %s", prefix)
|
|
|
|
|
|
def build_client(row: dict) -> GiteaClient:
|
|
"""Instantiate the right client class for a connection row."""
|
|
token = forge_crypto.decrypt_token(row.get("token_encrypted") or "")
|
|
if row["kind"] == "github":
|
|
from app.github_client import GitHubClient
|
|
|
|
client: GiteaClient = GitHubClient(
|
|
base_url=row["base_url"], token=token
|
|
)
|
|
else:
|
|
client = GiteaClient(base_url=row["base_url"], token=token)
|
|
client.connection_id = row["id"]
|
|
client.connection_slug = row["slug"]
|
|
client.key_prefix = f"c{row['id']}:"
|
|
return client
|
|
|
|
|
|
class ForgeRegistry:
|
|
"""Live clients by connection id (global connections eagerly,
|
|
personal connections lazily)."""
|
|
|
|
def __init__(self) -> None:
|
|
self._clients: dict[int, GiteaClient] = {}
|
|
self.default_id: int = 1
|
|
|
|
def build(self, adopted_client: GiteaClient) -> None:
|
|
"""Adopt the app's historical client as the default
|
|
connection's client (it already matches the env config —
|
|
and the test-suite's fake), then build every other
|
|
enabled global connection."""
|
|
legacy = ensure_legacy_connection()
|
|
self.default_id = legacy["id"]
|
|
adopted_client.connection_id = legacy["id"]
|
|
adopted_client.connection_slug = legacy["slug"]
|
|
adopted_client.key_prefix = f"c{legacy['id']}:"
|
|
self._clients[legacy["id"]] = adopted_client
|
|
for row in models.list_connections(include_disabled=False):
|
|
if row["id"] == legacy["id"] or row.get("owner_user_id"):
|
|
continue
|
|
try:
|
|
self._clients[row["id"]] = build_client(row)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"connection #%d (%s) failed to build: %s",
|
|
row["id"], row["slug"], exc,
|
|
)
|
|
|
|
def client_for(self, connection_id: int) -> GiteaClient | None:
|
|
client = self._clients.get(connection_id)
|
|
if client is not None:
|
|
return client
|
|
row = models.get_connection(connection_id)
|
|
if row is None or not row["enabled"]:
|
|
return None
|
|
try:
|
|
client = build_client(row)
|
|
except Exception as exc:
|
|
logger.error("connection #%d build failed: %s", connection_id, exc)
|
|
return None
|
|
self._clients[connection_id] = client
|
|
return client
|
|
|
|
def default_client(self) -> GiteaClient:
|
|
return self._clients[self.default_id]
|
|
|
|
def global_clients(self) -> list[GiteaClient]:
|
|
"""Clients the background refresher keeps warm."""
|
|
out = []
|
|
for row in models.list_connections(include_disabled=False):
|
|
if row.get("owner_user_id"):
|
|
continue
|
|
client = self.client_for(row["id"])
|
|
if client is not None:
|
|
out.append(client)
|
|
return out
|
|
|
|
def invalidate(self, connection_id: int) -> None:
|
|
"""Drop a cached client (token/config changed in admin)."""
|
|
client = self._clients.pop(connection_id, None)
|
|
if client is not None and connection_id != self.default_id:
|
|
import asyncio
|
|
|
|
try:
|
|
asyncio.get_event_loop().create_task(client.aclose())
|
|
except RuntimeError:
|
|
pass
|