pipedash_plugin_api/
logs.rs1use std::collections::HashMap;
2use std::time::Duration;
3
4use bytes::Bytes;
5use chrono::{
6 DateTime,
7 Utc,
8};
9use serde::{
10 Deserialize,
11 Serialize,
12};
13use tokio::sync::mpsc;
14
15use crate::error::{
16 PluginError,
17 PluginResult,
18};
19use crate::http::map_send_error;
20use crate::types::PipelineStatus;
21
22pub const LOG_CHUNK_STALL_TIMEOUT: Duration = Duration::from_secs(60);
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum LogNodeKind {
27 Stage,
28 Job,
29 Step,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum LogAvailability {
35 Ready,
36 Partial,
37 Pending,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct LogNode {
42 pub id: String,
43 pub parent_id: Option<String>,
44 pub kind: LogNodeKind,
45 pub name: String,
46 pub status: PipelineStatus,
47 pub started_at: Option<DateTime<Utc>>,
48 pub finished_at: Option<DateTime<Utc>>,
49 pub log: Option<LogAvailability>,
50 pub web_url: Option<String>,
51}
52
53pub struct RunRef<'a> {
54 pub pipeline_id: &'a str,
55 pub run_number: i64,
56 pub run_id: &'a str,
57 pub metadata: &'a HashMap<String, serde_json::Value>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum LineMarker {
62 Plain,
63 GroupStart,
64 GroupEnd,
65 SectionStart { open: bool },
66 OpenSection,
67 Error,
68 Warning,
69 Notice,
70 Debug,
71 Command,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct ParsedLine<'a> {
76 pub timestamp: Option<DateTime<Utc>>,
77 pub marker: LineMarker,
78 pub body: &'a [u8],
79}
80
81pub trait LogDialect: Send + Sync {
82 fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a>;
83
84 fn is_step_start(&self, _line: &ParsedLine<'_>) -> bool {
85 false
86 }
87}
88
89pub struct PlainDialect;
90
91impl LogDialect for PlainDialect {
92 fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a> {
93 ParsedLine {
94 timestamp: None,
95 marker: LineMarker::Plain,
96 body: line,
97 }
98 }
99}
100
101pub struct LogSink {
102 tx: mpsc::Sender<Bytes>,
103}
104
105impl LogSink {
106 pub fn new(tx: mpsc::Sender<Bytes>) -> Self {
107 Self { tx }
108 }
109
110 pub async fn write(&mut self, chunk: Bytes) -> PluginResult<()> {
111 if chunk.is_empty() {
112 return Ok(());
113 }
114 self.tx
115 .send(chunk)
116 .await
117 .map_err(|_| PluginError::Internal("Log reader stopped".into()))
118 }
119
120 pub async fn pipe(&mut self, mut response: reqwest::Response) -> PluginResult<()> {
121 let url = response.url().clone();
122 loop {
123 match tokio::time::timeout(LOG_CHUNK_STALL_TIMEOUT, response.chunk()).await {
124 Err(_) => {
125 return Err(PluginError::NetworkError(
126 "Log download stalled for 60 seconds".into(),
127 ))
128 }
129 Ok(Err(e)) => return Err(map_send_error(e, &url)),
130 Ok(Ok(None)) => return Ok(()),
131 Ok(Ok(Some(chunk))) => self.write(chunk).await?,
132 }
133 }
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use std::io::{
140 Read,
141 Write,
142 };
143 use std::net::TcpListener;
144
145 use super::*;
146
147 #[tokio::test]
148 async fn pipe_delivers_chunked_bytes_exactly() {
149 let _ = rustls::crypto::ring::default_provider().install_default();
150 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
151 let addr = listener.local_addr().unwrap();
152 std::thread::spawn(move || {
153 if let Ok((mut stream, _)) = listener.accept() {
154 let mut buf = [0u8; 4096];
155 let _ = stream.read(&mut buf);
156 let _ = stream.write_all(
157 b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\
158 5\r\nhello\r\n7\r\n world\n\r\n3\r\nend\r\n0\r\n\r\n",
159 );
160 }
161 });
162 let response = reqwest::Client::new()
163 .get(format!("http://{addr}/log"))
164 .send()
165 .await
166 .unwrap();
167 let (tx, mut rx) = mpsc::channel(16);
168 let mut sink = LogSink::new(tx);
169 sink.pipe(response).await.unwrap();
170 drop(sink);
171 let mut got = Vec::new();
172 while let Some(chunk) = rx.recv().await {
173 got.extend_from_slice(&chunk);
174 }
175 assert_eq!(got, b"hello world\nend");
176 }
177
178 #[tokio::test]
179 async fn write_after_reader_dropped_is_internal() {
180 let (tx, rx) = mpsc::channel(1);
181 drop(rx);
182 let mut sink = LogSink::new(tx);
183 let err = sink.write(Bytes::from_static(b"x")).await.unwrap_err();
184 assert!(matches!(err, PluginError::Internal(_)));
185 }
186}