docker-compose-updater/src/compose/parser.rs
Andras Schmelczer b414c05400
Some checks failed
Build and Publish Docker Image / test (push) Failing after 7s
Build and Publish Docker Image / build-and-push (push) Has been skipped
Build and Publish Docker Image / security-scan (push) Has been skipped
Clean up
2026-06-06 19:41:55 +01:00

249 lines
7.3 KiB
Rust

use crate::registry::ImageRef;
use anyhow::Result;
use regex::Regex;
use std::fs;
use std::sync::LazyLock;
use tracing::{info, warn};
static IMAGE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"^\s*image:\s*(?:["']([^"']+)["']|([^\s#]+))\s*(#.*)?$"#).unwrap()
});
#[derive(Debug, Clone)]
pub struct ServiceImage {
pub service_name: String,
pub image_ref: ImageRef,
pub original_line: String,
pub line_number: usize,
}
pub struct ComposeFile {
pub content: String,
pub services: Vec<ServiceImage>,
}
pub struct ComposeParser;
impl ComposeParser {
pub fn new() -> Self {
Self
}
pub fn parse_file(&self, file_path: &str) -> Result<ComposeFile> {
let content = fs::read_to_string(file_path)?;
let services = self.extract_services(&content)?;
Ok(ComposeFile { content, services })
}
fn extract_services(&self, content: &str) -> Result<Vec<ServiceImage>> {
let mut services = Vec::new();
let lines: Vec<&str> = content.lines().collect();
let mut in_services = false;
let mut current_service: Option<String> = None;
let mut service_indent: Option<usize> = None;
let mut service_child_indent: Option<usize> = None;
for (line_number, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = line.len() - trimmed.len();
if indent == 0 && trimmed.starts_with("services:") {
in_services = true;
current_service = None;
service_indent = None;
service_child_indent = None;
continue;
}
if in_services && indent == 0 {
in_services = false;
current_service = None;
service_indent = None;
service_child_indent = None;
continue;
}
if !in_services {
continue;
}
if (service_indent.is_none() || indent == service_indent.unwrap())
&& trimmed.ends_with(':')
&& !trimmed.contains(' ')
&& trimmed
.trim_end_matches(':')
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
{
let name = trimmed.trim_end_matches(':');
if !name.is_empty() {
service_indent = Some(indent);
service_child_indent = None;
current_service = Some(name.to_string());
}
continue;
}
if let Some(ref service_name) = current_service {
if let Some(si) = service_indent {
if indent > si {
// The first key under a service fixes the direct-child
// indent; only keys at that exact level are the service's
// own properties. This avoids picking up an `image:` nested
// under `build:` or other deeper mappings.
let child_indent = *service_child_indent.get_or_insert(indent);
if indent == child_indent {
if let Some(image_ref) = self.extract_image_from_line(line)? {
info!(
"Found service '{}' with image '{}'",
service_name, image_ref
);
services.push(ServiceImage {
service_name: service_name.clone(),
image_ref,
original_line: line.to_string(),
line_number,
});
}
}
}
}
}
}
Ok(services)
}
fn extract_image_from_line(&self, line: &str) -> Result<Option<ImageRef>> {
let trimmed = line.trim_start();
if !trimmed.starts_with("image:") {
return Ok(None);
}
let Some(captures) = IMAGE_REGEX.captures(line) else {
return Ok(None);
};
let image_str = captures
.get(1)
.or_else(|| captures.get(2))
.unwrap()
.as_str();
if image_str.contains('@') {
info!("Skipping digest-pinned image: {}", image_str);
return Ok(None);
}
if image_str.trim().is_empty() {
return Ok(None);
}
match ImageRef::parse(image_str) {
Ok(image_ref) => Ok(Some(image_ref)),
Err(e) => {
warn!("Failed to parse image '{}': {}", image_str, e);
Ok(None)
}
}
}
}
impl Default for ComposeParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_parse_compose_file() {
let compose_content = r#"
version: '3.8'
services:
web:
image: nginx:1.21.0 # Web server
ports:
- "80:80"
db:
image: postgres:13.7
environment:
POSTGRES_DB: myapp
redis:
image: redis:6.2-alpine
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let parser = ComposeParser::new();
let result = parser
.parse_file(temp_file.path().to_str().unwrap())
.unwrap();
assert_eq!(result.services.len(), 3);
assert_eq!(result.services[0].service_name, "web");
assert_eq!(result.services[0].image_ref.name, "nginx");
assert_eq!(result.services[0].image_ref.tag, "1.21.0");
}
#[test]
fn test_service_names_with_dots() {
let compose_content = r#"
services:
my.service:
image: nginx:1.21.0
another-service:
image: redis:7.0
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let parser = ComposeParser::new();
let result = parser
.parse_file(temp_file.path().to_str().unwrap())
.unwrap();
assert_eq!(result.services.len(), 2);
assert_eq!(result.services[0].service_name, "my.service");
assert_eq!(result.services[1].service_name, "another-service");
}
#[test]
fn test_ignores_image_nested_under_build() {
let compose_content = r#"
services:
web:
build:
context: .
image: should-not-match:9.9.9
image: nginx:1.21.0
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let parser = ComposeParser::new();
let result = parser
.parse_file(temp_file.path().to_str().unwrap())
.unwrap();
assert_eq!(result.services.len(), 1);
assert_eq!(result.services[0].service_name, "web");
assert_eq!(result.services[0].image_ref.name, "nginx");
assert_eq!(result.services[0].image_ref.tag, "1.21.0");
}
}