use super::{config::*, repo::*};
use anyhow::*;
use glob::*;
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, HashSet, VecDeque},
fmt,
fs::File,
io::{BufReader, Read},
path::Path,
};
pub struct Database {
state: DbState,
ignore_queue: bool,
pub state_dir: String,
}
const STATE_DIR: &str = ".cepler";
impl Database {
pub fn state_dir_from_config(scope: &str, path_to_config: &str) -> String {
let path = Path::new(path_to_config);
format!(
"{}/{}",
match path.parent() {
Some(parent) if parent == Path::new("") => STATE_DIR.to_string(),
None => STATE_DIR.to_string(),
Some(parent) => format!("{}/{}", parent.to_str().unwrap(), STATE_DIR),
},
scope
)
}
pub fn open(scope: &str, path_to_config: &str, ignore_queue: bool) -> Result<Self> {
let mut state = DbState::default();
let dir = Self::state_dir_from_config(scope, path_to_config);
if Path::new(&dir).is_dir() {
for path in glob(&format!("{}/*.state", dir))? {
let path = path?;
if let Some(name) = path.as_path().file_stem() {
let file = File::open(&path)?;
let reader = BufReader::new(file);
state.environments.insert(
name.to_str().expect("Convert name").to_string(),
EnvironmentState::from_reader(reader)?,
);
}
}
}
Ok(Self {
state,
state_dir: dir,
ignore_queue,
})
}
pub fn open_env_from_commit(
&self,
path_to_config: &str,
ignore_queue: bool,
scope: &str,
env_config: &EnvironmentConfig,
commit: CommitHash,
repo: &Repo,
) -> Result<Self> {
let dir = Self::state_dir_from_config(scope, path_to_config);
let mut state = DbState::default();
if let Some(env_state) = self.state.environments.get(&env_config.name) {
state
.environments
.insert(env_config.name.to_string(), env_state.clone());
}
if let Some(last_env) = env_config.propagated_from() {
let env_file = format!("{}/{}.state", dir, last_env);
let env_path = Path::new(&env_file);
if let Some(env_state) = repo.get_file_content(commit, env_path, |bytes| {
EnvironmentState::from_reader(bytes)
})? {
state.environments.insert(last_env.to_string(), env_state);
}
}
Ok(Self {
state,
state_dir: dir,
ignore_queue,
})
}
pub fn set_current_environment_state(
&mut self,
name: String,
propagated_from: Option<String>,
mut env: DeployState,
) -> Result<(u32, String)> {
let any_dirty = env.files.values().any(|f| f.dirty);
env.any_dirty = any_dirty;
let ret = format!("{}/{}.state", self.state_dir, &name);
let version = if let Some(state) = self.state.environments.get_mut(&name) {
std::mem::swap(&mut state.current, &mut env);
state.propagation_queue.push_front(env);
state.propagated_from = propagated_from;
state.version += 1;
state.version
} else {
let version = 1;
self.state.environments.insert(
name.clone(),
EnvironmentState {
version,
current: env,
propagated_from,
propagation_queue: VecDeque::new(),
},
);
version
};
self.state.prune_propagation_queue(name);
self.persist()?;
Ok((version, ret))
}
pub fn get_target_propagated_state(
&self,
env: &str,
env_ignore_queue: bool,
propagated_from: &str,
patterns: &[glob::Pattern],
) -> Option<&DeployState> {
let match_options = glob::MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: true,
};
match (
self.state.environments.get(env),
self.state.environments.get(propagated_from),
) {
(Some(env), Some(from)) => {
if let Some(from_head) = env.current.propagated_head.as_ref() {
if self.ignore_queue
|| env_ignore_queue
|| from_head == &from.current.head_commit
|| from.propagation_queue.is_empty()
{
Some(&from.current)
} else {
let mut ret = &from.current;
for state in from.propagation_queue.iter() {
if &state.head_commit == from_head {
break;
}
for (ident, file_state) in state.files.iter() {
let file_name = ident.name();
if patterns
.iter()
.any(|p| p.matches_with(&file_name, match_options))
{
if let Some((_, existing_state)) = env
.current
.files
.iter()
.find(|(ident, _)| ident.name() == file_name)
{
if existing_state.file_hash != file_state.file_hash {
ret = state;
break;
}
} else {
ret = state;
break;
}
}
}
}
Some(ret)
}
} else {
Some(&from.current)
}
}
(None, Some(state)) => Some(&state.current),
_ => None,
}
}
pub fn get_current_state(&self, env: &str) -> Option<(u32, &DeployState)> {
self.state
.environments
.get(env)
.map(|env| (env.version, &env.current))
}
fn persist(&self) -> Result<()> {
use std::fs;
use std::io::Write;
let _ = fs::remove_dir_all(&self.state_dir);
fs::create_dir_all(&self.state_dir)?;
for (name, env) in self.state.environments.iter() {
let mut file = File::create(&format!("{}/{}.state", self.state_dir, name))?;
let mut bytes = serde_yaml::to_vec(&env)?;
bytes.extend("\n".as_bytes());
file.write_all(&bytes)?;
}
Ok(())
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct DbState {
environments: BTreeMap<String, EnvironmentState>,
}
impl DbState {
fn prune_propagation_queue(&mut self, name: String) {
let mut keep_states = 0;
let to_prune = self.environments.get(&name).unwrap();
for commit_hash in self.environments.iter().filter_map(|(env_name, state)| {
if env_name == &name
|| state.propagated_from.is_none()
|| state.propagated_from.as_ref().unwrap() != &name
{
None
} else {
state.current.propagated_head.as_ref()
}
}) {
if commit_hash == &to_prune.current.head_commit {
continue;
}
for (idx, old_hash) in to_prune
.propagation_queue
.iter()
.map(|state| &state.head_commit)
.enumerate()
.skip(keep_states)
{
if old_hash == commit_hash {
break;
}
keep_states = keep_states.max(idx + 1);
}
}
let to_prune = self.environments.get_mut(&name).unwrap();
to_prune.propagation_queue.drain(keep_states..);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvironmentState {
#[serde(default)]
version: u32,
current: DeployState,
#[serde(skip_serializing_if = "Option::is_none")]
pub propagated_from: Option<String>,
#[serde(skip_serializing_if = "VecDeque::is_empty")]
#[serde(default)]
propagation_queue: VecDeque<DeployState>,
}
impl EnvironmentState {
fn from_reader(reader: impl Read) -> Result<Self> {
let state = serde_yaml::from_reader(reader)?;
Ok(state)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployState {
pub head_commit: CommitHash,
#[serde(skip_serializing_if = "Option::is_none")]
pub propagated_head: Option<CommitHash>,
#[serde(skip_serializing_if = "is_false")]
#[serde(default)]
any_dirty: bool,
#[serde(default)]
pub files: BTreeMap<FileIdent, FileState>,
}
#[derive(Debug, Clone, Hash, PartialOrd, PartialEq, Eq, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FileIdent(String);
impl FileIdent {
pub fn new(name: String, from: Option<&str>) -> Self {
Self(format!(
"{{{}}}/{}",
from.as_ref().unwrap_or(&"latest"),
name
))
}
pub fn name(&self) -> String {
self.0.chars().skip_while(|c| c != &'}').skip(2).collect()
}
pub fn propagated(&self) -> bool {
!self.0.starts_with("{latest}")
}
pub fn inner(self) -> String {
self.0
}
}
impl DeployState {
pub fn new(head_commit: CommitHash) -> Self {
Self {
head_commit,
propagated_head: None,
any_dirty: false,
files: BTreeMap::new(),
}
}
pub fn diff(&self, other: &DeployState) -> Vec<FileDiff> {
let mut removed_files: HashSet<&FileIdent> = other.files.keys().collect();
let mut diffs: Vec<_> = self
.files
.iter()
.filter_map(|(ident, state)| {
removed_files.remove(&ident);
if let Some(last_state) = other.files.get(ident) {
if state.file_hash.is_none() && last_state.file_hash.is_none() {
None
} else if state.dirty
|| last_state.dirty
|| state.file_hash != last_state.file_hash
{
Some(FileDiff {
ident: ident.clone(),
current_state: if state.file_hash.is_some() {
Some(state.clone())
} else {
None
},
added: last_state.file_hash.is_none(),
})
} else {
None
}
} else {
Some(FileDiff {
ident: ident.clone(),
current_state: if state.file_hash.is_some() {
Some(state.clone())
} else {
None
},
added: true,
})
}
})
.collect();
diffs.extend(removed_files.iter().map(|ident| FileDiff {
ident: FileIdent::clone(ident),
current_state: None,
added: false,
}));
diffs
}
}
#[derive(Debug)]
pub struct FileDiff {
pub ident: FileIdent,
pub current_state: Option<FileState>,
pub added: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileState {
pub file_hash: Option<FileHash>,
#[serde(skip_serializing_if = "is_false")]
#[serde(default)]
pub dirty: bool,
pub from_commit: CommitHash,
pub message: String,
}
impl fmt::Display for FileState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[{}] - {}",
self.from_commit.to_short_ref(),
self.message
)
}
}
fn is_false(b: &bool) -> bool {
!b
}