use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use ray_proto::policy::SuggestedFirewall;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeploySpec {
#[serde(default)]
pub networks: BTreeMap<String, SuggestedFirewall>,
}
pub fn load(path: &Path) -> Result<DeploySpec> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
anyhow::ensure!(
matches!(ext.as_str(), "yaml" | "yml"),
"ray apply specs must be YAML (.yaml/.yml): {}",
path.display()
);
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading spec {}", path.display()))?;
let cfg = config::Config::builder()
.add_source(config::File::from_str(&text, config::FileFormat::Yaml))
.build()
.with_context(|| format!("parsing spec {}", path.display()))?;
deserialize_spec(cfg)
}
fn deserialize_spec(cfg: config::Config) -> Result<DeploySpec> {
let mut value: config::Value = cfg.try_deserialize().context("reading config tree")?;
normalize_nil(&mut value);
value
.try_deserialize::<DeploySpec>()
.context("expected a top-level `networks:` map")
}
fn normalize_nil(v: &mut config::Value) {
use config::ValueKind;
match &mut v.kind {
ValueKind::Nil => {
v.kind = ValueKind::Table(config::Map::new());
}
ValueKind::Table(t) => {
for (_k, child) in t.iter_mut() {
normalize_nil(child);
}
}
ValueKind::Array(a) => {
for child in a.iter_mut() {
normalize_nil(child);
}
}
_ => {}
}
}
pub fn to_yaml(spec: &DeploySpec) -> Result<String> {
serde_yml::to_string(spec).context("serializing spec to YAML")
}
pub const EXAMPLE_SPEC: &str = r#"# Rayfish deploy spec. See `ray apply --help`.
# Under `networks:`, each network name maps directly to its firewall subjects.
# Save as e.g. deploy.yaml and run: ray apply deploy.yaml (YAML only).
#
# Subject/peer keys are HOSTNAMES. They are the names `ray apply
# --invite-missing` binds into invites — a node joining with such an invite is
# assigned that exact hostname (it cannot pick another), so the firewall always
# resolves the peer it names. The `*` subject targets every node, and a `*` peer
# means any peer. Suggestions are advisory: each node queues them for
# `ray firewall accept`, or auto-installs them if it joined with
# `--auto-accept-firewall`.
networks:
gaming:
# alice has an allow-list ⇒ only listed peers pass, rest denied.
alice:
allows:
bob: "tcp:22"
denies:
eve: "icmp"
# bob's allow-list uses comma-separated proto:ports tokens.
bob:
allows:
alice: "tcp:9000,tcp:8123"
# An empty subject is fully open (no rules materialized).
carol: {}
minecraft:
# Every node opens 6969 to any peer — one wildcard rule for the whole net.
"*":
allows:
"*": "tcp:6969"
"#;
pub fn expected_hosts(spec: &DeploySpec) -> Vec<String> {
let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for firewall in spec.networks.values() {
for (subject, rules) in firewall {
if subject != "*" {
set.insert(subject.clone());
}
for peer in rules.allows.keys().chain(rules.denies.keys()) {
if peer != "*" {
set.insert(peer.clone());
}
}
}
}
set.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ray_proto::policy::HostSuggestions;
fn parse(text: &str) -> Result<DeploySpec> {
let cfg = config::Config::builder()
.add_source(config::File::from_str(text, config::FileFormat::Yaml))
.build()
.context("building config")?;
deserialize_spec(cfg)
}
#[test]
fn parse_yaml() {
let yaml = r#"
networks:
gaming:
alice:
allows:
bob: "tcp:22"
"#;
let spec = parse(yaml).unwrap();
assert_eq!(spec.networks.len(), 1);
let g = spec.networks.get("gaming").unwrap();
let alice = g.get("alice").unwrap();
assert_eq!(alice.allows.get("bob").map(|s| s.as_str()), Some("tcp:22"));
}
#[test]
fn parse_yaml_empty_networks() {
let yaml = r#"
networks:
neta:
netb:
"#;
let spec = parse(yaml).unwrap();
assert_eq!(spec.networks.len(), 2);
assert!(spec.networks.get("neta").unwrap().is_empty());
}
#[test]
fn parse_yaml_null_subject_is_open() {
let yaml = r#"
networks:
net1:
beta:
gamma:
"#;
let spec = parse(yaml).unwrap();
let g = spec.networks.get("net1").unwrap();
assert_eq!(g.len(), 2);
assert!(g.get("beta").unwrap().allows.is_empty());
assert!(g.get("gamma").unwrap().allows.is_empty());
}
#[test]
fn parse_yaml_wildcard_subject_and_peer() {
let yaml = r#"
networks:
minecraft:
"*":
allows:
"*": "tcp:6969"
"#;
let spec = parse(yaml).unwrap();
let mc = spec.networks.get("minecraft").unwrap();
let wild = mc.get("*").expect("`*` subject must parse");
assert_eq!(wild.allows.get("*").map(|s| s.as_str()), Some("tcp:6969"));
let s1 = to_yaml(&spec).unwrap();
let s2 = to_yaml(&parse(&s1).unwrap()).unwrap();
assert_eq!(s1, s2);
}
#[test]
fn load_requires_yaml_extension() {
let dir = std::env::temp_dir().join(format!("rayfish-apply-ext-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let toml_path = dir.join("spec.toml");
std::fs::write(&toml_path, "networks = {}\n").unwrap();
let err = load(&toml_path).unwrap_err().to_string();
assert!(err.contains("YAML"), "{err}");
let yaml_path = dir.join("spec.yaml");
std::fs::write(&yaml_path, "networks:\n gaming:\n").unwrap();
let spec = load(&yaml_path).unwrap();
assert!(spec.networks.contains_key("gaming"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn roundtrip_yaml_is_stable_and_sorted() {
let mut fw = SuggestedFirewall::new();
fw.insert(
"alice".to_string(),
HostSuggestions {
allows: [("bob".to_string(), "tcp:22".to_string())].into(),
denies: [].into(),
},
);
let mut spec = DeploySpec {
networks: BTreeMap::new(),
};
spec.networks.insert("gaming".to_string(), fw);
spec.networks
.insert("admin".to_string(), SuggestedFirewall::new());
let s1 = to_yaml(&spec).unwrap();
let s2 = to_yaml(&parse(&s1).unwrap()).unwrap();
assert_eq!(
s1, s2,
"roundtrip must be byte-identical (sorted canonical)"
);
let admin_idx = s1.find("admin:").unwrap();
let gaming_idx = s1.find("gaming:").unwrap();
assert!(admin_idx < gaming_idx);
}
#[test]
fn expected_hosts_collects_subjects_and_peers_skipping_wildcard() {
let mut fw = SuggestedFirewall::new();
fw.insert(
"alice".to_string(),
HostSuggestions {
allows: [("bob".to_string(), "tcp:22".to_string())].into(),
denies: [("carol".to_string(), "icmp".to_string())].into(),
},
);
fw.insert(
"*".to_string(),
HostSuggestions {
allows: [("*".to_string(), "tcp:6969".to_string())].into(),
denies: [].into(),
},
);
let mut spec = DeploySpec {
networks: BTreeMap::new(),
};
spec.networks.insert("gaming".to_string(), fw);
let hosts = expected_hosts(&spec);
assert_eq!(
hosts,
vec!["alice".to_string(), "bob".to_string(), "carol".to_string()]
);
}
#[test]
fn old_file_level_trusted_field_errors() {
let yaml = r#"
trusted: true
networks:
gaming:
alice:
allows:
bob: "tcp:22"
"#;
assert!(parse(yaml).is_err());
}
#[test]
fn old_per_network_format_errors() {
let yaml = r#"
networks:
gaming:
trusted: true
firewall:
alice:
allows:
bob: "tcp:22"
"#;
assert!(parse(yaml).is_err());
}
#[test]
fn unknown_top_level_field_errors() {
let yaml = r#"
bogus: 1
networks: {}
"#;
assert!(parse(yaml).is_err());
}
#[test]
fn invalid_yaml_errors() {
assert!(parse("key: [unclosed").is_err());
}
#[test]
fn example_spec_parses() {
let spec = parse(EXAMPLE_SPEC).expect("EXAMPLE_SPEC must parse");
let g = spec.networks.get("gaming").unwrap();
assert_eq!(g.len(), 3);
let alice = g.get("alice").unwrap();
assert_eq!(alice.allows.get("bob").map(|s| s.as_str()), Some("tcp:22"));
assert!(g.get("carol").unwrap().allows.is_empty());
let mc = spec.networks.get("minecraft").unwrap();
assert_eq!(
mc.get("*").unwrap().allows.get("*").map(|s| s.as_str()),
Some("tcp:6969")
);
}
}