dockyard 0.1.1

Back up and restore Docker resources
use anyhow::{Context, Result};
use std::path::Path;
use std::fs::{File, create_dir_all};
use flate2::read::GzDecoder;
use tar::Archive;
use bollard::Docker;
use bollard::models::{Mount, MountTypeEnum};
use bollard::volume::CreateVolumeOptions;
use crate::container::{run_dockyard_command, handle_container_output};
use crate::backup::ContainerBackup;
use bollard::container::{CreateContainerOptions, Config};
use crate::file::decode_b64;
use futures::future::Either;

pub fn restore_directory(archive: &str, output: &str) -> Result<()> {
    log::info!("Restoring {} to {}", archive, output);
    let output_path = Path::new(output);
    let tar_file = File::open(Path::new(archive))?;
    let tar = GzDecoder::new(tar_file);
    let mut archive = Archive::new(tar);
    create_dir_all(&output_path)?;
    archive.unpack(&output_path)?;
    Ok(())
}

pub async fn restore_directory_from_mount(docker: &Docker, archive: String, backup_mount: Mount, directory: String) -> Result<()> {
    log::info!("Restoring directory {} from {}", directory, archive);
    let log_prefix = format!("restore directory {}", directory);
    let mounted_backup = format!("{}/{}", &backup_mount.target.as_ref().unwrap(), archive);
    let mounts = Some(vec![
        backup_mount,
        Mount {
            target: Some("/output".to_string()),
            source: Some(directory.to_string()),
            typ: Some(MountTypeEnum::BIND),
            ..Default::default()
        },
    ]);
    let cmd = vec!["restore", "directory", &mounted_backup, "/output"];
    let (exit_code, logs) = run_dockyard_command(docker, mounts, cmd).await?;
    handle_container_output(exit_code, &log_prefix, &logs)
}

pub async fn restore_volume(docker: &Docker, archive: String, backup_mount: Mount, volume_mount: Mount) -> Result<()> {
    log::info!("Restoring volume {} from {}", volume_mount.source.as_ref().unwrap(), archive);
    docker.create_volume(CreateVolumeOptions {
        name: volume_mount.source.as_ref().unwrap().to_string(),
        driver: "local".to_string(),
        driver_opts: Default::default(),
        labels: Default::default(),
    }).await?;
    let log_prefix = format!("restore volume {}", volume_mount.source.as_ref().unwrap());
    let mounted_backup = format!("{}/{}", &backup_mount.target.as_ref().unwrap(), archive);
    let volume_dir = volume_mount.target.as_ref().unwrap().to_string();
    let cmd = vec!["restore", "directory", &mounted_backup, &volume_dir];
    let mounts = Some(vec![backup_mount, volume_mount]);
    let (exit_code, logs) = run_dockyard_command(docker, mounts, cmd).await?;
    handle_container_output(exit_code, &log_prefix, &logs)
}

pub async fn restore_container(docker: &Docker, backup_file: &str, container: &str, backup_mount: Mount) -> Result<()> {
    log::info!("Restoring container {} from {}", container, backup_file);
    let mounted_backup = format!("/backup/{}", backup_file);
    let (exit_code, logs) = run_dockyard_command(
        docker,
        Some(vec![backup_mount.clone()]),
        vec!["cat", "--encoded", "-f", &mounted_backup],
    ).await?;
    if logs.is_empty() {
        return Err(anyhow!("Found empty file"));
    }
    let log_prefix = format!("restore container {}", container);
    handle_container_output(exit_code, &log_prefix, &logs[0..logs.len() - 1])?;
    let container_backup = decode_b64(logs.last().unwrap().to_string().trim())?;
    let container_backup: ContainerBackup = serde_json::from_str(&container_backup)?;
    let mut mount_restore_processes = vec![];
    for mb in container_backup.mounts {
        let archive_path = mb.path.to_str().unwrap().to_string();
        if mb.mount.typ.unwrap() == "bind" {
            let directory = mb.mount.source.unwrap();
            let f = restore_directory_from_mount(docker, archive_path, backup_mount.clone(), directory.clone());
            mount_restore_processes.push((directory, Either::Left(f)));
        } else {
            let volume = mb.mount.name.unwrap();
            let volume_mount = Mount {
                target: Some("/volume".to_string()),
                source: Some(volume.clone()),
                typ: Some(MountTypeEnum::VOLUME),
                ..Default::default()
            };
            let f = restore_volume(docker, archive_path, backup_mount.clone(), volume_mount);
            mount_restore_processes.push((volume, Either::Right(f)));
        }
    }
    for (name, res) in mount_restore_processes {
        res.await.with_context(|| format!("Failed to restore mount {}", &name))?;
        log::info!("Successfully restored mount {}", &name)
    }
    let container_config = Config {
        hostname: container_backup.container_config.hostname,
        domainname: container_backup.container_config.domainname,
        user: container_backup.container_config.user,
        attach_stdin: container_backup.container_config.attach_stdin,
        attach_stdout: container_backup.container_config.attach_stdout,
        attach_stderr: container_backup.container_config.attach_stderr,
        exposed_ports: container_backup.container_config.exposed_ports,
        tty: container_backup.container_config.tty,
        open_stdin: container_backup.container_config.open_stdin,
        stdin_once: container_backup.container_config.stdin_once,
        env: container_backup.container_config.env,
        cmd: container_backup.container_config.cmd,
        healthcheck: container_backup.container_config.healthcheck,
        args_escaped: container_backup.container_config.args_escaped,
        image: container_backup.container_config.image,
        volumes: container_backup.container_config.volumes,
        working_dir: container_backup.container_config.working_dir,
        entrypoint: container_backup.container_config.entrypoint,
        network_disabled: container_backup.container_config.network_disabled,
        mac_address: container_backup.container_config.mac_address,
        on_build: container_backup.container_config.on_build,
        labels: container_backup.container_config.labels,
        stop_signal: container_backup.container_config.stop_signal,
        stop_timeout: container_backup.container_config.stop_timeout,
        shell: container_backup.container_config.shell,
        host_config: Some(container_backup.host_config),
        ..Default::default()
    };

    docker.create_container(Some(CreateContainerOptions {
        name: container
    }), container_config).await?;
    log::info!("Successfully restored container {}", container);
    Ok(())
}


