Skip to content

Commit 41bc072

Browse files
authored
Merge pull request #383 from beyondessential/claude/source-reachability
Source reachability policy: fold per-source staleness into one reachability check
2 parents 71394f1 + e1f1372 commit 41bc072

20 files changed

Lines changed: 1051 additions & 326 deletions

File tree

.workhorse/specs/monitoring/checks.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,19 @@ Canopy-wide checks are Canopy monitoring its own operation (see [SELF](../privat
2121
A source is a named reporter of checks, identified by a short string.
2222
Multiple sources may report on the same server, each concerned with part of the system, and each source's reports are independent: a report from one source says nothing about another source's checks.
2323

24-
Two source names are reserved for Canopy itself: `canopy` for conditions Canopy determines on its own (staleness, reachability, backup health, key expiry, self-monitoring), and `manual` for conditions raised by operators.
24+
Two source names are reserved for Canopy itself: `canopy` for conditions Canopy determines on its own (reachability, backup health, key expiry, self-monitoring), and `manual` for conditions raised by operators.
2525
Reports arriving over the device API cannot use the reserved names.
2626

27+
### Reachability mode
28+
29+
Each source other than the reserved names carries an operator-set reachability mode, governing how the source's silence bears on its servers' reachability (see "Reachability"):
30+
31+
- `on` — a stale source warns, and all of a server's sources stale is unreachable;
32+
- `quiet` — a stale source raises no warning, but still counts toward unreachable;
33+
- `off` — the source is excluded from reachability entirely.
34+
35+
New sources default to `on`. The mode is global to the source, edited by operators alongside the check catalog.
36+
2737
## Results
2838

2939
A check's result is one of, in decreasing order of urgency:
@@ -102,23 +112,29 @@ A source's report for a server carries that source's complete current set of che
102112
Canopy trusts the reporter: a check the source previously reported but omits from its current report has recovered, and its state records that.
103113
Omission by one source never affects another source's checks.
104114

105-
## Source staleness
115+
## Reachability
116+
117+
Canopy tracks, for each server, the sources expected to report — those that have reported, are not in reachability mode `off`, and whose checks are not all decommissioned — and when each last reported.
118+
It keeps one `reachability` check per server, under the `canopy` source, reflecting how many expected sources are currently reporting within the server's down threshold:
119+
120+
- **passed** when every expected source is fresh;
121+
- **warning** when a source in mode `on` is stale but not every expected source is; the stale sources are named in the check's detail, so an operator sees which reporter went quiet. A `quiet` source going stale never raises this warning;
122+
- **failed** when every expected source is stale — nothing is reaching Canopy — and the server is presented as unreachable. `quiet` and `on` sources count alike here.
106123

107-
A source that has reported on a server is expected to keep reporting, unless all of its checks have been decommissioned.
108-
When a source's most recent report for a server is older than the server's down threshold, Canopy raises a staleness check for that (server, source) under the `canopy` source, and clears it when the source reports again.
109-
A server all of whose sources are stale is presented as unreachable.
124+
A stale source degrades the server rather than silently dropping its checks, so a reporter going quiet is never mistaken for health.
125+
There is no per-source staleness check; the one reachability check carries the full picture.
110126

111127
## Liveness and decommissioning
112128

113-
Source staleness is a per-server signal about a reporter that has gone quiet; check liveness is a fleet-wide signal about a (source, check) that has gone away everywhere.
129+
Reachability is a per-server signal about reporters that have gone quiet; check liveness is a fleet-wide signal about a (source, check) that has gone away everywhere.
114130
For each catalogued (source, check) Canopy tracks when it was most recently reported on any server.
115131

116132
A (source, check) not reported anywhere for seven days is surfaced to operators as a candidate for decommissioning.
117133
A (source, check) not reported anywhere for thirty days raises a Canopy-wide warning (see [SELF](../private-server/self-alerts.md)).
118134

119135
Decommissioning is an operator action, never automatic: the candidate list and the Canopy-wide warning surface what has gone away, and an operator decides.
120-
A decommissioned (source, check) is retired fleet-wide: its state on every server is resolved, recording decommissioning as the reason, and it then contributes to nothing — not health, not incidents, not source staleness.
121-
A source all of whose checks are decommissioned is no longer expected to report, so its staleness clears.
136+
A decommissioned (source, check) is retired fleet-wide: its state on every server is resolved, recording decommissioning as the reason, and it then contributes to nothing — not health, not incidents, not reachability.
137+
A source all of whose checks are decommissioned is no longer an expected source, so it drops out of the reachability signal.
122138

123139
If a decommissioned check is reported again it is treated as newly registered — pending operator review, at the warning ceiling — so a resurrected check never silently resumes a retired policy.
124140

crates/commons-types/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod device;
55
pub mod geo;
66
pub mod issue;
77
pub mod server;
8+
pub mod source;
89
pub mod status;
910
pub mod units;
1011
pub mod version;

crates/commons-types/src/source.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//! Operator policy attached to a reporting source (as opposed to a single
2+
//! check). Currently the reachability mode; the ingest mode follows.
3+
4+
use serde::{Deserialize, Serialize};
5+
6+
/// How a source's silence bears on its servers' reachability.
7+
///
8+
/// Stored as text in Postgres, validated as this enum at the edges.
9+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
10+
#[serde(rename_all = "lowercase")]
11+
pub enum ReachabilityMode {
12+
/// A stale source warns, and all of a server's sources stale is
13+
/// unreachable. The default.
14+
#[default]
15+
On,
16+
/// A stale source raises no warning, but still counts toward
17+
/// unreachable — so a server whose only (or last) source is quiet still
18+
/// reads unreachable when it goes silent.
19+
Quiet,
20+
/// The source is excluded from reachability entirely.
21+
Off,
22+
}
23+
24+
#[derive(Debug, Clone, Copy, thiserror::Error)]
25+
#[error("invalid reachability mode; expected one of: on, quiet, off")]
26+
pub struct ReachabilityModeFromStringError;
27+
28+
impl std::str::FromStr for ReachabilityMode {
29+
type Err = ReachabilityModeFromStringError;
30+
31+
fn from_str(s: &str) -> Result<Self, Self::Err> {
32+
match s.to_ascii_lowercase().as_str() {
33+
"on" => Ok(Self::On),
34+
"quiet" => Ok(Self::Quiet),
35+
"off" => Ok(Self::Off),
36+
_ => Err(ReachabilityModeFromStringError),
37+
}
38+
}
39+
}
40+
41+
impl TryFrom<String> for ReachabilityMode {
42+
type Error = ReachabilityModeFromStringError;
43+
44+
fn try_from(value: String) -> Result<Self, Self::Error> {
45+
value.parse()
46+
}
47+
}
48+
49+
impl std::fmt::Display for ReachabilityMode {
50+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51+
let s = match self {
52+
Self::On => "on",
53+
Self::Quiet => "quiet",
54+
Self::Off => "off",
55+
};
56+
write!(f, "{s}")
57+
}
58+
}
59+
60+
impl From<ReachabilityMode> for String {
61+
fn from(m: ReachabilityMode) -> Self {
62+
m.to_string()
63+
}
64+
}

crates/database/src/check_policies.rs

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -209,29 +209,9 @@ impl CheckPolicy {
209209
crate::issues::Issue::resolve(db, id, by, ResolvedReason::Decommissioned).await?;
210210
}
211211

212-
// A source with no live checks left is no longer expected to report;
213-
// clear any per-server staleness it still carries (source_freshness
214-
// now excludes it, so nothing re-files these).
215-
let live: i64 = cp::check_policies
216-
.filter(cp::source.eq(source))
217-
.filter(cp::decommissioned_at.is_null())
218-
.count()
219-
.get_result(db)
220-
.await?;
221-
if live == 0 {
222-
let stale_ref = format!("{}{}", crate::statuses::STALE_REF_PREFIX, source);
223-
let stale_ids: Vec<Uuid> = iss::issues
224-
.select(iss::id)
225-
.filter(iss::source.eq(crate::statuses::CANOPY_SOURCE))
226-
.filter(iss::check_name.eq(&stale_ref))
227-
.filter(iss::server_id.is_not_null())
228-
.filter(iss::resolved_at.is_null())
229-
.load(db)
230-
.await?;
231-
for id in stale_ids {
232-
crate::issues::Issue::resolve(db, id, by, ResolvedReason::Decommissioned).await?;
233-
}
234-
}
212+
// A source whose checks are all decommissioned drops out of
213+
// source_freshness, so it stops counting toward reachability with no
214+
// further action here.
235215
Ok(())
236216
}
237217

