refactor(settings): pivot to Coolify env API as source of truth
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 <noreply@anthropic.com>
This commit is contained in:
261
web/app.py
261
web/app.py
@@ -2552,6 +2552,12 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
|||||||
|
|
||||||
|
|
||||||
# ── Settings: MCP Server Configuration ────────────────────────────
|
# ── 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).
|
# Module-level guard: minimum interval between redeploys (60 seconds).
|
||||||
# Prevents accidental double-clicks or automated retry loops from queueing
|
# 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
|
_REDEPLOY_MIN_INTERVAL_SEC: float = 60.0
|
||||||
|
|
||||||
|
|
||||||
def _infisical_client():
|
def _coolify_ctx() -> tuple[str, str, str]:
|
||||||
"""Build Infisical SDK client, or return None if not configured."""
|
"""Return (base_url, app_uuid, token). Token may be empty."""
|
||||||
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."""
|
|
||||||
return (
|
return (
|
||||||
os.environ.get("INFISICAL_PROJECT_ID", "9a77b161-f70c-4dd3-9d67-b7ab850cef51"),
|
os.environ.get("COOLIFY_URL", "https://coolify.nautilus.marcusgroup.org"),
|
||||||
os.environ.get("INFISICAL_ENV", "dev"),
|
os.environ.get("COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio"),
|
||||||
os.environ.get("INFISICAL_PATH", "/legal-ai"),
|
os.environ.get("COOLIFY_API_TOKEN", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _read_infisical_values() -> tuple[dict[str, str], list[str]]:
|
async def _read_coolify_envs() -> tuple[dict[str, list[dict[str, Any]]], list[str]]:
|
||||||
"""Read all known catalog keys from Infisical. Returns (values, errors)."""
|
"""Read env vars from Coolify API.
|
||||||
client = _infisical_client()
|
|
||||||
if client is None:
|
Returns (grouped_by_key, errors). grouped_by_key maps env key →
|
||||||
return {}, ["infisical_unreachable"]
|
list of {uuid, key, value} dicts (Coolify may have duplicates per
|
||||||
project_id, env, path = _infisical_ctx()
|
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:
|
try:
|
||||||
secrets = client.get_all_secrets(
|
async with httpx.AsyncClient(timeout=20.0) as http:
|
||||||
environment=env, project_id=project_id, secret_path=path
|
resp = await http.get(
|
||||||
|
f"{base_url}/api/v1/applications/{app_uuid}/envs",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("infisical_read_failed: %s", e)
|
logger.warning("coolify_envs_unreachable: %s", e)
|
||||||
return {}, ["infisical_read_failed"]
|
return {}, ["coolify_unreachable"]
|
||||||
values: dict[str, str] = {}
|
if resp.status_code >= 400:
|
||||||
for s in secrets:
|
logger.warning(
|
||||||
if s.secret_key in ENV_CATALOG:
|
"coolify_envs_failed status=%s body=%s",
|
||||||
values[s.secret_key] = s.secret_value
|
resp.status_code, (resp.text or "")[:200],
|
||||||
return values, []
|
)
|
||||||
|
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(
|
def _build_env_var_row(
|
||||||
spec: EnvSpec,
|
spec: EnvSpec,
|
||||||
infisical_value: str | None,
|
coolify_entries: list[dict[str, Any]],
|
||||||
container_value: str | None,
|
container_value: str | None,
|
||||||
infisical_available: bool = True,
|
coolify_available: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build a single response row for an env var.
|
"""Build a single response row for an env var.
|
||||||
|
|
||||||
When infisical_available=False and infisical_value=None, drift is forced
|
`coolify_value` = authoritative value from Coolify (source of truth).
|
||||||
to False (we cannot detect drift without ground truth — UI shows the
|
`container_value` = what the running container sees in os.environ.
|
||||||
'errors' field instead).
|
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.
|
coolify_raw = _coolify_authoritative_value(coolify_entries)
|
||||||
if not infisical_available and infisical_value is None:
|
has_duplicates = len(coolify_entries) > 1
|
||||||
drift = False
|
if not coolify_available:
|
||||||
if spec.is_secret:
|
coolify_display: str | None = None
|
||||||
infisical_display: str | None = None
|
|
||||||
container_display: str | None = (
|
container_display: str | None = (
|
||||||
mask_secret(container_value) if container_value else None
|
mask_secret(container_value) if (spec.is_secret and container_value)
|
||||||
|
else container_value
|
||||||
)
|
)
|
||||||
else:
|
drift = False
|
||||||
infisical_display = None
|
|
||||||
container_display = container_value
|
|
||||||
elif spec.is_secret:
|
elif spec.is_secret:
|
||||||
i_norm = mask_secret(infisical_value) if infisical_value else None
|
coolify_display = mask_secret(coolify_raw) if coolify_raw else None
|
||||||
c_norm = mask_secret(container_value) if container_value else None
|
container_display = mask_secret(container_value) if container_value else None
|
||||||
# Raw comparison (not hash): values stay in server memory only — never
|
drift = bool(coolify_raw or container_value) and (
|
||||||
# logged or returned in the response. mask_secret is applied to display only.
|
(coolify_raw or "") != (container_value or "")
|
||||||
drift = (
|
|
||||||
(infisical_value or "") != (container_value or "")
|
|
||||||
and bool(infisical_value or container_value)
|
|
||||||
)
|
)
|
||||||
infisical_display = i_norm
|
|
||||||
container_display = c_norm
|
|
||||||
else:
|
else:
|
||||||
infisical_display = infisical_value
|
coolify_display = coolify_raw
|
||||||
container_display = container_value
|
container_display = container_value
|
||||||
drift = (
|
drift = (
|
||||||
normalize_for_compare(spec, infisical_value)
|
normalize_for_compare(spec, coolify_raw)
|
||||||
!= normalize_for_compare(spec, container_value)
|
!= 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
|
drift = False
|
||||||
row = spec.to_public_dict()
|
row = spec.to_public_dict()
|
||||||
row.update({
|
row.update({
|
||||||
"infisical_value": infisical_display,
|
"coolify_value": coolify_display,
|
||||||
"container_value": container_display,
|
"container_value": container_display,
|
||||||
"drift": drift,
|
"drift": drift,
|
||||||
|
"has_duplicates": has_duplicates,
|
||||||
})
|
})
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/settings/mcp/env")
|
@app.get("/api/settings/mcp/env")
|
||||||
async def api_mcp_env():
|
async def api_mcp_env():
|
||||||
"""List all catalog env vars with Infisical + container values."""
|
"""List all catalog env vars with Coolify (authoritative) + container values."""
|
||||||
infisical_values, errors = _read_infisical_values()
|
coolify_envs, errors = await _read_coolify_envs()
|
||||||
project_id, env, path = _infisical_ctx()
|
_, app_uuid, _ = _coolify_ctx()
|
||||||
infisical_available = not errors # empty errors list → Infisical reachable
|
coolify_available = not errors
|
||||||
rows = []
|
rows = []
|
||||||
for key, spec in ENV_CATALOG.items():
|
for key, spec in ENV_CATALOG.items():
|
||||||
i_val = infisical_values.get(key)
|
|
||||||
c_val = os.environ.get(key)
|
|
||||||
rows.append(
|
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 {
|
return {
|
||||||
"vars": rows,
|
"vars": rows,
|
||||||
"infisical_environment": env,
|
"coolify_app_uuid": app_uuid,
|
||||||
"infisical_project_id": project_id,
|
|
||||||
"infisical_path": path,
|
|
||||||
"coolify_app_uuid": os.environ.get(
|
|
||||||
"COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio"
|
|
||||||
),
|
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2685,7 +2714,7 @@ class McpEnvUpdateRequest(BaseModel):
|
|||||||
|
|
||||||
@app.patch("/api/settings/mcp/env/{key}")
|
@app.patch("/api/settings/mcp/env/{key}")
|
||||||
async def api_mcp_env_update(key: str, req: McpEnvUpdateRequest):
|
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)
|
spec = ENV_CATALOG.get(key)
|
||||||
if spec is None:
|
if spec is None:
|
||||||
raise HTTPException(404, f"Unknown env key: {key}")
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(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 (
|
str_value = "true" if coerced is True else (
|
||||||
"false" if coerced is False else str(coerced)
|
"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:
|
try:
|
||||||
# SDK pattern: try update, fall back to create if the secret doesn't exist.
|
async with httpx.AsyncClient(timeout=15.0) as http:
|
||||||
# The Infisical SDK's specific NotFound exception class isn't stable across
|
resp = await http.patch(
|
||||||
# versions, so we inspect the error string. Network/auth errors (which DON'T
|
f"{base_url}/api/v1/applications/{app_uuid}/envs",
|
||||||
# contain 'not found' / 404 / 'does not exist') are re-raised immediately
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
# so they reach the outer handler and surface as 502 with the real error,
|
json={"key": key, "value": str_value},
|
||||||
# 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,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("infisical_write_failed key=%s", key)
|
logger.exception("coolify_env_write_unreachable key=%s", key)
|
||||||
raise HTTPException(502, f"Infisical write failed: {e}")
|
raise HTTPException(502, f"Coolify unreachable: {e}")
|
||||||
|
if resp.status_code >= 400:
|
||||||
logger.info(
|
body_preview = (resp.text or "")[:200]
|
||||||
"mcp_env_update key=%s value=%s",
|
logger.warning(
|
||||||
key,
|
"coolify_env_write_failed key=%s status=%s body=%s",
|
||||||
"[masked]" if spec.is_secret else str_value,
|
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, str_value)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"key": key,
|
"key": key,
|
||||||
"saved_value": str_value,
|
"saved_value": str_value,
|
||||||
"requires_redeploy": True,
|
"requires_redeploy": True,
|
||||||
"message": "נשמר ב-Infisical. נדרש redeploy כדי שיכנס לתוקף בקונטיינר.",
|
"message": "נשמר ב-Coolify. נדרש redeploy כדי שהקונטיינר יקרא את הערך החדש.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2772,18 +2779,16 @@ async def api_mcp_env_redeploy():
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
429, f"Redeploy בהמתנה: נסה שוב בעוד {wait} שניות."
|
429, f"Redeploy בהמתנה: נסה שוב בעוד {wait} שניות."
|
||||||
)
|
)
|
||||||
coolify_url = os.environ.get("COOLIFY_URL", "http://158.178.131.193:8000")
|
base_url, app_uuid, token = _coolify_ctx()
|
||||||
coolify_token = os.environ.get("COOLIFY_API_TOKEN", "")
|
if not token:
|
||||||
app_uuid = os.environ.get("COOLIFY_APP_UUID", "gyjo0mtw2c42ej3xxvbz8zio")
|
|
||||||
if not coolify_token:
|
|
||||||
raise HTTPException(503, "COOLIFY_API_TOKEN not configured")
|
raise HTTPException(503, "COOLIFY_API_TOKEN not configured")
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as http:
|
async with httpx.AsyncClient(timeout=30.0) as http:
|
||||||
try:
|
try:
|
||||||
resp = await http.post(
|
resp = await http.post(
|
||||||
f"{coolify_url}/api/v1/deploy",
|
f"{base_url}/api/v1/deploy",
|
||||||
params={"uuid": app_uuid, "force": "false"},
|
params={"uuid": app_uuid, "force": "false"},
|
||||||
headers={"Authorization": f"Bearer {coolify_token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(502, f"Coolify unreachable: {e}")
|
raise HTTPException(502, f"Coolify unreachable: {e}")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# web/mcp_env_catalog.py
|
# web/mcp_env_catalog.py
|
||||||
"""Static catalog of MCP server env vars exposed in the settings UI.
|
"""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
|
from __future__ import annotations
|
||||||
|
|||||||
Reference in New Issue
Block a user