Skip to content

feat(fleet): flag a dial-back service whose advertised address the machine no longer has - #320

Merged
GeiserX merged 3 commits into
mainfrom
fix/advertised-address-mismatch
Aug 10, 2026
Merged

feat(fleet): flag a dial-back service whose advertised address the machine no longer has#320
GeiserX merged 3 commits into
mainfrom
fix/advertised-address-mismatch

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Why

Fix 2 of the storj dial-back incident review (follows #318). The incident: a silent ISP re-provision left the node advertising a dead literal IP — satellites dialled it for days while the container looked healthy. #318 added the log-pattern alarm; this PR adds the check that catches the mismatch the day it happens, from data the fleet already collects.

What

  • Schema: docker.advertised_address_env — the ONE env var holding the address the network dials the service back at. storj declares ADDRESS.
  • Worker: copies that single variable's value into heartbeat container entries as advertised_address — never any other env (env holds credentials); one inspect call per declaring container, absent key when undeclared/unset.
  • Hub (producer-state): compares it against that worker's detected egress IP. Findings (FAILING, outranking PRODUCING since storage/held income keeps ticking while inbound is dead): stale public literal, private literal, hostname resolving away from the egress, definitive NXDOMAIN. No claim (silent): undetected egress, transient DNS trouble, unreported address — a wrong "your address is stale" sends the operator to fix DNS that is fine. The reason text names its assumption so a deliberate second-WAN forward can be read and dismissed.

Testing

  • Decision-table tests over advertised_address_verdict covering findings and every no-claim row (the strongest finding input must stay silent when egress is undetected).
  • Hub helper tested with an injectable resolver; a container without the field must exit before any DB lookup (control protecting all pre-existing route tests).
  • Worker-side: the declared var and ONLY the declared var leaves the container inspect (wallet value asserted absent from the entry); undeclared services carry no key at all.
  • Catalog guard: every declared advertised_address_env names a real env var in that service's own env list.
  • Full suite: 4570 passed, 6 skipped, coverage 95.49%; ruff check + format clean.

Summary by CodeRabbit

  • New Features

    • Added advertised dial-back address reporting for supported services.
    • Added validation to detect private, unreachable, stale, or mismatched advertised addresses.
    • Added service configuration support for declaring the address environment variable.
    • Added address mismatch details to service status and health results.
  • Bug Fixes

    • Improved handling of IPv4, IPv6, hostnames, malformed addresses, and temporary DNS failures.
    • Prevented private resolved addresses from appearing in diagnostic messages.
    • Ensured address-check failures do not hide other service status signals.

…chine no longer has

The check that catches a stale advertised IP the day the ISP re-provisions
it, instead of days later via the provider's offline emails.

- advertised_address_env in the service schema: the ONE env var holding the
  address the network dials the service back at. The worker copies that
  single variable's value into heartbeat container entries — never any other
  env, which holds credentials — at the cost of one inspect call per
  declaring container. storj declares ADDRESS.
- The hub compares it against that worker's detected egress IP in producer
  state: a stale public literal, a private literal, and a hostname resolving
  away from the egress are FAILING findings that outrank PRODUCING (storage
  and held components keep ticking while inbound work is dead). Undetected
  egress, transient DNS trouble, and unreported addresses are NO CLAIM —
  a wrong 'your address is stale' sends the operator to fix DNS that is
  fine. Only a definitive NXDOMAIN counts as a resolution verdict.
- The mismatch reason names its assumption (inbound rides the machine's
  default egress) so a deliberate second-WAN forward can be read and
  dismissed rather than mistrusted.

Tests: decision-table over the verdict (findings AND no-claim rows), the
hub helper with an injectable resolver, worker-side extraction proving the
declared var and ONLY the declared var leaves the container inspect, a
catalog guard that every declared advertised_address_env names a real env
var, and a control that undeclared services carry no key at all.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@GeiserX, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 25eb56c3-87cd-4e8e-9685-5d06c350d102

📥 Commits

Reviewing files that changed from the base of the PR and between 3023608 and 942d569.

📒 Files selected for processing (2)
  • app/main.py
  • tests/test_producer_state.py
📝 Walkthrough

Walkthrough

The change adds catalog-controlled advertised dial-back addresses, propagates them through container status records, validates them against worker egress addresses, and reports confirmed mismatches in producer state.

Changes

Advertised Address Validation

Layer / File(s) Summary
Catalog configuration and status propagation
services/_schema.yml, services/storage/storj.yml, app/orchestrator.py, tests/test_orchestrator_coverage.py, tests/test_egress.py
Services can declare one non-secret environment variable for advertised_address. Managed and externally matched containers include the value in full and light status responses.
Advertised address parsing and verdicts
app/egress.py, tests/test_egress.py
The new helpers parse IPv4, IPv6, bracketed addresses, hostnames, and ports. They report private, unresolved, stale, and mismatched addresses while suppressing unavailable or alternate-family results.
Worker egress and DNS validation wiring
app/main.py, tests/test_producer_state.py
Running requested workers are checked against detected egress addresses. Hostname resolution uses a 60-second cache and distinguishes definitive DNS failures from transient failures.
Producer-state mismatch handling
app/producer_state.py, tests/test_producer_state.py
Address mismatches create a FAILING candidate and reason. The failure can outrank PRODUCING, while absent mismatches preserve existing state behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: detecting services with advertised addresses that no longer match the machine's address.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/advertised-address-mismatch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread tests/test_egress.py Dismissed
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.83673% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.51%. Comparing base (94b7fd6) to head (942d569).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
app/egress.py 90.38% 5 Missing ⚠️
app/orchestrator.py 87.17% 5 Missing ⚠️
app/main.py 96.22% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #320      +/-   ##
==========================================
- Coverage   95.57%   95.51%   -0.06%     
==========================================
  Files          51       51              
  Lines        6685     6828     +143     
==========================================
+ Hits         6389     6522     +133     
- Misses        296      306      +10     
Files with missing lines Coverage Δ
app/producer_state.py 98.52% <100.00%> (+0.06%) ⬆️
app/main.py 97.65% <96.22%> (-0.05%) ⬇️
app/egress.py 93.03% <90.38%> (-1.31%) ⬇️
app/orchestrator.py 82.59% <87.17%> (+0.88%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@GeiserX

GeiserX commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 44 minutes.

… positives

Round 2 from independent review:

- getaddrinfo raises UnicodeError (a ValueError, NOT an OSError) for an
  IDNA-invalid label; uncaught it escaped to the route umbrella and
  silently zeroed the log-signal scan sharing the try block — the address
  check disabling the very detection (#318) it complements. Caught, and
  the call site got its own suppress guard so no future failure mode can
  shadow log signals again. Worker-supplied hostnames are shape-validated
  before the resolver ever sees them, and resolutions are memoized 60s so
  a blackholed resolver costs one executor thread per window.
- Judge only RUNNING containers on the node the caller asked about: an
  exited container elsewhere in the fleet carries its last run's env, and
  judging it produced findings about the wrong machine.
- Cross-family comparisons are silence: the egress detectors are
  dual-stack, so a v6 egress against a v4 literal (or a v6-only DDNS name
  against v4 egress) says nothing about staleness. Same-family filtering
  before the membership check.
- Resolved IPs are redacted to public-only before being echoed: they
  originate from a worker-supplied name, and repeating a private answer
  would let a rogue worker read the hub's internal DNS view.
- A dangling 'host:' colon is a typo, stripped instead of earning a
  confident NXDOMAIN about a name never looked up (bare v6 keeps its
  trailing colons).
- advertised_address_env may never name a secret-flagged var: CI guard in
  the catalog tests plus a runtime backstop in the worker.
- External (image-matched) containers now carry advertised_address too —
  running a storagenode BEFORE installing CashPilot is the common storj
  adoption path, and those nodes were blind spots.
- Stale-egress caveat in both mismatch messages (worker egress readings
  are cached up to an hour; the first hour after an IP change can read
  stale and self-heals).

Tests: resolver three-valued contract on the REAL function (NXDOMAIN,
EAI_AGAIN, timeout, UnicodeError), family-guard decision rows, private-IP
redaction, running/worker_id filtering, secret backstop, external-node
coverage.
@GeiserX

GeiserX commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
tests/test_orchestrator_coverage.py (1)

439-439: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the imported label constants here.

_mock_container at Line 38 uses LABEL_SERVICE and LABEL_MANAGED, but _labeled hardcodes "cashpilot.service" and "cashpilot.managed". If a constant value changes, this helper silently produces slug "unknown" and the advertised-address assertions still pass.

♻️ Proposed change
-        c.labels = {"cashpilot.managed": "true", "cashpilot.service": slug}
+        c.labels = {LABEL_MANAGED: "true", LABEL_SERVICE: slug}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_orchestrator_coverage.py` at line 439, Update the label assignment
in _labeled to use the imported LABEL_MANAGED and LABEL_SERVICE constants
instead of hardcoded label keys, preserving the existing values and
advertised-address assertions.
app/orchestrator.py (1)

605-624: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log the inspect failure instead of swallowing it silently.

Both except Exception blocks return None with no record. The surrounding status loops log warnings for every other Docker failure (Lines 682, 690), so an inspect error here is the only failure in this file that leaves no trace. A permanently failing inspect then looks identical to "the service does not declare an address", and the address check stays silent forever with nothing to debug.

Log the exception, not the env value, so no credential can reach the log.

♻️ Proposed logging
     try:
         env = (container.client.api.inspect_container(container.id).get("Config") or {}).get("Env") or []
-    except Exception:
+    except Exception as exc:
+        logger.debug("Could not inspect %s for its advertised address: %s", getattr(container, "short_id", "?"), exc)
         return None

As per path instructions, "Check for proper error handling and logging".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/orchestrator.py` around lines 605 - 624, Update the exception handling
around service lookup and container inspection in the address-resolution flow to
log each caught exception before returning None. Use the existing module logging
pattern, include operation context and the exception object, and never log the
inspected environment or credential values.

Source: Path instructions

app/main.py (1)

2816-2823: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log the suppressed address-check failure

_advertised_address_mismatch() is guarded by contextlib.suppress(Exception), so an exception makes address_mismatch remain unset and leaves no diagnostic. If the check fails, log it inside the guard; do not suppress the entire contextlib import, which is already present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/main.py` around lines 2816 - 2823, Update the
_advertised_address_mismatch call within the running guard to catch the
exception explicitly and log the failure with the existing logger, while
preserving suppression so the surrounding verdict continues and address_mismatch
remains safely unset. Keep the existing contextlib import and do not suppress
the entire import.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/main.py`:
- Around line 2724-2753: Update _resolve_advertised_host to purge expired
_RESOLVE_CACHE entries before storing a new result, then enforce a fixed maximum
cache size by evicting older entries when the limit is exceeded. Keep valid
cached lookups and the existing resolution result semantics unchanged, and
define or reuse a clear cache-size limit alongside _RESOLVE_CACHE_TTL.
- Around line 2696-2716: Update the advertised-address evaluation around
reported and egress.advertised_address_verdict so every running candidate with
an advertised address is assessed rather than stopping at the first match, while
preserving the worker_id filtering. Include each candidate’s _node value in the
verdict reason so fleet-wide failures identify the affected node, and return the
combined per-candidate verdicts using the existing verdict structure.

In `@tests/test_producer_state.py`:
- Around line 517-527: Update the test helper _resolve to clear
main._RESOLVE_CACHE before invoking _resolve_advertised_host, then use a fixed
host value instead of deriving the hostname from id(side_effect). Keep the
existing wait_for patching and asyncio execution unchanged.

---

Nitpick comments:
In `@app/main.py`:
- Around line 2816-2823: Update the _advertised_address_mismatch call within the
running guard to catch the exception explicitly and log the failure with the
existing logger, while preserving suppression so the surrounding verdict
continues and address_mismatch remains safely unset. Keep the existing
contextlib import and do not suppress the entire import.

In `@app/orchestrator.py`:
- Around line 605-624: Update the exception handling around service lookup and
container inspection in the address-resolution flow to log each caught exception
before returning None. Use the existing module logging pattern, include
operation context and the exception object, and never log the inspected
environment or credential values.

In `@tests/test_orchestrator_coverage.py`:
- Line 439: Update the label assignment in _labeled to use the imported
LABEL_MANAGED and LABEL_SERVICE constants instead of hardcoded label keys,
preserving the existing values and advertised-address assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 70fad649-c7fd-427f-a698-6dd12403de17

📥 Commits

Reviewing files that changed from the base of the PR and between 2f8ed94 and 3023608.

📒 Files selected for processing (9)
  • app/egress.py
  • app/main.py
  • app/orchestrator.py
  • app/producer_state.py
  • services/_schema.yml
  • services/storage/storj.yml
  • tests/test_egress.py
  • tests/test_orchestrator_coverage.py
  • tests/test_producer_state.py

Comment thread app/main.py Outdated
Comment thread app/main.py
Comment thread tests/test_producer_state.py
CodeRabbit round on the review commit:

- _RESOLVE_CACHE keys are worker-supplied hostnames and nothing ever
  evicted them — steady memory growth for the hub's lifetime. Expired
  entries are purged on every write and the table is capped at 256,
  oldest-first.
- The mismatch reason said 'this machine' without saying WHICH machine —
  a fleet finding nobody can act on. The worker's node name is prefixed
  into the reason.
- The resolver tests keyed cache uniqueness on id(), which CPython
  reuses; fixed host + explicit cache clear instead.
Comment thread tests/test_producer_state.py Dismissed
@GeiserX
GeiserX merged commit b3c0119 into main Aug 10, 2026
9 checks passed
@GeiserX
GeiserX deleted the fix/advertised-address-mismatch branch August 10, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants