use crate::error::{DockerError, Result};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
pub const ARCBOX_CONTEXT_NAME: &str = "arcbox";
mod types;
pub use types::{
ContextEndpoints, ContextMeta, ContextMetadata, ContextStatus, DockerConfig, DockerEndpoint,
};
pub struct DockerContextManager {
socket_path: PathBuf,
context_name: String,
docker_config_dir: PathBuf,
}
impl DockerContextManager {
pub fn new(socket_path: PathBuf) -> Result<Self> {
Self::new_with_context_name(socket_path, ARCBOX_CONTEXT_NAME)
}
pub fn new_with_context_name(
socket_path: PathBuf,
context_name: impl Into<String>,
) -> Result<Self> {
let docker_config_dir = dirs::home_dir()
.ok_or_else(|| DockerError::Context("cannot find home directory".to_string()))?
.join(".docker");
Ok(Self {
socket_path,
context_name: context_name.into(),
docker_config_dir,
})
}
#[must_use]
pub fn with_config_dir(socket_path: PathBuf, docker_config_dir: PathBuf) -> Self {
Self::with_context_name_and_config_dir(socket_path, ARCBOX_CONTEXT_NAME, docker_config_dir)
}
#[must_use]
pub fn with_context_name_and_config_dir(
socket_path: PathBuf,
context_name: impl Into<String>,
docker_config_dir: PathBuf,
) -> Self {
Self {
socket_path,
context_name: context_name.into(),
docker_config_dir,
}
}
#[must_use]
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
#[must_use]
pub fn docker_config_dir(&self) -> &Path {
&self.docker_config_dir
}
#[must_use]
pub fn context_name(&self) -> &str {
&self.context_name
}
#[must_use]
pub fn context_exists(&self) -> bool {
self.context_meta_path().exists()
}
pub fn is_default(&self) -> Result<bool> {
let config = self.read_docker_config()?;
Ok(config.current_context.as_deref() == Some(self.context_name()))
}
pub fn current_context(&self) -> Result<Option<String>> {
let config = self.read_docker_config()?;
Ok(config.current_context)
}
pub fn create_context(&self) -> Result<()> {
let meta_dir = self.context_dir();
fs::create_dir_all(&meta_dir).map_err(|e| {
DockerError::Context(format!("failed to create context directory: {e}"))
})?;
let meta = ContextMeta {
name: self.context_name.clone(),
metadata: ContextMetadata {
description: "ArcBox Container Runtime".to_string(),
},
endpoints: ContextEndpoints {
docker: DockerEndpoint {
host: format!("unix://{}", self.socket_path.display()),
skip_tls_verify: false,
},
},
};
let meta_path = self.context_meta_path();
let meta_json = serde_json::to_string_pretty(&meta).map_err(|e| {
DockerError::Context(format!("failed to serialize context metadata: {e}"))
})?;
fs::write(&meta_path, meta_json)
.map_err(|e| DockerError::Context(format!("failed to write context metadata: {e}")))?;
info!(context = %self.context_name, "Created Docker context");
debug!(path = %meta_path.display(), "Context metadata written");
Ok(())
}
pub fn remove_context(&self) -> Result<()> {
if self.is_default()? {
self.restore_default()?;
}
let context_dir = self.context_dir();
if context_dir.exists() {
fs::remove_dir_all(&context_dir).map_err(|e| {
DockerError::Context(format!("failed to remove context directory: {e}"))
})?;
info!(context = %self.context_name, "Removed Docker context");
} else {
debug!("Context directory does not exist, nothing to remove");
}
Ok(())
}
pub fn set_default(&self) -> Result<()> {
if !self.context_exists() {
return Err(DockerError::Context(
"ArcBox context does not exist, run create_context first".to_string(),
));
}
let mut config = self.read_docker_config()?;
if let Some(ref current) = config.current_context {
if current != self.context_name() {
self.save_previous_context(current)?;
}
}
config.current_context = Some(self.context_name.clone());
self.write_docker_config(&config)?;
info!(context = %self.context_name, "Set Docker context as default");
Ok(())
}
pub fn restore_default(&self) -> Result<()> {
let previous = self.read_previous_context()?;
let mut config = self.read_docker_config()?;
config.current_context.clone_from(&previous);
self.write_docker_config(&config)?;
let _ = fs::remove_file(self.previous_context_path());
if let Some(name) = previous {
info!("Restored default Docker context to '{name}'");
} else {
info!("Cleared default Docker context");
}
Ok(())
}
pub fn enable(&self) -> Result<()> {
self.create_context()?;
self.set_default()?;
Ok(())
}
pub fn disable(&self) -> Result<()> {
if self.is_default()? {
self.restore_default()?;
}
Ok(())
}
#[must_use]
pub fn status(&self) -> ContextStatus {
ContextStatus {
context_exists: self.context_exists(),
is_default: self.is_default().unwrap_or(false),
socket_path: self.socket_path.clone(),
socket_exists: self.socket_path.exists(),
}
}
fn context_hash(name: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(name.as_bytes());
hex::encode(hasher.finalize())
}
fn context_dir(&self) -> PathBuf {
let hash = Self::context_hash(&self.context_name);
self.docker_config_dir
.join("contexts")
.join("meta")
.join(hash)
}
fn context_meta_path(&self) -> PathBuf {
self.context_dir().join("meta.json")
}
fn config_path(&self) -> PathBuf {
self.docker_config_dir.join("config.json")
}
fn previous_context_path(&self) -> PathBuf {
self.docker_config_dir
.join(format!(".{}-previous-context", self.context_name))
}
fn read_docker_config(&self) -> Result<DockerConfig> {
let config_path = self.config_path();
if !config_path.exists() {
return Ok(DockerConfig::default());
}
let data = fs::read_to_string(&config_path)
.map_err(|e| DockerError::Context(format!("failed to read config.json: {e}")))?;
serde_json::from_str(&data)
.map_err(|e| DockerError::Context(format!("failed to parse config.json: {e}")))
}
fn write_docker_config(&self, config: &DockerConfig) -> Result<()> {
fs::create_dir_all(&self.docker_config_dir).map_err(|e| {
DockerError::Context(format!("failed to create .docker directory: {e}"))
})?;
let config_path = self.config_path();
let json = serde_json::to_string_pretty(config)
.map_err(|e| DockerError::Context(format!("failed to serialize config.json: {e}")))?;
fs::write(&config_path, json)
.map_err(|e| DockerError::Context(format!("failed to write config.json: {e}")))?;
debug!(path = %config_path.display(), "Docker config written");
Ok(())
}
fn save_previous_context(&self, name: &str) -> Result<()> {
let path = self.previous_context_path();
fs::write(&path, name)
.map_err(|e| DockerError::Context(format!("failed to save previous context: {e}")))?;
debug!(previous = %name, "Saved previous context");
Ok(())
}
fn read_previous_context(&self) -> Result<Option<String>> {
let path = self.previous_context_path();
if !path.exists() {
return Ok(None);
}
let name = fs::read_to_string(&path)
.map_err(|e| DockerError::Context(format!("failed to read previous context: {e}")))?
.trim()
.to_string();
if name.is_empty() {
Ok(None)
} else {
Ok(Some(name))
}
}
}
#[cfg(test)]
mod tests;