feat: add Docker operations backend and image
This commit is contained in:
@@ -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 n’est 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 l’API Docker
|
||||
* par l’intermé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 l’image {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 l’image {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 n’a retourné aucun résultat ", "d’attente.").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 {} n’utilise pas le préfixe \
|
||||
Enuxia autorisé.",
|
||||
spec.name,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if spec.image.trim().is_empty() {
|
||||
return Err(DockerError {
|
||||
code: "missing_container_image".to_owned(),
|
||||
|
||||
message: "L’image 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 l’image 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 l’image 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!Dockerfile
|
||||
!entrypoint.sh
|
||||
@@ -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"]
|
||||
Executable
+73
@@ -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 n’a été demandée."
|
||||
;;
|
||||
|
||||
*)
|
||||
fail \
|
||||
"unsupported_operation" \
|
||||
"L’opération ${operation} n’est pas prise en charge."
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user