Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ including those that only surface once you provision connections yourself.
or alerting.
- CLI mode runs one `hookdeck listen` process per route. That is fine for a
handful; a gateway with dozens of routes wants push mode.
- In CLI mode the CLI's project and the API key's project are separate pieces
of state, and only the operator reconciles them. `hermes hookdeck setup`,
`status`, `retry`, the dashboard tab and the agent tools all act on the
project `HOOKDECK_API_KEY` belongs to; `hookdeck listen` forwards from
whichever project `~/.config/hookdeck/config.toml` records. When they
differ the gateway starts, reports healthy, and receives nothing — every
event is ignored as `CLI_DISCONNECTED` while the tunnel restart-loops. Run
`hermes hookdeck doctor`, which compares the two and names both project ids.
`cli_config` points the CLI at a config of the gateway's own, but populating
one still needs `hookdeck ci`: the CLI does not accept a project API key as
a session key.
- `setup` only pushes an `events` filter down into Hookdeck when it knows where
the event name lives: a header for GitHub, GitLab and Shopify, or a body path
you set with `event_path`. Otherwise the filter stays adapter-side, because a
Expand Down
15 changes: 15 additions & 0 deletions examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ gateway:
# gateway should do to a shared tool. Run `hookdeck login` instead.
# cli_login: false

# Which CLI config `hookdeck listen` reads, via --hookdeck-config.
# Unset, it uses ~/.config/hookdeck/config.toml — shared with every
# other Hookdeck CLI use on the machine, and its active project is
# ambient state this gateway does not control. That project is what
# `hookdeck listen` forwards from, and it is entirely independent of
# HOOKDECK_API_KEY: when the two differ, `hermes hookdeck setup`
# creates the connection in one project while the tunnel looks for it
# in the other, and the gateway reports healthy while receiving
# nothing. `hermes hookdeck doctor` compares them and says so.
#
# Note that populating a gateway-owned config still requires
# `hookdeck ci`, because the CLI will not accept a project API key as
# a session key.
# cli_config: ~/.hermes/hookdeck-cli.toml

routes:
# A GitHub PR reviewer. `source` is the Hookdeck source name.
github-prs:
Expand Down
1 change: 1 addition & 0 deletions hookdeck/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ async def _start_tunnels(self) -> bool:
connection_name=route_name,
binary=self.settings.cli_binary,
login=self.settings.cli_login,
config_path=self.settings.cli_config,
)
await tunnel.start()
self._tunnels.append(tunnel)
Expand Down
91 changes: 89 additions & 2 deletions hookdeck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import Any, Optional

from .api import HookdeckAPI, HookdeckAPIError, run_sync
from .cliconfig import DEFAULT_CLI_CONFIG, cli_project_id
from .constants import DEFAULT_PATH, DEFAULT_PORT
from .provision import (
build_connection_payload,
Expand Down Expand Up @@ -442,6 +443,17 @@ def _check_routes(routes: dict) -> Check:
)


def _cli_config_path(extra: dict) -> Path:
"""Where the CLI's session lives, honouring an override.

``hookdeck listen`` takes ``--hookdeck-config``, so an operator who points
the gateway at a config of its own must have doctor read the same one —
otherwise the check reports on a file nothing uses.
"""
configured = extra.get("cli_config")
return Path(configured).expanduser() if configured else DEFAULT_CLI_CONFIG


def _check_cli(extra: dict) -> list[Check]:
"""The CLI is only reachable in cli mode, and only the resolved one matters."""
configured = extra.get("cli_binary") or "hookdeck"
Expand Down Expand Up @@ -505,7 +517,79 @@ def _report_stranded_runs() -> None:
ledger.close()


async def _check_live_connections(routes: dict) -> list[Check]:
async def _api_project_id(
api: HookdeckAPI, already_found: list[dict]
) -> Optional[str]:
"""Which project the API key belongs to.

There is no endpoint that answers this directly, but every resource the key
can reach carries ``team_id`` — so the connections the caller already
listed answer it for free, and only an empty result needs a request of its
own. ``None`` means the key reaches no connections at all, which is not
itself a fault: a project can legitimately be empty.
"""
for connection in already_found:
if connection.get("team_id"):
return str(connection["team_id"])

result = await api.list_connections(limit=1)
models = (result or {}).get("models") or (result or {}).get("data") or []
for connection in models:
if connection.get("team_id"):
return str(connection["team_id"])
return None


async def _check_cli_project(
api: HookdeckAPI, extra: dict, already_found: list[dict]
) -> Check:
"""Whether the CLI forwards from the project the API key acts on.

These are two unrelated pieces of state — an env var and
``~/.config/hookdeck/config.toml`` — and nothing else reconciles them. When
they differ, `setup` creates the connection in one project while
``hookdeck listen`` looks for it in the other: the gateway logs that it is
listening, the tunnel restart-loops out of sight, and every event is
ignored as ``CLI_DISCONNECTED``. From the outside that is indistinguishable
from "no events are arriving", which is the whole class of problem this
command exists to make diagnosable.
"""
config_path = _cli_config_path(extra)
cli_project = cli_project_id(config_path)
api_project = await _api_project_id(api, already_found)

if cli_project is None:
return Check(
False,
f"No Hookdeck CLI session found at {config_path} — `hookdeck "
"listen` cannot start, so no events will reach the gateway. Run "
"`hookdeck login`.",
)
if api_project is None:
return Check(
True,
f"Hookdeck CLI is logged into project {cli_project}",
note="The API key's project could not be determined, because it "
"reaches no connections yet — so the two are unverified. Re-run "
"this after `hermes hookdeck setup`.",
)
if cli_project != api_project:
return Check(
False,
f"Project mismatch: the CLI forwards from {cli_project} but the "
f"API key acts on {api_project}. `hermes hookdeck setup` creates "
"connections in the API key's project while `hookdeck listen` "
"looks for them in the CLI's, so the gateway will report healthy "
"and receive nothing.",
note="Point the CLI at the same project with `hookdeck login`, or "
"set the API key to one belonging to " + cli_project + ".",
)
return Check(True, f"CLI and API key agree on project {api_project}")


