Page MenuHomeDevCentral

D2983.id7611.diff
No OneTemporary

D2983.id7611.diff

diff --git a/.alkane.conf b/.alkane.conf
new file mode 100644
--- /dev/null
+++ b/.alkane.conf
@@ -0,0 +1,19 @@
+# -------------------------------------------------------------
+# Alkane :: Configuration
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# License: BSD-2-Clause
+# -------------------------------------------------------------
+
+roots:
+
+ # By default: /var/db/alkane
+ db: tests/data/db
+
+ # By default: /var/wwwroot
+ sites: tests/data/wwwroot
+
+ # By default: /usr/local/libexec/alkane
+ recipes: tests/data/recipes
+
+site_directory_template: "%domain%.%tld%/%subdomain%"
diff --git a/.arcconfig b/.arcconfig
new file mode 100644
--- /dev/null
+++ b/.arcconfig
@@ -0,0 +1,4 @@
+{
+ "phabricator.uri": "https://devcentral.nasqueron.org/",
+ "repository.callsign": "ALK"
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+/src/data/public_suffix_list.dat
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "alkane"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+env_logger = "^0.10.0"
+lazy_static = "^1.4.0"
+limiting-factor = "0.7.2"
+log = "^0.4.17"
+rocket = "^0.4.11"
+rocket_codegen = "^0.4.11"
+serde_yaml = "^0.9.21"
+
+[dependencies.clap]
+version = "~4.2.1"
+features = ["derive"]
+
+[dependencies.rocket_contrib]
+version = "^0.4.11"
+default-features = false
+features = ["json"]
+
+[dependencies.serde]
+version = "^1.0.159"
+features = ["derive"]
diff --git a/Makefile b/Makefile
new file mode 100644
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,44 @@
+# -------------------------------------------------------------
+# Alkane :: Build
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# License: BSD-2-Clause
+# -------------------------------------------------------------
+
+CARGO=cargo
+MKDIRHIER=mkdir -p
+WGET=wget
+RM=rm -rf
+
+# -------------------------------------------------------------
+# Main targets
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+all: data build
+
+clean: clean-data clean-build
+
+# -------------------------------------------------------------
+# Build targets
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+data: src/data/public_suffix_list.dat
+
+build: target/release/alkane
+
+src/data/public_suffix_list.dat:
+ ${MKDIRHIER} src/data
+ ${WGET} -O src/data/public_suffix_list.dat https://publicsuffix.org/list/public_suffix_list.dat
+
+target/release/alkane:
+ cd src && ${CARGO} build --release
+
+# -------------------------------------------------------------
+# Clean targets
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+clean-data:
+ ${RM} src/data/public_suffix_list.dat
+
+clean-build:
+ ${RM} target/release
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,113 @@
+# Alkane
+
+## Features
+
+The `alkane` command allows to manage the Nasqueron PaaS Alkane,
+to host PHP sites through nginx and php-fpm, and static sites
+directly through nginx.
+
+It can work in two complementary modes:
+
+ - as a command, to allow local system administration task and debug
+ - as a server, to allow components to interact with it for automation
+
+For example, to update the website foo.domain.tld, you can:
+
+ - use the command `alkane update foo.domain.tld`
+ - send a POST request to `http://localhost:10206/update/foo.domain.tld`
+
+For example, you can use the server to allow Jenkins or your continuous
+delivery system to trigger an update process.
+
+Both modes will run the same action code.
+
+## Configuration
+
+The configuration is written in YAML and is seeked at:
+
+ - .alkane.conf
+ - /usr/local/etc/alkane.conf
+ - /etc/alkane.conf
+
+## Usage
+
+### Alkane update
+
+The update feature allows to update a site according configured instructions:
+
+ - do something simple like `git pull`
+ - download a Jenkins artifact
+ - run a recipe like `git pull && composer update` or `make update`
+ - it can ask php-fpm to reload, so Opcache picks the new code
+
+The instructions aren't given as command argument or HTTP request, but
+preconfigured on the server.
+
+Alkane is responsible to update the local site content, to help deploying
+your artefact, but not to update nginx or certificates configuration.
+On Nasqueron servers, nginx is provisioned through Salt.
+
+### Alkane init
+
+The init feature works similarly than the update process, but is responsible
+to deploy the site the first time, when it doesn't exist.
+
+### Alkane misc commands
+
+ - **is-present**: determine if a site is hosted on the PaaS
+ - **deploy**: call `init` or `update` as needed
+
+### Alkane server
+
+To run the **Alkane** server and expose the API, use `alkane server`.
+
+The following environment variables allow to configure the server:
+
+| Variable | Description | Default value |
+|--------------------|-----------------|---------------|
+| ROCKET_PORT | Server port | 8000 |
+| ROCKET_ADDRESS | Address to bind | 0.0.0.0 |
+
+The following options allow to configure the server:
+
+| Argument | Description | Default value |
+|--------------------|-----------------|---------------|
+| --mounting-point | Mounting point | / |
+
+Nasqueron servers expose Alkane on the port 10206, for C2H6.
+
+## Development notes
+### Design goals
+
+Alkane update process helps us to separate concerns:
+
+ - Infrastructure as code configure the server and provisions Alkane config
+ - Both Salt and Jenkins can when needed request a site update
+ - CD prepare the build (npm build for example) and ask Alkane to deploy it
+
+At Nasqueron, we wanted to get a more standardized way to work and ensure
+the site ends in the same state if deployed through Salt or Jenkins CD.
+
+Currently, Alkane isn't responsible to rollback the deployment. To rollback,
+you can revert the commit, and trigger CD for it and Alkane will pick it
+like usual.
+
+Alkane can help you to run Canary tests. To do so, you can run `alkane update`
+on a server, or a small range of servers, observe traffic, and take the
+decision to deploy everywhere from responses from those.
+
+### License
+
+**Alkane** is distributed under BSD-2-Clause.
+
+### Contribute
+
+Alkane is written in Rust using:
+
+ - Rocket and Limiting Factor for the HTTP API
+ - Clap to parse arguments
+
+Issues can be tracked at https://devcentral.nasqueron.org/
+
+To send your change, you can follow this guide:
+https://agora.nasqueron.org/How_to_contribute_code
diff --git a/src/actions.rs b/src/actions.rs
new file mode 100644
--- /dev/null
+++ b/src/actions.rs
@@ -0,0 +1,91 @@
+/* -------------------------------------------------------------
+ Alkane :: Actions
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+use crate::command::ServerArgs;
+use crate::config::AlkaneConfig;
+use crate::db::Database;
+use crate::deploy::AlkaneDeployError;
+use crate::deploy::DeployError;
+use crate::runner::RecipeStatus;
+use crate::runner::store::RecipesStore;
+use crate::server::kernel::run;
+
+/* -------------------------------------------------------------
+ Actions only available in CLI
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+pub fn serve (args: ServerArgs, config: AlkaneConfig) {
+ run(config, &args.mounting_point);
+}
+
+/* -------------------------------------------------------------
+ Actions available both for CLI and HTTP
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+pub fn initialize (site_name: &str, config: &AlkaneConfig) -> Result<RecipeStatus, DeployError> {
+ let db = Database::from_config(config)
+ .ok_or_else(|| {
+ let error = AlkaneDeployError::new("Can't initialize database");
+ DeployError::Alkane(error)
+ })?;
+
+ let recipes = RecipesStore::from_config(config)
+ .ok_or_else(|| {
+ let error = AlkaneDeployError::new("Can't initialize recipes store");
+ DeployError::Alkane(error)
+ })?;
+
+ let site = config.get_site(site_name).expect("Can't get site path.");
+ let status = recipes.run_recipe(&site, "init");
+ db.set_initialized(&site.name);
+
+ Ok(status)
+}
+
+pub fn update (site_name: &str, config: &AlkaneConfig) -> Result<RecipeStatus, DeployError> {
+ let recipes = RecipesStore::from_config(config)
+ .ok_or_else(|| {
+ let error = AlkaneDeployError::new("Can't initialize recipes store");
+ DeployError::Alkane(error)
+ })?;
+
+ let site = config.get_site(site_name).expect("Can't get site path.");
+ let status = recipes.run_recipe(&site, "update");
+ Ok(status)
+}
+
+pub fn deploy (site_name: &str, config: &AlkaneConfig) -> Result<RecipeStatus, DeployError> {
+ if is_present(site_name, config) {
+ update(site_name, config)
+ } else {
+ initialize(site_name, config)
+ }
+}
+
+pub fn is_present (site_name: &str, config: &AlkaneConfig) -> bool {
+ match Database::from_config(&config) {
+ None => false,
+ Some(db) => db.is_initialized(site_name),
+ }
+}
+
+/* -------------------------------------------------------------
+ Tests
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ pub fn test_is_present() {
+ let config = AlkaneConfig::load().unwrap();
+
+ assert_eq!(true, is_present("foo.acme.tld", &config));
+ assert_eq!(false, is_present("notexisting.acme.tld", &config));
+ }
+}
diff --git a/src/command.rs b/src/command.rs
new file mode 100644
--- /dev/null
+++ b/src/command.rs
@@ -0,0 +1,94 @@
+/* -------------------------------------------------------------
+ Alkane :: Commands
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+use clap::{Args, Parser};
+
+/* -------------------------------------------------------------
+ Main command
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[derive(Debug, Parser)]
+#[command(name = "alkane")]
+#[clap(author="Nasqueron project", version, about="Manage Alkane PaaS", long_about=None)]
+pub enum AlkaneCommand {
+ /// Launch an HTTP server to expose the Alkane REST API
+ Server(ServerArgs),
+
+ /// Initialize a site
+ #[command(arg_required_else_help = true)]
+ Init(DeployArgs),
+
+ /// Update site assets to latest version
+ #[command(arg_required_else_help = true)]
+ Update(DeployArgs),
+
+ /// Initialize of if already initialized update a site
+ #[command(arg_required_else_help = true)]
+ Deploy(DeployArgs),
+
+ /// Determine if a domain is served on our PaaS
+ #[command(name = "is-present", arg_required_else_help = true)]
+ IsPresent(IsPresentArgs)
+}
+
+/* -------------------------------------------------------------
+ Subcommands arguments
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[derive(Debug, Args)]
+pub struct ServerArgs {
+ #[arg(long, default_value = "/")]
+ pub mounting_point: String,
+}
+
+#[derive(Debug, Args)]
+pub struct DeployArgs {
+ /// The name of the site to deploy, using sub.domain.tld format
+ pub site_name: String,
+
+ /// The artifact to deploy. Allows CD to give metadata or an URL to download last artifact
+ pub artifact: Option<String>,
+}
+
+#[derive(Debug, Args)]
+pub struct IsPresentArgs {
+ #[arg(short, long, default_value_t = false)]
+ pub quiet: bool,
+
+ /// The name of the site to deploy, using sub.domain.tld format
+ pub site_name: String,
+}
+
+/* -------------------------------------------------------------
+ Helper methods
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+pub trait ToStatusCode {
+ fn to_status_code(&self) -> i32;
+}
+
+impl ToStatusCode for bool {
+ fn to_status_code (&self) -> i32 {
+ if *self {
+ 0
+ } else {
+ 1
+ }
+ }
+}
+
+impl<T> ToStatusCode for Option<T> {
+ fn to_status_code (&self) -> i32 {
+ self.is_some().to_status_code()
+ }
+}
+
+impl<T, E> ToStatusCode for Result<T, E> {
+ fn to_status_code (&self) -> i32 {
+ self.is_ok().to_status_code()
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,197 @@
+/* -------------------------------------------------------------
+ Alkane :: Configuration
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+use std::collections::HashMap;
+use std::fs::File;
+use std::path::{MAIN_SEPARATOR_STR, Path};
+
+use lazy_static::lazy_static;
+use log::info;
+use serde::Deserialize;
+use serde_yaml;
+use crate::runner::site::Site;
+
+use crate::services::tld::extract_domain_parts;
+
+/* -------------------------------------------------------------
+ Constants:
+ - ROOTS: default path for root directories
+ - CONFIG_PATHS: paths where to find the configuration file
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+lazy_static! {
+ static ref ROOTS: HashMap<&'static str, &'static str> = {
+ let mut map = HashMap::new();
+ map.insert("db", "/var/db/alkane");
+ map.insert("sites", "/var/wwwroot");
+ map.insert("recipes", "/usr/local/libexec/alkane");
+ map
+ };
+
+ static ref CONFIG_PATHS: Vec<&'static str> = vec![
+ ".alkane.conf",
+ "/etc/alkane.conf",
+ "/usr/local/etc/alkane.conf",
+ ];
+}
+
+/* -------------------------------------------------------------
+ AlkaneConfig is the deserialized representation of
+ the Alkane configuration file alkane.conf
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[derive(Debug, Deserialize)]
+pub struct AlkaneConfig {
+ /// The paths to the root directories used by Alkane
+ roots: HashMap<String, String>,
+
+ /// The template for a site directory
+ site_directory_template: String,
+}
+
+#[derive(Debug)]
+pub enum AlkaneConfigError {
+ IO(std::io::Error),
+ YAML(serde_yaml::Error),
+ FileNotFound,
+}
+
+impl AlkaneConfig {
+ pub fn load() -> Result<Self, AlkaneConfigError> {
+ match Self::find() {
+ None => Err(AlkaneConfigError::FileNotFound),
+ Some(path) => {
+ info!("Configuration file found: {}", &path);
+
+ let file = File::open(&path)
+ .map_err(AlkaneConfigError::IO)?;
+
+ serde_yaml::from_reader(file)
+ .map_err(AlkaneConfigError::YAML)
+ }
+ }
+ }
+
+ fn find() -> Option<String> {
+ CONFIG_PATHS
+ .iter()
+ .filter(|&path| Path::new(path).exists())
+ .map(|&path| String::from(path))
+ .next()
+ }
+
+ pub fn get_root(&self, key: &str) -> Option<String> {
+ if self.roots.contains_key(key) {
+ self.roots.get(key)
+ .map(|s| String::from(s))
+ } else {
+ ROOTS.get(key)
+ .map(|s| String::from(*s))
+ }
+ }
+
+ pub fn get_site(&self, site_name: &str) -> Option<Site> {
+ self.get_site_path(site_name)
+ .map(|path| Site {
+ name: site_name.to_string(),
+ path,
+ })
+ }
+
+ pub fn get_site_path(&self, site_name: &str) -> Option<String> {
+ let root = self.get_root("sites")?;
+ let root = root.replace("/", MAIN_SEPARATOR_STR);
+
+ let subdir = self.resolve_site_subdir(site_name)?;
+
+ Path::new(&root)
+ .join(&subdir)
+ .to_str()
+ .map(|path| String::from(path))
+ }
+
+ fn resolve_site_subdir(&self, site_name: &str) -> Option<String> {
+ let subdir = self.site_directory_template.clone()
+ .replace("/", MAIN_SEPARATOR_STR)
+ .replace("%fqdn%", site_name);
+
+ if contains_domain_parts_variables(&subdir) {
+ extract_domain_parts(site_name)
+ .map(|parts| {
+ subdir
+ .replace("%subdomain%", &parts.0)
+ .replace("%domain%", &parts.1)
+ .replace("%tld%", &parts.2)
+ })
+ } else {
+ Some(subdir)
+ }
+ }
+}
+
+/* -------------------------------------------------------------
+ Helper methods to extract domain name parts
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+fn contains_domain_parts_variables<S> (site_name: S) -> bool
+ where S: AsRef<str>
+{
+ let site_name = site_name.as_ref();
+
+ site_name.contains("%subdomain%") ||
+ site_name.contains("%domain%") ||
+ site_name.contains("%tld%")
+}
+
+/* -------------------------------------------------------------
+ Tests
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ pub fn test_load() {
+ let config = AlkaneConfig::load();
+
+ assert!(config.is_ok());
+ let config = config.unwrap();
+
+ assert_eq!(&config.site_directory_template, "%domain%.%tld%/%subdomain%");
+ }
+
+ #[test]
+ pub fn test_root() {
+ let config = AlkaneConfig::load().unwrap();
+
+ assert_eq!(None, config.get_root("notexisting"));
+ assert_eq!(Some(String::from("tests/data/db")), config.get_root("db"));
+ }
+
+ #[test]
+ pub fn test_get_site_path() {
+ let config = AlkaneConfig::load().unwrap();
+
+ let expected = Path::new("tests")
+ .join("data")
+ .join("wwwroot")
+ .join("example.org")
+ .join("foo");
+ let expected = String::from(expected.to_str().unwrap());
+ let expected = Some(expected);
+
+ assert_eq!(expected, config.get_site_path("foo.example.org"));
+ }
+
+ #[test]
+ pub fn test_contains_domain_parts_variables() {
+ assert_eq!(true, contains_domain_parts_variables("%domain%/%subdomain%"));
+ assert_eq!(false, contains_domain_parts_variables("%fqdn%"));
+ assert_eq!(false, contains_domain_parts_variables(""));
+ }
+}
diff --git a/src/db.rs b/src/db.rs
new file mode 100644
--- /dev/null
+++ b/src/db.rs
@@ -0,0 +1,95 @@
+/* -------------------------------------------------------------
+ Alkane :: Database
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+use std::fs::OpenOptions;
+use std::io::Error as IOError;
+use std::path::{Path, PathBuf};
+use log::warn;
+
+use crate::config::AlkaneConfig;
+
+pub struct Database {
+ root: String,
+}
+
+impl Database {
+ pub fn new<S> (root: S) -> Self
+ where S: AsRef<str> {
+ Self {
+ root: root.as_ref().to_string(),
+ }
+ }
+
+ pub fn from_config (config: &AlkaneConfig) -> Option<Self> {
+ config.get_root("db")
+ .map(Self::new)
+ }
+
+ pub fn is_initialized (&self, site_name: &str) -> bool {
+ self.get_initialized_path(site_name)
+ .exists()
+ }
+
+ pub fn set_initialized(&self, site_name: &str) -> bool {
+ let path = self.get_initialized_path(site_name);
+
+ if !path.exists() {
+ match touch(&path) {
+ Ok(_) => true,
+ Err(error) => {
+ warn!(
+ "Can't mark site {} as initialized: {:?}",
+ site_name, error
+ );
+
+ false
+ }
+ }
+ } else {
+ true
+ }
+ }
+
+ fn get_initialized_path(&self, site_name: &str) -> PathBuf {
+ Path::new(&self.root)
+ .join("initialized")
+ .join(site_name)
+ }
+}
+
+/// Creates an empty file, similar to the touch command
+/// Ignores existing files.
+fn touch (path: &PathBuf) -> Result<(), IOError> {
+ let mut options = OpenOptions::new();
+ options.create(true)
+ .write(true);
+
+ options
+ .open(path)
+ .map(|_| ())
+}
+
+/* -------------------------------------------------------------
+ Tests
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+ use super::*;
+
+ #[test]
+ pub fn test_touch() {
+ let path = Path::new("tmp-touch.empty");
+ assert!(!path.exists(), "Temporary file tmp-touch.empty shouldn't exist when test starts");
+
+ touch(&path.to_path_buf()).expect("File can't be created");
+ assert!(path.exists(), "Function touch returned Ok but temporary file does NOT exist.");
+
+ fs::remove_file(path).expect("Can't remove file after having created it.")
+ }
+}
diff --git a/src/deploy.rs b/src/deploy.rs
new file mode 100644
--- /dev/null
+++ b/src/deploy.rs
@@ -0,0 +1,45 @@
+/* -------------------------------------------------------------
+ Alkane :: Deploy
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+use std::error::Error;
+use std::fmt::{Display, Formatter};
+
+/* -------------------------------------------------------------
+ Errors during our own workflow deployment
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+/// Represents an error during the workflow to run a deployment
+#[derive(Debug)]
+pub struct AlkaneDeployError {
+ pub message: String,
+}
+
+impl Display for AlkaneDeployError {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "Alkane deploy error: {}", self.message)
+ }
+}
+
+impl Error for AlkaneDeployError {
+}
+
+impl AlkaneDeployError {
+ pub fn new<S> (message: S) -> Self where S: AsRef<str> {
+ Self {
+ message: message.as_ref().to_string(),
+ }
+ }
+}
+
+/* -------------------------------------------------------------
+ Errors that can occur during a deployment
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[derive(Debug)]
+pub enum DeployError {
+ Alkane(AlkaneDeployError),
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,98 @@
+/* -------------------------------------------------------------
+ Alkane
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ Description: Manage nginx and php-fpm Alkane PaaS
+ ------------------------------------------------------------- */
+
+#![feature(decl_macro)]
+
+use std::process::exit;
+
+use clap::Parser;
+
+use crate::actions::*;
+use crate::command::{AlkaneCommand, ToStatusCode};
+use crate::config::AlkaneConfig;
+use crate::deploy::DeployError;
+use crate::runner::RecipeStatus;
+
+/* -------------------------------------------------------------
+ Modules
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+mod actions;
+mod command;
+mod config;
+mod db;
+mod runner;
+mod server;
+mod services;
+mod deploy;
+
+/* -------------------------------------------------------------
+ Application entry point
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+fn main() {
+ env_logger::init();
+
+ let command = AlkaneCommand::parse(); // Will exit if argument is missing or --help/--version provided.
+ let config = match AlkaneConfig::load() {
+ Ok(config) => config,
+ Err(error) => {
+ eprintln!("Can't load configuration: {:?}", error);
+ exit(4);
+ }
+ };
+
+ match command {
+ AlkaneCommand::Server(args) => {
+ serve(args, config);
+ }
+
+ AlkaneCommand::Update(args) => {
+ let result = update(&args.site_name, &config);
+ deploy_exit(result);
+ }
+
+ AlkaneCommand::Init(args) => {
+ let result = initialize(&args.site_name, &config);
+ deploy_exit(result);
+ }
+
+ AlkaneCommand::Deploy(args) => {
+ let result = deploy(&args.site_name, &config);
+ deploy_exit(result);
+ }
+
+ AlkaneCommand::IsPresent(args) => {
+ let is_present = is_present(&args.site_name, &config);
+
+ if !args.quiet {
+ if is_present {
+ let path = config.get_site_path(&args.site_name).unwrap();
+ println!("{}", path);
+ } else {
+ eprintln!("Site is absent.")
+ }
+ }
+
+ exit(is_present.to_status_code());
+ }
+ }
+}
+
+fn deploy_exit (result: Result<RecipeStatus, DeployError>) {
+ match result {
+ Ok(status) => {
+ exit(status.to_status_code())
+ }
+
+ Err(error) => {
+ eprintln!("Can't deploy: {:?}", error);
+ exit(16);
+ }
+ }
+}
diff --git a/src/runner/mod.rs b/src/runner/mod.rs
new file mode 100644
--- /dev/null
+++ b/src/runner/mod.rs
@@ -0,0 +1,110 @@
+/* -------------------------------------------------------------
+ Alkane :: Runner
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ Description: Run a recipe to initialize or update a site
+ The recipe can take full responsibility for
+ the build or delegate to a CD system. The
+ CD system can then ping us back to go on.
+ ------------------------------------------------------------- */
+
+use std::ffi::OsStr;
+use std::fmt::{Debug, Display};
+use std::process::{Command, Stdio};
+
+use log::{error, info, warn};
+
+/* -------------------------------------------------------------
+ Modules
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+pub mod site;
+pub mod store;
+
+/* -------------------------------------------------------------
+ Exit status of a recipe.
+
+ The executable called to build the site should use
+ those exit code inspired by the Nagios one.
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+pub enum RecipeStatus {
+ Success,
+ Warning,
+ Error,
+ Unknown,
+}
+
+impl RecipeStatus {
+ pub fn from_status_code (code: i32) -> Self {
+ match code {
+ 0 => RecipeStatus::Success,
+ 1 => RecipeStatus::Warning,
+ 2 => RecipeStatus::Error,
+ _ => RecipeStatus::Unknown,
+ }
+ }
+
+ pub fn to_status_code (&self) -> i32 {
+ match self {
+ RecipeStatus::Success => 0,
+ RecipeStatus::Warning => 1,
+ RecipeStatus::Error => 2,
+ RecipeStatus::Unknown => 3,
+ }
+ }
+}
+
+/* -------------------------------------------------------------
+ Run an executable, returns the recipe status
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+pub fn run<E, I, S> (command: S, args: I, environment: E) -> RecipeStatus
+where E: IntoIterator<Item = (S, S)>,
+ I: IntoIterator<Item = S> + Debug,
+ S: AsRef<OsStr> + Display
+{
+ info!("Running command {} with args {:?}", command, args);
+
+ let result = Command::new(command)
+ .args(args)
+ .envs(environment)
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output();
+
+ match result {
+ Ok(process_output) => {
+ let stdout = read_bytes(&process_output.stdout);
+ let stderr = read_bytes(&process_output.stdout);
+
+ if !stdout.is_empty() {
+ info!("Channel stdout: {}", stdout);
+ }
+
+ if !stderr.is_empty() {
+ warn!("Channel stderr: {}", stdout);
+ }
+
+ match process_output.status.code() {
+ None => {
+ warn!("Process terminated by signal.");
+
+ RecipeStatus::Unknown
+ }
+ Some(code) => RecipeStatus::from_status_code(code),
+ }
+ }
+
+ Err(error) => {
+ error!("Process can't spawn: {:?}", error);
+
+ RecipeStatus::Error
+ },
+ }
+}
+
+fn read_bytes(bytes: &Vec<u8>) -> String {
+ String::from_utf8_lossy(bytes).to_string()
+}
diff --git a/src/runner/site.rs b/src/runner/site.rs
new file mode 100644
--- /dev/null
+++ b/src/runner/site.rs
@@ -0,0 +1,12 @@
+/* -------------------------------------------------------------
+ Alkane :: Runner :: Site
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ Description: Represent the website metadata
+ ------------------------------------------------------------- */
+
+pub struct Site {
+ pub name: String,
+ pub path: String,
+}
diff --git a/src/runner/store.rs b/src/runner/store.rs
new file mode 100644
--- /dev/null
+++ b/src/runner/store.rs
@@ -0,0 +1,98 @@
+/* -------------------------------------------------------------
+ Alkane :: Runner :: Recipes store
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+use std::collections::HashMap;
+use std::path::Path;
+use crate::config::AlkaneConfig;
+use crate::runner::RecipeStatus;
+use crate::runner::run;
+use crate::runner::site::Site;
+
+pub struct RecipesStore {
+ root: String,
+}
+
+impl RecipesStore {
+ pub fn new<S>(root: S) -> Self
+ where S: AsRef<str> {
+ Self {
+ root: root.as_ref().to_string(),
+ }
+ }
+
+ pub fn from_config(config: &AlkaneConfig) -> Option<Self> {
+ config.get_root("recipes")
+ .map(Self::new)
+ }
+
+ fn get_recipe_path(&self, site_name: &str, action: &str) -> String {
+ Path::new(&self.root)
+ .join(site_name)
+ .join(action)
+ .to_str()
+ .expect("Can't read recipe path as UTF-8")
+ .to_string()
+ }
+
+ pub fn run_recipe(&self, site: &Site, action: &str) -> RecipeStatus {
+ let command = self.get_recipe_path(&site.name, action);
+ let environment = self.get_environment(&site);
+
+ run(command, Vec::new(), environment)
+ }
+
+ fn get_environment(&self, site: &Site) -> HashMap<String, String> {
+ let mut map = HashMap::new();
+
+ map.insert("ALKANE_RECIPES_PATH".to_string(), self.root.clone());
+ map.insert("ALKANE_SITE_NAME".to_string(), site.name.clone());
+ map.insert("ALKANE_SITE_PATH".to_string(), site.path.clone());
+
+ map
+ }
+}
+
+/* -------------------------------------------------------------
+ Tests
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[cfg(test)]
+mod tests {
+ use std::path::MAIN_SEPARATOR_STR;
+ use super::*;
+
+ #[test]
+ pub fn test_get_recipe_path() {
+ let expected = "tests/data/recipes/foo.acme.tld/update"
+ .replace("/", MAIN_SEPARATOR_STR);
+
+ let test_store_path = "tests/data/recipes"
+ .replace("/", MAIN_SEPARATOR_STR);
+ let store = RecipesStore::new(&test_store_path);
+
+ assert_eq!(expected, store.get_recipe_path("foo.acme.tld", "update"));
+ }
+
+ #[test]
+ pub fn test_get_environment() {
+ let mut expected = HashMap::new();
+ expected.insert("ALKANE_RECIPES_PATH".to_string(), "tests/data/recipes".replace("/", MAIN_SEPARATOR_STR));
+ expected.insert("ALKANE_SITE_NAME".to_string(), "foo.acme.tld".to_string());
+ expected.insert("ALKANE_SITE_PATH".to_string(), "tests/data/wwwroot/acme.tld/foo".replace("/", MAIN_SEPARATOR_STR));
+
+ let test_store_path = "tests/data/recipes"
+ .replace("/", MAIN_SEPARATOR_STR);
+ let store = RecipesStore::new(&test_store_path);
+
+ let site = Site {
+ name: "foo.acme.tld".to_string(),
+ path: "tests/data/wwwroot/acme.tld/foo".replace("/", MAIN_SEPARATOR_STR),
+ };
+
+ assert_eq!(expected, store.get_environment(&site));
+ }
+}
diff --git a/src/server/kernel.rs b/src/server/kernel.rs
new file mode 100644
--- /dev/null
+++ b/src/server/kernel.rs
@@ -0,0 +1,34 @@
+/* -------------------------------------------------------------
+ Alkane :: Server :: Kernel
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+use rocket::ignite;
+use rocket_codegen::routes;
+
+use crate::config::AlkaneConfig;
+use crate::server::requests::*;
+
+/* -------------------------------------------------------------
+ Server entry point
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+pub fn run (config: AlkaneConfig, mounting_point: &str) {
+ let routes = routes![
+ // Monitoring
+ status,
+
+ // Alkane API
+ init,
+ update,
+ deploy,
+ is_present,
+ ];
+
+ ignite()
+ .manage(config)
+ .mount(mounting_point, routes)
+ .launch();
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
new file mode 100644
--- /dev/null
+++ b/src/server/mod.rs
@@ -0,0 +1,13 @@
+/* -------------------------------------------------------------
+ Alkane :: Server
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+/* -------------------------------------------------------------
+ Modules
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+pub mod kernel;
+pub mod requests;
diff --git a/src/server/requests.rs b/src/server/requests.rs
new file mode 100644
--- /dev/null
+++ b/src/server/requests.rs
@@ -0,0 +1,75 @@
+/* -------------------------------------------------------------
+ Alkane :: Server :: Requests
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+use limiting_factor::api::replies::{ApiJsonResponse, ApiResponse};
+use log::{info, warn};
+use rocket::State;
+use rocket_codegen::get;
+
+use crate::actions;
+use crate::config::AlkaneConfig;
+
+/* -------------------------------------------------------------
+ Monitoring
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[get("/status")]
+pub fn status() -> &'static str {
+ "ALIVE"
+}
+
+/* -------------------------------------------------------------
+ Alkane requests
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[get("/is_present/<site_name>")]
+pub fn is_present(site_name: String, config: State<AlkaneConfig>) -> ApiJsonResponse<bool> {
+ actions::is_present(&site_name, &config)
+ .into_json_response()
+}
+
+#[get("/init/<site_name>")]
+pub fn init(site_name: String, config: State<AlkaneConfig>) -> ApiJsonResponse<bool> {
+ info!("Deploying {}", &site_name);
+
+ match actions::initialize(&site_name, &config) {
+ Ok(_) => true.into_json_response(),
+ Err(error) => {
+ warn!("Deployment error: {:?}", error);
+
+ false.into_json_response()
+ },
+ }
+}
+
+#[get("/update/<site_name>")]
+pub fn update(site_name: String, config: State<AlkaneConfig>) -> ApiJsonResponse<bool> {
+ info!("Deploying {}", &site_name);
+
+ match actions::update(&site_name, &config) {
+ Ok(_) => true.into_json_response(),
+ Err(error) => {
+ warn!("Deployment error: {:?}", error);
+
+ false.into_json_response()
+ },
+ }
+}
+
+#[get("/deploy/<site_name>")]
+pub fn deploy(site_name: String, config: State<AlkaneConfig>) -> ApiJsonResponse<bool> {
+ info!("Deploying {}", &site_name);
+
+ match actions::deploy(&site_name, &config) {
+ Ok(_) => true.into_json_response(),
+ Err(error) => {
+ warn!("Deployment error: {:?}", error);
+
+ false.into_json_response()
+ },
+ }
+}
diff --git a/src/services/mod.rs b/src/services/mod.rs
new file mode 100644
--- /dev/null
+++ b/src/services/mod.rs
@@ -0,0 +1,8 @@
+/* -------------------------------------------------------------
+ Alkane :: Services
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ ------------------------------------------------------------- */
+
+pub mod tld;
diff --git a/src/services/tld.rs b/src/services/tld.rs
new file mode 100644
--- /dev/null
+++ b/src/services/tld.rs
@@ -0,0 +1,144 @@
+/* -------------------------------------------------------------
+ Alkane :: Services :: TLD
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ Project: Nasqueron
+ License: BSD-2-Clause
+ Description: Parse public suffix list to identify TLD
+ ------------------------------------------------------------- */
+
+use lazy_static::lazy_static;
+
+/* -------------------------------------------------------------
+ Public suffix list
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+lazy_static! {
+ static ref PUBLIC_SUFFIX_LIST: &'static str = include_str!(
+ "../data/public_suffix_list.dat"
+ );
+
+ static ref PUBLIC_SUFFIXES: Vec<&'static str> = {
+ PUBLIC_SUFFIX_LIST
+ .lines()
+ .map(|line| line.trim())
+ .filter(|line| !line.is_empty() && !line.starts_with("//"))
+ .collect()
+ };
+}
+
+/* -------------------------------------------------------------
+ Helper methods
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+pub fn get_tld<S>(fqdn: S) -> String where S: AsRef<str> {
+ let fqdn = fqdn.as_ref();
+
+ let parts: Vec<&str> = fqdn.split(".").collect();
+ let n = parts.len();
+
+ // Seek a.b.c.d, b.c.d, c.d, d until we find a known suffix
+ for i in 0..n {
+ let candidate = &parts[i..].join(".");
+
+ if PUBLIC_SUFFIXES.contains(&candidate.as_str()) {
+ return String::from(candidate);
+ }
+ }
+
+ // If the TLD isn't declared in the public suffix list,
+ // heuristic suggests the last part is a private TLD.
+ match parts.last() {
+ None => String::new(),
+ Some(part) => String::from(*part),
+ }
+}
+
+pub fn extract_domain_parts<S>(fqdn: S) -> Option<(String, String, String)>
+ where S: AsRef<str>
+{
+ let fqdn = fqdn.as_ref();
+
+ let tld = get_tld(fqdn);
+
+ let tld_parts_len = count_chars(&tld, '.') + 1;
+ let parts: Vec<_> = fqdn.split(".").collect();
+
+ let n = parts.len();
+ if n >= tld_parts_len + 2 {
+ // We've got a winner
+ let bound = n - tld_parts_len - 1;
+ Some((
+ parts[0..bound].join("."),
+ parts[bound].to_string(),
+ tld,
+ ))
+ } else {
+ None
+ }
+}
+
+fn count_chars<S>(expression: S, pattern: char) -> usize where S: AsRef<str> {
+ let expression = expression.as_ref();
+
+ expression
+ .chars()
+ .filter(|&c| c == pattern)
+ .count()
+}
+
+/* -------------------------------------------------------------
+ Unit tests
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_get_tld() {
+ assert_eq!("eu.org", get_tld("foo.acme.eu.org"))
+ }
+
+ fn provide_extracted_domains () -> impl Iterator<
+ Item = (&'static str, Option<(String, String, String)>)
+ > {
+ vec![
+ // Regular TLD cases
+ ("foo.acme.org", Some((
+ String::from("foo"),
+ String::from("acme"),
+ String::from("org"),
+ ))),
+ ("acme.org", None),
+ ("org", None),
+ ("", None),
+
+ // Composite TLD from the public suffix list
+ ("foo.acme.co.uk", Some((
+ String::from("foo"),
+ String::from("acme"),
+ String::from("co.uk"),
+ ))),
+
+ ("foo.acme.eu.org", Some((
+ String::from("foo"),
+ String::from("acme"),
+ String::from("eu.org"),
+ ))),
+
+ // Longer subdomain
+ ("bar.foo.acme.eu.org", Some((
+ String::from("bar.foo"),
+ String::from("acme"),
+ String::from("eu.org"),
+ ))),
+ ].into_iter()
+ }
+
+ #[test]
+ fn test_extract_domain_parts() {
+ for (fqdn, expected) in provide_extracted_domains() {
+ assert_eq!(expected, extract_domain_parts(fqdn), "Test with {}", fqdn);
+ }
+ }
+}
diff --git a/tests/data/db/initialized/foo.acme.tld b/tests/data/db/initialized/foo.acme.tld
new file mode 100644
diff --git a/tests/data/recipes/foo.acme.tld/init b/tests/data/recipes/foo.acme.tld/init
new file mode 100644
--- /dev/null
+++ b/tests/data/recipes/foo.acme.tld/init
@@ -0,0 +1,10 @@
+#!/bin/sh
+
+# -------------------------------------------------------------
+# Alkane :: Example recipe to build a site
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# License: Trivial work, not eligible to copyright
+# -------------------------------------------------------------
+
+echo "Initialized ... $(date)" > "$ALKANE_SITE_PATH"/hello
diff --git a/tests/data/recipes/foo.acme.tld/update b/tests/data/recipes/foo.acme.tld/update
new file mode 100644
--- /dev/null
+++ b/tests/data/recipes/foo.acme.tld/update
@@ -0,0 +1,10 @@
+#!/bin/sh
+
+# -------------------------------------------------------------
+# Alkane :: Example recipe to build a site
+# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+# Project: Nasqueron
+# License: Trivial work, not eligible to copyright
+# -------------------------------------------------------------
+
+echo "Updated ....... $(date)" >> "$ALKANE_SITE_PATH"/hello

File Metadata

Mime Type
text/plain
Expires
Thu, Feb 27, 21:39 (2 h, 3 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2447868
Default Alt Text
D2983.id7611.diff (42 KB)

Event Timeline