use std::{
ffi::OsStr,
fmt,
path::{Path, PathBuf},
};
use anyhow::Context as _;
use serde::{Deserialize, Deserializer, Serialize};
use tokio::process::Command;
use crate::{
cache_root,
console::hang_up,
protocol::{
BuildImageCall, BuildImageResp, Client, Failure, RemoveImageCall, stdio::StdioClient,
},
};
pub struct ImageClient {
client: Box<dyn Client>,
}
impl ImageClient {
pub async fn try_new() -> Result<Self, Failure> {
Self::try_from_cmd(&[cache_root().join("bin").join("virtx-uvm")]).await
}
pub async fn try_from_cmd(cmd: &[impl AsRef<OsStr>]) -> Result<Self, Failure> {
let (program, args) = cmd
.split_first()
.ok_or_else(|| Failure::broken("an image server needs a program to run"))?;
let mut server = Command::new(program);
server.args(args);
let client = StdioClient::new(server)
.context("starting the image server")
.map_err(Failure::Broken)?;
Self::try_from_client(client).await
}
pub async fn try_from_client(client: impl Client + 'static) -> Result<Self, Failure> {
let mut client: Box<dyn Client> = Box::new(client);
client.version().await?;
Ok(ImageClient { client })
}
pub async fn version(&mut self) -> Result<String, Failure> {
self.client.version().await.map(|answer| answer.version)
}
pub async fn build(
&mut self,
recipe: Recipe,
reference: Option<&str>,
) -> Result<BuildImageResp, Failure> {
let build = BuildImageCall {
recipe,
reference: reference.map(str::to_string),
};
self.client.build_image(build).await
}
pub async fn list(&mut self) -> Result<Vec<ImageEntry>, Failure> {
self.client.list_images().await.map(|answer| answer.images)
}
pub async fn remove(&mut self, image: impl Into<ImageSource>) -> Result<(), Failure> {
let remove = RemoveImageCall {
image: image.into(),
};
self.client.remove_image(remove).await.map(|_| ())
}
}
impl Drop for ImageClient {
fn drop(&mut self) {
hang_up(&mut self.client, ());
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ImageSource {
Recipe { recipe: Recipe },
Ref {
#[serde(rename = "ref")]
reference: String,
},
Digest { digest: String },
}
impl ImageSource {
pub fn reference(reference: impl Into<String>) -> Self {
ImageSource::Ref {
reference: reference.into(),
}
}
pub fn digest(digest: impl Into<String>) -> Self {
ImageSource::Digest {
digest: digest.into(),
}
}
}
impl From<Recipe> for ImageSource {
fn from(recipe: Recipe) -> Self {
ImageSource::Recipe { recipe }
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageEntries {
pub images: Vec<ImageEntry>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageEntry {
pub digest: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<String>,
}
const FORMAT_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recipe {
#[serde(rename = "v", deserialize_with = "known_version")]
version: u32,
#[serde(deserialize_with = "some_base")]
pub base: String,
pub steps: Vec<Step>,
}
impl Recipe {
pub fn new(base: impl Into<String>) -> Self {
Recipe {
version: FORMAT_VERSION,
base: base.into(),
steps: Vec::new(),
}
}
pub fn from_dockerfile(content: impl AsRef<str>) -> anyhow::Result<Self> {
let mut instructions: Vec<(usize, String)> = Vec::new();
let mut current: Option<(usize, String)> = None;
for (index, raw) in content.as_ref().lines().enumerate() {
let trimmed = raw.trim();
if trimmed.starts_with('#') || (trimmed.is_empty() && current.is_none()) {
continue;
}
let (body, continues) = match trimmed.strip_suffix('\\') {
Some(body) => (body.trim_end(), true),
None => (trimmed, false),
};
match current.as_mut() {
Some((_, text)) if !body.is_empty() => {
text.push(' ');
text.push_str(body);
}
Some(_) => {}
None => current = Some((index + 1, body.to_string())),
}
if !continues
&& let Some(instruction) = current.take()
&& !instruction.1.is_empty()
{
instructions.push(instruction);
}
}
if let Some(instruction) = current.take() {
instructions.push(instruction);
}
let mut base: Option<String> = None;
let mut steps: Vec<Step> = Vec::new();
for (line, text) in instructions {
let (instruction, rest) = match text.split_once(char::is_whitespace) {
Some((instruction, rest)) => (instruction.to_uppercase(), rest.trim()),
None => (text.to_uppercase(), ""),
};
match instruction.as_str() {
"FROM" => {
anyhow::ensure!(
base.is_none(),
"Dockerfile:{line}: a second FROM — this builds one stage only, and a \
multi-stage Dockerfile would silently build the last stage over the \
wrong base"
);
anyhow::ensure!(!rest.is_empty(), "Dockerfile:{line}: FROM names no image");
anyhow::ensure!(
!rest.contains(" AS ") && !rest.contains(" as "),
"Dockerfile:{line}: a named stage — this builds one stage only"
);
base = Some(rest.to_string());
}
_ if base.is_none() => anyhow::bail!(
"Dockerfile:{line}: {instruction} before any FROM — a build has to start \
from a base"
),
"RUN" => {
anyhow::ensure!(!rest.is_empty(), "Dockerfile:{line}: RUN names no command");
steps.push(Step::Run(rest.to_string()));
}
"COPY" => {
anyhow::ensure!(
!rest.starts_with("--"),
"Dockerfile:{line}: COPY with a flag ({rest}) — this translates \
`COPY <src> <dst>` and nothing else"
);
let parts: Vec<&str> = rest.split_whitespace().collect();
anyhow::ensure!(
parts.len() == 2,
"Dockerfile:{line}: COPY takes one source and one destination here, \
and this has {}",
parts.len()
);
steps.push(Step::copy(parts[0], parts[1]));
}
"ENV"
if rest
.split_whitespace()
.next()
.is_some_and(|w| w.contains('=')) =>
{
let mut pairs: Vec<String> = Vec::new();
let mut pair = String::new();
let mut quote: Option<char> = None;
for c in rest.chars() {
match quote {
Some(q) => {
quote = (c != q).then_some(q);
pair.push(c);
}
None if c == '"' || c == '\'' => {
quote = Some(c);
pair.push(c);
}
None if c.is_whitespace() => {
if !pair.is_empty() {
pairs.push(std::mem::take(&mut pair));
}
}
None => pair.push(c),
}
}
if !pair.is_empty() {
pairs.push(pair);
}
for pair in pairs {
let (key, value) = pair.split_once('=').with_context(|| {
format!(
"Dockerfile:{line}: ENV mixes `k=v` pairs with a bare word \
({pair}), which is two spellings in one instruction"
)
})?;
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
steps.push(Step::env(key, value));
}
}
"ENV" => {
let (key, value) = rest.split_once(char::is_whitespace).with_context(|| {
format!("Dockerfile:{line}: ENV names a variable and no value")
})?;
steps.push(Step::env(key, value.trim()));
}
"WORKDIR" => {
anyhow::ensure!(
!rest.is_empty(),
"Dockerfile:{line}: WORKDIR names no directory"
);
steps.push(Step::Workdir(rest.to_string()));
}
other => anyhow::bail!(
"Dockerfile:{line}: {other} is not one of FROM, RUN, COPY, ENV and \
WORKDIR, which is all a declaration has"
),
}
}
let base = base.context("this Dockerfile has no FROM, so there is no base to build on")?;
Ok(Recipe::new(base).steps(steps))
}
pub fn step(mut self, step: impl Into<Step>) -> Self {
self.steps.push(step.into());
self
}
pub fn steps(mut self, steps: impl IntoIterator<Item = impl Into<Step>>) -> Self {
self.steps.extend(steps.into_iter().map(Into::into));
self
}
}
fn known_version<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
let found = u32::deserialize(d)?;
if found != FORMAT_VERSION {
return Err(serde::de::Error::custom(format!(
"this image is written in format {found}, and this virtx reads {FORMAT_VERSION}"
)));
}
Ok(found)
}
fn some_base<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
let base = String::deserialize(d)?;
if base.is_empty() {
return Err(serde::de::Error::custom(
"this image names no base, and there is no build without one",
));
}
Ok(base)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Step {
Run(String),
Copy { src: PathBuf, dst: String },
Env { key: String, value: String },
Workdir(String),
}
impl Step {
pub fn run(cmd: impl Into<String>) -> Self {
Step::Run(cmd.into())
}
pub fn copy(src: impl AsRef<Path>, dst: impl Into<String>) -> Self {
Step::Copy {
src: src.as_ref().to_path_buf(),
dst: dst.into(),
}
}
pub fn env(key: impl Into<String>, value: impl Into<String>) -> Self {
Step::Env {
key: key.into(),
value: value.into(),
}
}
pub fn workdir(dir: impl Into<String>) -> Self {
Step::Workdir(dir.into())
}
}
impl From<&str> for Step {
fn from(command: &str) -> Self {
Step::run(command)
}
}
impl From<String> for Step {
fn from(command: String) -> Self {
Step::Run(command)
}
}
impl fmt::Display for Step {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Step::Run(command) => write!(f, "RUN {command}"),
Step::Copy { src, dst } => write!(f, "COPY {} {dst}", src.display()),
Step::Env { key, value } => write!(f, "ENV {key}={value}"),
Step::Workdir(dir) => write!(f, "WORKDIR {dir}"),
}
}
}