mod plan;
use crate::compose::types::{ComposeFile, Service};
use crate::error::{ComposeError, Result};
use crate::libpod::types::container::Secret;
use crate::libpod::{urlencoded, API_PREFIX};
use plan::{
check_secret_size, collect_native_plans, is_inline_source, ref_name_target, scoped_name,
};
use super::Engine;
impl Engine {
pub(super) fn build_secret_binds(
&self,
service: &Service,
file: &ComposeFile,
) -> Result<Vec<String>> {
let mut binds = Vec::new();
for secret_ref in &service.secrets {
let (name, target_override) = ref_name_target(secret_ref.source(), secret_ref.target());
if let Some(def) = file.secrets.get(&name) {
if let Some(host_path) = &def.file {
let target = target_override.unwrap_or_else(|| format!("/run/secrets/{name}"));
let resolved = super::container::resolve_bind_source(host_path, &self.base_dir);
binds.push(make_bind(&name, &resolved, &target)?);
}
}
}
Ok(binds)
}
pub(super) fn build_config_binds(
&self,
service: &Service,
file: &ComposeFile,
) -> Result<Vec<String>> {
let mut binds = Vec::new();
for config_ref in &service.configs {
let (name, target_override) = ref_name_target(config_ref.source(), config_ref.target());
if let Some(def) = file.configs.get(&name) {
if let Some(host_path) = &def.file {
let target = target_override.unwrap_or_else(|| format!("/{name}"));
let resolved = super::container::resolve_bind_source(host_path, &self.base_dir);
binds.push(make_bind(&name, &resolved, &target)?);
}
}
}
Ok(binds)
}
pub(super) async fn build_native_secrets(
&self,
service: &Service,
file: &ComposeFile,
) -> Result<Vec<Secret>> {
let plans = collect_native_plans(&self.project, service, file)?;
let mut secrets = Vec::with_capacity(plans.len());
for plan in plans {
match &plan.payload {
Some(bytes) => self.create_secret(&plan.source, bytes).await?,
None => {
self.ensure_external_exists("secret", "secrets", &plan.source)
.await?
}
}
secrets.push(Secret {
source: plan.source,
target: Some(plan.target),
uid: plan.uid,
gid: plan.gid,
mode: plan.mode,
});
}
Ok(secrets)
}
async fn create_secret(&self, name: &str, payload: &[u8]) -> Result<()> {
check_secret_size(name, payload.len())?;
let delete_path = format!("{API_PREFIX}/secrets/{}", urlencoded(name));
self.client
.delete_ok(&delete_path)
.await
.map_err(ComposeError::Podman)?;
let labels = serde_json::json!({ "podup.project": self.project }).to_string();
let path = format!(
"{API_PREFIX}/secrets/create?name={}&labels={}",
urlencoded(name),
urlencoded(&labels),
);
self.client
.post_bytes_json::<serde_json::Value>(
&path,
bytes::Bytes::copy_from_slice(payload),
"application/octet-stream",
)
.await
.map(|_| ())
.map_err(ComposeError::Podman)
}
pub(super) async fn remove_internal_secrets(&self, file: &ComposeFile) -> Result<()> {
for (name, def) in &file.secrets {
if is_inline_source(
def.external,
def.content.as_deref(),
def.environment.as_deref(),
) {
self.delete_secret(&scoped_name(&self.project, "secret", name))
.await;
}
}
for (name, def) in &file.configs {
if is_inline_source(
def.external,
def.content.as_deref(),
def.environment.as_deref(),
) {
self.delete_secret(&scoped_name(&self.project, "config", name))
.await;
}
}
Ok(())
}
async fn delete_secret(&self, name: &str) {
let inspect = format!("{API_PREFIX}/secrets/{}/json", urlencoded(name));
match self.client.get_json::<serde_json::Value>(&inspect).await {
Ok(info) => {
let owned = info
.get("Spec")
.and_then(|spec| spec.get("Labels"))
.and_then(|labels| labels.get("podup.project"))
.and_then(|v| v.as_str())
== Some(self.project.as_str());
if !owned {
tracing::warn!(
"secret {name} is not labelled podup.project={} — \
leaving it untouched (not created by podup)",
self.project
);
return;
}
}
Err(e) if e.is_status(404) => return,
Err(e) => {
tracing::warn!("could not inspect secret {name} before removal: {e}");
return;
}
}
let path = format!("{API_PREFIX}/secrets/{}", urlencoded(name));
match self.client.delete_ok(&path).await {
Ok(()) => tracing::info!("removed secret {name}"),
Err(e) => tracing::warn!("could not remove secret {name}: {e}"),
}
}
}
fn make_bind(name: &str, resolved: &str, target: &str) -> Result<String> {
if resolved.contains(':') {
return Err(ComposeError::Unsupported(format!(
"secret/config '{name}': host path must not contain a colon: {resolved}"
)));
}
if target.contains(':') {
return Err(ComposeError::Unsupported(format!(
"secret/config '{name}': mount target must not contain a colon: {target}"
)));
}
Ok(format!("{resolved}:{target}:ro"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::libpod::Client;
use std::path::PathBuf;
fn engine_with_base(base: &str) -> Engine {
Engine::with_base_dir(
Client::new("unused"),
"proj".to_string(),
PathBuf::from(base),
)
}
#[test]
fn secret_file_relative_path_is_anchored_to_base_dir() {
let base = PathBuf::from("/srv/project");
let yaml = "services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n file: secret.txt\n";
let file = crate::compose::parse_str_raw(yaml).unwrap();
let engine = engine_with_base(&base.to_string_lossy());
let binds = engine
.build_secret_binds(&file.services["web"], &file)
.unwrap();
let expected = format!("{}:/run/secrets/tok:ro", base.join("secret.txt").display());
assert_eq!(binds, vec![expected]);
}
#[cfg(unix)]
#[test]
fn config_file_absolute_path_is_passed_through() {
let yaml = "services:\n web:\n image: nginx\n configs: [cfg]\nconfigs:\n cfg:\n file: /etc/app/cfg.yaml\n";
let file = crate::compose::parse_str_raw(yaml).unwrap();
let engine = engine_with_base("/srv/project");
let binds = engine
.build_config_binds(&file.services["web"], &file)
.unwrap();
assert_eq!(binds, vec!["/etc/app/cfg.yaml:/cfg:ro"]);
}
#[test]
fn make_bind_rejects_colon_in_path_or_target() {
assert!(make_bind("s", "/host/a:b", "/run/secrets/s").is_err());
assert!(make_bind("s", "/host/a", "/run/secrets/s:rw").is_err());
assert_eq!(
make_bind("s", "/host/a", "/run/secrets/s").unwrap(),
"/host/a:/run/secrets/s:ro"
);
}
#[test]
fn inline_secret_produces_no_bind() {
let yaml = "services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n content: data\n";
let file = crate::compose::parse_str_raw(yaml).unwrap();
let engine = engine_with_base("/srv/project");
let binds = engine
.build_secret_binds(&file.services["web"], &file)
.unwrap();
assert!(binds.is_empty());
}
}