use std::fs;
use std::io;
use std::path::Path;
use color_eyre::Result;
use color_eyre::eyre::Context;
use crate::cmd::dns_parser;
pub fn run(
zone_file: impl AsRef<Path>,
zone_name: Option<impl AsRef<str>>,
zone_id_var: String,
) -> Result<()> {
let content = fs::read_to_string(&zone_file)
.with_context(|| format!("failed to read file {}", zone_file.as_ref().display()))?;
let zone = zone_name
.map(|s| s.as_ref().to_string())
.unwrap_or_else(|| extract_zone(&content));
let records = dns_parser::parse_zone(&content);
print_terraform_resources(&records, &zone, &zone_id_var, io::stdout().lock())?;
Ok(())
}
fn extract_zone(content: &str) -> String {
for line in content.lines() {
if dns_parser::SOA.is_match(line) {
let parts: Vec<&str> = line.split_whitespace().collect();
let name = parts[0].trim_end_matches('.');
return name.to_string();
}
}
"example.com".to_string()
}
fn write_terraform_header<W: io::Write>(mut out: W) -> Result<()> {
Ok(writeln!(
out,
r#"terraform {{
required_providers {{
cloudflare = {{
source = "cloudflare/cloudflare"
}}
}}
}}
"#
)?)
}
fn print_terraform_resources<W: io::Write>(
records: &[dns_parser::DnsRecord],
zone: &str,
zone_id: &str,
mut out: W,
) -> Result<()> {
write_terraform_header(&mut out)?;
for (i, rec) in records.iter().enumerate() {
if rec.rtype == "NS" {
continue;
}
let record_name = dns_parser::strip_zone(&rec.name, zone);
let resource_base = if record_name == zone {
"apex".to_string()
} else {
record_name.replace('.', "_")
};
let resource_name = format!(
"record_{}_{}_{}",
resource_base,
rec.rtype.to_lowercase(),
i
);
writeln!(out, "resource \"cloudflare_record\" \"{resource_name}\" {{")?;
writeln!(out, " zone_id = {zone_id}")?;
writeln!(out, " name = {record_name:?}")?;
writeln!(out, " type = {:?}", rec.rtype)?;
writeln!(out, " ttl = {}", rec.ttl)?;
if dns_parser::can_proxy(&rec.rtype)
&& let Some(proxied) = rec.proxied
{
writeln!(out, " proxied = {proxied}")?;
}
match rec.rtype.as_str() {
"SRV" => {
let (_, service, proto) = dns_parser::parse_srv_name(&rec.name, zone);
let parts: Vec<&str> = rec.data.split_whitespace().collect();
let priority: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let weight: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
let port: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
let target = parts.get(3).map(|s| s.trim_end_matches('.')).unwrap_or("");
writeln!(out, " data {{")?;
writeln!(out, " service = {service:?}")?;
writeln!(out, " proto = {proto:?}")?;
writeln!(out, " name = {record_name:?}")?;
writeln!(out, " priority = {priority}")?;
writeln!(out, " weight = {weight}")?;
writeln!(out, " port = {port}")?;
writeln!(out, " target = {target:?}")?;
writeln!(out, " }}")?;
}
"MX" => {
let parts: Vec<&str> = rec.data.splitn(2, ' ').collect();
let priority = parts.first().map(|s| s.trim()).unwrap_or("0");
let value = parts
.get(1)
.map(|s| s.trim_end_matches('.').trim())
.unwrap_or("");
writeln!(out, " priority = {priority}")?;
writeln!(out, " content = {value:?}")?;
}
"TXT" => {
let txt = dns_parser::parse_txt_data(&rec.data);
writeln!(out, " content = {txt:?}")?;
}
_ => {
let value = rec.data.trim_end_matches('.');
writeln!(out, " content = {value:?}")?;
}
}
writeln!(out, "}}")?;
writeln!(out)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_ZONE: &str = r#";;
;; Domain: example.com.
;;
;; NS Records
example.com. 86400 IN NS ns1.example.com.
;; A Records
example.com. 1 IN A 203.0.113.1 ; cf_tags=cf-proxied:true
mail.example.com. 1 IN A 192.0.2.1 ; cf_tags=cf-proxied:false
api.example.com. 3600 IN A 10.0.0.1
"#;
#[test]
fn output_produces_hcl() {
let records = dns_parser::parse_zone(SAMPLE_ZONE);
let zone = "example.com";
let zone_id = "var.cloudflare_zone_id";
let mut buf = Vec::new();
let result = print_terraform_resources(&records, zone, zone_id, &mut buf);
assert!(result.is_ok(), "print_terraform_resources should succeed");
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("resource \"cloudflare_record\" \"record_apex_a_1\""));
}
}