1pub mod fetch;
2pub mod rendered;
3pub mod tagged;
4
5use std::collections::HashMap;
6use std::future::Future;
7use std::sync::atomic::{
8 AtomicBool,
9 Ordering,
10};
11use std::sync::Arc;
12use std::time::Duration;
13
14use bytes::Bytes;
15use chrono::{
16 DateTime,
17 Utc,
18};
19use reqwest::header::{
20 HeaderMap,
21 CONTENT_RANGE,
22};
23use serde::de::DeserializeOwned;
24use serde::{
25 Deserialize,
26 Serialize,
27};
28use tokio::sync::mpsc;
29
30use crate::error::{
31 PluginError,
32 PluginResult,
33};
34use crate::http::{
35 check_response,
36 map_send_error,
37};
38use crate::types::PipelineStatus;
39
40pub const LOG_CHUNK_STALL_TIMEOUT: Duration = Duration::from_secs(60);
41
42pub async fn next_log_chunk(response: &mut reqwest::Response) -> PluginResult<Option<Bytes>> {
43 match tokio::time::timeout(LOG_CHUNK_STALL_TIMEOUT, response.chunk()).await {
44 Err(_) => Err(PluginError::NetworkError(
45 "Log download stalled for 60 seconds".into(),
46 )),
47 Ok(chunk) => chunk.map_err(|e| map_send_error(e, response.url())),
48 }
49}
50
51const LOG_CHUNK_BYTES: usize = 64 * 1024;
52
53pub const CONTENT_HASH_SEED: u64 = 0xcbf2_9ce4_8422_2325;
54
55pub fn content_hash(bytes: &[u8]) -> u64 {
56 extend_content_hash(CONTENT_HASH_SEED, bytes)
57}
58
59pub fn extend_content_hash(hash: u64, bytes: &[u8]) -> u64 {
60 bytes.iter().fold(hash, |hash, &byte| {
61 (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
62 })
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum LogNodeKind {
68 Stage,
69 Job,
70 Step,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum LogAvailability {
76 Ready,
77 Partial,
78 Pending,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct LogNode {
83 pub id: String,
84 pub parent_id: Option<String>,
85 pub kind: LogNodeKind,
86 pub name: String,
87 pub status: PipelineStatus,
88 pub started_at: Option<DateTime<Utc>>,
89 pub finished_at: Option<DateTime<Utc>>,
90 pub log: Option<LogAvailability>,
91 pub web_url: Option<String>,
92}
93
94impl LogNode {
95 pub fn step(
96 job_id: &str, number: impl std::fmt::Display, name: String, status: PipelineStatus,
97 started_at: Option<DateTime<Utc>>, finished_at: Option<DateTime<Utc>>,
98 ) -> Self {
99 Self {
100 id: format!("{job_id}:{number}"),
101 parent_id: Some(job_id.to_string()),
102 kind: LogNodeKind::Step,
103 name,
104 status,
105 started_at,
106 finished_at,
107 log: None,
108 web_url: None,
109 }
110 }
111}
112
113pub struct RunRef<'a> {
114 pub pipeline_id: &'a str,
115 pub run_number: i64,
116 pub run_id: &'a str,
117 pub metadata: &'a HashMap<String, serde_json::Value>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(transparent)]
122pub struct LogCursor(serde_json::Value);
123
124impl LogCursor {
125 pub fn new<T: Serialize>(position: &T) -> PluginResult<Self> {
126 serde_json::to_value(position)
127 .map(Self)
128 .map_err(|error| PluginError::SerializationError(error.to_string()))
129 }
130
131 pub fn parse<T: DeserializeOwned>(&self) -> Option<T> {
132 serde_json::from_value(self.0.clone()).ok()
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum LogReadMode {
139 Append,
140 Replace,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct LogRead {
145 pub mode: LogReadMode,
146 pub cursor: Option<LogCursor>,
147}
148
149impl LogRead {
150 pub fn replace(cursor: Option<LogCursor>) -> Self {
151 Self {
152 mode: LogReadMode::Replace,
153 cursor,
154 }
155 }
156
157 pub fn append(cursor: LogCursor) -> Self {
158 Self {
159 mode: LogReadMode::Append,
160 cursor: Some(cursor),
161 }
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum LineMarker {
167 Plain,
168 GroupStart,
169 GroupEnd,
170 SectionStart { open: bool },
171 OpenSection,
172 Error,
173 Warning,
174 Notice,
175 Debug,
176 Command,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct ParsedLine<'a> {
181 pub timestamp: Option<DateTime<Utc>>,
182 pub marker: LineMarker,
183 pub body: &'a [u8],
184}
185
186impl<'a> ParsedLine<'a> {
187 pub fn plain(line: &'a [u8]) -> Self {
188 Self {
189 timestamp: None,
190 marker: LineMarker::Plain,
191 body: line,
192 }
193 }
194}
195
196pub trait LogDialect: Send + Sync {
197 fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a>;
198
199 fn is_step_start(&self, _line: &ParsedLine<'_>) -> bool {
200 false
201 }
202
203 fn infers_errors(&self) -> bool {
204 true
205 }
206}
207
208pub struct PlainDialect;
209
210impl LogDialect for PlainDialect {
211 fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a> {
212 ParsedLine::plain(line)
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub struct ContentRange {
218 pub span: Option<(u64, u64)>,
219 pub total: Option<u64>,
220}
221
222impl ContentRange {
223 pub fn parse(headers: &HeaderMap) -> Option<Self> {
224 let value = headers
225 .get(CONTENT_RANGE)?
226 .to_str()
227 .ok()?
228 .strip_prefix("bytes ")?;
229 let (span, total) = value.split_once('/')?;
230 let span = match span.trim() {
231 "*" => None,
232 span => {
233 let (start, end) = span.split_once('-')?;
234 Some((start.parse().ok()?, end.parse().ok()?))
235 }
236 };
237 let total = match total.trim() {
238 "*" => None,
239 total => Some(total.parse().ok()?),
240 };
241 Some(Self { span, total })
242 }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum RangedRead {
247 Appended(u64),
248 Replaced(u64),
249 Refetch,
250}
251
252pub struct LogSink {
253 tx: mpsc::Sender<Bytes>,
254 bytes: u64,
255 restarted: Arc<AtomicBool>,
256}
257
258impl LogSink {
259 pub fn new(tx: mpsc::Sender<Bytes>) -> Self {
260 Self {
261 tx,
262 bytes: 0,
263 restarted: Arc::new(AtomicBool::new(false)),
264 }
265 }
266
267 pub fn restart_signal(&self) -> Arc<AtomicBool> {
268 Arc::clone(&self.restarted)
269 }
270
271 pub fn restart(&mut self) -> PluginResult<()> {
272 if self.bytes > 0 {
273 return Err(PluginError::Internal(
274 "A log read can only restart before it writes".into(),
275 ));
276 }
277 self.restarted.store(true, Ordering::SeqCst);
278 Ok(())
279 }
280
281 pub fn restarted(&self) -> bool {
282 self.restarted.load(Ordering::SeqCst)
283 }
284
285 pub fn bytes_written(&self) -> u64 {
286 self.bytes
287 }
288
289 pub async fn write_chunks(&mut self, mut bytes: Bytes) -> PluginResult<()> {
290 while !bytes.is_empty() {
291 self.write(bytes.split_to(bytes.len().min(LOG_CHUNK_BYTES)))
292 .await?;
293 }
294 Ok(())
295 }
296
297 pub async fn write(&mut self, chunk: Bytes) -> PluginResult<()> {
298 if chunk.is_empty() {
299 return Ok(());
300 }
301 self.bytes += chunk.len() as u64;
302 self.tx
303 .send(chunk)
304 .await
305 .map_err(|_| PluginError::Internal("Log reader stopped".into()))
306 }
307
308 pub async fn pipe(&mut self, response: reqwest::Response) -> PluginResult<()> {
309 self.pipe_after(response, 0).await
310 }
311
312 pub async fn pipe_after(
313 &mut self, mut response: reqwest::Response, mut skip: u64,
314 ) -> PluginResult<()> {
315 while let Some(chunk) = next_log_chunk(&mut response).await? {
316 let cut = usize::try_from(skip).map_or(chunk.len(), |s| s.min(chunk.len()));
317 skip -= cut as u64;
318 self.write(chunk.slice(cut..)).await?;
319 }
320 Ok(())
321 }
322
323 pub async fn pipe_range(
324 &mut self, response: reqwest::Response, offset: u64,
325 ) -> PluginResult<RangedRead> {
326 let range = ContentRange::parse(response.headers());
327 match response.status().as_u16() {
328 206 if range
329 .and_then(|r| r.span)
330 .is_some_and(|(start, _)| start == offset) =>
331 {
332 self.pipe(response).await?;
333 Ok(RangedRead::Appended(offset + self.bytes))
334 }
335 416 if range.is_some_and(|r| r.span.is_none() && r.total == Some(offset)) => {
336 Ok(RangedRead::Appended(offset))
337 }
338 200 => {
339 self.restart()?;
340 self.pipe(response).await?;
341 Ok(RangedRead::Replaced(self.bytes))
342 }
343 206 | 416 => Ok(RangedRead::Refetch),
344 _ => {
345 check_response(response).await?;
346 Ok(RangedRead::Refetch)
347 }
348 }
349 }
350
351 pub async fn pipe_resumed<F, Fut>(
352 &mut self, offset: Option<u64>, mut fetch: F,
353 ) -> PluginResult<(LogReadMode, u64)>
354 where
355 F: FnMut(Option<u64>) -> Fut,
356 Fut: Future<Output = PluginResult<reqwest::Response>>,
357 {
358 if let Some(offset) = offset {
359 match self.pipe_range(fetch(Some(offset)).await?, offset).await? {
360 RangedRead::Appended(next) => return Ok((LogReadMode::Append, next)),
361 RangedRead::Replaced(next) => return Ok((LogReadMode::Replace, next)),
362 RangedRead::Refetch => self.restart()?,
363 }
364 }
365 self.pipe(check_response(fetch(None).await?).await?).await?;
366 Ok((LogReadMode::Replace, self.bytes))
367 }
368}
369
370#[cfg(test)]
371mod tests;