c7cc92382b
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
589 lines
32 KiB
Markdown
589 lines
32 KiB
Markdown
---
|
|
status: draft
|
|
system: executors
|
|
requirements:
|
|
- REQ-EXECUTORS-SSH-REACHABILITY-001
|
|
- REQ-EXECUTORS-SSH-REACHABILITY-002
|
|
- REQ-EXECUTORS-SSH-REACHABILITY-003
|
|
created: 2026-09-07
|
|
owners:
|
|
- tbd
|
|
---
|
|
# SSH Host Reachability System Design
|
|
|
|
## Purpose and boundaries
|
|
|
|
The executor system owns SSH lifecycle and executor failure and recovery
|
|
contracts, so it owns the path answering "is this SSH host still reachable".
|
|
This design covers the poller, the probe, its classification, persistence and
|
|
configuration. The API, event and web projections are in
|
|
[SSH Host Reachability Surfaces](ssh-reachability-surfaces.md).
|
|
|
|
Contracts this design uses but does not own:
|
|
|
|
- `internal/agent/runtime/lifecycle` owns SSH target resolution
|
|
(`ResolveSSHTarget`), dialing (`DialSSH`), fingerprint pinning, `ProxyJump`,
|
|
and the dial error types. The probe consumes those, and does not reimplement.
|
|
- The task system owns `executors`, `executor_profiles`, and `task_sessions`,
|
|
and the event bus to WebSocket bridge in `internal/gateway/websocket`.
|
|
- `internal/common/config` owns the operator configuration catalog.
|
|
- `internal/integrations/healthpoll` is precedent, not a dependency: its
|
|
`Prober` is shaped around one integration with many workspaces, while this
|
|
poller sweeps many independent hosts. Its conventions are kept, not imported.
|
|
|
|
## Requirement mapping
|
|
|
|
| Requirement | Design section |
|
|
| --- | --- |
|
|
| `REQ-EXECUTORS-SSH-REACHABILITY-001` | [The poller](#the-poller), [The probe](#the-probe), [State transitions](#state-transitions), [Persistence](#persistence), [Configuration](#configuration) |
|
|
| `REQ-EXECUTORS-SSH-REACHABILITY-002` | [Surfaces design](ssh-reachability-surfaces.md) |
|
|
| `REQ-EXECUTORS-SSH-REACHABILITY-003` | [Surfaces design](ssh-reachability-surfaces.md) |
|
|
|
|
The task-card indicator and the launch-path record write are deferred; see the
|
|
requirement document's `## Out of scope`.
|
|
|
|
## Components and responsibilities
|
|
|
|
**`internal/agent/runtime/lifecycle` (extended).** `SSHExecutor.HealthCheck`
|
|
keeps returning `nil`: the runtime is available even when an individual host is
|
|
not, and the registry's `HealthCheckAll` sweep is a per-runtime concept called
|
|
once from `Manager.Start`. Conflating per-host reachability with runtime health
|
|
there would mark the whole SSH runtime down because one of several hosts moved.
|
|
|
|
This package gains **three** exported functions and the two types they need:
|
|
|
|
- `SSHTargetFromExecutorConfig(map[string]string) (*SSHTarget, error)` — lifted
|
|
from `internal/ssh`; projects `executors.config` into the `SSHConnConfig` that
|
|
`ResolveSSHTarget` takes. That function needs the typed struct, not a map, so
|
|
without this the reachability package must import `internal/ssh` to read an
|
|
executor's own config.
|
|
- `ProbeSSHHost(ctx, target *SSHTarget, timeout time.Duration) SSHProbeOutcome`
|
|
— dials with the pinned fingerprint, closes immediately, runs no remote
|
|
command, and reports success, cancellation, target host, reason, and a
|
|
sanitized message. **It bounds the handshake on the derived probe context,
|
|
not only the TCP dial.** `ssh.NewClientConn` takes no context and
|
|
`ssh.ClientConfig.Timeout` covers only `ssh.Dial`'s own TCP connect, so a host
|
|
that completes TCP and then stalls the handshake would hold its goroutine
|
|
past `probeTimeout` and past `Stop`. `handshakeWithDeadline` sets a deadline
|
|
when the transport supports it and also closes the owner connection from a
|
|
context cancellation callback, which covers ProxyJump channel connections.
|
|
- `ClassifyDialError(err error) SSHReachabilityReason` — maps a dial error to
|
|
exactly one reason from the closed set.
|
|
|
|
**Classification lives here, not in the reachability package, and that is the
|
|
load-bearing decision in this section.** The error types it must distinguish are
|
|
`lifecycle`'s own and unexported: `errHostKeyMismatch` is lowercase, so no other
|
|
package can `errors.As` it. Exporting the *classifier* rather than the *error
|
|
types* keeps that boundary intact and gives the launch path identical
|
|
classification without a second implementation.
|
|
`Reason` is a string enum whose members are exactly `config`, `network`,
|
|
`timeout`, `auth`, `host_key`, `unknown`, plus the empty string for success.
|
|
|
|
**`internal/executors/reachability` (new).** Owns the poller, the concurrency
|
|
limiter, the hysteresis rule and the store interface: it enumerates eligible
|
|
executors, orders them, runs a bounded pass, calls `ProbeSSHHost`, applies the
|
|
state rule, writes results and publishes change events. It depends on a narrow
|
|
repository interface, not the full task repository.
|
|
|
|
**`internal/task/repository/sqlite` (extended).** Owns the
|
|
`executor_reachability` table, its additive migration, its queries and the
|
|
explicit record delete in [Persistence](#persistence).
|
|
|
|
**`internal/task/service` (extended).** Owns save detection, because it is the
|
|
only layer holding both the stored executor and the incoming change.
|
|
`Service.UpdateExecutor` (`service_resources.go`) loads the row, applies the
|
|
request and bumps `UpdatedAt`; `Service.CreateExecutor` is the creation call
|
|
site. Each notifies an `ExecutorSaveObserver` — an interface the service
|
|
declares and the reachability package implements, wired at construction, so the
|
|
service depends on an interface rather than on the reachability package. The
|
|
observer is handed the connection-configuration fields as they stood before the
|
|
save and as they stand after, compares exactly the fields Terminology names,
|
|
and on any difference performs the reset in [Persistence](#persistence) and
|
|
schedules the off-cycle probe. A create is a difference by definition.
|
|
|
|
**`applyExecutorUpdates` mutates the loaded executor in place**, so the before
|
|
values must be copied out before it runs. Read afterwards they compare the row
|
|
against itself, every save looks like a no-op, the reset never fires, and a
|
|
re-pointed executor keeps reporting the previous host's state indefinitely.
|
|
|
|
**`internal/ssh` and `apps/web`.** The HTTP and WebSocket projection, the web
|
|
components and the launch interaction. Specified in the
|
|
[surfaces design](ssh-reachability-surfaces.md), which owns them; this document
|
|
states no contract for them.
|
|
|
|
## The poller
|
|
|
|
A single ticker loop, started where the lifecycle manager and the integration
|
|
pollers start and stopped on backend shutdown. Both `Start` and `Stop` are
|
|
idempotent. Every probe the package starts is registered on one package-owned
|
|
`WaitGroup` and derives from one package context, so `Stop` cancels and awaits
|
|
*all* of them — the in-flight pass, an off-cycle probe after a save, a coalesced
|
|
immediate probe — not only the pass. That is what makes an immediate probe's
|
|
package-owned context safe: it outlives its caller but not the package. Draining
|
|
is bounded because every probe carries `probeTimeout` *and* because
|
|
`ProbeSSHHost` bounds the handshake phase as well as the TCP dial; without that
|
|
second half a stalled handshake outlives both its deadline and `Stop`. A `goleak`
|
|
`TestMain` guards the package, matching `healthpoll`.
|
|
|
|
When the effective interval is `0` the poller does not start: no startup pass
|
|
and no ticker. The immediate-probe route still works, not going through the
|
|
poller loop. The package context, `WaitGroup` and semaphore are still created
|
|
and registered for cleanup; only the pass and ticker are skipped. A `Start`
|
|
returning early before creating them leaves immediate probes unowned at
|
|
shutdown, breaking the drain contract and the `goleak` guard.
|
|
|
|
One pass:
|
|
|
|
1. List eligible executors: `type = 'ssh'`, `deleted_at IS NULL`,
|
|
`status = 'active'`, capturing each row's `updated_at` with its id.
|
|
2. Sort ascending by the `executors.id` column. `id` is the primary key and is
|
|
never reused, so the order is total; no tiebreak is needed or defined.
|
|
3. Acquire the shared probe semaphore per executor, preserving dispatch order.
|
|
4. Await all workers, then release the pass.
|
|
|
|
A `sync.Mutex`-guarded `passRunning` flag makes an overlapping tick a no-op: the
|
|
tick is dropped, not queued, and increments a skipped-pass counter. Queueing it
|
|
would compound the overload that caused the overlap, and the next tick carries
|
|
identical information.
|
|
|
|
**The semaphore is process-global, not per-pass.** One `passConcurrency`-slot
|
|
semaphore, owned by the package, is acquired by every probe whoever asked for
|
|
it: a scheduled pass, an off-cycle probe after a save, an immediate-probe
|
|
request. Saving twenty executors in a script therefore opens at most
|
|
`passConcurrency` connections, not twenty. A launch's own `DialSSH` is not a
|
|
probe and never takes a slot: a launch must not queue behind background probing.
|
|
|
|
Cost per pass is `N` TCP connections and handshakes for `N` hosts, at most
|
|
`passConcurrency` at a time, each bounded by `probeTimeout`. At the defaults
|
|
below, one host costs one handshake per minute.
|
|
|
|
## The probe
|
|
|
|
Per executor, inside its own `context.WithTimeout(ctx, probeTimeout)`:
|
|
|
|
1. Project `executors.config` into an `SSHConnConfig` and resolve it with
|
|
`lifecycle.ResolveSSHTarget` — the same projection the existing handler
|
|
performs in `sshTargetFromExecutorConfig`, lifted into a shared helper so the
|
|
poller and the handlers cannot drift. A resolution failure classifies as
|
|
`config` and returns without touching the network. **`ResolveSSHTarget` does
|
|
not validate the pin, so the probe checks it separately** and returns `config`
|
|
when the resolved target's `PinnedFingerprint` is empty, before any dial.
|
|
Leaving this to resolution is a security defect rather than a gap:
|
|
`buildClientConfig`'s host-key callback returns `nil` unconditionally when the
|
|
pin is empty, so the probe would dial unpinned, accept whatever key answered,
|
|
and record a substituted host as `reachable`.
|
|
2. `lifecycle.ProbeSSHHost(ctx, target)`, dialing with the pinned fingerprint
|
|
set, so a changed host key fails the handshake rather than being silently
|
|
accepted. That is the substantive difference from the manual test path, which
|
|
dials unpinned to observe a fingerprint for first-time trust.
|
|
3. On success the client is closed immediately and the outcome is `reachable`.
|
|
|
|
`ClassifyDialError` walks a total order and returns the first match. The table
|
|
below is written **in that order**, and the order is the contract:
|
|
|
|
| Order | Reason | Condition |
|
|
| --- | --- | --- |
|
|
| 1 | `config` | Target resolution failed, or resolved with no pinned fingerprint, before any dial. |
|
|
| 2 | `timeout` | The probe deadline expired, or the error unwraps to a net timeout. |
|
|
| 3 | `host_key` | The handshake failed host-key verification against the pin. |
|
|
| 4 | `auth` | Transport negotiation completed; authentication failed. |
|
|
| 5 | `network` | TCP dial failed: refused, no route, DNS, network unreachable. |
|
|
| 6 | `unknown` | Any other error. |
|
|
|
|
`config` is decided before any dial. `timeout` precedes `network` because a
|
|
deadline expiry surfaces as a dial error and would otherwise read as a refused
|
|
connection. `host_key` precedes `auth` because both arrive as handshake failures
|
|
and only `host_key` is a security event. The stored message is the error text,
|
|
which `dialDirect` already stamps with host and port.
|
|
|
|
## State transitions
|
|
|
|
Three states, and one counter that is part of the contract rather than an
|
|
implementation detail:
|
|
|
|
- `unknown`: no record, or a record explicitly reset. Never inferred.
|
|
- `reachable`: the last probe succeeded.
|
|
- `unreachable`: a sticky failure, or a transient failure streak that reached
|
|
`failureThreshold`.
|
|
|
|
**On success:** `state = reachable`, `consecutive_failures = 0`, reason and
|
|
message cleared, `last_success_at = checked_at`. One success is enough from any
|
|
prior state: a host that just answered is reachable, and delaying that
|
|
conclusion only prolongs a false alarm.
|
|
|
|
**On a sticky failure (`config` or `host_key`):** `state = unreachable`
|
|
immediately, whatever the counter says. The counter still increments and
|
|
`last_success_at` is left alone. Both conditions are deterministic and will
|
|
repeat on the next probe, so the threshold would buy no confidence — it would
|
|
only delay a possible host-key compromise by a full interval.
|
|
|
|
**On a transient failure (`network`, `timeout`, `auth`, `unknown`):**
|
|
`consecutive_failures += 1`, and the state becomes `unreachable` on the probe
|
|
where the counter reaches `failureThreshold`. Until then the state is left
|
|
**exactly as it was**: `reachable` stays `reachable` and `unknown` stays
|
|
`unknown`. A failing probe never *promotes* a state, so it cannot turn `unknown`
|
|
into `reachable`; it changes the reason and the counter while the state stands
|
|
still. That pair, a non-empty reason under `reachable` or `unknown`, is the
|
|
intended representation of a below-threshold failure, not a contradiction: one
|
|
dropped packet must not paint a healthy host as down, while recovery is reported
|
|
at once.
|
|
|
|
A save that changes the connection configuration resets the record to `unknown`,
|
|
then probes out of band if the executor is still eligible, because the prior
|
|
streak and success describe a different target. A save that changes nothing
|
|
resets nothing; the mechanism is in [Persistence](#persistence).
|
|
|
|
## Persistence
|
|
|
|
New table, one row per SSH executor:
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS executor_reachability (
|
|
executor_id TEXT PRIMARY KEY,
|
|
state TEXT NOT NULL DEFAULT 'unknown',
|
|
reason TEXT NOT NULL DEFAULT '',
|
|
message TEXT NOT NULL DEFAULT '',
|
|
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
host TEXT NOT NULL DEFAULT '',
|
|
checked_at TIMESTAMP,
|
|
last_success_at TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL
|
|
);
|
|
```
|
|
|
|
`last_success_at` exists because the pre-launch warning must name the age of the
|
|
last *successful* probe, and `checked_at` is overwritten by every failure. It is
|
|
`NULL` until the first success, and only the configuration reset clears it.
|
|
|
|
A separate table rather than columns on `executors`: `config` is user-authored
|
|
input and `status` a user-controlled switch, so observed state must not share
|
|
either, and a per-interval write stays off the row every read touches.
|
|
|
|
**Deletion is explicit, not a cascade.** `Repository.DeleteExecutor` is a *soft*
|
|
delete (`UPDATE executors SET deleted_at = ?, updated_at = ?`), so no foreign-key
|
|
cascade can fire and the table declares none. `DeleteExecutor` issues `DELETE
|
|
FROM executor_reachability WHERE executor_id = ?` in the same transaction as the
|
|
soft delete, and is the only call site that removes a record.
|
|
|
|
**One statement decides every observation.** The write is a single upsert; the caller
|
|
supplies the observation (reason, message, host, `checked_at`) and the statement
|
|
derives the counter and the state from the row's own prior values, so no
|
|
read-modify-write in Go can lose a streak to an interleaving:
|
|
|
|
```sql
|
|
INSERT INTO executor_reachability (executor_id, state, reason, message,
|
|
consecutive_failures, host, checked_at, last_success_at, updated_at)
|
|
SELECT :id, :state, :reason, :message, :failures, :host, :checked_at,
|
|
:last_success_at, :now
|
|
WHERE EXISTS (SELECT 1 FROM executors e
|
|
WHERE e.id = :id AND e.deleted_at IS NULL
|
|
AND e.status = 'active' AND e.updated_at = :seen_updated_at)
|
|
ON CONFLICT (executor_id) DO UPDATE SET
|
|
consecutive_failures = CASE WHEN :reason = '' THEN 0
|
|
ELSE executor_reachability.consecutive_failures + 1 END,
|
|
state = CASE WHEN :reason = '' THEN 'reachable'
|
|
WHEN :reason IN ('config','host_key') THEN 'unreachable'
|
|
WHEN executor_reachability.consecutive_failures + 1 >= :threshold
|
|
THEN 'unreachable'
|
|
ELSE executor_reachability.state END,
|
|
reason = :reason, message = :message, host = :host,
|
|
checked_at = :checked_at,
|
|
last_success_at = CASE WHEN :reason = '' THEN :checked_at
|
|
ELSE executor_reachability.last_success_at END,
|
|
updated_at = :now
|
|
WHERE executor_reachability.checked_at IS NULL
|
|
OR excluded.checked_at > executor_reachability.checked_at;
|
|
```
|
|
|
|
On the insert path there is no prior row, so the caller supplies the initial
|
|
`state` and `failures`; every later write derives both in SQL from the stored
|
|
row. The `ON CONFLICT` `WHERE` is last-write-wins on `checked_at`: a scheduled
|
|
pass and an immediate probe racing on one executor converge on the later
|
|
observation rather than on whichever transaction committed second, and an equal
|
|
timestamp is not later, so the incoming row loses. `checked_at` is stored at
|
|
millisecond resolution or finer, keeping the tie rule a backstop.
|
|
|
|
**That `>` is a text comparison, so the encoding is part of the contract.**
|
|
`TIMESTAMP` takes NUMERIC affinity and the driver binds `time.Time` as text
|
|
(`2006-01-02 15:04:05.999999999-07:00`), so SQLite orders these lexically, not
|
|
temporally. Two rules make lexical order equal time order, and both are
|
|
required: **normalize to UTC** before writing, because the driver keeps whatever
|
|
offset it is handed and a DST fall-back then sorts an earlier observation later;
|
|
and **bind `time.Time`, never a pre-formatted string**, because RFC3339 renders
|
|
UTC as `Z`, which sorts after `.` and would let a whole-second observation beat
|
|
a later sub-second one. Postgres compares temporally and is unaffected, which is
|
|
why this belongs in the SQLite tests: the parity test passes on Postgres while
|
|
SQLite keeps the wrong row.
|
|
|
|
The eligibility guard is the `EXISTS` above, and `:seen_updated_at` is the
|
|
`updated_at` the probe captured before it dialed. Every mutation of the executor
|
|
row — configuration save, status change, soft delete — bumps `updated_at`, so one
|
|
comparison covers all three: it stops a probe in flight against the *old* target
|
|
from landing after a reset, and stops a worker from re-creating a deleted record
|
|
or writing one for an executor deactivated mid-pass. The `EXISTS` gates the
|
|
insert; the `ON CONFLICT` arm inherits it, because a row can only conflict if the
|
|
insert was admitted. A refused write is logged at debug and dropped.
|
|
|
|
**The reset is a second statement, and deliberately not that one.** A
|
|
configuration reset is not an observation; it invalidates one. The upsert above
|
|
cannot express it — its `state` CASE has no `unknown` branch, and its `WHERE`
|
|
admits only a strictly later `checked_at`, which a reset does not carry. Nor may
|
|
it be expressed as a delete: `DeleteExecutor` is the only call site permitted to
|
|
remove a row.
|
|
|
|
```sql
|
|
INSERT INTO executor_reachability (executor_id, state, reason, message,
|
|
consecutive_failures, host, checked_at, last_success_at, updated_at)
|
|
SELECT :id, 'unknown', '', '', 0, :host, NULL, NULL, :now
|
|
WHERE EXISTS (SELECT 1 FROM executors e
|
|
WHERE e.id = :id AND e.type = 'ssh'
|
|
AND e.deleted_at IS NULL AND e.status = 'active')
|
|
ON CONFLICT (executor_id) DO UPDATE SET
|
|
state = 'unknown', reason = '', message = '', consecutive_failures = 0,
|
|
host = :host, checked_at = NULL, last_success_at = NULL, updated_at = :now;
|
|
```
|
|
|
|
The `EXISTS` carries a decision, not just a safety check: an executor whose
|
|
`status` is not `active` is **not** reset by a save, because
|
|
`AC-EXECUTORS-SSH-REACHABILITY-001.17` retains its record unchanged and
|
|
eligibility wins over the reset trigger. The statement lands zero rows and the
|
|
retained record stands until the executor is reactivated and probed.
|
|
|
|
`:host` is the newly saved host, so the settings page never renders a pre-change
|
|
host beside `unknown` — the window this capability exists to illuminate is
|
|
exactly the one that would otherwise lie. There is deliberately no `checked_at`
|
|
guard and no `:seen_updated_at` pin: a reset is ordered by the save that caused
|
|
it, not by an observation clock, so it must win over whatever is stored. A probe
|
|
already in flight against the pre-save target cannot overwrite it afterwards,
|
|
because that probe's own write still carries the pre-save `updated_at` and the
|
|
upsert's `EXISTS` rejects it. That is the mechanism
|
|
`AC-EXECUTORS-SSH-REACHABILITY-001.19` requires, and it is why the reset needs no
|
|
clock of its own.
|
|
|
|
The reset advances the row's `updated_at` like any other write, which is what
|
|
lets a client order it against a concurrent probe result: see the surfaces
|
|
design's reconciliation rule.
|
|
|
|
Retention is one row per executor. The table survives restart and the surface
|
|
reports the preserved record until a new probe replaces it: a blank panel after
|
|
every restart would be worse than a timestamped result marked stale.
|
|
|
|
The migration is a single additive `CREATE TABLE IF NOT EXISTS` applied through
|
|
the existing idempotent mechanism in `internal/task/repository/sqlite`, so it is
|
|
safe on SQLite and Postgres and on an already-migrated database.
|
|
|
|
## Configuration
|
|
|
|
One catalog entry in `internal/common/config/catalog.go`:
|
|
|
|
| Key | Environment variable | Default |
|
|
| --- | --- | --- |
|
|
| `executors.sshReachabilityIntervalSeconds` | `KANDEV_EXECUTORS_SSHREACHABILITYINTERVALSECONDS` | `60` |
|
|
|
|
Supported range 15 to 3600 seconds; `0` disables the poller. **The catalog does
|
|
not clamp.** `applyNonNegativeIntEnv` falls back to the *default* — never to a
|
|
bound — for a value that is absent, empty, negative, fractional or unparsable, so
|
|
every one of those yields `60`. Its minimum must stay `0`: widening it to `15` to
|
|
express the supported range would push `0` below the minimum and silently turn
|
|
the documented kill switch into a 60-second cadence.
|
|
|
|
**That normalization is the environment path only, and the two sources do not
|
|
behave alike.** `applyBoundedIntEnv`, which `applyNonNegativeIntEnv` wraps, reads
|
|
`nonEmptyEnv(env, entry.EnvVars...)` and returns early when no environment
|
|
variable is set, so a value written in `config.yaml` never reaches its
|
|
fallback-and-bounds logic at all. A YAML value is decoded by `decodeConfig`,
|
|
which sets no `WeaklyTypedInput`, and `Load` returns the decode error, so a
|
|
non-integer in `config.yaml` refuses boot exactly as it does for every other
|
|
typed key in the catalog. That is the documented behavior rather than an
|
|
exception carved out for this key, and
|
|
`AC-EXECUTORS-SSH-REACHABILITY-001.24` states it that way instead of promising a
|
|
leniency the loader does not implement. A *negative whole number* does decode
|
|
cleanly and so does reach the package, which is why the clamp below needs a
|
|
negative branch of its own.
|
|
|
|
Clamping is therefore the package's job, applied to whatever value reaches it:
|
|
below `0` yields the default `60`, `0` disables, `1`-`14` becomes `15`, above
|
|
`3600` becomes `3600`, and a clamp logs once at startup rather than refusing to
|
|
boot.
|
|
|
|
**The clamped value is the effective interval, and every consumer uses it.**
|
|
Staleness (three times the interval), the `probing_enabled` projection, and the
|
|
settings page's own refresh cadence all read the effective interval, never the
|
|
raw configured number. An operator who writes `5` gets a 15-second cadence and a
|
|
45-second staleness horizon, consistently across the backend and the UI.
|
|
|
|
The other three knobs are compile-time constants, not configuration, because no
|
|
operator scenario has been identified that needs them independently and each new
|
|
key is a permanent contract:
|
|
|
|
| Constant | Value | Rationale |
|
|
| --- | --- | --- |
|
|
| `probeTimeout` | 10s | `sshDialTimeout` is 30s, which would let one hung host hold a slot for half the interval. |
|
|
| `passConcurrency` | 4 | Bounds simultaneous outbound connections and file descriptors while keeping a pass short. |
|
|
| `failureThreshold` | 2 | Smallest value that suppresses a single-probe blip; worst-case detection is two intervals. |
|
|
|
|
This capability adds no runtime feature toggle: the interval key already
|
|
provides the kill switch through the designated operator-startup mechanism, and
|
|
a toggle would leave a retired identity to carry forever.
|
|
|
|
## Control flow
|
|
|
|
```text
|
|
ticker -> poller.pass() (only if effective interval > 0)
|
|
| list eligible SSH executors by executors.id, with updated_at
|
|
v
|
|
global semaphore(passConcurrency) <- off-cycle and immediate probes
|
|
v
|
|
probe() SSHTargetFromExecutorConfig > ProbeSSHHost > ClassifyDialError
|
|
v
|
|
store.Upsert(observation) guards: eligible, updated_at, checked_at
|
|
|- state or reason changed -> bus.Publish -> WS -> store slice
|
|
'- unchanged -> stop
|
|
|
|
save -> ExecutorSaveObserver (do the connection-config fields differ?)
|
|
'- yes -> store.Reset(host) guard: eligible -> bus.Publish -> off-cycle probe
|
|
|
|
launch -> session creation reads record, emits session.launch.warning
|
|
'- SSHExecutor.CreateInstance (reads no record, writes no record)
|
|
'- failed at its dial -> ClassifyDialError -> error names host
|
|
```
|
|
|
|
## Failure and recovery
|
|
|
|
The poller is best-effort and never a source of truth for a caller. Every
|
|
failure mode degrades to a stale or `unknown` record and a log line:
|
|
|
|
- Listing executors fails: the pass is abandoned with a warning, nothing is
|
|
written, the next tick retries.
|
|
- A probe panics or errors unexpectedly: it classifies `unknown` and the pass
|
|
continues; no other executor is affected.
|
|
- A record write fails: logged, the pass completes, the next probe rewrites,
|
|
costing one interval of freshness.
|
|
- A write refused by a guard is expected, not an error: logged at debug and
|
|
dropped.
|
|
- A probe exceeding `probeTimeout` is abandoned by its context; the client is
|
|
closed on the returning path so it leaks no connection, and its slot released.
|
|
- A probe cancelled by `Stop` writes **nothing**, which is not the case above.
|
|
Shutdown cancels the package context, so an in-flight dial returns
|
|
`context.Canceled`, and no guard catches it: a shutdown mutates no executor
|
|
row, so `EXISTS` and `updated_at` still admit the write. Recorded, it would
|
|
increment `consecutive_failures` for every probe in flight — up to
|
|
`passConcurrency` hosts per restart — and at `failureThreshold` a second
|
|
restart would flip a healthy host to `unreachable`, turning a routine deploy
|
|
into exactly the false alarm this capability exists to prevent.
|
|
`ClassifyDialError` must therefore separate the two context errors: only a
|
|
deadline is a `timeout` observation, and a cancellation is not an observation.
|
|
- The store is unreachable on an API read: the route errors and the surface
|
|
reports reachability as not known, never `unreachable`.
|
|
|
|
There are no retries inside a pass: the next tick is the retry, and an immediate
|
|
retry against a host that just refused a connection adds cost, not information.
|
|
|
|
## Security
|
|
|
|
The probe uses the pinned fingerprint on the dial to the **target**, so a
|
|
host-key change there is a detected `host_key` failure and never a silent
|
|
re-pin; the stored fingerprint is read-only to this path. `host_key` reports
|
|
`unreachable` on its first occurrence rather than waiting out the threshold: a
|
|
mismatch is possible interception, and delaying it a full interval is a security
|
|
cost with no accuracy benefit. Credentials are unchanged: the probe reuses the
|
|
executor's configured identity source and holds no secret of its own.
|
|
|
|
`message` carries an SSH error string, which can include host, port, username
|
|
and identity path — all values the user configured and can already see on the
|
|
same settings page, so the record is not sensitive. `ClassifyDialError` must not
|
|
place key material or agent socket contents into `message`.
|
|
|
|
The probe opens no remote shell and runs no remote command, so it grants nothing
|
|
beyond what the manual test already exercises, and adds no new trust boundary.
|
|
|
|
**`ProxyJump` is a second hop with a weaker trust model, and the reason set must
|
|
not paper over it.** An executor pins one fingerprint and it belongs to the
|
|
target; `dialViaJump` verifies the bastion against `~/.ssh/known_hosts` instead,
|
|
accepting an absent host with a logged warning (OpenSSH's
|
|
`StrictHostKeyChecking=accept-new`). Two consequences the probe inherits:
|
|
|
|
- A bastion key *mismatch* is rejected, but surfaces as `ssh: bastion dial: …`,
|
|
not as `errHostKeyMismatch`, so `ClassifyDialError` would call it `network` or
|
|
`unknown` and wait out `failureThreshold` under the wrong reason. The
|
|
classifier must map a bastion host-key rejection to `host_key`, which
|
|
`AC-EXECUTORS-SSH-REACHABILITY-001.29` now requires observably.
|
|
- Every bastion-path error is about the *bastion*, while the record's `host` is
|
|
the target. A bastion that is down reports the target unreachable and sends an
|
|
operator to a machine that is fine — the same misattribution as the firewall
|
|
message of 2026-09-06, from a different cause. **`host` stays the target in
|
|
every case**: it identifies the executor's subject, the column keeps one
|
|
meaning for success and failure alike, and `AC-…-001.4` and `AC-…-002.1` stay
|
|
true for a probe that succeeds through a jump. The disambiguation belongs in
|
|
`message`, which must name the bastion and state that the failure arose on the
|
|
jump hop whenever it did. `message` is rendered beside the state wherever an
|
|
unreachable record is shown, so the operator reads "unreachable … via bastion
|
|
X" instead of being sent to a healthy target.
|
|
|
|
## Observability
|
|
|
|
Structured `zap` logs plus `expvar` counters under `/debug/vars`, following the
|
|
`office_stall_*` and `routing_*` precedent. Every name below carries the prefix
|
|
`executor_ssh_reachability_`:
|
|
|
|
- `probe_total`, labelled by outcome (`reachable` plus each failure reason).
|
|
- `state_transitions_total`, by destination state.
|
|
- `pass_skipped_total`, when a tick is dropped because a pass is still running:
|
|
the interval is shorter than a pass.
|
|
- `write_refused_total`, when a guard drops a write. A rising value means
|
|
configuration churn, not a fault.
|
|
- `reset_total`, when a save invalidates a record.
|
|
- `probe_discarded_total`, when `Stop` cancelled a probe. `probe_total` is
|
|
labelled by outcome and a cancelled probe has none, so without this the
|
|
shutdown discard is the package's only uncounted drop.
|
|
- `probe_duration_ms`, the last pass's aggregate probe duration.
|
|
|
|
A transition logs at `Warn` for `reachable` to `unreachable` and `Info` for the
|
|
reverse, with executor id, host, reason and failure count. Unchanged results do
|
|
not log, so a steady host stays silent.
|
|
|
|
## Prior art and departures
|
|
|
|
`internal/integrations/healthpoll` is the closest in-repo precedent: an
|
|
immediate probe on start, a fixed cadence, a configured-or-skip gate,
|
|
best-effort semantics, idempotent `Start`/`Stop`, and a goroutine-leak test.
|
|
Those conventions are kept. Its persistence shape — a timestamp, a boolean and an
|
|
error string on the config row — is not, in four places, because an SSH host is
|
|
not an HTTP API: a **closed reason set** rather than an error string, since five
|
|
failures are acted on differently and only a typed reason lets the UI say
|
|
"fingerprint changed"; **asymmetric hysteresis** rather than flipping on the
|
|
first result, which would let one dropped packet paint a healthy host as down
|
|
(see [State transitions](#state-transitions)); a **separate record** rather than
|
|
a column, since observed state must not share a row with user-authored input
|
|
(see [Persistence](#persistence)); and **changes pushed, results not**, since
|
|
pushing every result would put one message per executor per interval on the wire
|
|
forever.
|
|
|
|
## Testing
|
|
|
|
Strategy only; the plan's `## Tests` table owns the per-criterion evidence.
|
|
|
|
- Poller tests drive the ticker with `testing/synctest`, matching
|
|
`healthpoll_test.go`, under a `goleak` `TestMain`.
|
|
- Classification and hysteresis are table-driven — over constructed errors and
|
|
over probe sequences — so the ordering rules and the exact probe each state
|
|
flips on are asserted, not inferred.
|
|
- The upsert's ordering rule is exercised on **both** dialects, including the
|
|
whole-second versus sub-second pair the text-encoding trap turns into a silent
|
|
SQLite-only failure.
|
|
- The guards get a negative test each: soft-deleted, deactivated or reconfigured
|
|
mid-probe writes nothing. The reset gets its own: it lands with no
|
|
`checked_at`, and a probe in flight from before the save does not overwrite it.
|
|
- The immediate-probe route is tested for caller cancellation and for a
|
|
guard-refused write returning `persisted: false`.
|
|
- Web E2E lives in `apps/web/e2e/tests/ssh/` under the `containers` project,
|
|
gated on `KANDEV_E2E_CONTAINERS=1`.
|
|
|
|
## Related decisions
|
|
|
|
No new ADR: this design creates no system boundary and no ownership rule, and
|
|
extends an existing contract through the repository's designated mechanisms for
|
|
configuration, persistence, events and metrics.
|
|
([0018](../../../decisions/0018-runtime-settings-overrides.md) covers the
|
|
adjacent runtime-override tier.)
|