-
Notifications
You must be signed in to change notification settings - Fork 5
feat: docker-volume-backup-restore #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| use crate::domain::docker_volume::docker::{ | ||
| client, create_helper, remove_helper, resolve_helper_image, start_container, stop_container, | ||
| }; | ||
| use crate::services::backup::logger::JobLogger; | ||
| use crate::services::config::DatabaseConfig; | ||
| use anyhow::{Context, Result}; | ||
| use bollard::query_parameters::DownloadFromContainerOptions; | ||
| use futures_util::StreamExt; | ||
| use std::path::PathBuf; | ||
| use std::sync::Arc; | ||
| use std::time::Instant; | ||
| use tokio::fs::File; | ||
| use tokio::io::AsyncWriteExt; | ||
|
|
||
| pub async fn run(cfg: DatabaseConfig, backup_dir: PathBuf, logger: Arc<JobLogger>) -> Result<PathBuf> { | ||
| tokio::task::spawn_blocking(move || -> Result<PathBuf> { | ||
| futures::executor::block_on(async move { | ||
| logger.log("info", format!("Starting docker-volume backup for {}", cfg.name)); | ||
|
|
||
| let docker = client()?; | ||
| let image = resolve_helper_image(&docker).await?; | ||
| logger.log("debug", format!("Helper image: {image}")); | ||
|
|
||
| if let Some(name) = &cfg.container_name { | ||
| logger.log("info", format!("Stopping container {name} for consistent backup")); | ||
| stop_container(&docker, name).await?; | ||
| } | ||
|
|
||
| let result = async { | ||
| let helper = create_helper(&docker, &image, &cfg.volume_name, &cfg.generated_id, true, None).await?; | ||
|
|
||
| let file_path = backup_dir.join(format!("{}.tar", cfg.generated_id)); | ||
| let start = Instant::now(); | ||
|
|
||
| let dl_opts = DownloadFromContainerOptions { path: "/vol".to_string() }; | ||
| let mut stream = docker.download_from_container(&helper.id, Some(dl_opts)); | ||
|
|
||
| let mut out = File::create(&file_path) | ||
| .await | ||
| .with_context(|| format!("Failed to create backup file {}", file_path.display()))?; | ||
| let mut bytes_written: u64 = 0; | ||
| while let Some(chunk) = stream.next().await { | ||
| let chunk = chunk.context("Error streaming volume archive from Docker")?; | ||
| bytes_written += chunk.len() as u64; | ||
| out.write_all(&chunk).await?; | ||
| } | ||
| out.flush().await?; | ||
|
|
||
| let duration_ms = start.elapsed().as_millis() as f64; | ||
| logger.log_command("docker download_from_container", None, Some(0), Some(duration_ms)); | ||
| logger.log("info", format!("Volume backup wrote {bytes_written} bytes to {}", file_path.display())); | ||
|
|
||
| remove_helper(&docker, &helper.id).await; | ||
| anyhow::Ok(file_path) | ||
| } | ||
| .await; | ||
|
RambokDev marked this conversation as resolved.
|
||
|
|
||
| if let Some(name) = &cfg.container_name { | ||
| if let Err(e) = start_container(&docker, name).await { | ||
| logger.log("error", format!("Failed to restart container {name}: {e}")); | ||
| } | ||
| } | ||
|
|
||
| result | ||
| }) | ||
| }) | ||
| .await? | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| use anyhow::Result; | ||
| use async_trait::async_trait; | ||
| use std::path::{Path, PathBuf}; | ||
| use std::sync::Arc; | ||
|
|
||
| use super::{backup, ping, restore}; | ||
| use crate::domain::factory::Database; | ||
| use crate::services::backup::logger::JobLogger; | ||
| use crate::services::config::DatabaseConfig; | ||
| use crate::utils::locks::{DbOpLock, FileLock}; | ||
|
|
||
| pub struct DockerVolumeDatabase { | ||
| cfg: DatabaseConfig, | ||
| } | ||
|
|
||
| impl DockerVolumeDatabase { | ||
| pub fn new(cfg: DatabaseConfig) -> Self { | ||
| Self { cfg } | ||
| } | ||
| } | ||
|
|
||
| #[async_trait] | ||
| impl Database for DockerVolumeDatabase { | ||
| fn file_extension(&self) -> &'static str { | ||
| ".tar" | ||
| } | ||
|
|
||
| async fn ping(&self) -> Result<bool> { | ||
| ping::run(self.cfg.clone()).await | ||
| } | ||
|
|
||
| async fn backup(&self, dir: &Path, logger: Arc<JobLogger>) -> Result<PathBuf> { | ||
| FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?; | ||
| let res = backup::run(self.cfg.clone(), dir.to_path_buf(), logger).await; | ||
| FileLock::release(&self.cfg.generated_id).await?; | ||
| res | ||
| } | ||
|
|
||
| async fn restore(&self, file: &Path, logger: Arc<JobLogger>) -> Result<()> { | ||
| FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?; | ||
| let res = restore::run(self.cfg.clone(), file.to_path_buf(), logger).await; | ||
| FileLock::release(&self.cfg.generated_id).await?; | ||
| res | ||
| } | ||
|
RambokDev marked this conversation as resolved.
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| #![allow(dead_code)] | ||
|
|
||
| use anyhow::{Context, Result}; | ||
| use bollard::Docker; | ||
| use bollard::models::{ContainerCreateBody, HostConfig}; | ||
| use bollard::query_parameters::{ | ||
| CreateContainerOptions, InspectContainerOptions, ListContainersOptions, | ||
| RemoveContainerOptions, StartContainerOptions, StopContainerOptions, | ||
| }; | ||
| use std::collections::HashMap; | ||
| use tracing::{info, warn}; | ||
| use uuid::Uuid; | ||
|
|
||
| pub const EPHEMERAL_LABEL: &str = "io.portabase.ephemeral"; | ||
| const HELPER_MOUNT: &str = "/vol"; | ||
|
|
||
| pub fn client() -> Result<Docker> { | ||
| Docker::connect_with_unix_defaults().context("Failed to connect to Docker daemon socket") | ||
| } | ||
|
|
||
| pub fn parse_container_id(mountinfo: &str, cgroup: &str) -> Option<String> { | ||
| for src in [mountinfo, cgroup] { | ||
| for line in src.lines() { | ||
| for marker in ["/containers/", "/docker/"] { | ||
| if let Some(idx) = line.find(marker) { | ||
| let rest = &line[idx + marker.len()..]; | ||
| let id: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect(); | ||
| if id.len() >= 64 { | ||
| return Some(id[..64].to_string()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| None | ||
| } | ||
|
|
||
| pub async fn resolve_helper_image(docker: &Docker) -> Result<String> { | ||
| if let Ok(img) = std::env::var("PORTABASE_HELPER_IMAGE") { | ||
| if !img.trim().is_empty() { | ||
| return Ok(img); | ||
| } | ||
| } | ||
| let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").unwrap_or_default(); | ||
| let cgroup = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default(); | ||
| let id = parse_container_id(&mountinfo, &cgroup).context( | ||
| "Could not determine own container id; set PORTABASE_HELPER_IMAGE to a locally-present image", | ||
| )?; | ||
| let info = docker | ||
| .inspect_container(&id, None::<InspectContainerOptions>) | ||
| .await | ||
| .with_context(|| format!("Failed to inspect self container {id}"))?; | ||
| info.image | ||
| .context("Self container inspection returned no image reference") | ||
| } | ||
|
|
||
| pub struct Helper { | ||
| pub id: String, | ||
| } | ||
|
|
||
| pub async fn create_helper( | ||
| docker: &Docker, | ||
| image: &str, | ||
| volume_name: &str, | ||
| generated_id: &str, | ||
| read_only: bool, | ||
| cmd: Option<Vec<String>>, | ||
| ) -> Result<Helper> { | ||
| let bind = format!( | ||
| "{volume_name}:{HELPER_MOUNT}{}", | ||
| if read_only { ":ro" } else { "" } | ||
| ); | ||
| let mut labels = HashMap::new(); | ||
| labels.insert(EPHEMERAL_LABEL.to_string(), "true".to_string()); | ||
| labels.insert("com.docker.compose.project".to_string(), String::new()); | ||
| labels.insert("com.docker.compose.service".to_string(), String::new()); | ||
| labels.insert("com.docker.compose.oneoff".to_string(), String::new()); | ||
|
|
||
| let name = format!( | ||
| "portabase-vol-{generated_id}-{}", | ||
| &Uuid::new_v4().to_string()[..8] | ||
| ); | ||
|
|
||
| let body = ContainerCreateBody { | ||
| image: Some(image.to_string()), | ||
| cmd, | ||
| labels: Some(labels), | ||
| host_config: Some(HostConfig { | ||
| binds: Some(vec![bind]), | ||
| auto_remove: Some(false), | ||
| ..Default::default() | ||
| }), | ||
| ..Default::default() | ||
| }; | ||
|
|
||
| let opts = CreateContainerOptions { | ||
| name: Some(name), | ||
| ..Default::default() | ||
| }; | ||
|
|
||
| let res = docker | ||
| .create_container(Some(opts), body) | ||
| .await | ||
| .with_context(|| format!("Failed to create helper container for volume {volume_name}"))?; | ||
|
|
||
| Ok(Helper { id: res.id }) | ||
| } | ||
|
|
||
|
|
||
| pub async fn remove_helper(docker: &Docker, id: &str) { | ||
| let stop_opts = StopContainerOptions { | ||
| t: Some(2), | ||
| ..Default::default() | ||
| }; | ||
| let _ = docker.stop_container(id, Some(stop_opts)).await; | ||
|
|
||
| if let Ok(info) = docker | ||
| .inspect_container(id, None::<InspectContainerOptions>) | ||
| .await | ||
| { | ||
| let name = info.name.unwrap_or_default(); | ||
| let name = name.trim_start_matches('/'); | ||
| let code = info.state.and_then(|s| s.exit_code).unwrap_or_default(); | ||
| info!("Helper container {name} exited with code {code}"); | ||
| } | ||
|
|
||
| let opts = RemoveContainerOptions { | ||
| force: true, | ||
| ..Default::default() | ||
| }; | ||
| if let Err(e) = docker.remove_container(id, Some(opts)).await { | ||
| warn!("Failed to remove helper container {id}: {e}"); | ||
| } | ||
| } | ||
|
|
||
| pub async fn stop_container(docker: &Docker, name: &str) -> Result<()> { | ||
| docker | ||
| .stop_container(name, None::<StopContainerOptions>) | ||
| .await | ||
| .with_context(|| format!("Failed to stop container {name}")) | ||
| } | ||
|
|
||
| pub async fn start_container(docker: &Docker, name: &str) -> Result<()> { | ||
| docker | ||
| .start_container(name, None::<StartContainerOptions>) | ||
| .await | ||
| .with_context(|| format!("Failed to start container {name}")) | ||
| } | ||
|
|
||
| pub async fn sweep_ephemeral(docker: &Docker) -> Result<usize> { | ||
| let mut filters = HashMap::new(); | ||
| filters.insert("label".to_string(), vec![format!("{EPHEMERAL_LABEL}=true")]); | ||
|
|
||
| let opts = ListContainersOptions { | ||
| all: true, | ||
| filters: Some(filters), | ||
| ..Default::default() | ||
| }; | ||
|
|
||
| let list = docker.list_containers(Some(opts)).await?; | ||
| let mut removed = 0; | ||
| for c in list { | ||
| if let Some(id) = c.id { | ||
| remove_helper(docker, &id).await; | ||
| removed += 1; | ||
| } | ||
| } | ||
| Ok(removed) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| pub mod backup; | ||
| pub mod database; | ||
| pub mod docker; | ||
| pub mod ping; | ||
| pub mod restore; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| use crate::domain::docker_volume::docker::client; | ||
| use crate::services::config::DatabaseConfig; | ||
| use anyhow::Result; | ||
|
|
||
| pub async fn run(cfg: DatabaseConfig) -> Result<bool> { | ||
| let docker = client()?; | ||
| match docker.inspect_volume(&cfg.volume_name).await { | ||
| Ok(_) => Ok(true), | ||
| Err(bollard::errors::Error::DockerResponseServerError { status_code: 404, .. }) => Ok(false), | ||
| Err(e) => Err(e.into()), | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.