feat: add Docker operations backend and image

This commit is contained in:
Julien Denizot
2026-06-24 11:38:23 +02:00
parent d16353a72a
commit 66099e8cd8
4 changed files with 484 additions and 5 deletions
+399 -5
View File
@@ -1,11 +1,27 @@
use std::{collections::BTreeMap, future::Future, time::Duration};
use std::{
collections::{BTreeMap, HashMap},
future::Future,
time::Duration,
};
use bollard::{
container::LogOutput,
errors::Error as BollardError,
models::{ContainerCreateBody, HostConfig},
query_parameters::{
CreateContainerOptionsBuilder, CreateImageOptionsBuilder, LogsOptionsBuilder,
RemoveContainerOptionsBuilder, WaitContainerOptionsBuilder,
},
};
use chrono::{SecondsFormat, Utc};
use enuxia_aio_protocol::BackupResult;
use futures_util::StreamExt;
use tokio::time::sleep;
use crate::docker_api::DockerApiClient;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContainerSpec {
pub name: String,
@@ -34,6 +50,11 @@ pub trait DockerBackend: Send + Sync {
) -> impl Future<Output = Result<ContainerOutcome, DockerError>> + Send + 'a;
}
/*
* Backend simulé utilisé par les tests unitaires
* et tant que le backend réel nest pas activé
* dans main.rs.
*/
pub struct SimulatedDockerBackend {
execution_duration: Duration,
}
@@ -85,8 +106,8 @@ impl DockerBackend for SimulatedDockerBackend {
code: "simulation_serialization_failed".to_owned(),
message: format!(
"Sérialisation du résultat simulé impossible : \
{error}"
"Sérialisation du résultat simulé \
impossible : {error}"
),
})?;
@@ -99,6 +120,291 @@ impl DockerBackend for SimulatedDockerBackend {
}
}
/*
* Véritable backend utilisant lAPI Docker
* par lintermédiaire de Bollard.
*/
#[derive(Clone)]
pub struct DockerApiBackend {
client: DockerApiClient,
}
impl DockerApiBackend {
pub fn new(client: DockerApiClient) -> Self {
Self { client }
}
async fn run_container_inner(
&self,
spec: &ContainerSpec,
) -> Result<ContainerOutcome, DockerError> {
validate_spec(spec)?;
self.ensure_image(&spec.image).await?;
let docker = self.client.docker();
let labels = HashMap::from([
("fr.enuxia.aio.managed".to_owned(), "true".to_owned()),
("fr.enuxia.aio.kind".to_owned(), "operation".to_owned()),
]);
let environment = spec
.environment
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect();
let create_options = CreateContainerOptionsBuilder::default()
.name(&spec.name)
.build();
/*
* Politique volontairement très restrictive
* pour cette première validation :
*
* - aucun réseau ;
* - utilisateur non privilégié ;
* - système racine en lecture seule ;
* - aucune capacité Linux.
*/
let config = ContainerCreateBody {
image: Some(spec.image.clone()),
cmd: Some(spec.command.clone()),
env: Some(environment),
user: Some("65534:65534".to_owned()),
attach_stdout: Some(true),
attach_stderr: Some(true),
network_disabled: Some(true),
labels: Some(labels),
host_config: Some(HostConfig {
auto_remove: Some(false),
network_mode: Some("none".to_owned()),
readonly_rootfs: Some(true),
cap_drop: Some(vec!["ALL".to_owned()]),
..Default::default()
}),
..Default::default()
};
let created = docker
.create_container(Some(create_options), config)
.await
.map_err(|error| DockerError {
code: "container_create_failed".to_owned(),
message: format!(
"Création du conteneur {} impossible : \
{error}",
spec.name,
),
})?;
let container_id = created.id;
let execution_result = self.execute_container(&container_id).await;
let cleanup_result = docker
.remove_container(
&container_id,
Some(RemoveContainerOptionsBuilder::default().force(true).build()),
)
.await
.map_err(|error| DockerError {
code: "container_cleanup_failed".to_owned(),
message: format!(
"Suppression du conteneur {} impossible : \
{error}",
spec.name,
),
});
match (execution_result, cleanup_result) {
(Ok(outcome), Ok(())) => Ok(outcome),
(Err(execution_error), Ok(())) => Err(execution_error),
(Ok(_), Err(cleanup_error)) => Err(cleanup_error),
(Err(execution_error), Err(cleanup_error)) => Err(DockerError {
code: "container_execution_and_cleanup_failed".to_owned(),
message: format!(
"{} ; nettoyage également en échec : {}",
execution_error.message, cleanup_error.message,
),
}),
}
}
async fn ensure_image(&self, image: &str) -> Result<(), DockerError> {
let docker = self.client.docker();
match docker.inspect_image(image).await {
Ok(_) => {
return Ok(());
}
Err(BollardError::DockerResponseServerError {
status_code: 404, ..
}) => {}
Err(error) => {
return Err(DockerError {
code: "image_inspect_failed".to_owned(),
message: format!("Inspection de limage {image} impossible : {error}"),
});
}
}
let options = CreateImageOptionsBuilder::default()
.from_image(image)
.build();
let mut stream = docker.create_image(Some(options), None, None);
while let Some(item) = stream.next().await {
item.map_err(|error| DockerError {
code: "image_pull_failed".to_owned(),
message: format!("Téléchargement de limage {image} impossible : {error}"),
})?;
}
Ok(())
}
async fn execute_container(&self, container_id: &str) -> Result<ContainerOutcome, DockerError> {
let docker = self.client.docker();
docker
.start_container(container_id, None)
.await
.map_err(|error| DockerError {
code: "container_start_failed".to_owned(),
message: format!(
"Démarrage du conteneur impossible : \
{error}"
),
})?;
let wait_options = WaitContainerOptionsBuilder::default()
.condition("not-running")
.build();
let mut wait_stream = docker.wait_container(container_id, Some(wait_options));
let wait_response = wait_stream
.next()
.await
.ok_or_else(|| DockerError {
code: "container_wait_empty".to_owned(),
message: concat!("Docker na retourné aucun résultat ", "dattente.").to_owned(),
})?
.map_err(|error| DockerError {
code: "container_wait_failed".to_owned(),
message: format!(
"Attente du conteneur impossible : \
{error}"
),
})?;
let logs_options = LogsOptionsBuilder::default()
.stdout(true)
.stderr(true)
.build();
let mut logs_stream = docker.logs(container_id, Some(logs_options));
let mut stdout = Vec::new();
let mut stderr = Vec::new();
while let Some(item) = logs_stream.next().await {
match item.map_err(|error| DockerError {
code: "container_logs_failed".to_owned(),
message: format!(
"Lecture des journaux impossible : \
{error}"
),
})? {
LogOutput::StdOut { message } | LogOutput::Console { message } => {
stdout.extend_from_slice(message.as_ref());
}
LogOutput::StdErr { message } => {
stderr.extend_from_slice(message.as_ref());
}
LogOutput::StdIn { .. } => {}
}
}
Ok(ContainerOutcome {
exit_code: wait_response.status_code,
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
})
}
}
impl DockerBackend for DockerApiBackend {
fn run_container<'a>(
&'a self,
spec: &'a ContainerSpec,
) -> impl Future<Output = Result<ContainerOutcome, DockerError>> + Send + 'a {
async move { self.run_container_inner(spec).await }
}
}
fn validate_spec(spec: &ContainerSpec) -> Result<(), DockerError> {
if !spec.name.starts_with("enuxia-aio-") {
return Err(DockerError {
code: "invalid_container_name".to_owned(),
message: format!(
"Le conteneur {} nutilise pas le préfixe \
Enuxia autorisé.",
spec.name,
),
});
}
if spec.image.trim().is_empty() {
return Err(DockerError {
code: "missing_container_image".to_owned(),
message: "Limage du conteneur est absente.".to_owned(),
});
}
if spec.command.is_empty() {
return Err(DockerError {
code: "missing_container_command".to_owned(),
message: "La commande du conteneur est absente.".to_owned(),
});
}
Ok(())
}
fn required_environment<'a>(spec: &'a ContainerSpec, name: &str) -> Result<&'a str, DockerError> {
spec.environment
.get(name)
@@ -118,6 +424,8 @@ fn now_timestamp() -> String {
mod tests {
use super::*;
const EXPECTED_OUTPUT: &str = "ENUXIA_DOCKER_BACKEND=ok";
#[tokio::test]
async fn simulated_backend_returns_json_result() {
let backend = SimulatedDockerBackend::with_duration(Duration::ZERO);
@@ -151,7 +459,93 @@ mod tests {
assert_eq!(result.backup_id, "simulation-operation-001",);
assert_eq!(result.site, "aio-lab.local");
assert_eq!(result.site, "aio-lab.local",);
assert_eq!(result.size_bytes, 0);
}
#[tokio::test]
#[ignore = "nécessite un véritable daemon Docker"]
async fn real_backend_runs_isolated_container() {
let client = DockerApiClient::connect_from_env()
.await
.expect("Docker connection");
let backend = DockerApiBackend::new(client);
let spec = ContainerSpec {
name: format!("enuxia-aio-backend-test-{}", uuid::Uuid::now_v7(),),
image: "alpine:3.20".to_owned(),
command: vec![
"/bin/sh".to_owned(),
"-c".to_owned(),
format!("printf '{}\\n'", EXPECTED_OUTPUT,),
],
environment: BTreeMap::new(),
};
let outcome = backend
.run_container(&spec)
.await
.expect("real Docker backend");
assert_eq!(outcome.exit_code, 0);
assert_eq!(outcome.stdout.trim(), EXPECTED_OUTPUT,);
assert!(outcome.stderr.trim().is_empty());
}
}
#[cfg(test)]
mod operations_image_tests {
use super::*;
#[tokio::test]
#[ignore = "nécessite le daemon Docker et limage ops locale"]
async fn real_backend_runs_operations_image() {
let client = DockerApiClient::connect_from_env()
.await
.expect("connexion Docker");
let backend = DockerApiBackend::new(client);
let operation_id = format!("operation-{}", uuid::Uuid::now_v7(),);
let mut environment = BTreeMap::new();
environment.insert("ENUXIA_OPERATION_ID".to_owned(), operation_id.clone());
environment.insert("ENUXIA_SITE_NAME".to_owned(), "aio-lab.local".to_owned());
let spec = ContainerSpec {
name: format!("enuxia-aio-ops-test-{}", uuid::Uuid::now_v7(),),
image: "enuxia-frappe-aio-ops:dev".to_owned(),
command: vec!["backup".to_owned()],
environment,
};
let outcome = backend
.run_container(&spec)
.await
.expect("exécution de limage ops");
assert_eq!(outcome.exit_code, 0);
assert!(outcome.stderr.trim().is_empty());
let result: BackupResult =
serde_json::from_str(outcome.stdout.trim()).expect("résultat BackupResult valide");
assert_eq!(result.backup_id, format!("skeleton-{operation_id}"),);
assert_eq!(result.site, "aio-lab.local",);
assert_eq!(result.size_bytes, 0);
}
}
+3
View File
@@ -0,0 +1,3 @@
*
!Dockerfile
!entrypoint.sh
+9
View File
@@ -0,0 +1,9 @@
FROM alpine:3.20
COPY --chmod=0555 \
entrypoint.sh \
/usr/local/bin/enuxia-aio-ops
USER 65534:65534
ENTRYPOINT ["/usr/local/bin/enuxia-aio-ops"]
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
set -eu
fail() {
code="$1"
message="$2"
printf 'ENUXIA_OPS_ERROR=%s\n' "${code}" >&2
printf '%s\n' "${message}" >&2
exit 64
}
validate_identifier() {
name="$1"
value="$2"
case "${value}" in
"")
fail \
"missing_${name}" \
"La valeur ${name} est obligatoire."
;;
*[!A-Za-z0-9._-]*)
fail \
"invalid_${name}" \
"La valeur ${name} contient des caractères interdits."
;;
esac
}
operation="${1:-}"
case "${operation}" in
backup)
operation_id="${ENUXIA_OPERATION_ID:-}"
site_name="${ENUXIA_SITE_NAME:-}"
validate_identifier \
"operation_id" \
"${operation_id}"
validate_identifier \
"site_name" \
"${site_name}"
created_at="$(
date -u '+%Y-%m-%dT%H:%M:%SZ'
)"
printf '{'
printf '"backup_id":"skeleton-%s",' \
"${operation_id}"
printf '"site":"%s",' \
"${site_name}"
printf '"created_at":"%s",' \
"${created_at}"
printf '"size_bytes":0'
printf '}\n'
;;
"")
fail \
"missing_operation" \
"Aucune opération na été demandée."
;;
*)
fail \
"unsupported_operation" \
"Lopération ${operation} nest pas prise en charge."
;;
esac