From d1e12619d45ed64e4be6f83a2f2f66b0bdb95870 Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 4 May 2026 07:50:02 +0000 Subject: [PATCH] refactor(settings): pivot to Coolify env API as source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation showed legal-ai container has no INFISICAL_TOKEN and there is no /legal-ai folder in Infisical — all env vars are stored in Coolify and injected into os.environ at container start. - Replace _read_infisical_values with _read_coolify_envs - New: _coolify_authoritative_value picks among Coolify duplicates - PATCH writes via Coolify API (upsert by key) - Drift = Coolify-stored vs container-runtime (common: Coolify edited without redeploy) - Response field renamed: infisical_value → coolify_value - New 'has_duplicates' flag per row when Coolify has multiple entries Co-Authored-By: Claude Sonnet 4.6 --- web/app.py | 269 +++++++++++++++++++++-------------------- web/mcp_env_catalog.py | 4 +- 2 files changed, 140 insertions(+), 133 deletions(-) diff --git a/web/app.py b/web/app.py index 2a3212b..a6f719a 100644 --- a/web/app.py +++ b/web/app.py @@ -2552,6 +2552,12 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest): # ── Settings: MCP Server Configuration ──────────────────────────── +# +# Source of truth for legal-ai env vars is Coolify (see memory: +# reference_legal_ai_env_architecture). The container's os.environ is +# populated by Coolify at startup. We read & write through the Coolify +# API. Drift = (Coolify env value != container os.environ value), which +# means a Coolify update was made without a redeploy. # Module-level guard: minimum interval between redeploys (60 seconds). # Prevents accidental double-clicks or automated retry loops from queueing @@ -2560,121 +2566,144 @@ _LAST_REDEPLOY_AT: float = 0.0 _REDEPLOY_MIN_INTERVAL_SEC: float = 60.0 -def _infisical_client(): - """Build Infisical SDK client, or return None if not configured.""" - token = os.environ.get("INFISICAL_TOKEN", "") - if not token: - return None - try: - from infisical_sdk import InfisicalSDKClient - return InfisicalSDKClient(token=token) - except Exception as e: - logger.warning("infisical_client_unavailable: %s", e) - return None - - -def _infisical_ctx(): - """Return (project_id, environment, secret_path) for legal-ai secrets.""" +def _coolify_ctx() -> tuple[str, str, str]: + """Return (base_url, app_uuid, token). Token may be empty.""" return ( - os.environ.get("INFISICAL_PROJECT_ID", "9a77b161-f70c-4dd3-9d67-b7ab850cef51"), - os.environ.get("INFISICAL_ENV", "dev"), - os.environ.get("INFISICAL_PATH", "/legal-ai"), + os.environ.get("COOLIFY_URL", "https://coolify.nautilus.marcusgroup.org"), + os.environ.get("COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio"), + os.environ.get("COOLIFY_API_TOKEN", ""), ) -def _read_infisical_values() -> tuple[dict[str, str], list[str]]: - """Read all known catalog keys from Infisical. Returns (values, errors).""" - client = _infisical_client() - if client is None: - return {}, ["infisical_unreachable"] - project_id, env, path = _infisical_ctx() +async def _read_coolify_envs() -> tuple[dict[str, list[dict[str, Any]]], list[str]]: + """Read env vars from Coolify API. + + Returns (grouped_by_key, errors). grouped_by_key maps env key → + list of {uuid, key, value} dicts (Coolify may have duplicates per + key for build-time vs runtime — we surface them all). + """ + base_url, app_uuid, token = _coolify_ctx() + if not token: + return {}, ["coolify_token_missing"] try: - secrets = client.get_all_secrets( - environment=env, project_id=project_id, secret_path=path - ) + async with httpx.AsyncClient(timeout=20.0) as http: + resp = await http.get( + f"{base_url}/api/v1/applications/{app_uuid}/envs", + headers={"Authorization": f"Bearer {token}"}, + ) except Exception as e: - logger.warning("infisical_read_failed: %s", e) - return {}, ["infisical_read_failed"] - values: dict[str, str] = {} - for s in secrets: - if s.secret_key in ENV_CATALOG: - values[s.secret_key] = s.secret_value - return values, [] + logger.warning("coolify_envs_unreachable: %s", e) + return {}, ["coolify_unreachable"] + if resp.status_code >= 400: + logger.warning( + "coolify_envs_failed status=%s body=%s", + resp.status_code, (resp.text or "")[:200], + ) + return {}, ["coolify_envs_failed"] + try: + items = resp.json() + except Exception as e: + logger.warning("coolify_envs_parse_failed: %s", e) + return {}, ["coolify_envs_parse_failed"] + grouped: dict[str, list[dict[str, Any]]] = {} + for item in items if isinstance(items, list) else []: + key = item.get("key") + if not key or key not in ENV_CATALOG: + continue + grouped.setdefault(key, []).append(item) + return grouped, [] + + +def _coolify_authoritative_value(entries: list[dict[str, Any]]) -> str | None: + """Pick the authoritative value when Coolify has multiple entries for a key. + + Strategy: if all entries have the same value, return it. If they + differ, return the LAST one (Coolify's own runtime injection order + treats later definitions as overrides) and log a warning so the + UI can display the conflict. + """ + if not entries: + return None + values = {e.get("value") for e in entries} + if len(values) > 1: + logger.warning( + "coolify_env_duplicate_conflict key=%s values=%s", + entries[0].get("key"), + [str(v)[:20] for v in values], + ) + return entries[-1].get("value") def _build_env_var_row( spec: EnvSpec, - infisical_value: str | None, + coolify_entries: list[dict[str, Any]], container_value: str | None, - infisical_available: bool = True, + coolify_available: bool, ) -> dict[str, Any]: """Build a single response row for an env var. - When infisical_available=False and infisical_value=None, drift is forced - to False (we cannot detect drift without ground truth — UI shows the - 'errors' field instead). + `coolify_value` = authoritative value from Coolify (source of truth). + `container_value` = what the running container sees in os.environ. + Drift = coolify_value != container_value (common cause: Coolify env + updated without a redeploy). + + When `coolify_available=False` we cannot detect drift; the row + surfaces only container_value with drift=False (UI shows a banner + via the `errors` field). """ - # When Infisical is unreachable, we have no ground truth — don't fabricate drift. - if not infisical_available and infisical_value is None: - drift = False - if spec.is_secret: - infisical_display: str | None = None - container_display: str | None = ( - mask_secret(container_value) if container_value else None - ) - else: - infisical_display = None - container_display = container_value - elif spec.is_secret: - i_norm = mask_secret(infisical_value) if infisical_value else None - c_norm = mask_secret(container_value) if container_value else None - # Raw comparison (not hash): values stay in server memory only — never - # logged or returned in the response. mask_secret is applied to display only. - drift = ( - (infisical_value or "") != (container_value or "") - and bool(infisical_value or container_value) + coolify_raw = _coolify_authoritative_value(coolify_entries) + has_duplicates = len(coolify_entries) > 1 + if not coolify_available: + coolify_display: str | None = None + container_display: str | None = ( + mask_secret(container_value) if (spec.is_secret and container_value) + else container_value + ) + drift = False + elif spec.is_secret: + coolify_display = mask_secret(coolify_raw) if coolify_raw else None + container_display = mask_secret(container_value) if container_value else None + drift = bool(coolify_raw or container_value) and ( + (coolify_raw or "") != (container_value or "") ) - infisical_display = i_norm - container_display = c_norm else: - infisical_display = infisical_value + coolify_display = coolify_raw container_display = container_value drift = ( - normalize_for_compare(spec, infisical_value) + normalize_for_compare(spec, coolify_raw) != normalize_for_compare(spec, container_value) ) - if infisical_value is None and container_value is None: + if coolify_raw is None and container_value is None: drift = False row = spec.to_public_dict() row.update({ - "infisical_value": infisical_display, + "coolify_value": coolify_display, "container_value": container_display, "drift": drift, + "has_duplicates": has_duplicates, }) return row @app.get("/api/settings/mcp/env") async def api_mcp_env(): - """List all catalog env vars with Infisical + container values.""" - infisical_values, errors = _read_infisical_values() - project_id, env, path = _infisical_ctx() - infisical_available = not errors # empty errors list → Infisical reachable + """List all catalog env vars with Coolify (authoritative) + container values.""" + coolify_envs, errors = await _read_coolify_envs() + _, app_uuid, _ = _coolify_ctx() + coolify_available = not errors rows = [] for key, spec in ENV_CATALOG.items(): - i_val = infisical_values.get(key) - c_val = os.environ.get(key) rows.append( - _build_env_var_row(spec, i_val, c_val, infisical_available=infisical_available) + _build_env_var_row( + spec, + coolify_envs.get(key, []), + os.environ.get(key), + coolify_available=coolify_available, + ) ) return { "vars": rows, - "infisical_environment": env, - "infisical_project_id": project_id, - "infisical_path": path, - "coolify_app_uuid": os.environ.get( - "COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio" - ), + "coolify_app_uuid": app_uuid, "errors": errors, } @@ -2685,7 +2714,7 @@ class McpEnvUpdateRequest(BaseModel): @app.patch("/api/settings/mcp/env/{key}") async def api_mcp_env_update(key: str, req: McpEnvUpdateRequest): - """Update a non-secret env var in Infisical. Requires redeploy to take effect.""" + """Update a non-secret env var in Coolify. Requires redeploy to take effect.""" spec = ENV_CATALOG.get(key) if spec is None: raise HTTPException(404, f"Unknown env key: {key}") @@ -2698,66 +2727,44 @@ async def api_mcp_env_update(key: str, req: McpEnvUpdateRequest): except ValueError as e: raise HTTPException(400, str(e)) - client = _infisical_client() - if client is None: - raise HTTPException(503, "Infisical not configured") - project_id, env, path = _infisical_ctx() str_value = "true" if coerced is True else ( "false" if coerced is False else str(coerced) ) + + base_url, app_uuid, token = _coolify_ctx() + if not token: + raise HTTPException(503, "COOLIFY_API_TOKEN not configured") + + # Coolify's PATCH endpoint upserts by key (creates if not exists, + # updates if exists). For keys with duplicates, this updates ALL + # entries with that key — which is what we want. try: - # SDK pattern: try update, fall back to create if the secret doesn't exist. - # The Infisical SDK's specific NotFound exception class isn't stable across - # versions, so we inspect the error string. Network/auth errors (which DON'T - # contain 'not found' / 404 / 'does not exist') are re-raised immediately - # so they reach the outer handler and surface as 502 with the real error, - # rather than being silently retried as a create-call. - try: - client.update_secret_by_name( - project_id=project_id, - environment_slug=env, - secret_path=path, - secret_name=key, - secret_value=str_value, - ) - except Exception as update_err: - err_text = str(update_err).lower() - looks_like_missing = ( - "not found" in err_text - or "does not exist" in err_text - or "404" in err_text - ) - if not looks_like_missing: - logger.warning( - "infisical_update_failed key=%s err=%s — not retrying as create", - key, update_err, - ) - raise - logger.info( - "infisical_secret_missing key=%s — falling back to create", key, - ) - client.create_secret_by_name( - project_id=project_id, - environment_slug=env, - secret_path=path, - secret_name=key, - secret_value=str_value, + async with httpx.AsyncClient(timeout=15.0) as http: + resp = await http.patch( + f"{base_url}/api/v1/applications/{app_uuid}/envs", + headers={"Authorization": f"Bearer {token}"}, + json={"key": key, "value": str_value}, ) except Exception as e: - logger.exception("infisical_write_failed key=%s", key) - raise HTTPException(502, f"Infisical write failed: {e}") + logger.exception("coolify_env_write_unreachable key=%s", key) + raise HTTPException(502, f"Coolify unreachable: {e}") + if resp.status_code >= 400: + body_preview = (resp.text or "")[:200] + logger.warning( + "coolify_env_write_failed key=%s status=%s body=%s", + key, resp.status_code, body_preview, + ) + raise HTTPException( + 502, f"Coolify update failed: {resp.status_code} — {body_preview}" + ) - logger.info( - "mcp_env_update key=%s value=%s", - key, - "[masked]" if spec.is_secret else str_value, - ) + logger.info("mcp_env_update key=%s value=%s", key, str_value) return { "ok": True, "key": key, "saved_value": str_value, "requires_redeploy": True, - "message": "נשמר ב-Infisical. נדרש redeploy כדי שיכנס לתוקף בקונטיינר.", + "message": "נשמר ב-Coolify. נדרש redeploy כדי שהקונטיינר יקרא את הערך החדש.", } @@ -2772,18 +2779,16 @@ async def api_mcp_env_redeploy(): raise HTTPException( 429, f"Redeploy בהמתנה: נסה שוב בעוד {wait} שניות." ) - coolify_url = os.environ.get("COOLIFY_URL", "http://158.178.131.193:8000") - coolify_token = os.environ.get("COOLIFY_API_TOKEN", "") - app_uuid = os.environ.get("COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio") - if not coolify_token: + base_url, app_uuid, token = _coolify_ctx() + if not token: raise HTTPException(503, "COOLIFY_API_TOKEN not configured") async with httpx.AsyncClient(timeout=30.0) as http: try: resp = await http.post( - f"{coolify_url}/api/v1/deploy", + f"{base_url}/api/v1/deploy", params={"uuid": app_uuid, "force": "false"}, - headers={"Authorization": f"Bearer {coolify_token}"}, + headers={"Authorization": f"Bearer {token}"}, ) except Exception as e: raise HTTPException(502, f"Coolify unreachable: {e}") diff --git a/web/mcp_env_catalog.py b/web/mcp_env_catalog.py index a16f91a..34d2ca6 100644 --- a/web/mcp_env_catalog.py +++ b/web/mcp_env_catalog.py @@ -1,7 +1,9 @@ # web/mcp_env_catalog.py """Static catalog of MCP server env vars exposed in the settings UI. -Whitelist policy: keys not in this catalog are not displayed or editable. +Source of truth: Coolify env vars (read/write via Coolify API). +This file defines the whitelist + types + display metadata. +Keys not in this catalog are not displayed or editable. """ from __future__ import annotations