Files
windmill/backend/windmill-api/src/service_logs.rs
T
Ruben Fiszelandwindmill-internal-app[bot] 338d75cc52 feat: serve service log context from parquet and retire the raw log files (#10892)
* feat: serve service log context from the parquet store and retire the raw files

* fix: keep the log ingest cursor in the store and stream file rebuilds

* fix: roll back a partial index rebuild and move the cursor before the commit

* fix: make the index rebuild idempotent and repair a cursor the index never caught up with

* fix: seed the indexed cursor on upgrade and after a rebuild

* fix: fail the indexing pass on an unreadable cursor instead of reading it as absent

* docs: record what keeps both known_ts entries, not the path main removed

* chore: update ee-repo-ref to 466eb1830879052a5d042295256a78375bee916d

This commit updates the EE repository reference after PR #754 was merged in windmill-ee-private.

Previous ee-repo-ref: ddb3a536b8d85c134c01f87da7783baaa204a6d1

New ee-repo-ref: 466eb1830879052a5d042295256a78375bee916d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-29 18:47:53 +02:00

222 lines
8.0 KiB
Rust

/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::utils::{content_plain, require_devops_role};
use axum::{body::Body, extract::Query, response::Response, routing::get, Extension, Json, Router};
use serde::Serialize;
use windmill_common::{
error::{Error, JsonResult},
utils::Pagination,
};
use crate::db::{ApiAuthed, DB};
pub fn global_service() -> Router {
Router::new()
.route("/list_files", get(list_files))
.route("/get_log_file/{*path}", get(get_log_file))
}
use axum::extract::Path;
#[derive(Debug, serde::Deserialize)]
pub struct LogFileQuery {
before: Option<chrono::DateTime<chrono::Utc>>,
after: Option<chrono::DateTime<chrono::Utc>>,
with_error: Option<bool>,
}
#[derive(Debug, sqlx::FromRow, Serialize)]
pub struct LogFile {
pub hostname: String,
pub mode: String,
pub worker_group: Option<String>,
pub log_ts: chrono::NaiveDateTime,
pub file_path: String,
pub ok_lines: Option<i64>,
pub err_lines: Option<i64>,
pub json_fmt: bool,
}
async fn list_files(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(pagination): Query<Pagination>,
Query(lq): Query<LogFileQuery>,
) -> JsonResult<Vec<LogFile>> {
require_devops_role(&db, &authed).await?;
let (per_page, offset) = windmill_common::utils::paginate(pagination);
let mut sqlb = sql_builder::SqlBuilder::select_from("log_file")
.fields(&[
"hostname",
"mode::text",
"worker_group",
"log_ts",
"file_path",
"ok_lines",
"err_lines",
"json_fmt",
])
.order_by("log_ts", true)
.offset(offset)
.limit(per_page)
.clone();
if let Some(dt) = &lq.before {
sqlb.and_where_le(
"log_ts",
format!("to_timestamp({} / 1000.0)", dt.timestamp_millis()),
);
}
if let Some(dt) = &lq.after {
sqlb.and_where_ge(
"log_ts",
format!("to_timestamp({} / 1000.0)", dt.timestamp_millis()),
);
}
if let Some(true) = lq.with_error {
sqlb.and_where("err_lines > 0");
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let rows = sqlx::query_as::<_, LogFile>(&sql).fetch_all(&db).await?;
Ok(Json(rows))
}
/// Rebuild one source log file from the columnar store.
///
/// Not the original bytes: the store holds a line's fields rather than its text,
/// so the JSON is re-serialized here and key order and whitespace are this
/// writer's. Everything a reader can see survives — the drawer this feeds
/// renders a prettified view of each line either way, and a line that was never
/// JSON comes back exactly as it was written.
#[cfg(all(feature = "tantivy", feature = "private"))]
async fn get_log_file_from_store(
db: &DB,
store: &windmill_indexer::service_logs_store_ee::Store,
path: &str,
) -> windmill_common::error::Result<Response> {
let (hostname, file_name) = path
.split_once('/')
.ok_or_else(|| Error::BadRequest("Invalid path".to_string()))?;
// The store is partitioned by day and mode, neither of which the path
// carries. `log_file` names both, and its primary key starts with hostname.
let file = sqlx::query!(
// `mode!` because the column is NOT NULL and only the cast makes sqlx
// think otherwise; a silent default would look up a `mode=` partition
// that matches nothing and read as a missing file.
"SELECT mode::text AS \"mode!\", log_ts FROM log_file WHERE hostname = $1 AND file_path = $2 ORDER BY log_ts DESC LIMIT 1",
hostname,
file_name
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("File {path} not found")))?;
// A row registered by this version carries the minute in the file's own name,
// so the two agree and the second is redundant. One written before the
// uploader derived `log_ts` from the name carries a wall clock instead, and
// those outlive an upgrade by the retention period — which is also what makes
// the `ORDER BY` above worth having. The name is authoritative, so both go.
let mut known_ts = vec![chrono::DateTime::from_naive_utc_and_offset(
file.log_ts,
chrono::Utc,
)];
if let Some(named) = file_name.rsplit('.').next().and_then(|s| {
chrono::NaiveDateTime::parse_from_str(s, windmill_common::tracing_init::LOG_TIMESTAMP_FMT)
.ok()
}) {
known_ts.push(chrono::DateTime::from_naive_utc_and_offset(
named,
chrono::Utc,
));
}
let text = windmill_indexer::service_logs_store_ee::read_log_file(
store, &file.mode, hostname, file_name, &known_ts,
)
.await
.map_err(|e| Error::internal_err(format!("Error reading the service log store: {e}")))?
.ok_or_else(|| Error::NotFound(format!("File {path} not found")))?;
Ok(content_plain(Body::from(text)))
}
async fn get_log_file(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(path): Path<windmill_common::utils::StripPath>,
) -> windmill_common::error::Result<Response> {
use windmill_common::tracing_init::TMP_WINDMILL_LOGS_SERVICE;
require_devops_role(&db, &authed).await?;
let path = path.to_path();
if path.contains("..") {
return Err(Error::BadRequest("Invalid path".to_string()));
}
#[cfg(feature = "parquet")]
let s3_client = windmill_object_store::get_object_store().await;
#[cfg(feature = "parquet")]
if let Some(s3_client) = s3_client {
use windmill_object_store::object_store_reexports::ObjectStoreError;
// The raw file, for as long as it is there. It outlives its ingestion by
// one indexer pass at most, so this covers the most recent minutes of a
// host's logs byte for byte; everything older is rebuilt from the store.
let object_path = format!("{}{}", windmill_common::tracing_init::LOGS_SERVICE, path);
match s3_client
.get(&windmill_object_store::object_store_reexports::Path::from(
object_path,
))
.await
{
Ok(file) => match file.bytes().await {
Ok(bytes) => {
return Ok(content_plain(Body::from(bytes::Bytes::from(bytes))));
}
Err(e) => {
return Err(Error::internal_err(format!(
"Error pulling the bytes: {}",
e
)));
}
},
Err(ObjectStoreError::NotFound { .. }) => {}
Err(e) => {
return Err(Error::internal_err(format!(
"Error fetching the file: {}",
e
)));
}
}
#[cfg(all(feature = "tantivy", feature = "private"))]
return get_log_file_from_store(&db, &s3_client, &path).await;
#[cfg(not(all(feature = "tantivy", feature = "private")))]
return Err(Error::NotFound(format!("File {path} not found")));
}
let full_path = format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path);
// SECURITY (defense in depth): refuse to read through a symlink so a planted
// symlink in the logs directory cannot be used to exfiltrate arbitrary files.
// `symlink_metadata` returns the link's own metadata without following it.
match tokio::fs::symlink_metadata(&full_path).await {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(Error::BadRequest("Invalid path".to_string()));
}
Ok(_) => {}
Err(_) => return Err(Error::NotFound(format!("File {path} not found"))),
}
let file = tokio::fs::read(&full_path).await;
if let Ok(bytes) = file {
Ok(content_plain(Body::from(bytes::Bytes::from(bytes))))
} else {
Err(Error::NotFound(format!("File {path} not found")))
}
}