Skip to content

Commit f34c635

Browse files
committed
feat(flux): add coold heartbeats and logs
Send Flux ping frames to connected coold streams, track pong timeouts, publish coold reachability updates, and route coold.logs requests.
1 parent cdd1fd0 commit f34c635

8 files changed

Lines changed: 343 additions & 13 deletions

File tree

coold/src/grpc/client.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use tracing::{info, warn};
1414
use crate::config::{Config, VERSION};
1515
use crate::grpc::handlers::handle;
1616
use crate::grpc::proto::{
17-
agent_client::AgentClient, client_msg, ClientMsg, Hello, ResourceStatusUpdate,
17+
agent_client::AgentClient, client_msg, server_msg, ClientMsg, Hello, Pong, ResourceStatusUpdate,
1818
};
1919
use crate::podman::PodmanClient;
2020

@@ -212,6 +212,7 @@ fn primitive_capabilities() -> Vec<String> {
212212
"firewall.revoke",
213213
"firewall.list",
214214
"firewall.reconcile",
215+
"coold.logs",
215216
]
216217
.into_iter()
217218
.map(str::to_string)
@@ -336,6 +337,11 @@ async fn connect_and_serve(
336337
continue;
337338
};
338339

340+
if matches!(command, server_msg::Command::Ping(_)) {
341+
tx.send(pong_for(&request_id)).await.context("send Pong")?;
342+
continue;
343+
}
344+
339345
let tx = tx.clone();
340346
let podman = podman.clone();
341347
tokio::spawn(async move {
@@ -348,6 +354,14 @@ async fn connect_and_serve(
348354
Ok(())
349355
}
350356

357+
fn pong_for(request_id: &str) -> ClientMsg {
358+
ClientMsg {
359+
payload: Some(client_msg::Payload::Pong(Pong {
360+
request_id: request_id.to_string(),
361+
})),
362+
}
363+
}
364+
351365
#[cfg(test)]
352366
mod tests {
353367
use super::load_host_jwt;
@@ -375,6 +389,18 @@ mod tests {
375389
assert!(msg.contains("insecure perms"), "got: {msg}");
376390
}
377391

392+
#[test]
393+
fn builds_pong_for_ping_request_id() {
394+
let msg = super::pong_for("ping-1");
395+
396+
match msg.payload {
397+
Some(super::client_msg::Payload::Pong(pong)) => {
398+
assert_eq!(pong.request_id, "ping-1");
399+
}
400+
other => panic!("expected pong payload, got {other:?}"),
401+
}
402+
}
403+
378404
#[tokio::test]
379405
async fn rejects_group_readable() {
380406
let dir = tempfile::tempdir().unwrap();

coold/src/grpc/handlers.rs

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ use crate::grpc::proto::{
1111
client_msg, response, server_msg, ApplyIngressResp, ClientMsg, ContainerSummary,
1212
ContainersCreateResp, ContainersDeleteResp, ContainersExecResp, ContainersHealthcheckRunResp,
1313
ContainersInspectResp, ContainersListResp, ContainersLogsResp, ContainersRestartResp,
14-
ContainersStartResp, ContainersStopResp, Error, FirewallAllowResp, FirewallListResp,
15-
FirewallReconcileResp, FirewallRevokeResp, FirewallRule as ProtoFirewallRule, ImageSummary,
16-
ImagesDeleteResp, ImagesListResp, ImagesPullResp, IngressAppConfig, Response, StopIngressResp,
14+
ContainersStartResp, ContainersStopResp, CooldLogsResp, Error, FirewallAllowResp,
15+
FirewallListResp, FirewallReconcileResp, FirewallRevokeResp, FirewallRule as ProtoFirewallRule,
16+
ImageSummary, ImagesDeleteResp, ImagesListResp, ImagesPullResp, IngressAppConfig, Response,
17+
StopIngressResp,
1718
};
1819
use crate::podman::client::{CreateContainerInput, CreatePortMapping};
1920
use crate::podman::PodmanClient;
@@ -25,6 +26,9 @@ pub async fn handle(
2526
tx: mpsc::Sender<ClientMsg>,
2627
) {
2728
match command {
29+
server_msg::Command::Ping(_) => {
30+
debug!(%request_id, "ping command reached handler after fast-path; ignoring");
31+
}
2832
server_msg::Command::ImagesPull(req) => {
2933
let body = match podman.pull_image(&req.reference).await {
3034
Ok((digest, output)) => {
@@ -223,6 +227,20 @@ pub async fn handle(
223227
)
224228
.await;
225229
}
230+
server_msg::Command::CooldLogs(req) => {
231+
let body = match coold_logs(req.tail).await {
232+
Ok(output) => response::Body::CooldLogs(CooldLogsResp { output }),
233+
Err(e) => error_body(e),
234+
};
235+
send_response(
236+
&tx,
237+
Response {
238+
request_id,
239+
body: Some(body),
240+
},
241+
)
242+
.await;
243+
}
226244
server_msg::Command::ContainersExec(req) => {
227245
let body = match podman.exec_container(&req.id, req.command).await {
228246
Ok((exit_code, output)) => {
@@ -1192,6 +1210,43 @@ async fn run_command(command: &mut Command) -> Result<String> {
11921210
Ok(combined)
11931211
}
11941212

1213+
async fn coold_logs(tail: u32) -> Result<String> {
1214+
let tail = tail.clamp(1, 1000).to_string();
1215+
let output = Command::new("journalctl")
1216+
.args([
1217+
"--unit",
1218+
"coold",
1219+
"--no-pager",
1220+
"--output",
1221+
"short-iso",
1222+
"--lines",
1223+
&tail,
1224+
])
1225+
.output()
1226+
.await
1227+
.context("read coold journal logs")?;
1228+
1229+
let combined = String::from_utf8_lossy(&output.stdout)
1230+
.lines()
1231+
.chain(String::from_utf8_lossy(&output.stderr).lines())
1232+
.filter(|line| !line.is_empty())
1233+
.collect::<Vec<_>>()
1234+
.join("\n");
1235+
1236+
if !output.status.success() {
1237+
return Err(anyhow!(
1238+
"{}",
1239+
if combined.is_empty() {
1240+
format!("journalctl exited with {}", output.status)
1241+
} else {
1242+
combined
1243+
}
1244+
));
1245+
}
1246+
1247+
Ok(combined)
1248+
}
1249+
11951250
async fn containers_list(podman: &PodmanClient) -> Result<ContainersListResp> {
11961251
let containers = podman.containers_list().await?;
11971252

flux/src/config.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ pub struct Config {
9999
default_value = "10"
100100
)]
101101
pub laravel_heartbeat_interval_secs: u64,
102+
103+
/// Seconds between Flux ping frames sent to each connected coold stream.
104+
#[arg(
105+
long,
106+
env = "COOLIFY_FLUX_COOLD_PING_INTERVAL_SECS",
107+
default_value = "10"
108+
)]
109+
pub coold_ping_interval_secs: u64,
110+
111+
/// Seconds without a coold pong before Flux marks that server unreachable.
112+
#[arg(
113+
long,
114+
env = "COOLIFY_FLUX_COOLD_PONG_TIMEOUT_SECS",
115+
default_value = "45"
116+
)]
117+
pub coold_pong_timeout_secs: u64,
102118
}
103119

104120
impl Config {
@@ -127,6 +143,14 @@ mod tests {
127143
);
128144
}
129145

146+
#[test]
147+
fn defaults_coold_heartbeat_timing() {
148+
let config = Config::parse_from(["flux", "--grpc-bind", "127.0.0.1:6443"]);
149+
150+
assert_eq!(config.coold_ping_interval_secs, 10);
151+
assert_eq!(config.coold_pong_timeout_secs, 45);
152+
}
153+
130154
#[test]
131155
fn rejects_flux_file_log_path_override() {
132156
let result = Config::try_parse_from([

flux/src/envelope.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ pub enum CommandPayload {
139139
},
140140
#[serde(rename = "firewall.reconcile")]
141141
FirewallReconcile,
142+
#[serde(rename = "coold.logs")]
143+
CooldLogs {
144+
#[serde(default = "default_coold_logs_tail")]
145+
tail: u32,
146+
},
142147
}
143148

144149
#[derive(Debug, Deserialize)]
@@ -153,6 +158,10 @@ fn default_logs_stdout() -> bool {
153158
true
154159
}
155160

161+
fn default_coold_logs_tail() -> u32 {
162+
200
163+
}
164+
156165
#[derive(Debug, Deserialize)]
157166
pub struct IngressAppConfig {
158167
pub name: String,
@@ -293,6 +302,9 @@ impl ResponseBody {
293302
Some(Body::FirewallReconcile(r)) => Some(ResponseBody::Ok {
294303
data: serde_json::json!({ "output": r.output }),
295304
}),
305+
Some(Body::CooldLogs(r)) => Some(ResponseBody::Ok {
306+
data: serde_json::json!({ "output": r.output }),
307+
}),
296308
Some(Body::Error(e)) => Some(ResponseBody::Error {
297309
code: e.code,
298310
message: e.message,
@@ -312,7 +324,7 @@ mod tests {
312324
response, ContainerSummary, ContainersCreateResp, ContainersDeleteResp, ContainersExecResp,
313325
ContainersHealthcheckRunResp, ContainersInspectResp, ContainersListResp,
314326
ContainersLogsResp, ContainersRestartResp, ContainersStartResp, ContainersStopResp,
315-
ImageSummary, ImagesDeleteResp, ImagesListResp, ImagesPullResp,
327+
CooldLogsResp, ImageSummary, ImagesDeleteResp, ImagesListResp, ImagesPullResp,
316328
};
317329

318330
fn data_for(body: response::Body) -> serde_json::Value {

flux/src/main.rs

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ async fn main() -> Result<()> {
3737
unix_bridge::run(config.clone(), streams.clone(), pending.clone()),
3838
pending_sweeper::run(config.clone(), pending.clone()),
3939
registry::heartbeat_loop(config.clone(), streams.clone()),
40+
coold_heartbeat::run(config.clone(), streams.clone()),
4041
)?;
4142

4243
Ok(())
@@ -262,6 +263,19 @@ mod grpc_server {
262263
let registry = self.registry.clone();
263264
let resource_status = self.resource_status.clone();
264265
let host_id_clone = host_id.clone();
266+
{
267+
let publisher = resource_status.clone();
268+
let host_id = host_id.clone();
269+
tokio::spawn(async move {
270+
publisher
271+
.publish(coold_status_update(
272+
&host_id,
273+
"installed",
274+
"coold stream connected.",
275+
))
276+
.await;
277+
});
278+
}
265279
if let Some(registry) = registry.clone() {
266280
let host_id = host_id.clone();
267281
let capabilities = hello.capabilities.clone();
@@ -297,6 +311,23 @@ mod grpc_server {
297311
publisher.publish(update).await;
298312
});
299313
}
314+
Ok(ClientMsg {
315+
payload: Some(client_msg::Payload::Pong(_)),
316+
}) => {
317+
if streams.touch(&host_id_clone) {
318+
let publisher = resource_status.clone();
319+
let host_id = host_id_clone.clone();
320+
tokio::spawn(async move {
321+
publisher
322+
.publish(coold_status_update(
323+
&host_id,
324+
"installed",
325+
"coold heartbeat restored.",
326+
))
327+
.await;
328+
});
329+
}
330+
}
300331
Ok(ClientMsg {
301332
payload: Some(client_msg::Payload::Hello(_)),
302333
}) => warn!(host_id = %host_id_clone, "duplicate Hello ignored"),
@@ -310,6 +341,13 @@ mod grpc_server {
310341
}
311342
info!(host_id = %host_id_clone, "coold stream disconnected");
312343
streams.remove(&host_id_clone);
344+
resource_status
345+
.publish(coold_status_update(
346+
&host_id_clone,
347+
"unreachable",
348+
"coold stream disconnected.",
349+
))
350+
.await;
313351
if let Some(registry) = registry {
314352
if let Err(e) = registry.disconnect(&host_id_clone, disconnect_reason).await {
315353
warn!(
@@ -351,9 +389,24 @@ mod grpc_server {
351389
pending.deliver(&request_id, data);
352390
}
353391

392+
pub(super) fn coold_status_update(
393+
host_id: &str,
394+
status: &str,
395+
status_message: &str,
396+
) -> coolify_proto::agent::v1::ResourceStatusUpdate {
397+
coolify_proto::agent::v1::ResourceStatusUpdate {
398+
resource_type: "server".into(),
399+
host_id: host_id.into(),
400+
container_id: String::new(),
401+
container_name: String::new(),
402+
status: status.into(),
403+
status_message: status_message.into(),
404+
}
405+
}
406+
354407
#[cfg(test)]
355408
mod tests {
356-
use super::{advertised_capability_not_granted, validate_bind};
409+
use super::{advertised_capability_not_granted, coold_status_update, validate_bind};
357410
use std::net::SocketAddr;
358411

359412
fn parse(s: &str) -> SocketAddr {
@@ -424,6 +477,68 @@ mod grpc_server {
424477
Some("ingress.apply")
425478
);
426479
}
480+
481+
#[test]
482+
fn builds_server_status_update_for_coold_liveness() {
483+
let update =
484+
coold_status_update("100.64.0.5", "unreachable", "coold heartbeat timed out.");
485+
486+
assert_eq!(update.resource_type, "server");
487+
assert_eq!(update.host_id, "100.64.0.5");
488+
assert_eq!(update.status, "unreachable");
489+
assert_eq!(update.status_message, "coold heartbeat timed out.");
490+
}
491+
}
492+
}
493+
494+
mod coold_heartbeat {
495+
use anyhow::Result;
496+
use coolify_proto::agent::v1::{server_msg, Ping, ServerMsg};
497+
use tracing::warn;
498+
use uuid::Uuid;
499+
500+
use crate::{
501+
config::Config, grpc_server::coold_status_update, resource_status::ResourceStatusPublisher,
502+
state::Streams,
503+
};
504+
505+
pub async fn run(config: Config, streams: Streams) -> Result<()> {
506+
let publisher = ResourceStatusPublisher::new(
507+
config.laravel_api_url.clone(),
508+
config.laravel_api_token.clone(),
509+
);
510+
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(
511+
config.coold_ping_interval_secs.max(1),
512+
));
513+
let timeout = std::time::Duration::from_secs(config.coold_pong_timeout_secs.max(1));
514+
515+
loop {
516+
ticker.tick().await;
517+
518+
for host_id in streams.mark_stale(timeout) {
519+
publisher
520+
.publish(coold_status_update(
521+
&host_id,
522+
"unreachable",
523+
"coold heartbeat timed out.",
524+
))
525+
.await;
526+
}
527+
528+
for (host_id, tx) in streams.ping_targets() {
529+
let request_id = Uuid::new_v4().to_string();
530+
if tx
531+
.send(ServerMsg {
532+
request_id,
533+
command: Some(server_msg::Command::Ping(Ping {})),
534+
})
535+
.await
536+
.is_err()
537+
{
538+
warn!(%host_id, "coold heartbeat ping send failed");
539+
}
540+
}
541+
}
427542
}
428543
}
429544

0 commit comments

Comments
 (0)