#[cfg(test)]
mod test {
    use super::*;
    use simple_logger::SimpleLogger;
    use log::LevelFilter;
    use tempfile::TempDir;
    use std::fs::{create_dir, read_to_string, read_dir};
    use std::io::Write;
    use flate2::write::GzEncoder;
    use flate2::Compression;
    use std::path::PathBuf;
    use tokio::runtime::Runtime;
    use bollard::models::{MountPoint, ContainerConfig, HostConfig};
    use crate::backup::MountBackup;
    use bollard::container::{InspectContainerOptions, RemoveContainerOptions};
    use uuid::Uuid;
    use crate::container::{get_backup_directory_mount, run_docker_command};

    #[test]
    fn restore_directory_test() {
        let _ = SimpleLogger::new().with_level(LevelFilter::Info).init();
        let working_dir = TempDir::new().unwrap();
        let archive_path = create_archive(&working_dir);
        let output = Path::join(&working_dir.path(), "output");
        create_dir(&output).unwrap();
        restore_directory(&archive_path.to_str().unwrap(), &output.to_str().unwrap()).unwrap();
    }

    #[test]
    fn restore_volume_test() {
        let _ = SimpleLogger::new().with_level(LevelFilter::Info).init();
        let test_id = Uuid::new_v4().to_string();
        let working_dir = TempDir::new().unwrap();
        let archive_path = create_archive(&working_dir);
        let volume_contents = working_dir.path().join("volume-contents");

        let mut rt = Runtime::new().unwrap();
        let docker = Docker::connect_with_unix_defaults().unwrap();

        rt.block_on(async {
            let volume_name = format!("volume_{}", test_id);
            &docker.create_volume(CreateVolumeOptions {
                name: volume_name.clone(),
                driver: "local".to_string(),
                driver_opts: Default::default(),
                labels: Default::default(),
            });
            let volume_mount = Mount {
                target: Some("/volume".to_string()),
                source: Some(volume_name.clone()),
                typ: Some(MountTypeEnum::VOLUME),
                ..Default::default()
            };
            restore_volume(
                &docker,
                archive_path.strip_prefix(working_dir.path()).unwrap().to_str().unwrap().to_string(),
                Mount {
                    target: Some("/backup".to_string()),
                    source: Some(working_dir.path().to_str().unwrap().to_string()),
                    typ: Some(MountTypeEnum::BIND),
                    ..Default::default()
                }, volume_mount.clone()).await.unwrap();
            copy_from_volume(&docker, &volume_name, volume_contents.to_str().unwrap()).await.unwrap();
        });

        let mut count = 0;
        for maybe_entry in read_dir(volume_contents).unwrap() {
            let entry = maybe_entry.unwrap();
            let num = entry.file_name();
            count += 1;
            assert_eq!(read_to_string(entry.path()).unwrap(), format!("Restore test data {}", num.to_str().unwrap()));
        }
        assert_eq!(count, 100);
    }

