| 18 |
18 |
|
use async_trait::async_trait;
|
| 19 |
19 |
|
use futures_util::StreamExt;
|
| 20 |
20 |
|
use std::path::Path;
|
|
21 |
+ |
use std::time::Duration;
|
| 21 |
22 |
|
|
| 22 |
23 |
|
/// A handle to one `ops-agent`, scoped to a caller-side capability set.
|
| 23 |
24 |
|
pub struct AgentRpc {
|
| 27 |
28 |
|
client: reqwest::Client,
|
| 28 |
29 |
|
}
|
| 29 |
30 |
|
|
|
31 |
+ |
/// How long to wait for the TCP connect before calling an agent unreachable.
|
|
32 |
+ |
///
|
|
33 |
+ |
/// Applies to every request, including `/run` and `/pull`: it bounds only
|
|
34 |
+ |
/// establishing the connection, never a live response body, so a multi-minute
|
|
35 |
+ |
/// build or a large artifact download is unaffected. The agent is one tailnet
|
|
36 |
+ |
/// hop away, so a connect that has not landed in this long is not going to.
|
|
37 |
+ |
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
|
38 |
+ |
|
|
39 |
+ |
/// Whole-request cap for the short control calls (`/health`), which return a
|
|
40 |
+ |
/// small JSON body immediately.
|
|
41 |
+ |
///
|
|
42 |
+ |
/// Deliberately NOT applied to the client as a whole: `reqwest`'s request
|
|
43 |
+ |
/// timeout runs until the response *body* has finished, so a client-level
|
|
44 |
+ |
/// timeout would sever `/run` mid-build and truncate `/pull` on a large DMG.
|
|
45 |
+ |
/// Those two are unbounded by design — a build legitimately runs for minutes —
|
|
46 |
+ |
/// and their liveness is covered by `CONNECT_TIMEOUT` plus the exit frame that
|
|
47 |
+ |
/// `run_streaming` requires before it will report success.
|
|
48 |
+ |
const HEALTH_TIMEOUT: Duration = Duration::from_secs(10);
|
|
49 |
+ |
|
| 30 |
50 |
|
impl AgentRpc {
|
| 31 |
51 |
|
/// `base_url` is e.g. `http://mbp.tailnet:8765` (the agent listens on the
|
| 32 |
52 |
|
/// tailnet interface). `host_label` is used only for audit messages.
|
| 33 |
53 |
|
pub fn new(base_url: impl Into<String>, host_label: impl Into<String>, caps: CapabilitySet) -> Self {
|
| 34 |
|
- |
Self {
|
| 35 |
|
- |
base_url: base_url.into(),
|
| 36 |
|
- |
host_label: host_label.into(),
|
| 37 |
|
- |
caps,
|
| 38 |
|
- |
client: reqwest::Client::new(),
|
| 39 |
|
- |
}
|
|
54 |
+ |
// A default-built client has no timeouts whatsoever, which left an agent
|
|
55 |
+ |
// that accepts TCP and then never answers (a wedged process, or a path
|
|
56 |
+ |
// that blackholes after the handshake) hanging a release indefinitely
|
|
57 |
+ |
// with no output. An unreachable host only ever failed because the OS
|
|
58 |
+ |
// gave up on the connect (~30s), which was luck, not design.
|
|
59 |
+ |
let client = reqwest::Client::builder()
|
|
60 |
+ |
.connect_timeout(CONNECT_TIMEOUT)
|
|
61 |
+ |
.build()
|
|
62 |
+ |
.expect("reqwest client with a connect timeout");
|
|
63 |
+ |
Self { base_url: base_url.into(), host_label: host_label.into(), caps, client }
|
| 40 |
64 |
|
}
|
| 41 |
65 |
|
|
| 42 |
66 |
|
/// `GET /health` — liveness plus the agent's own declared grant.
|
| 44 |
68 |
|
let resp = self
|
| 45 |
69 |
|
.client
|
| 46 |
70 |
|
.get(format!("{}/health", self.base_url))
|
|
71 |
+ |
.timeout(HEALTH_TIMEOUT)
|
| 47 |
72 |
|
.send()
|
| 48 |
73 |
|
.await
|
| 49 |
|
- |
.context("GET /health")?
|
|
74 |
+ |
.with_context(|| {
|
|
75 |
+ |
format!(
|
|
76 |
+ |
"GET /health ({}): no answer within {}s — the agent is unreachable, or it is \
|
|
77 |
+ |
accepting connections without answering them",
|
|
78 |
+ |
self.host_label,
|
|
79 |
+ |
HEALTH_TIMEOUT.as_secs()
|
|
80 |
+ |
)
|
|
81 |
+ |
})?
|
| 50 |
82 |
|
.error_for_status()
|
| 51 |
83 |
|
.context("agent /health status")?;
|
| 52 |
84 |
|
resp.json().await.context("decoding /health")
|