use std::collections::BTreeMap;
use serde_json::json;
use super::Ann;
use super::*;
use crate::app::manifest::{self, Problem};
use crate::spec::SecretDef;
use crate::stack::StackDef;
fn export_yaml(def: &StackDef) -> Result<String> {
let mut file = def.file.clone();
for (key, s) in file.secrets.iter_mut() {
if !s.is_client_side() {
continue;
}
let store = match def.secrets.get(key) {
Some(b) => b.name.clone(),
None => crate::stack::secrets::owned_name(&def.name, key)?,
};
*s = SecretDef {
external: true,
name: Some(store),
..SecretDef::default()
};
}
serde_yaml_ng::to_string(&file).map_err(|e| Error::invalid(e.to_string()))
}
fn managed_by(d: &Daemon, org: &crate::org::OrgId, stack: &str) -> Option<&'static str> {
if stack == crate::ingress::cloudflare::TUNNEL_STACK {
return Some("ingress");
}
let projects = d.apps.project_list(org).ok()?;
projects
.iter()
.flat_map(|p| {
p.environments
.iter()
.map(move |e| format!("{}-{e}", p.name))
})
.any(|s| stack == s || stack.strip_prefix(&format!("{s}-pr-")).is_some())
.then_some("apps")
}
fn org_id(org: &Option<String>) -> Result<crate::org::OrgId> {
match org {
Some(o) => crate::org::OrgId::new(o.clone()),
None => Ok(crate::org::OrgId::default_org()),
}
}
pub(super) fn register(r: &mut Registry, d: &Arc<Daemon>, ann: &Ann) -> Result<()> {
stack_export_tool(r, d, ann)?;
stack_validate_tool(r, d, ann)
}
fn stack_export_tool(r: &mut Registry, d: &Arc<Daemon>, ann: &Ann) -> Result<()> {
tool!(
r,
d,
"stack_export",
"Export a stack as YAML",
"The compose file a stack runs from, as YAML text ready for stack_deploy (the web UI's stack editor shows it): resolved (no ${VAR}), with secrets that came from a file or environment variable named as `external` store secrets, so it deploys again without their values. Also says whether the stack belongs to a project's apps (`managed_by: apps`), in which case change it through the apps, not this file.",
obj(json!({"name": {"type": "string"}}), &["name"]),
ann.ro,
|d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct A {
name: String,
#[serde(default)]
org: Option<String>,
}
let a: A = args(a)?;
let def = d.ctl.definition(&qname(&a.org, &a.name)?)?;
Ok(json!({
"name": def.name,
"yaml": export_yaml(&def)?,
"services": def.file.services.keys().collect::<Vec<_>>(),
"managed_by": managed_by(d, &def.org, &def.name),
"deployed_at": def.deployed_at,
"deployed_by": def.deployed_by,
}))
}
);
Ok(())
}
fn stack_validate_tool(r: &mut Registry, d: &Arc<Daemon>, ann: &Ann) -> Result<()> {
tool!(
r,
d,
"stack_validate",
"Check a compose file",
"A dry run of stack_deploy for an editor: parses the compose YAML, checks it the way a deploy would (services, secrets, ports, ingress) and says what would change, without deploying or storing anything. Answers {valid, errors: [{line, column, message}], changes (per service), exists, managed_by, diff (a unified diff from the deployed file, stack_export's text, to this one)}; a bad file is an answer, not a failed call.",
obj(
json!({
"name": {"type": "string", "description": "The stack's name; an unused one is a new stack."},
"compose": {"type": "string", "description": "The compose file, as YAML text."},
"vars": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Variables for ${VAR}."}
}),
&["name", "compose"]
),
ann.ro,
|d: &Daemon, a: Value, c: &Caller| -> Result<Value> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct A {
name: String,
compose: String,
#[serde(default)]
vars: BTreeMap<String, String>,
#[serde(default)]
org: Option<String>,
}
let a: A = args(a)?;
let org = org_id(&a.org)?;
let current = d
.ctl
.definition(&qname(&a.org, &a.name)?)
.ok()
.map(|def| export_yaml(&def))
.transpose()?;
let managed = managed_by(d, &org, &a.name);
let dry = stack_deploy(
d,
json!({
"name": a.name, "org": a.org, "compose": a.compose,
"vars": a.vars, "dry_run": true,
}),
c,
);
let mut out = json!({
"exists": current.is_some(),
"managed_by": managed,
"diff": manifest::unified_diff(current.as_deref().unwrap_or(""), &a.compose),
});
match dry {
Ok(v) => {
out["valid"] = json!(true);
out["errors"] = json!([]);
out["changes"] = v["changes"].clone();
}
Err(e @ (Error::Forbidden(_) | Error::NotFound(_))) => return Err(e),
Err(e) => {
let message = match &e {
Error::Parse { message, .. } => message.clone(),
Error::Invalid(m) => m.clone(),
e => e.to_string(),
};
let p: Problem = manifest::locate_any(&a.compose, &message);
out["valid"] = json!(false);
out["errors"] = json!([p]);
}
}
Ok(out)
}
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
const COMPOSE: &str = r#"
secrets:
db_password: {environment: DB_PASSWORD}
api_key: {external: true}
services:
api:
image: dev-base
command: [sleep, infinity]
environment: {PORT: "8080"}
ports: ["127.0.0.1:18080:8080"]
secrets: [db_password, api_key]
volumes: ["data:/data"]
deploy: {replicas: 2, update_config: {order: start-first}}
volumes:
data: {}
"#;
fn load(text: &str, name: &str) -> crate::compose::Project {
crate::compose::load_docs(
&[(PathBuf::from("compose.yaml"), text.to_string())],
Path::new("/srv/x"),
Some(name),
&|_| None,
)
.unwrap()
}
fn def(text: &str) -> StackDef {
StackDef {
name: "shop".into(),
org: crate::org::OrgId::default_org(),
file: load(text, "shop").file,
base_dir: "/srv/x".into(),
secrets: BTreeMap::new(),
force: BTreeMap::new(),
images: BTreeMap::new(),
deployed_at: 0,
deployed_by: String::new(),
previous: None,
}
}
#[test]
fn an_exported_stack_deploys_again_unchanged_without_secret_values() {
let d = def(COMPOSE);
let yaml = export_yaml(&d).unwrap();
assert!(!yaml.contains("DB_PASSWORD"), "{yaml}");
assert!(yaml.contains("shop_db_password"), "{yaml}");
let again = load(&yaml, "shop");
assert_eq!(again.file.services, d.file.services);
assert_eq!(again.file.volumes, d.file.volumes);
assert!(again.file.secrets["db_password"].external);
assert_eq!(again.file.secrets["api_key"], d.file.secrets["api_key"]);
let e2 = StackDef {
file: again.file,
..d
};
assert_eq!(export_yaml(&e2).unwrap(), yaml);
}
}