diff --git a/api/openapi.json b/api/openapi.json index 2cd138f7..13ed549f 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -3495,7 +3495,7 @@ "operationId": "forceUpdateCheck", "responses": { "200": { - "description": "Refreshed update-check state (`last_error` carries a failed check)", + "description": "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)", "content": { "application/json": { "schema": { @@ -7372,7 +7372,8 @@ "apply", "channel_hint", "check_disabled", - "available" + "available", + "not_published" ], "properties": { "apply": { @@ -7452,6 +7453,10 @@ } ] }, + "not_published": { + "type": "boolean", + "description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error." + }, "opt_in_hint": { "type": [ "string", diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 97fddc09..f4729d00 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -480,8 +480,23 @@ fn headless_check_update() -> glib::ExitCode { "installed {} ({}, {})", status.current, status.kind, status.channel ); - println!("available {}", status.latest); - if let Some(err) = &status.error { + // `latest` falls back to `current` when the check couldn't run — printing that as + // "available" would read as a confirmed answer we don't have. + if status.error.is_some() { + println!("available unknown"); + } else { + println!("available {}", status.latest); + } + if status.not_published { + // Says what it is, in words, instead of a raw HTTP status. The exit code still + // reports "could not tell" (see the doc comment above): an empty channel is the + // absence of evidence that this build is current, and a mistyped + // PUNKTFUNK_UPDATE_FEED is indistinguishable from one out here. + println!( + "update nothing published on the {} channel yet", + status.channel + ); + } else if let Some(err) = &status.error { eprintln!("check-update: {err}"); } else if status.update_available { println!("update yes"); diff --git a/crates/pf-client-core/src/update.rs b/crates/pf-client-core/src/update.rs index 5d788087..b33d9cb3 100644 --- a/crates/pf-client-core/src/update.rs +++ b/crates/pf-client-core/src/update.rs @@ -99,6 +99,18 @@ pub struct Status { /// Why the check couldn't complete. `update_available` is always false when set. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// The feed answered, but this channel has **no release published yet** — an expected + /// state rather than a malfunction, so a caller can say so plainly instead of showing a + /// raw "HTTP 404". + /// + /// Deliberately NOT symmetric with the host's `UpdateStatus`, which clears `last_error` + /// for this case: there the consumer is a human reading a console, and a red "last check + /// failed" on an empty feed is the bug being fixed. Here the consumer is a shell script + /// reading an exit code, so `error` stays set and `--check-update` keeps returning 1. + /// An empty channel is not evidence that this build is current, and a mistyped + /// `PUNKTFUNK_UPDATE_FEED` is indistinguishable from one out here. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub not_published: bool, } /// The Ed25519 keys trusted for update manifests — pinned once in [`pf_update_check`] so the @@ -302,6 +314,7 @@ pub fn check(current: &str) -> Status { opt_in_hint: opt_in_would_help(kind, caps).then(opt_in_hint), notes_url: String::new(), error: None, + not_published: false, }; let (apply, applier) = apply_route(kind, caps); status.apply = apply; @@ -320,7 +333,8 @@ pub fn check(current: &str) -> Status { ) { Ok(m) => m, Err(e) => { - status.error = Some(e); + status.not_published = e.is_not_published(); + status.error = Some(e.to_string()); return status; } }; diff --git a/crates/pf-update-check/src/feed.rs b/crates/pf-update-check/src/feed.rs index a44bd84d..002b0daa 100644 --- a/crates/pf-update-check/src/feed.rs +++ b/crates/pf-update-check/src/feed.rs @@ -19,6 +19,43 @@ pub const DEFAULT_FEED_BASE: &str = /// One fetch's wall-clock budget. const FETCH_TIMEOUT: Duration = Duration::from_secs(15); +/// Why a fetch didn't produce a manifest. +/// +/// Exactly one failure mode is an *expected* steady state: a channel nobody has published to +/// answers `manifest.json` with a 404. Collapsing that into the same string as a transport or +/// signature failure is what made an empty stable feed render as "last check failed: feed +/// returned HTTP 404" — telling operators their box is broken when the feed is merely empty. +/// Everything else stays a real failure, loudly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FeedError { + /// This channel has no manifest at all. Note the deliberate narrowness: only a 404 on + /// `manifest.json` itself counts. A 404 on the *signature* means the manifest exists + /// without its proof — a half-published pair (the publisher's manifest-then-signature + /// window, or a botched upload), which must stay fail-closed and noisy. + NotPublished, + /// A real failure: transport, an HTTP status that isn't the empty-channel 404, the size + /// cap, or signature/schema rejection. + Failed(String), +} + +impl FeedError { + /// Is this the benign "nothing published on this channel yet" state? + pub fn is_not_published(&self) -> bool { + matches!(self, Self::NotPublished) + } +} + +impl std::fmt::Display for FeedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotPublished => f.write_str("no release has been published on this channel yet"), + Self::Failed(msg) => f.write_str(msg), + } + } +} + +impl std::error::Error for FeedError {} + /// The feed base, with a `PUNKTFUNK_UPDATE_FEED` override for tests and dev feeds. This is /// operator config (an env var on the process), never request-time input; the `https://` (or /// loopback) requirement keeps a stray value from silently downgrading the transport. @@ -36,9 +73,11 @@ pub fn fetch_manifest_blocking( channel: &str, keys: &[PublicKey], user_agent: &str, -) -> Result { +) -> Result { if keys.is_empty() { - return Err("no update key is pinned in this build".into()); + return Err(FeedError::Failed( + "no update key is pinned in this build".into(), + )); } let agent = ureq::AgentBuilder::new() .timeout(FETCH_TIMEOUT) @@ -48,29 +87,42 @@ pub fn fetch_manifest_blocking( let url = format!("{base}/{channel}/manifest.json"); let sig_url = format!("{url}.sig"); - let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?; + // Only the MANIFEST leg can report an empty channel; see [`FeedError::NotPublished`]. + let body = read_capped(agent.get(&url).call().map_err(manifest_err)?)?; let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?; - let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?; + let sig_text = String::from_utf8(sig) + .map_err(|_| FeedError::Failed("signature file is not text".into()))?; - manifest::verify_and_parse(&body, &sig_text, keys, channel).map_err(|e| format!("{e:#}")) + manifest::verify_and_parse(&body, &sig_text, keys, channel) + .map_err(|e| FeedError::Failed(format!("{e:#}"))) } -fn fetch_err(e: ureq::Error) -> String { +/// The manifest leg: a 404 here means the channel is empty, not broken. +fn manifest_err(e: ureq::Error) -> FeedError { match e { - ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"), - other => format!("feed fetch failed: {other}"), + ureq::Error::Status(404, _) => FeedError::NotPublished, + other => fetch_err(other), } } -fn read_capped(resp: ureq::Response) -> Result, String> { +fn fetch_err(e: ureq::Error) -> FeedError { + FeedError::Failed(match e { + ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"), + other => format!("feed fetch failed: {other}"), + }) +} + +fn read_capped(resp: ureq::Response) -> Result, FeedError> { use std::io::Read as _; let mut buf = Vec::new(); let mut reader = resp.into_reader().take(MAX_MANIFEST_BYTES as u64 + 1); reader .read_to_end(&mut buf) - .map_err(|e| format!("read failed: {e}"))?; + .map_err(|e| FeedError::Failed(format!("read failed: {e}")))?; if buf.len() > MAX_MANIFEST_BYTES { - return Err("response exceeds the manifest size cap".into()); + return Err(FeedError::Failed( + "response exceeds the manifest size cap".into(), + )); } Ok(buf) } @@ -85,7 +137,42 @@ mod tests { // licence to trust whatever the feed serves. let err = fetch_manifest_blocking("https://127.0.0.1:1", "stable", &[], "test").unwrap_err(); - assert!(err.contains("no update key"), "{err}"); + assert!(err.to_string().contains("no update key"), "{err}"); + // And it is a real failure — never the benign empty-channel state. + assert!(!err.is_not_published()); + } + + fn status(code: u16) -> ureq::Error { + ureq::Error::Status(code, ureq::Response::new(code, "status", "").unwrap()) + } + + /// The whole point of the split: an empty channel is not a broken feed. + #[test] + fn manifest_404_is_not_published_but_other_statuses_are_failures() { + assert_eq!(manifest_err(status(404)), FeedError::NotPublished); + for code in [403, 500, 502] { + let e = manifest_err(status(code)); + assert!(!e.is_not_published(), "HTTP {code} must stay a failure"); + assert!(e.to_string().contains(&code.to_string()), "{e}"); + } + } + + /// A missing SIGNATURE is a half-published pair, not an empty channel — the manifest leg + /// already answered 200. Treating it as "nothing published yet" would quietly excuse the + /// one window where a manifest exists without its proof. + #[test] + fn signature_404_stays_a_failure() { + let e = fetch_err(status(404)); + assert!(!e.is_not_published()); + assert_eq!(e.to_string(), "feed returned HTTP 404"); + } + + #[test] + fn not_published_reads_as_plain_english() { + assert_eq!( + FeedError::NotPublished.to_string(), + "no release has been published on this channel yet" + ); } #[test] diff --git a/crates/pf-update-check/src/lib.rs b/crates/pf-update-check/src/lib.rs index 000d8c12..5fdf6cfb 100644 --- a/crates/pf-update-check/src/lib.rs +++ b/crates/pf-update-check/src/lib.rs @@ -35,6 +35,7 @@ pub mod sig; pub mod version; pub use detect::{InstallKind, Product}; +pub use feed::FeedError; pub use manifest::{Manifest, MAX_MANIFEST_BYTES, SCHEMA}; pub use sig::{verify_signature, PublicKey}; pub use version::{canary_run, is_newer, triple, Channel}; diff --git a/crates/punktfunk-host/src/mgmt/update.rs b/crates/punktfunk-host/src/mgmt/update.rs index ba263af9..7b9e2d39 100644 --- a/crates/punktfunk-host/src/mgmt/update.rs +++ b/crates/punktfunk-host/src/mgmt/update.rs @@ -83,6 +83,12 @@ pub(crate) struct UpdateStatus { pub last_checked_unix: Option, /// Why the last check failed, verbatim, if it did. pub last_error: Option, + /// The check reached the feed and found this channel has **no release published yet** — + /// an expected state (a channel nobody has announced to answers with a 404), not a + /// failure. Mutually exclusive with `last_error`, so a UI can say "nothing published yet" + /// instead of painting an empty feed as a broken host. Never set once a manifest has been + /// seen for this channel: a feed that loses a document it used to serve stays an error. + pub not_published: bool, /// This install could one-click apply, but the operator hasn't opted in yet — the /// command to run (Linux: join the `punktfunk-update` group). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -142,6 +148,7 @@ fn status_from(snap: update::Snapshot) -> UpdateStatus { }), last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix), last_error: snap.last_error, + not_published: snap.not_published, opt_in_hint: update::opt_in_hint(), job, last_result: snap.last_result.as_ref().map(|r| UpdateResultInfo { @@ -186,7 +193,7 @@ pub(crate) async fn get_update_status() -> Json { tag = "update", operation_id = "forceUpdateCheck", responses( - (status = OK, description = "Refreshed update-check state (`last_error` carries a failed check)", body = UpdateStatus), + (status = OK, description = "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)", body = UpdateStatus), (status = CONFLICT, description = "Update checks are disabled on this host", body = ApiError), (status = TOO_MANY_REQUESTS, description = "A forced check ran less than 30 s ago", body = ApiError), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), diff --git a/crates/punktfunk-host/src/update.rs b/crates/punktfunk-host/src/update.rs index 86398fd9..095af660 100644 --- a/crates/punktfunk-host/src/update.rs +++ b/crates/punktfunk-host/src/update.rs @@ -27,7 +27,7 @@ pub(crate) use pf_update_check::manifest; pub(crate) mod windows; use manifest::Manifest; -use pf_update_check::PublicKey; +use pf_update_check::{FeedError, PublicKey}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -141,6 +141,9 @@ pub(crate) struct Checked { struct Runtime { checked: Option, last_error: Option, + /// The channel has nothing published (and never had, for us) — an expected state, kept + /// out of `last_error` so a console never paints an empty feed as a broken host. + not_published: bool, /// Refresh in flight (status kicks at most one). refreshing: bool, /// Wall-clock guard for the forced-check rate limit. @@ -214,7 +217,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) { /// Fetch + verify the channel manifest through the shared checker. Blocking — call from a /// blocking thread. -fn fetch_manifest_blocking(channel: &str) -> Result { +fn fetch_manifest_blocking(channel: &str) -> Result { pf_update_check::feed::fetch_manifest_blocking( &pf_update_check::feed::feed_base(), channel, @@ -227,18 +230,18 @@ fn fetch_manifest_blocking(channel: &str) -> Result { } /// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache, -/// announce a newly available release on the event bus. Returns the user-facing error string -/// on failure (also cached for status). -pub(crate) fn refresh_blocking() -> Result { +/// announce a newly available release on the event bus. Returns the user-facing error on +/// failure (also cached for status). +pub(crate) fn refresh_blocking() -> Result { let (kind, channel) = detect::detect(); let result = fetch_manifest_blocking(channel.as_str()).and_then(|m| { let path = state_path(); let floor = load_floor(&path, channel.as_str()); if m.serial < floor { - return Err(format!( + return Err(FeedError::Failed(format!( "manifest serial {} is older than the last accepted {} — refusing rollback", m.serial, floor - )); + ))); } store_floor(&path, channel.as_str(), m.serial); Ok(m) @@ -268,16 +271,32 @@ pub(crate) fn refresh_blocking() -> Result { }); } rt.last_error = None; + rt.not_published = false; rt.checked = Some(checked.clone()); Ok(checked) } Err(e) => { - rt.last_error = Some(e.clone()); + let (last_error, not_published) = classify_failure(&e, rt.checked.is_some()); + rt.last_error = last_error; + rt.not_published = not_published; Err(e) } } } +/// Split a failed refresh into `(last_error, not_published)` — the two are never both set. +/// +/// An empty channel is only benign while we have never seen a manifest for it. Once a check +/// has succeeded, the same 404 means the feed LOST a document it used to serve, which is a +/// regression that must stay a loud error rather than being excused as "nothing yet". +fn classify_failure(e: &FeedError, had_manifest: bool) -> (Option, bool) { + if e.is_not_published() && !had_manifest { + (None, true) + } else { + (Some(e.to_string()), false) + } +} + /// The status handler's read: current cache + errors, kicking a background refresh when the /// cache is cold and checks are enabled. pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot { @@ -296,6 +315,7 @@ pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot { Snapshot { checked: rt.checked.clone(), last_error: rt.last_error.clone(), + not_published: rt.not_published, job: rt.job.clone(), last_result: jobs::read_result(&jobs::result_path()), } @@ -329,6 +349,7 @@ pub(crate) async fn force_check() -> Result { Ok(Snapshot { checked: rt.checked.clone(), last_error: rt.last_error.clone(), + not_published: rt.not_published, job: rt.job.clone(), last_result: jobs::read_result(&jobs::result_path()), }) @@ -570,6 +591,8 @@ pub(crate) fn reconcile_at_boot() { pub(crate) struct Snapshot { pub checked: Option, pub last_error: Option, + /// The channel simply has no release yet — mutually exclusive with `last_error`. + pub not_published: bool, /// The live apply job, when one runs. When the process was restarted mid-apply this is /// `None` but a fresh intent still reads as in-flight — the API layer surfaces that via /// [`Snapshot::applying_from_intent`]. @@ -640,6 +663,34 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The console paints `last_error` as a failure and `not_published` as a plain sentence, + /// so the two must never arrive together — and the benign reading must not survive a + /// channel that has already served us a manifest. + #[test] + fn empty_channel_is_benign_only_until_a_manifest_has_been_seen() { + let (err, not_published) = classify_failure(&FeedError::NotPublished, false); + assert_eq!(err, None); + assert!(not_published); + + // Same 404, but the feed used to answer — that is a regression, not "nothing yet". + let (err, not_published) = classify_failure(&FeedError::NotPublished, true); + assert_eq!( + err.as_deref(), + Some("no release has been published on this channel yet") + ); + assert!(!not_published); + + // Every real failure stays an error whether or not we have a cached manifest. + for had_manifest in [false, true] { + let (err, not_published) = classify_failure( + &FeedError::Failed("feed returned HTTP 500".into()), + had_manifest, + ); + assert_eq!(err.as_deref(), Some("feed returned HTTP 500")); + assert!(!not_published); + } + } + #[test] fn pinned_keys_skip_empty_rotation_slot() { let keys = pinned_keys(); @@ -664,6 +715,7 @@ mod tests { fetched_unix: now_unix(), }), last_error: None, + not_published: false, job: None, last_result: None, }; diff --git a/web/messages/de.json b/web/messages/de.json index 3a51bf70..0f78fabe 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -543,6 +543,8 @@ "update_checking": "Prüfe…", "update_last_checked": "Zuletzt geprüft", "update_never_checked": "Noch nicht geprüft", + "update_none_published": "Noch keine veröffentlicht", + "update_not_published": "Auf dem Kanal {channel} wurde noch nichts veröffentlicht. Die Prüfung funktioniert — es gibt nur noch keine Version zum Vergleichen.", "update_stale": "Der Update-Feed hat sich seit über 45 Tagen nicht geändert — Prüfungen gelingen, aber es kommt nichts Neues an. Falls das nicht stimmen kann, prüfe die Verbindung dieses Hosts zu git.unom.io.", "update_disabled": "Update-Prüfungen sind auf diesem Host deaktiviert (PUNKTFUNK_UPDATE_CHECK=0).", "update_error": "Letzte Prüfung fehlgeschlagen:", diff --git a/web/messages/en.json b/web/messages/en.json index 6b095454..7104f83a 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -543,6 +543,8 @@ "update_checking": "Checking…", "update_last_checked": "Last checked", "update_never_checked": "Not checked yet", + "update_none_published": "None published yet", + "update_not_published": "Nothing has been published to the {channel} channel yet. The check itself is working — there's just no release to compare against.", "update_stale": "The update feed hasn't changed in over 45 days — checks succeed but nothing new arrives. If that seems wrong, check this host's connectivity to git.unom.io.", "update_disabled": "Update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0).", "update_error": "Last check failed:", diff --git a/web/src/sections/Host/UpdateCard.tsx b/web/src/sections/Host/UpdateCard.tsx index 0e1978ef..710ea30c 100644 --- a/web/src/sections/Host/UpdateCard.tsx +++ b/web/src/sections/Host/UpdateCard.tsx @@ -162,7 +162,9 @@ export const UpdateCard: FC<{ ) : ( - {m.update_never_checked()} + {s.not_published + ? m.update_none_published() + : m.update_never_checked()} ) } @@ -211,6 +213,17 @@ export const UpdateCard: FC<{ {m.update_stale()}

)} + {/* An empty channel is a normal state, not a fault: it looks like a + 404 down at the transport, but it means "nobody has announced a + release here yet". Rendering it in the failure style told + operators their host was broken when nothing was. The host keeps + the two apart (`not_published` is never set alongside + `last_error`), so this stays a straight either/or. */} + {!inFlight && s.not_published && ( +

+ {m.update_not_published({ channel: s.channel })} +

+ )} {!inFlight && s.last_error && (

{m.update_error()} {s.last_error}