mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 20:04:43 +02:00
fix(4-1): resolve api_key alias from per-adapter settings, not class attr
The /adapters list view's "⚠️ API Key Missing" chip, the /adapters/{name}
edit form's disabled enable-checkbox, the POST error re-render path, AND
the supervisor's adapter-start precondition all compared the hardcoded
SourceAdapter class attribute `requires_api_key` against `config.api_keys`,
ignoring the per-row `settings[api_key_field]` alias the operator
actually selected via the form.
FIRMS' class attr is `requires_api_key = "firms"`; the api_keys_new.html
placeholder text steers operators toward aliases like `firms_production`
instead, and the FIRMSSettings.api_key_alias field is exactly the
overridable slot that the form writes. The four predicates ignored that
slot, so a working key under any non-default alias was treated as
missing — chip on, checkbox disabled, supervisor refusing to start with
`last_error = "missing api key: firms"`.
Audit: FIRMS is the only adapter today with `requires_api_key != None`.
Every other adapter is unaffected by either the route or supervisor
predicate.
Helper module:
- src/central/api_key_resolver.py exposes:
resolve_api_key_alias(adapter_cls, settings) -> str | None
Pure sync function. Returns the alias to consult, or None when no
key is required. Supervisor uses this directly + its own
get_api_key.
adapter_has_resolved_api_key(conn, adapter_cls, settings) -> (bool, alias)
Async wrapper that runs the SELECT 1 against config.api_keys.
The three GUI routes use this.
Resolution: settings[api_key_field] when set to a non-empty str,
otherwise the class-attr default.
Four call sites swapped:
- routes.py:adapters_list (/adapters list — warning chip)
- routes.py:adapters_edit_form (/adapters/{name} edit GET — disabled checkbox)
- routes.py:adapters_edit_submit (POST error re-render)
- supervisor.py:_start_adapter (adapter-start precondition)
Side-effect tests/test_adapters.py fix:
- TestAdaptersJsonbRegression::test_adapters_edit_fetches_api_keys_into_context
used `AsyncMock()` (no return_value) for mock_conn.__aexit__. AsyncMock
without a return_value yields a MagicMock — which is truthy, and the
async context manager protocol reads truthy from __aexit__ as
"exception suppressed." That silently swallowed any error inside
`async with` blocks. The route refactor moved an assignment inside the
one async with at site 2, so a swallowed mock error left the variable
unbound. Fixed: `AsyncMock(return_value=None)` + a comment so the next
person doesn't re-introduce the bug. fetchval mock added because the
resolver now issues it (the swallowed exception previously hid the
missing mock).
Verification:
- pytest: 479 passed (was 469; +10 new resolver tests).
- grep -rn "adapter_cls.requires_api_key" /opt/central/src returns only
the new helper (2 lines, same file).
- Resolver against live FIRMS settings: resolved_alias='firms_production',
has_key=True, api_key_missing=False -> NO warning chip, checkbox
CLICKABLE.
- Supervisor on live CT104: FIRMS flipped enabled=true via DB UPDATE;
supervisor started the adapter with `api_key_present: true,
api_key_alias: 'firms_production'`; last_error cleared from "missing
api key: firms" -> NULL; two satellite polls completed (VIIRS_SNPP_NRT
477 features, VIIRS_NOAA20_NRT 400 features); 869 new events published
to JetStream.
NOTE: This commit's verification flipped FIRMS to enabled=true in the
running config — the adapter is now actively polling. Pause via the UI
if that's not intended for now; the bug fix itself does not require
FIRMS to be enabled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
48bcb33096
commit
7de460bc06
5 changed files with 369 additions and 40 deletions
82
src/central/api_key_resolver.py
Normal file
82
src/central/api_key_resolver.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Resolve an adapter row's effective api-key alias and check existence.
|
||||
|
||||
Four call sites share this answer: the /adapters list view, the
|
||||
/adapters/{name} edit form (GET), the same edit form's POST error
|
||||
re-render path, and the supervisor's adapter-start precondition.
|
||||
Previously each inlined a predicate that compared the hardcoded
|
||||
SourceAdapter class attribute `requires_api_key` against `config.api_keys`,
|
||||
ignoring the per-row `settings[api_key_field]` value the operator
|
||||
actually selected. An operator could pick alias "firms_production" via
|
||||
the form, store a working key under that alias, and still see the
|
||||
"⚠️ API Key Missing" chip + a disabled enable checkbox + the supervisor
|
||||
refusing to start the adapter — all from the same root cause.
|
||||
|
||||
Two entry points:
|
||||
|
||||
- `resolve_api_key_alias` — sync. Pure function of (cls, settings).
|
||||
Returns the alias the row should consult, or None when no key is
|
||||
required (no SQL needed). Supervisor uses this directly because it
|
||||
already has a ConfigStore and does its own key fetch.
|
||||
|
||||
- `adapter_has_resolved_api_key` — async. Wraps the sync resolver with
|
||||
the existence SELECT against config.api_keys. The three GUI route
|
||||
call sites use this.
|
||||
|
||||
Resolution order (same as the adapter's runtime read in __init__):
|
||||
1. requires_api_key is None -> no key needed.
|
||||
2. settings[api_key_field] is a non-empty string -> use that.
|
||||
3. fall back to the requires_api_key class-attribute default.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from central.adapter import SourceAdapter
|
||||
|
||||
|
||||
def resolve_api_key_alias(
|
||||
adapter_cls: type[SourceAdapter] | None,
|
||||
settings: dict | None,
|
||||
) -> str | None:
|
||||
"""Return the api-key alias for this adapter row, or None when no key is required.
|
||||
|
||||
Pure function — no IO, no SQL. Same resolution order the adapter's own
|
||||
__init__ uses when reading the alias out of config.settings.
|
||||
"""
|
||||
if adapter_cls is None or adapter_cls.requires_api_key is None:
|
||||
return None
|
||||
field = adapter_cls.api_key_field
|
||||
if field and settings:
|
||||
value = settings.get(field)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return adapter_cls.requires_api_key
|
||||
|
||||
|
||||
async def adapter_has_resolved_api_key(
|
||||
conn: Any,
|
||||
adapter_cls: type[SourceAdapter] | None,
|
||||
settings: dict | None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Return (has_key, resolved_alias) for one adapter row.
|
||||
|
||||
Args:
|
||||
conn: asyncpg connection or any object exposing an awaitable fetchval
|
||||
with the same signature.
|
||||
adapter_cls: The discovered SourceAdapter subclass for this row, or
|
||||
None when the row references an adapter no longer in the codebase.
|
||||
settings: The row's jsonb settings dict, or None when the row has no
|
||||
settings stored yet.
|
||||
|
||||
Returns:
|
||||
(has_key, resolved_alias). When the adapter does not require an api
|
||||
key (requires_api_key is None) or no adapter class is available,
|
||||
returns (True, None) and no SQL is issued.
|
||||
"""
|
||||
alias = resolve_api_key_alias(adapter_cls, settings)
|
||||
if alias is None:
|
||||
return True, None
|
||||
has_key = await conn.fetchval(
|
||||
"SELECT 1 FROM config.api_keys WHERE alias = $1",
|
||||
alias,
|
||||
)
|
||||
return bool(has_key), alias
|
||||
|
|
@ -51,6 +51,7 @@ from pathlib import Path
|
|||
from central.config_models import AdapterConfig
|
||||
from central.gui.db import get_pool
|
||||
from central.gui.form_descriptors import describe_fields, FieldDescriptor
|
||||
from central.api_key_resolver import adapter_has_resolved_api_key
|
||||
from central.adapter_discovery import discover_adapters
|
||||
from central.streams import STREAMS as STREAM_REGISTRY
|
||||
from pydantic import ValidationError
|
||||
|
|
@ -1348,16 +1349,13 @@ async def adapters_list(
|
|||
settings = row["settings"] or {}
|
||||
adapter_cls = adapter_classes.get(row["name"])
|
||||
|
||||
# Check if required API key is missing
|
||||
api_key_missing = False
|
||||
requires_api_key_alias = None
|
||||
if adapter_cls and adapter_cls.requires_api_key is not None:
|
||||
requires_api_key_alias = adapter_cls.requires_api_key
|
||||
has_key = await conn.fetchval(
|
||||
"SELECT 1 FROM config.api_keys WHERE alias = $1",
|
||||
requires_api_key_alias,
|
||||
)
|
||||
api_key_missing = not has_key
|
||||
# Check if required API key is missing — resolve via the per-row
|
||||
# settings[api_key_field] (operator-selected alias), falling back
|
||||
# to the class-attribute default when settings hasn't been set.
|
||||
has_key, requires_api_key_alias = await adapter_has_resolved_api_key(
|
||||
conn, adapter_cls, settings,
|
||||
)
|
||||
api_key_missing = not has_key
|
||||
|
||||
adapters.append({
|
||||
"name": row["name"],
|
||||
|
|
@ -1445,23 +1443,15 @@ async def adapters_edit_form(
|
|||
if f.name == adapter_cls.api_key_field:
|
||||
f.widget = "api_key_select"
|
||||
|
||||
# Fetch API keys for api_key_select widget
|
||||
api_keys = []
|
||||
# Fetch API keys for api_key_select widget + resolve the per-adapter
|
||||
# alias against the operator-set settings, not the class-attr default.
|
||||
async with pool.acquire() as conn:
|
||||
api_key_rows = await conn.fetch("SELECT alias FROM config.api_keys ORDER BY alias")
|
||||
api_keys = [{"alias": r["alias"]} for r in api_key_rows]
|
||||
|
||||
# Check if required API key is missing
|
||||
api_key_missing = False
|
||||
requires_api_key_alias = None
|
||||
if adapter_cls and adapter_cls.requires_api_key is not None:
|
||||
requires_api_key_alias = adapter_cls.requires_api_key
|
||||
async with pool.acquire() as conn:
|
||||
has_key = await conn.fetchval(
|
||||
"SELECT 1 FROM config.api_keys WHERE alias = $1",
|
||||
requires_api_key_alias,
|
||||
)
|
||||
api_key_missing = not has_key
|
||||
has_key, requires_api_key_alias = await adapter_has_resolved_api_key(
|
||||
conn, adapter_cls, settings,
|
||||
)
|
||||
api_key_missing = not has_key
|
||||
|
||||
# Generic settings-driven preview. Adapters opt in by overriding
|
||||
# SourceAdapter.preview_for_settings; the framework is duck-typed on the
|
||||
|
|
@ -1700,20 +1690,15 @@ async def adapters_edit_submit(
|
|||
if f.name == adapter_cls.api_key_field:
|
||||
f.widget = "api_key_select"
|
||||
|
||||
# Fetch API keys for api_key_select widget
|
||||
# Fetch API keys for api_key_select widget + resolve the per-adapter
|
||||
# alias against the pre-edit settings (form validation failed, so
|
||||
# the stored settings haven't been replaced).
|
||||
api_key_rows = await conn.fetch("SELECT alias FROM config.api_keys ORDER BY alias")
|
||||
api_keys = [{"alias": r["alias"]} for r in api_key_rows]
|
||||
|
||||
# Check if required API key is missing
|
||||
api_key_missing = False
|
||||
requires_api_key_alias = None
|
||||
if adapter_cls and adapter_cls.requires_api_key is not None:
|
||||
requires_api_key_alias = adapter_cls.requires_api_key
|
||||
has_key = await conn.fetchval(
|
||||
"SELECT 1 FROM config.api_keys WHERE alias = $1",
|
||||
requires_api_key_alias,
|
||||
)
|
||||
api_key_missing = not has_key
|
||||
has_key, requires_api_key_alias = await adapter_has_resolved_api_key(
|
||||
conn, adapter_cls, current_settings,
|
||||
)
|
||||
api_key_missing = not has_key
|
||||
|
||||
csrf_token = request.state.csrf_token
|
||||
response = templates.TemplateResponse(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from central.config_models import AdapterConfig
|
|||
from central.config_source import ConfigSource, DbConfigSource
|
||||
from central.config_store import ConfigStore
|
||||
from central.bootstrap_config import get_settings
|
||||
from central.api_key_resolver import resolve_api_key_alias
|
||||
from central.stream_manager import StreamManager
|
||||
from central.streams import STREAMS as STREAM_REGISTRY
|
||||
CURSOR_DB_PATH = Path("/var/lib/central/cursors.db")
|
||||
|
|
@ -263,10 +264,13 @@ class Supervisor:
|
|||
If the adapter was previously stopped (state exists but task is not running),
|
||||
reuses the existing state to preserve last_completed_poll for rate limiting.
|
||||
"""
|
||||
# API key precondition
|
||||
# API key precondition — resolve via per-row settings[api_key_field]
|
||||
# (operator-selected alias), falling back to the class-attribute
|
||||
# default when settings hasn't been set. Returns None when no key
|
||||
# is required.
|
||||
adapter_cls = self._adapters.get(config.name)
|
||||
if adapter_cls is not None and adapter_cls.requires_api_key is not None:
|
||||
alias = adapter_cls.requires_api_key
|
||||
alias = resolve_api_key_alias(adapter_cls, config.settings)
|
||||
if alias is not None:
|
||||
key_value = await self._config_store.get_api_key(alias)
|
||||
if not key_value:
|
||||
error_msg = f"missing api key: {alias}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue