mod plan;
use std::collections::HashMap;
use std::path::Path;
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, host_file_secret_mode, is_podup_created_source,
scoped_name, Payload,
};
use super::Engine;
impl Engine {
pub(super) async fn build_native_secrets(
&self,
service: &Service,
file: &ComposeFile,
) -> Result<Vec<Secret>> {
let plans = collect_native_plans(&self.project, service, file, &self.base_dir)?;
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?;
}
let mode = match (&plan.payload, plan.mode) {
(Some(Payload::File(path)), None) => Some(host_file_secret_mode(path)),
_ => plan.mode,
};
secrets.push(Secret {
source: plan.source,
target: Some(plan.target),
uid: plan.uid,
gid: plan.gid,
mode,
});
}
Ok(secrets)
}
pub(super) async fn create_project_secrets(&self, file: &ComposeFile) -> Result<()> {
for (name, payload) in collect_payload_union(&self.project, file, &self.base_dir)? {
let bytes = match payload {
Payload::Inline(bytes) => bytes,
Payload::File(path) => crate::filesystem::read_capped(&path).map_err(|e| {
ComposeError::Unsupported(format!(
"secret/config source {} could not be read: {e}",
path.display()
))
})?,
};
self.create_secret(&name, &bytes).await?;
}
Ok(())
}
async fn create_secret(&self, name: &str, payload: &[u8]) -> Result<()> {
check_secret_size(name, payload.len())?;
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 {
return Err(ComposeError::Unsupported(format!(
"a secret named '{name}' already exists and is not labelled \
podup.project={} — refusing to overwrite a secret podup did \
not create",
self.project
)));
}
}
Err(e) if e.is_status(404) => {}
Err(e) => return Err(ComposeError::Podman(e)),
}
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_podup_created_source(
def.external,
def.content.as_deref(),
def.environment.as_deref(),
def.file.as_deref(),
) {
self.delete_secret(&scoped_name(&self.project, "secret", name))
.await;
}
}
for (name, def) in &file.configs {
if is_podup_created_source(
def.external,
def.content.as_deref(),
def.environment.as_deref(),
def.file.as_deref(),
) {
self.delete_secret(&scoped_name(&self.project, "config", name))
.await;
}
}
for name in self.list_project_secret_names().await {
self.delete_secret(&name).await;
}
Ok(())
}
async fn list_project_secret_names(&self) -> Vec<String> {
let path = format!("{API_PREFIX}/secrets/json");
match self.client.get_json::<Vec<serde_json::Value>>(&path).await {
Ok(list) => list
.iter()
.filter_map(|s| {
let spec = s.get("Spec")?;
let owned = spec
.get("Labels")
.and_then(|l| l.get("podup.project"))
.and_then(|v| v.as_str())
== Some(self.project.as_str());
if owned {
spec.get("Name")
.and_then(|n| n.as_str())
.map(str::to_string)
} else {
None
}
})
.collect(),
Err(e) => {
tracing::debug!("could not list project secrets for orphan cleanup: {e}");
Vec::new()
}
}
}
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 collect_payload_union(
project: &str,
file: &ComposeFile,
base_dir: &Path,
) -> Result<HashMap<String, Payload>> {
let mut payloads: HashMap<String, Payload> = HashMap::new();
for service in file.services.values() {
for plan in collect_native_plans(project, service, file, base_dir)? {
if let Some(payload) = plan.payload {
payloads.entry(plan.source).or_insert(payload);
}
}
}
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),
)
}
fn only_file_path(engine: &Engine, yaml: &str) -> PathBuf {
let file = crate::compose::parse_str_raw(yaml).unwrap();
let union = collect_payload_union("proj", &file, &engine.base_dir).unwrap();
assert_eq!(union.len(), 1);
match union.into_values().next().unwrap() {
Payload::File(p) => p,
Payload::Inline(_) => panic!("expected a file payload"),
}
}
#[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 engine = engine_with_base(&base.to_string_lossy());
assert_eq!(only_file_path(&engine, yaml), base.join("secret.txt"));
}
#[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 engine = engine_with_base("/srv/project");
assert_eq!(
only_file_path(&engine, yaml),
PathBuf::from("/etc/app/cfg.yaml")
);
}
#[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_payload_union("proj", &file, Path::new("/base")).unwrap();
assert_eq!(union.len(), 1);
assert!(matches!(
union.get("proj_secret_tok"),
Some(Payload::Inline(b)) if b == b"shared"
));
}
#[test]
fn payload_union_collects_every_source_podup_creates_but_not_external() {
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_payload_union("proj", &file, Path::new("/base")).unwrap();
let mut names: Vec<&String> = union.keys().collect();
names.sort();
assert_eq!(
names,
vec!["proj_config_cfg", "proj_secret_onfile", "proj_secret_tok"]
);
}
#[test]
fn external_secret_is_never_in_the_payload_union() {
let yaml = "services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n external: true\n";
let file = crate::compose::parse_str_raw(yaml).unwrap();
let union = collect_payload_union("proj", &file, Path::new("/base")).unwrap();
assert!(union.is_empty());
}
}