The poller read `<state>/github-token` once at start, so after a PAT rotation it kept polling with the revoked token until the unit restarted. It now re-reads the file on every tick, as `hive-forge-notify` does: a different non-empty value rebuilds the GitHub source, clears the own-login cache, and logs one info line. A missing or empty file keeps the token in use. A 401 from the notifications endpoint is now logged at warn, once per token value; other non-2xx statuses stay at debug. Part of #5017 (slice G).
301 lines
12 KiB
Rust
301 lines
12 KiB
Rust
//! `hive-github-notify` binary — long-running per-agent **github.com**
|
|
//! notification poller. Same delivery path as its Forgejo sibling: unread
|
|
//! list, per-thread summary, todo upsert on the harness's in-agent socket,
|
|
//! mark read on the source.
|
|
//!
|
|
//! Takes no arguments. `HYPERHIVE_STATE_DIR` holds the PAT
|
|
//! (`github-token`, fetched from the swarm secret store by
|
|
//! `nix/agent-modules/github-token.nix` — see `docs/integrations/github.md`)
|
|
//! and `HIVE_AGENT_SOCKET` is the harness's todo socket.
|
|
//! Having a PAT *is* the opt-in: with no token the poller logs why and
|
|
//! exits 0, so deploying this unit to an agent that never gets one costs a
|
|
//! settled process rather than a restart loop. Once polling, it re-reads
|
|
//! `github-token` on every tick, so a rotated PAT takes effect on the next
|
|
//! poll without a restart.
|
|
//!
|
|
//! ⚠️ Reading the notification stream needs the **`notifications` scope**
|
|
//! on the PAT — a token minted for `gh` + `git push` usually carries `repo`
|
|
//! only, which is enough to push and open PRs but not to read (or mark
|
|
//! read) notifications. A PAT without it is not fatal: the poller logs the
|
|
//! refusal and stays quiet, so the symptom is silence rather than an error.
|
|
|
|
mod source;
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use hive_forge_notify::notify::{
|
|
POLL_INTERVAL_SECS, TOKEN_RETRY_MAX, TOKEN_RETRY_SECS, poll_once, resolve_own_login,
|
|
};
|
|
use hive_forge_notify::{HTTP_TIMEOUT_SECS, agent_socket, init_tracing};
|
|
use source::GithubSource;
|
|
use tracing::{debug, info, warn};
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
init_tracing();
|
|
|
|
let socket = agent_socket();
|
|
info!(socket = %socket.display(), "hive-github-notify starting");
|
|
|
|
// Returns only when no PAT ever arrives; otherwise loops forever.
|
|
github_loop(hive_forge_notify::state_dir(), socket).await;
|
|
}
|
|
|
|
/// Waits for the PAT to appear: the agent's store fetch writes the token
|
|
/// out of band and it takes effect without a rebuild, so an agent that gains
|
|
/// a PAT mid-session starts getting notifications on the next tick rather
|
|
/// than after a restart. Gives up — returning, so the process exits 0
|
|
/// rather than restart-looping — when no PAT ever arrives, which is the
|
|
/// common case for an agent that has the unit but no account.
|
|
async fn github_loop(state_dir: String, socket: std::path::PathBuf) {
|
|
let token_path = format!("{state_dir}/github-token");
|
|
let mut attempts = 0u32;
|
|
let token = loop {
|
|
match tokio::fs::read_to_string(&token_path).await {
|
|
Ok(t) if !t.trim().is_empty() => break t.trim().to_owned(),
|
|
_ => debug!("forge_notify: no github token at {token_path} yet"),
|
|
}
|
|
attempts += 1;
|
|
if attempts >= TOKEN_RETRY_MAX {
|
|
debug!(
|
|
"forge_notify: no github token after {TOKEN_RETRY_MAX} retries — github disabled"
|
|
);
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(TOKEN_RETRY_SECS)).await;
|
|
};
|
|
|
|
let client = match reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
|
|
.build()
|
|
{
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
warn!("forge_notify: failed to build github HTTP client: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let mut pat = Pat::new(source::API_BASE, token_path, token);
|
|
let mut own_login = resolve_own_login(&client, &pat.source).await;
|
|
let mut delivered: HashMap<String, String> = HashMap::new();
|
|
|
|
// GitHub tells callers how often it is willing to be polled
|
|
// (`X-Poll-Interval`, 60s in practice) and rate-limits those who
|
|
// ignore it. Start at our own cadence and re-arm to whatever the
|
|
// server asks for — never faster than it wants, never slower than we
|
|
// need.
|
|
let mut cadence = POLL_INTERVAL_SECS;
|
|
let mut interval = tokio::time::interval(Duration::from_secs(cadence));
|
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
interval.tick().await;
|
|
|
|
info!("forge_notify: github polling started");
|
|
|
|
loop {
|
|
interval.tick().await;
|
|
let hint = poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).await;
|
|
if let Some(secs) = hint.filter(|s| *s > cadence) {
|
|
debug!(
|
|
secs,
|
|
"forge_notify: github asked for a slower poll — re-arming"
|
|
);
|
|
cadence = secs;
|
|
interval = tokio::time::interval(Duration::from_secs(cadence));
|
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
interval.tick().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One poll: pick up a rotated PAT, resolve the own login if it is not
|
|
/// known for the PAT in use, then poll. Returns the server's poll-interval
|
|
/// hint.
|
|
async fn poll_tick(
|
|
pat: &mut Pat,
|
|
own_login: &mut String,
|
|
client: &reqwest::Client,
|
|
socket: &std::path::Path,
|
|
delivered: &mut HashMap<String, String>,
|
|
) -> Option<u64> {
|
|
if pat.reread() {
|
|
info!("forge_notify: github token changed on disk; using the new one");
|
|
own_login.clear();
|
|
}
|
|
if own_login.is_empty() {
|
|
*own_login = resolve_own_login(client, &pat.source).await;
|
|
}
|
|
poll_once(&pat.source, client, socket, delivered, own_login).await
|
|
}
|
|
|
|
/// The PAT in use and the source built from it. The agent's store fetch
|
|
/// replaces `github-token` by atomic rename when the PAT rotates and never
|
|
/// deletes it, so a missing or empty file means "not readable right now"
|
|
/// and keeps the current token.
|
|
struct Pat {
|
|
api_base: String,
|
|
path: std::path::PathBuf,
|
|
token: String,
|
|
source: GithubSource,
|
|
}
|
|
|
|
impl Pat {
|
|
fn new(api_base: &str, path: impl Into<std::path::PathBuf>, token: String) -> Self {
|
|
let source = GithubSource::new(api_base, &token);
|
|
Self {
|
|
api_base: api_base.to_owned(),
|
|
path: path.into(),
|
|
token,
|
|
source,
|
|
}
|
|
}
|
|
|
|
/// Re-read the file and swap to its token if it differs. `true` when
|
|
/// the token changed.
|
|
fn reread(&mut self) -> bool {
|
|
match hive_forge_notify::read_first_token(std::slice::from_ref(&self.path)) {
|
|
Some(t) if t != self.token => {
|
|
self.source = GithubSource::new(&self.api_base, &t);
|
|
self.token = t;
|
|
true
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
|
|
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
|
use tokio::sync::mpsc;
|
|
|
|
use super::{Pat, poll_tick};
|
|
|
|
fn scratch(tag: &str) -> std::path::PathBuf {
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |d| d.as_nanos());
|
|
let dir = std::env::temp_dir().join(format!("hive-github-notify-{tag}-{ts}"));
|
|
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
|
dir
|
|
}
|
|
|
|
/// A stand-in for api.github.com on loopback. Answers `/user` with a
|
|
/// login and `/notifications` with an empty page, and reports each
|
|
/// request as `(path, Authorization header)`.
|
|
async fn stub() -> (String, mpsc::UnboundedReceiver<(String, String)>) {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind");
|
|
let base = format!("http://{}", listener.local_addr().expect("addr"));
|
|
let (tx, rx) = mpsc::unbounded_channel();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let (mut sock, _) = listener.accept().await.expect("accept");
|
|
let mut buf = Vec::new();
|
|
let mut chunk = [0u8; 1024];
|
|
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
|
let n = sock.read(&mut chunk).await.expect("read");
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
buf.extend_from_slice(&chunk[..n]);
|
|
}
|
|
let head = String::from_utf8_lossy(&buf).into_owned();
|
|
let path = head
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.unwrap_or_default()
|
|
.to_owned();
|
|
let auth = head
|
|
.lines()
|
|
.find_map(|l| {
|
|
let (k, v) = l.split_once(':')?;
|
|
k.eq_ignore_ascii_case("authorization")
|
|
.then(|| v.trim().to_owned())
|
|
})
|
|
.unwrap_or_default();
|
|
let body = if path.starts_with("/user") {
|
|
r#"{"login":"me"}"#
|
|
} else {
|
|
"[]"
|
|
};
|
|
let reply = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
|
|
content-length: {}\r\nconnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
let _ = sock.write_all(reply.as_bytes()).await;
|
|
let _ = tx.send((path, auth));
|
|
}
|
|
});
|
|
(base, rx)
|
|
}
|
|
|
|
/// Every request one tick made, in order.
|
|
fn drain(rx: &mut mpsc::UnboundedReceiver<(String, String)>) -> Vec<(String, String)> {
|
|
std::iter::from_fn(|| rx.try_recv().ok()).collect()
|
|
}
|
|
|
|
fn poll(path: &str) -> bool {
|
|
path.starts_with("/notifications")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_rotated_token_is_sent_on_the_next_poll() {
|
|
let (base, mut rx) = stub().await;
|
|
let dir = scratch("rotate");
|
|
let path = dir.join("github-token");
|
|
std::fs::write(&path, "old\n").expect("write");
|
|
let mut pat = Pat::new(&base, &path, "old".to_owned());
|
|
let mut own_login = String::new();
|
|
let mut delivered = HashMap::new();
|
|
let client = reqwest::Client::new();
|
|
let socket = dir.join("no-socket");
|
|
|
|
poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).await;
|
|
let first = drain(&mut rx);
|
|
assert!(first.iter().any(|(p, a)| poll(p) && a == "Bearer old"));
|
|
assert_eq!(own_login, "me");
|
|
|
|
std::fs::write(&path, "new\n").expect("write");
|
|
poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).await;
|
|
let second = drain(&mut rx);
|
|
assert!(second.iter().all(|(_, a)| a == "Bearer new"), "{second:?}");
|
|
assert!(second.iter().any(|(p, _)| poll(p)), "{second:?}");
|
|
// The own login is resolved again, under the new token.
|
|
assert!(second.iter().any(|(p, _)| p == "/user"), "{second:?}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_empty_or_missing_file_keeps_the_token_in_use() {
|
|
let (base, mut rx) = stub().await;
|
|
let dir = scratch("keep");
|
|
let path = dir.join("github-token");
|
|
let mut pat = Pat::new(&base, &path, "old".to_owned());
|
|
let mut own_login = String::new();
|
|
let mut delivered = HashMap::new();
|
|
let client = reqwest::Client::new();
|
|
let socket = dir.join("no-socket");
|
|
|
|
std::fs::write(&path, "\n").expect("write");
|
|
poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).await;
|
|
let empty = drain(&mut rx);
|
|
assert!(empty.iter().any(|(p, _)| poll(p)), "{empty:?}");
|
|
assert!(empty.iter().all(|(_, a)| a == "Bearer old"), "{empty:?}");
|
|
|
|
std::fs::remove_file(&path).expect("remove");
|
|
poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).await;
|
|
let missing = drain(&mut rx);
|
|
assert!(missing.iter().any(|(p, _)| poll(p)), "{missing:?}");
|
|
assert!(
|
|
missing.iter().all(|(_, a)| a == "Bearer old"),
|
|
"{missing:?}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|