Skip to content

Commit

Permalink
Merge pull request #6 from bittrance/dedicated-config
Browse files Browse the repository at this point in the history
Config parsing is gathered in its own file
  • Loading branch information
bittrance authored Dec 28, 2023
2 parents 51b674b + 58741ae commit 850fb0e
Show file tree
Hide file tree
Showing 16 changed files with 477 additions and 436 deletions.
71 changes: 23 additions & 48 deletions src/actions.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use std::{
collections::HashMap,
io::Read,
path::Path,
process::{Command, Stdio},
Expand All @@ -8,11 +7,9 @@ use std::{
time::Instant,
};

use serde::Deserialize;

use crate::{
config::ActionConfig,
errors::GitOpsError,
opts::CliOptions,
receiver::{SourceType, WorkloadEvent},
utils::POLL_INTERVAL,
};
Expand All @@ -23,60 +20,35 @@ pub enum ActionResult {
Failure,
}

#[derive(Clone, Debug, Deserialize)]
#[derive(Clone)]
pub struct Action {
name: String,
entrypoint: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
environment: HashMap<String, String>,
#[serde(default)]
inherit_environment: bool,
config: ActionConfig,
}

impl Action {
pub fn id(&self) -> String {
self.name.clone()
pub fn new(config: ActionConfig) -> Self {
Action { config }
}

pub fn set_env(&mut self, key: String, val: String) {
self.environment.insert(key, val);
pub fn id(&self) -> String {
self.config.name.clone()
}
}

impl TryFrom<&CliOptions> for Action {
type Error = GitOpsError;

fn try_from(opts: &CliOptions) -> Result<Self, Self::Error> {
let mut environment = HashMap::new();
for env in &opts.environment {
let (key, val) = env
.split_once('=')
.ok_or_else(|| GitOpsError::InvalidEnvVar(env.clone()))?;
environment.insert(key.to_owned(), val.to_owned());
}
Ok(Self {
name: opts.action.clone().unwrap(),
// TODO --action won't work on Windows
entrypoint: "/bin/sh".to_string(),
args: vec!["-c".to_string(), opts.action.clone().unwrap()],
environment,
inherit_environment: false,
})
pub fn set_env(&mut self, key: String, val: String) {
self.config.environment.insert(key, val);
}
}

fn build_command(action: &Action, cwd: &Path) -> Command {
let mut command = Command::new(action.entrypoint.clone());
command.args(action.args.clone());
if !action.inherit_environment {
fn build_command(config: &ActionConfig, cwd: &Path) -> Command {
let mut command = Command::new(config.entrypoint.clone());
command.args(config.args.clone());
if !config.inherit_environment {
command.env_clear();
if let Ok(path) = std::env::var("PATH") {
command.env("PATH", path);
}
}
command.envs(action.environment.iter());
command.envs(config.environment.iter());
command.current_dir(cwd);
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
Expand Down Expand Up @@ -121,7 +93,7 @@ pub fn run_action<F>(
where
F: Fn(WorkloadEvent) -> Result<(), GitOpsError> + Send + 'static,
{
let mut command = build_command(action, cwd);
let mut command = build_command(&action.config, cwd);
let mut child = command.spawn().map_err(GitOpsError::ActionError)?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
Expand Down Expand Up @@ -152,6 +124,7 @@ where
#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
process::ExitStatus,
sync::{Arc, Mutex},
time::Duration,
Expand All @@ -162,11 +135,13 @@ mod tests {

fn shell_action(cmd: &str) -> Action {
Action {
name: "test".to_owned(),
entrypoint: "/bin/sh".to_owned(),
args: vec!["-c".to_owned(), cmd.to_owned()],
environment: HashMap::new(),
inherit_environment: false,
config: ActionConfig {
name: "test".to_owned(),
entrypoint: "/bin/sh".to_owned(),
args: vec!["-c".to_owned(), cmd.to_owned()],
environment: HashMap::new(),
inherit_environment: false,
},
}
}

Expand Down
256 changes: 256 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
use std::{collections::HashMap, io::Read, path::PathBuf, time::Duration};

use gix::Url;
use serde::{Deserialize, Deserializer};

use crate::{errors::GitOpsError, opts::CliOptions};

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
pub tasks: Vec<GitTaskConfig>,
}

#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitTaskConfig {
pub name: String,
pub github: Option<GithubConfig>,
pub git: GitConfig,
pub actions: Vec<ActionConfig>,
#[serde(
default = "GitTaskConfig::default_interval",
deserialize_with = "human_readable_duration"
)]
pub interval: Duration,
#[serde(
default = "GitTaskConfig::default_timeout",
deserialize_with = "human_readable_duration"
)]
pub timeout: Duration,
}

impl GitTaskConfig {
pub fn default_interval() -> Duration {
Duration::from_secs(60)
}

pub fn default_timeout() -> Duration {
Duration::from_secs(3600)
}
}

impl TryFrom<&CliOptions> for GitTaskConfig {
type Error = GitOpsError;

fn try_from(opts: &CliOptions) -> Result<Self, Self::Error> {
let url = Url::try_from(opts.url.clone().unwrap()).map_err(GitOpsError::InvalidUrl)?;
let action: ActionConfig = TryFrom::try_from(opts)?;
Ok(Self {
name: url.path.to_string(),
github: TryFrom::try_from(opts)?,
git: TryFrom::try_from(opts)?,
actions: vec![action],
interval: opts.interval.unwrap_or(Self::default_interval()),
timeout: opts.timeout.unwrap_or(Self::default_timeout()),
})
}
}

#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GithubConfig {
pub app_id: String,
pub private_key_file: PathBuf,
#[serde(default = "GithubConfig::default_context")]
pub status_context: Option<String>,
}

impl GithubConfig {
pub fn default_context() -> Option<String> {
Some("kitops".to_owned())
}
}

impl TryFrom<&CliOptions> for Option<GithubConfig> {
type Error = GitOpsError;

fn try_from(opts: &CliOptions) -> Result<Self, Self::Error> {
match (&opts.github_app_id, &opts.github_private_key_file) {
(None, None) => Ok(None),
(Some(app_id), Some(private_key_file)) => Ok(Some(GithubConfig {
app_id: app_id.clone(),
private_key_file: private_key_file.clone(),
status_context: opts.github_status_context.clone(),
})),
_ => Err(GitOpsError::InvalidNotifyConfig),
}
}
}

#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitConfig {
#[serde(deserialize_with = "url_from_string")]
pub url: Url,
#[serde(default = "GitConfig::default_branch")]
pub branch: String,
}

impl GitConfig {
pub fn default_branch() -> String {
"main".to_owned()
}
}

impl TryFrom<&CliOptions> for GitConfig {
type Error = GitOpsError;

fn try_from(opts: &CliOptions) -> Result<Self, Self::Error> {
let url = Url::try_from(opts.url.clone().unwrap()).map_err(GitOpsError::InvalidUrl)?;
Ok(GitConfig {
url,
branch: opts.branch.clone(),
})
}
}

#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionConfig {
pub name: String,
pub entrypoint: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub environment: HashMap<String, String>,
#[serde(default)]
pub inherit_environment: bool,
}

impl TryFrom<&CliOptions> for ActionConfig {
type Error = GitOpsError;

fn try_from(opts: &CliOptions) -> Result<Self, Self::Error> {
let mut environment = HashMap::new();
for env in &opts.environment {
let (key, val) = env
.split_once('=')
.ok_or_else(|| GitOpsError::InvalidEnvVar(env.clone()))?;
environment.insert(key.to_owned(), val.to_owned());
}
Ok(ActionConfig {
name: opts.action.clone().unwrap(),
// TODO --action won't work on Windows
entrypoint: "/bin/sh".to_string(),
args: vec!["-c".to_string(), opts.action.clone().unwrap()],
environment,
inherit_environment: false,
})
}
}

fn human_readable_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
humantime::parse_duration(&s).map_err(serde::de::Error::custom)
}

