Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
87dcd01
Add firewall guidance when Supabase sign-in connection is reset/refus…
claude Jun 14, 2026
017d224
Address review: log firewall hint at WARNING and scope to login flow …
claude Jun 14, 2026
21513c0
docs: add firewall troubleshooting entry and link it from the sign-in…
claude Jun 14, 2026
0d38f6b
docs: add language specifier to troubleshooting code fence (MD040)
claude Jun 14, 2026
abe9ff1
docs: make firewall health-check region-aware with robusta-region sel…
claude Jun 14, 2026
74a38f4
docs: note sp.robusta.dev is covered by the robusta-region fence in C…
claude Jun 14, 2026
4bf7e6c
docs: fix firewall health-check — Holmes crashes, so can't exec into …
claude Jun 14, 2026
5820ad0
docs: use the Holmes deployment image for the firewall health-check
claude Jun 14, 2026
33a796a
docs: make firewall health-check copy-paste — auto-detect Holmes ns &…
claude Jun 14, 2026
a46ec61
Merge branch 'master' into claude/dazzling-gauss-nespl7
moshemorad Jun 14, 2026
f31e82d
docs: add screenshot of the firewall error as it appears in pod logs
claude Jun 14, 2026
d77b296
Merge remote-tracking branch 'origin/claude/dazzling-gauss-nespl7' in…
claude Jun 14, 2026
f5bd2a1
Simplify the firewall log/exception message and defer how-to-fix to t…
claude Jun 14, 2026
2f507af
Show the troubleshooting URL once (in the exception, not also the log)
claude Jun 15, 2026
e729eb4
Move firewall guidance into the WARNING log; keep the exception thin
claude Jun 15, 2026
78bd6ab
docs: remove the startup-log screenshot from the troubleshooting page
claude Jun 15, 2026
0434427
Merge branch 'master' into claude/dazzling-gauss-nespl7
moshemorad Jun 15, 2026
500a1c3
Merge branch 'master' into claude/dazzling-gauss-nespl7
moshemorad Jun 15, 2026
912b95a
Merge branch 'master' into claude/dazzling-gauss-nespl7
moshemorad Jun 15, 2026
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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ When writing documentation in the `docs/` directory:

## Robusta Platform/API URLs in Docs

Whenever you reference `api.robusta.dev` or `platform.robusta.dev` in a docs page, use the `robusta-region` custom fence so readers can pick US/EU/AP. **Never hardcode a single region** — the Robusta platform is hosted in multiple regions and a bare `api.robusta.dev` link silently breaks for EU/AP users.
Whenever you reference `api.robusta.dev`, `platform.robusta.dev`, or `sp.robusta.dev` (the Supabase host) in a docs page, use the `robusta-region` custom fence so readers can pick US/EU/AP. **Never hardcode a single region** — the Robusta platform is hosted in multiple regions and a bare `api.robusta.dev` link silently breaks for EU/AP users.

The fence (defined in `docs/custom_fences.py`, registered in `mkdocs.yml`) takes a US URL as input and emits a three-tab picker with the domain rewritten per region. Pick the input shape that matches your context:

Expand Down Expand Up @@ -886,6 +886,6 @@ The fence (defined in `docs/custom_fences.py`, registered in `mkdocs.yml`) takes
```
````

Author the URL once using the US domain (`api.robusta.dev` / `platform.robusta.dev`); the fence handles the `.eu` / `.ap` rewrites. If you add a new region or rename one, update `ROBUSTA_REGIONS` in `docs/custom_fences.py` — that single change propagates to every page.
Author the URL once using the US domain (`api.robusta.dev` / `platform.robusta.dev` / `sp.robusta.dev`); the fence handles the `.eu` / `.ap` rewrites. The set of rewritten hosts lives in `ROBUSTA_DOMAIN_RE` in `docs/custom_fences.py` — add a host there if you need another subdomain covered. If you add a new region or rename one, update `ROBUSTA_REGIONS` in the same file — that single change propagates to every page.

Existing usages (greppable starting points): `docs/ai-providers/robusta-ai.md`, `docs/installation/ui-installation.md`, `docs/reference/environment-variables.md`, `docs/data-sources/builtin-toolsets/coralogix-logs.md`, `docs/data-sources/builtin-toolsets/kubernetes-mcp.md`.
Existing usages (greppable starting points): `docs/ai-providers/robusta-ai.md`, `docs/installation/ui-installation.md`, `docs/reference/environment-variables.md`, `docs/reference/troubleshooting.md`, `docs/data-sources/builtin-toolsets/coralogix-logs.md`, `docs/data-sources/builtin-toolsets/kubernetes-mcp.md`.
12 changes: 6 additions & 6 deletions docs/custom_fences.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
Fences available:
- yaml-toolset-config: Creates 3 tabs (Holmes CLI, Holmes Helm Chart, Robusta Helm Chart) for toolset configurations
- yaml-helm-values: Creates 2 tabs (Holmes Helm Chart, Robusta Helm Chart) for Helm-only configurations like permissions
- robusta-region: Creates 3 tabs (US, EU, AP) for any text containing api.robusta.dev or platform.robusta.dev. Plain
URLs render as code blocks; markdown links `[text](url)` render as clickable links.
- robusta-region: Creates 3 tabs (US, EU, AP) for any text containing api.robusta.dev, platform.robusta.dev, or
sp.robusta.dev. Plain URLs render as code blocks; markdown links `[text](url)` render as clickable links.
"""

import html
Expand All @@ -15,12 +15,12 @@
import yaml # type: ignore

ROBUSTA_REGIONS = (("US", ""), ("EU", "eu"), ("AP", "ap"))
ROBUSTA_DOMAIN_RE = re.compile(r"\b(api|platform)\.robusta\.dev\b")
ROBUSTA_DOMAIN_RE = re.compile(r"\b(api|platform|sp)\.robusta\.dev\b")
MARKDOWN_LINK_RE = re.compile(r"^\[([^\]]+)\]\(([^)\s]+)\)(\{[^}]*\})?$")


def _rewrite_robusta_domain(text: str, region_infix: str) -> str:
"""Rewrite api.robusta.dev / platform.robusta.dev to the regional variant."""
"""Rewrite api/platform/sp .robusta.dev to the regional variant."""
if not region_infix:
return text
return ROBUSTA_DOMAIN_RE.sub(rf"\1.{region_infix}.robusta.dev", text)
Expand Down Expand Up @@ -140,8 +140,8 @@ def helm_tabs_fence_format(source, language, css_class, options, md, **kwargs):

def robusta_region_fence_format(source, language, css_class, options, md, **kwargs):
"""
Render the source as three tabs (US, EU, AP), rewriting `api.robusta.dev`
and `platform.robusta.dev` to the regional subdomain in each tab.
Render the source as three tabs (US, EU, AP), rewriting `api.robusta.dev`,
`platform.robusta.dev` and `sp.robusta.dev` to the regional subdomain in each tab.

Auto-detects two input shapes:

Expand Down
23 changes: 23 additions & 0 deletions docs/reference/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ export LLM_EXTRA_STRIP_MESSAGE_FIELDS="provider_specific_fields"

Replace the value with whichever field is named in your error message. Multiple fields can be passed, e.g. `"provider_specific_fields,reasoning_content"`.

## 7. Startup Fails with `Connection reset by peer` { #firewall-blocking-robusta-platform }

HolmesGPT crashes on startup while signing in to the Robusta platform, with a traceback ending in:

```text
httpx.ConnectError: [Errno 104] Connection reset by peer
```

This means an **outbound firewall or egress policy is blocking traffic from your cluster to the Robusta platform**. The hostname resolves and the TLS certificate is valid, so it is not a DNS or certificate problem — the connection itself is being reset or refused.

**Solution:**

Allow outbound HTTPS (port 443) from the HolmesGPT pod to the Robusta platform — i.e. allowlist the `robusta.dev` domain (`*.robusta.dev`), which covers the `api.*` and `sp.*` subdomains across all regions.

To confirm the block, run the one-off pod below. It **auto-detects the Holmes pod's namespace and image** and curls the platform from a fresh pod — the HolmesGPT pod itself crashes on this error (`CrashLoopBackOff`), so `kubectl exec` into it won't work. Reusing Holmes's own image means nothing new is pulled (the same firewall may also block image pulls) and it shares Holmes's CA and network config. Just pick your region — a firewall block shows `Connection reset by peer`, while a reachable endpoint returns JSON:

```robusta-region {lang=bash}
read -r NS IMG <<<"$(kubectl get pods -A -l app=holmes -o jsonpath='{.items[0].metadata.namespace} {.items[0].spec.containers[0].image}')"
kubectl run holmes-egress-check --rm -it --restart=Never -n "$NS" --image="$IMG" --command -- curl -vk https://sp.robusta.dev/auth/v1/health
```

If the same logs also show a LiteLLM warning about failing to fetch the model cost map from `raw.githubusercontent.com`, that is the same firewall blocking GitHub egress — point Holmes at a region-local mirror with [`LITELLM_MODEL_COST_MAP_URL`](environment-variables.md#litellm_model_cost_map_url).

---

## Still stuck?
Expand Down
52 changes: 52 additions & 0 deletions holmes/core/supabase_dal.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ class RobustaToken(BaseModel):
password: str


# Troubleshooting guide for an outbound firewall blocking egress to the Robusta
# platform (surfaces as a connection reset during sign-in). Linked from the log
# and exception so users can find the fix.
FIREWALL_TROUBLESHOOTING_URL = (
"https://holmesgpt.dev/reference/troubleshooting/#firewall-blocking-robusta-platform"
)


class SupabaseDnsException(Exception):
def __init__(self, error: Exception, url: str):
message = (
Expand All @@ -138,6 +146,23 @@ def __init__(self, error: Exception, url: str):
super().__init__(message)


class SupabaseConnectionException(Exception):
"""Raised when Holmes cannot open a connection to the Robusta platform.

Almost always an outbound firewall / egress policy blocking traffic to the
Robusta platform (not a DNS or TLS certificate problem). The actionable
guidance - allowlist '*.robusta.dev' plus the docs link - is logged at
WARNING right before this is raised, so the exception message itself stays a
thin technical wrapper around the underlying connection error.
"""

def __init__(self, error: Exception, url: str):
super().__init__(
f"Could not connect to the Robusta platform at {url} "
f"({error.__class__.__name__}: {error})"
)


class SupabaseDal:
def __init__(self, cluster: str):
self.enabled = self.__init_config()
Expand Down Expand Up @@ -291,6 +316,33 @@ def sign_in(self) -> str:
]
):
raise SupabaseDnsException(e, self.url) from e
if isinstance(e, (ConnectionError, TimeoutError)) or any(
conn_indicator in error_msg
for conn_indicator in [
"connection reset by peer",
"connection reset",
"connection refused",
"connection aborted",
"connection timed out",
"network is unreachable",
"no route to host",
"errno 104", # ECONNRESET
"errno 111", # ECONNREFUSED
]
):
# The platform resolved but refused/reset the connection - almost
# always an outbound firewall. Log the full actionable guidance at
# WARNING (not ERROR, so it doesn't raise a Sentry alert) before
# raising; the exception below stays a thin technical wrapper.
logging.warning(
"Could not connect to the Robusta platform at %s. This is "
"usually an outbound firewall blocking egress to the platform - "
"allowlist outbound HTTPS to '*.robusta.dev'. See %s for "
"troubleshooting steps.",
self.url,
FIREWALL_TROUBLESHOOTING_URL,
)
raise SupabaseConnectionException(e, self.url) from e
raise

def get_resource_recommendation(
Expand Down
94 changes: 94 additions & 0 deletions tests/core/test_supabase_dal.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,111 @@
"""Unit tests for SupabaseDal.get_resource_recommendation method."""

import logging
from unittest.mock import Mock, patch

import pytest
from postgrest.exceptions import APIError as PGAPIError

from holmes.core.supabase_dal import (
FIREWALL_TROUBLESHOOTING_URL,
GROUPED_ISSUES_TABLE,
ISSUES_TABLE,
SupabaseConnectionException,
SupabaseDal,
SupabaseDnsException,
)


class TestSignIn:
"""Tests for SupabaseDal.sign_in() error classification.

A firewall / egress policy that blocks the cluster from reaching the Robusta
platform surfaces as a connection reset/refused during sign-in. Holmes should
convert that into a SupabaseConnectionException whose message points the user
at their firewall, instead of leaking a raw httpx traceback. Genuine auth
errors must still propagate unchanged.
"""

@pytest.fixture
def mock_dal(self):
with patch("holmes.core.supabase_dal.create_client"):
dal = SupabaseDal(cluster="test-cluster")
dal.enabled = True
dal.client = Mock()
dal.url = "https://sp.eu.robusta.dev"
dal.email = "user@example.com"
dal.password = "secret"
return dal

def test_connection_reset_raises_firewall_exception(self, mock_dal, caplog):
# The exact error Aviva hit at startup (ROB-273): httpx surfaces the
# firewall block as "[Errno 104] Connection reset by peer".
mock_dal.client.auth.sign_in_with_password.side_effect = Exception(
"[Errno 104] Connection reset by peer"
)

with caplog.at_level(logging.WARNING):
with pytest.raises(SupabaseConnectionException) as exc_info:
mock_dal.sign_in()

# The exception stays a thin technical wrapper - it names the platform and
# the underlying error but carries none of the actionable guidance.
message = str(exc_info.value)
assert "Robusta platform" in message
assert "curl" not in message
assert "*.robusta.dev" not in message
assert FIREWALL_TROUBLESHOOTING_URL not in message

# All the firewall guidance - cause, the allowlist fix, and the docs link -
# is logged at WARNING (not ERROR, so it doesn't raise a Sentry alert)
# before the exception is raised.
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert any("firewall" in r.getMessage().lower() for r in warnings)
assert any("*.robusta.dev" in r.getMessage() for r in warnings)
assert any(FIREWALL_TROUBLESHOOTING_URL in r.getMessage() for r in warnings)

def test_connection_refused_raises_firewall_exception(self, mock_dal):
mock_dal.client.auth.sign_in_with_password.side_effect = (
ConnectionRefusedError("[Errno 111] Connection refused")
)
with pytest.raises(SupabaseConnectionException):
mock_dal.sign_in()

def test_timeout_raises_firewall_exception(self, mock_dal):
mock_dal.client.auth.sign_in_with_password.side_effect = TimeoutError(
"connection timed out"
)
with pytest.raises(SupabaseConnectionException):
mock_dal.sign_in()

def test_dns_error_still_raises_dns_exception(self, mock_dal):
mock_dal.client.auth.sign_in_with_password.side_effect = Exception(
"Temporary failure in name resolution"
)
with pytest.raises(SupabaseDnsException):
mock_dal.sign_in()

def test_auth_error_is_not_wrapped(self, mock_dal):
# A genuine credential error is not a connectivity/firewall problem;
# wrapping it would mislead the user, so it must propagate unchanged.
original = ValueError("Invalid login credentials")
mock_dal.client.auth.sign_in_with_password.side_effect = original
with pytest.raises(ValueError) as exc_info:
mock_dal.sign_in()
assert exc_info.value is original

def test_successful_sign_in_returns_user_id(self, mock_dal):
session = Mock(access_token="access-token", refresh_token="refresh-token")
res = Mock(session=session, user=Mock(id="user-123"))
mock_dal.client.auth.sign_in_with_password.return_value = res

assert mock_dal.sign_in() == "user-123"
mock_dal.client.auth.set_session.assert_called_once_with(
"access-token", "refresh-token"
)
mock_dal.client.postgrest.auth.assert_called_once_with("access-token")


class TestIsRealtimeEnabled:
"""Tests for SupabaseDal.is_realtime_enabled()."""

Expand Down
Loading