async def _check_live_connections(
routes: dict, *, mode: str, extra: dict
) -> list[Check]:
"""Reachability, plus whether each retry rule covers what the adapter emits.

A rule narrower than the emitted statuses is silent data loss — a deferred
Expand All @@ -523,6 +607,9 @@ async def _check_live_connections(routes: dict) -> list[Check]:
result = await api.list_connections(name=route_name, limit=10)
found += (result or {}).get("models") or (result or {}).get("data") or []

if mode == "cli":
checks.append(await _check_cli_project(api, extra, found))

for connection in found:
if connection.get("name") not in routes:
continue
Expand Down Expand Up @@ -575,7 +662,7 @@ def _cmd_doctor(_args: argparse.Namespace) -> int:
if os.getenv("HOOKDECK_API_KEY"):
print()
try:
live = run_sync(_check_live_connections(routes))
live = run_sync(_check_live_connections(routes, mode=mode, extra=extra))
except HookdeckAPIError as exc:
live = [Check(False, f"Hookdeck API check failed: {exc}")]
for check in live:
Expand Down
86 changes: 86 additions & 0 deletions hookdeck/cliconfig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Reading the Hookdeck CLI's own configuration.

In ``cli`` mode there are two independent notions of "which project": the API
key, which every ``hermes hookdeck`` command and the dashboard act on, and the
CLI's stored session, which is what ``hookdeck listen`` actually forwards from.
Nothing reconciles them, and when they differ the gateway reports healthy while
no event ever arrives — the tunnel restart-loops out of sight, and the source
gets auto-created in the wrong project on the way.

This module is the read-only half of catching that: it reports which project
the CLI would use. Nothing here writes, because ``hookdeck ci`` — the only way
to change it — rewrites the operator's shared config and switches its active
project, which is not something starting a gateway should do.
"""

from __future__ import annotations

import re
from pathlib import Path

#: Where the CLI keeps its session unless told otherwise, matching the CLI's
#: own ``--hookdeck-config`` default.
DEFAULT_CLI_CONFIG = Path.home() / ".config" / "hookdeck" / "config.toml"


def _parse_toml(text: str) -> dict:
"""Parse the CLI config, by whatever means this interpreter has.

``tomllib`` is stdlib from 3.11 and this package supports 3.10, so there is
a fallback. The file is a handful of ``key = "value"`` lines under one
table header, so the fallback covers it — and a diagnostic that degrades is
better than one that raises on the older interpreter.
"""
try:
import tomllib
except ImportError: # pragma: no cover - only on 3.10
return _parse_flat_toml(text)
try:
return tomllib.loads(text)
except Exception: # noqa: BLE001 - a malformed config is "unknown", not fatal
return _parse_flat_toml(text)


_ASSIGNMENT = re.compile(r"""^\s*([A-Za-z0-9_-]+)\s*=\s*(.+?)\s*$""")
_TABLE = re.compile(r"""^\s*\[([^\]]+)\]\s*$""")


def _parse_flat_toml(text: str) -> dict:
"""Enough TOML for this one file: top-level keys and single-level tables."""
result: dict = {}
table = result
for line in text.splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
header = _TABLE.match(line)
if header:
table = result.setdefault(header.group(1).strip(), {})
continue
assignment = _ASSIGNMENT.match(line)
if assignment:
table[assignment.group(1)] = assignment.group(2).strip().strip('"\'')
return result


def cli_project_id(path: Path | None = None) -> str | None:
"""The project ``hookdeck listen`` would forward from.

``None`` when it cannot be determined — no config, unreadable, or no
project recorded. That is reported as "unknown" rather than as a mismatch,
because guessing here would be worse than saying so.
"""
config_path = DEFAULT_CLI_CONFIG if path is None else path
try:
text = config_path.read_text()
except OSError:
return None

parsed = _parse_toml(text)
# The CLI supports named profiles and records which one is active at the
# top level; each profile is a table of its own.
profile = parsed.get("profile") or "default"
section = parsed.get(profile)
if not isinstance(section, dict):
return None
project = section.get("project_id")
return str(project) if project else None
8 changes: 8 additions & 0 deletions hookdeck/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ class AdapterSettings:
ledger_ttl_seconds: float = DEFAULT_LEDGER_TTL_SECONDS
max_body_bytes: int = DEFAULT_MAX_BODY_BYTES
cli_binary: str = "hookdeck"
#: Path passed to `hookdeck listen --hookdeck-config`. Empty means the
#: CLI's shared default, whose active project is ambient state the gateway
#: does not control — `hermes hookdeck doctor` reports when that disagrees
#: with the API key's project.
cli_config: str = ""
cli_login: bool = False

# ------------------------------------------------------------------
Expand Down Expand Up @@ -186,6 +191,9 @@ def text(key: str, env: str = "", default: str = "") -> str:
# An npm global shadowing a Homebrew install is the common case,
# and PATH silently picks the older one.
cli_binary=text("cli_binary", default="hookdeck"),
cli_config=str(
Path(extra["cli_config"]).expanduser() if extra.get("cli_config") else ""
),
# Off by default: `hookdeck ci` rewrites the shared CLI config and
# repoints its active project — not something starting a gateway
# should do to a tool the operator uses for other work.
Expand Down
Loading