mod plan;
use std::collections::HashMap;
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 {
if plan.payload.is_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)
}
pub(super) async fn create_inline_secrets(&self, file: &ComposeFile) -> Result<()> {
for (name, bytes) in collect_inline_union(&self.project, file)? {
self.create_secret(&name, &bytes).await?;
}
Ok(())
}
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"))
}
fn collect_inline_union(project: &str, file: &ComposeFile) -> Result<HashMap<String, Vec<u8>>> {
let mut payloads: HashMap<String, Vec<u8>> = HashMap::new();
for service in file.services.values() {
for plan in collect_native_plans(project, service, file)? {
if let Some(bytes) = plan.payload {
payloads.entry(plan.source).or_insert(bytes);
}
}
}
Ok(payloads)
}
#[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_union_dedups_shared_secret_across_services() {
let yaml = "services:\n a:\n image: nginx\n secrets: [tok]\n b:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n content: shared\n";
let file = crate::compose::parse_str_raw(yaml).unwrap();
let union = collect_inline_union("proj", &file).unwrap();
assert_eq!(union.len(), 1);
assert_eq!(
union.get("proj_secret_tok").map(Vec::as_slice),
Some(b"shared".as_slice())
);
}
#[test]
fn inline_union_collects_secrets_and_configs_skips_external_and_file() {
let yaml = "services:\n web:\n image: nginx\n secrets: [tok, ext, onfile]\n configs: [cfg]\nsecrets:\n tok:\n content: s\n ext:\n external: true\n onfile:\n file: ./f.txt\nconfigs:\n cfg:\n content: c\n";
let file = crate::compose::parse_str_raw(yaml).unwrap();
let union = collect_inline_union("proj", &file).unwrap();
let mut names: Vec<&String> = union.keys().collect();
names.sort();
assert_eq!(names, vec!["proj_config_cfg", "proj_secret_tok"]);
}
#[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());
}
}