crates/database/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub mod server_groups;
2727
pub mod servers;
2828
pub mod silenced_refs;
2929
pub mod slack_outbox;
30+
pub mod source_policies;
3031
pub mod sql_playground_history;
3132
pub mod stability;
3233
pub mod statuses;

crates/database/src/schema.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,15 @@ diesel::table! {
545545
}
546546
}
547547

548+
diesel::table! {
549+
source_policies (source) {
550+
source -> Text,
551+
reachability -> Text,
552+
created_at -> Timestamptz,
553+
updated_at -> Timestamptz,
554+
}
555+
}
556+
548557
diesel::table! {
549558
statuses (id, created_at) {
550559
id -> Uuid,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//! Operator policy per reporting source. Currently the reachability mode
2+
//! (how the source's silence bears on its servers' reachability); the
3+
//! ingest mode follows in a later change. Absent rows mean the defaults.
4+
5+
use std::collections::HashMap;
6+
7+
use commons_errors::{AppError, Result};
8+
use commons_types::source::ReachabilityMode;
9+
use diesel::prelude::*;
10+
use diesel::sql_types;
11+
use diesel_async::{AsyncPgConnection, RunQueryDsl};
12+
use jiff::Timestamp;
13+
14+
/// A reporting source with its reachability policy and fleet-wide
15+
/// last-seen, for the operator source list. Reserved sources are excluded.
16+
#[derive(Clone, Debug)]
17+
pub struct SourceInfo {
18+
pub source: String,
19+
pub reachability: ReachabilityMode,
20+
pub last_seen: Option<Timestamp>,
21+
}
22+
23+
/// One source's policy row.
24+
#[derive(Clone, Debug, Queryable, Selectable)]
25+
#[diesel(table_name = crate::schema::source_policies)]
26+
#[diesel(check_for_backend(diesel::pg::Pg))]
27+
pub struct SourcePolicy {
28+
pub source: String,
29+
#[diesel(deserialize_as = String, serialize_as = String)]
30+
pub reachability: ReachabilityMode,
31+
#[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)]
32+
pub created_at: Timestamp,
33+
#[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)]
34+
pub updated_at: Timestamp,
35+
}
36+
37+
impl SourcePolicy {
38+
/// Every source policy row, ordered by source.
39+
pub async fn list(db: &mut AsyncPgConnection) -> Result<Vec<Self>> {
40+
use crate::schema::source_policies::dsl;
41+
dsl::source_policies
42+
.select(Self::as_select())
43+
.order(dsl::source.asc())
44+
.load(db)
45+
.await
46+
.map_err(AppError::from)
47+
}
48+
49+
/// Every non-reserved reporting source (those with catalogued checks),
50+
/// with its reachability mode (defaulting to `on`) and the most recent
51+
/// time any of its checks was seen fleet-wide. Ordered by source.
52+
pub async fn list_sources(db: &mut AsyncPgConnection) -> Result<Vec<SourceInfo>> {
53+
#[derive(QueryableByName)]
54+
struct Row {
55+
#[diesel(sql_type = sql_types::Text)]
56+
source: String,
57+
#[diesel(sql_type = sql_types::Text)]
58+
reachability: String,
59+
#[diesel(sql_type = sql_types::Nullable<sql_types::Timestamptz>)]
60+
last_seen: Option<jiff_diesel::Timestamp>,
61+
}
62+
let rows: Vec<Row> = diesel::sql_query(
63+
"SELECT cp.source, \
64+
coalesce(sp.reachability, 'on') AS reachability, \
65+
max(cp.last_seen) AS last_seen \
66+
FROM check_policies cp \
67+
LEFT JOIN source_policies sp ON sp.source = cp.source \
68+
WHERE cp.source NOT IN ('canopy', 'manual') \
69+
GROUP BY cp.source, sp.reachability \
70+
ORDER BY cp.source",
71+
)
72+
.load(db)
73+
.await?;
74+
Ok(rows
75+
.into_iter()
76+
.map(|r| SourceInfo {
77+
source: r.source,
78+
reachability: r.reachability.parse().unwrap_or_default(),
79+
last_seen: r.last_seen.map(Into::into),
80+
})
81+
.collect())
82+
}
83+
84+
/// Each source's reachability mode. Sources without a row are absent
85+
/// here; callers default them to [`ReachabilityMode::On`].
86+
pub async fn modes(db: &mut AsyncPgConnection) -> Result<HashMap<String, ReachabilityMode>> {
87+
use crate::schema::source_policies::dsl;
88+
let rows: Vec<(String, String)> = dsl::source_policies
89+
.select((dsl::source, dsl::reachability))
90+
.load(db)
91+
.await?;
92+
Ok(rows
93+
.into_iter()
94+
.filter_map(|(source, mode)| mode.parse().ok().map(|m| (source, m)))
95+
.collect())
96+
}
97+
98+
/// Set a source's reachability mode, creating its policy row if needed.
99+
pub async fn set_reachability(
100+
db: &mut AsyncPgConnection,
101+
source: &str,
102+
mode: ReachabilityMode,
103+
) -> Result<()> {
104+
use crate::schema::source_policies::dsl;
105+
diesel::insert_into(dsl::source_policies)
106+
.values((
107+
dsl::source.eq(source),
108+
dsl::reachability.eq(mode.to_string()),
109+
))
110+
.on_conflict(dsl::source)
111+
.do_update()
112+
.set((
113+
dsl::reachability.eq(mode.to_string()),
114+
dsl::updated_at.eq(jiff_diesel::Timestamp::from(Timestamp::now())),
115+
))
116+
.execute(db)
117+
.await?;
118+
Ok(())
119+
}
120+
}

0 commit comments

Comments
 (0)