#![doc = include_str!("../README.md")]
use std::collections::HashMap;
use std::hash::Hash;
use serde::Deserialize;
use serde_yaml::{Deserializer, Value, from_str, to_string};
use siphasher::sip128::{Hasher128, SipHasher24};
use rc_conf::RcConf;
use shvar::VariableProvider;
use utf8path::Path;
const K8SIGNORE: &str = ".k8srcignore";
const SERVICE_DEFAULT_YAML: &str = r#"apiVersion: apps/v1
kind: Deployment
metadata:
name: ${SERVICE:?SERVICE not defined}
namespace: ${NAMESPACE:?NAMESPACE not defined}
labels:
app: ${SERVICE:?SERVICE not defined}
spec:
replicas: ${REPLICAS:?}
selector:
matchLabels:
app: ${SERVICE:?SERVICE not defined}
template:
metadata:
labels:
app: ${SERVICE:?SERVICE not defined}
spec:
containers:
- name: ${SERVICE:?SERVICE not defined}
image: ${IMAGE:?IMAGE not defined}
ports:
- containerPort: ${PORT:?PORT not defined}
envFrom:
- configMapRef:
name: ${RCVARS:?RCVARS not defined}
env:
- name: RCVAR_ARGV0
value: ${SERVICE:?SERVICE not defined}
---
apiVersion: v1
kind: Service
metadata:
name: ${SERVICE:?SERVICE not defined}
namespace: ${NAMESPACE:?NAMESPACE not defined}
labels:
app: ${SERVICE:?SERVICE not defined}
spec:
type: NodePort
ports:
- port: ${PORT:?PORT not defined}
protocol: TCP
targetPort: ${PORT:?PORT not defined}
selector:
app: ${SERVICE:?SERVICE not defined}
"#;
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
NonUtf8Path(std::path::PathBuf),
ParseIntError(std::num::ParseIntError),
RcConf(rc_conf::Error),
Shvar(shvar::Error),
SerdeYaml(serde_yaml::Error),
InvalidCurrentDirectory,
ManifestsDirectoryExists,
ManifestExists(Path<'static>),
ManifestMissing(Path<'static>),
BadOptions(String),
VerificationError(Path<'static>),
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Self::Io(err)
}
}
impl From<std::num::ParseIntError> for Error {
fn from(err: std::num::ParseIntError) -> Self {
Self::ParseIntError(err)
}
}
impl From<rc_conf::Error> for Error {
fn from(err: rc_conf::Error) -> Self {
Self::RcConf(err)
}
}
impl From<shvar::Error> for Error {
fn from(err: shvar::Error) -> Self {
Self::Shvar(err)
}
}
impl From<serde_yaml::Error> for Error {
fn from(err: serde_yaml::Error) -> Self {
Self::SerdeYaml(err)
}
}
pub fn rewrite(rc_conf: &RcConf, service: &str, yaml: &str) -> Result<String, Error> {
let vp = rc_conf.variable_provider_for(service)?;
_rewrite(&vp, yaml)
}
fn _rewrite(vp: &impl VariableProvider, yaml: &str) -> Result<String, Error> {
let mut docs = vec![];
for doc in Deserializer::from_str(yaml) {
let value = Value::deserialize(doc)?;
docs.push(value);
}
let docs = docs
.into_iter()
.map(|y| transform(vp, y))
.collect::<Result<Vec<_>, _>>()?;
let mut out = String::new();
for doc in docs.into_iter().flatten() {
out += &to_string(&doc)?;
}
Ok(out)
}
fn transform(vp: &dyn VariableProvider, yaml: Value) -> Result<Option<Value>, Error> {
fn transform_vec(vp: &dyn VariableProvider, yaml: Vec<Value>) -> Result<Option<Value>, Error> {
let yaml = yaml
.into_iter()
.map(|y| transform(vp, y))
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(Value::Sequence(yaml.into_iter().flatten().collect())))
}
fn transform_kv(
vp: &dyn VariableProvider,
k: Value,
v: Value,
) -> Result<Option<(Value, Value)>, Error> {
let k = transform(vp, k)?;
let v = transform(vp, v)?;
if let (Some(k), Some(v)) = (k, v) {
Ok(Some((k, v)))
} else {
Ok(None)
}
}
fn transform_hash(
vp: &dyn VariableProvider,
yaml: impl Iterator<Item = (Value, Value)>,
) -> Result<Option<Value>, Error> {
let yaml = yaml
.into_iter()
.map(|(k, v)| transform_kv(vp, k, v))
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(Value::Mapping(yaml.into_iter().flatten().collect())))
}
match yaml {
Value::String(s) => match shvar::expand_recursive(vp, &s) {
Ok(expanded) => {
let pieces = shvar::split(&expanded)?;
let quoted = shvar::quote(pieces);
let value: Value = from_str("ed)?;
Ok(Some(value))
}
Err(shvar::Error::Requested(msg)) => {
if msg.is_empty() {
Ok(None)
} else {
Err(shvar::Error::Requested(msg).into())
}
}
Err(err) => Err(err.into()),
},
Value::Sequence(a) => transform_vec(vp, a),
Value::Mapping(h) => transform_hash(vp, h.into_iter()),
_ => Ok(Some(yaml)),
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "command_line", derive(arrrg_derive::CommandLine))]
pub struct RegenerateOptions {
#[cfg_attr(
feature = "command_line",
arrrg(optional, "Root of the k8src repository.")
)]
pub root: Option<String>,
#[cfg_attr(feature = "command_line", arrrg(optional, "Root of the k8src output."))]
pub output: Option<String>,
#[cfg_attr(feature = "command_line", arrrg(flag, "Overwrite the existing files."))]
pub overwrite: bool,
#[cfg_attr(
feature = "command_line",
arrrg(flag, "Verify the existing files rather than generating.")
)]
pub verify: bool,
}
fn write_yaml(
options: &RegenerateOptions,
output: Path,
yaml: &str,
tracking: &mut Vec<Path>,
) -> Result<(), Error> {
if options.verify && options.overwrite {
Err(Error::BadOptions(
"--verify and --overwrite are mutually exclusive".to_string(),
))
} else if options.verify {
if !output.exists() {
return Err(Error::ManifestMissing(output.into_owned()));
}
let returned = yaml;
let expected = std::fs::read_to_string(&output)?;
if expected != returned {
Err(Error::VerificationError(output.into_owned()))
} else {
tracking.push(output.into_owned());
Ok(())
}
} else {
if output.exists() && !options.overwrite {
return Err(Error::ManifestExists(output.into_owned()));
}
std::fs::create_dir_all(output.dirname())?;
std::fs::write(&output, yaml)?;
tracking.push(output.into_owned());
Ok(())
}
}
pub fn regenerate(options: RegenerateOptions) -> Result<(), Error> {
let root = if let Some(root) = options.root.as_ref() {
Path::from(root)
} else {
Path::cwd().ok_or(Error::InvalidCurrentDirectory)?
};
let output = if let Some(output) = options.output.as_ref() {
Path::from(output)
} else {
root.join("manifests")
};
if !options.verify && !options.overwrite && output.exists() {
return Err(Error::ManifestsDirectoryExists);
}
if options.overwrite && output.exists() {
std::fs::remove_dir_all(&output)?;
}
let rc_confs = restrict_to_terminals(find_rc_confs(&root)?);
for rc_conf in rc_confs.into_iter() {
let candidates = candidates(&root, &rc_conf);
let Some(relative) = candidates[candidates.len() - 1].strip_prefix(root.as_str()) else {
panic!("there's a logic error; this should be unreachable");
};
let relative = Path::from(relative.as_str().trim_start_matches('/'));
let rc_conf_path = rc_conf_path(&candidates);
let rc_conf = RcConf::parse(&rc_conf_path)?;
let mut root_yaml = r#"apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
"#
.to_string();
let mut extended = false;
let mut tracking = vec![];
for service in rc_conf.list_services()? {
let Some(image) = rc_conf.lookup_suffix(&service, "IMAGE") else {
todo!();
};
let extra =
HashMap::from_iter([("IMAGE", image.clone()), ("RCVAR_ARGV0", service.clone())]);
let rcvar = rc_conf.argv(&service, "IMAGE_RCVAR", &extra)?;
let rcvars = if !rcvar.is_empty() {
let rcvar = std::process::Command::new(&rcvar[0])
.args(&rcvar[1..])
.output()?;
let rckeys = String::from_utf8_lossy(&rcvar.stdout);
let mut rckeys = rckeys
.split_whitespace()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
rckeys.sort();
let mut rcvars = rc_conf
.generate_rcvars(&service, &rckeys)?
.into_iter()
.collect::<Vec<_>>();
rcvars.sort();
rcvars
} else {
vec![]
};
let mut yaml = match template_for_service(&candidates, &rc_conf, &service) {
Some(template) => std::fs::read_to_string(template)?,
None => SERVICE_DEFAULT_YAML.to_string(),
};
const MAGIC_KEY: &[u8; 16] = &[
173, 14, 53, 145, 150, 207, 208, 116, 119, 25, 149, 255, 4, 53, 29, 50,
];
let mut hasher = SipHasher24::new_with_key(MAGIC_KEY);
service.hash(&mut hasher);
rcvars.hash(&mut hasher);
let sig = hasher.finish128().as_u128();
let mut config_map = format!(
r#"apiVersion: v1
kind: ConfigMap
metadata:
name: config-map-{sig}
namespace: ${{NAMESPACE:?NAMESPACE not defined}}
data:
"#
);
for (key, value) in rcvars {
config_map += &format!(" {key}: {value:?}");
}
yaml += "---\n";
yaml += &config_map;
let rcvp = rc_conf.variable_provider_for(&service)?;
let locals = HashMap::from_iter([
("SERVICE", service.clone()),
("RCVARS", format!("config-map-{sig}")),
]);
let vp = (&locals, &rcvp);
let yaml = _rewrite(&vp, &yaml)?;
let output = output.join(Path::from(format!("{relative}/herd/{service}.yaml")));
write_yaml(&options, output, &yaml, &mut tracking)?;
root_yaml += &format!("- {service}.yaml\n");
extended = true;
}
if extended {
write_yaml(
&options,
output.join(Path::from(format!("{relative}/herd/kustomization.yaml"))),
&root_yaml,
&mut tracking,
)?;
}
let mut have_pets = false;
for candidate in candidates.iter().rev() {
let pets = candidate.join("pets");
if !pets.exists() {
continue;
}
fn copy_pets_from_dir(
options: &RegenerateOptions,
root: &Path,
output: &Path,
pets: &Path,
tracking: &mut Vec<Path>,
) -> Result<bool, Error> {
let mut copied = false;
for pet in std::fs::read_dir(pets)? {
let pet = pet?;
let pet =
Path::try_from(pet.path()).map_err(|_| Error::NonUtf8Path(pet.path()))?;
if pet.join(K8SIGNORE).exists() {
continue;
}
if pet.is_dir() {
copied |= copy_pets_from_dir(options, root, output, &pet, tracking)?;
continue;
}
if !pet.as_str().ends_with(".yaml") {
eprintln!("skipping pet {pet:?}");
continue;
}
let Some(relative) = pet.strip_prefix(root.as_str()) else {
panic!("there's a logic error; this should be unreachable");
};
let relative = Path::from(relative.as_str().trim_start_matches('/'));
let source = pet.clone();
let output = output.join(Path::from(format!("{relative}")));
write_yaml(options, output, &std::fs::read_to_string(source)?, tracking)?;
copied = true;
}
Ok(copied)
}
have_pets |= copy_pets_from_dir(&options, &root, &output, &pets, &mut tracking)?;
}
if extended && have_pets {
write_yaml(
&options,
output.join(Path::from(format!("{relative}/kustomization.yaml"))),
r#"apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- herd
- pets
"#,
&mut tracking,
)?;
} else if extended {
write_yaml(
&options,
output.join(Path::from(format!("{relative}/kustomization.yaml"))),
r#"apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- herd
"#,
&mut tracking,
)?;
} else if have_pets {
write_yaml(
&options,
output.join(Path::from(format!("{relative}/kustomization.yaml"))),
r#"apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pets
"#,
&mut tracking,
)?;
}
}
Ok(())
}
fn find_rc_confs(root: &Path) -> Result<Vec<Path<'static>>, Error> {
let mut paths = vec![];
if root.join(K8SIGNORE).exists() {
return Ok(paths);
}
for dirent in std::fs::read_dir(root)? {
let dirent = dirent?;
let path = Path::try_from(dirent.path()).map_err(|_| Error::NonUtf8Path(dirent.path()))?;
if dirent.file_name() == "rc.conf" {
paths.push(path.clone());
}
if dirent.file_type()?.is_dir() {
let children = find_rc_confs(&path)?;
paths.extend(children);
}
}
Ok(paths)
}
fn restrict_to_terminals(mut rc_confs: Vec<Path<'static>>) -> Vec<Path<'static>> {
rc_confs.sort_by_key(|rc_conf| rc_conf.as_str().len());
let mut restricted: Vec<Path> = vec![];
for rc_conf in rc_confs.into_iter().rev() {
fn is_parent_rc_conf(parent: &Path, child: &Path) -> bool {
let parent = parent.dirname();
let mut child = child.clone();
while child.components().count() > parent.components().count() {
child = child.dirname().into_owned();
}
child == parent
}
if !restricted.iter().any(|r| is_parent_rc_conf(&rc_conf, r)) {
restricted.push(rc_conf);
}
}
restricted
}
pub fn candidates(root: &Path, rc_conf: &Path) -> Vec<Path<'static>> {
assert!(rc_conf.as_str().starts_with(root.as_str()));
let mut rc_conf = rc_conf.dirname();
let mut candidates = vec![];
while rc_conf != *root {
candidates.push(rc_conf.clone().into_owned());
rc_conf = rc_conf.dirname().into_owned();
}
candidates.push(root.clone().into_owned());
candidates.reverse();
candidates
}
pub fn rc_conf_path(candidates: &[Path]) -> String {
let mut rc_conf_path = String::new();
for candidate in candidates {
if !rc_conf_path.is_empty() {
rc_conf_path.push(':');
}
rc_conf_path += candidate.join("rc.conf").as_str();
}
rc_conf_path
}
fn template_for_service(
candidates: &[Path],
rc_conf: &RcConf,
service: &str,
) -> Option<Path<'static>> {
let mut service = service.to_string();
loop {
for candidate in candidates.iter().rev() {
let candidate = candidate
.join("templates/rc.d")
.join(format!("{service}.yaml.template"));
if candidate.exists() {
return Some(candidate.into_owned());
}
}
let direct_alias = rc_conf.direct_alias(&service);
if direct_alias == service {
break;
} else {
service = direct_alias.to_string();
}
}
for candidate in candidates.iter().rev() {
let candidate = candidate.join("templates/service.yaml.template");
if candidate.exists() {
return Some(candidate.into_owned());
}
}
None
}