fn url_from_string<'de, D>(deserializer: D) -> Result<Url, D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
Url::try_from(s).map_err(serde::de::Error::custom)
}

pub fn read_config(reader: impl Read) -> Result<ConfigFile, GitOpsError> {
serde_yaml::from_reader(reader).map_err(GitOpsError::MalformedConfig)
}

#[cfg(test)]
mod tests {
use std::time::Duration;

use crate::{config::GitTaskConfig, errors::GitOpsError};

use super::read_config;

#[test]
fn minimum_config() {
let config = r#"tasks:
- name: testo
git:
url: https://github.com/bittrance/kitops
actions:
- name: list files
entrypoint: /bin/ls
"#;
read_config(config.as_bytes()).unwrap();
}

#[test]
fn fail_on_unknown_git_config() {
let config = r#"tasks:
- name: testo
git:
url: https://github.com/bittrance/kitops
non: sense
actions:
- name: list files
entrypoint: /bin/ls
"#;
assert!(matches!(
read_config(config.as_bytes()),
Err(GitOpsError::MalformedConfig(_))
));
}

#[test]
fn fail_on_unknown_action_config() {
let config = r#"tasks:
- name: testo
git:
url: https://github.com/bittrance/kitops
actions:
- name: list files
non: sense
entrypoint: /bin/ls
"#;
assert!(matches!(
read_config(config.as_bytes()),
Err(GitOpsError::MalformedConfig(_))
));
}

#[test]
fn action_environment_config() {
let config = r#"tasks:
- name: testo
git:
url: https://github.com/bittrance/kitops
actions:
- name: list files
entrypoint: /bin/ls
environment:
FOO: bar
"#;
read_config(config.as_bytes()).unwrap();
}

#[test]
fn parse_gittaskconfig() {
let raw_config = r#"name: testo
git:
url: https://github.com/bittrance/kitops
timeout: 3s
interval: 1m 2s
actions: []
"#;
let config = serde_yaml::from_str::<GitTaskConfig>(raw_config).unwrap();
assert_eq!(config.timeout, Duration::from_secs(3));
assert_eq!(config.interval, Duration::from_secs(62));
}
}
2 changes: 2 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ pub enum GitOpsError {
NotifyError(String),
#[error("Failed to launch action: {0}")]
ActionError(std::io::Error),
#[error("Auth only on HTTPS URLs: {0}")]
GitHubAuthNonHttpsUrl(String),
#[error("Missing private key file: {0}")]
GitHubMissingPrivateKeyFile(std::io::Error),
#[error("Malformed private RS256 key: {0}")]
Expand Down
Loading

0 comments on commit 850fb0e

Please sign in to comment.