    #[test]
    fn restore_container_test() {
        let _ = SimpleLogger::new().with_level(LevelFilter::Info).init();
        let working_dir = TempDir::new().unwrap();
        let volume_restore_path = Path::join(working_dir.path(), "volume");
        create_dir_all(&volume_restore_path).unwrap();
        let archive_path = create_archive(&working_dir);
        let container_name = format!("restore_test_{}", Uuid::new_v4());
        let backup_name = "backup.json";
        let source = Some(volume_restore_path.to_str().unwrap().to_string());
        let typ = Some("bind".to_string());
        let name = Some("volume".to_string());
        let destination = Some("/volume".to_string());
        let driver = Some("local".to_string());
        let mount_backup = MountBackup {
            path: PathBuf::from(archive_path.strip_prefix(&working_dir).unwrap()),
            mount: MountPoint { name: name.clone(), typ: typ.clone(), source: source.clone(), destination: destination.clone(), driver: driver.clone(), ..Default::default() },
        };
        let mount = Mount {
            target: destination.clone(),
            source: source.clone(),
            typ: Some(MountTypeEnum::BIND),
            ..Default::default()
        };
        let container_backup = ContainerBackup {
            name: container_name.clone(),
            container_config: ContainerConfig {
                cmd: Some(vec!["tail".to_string(), "-f".to_string(), "/dev/null".to_string()]),
                image: Some("nginx:latest".to_string()),
                entrypoint: None,
                ..Default::default()
            },
            host_config: HostConfig { mounts: Some(vec![mount]), ..Default::default() },
            mounts: vec![mount_backup],
        };
        let backup_path = working_dir.path().join(backup_name);
        File::create(&backup_path).unwrap().write_all(serde_json::to_string(&container_backup).unwrap().as_bytes()).unwrap();

        let mut rt = Runtime::new().unwrap();
        let docker = Docker::connect_with_unix_defaults().unwrap();
        let inspection = rt.block_on(async {
            restore_container(&docker, backup_name, container_name.as_str(), get_backup_directory_mount(working_dir.path().to_str().unwrap().to_string())).await.unwrap();
            let inspection = &docker.inspect_container(&container_name, None::<InspectContainerOptions>).await.unwrap();
            inspection.clone()
        });
        assert_eq!(inspection.name.as_ref().unwrap(), &format!("/{}", container_backup.name));
        let inspection_mounts = inspection.mounts.as_ref().unwrap();
        assert_eq!(inspection_mounts.len(), 1);
        let inspection_mount = inspection_mounts.first().unwrap();
        assert_eq!(inspection_mount.typ.as_ref().unwrap(), &typ.unwrap());
        assert_eq!(inspection_mount.source.as_ref().unwrap(), &format!("/host_mnt/private{}", &source.unwrap()));
        assert_eq!(inspection_mount.destination.as_ref().unwrap(), &destination.unwrap());
        rt.block_on(async {
            docker.remove_container(&container_name, None::<RemoveContainerOptions>).await.unwrap();
        });
    }

    fn create_archive(working_dir: &TempDir) -> PathBuf {
        let input = Path::join(working_dir.path(), "input");
        create_dir(input.as_path()).unwrap();

        for i in 0..100 {
            let mut f = File::create(Path::join(&input, i.to_string())).unwrap();
            f.write_all(format!("Restore test data {}", i).as_bytes()).unwrap();
        }

        let archive_path = Path::join(working_dir.path(), "archive.tgz");
        let archive = File::create(&archive_path).unwrap();
        let enc = GzEncoder::new(archive, Compression::default());
        let mut tar = tar::Builder::new(enc);
        tar.append_dir_all("", input.as_path()).unwrap();
        archive_path
    }

    async fn copy_from_volume(docker: &Docker, volume: &str, destination: &str) -> Result<()> {
        let mounts = vec![
            Mount {
                source: Some(volume.to_string()),
                target: Some("/source".to_string()),
                typ: Some(MountTypeEnum::VOLUME),
                ..Default::default()
            }, Mount {
                source: Some(destination.to_string()),
                target: Some("/destination".to_string()),
                typ: Some(MountTypeEnum::BIND),
                ..Default::default()
            }
        ];
        create_dir_all(Path::new(destination)).unwrap();
        let cmd = vec!["/bin/cp", "-r", "/source/.", "/destination"];
        let container_name = format!("copy_from_volume_{}", Uuid::new_v4());
        let (exit_code, logs) = run_docker_command(docker, &container_name, "alpine:latest", Some(mounts), cmd, None).await?;
        handle_container_output(exit_code, "copy from volume", &logs)
    }
}