Page Menu
Home
DevCentral
Search
Configure Global Search
Log In
Files
F3777979
D2983.id7613.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
47 KB
Referenced Files
None
Subscribers
None
D2983.id7613.diff
View Options
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,3 @@
+/target
+/src/data/public_suffix_list.dat
+tests/data/wwwroot/acme.tld/foo/hello
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/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,24 @@
+Copyright 2023 Sébastien Santoro, Nasqueron
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
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,153 @@
+# 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.
+
+## 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.
+
+When triggered from HTTP API by an external service like CD,
+the raw POST content is available to the instructions script
+under ALKANE_SITE_CONTEXT environment variable.
+
+### 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 the alkane C2H6.
+
+## Configuration
+
+### Configuration file
+
+The configuration is written in YAML and is seeked at:
+
+- .alkane.conf
+- /usr/local/etc/alkane.conf
+- /etc/alkane.conf
+
+This repository provides an example `.alkane.conf` both for reference
+and for testing suite: path matches the test/data folder.
+
+### Recipes scripts
+
+Each site should have two scripts in /usr/local/libexec/alkane/<site name>,
+or any other directory set as `roots.recipes` in the configuration:
+
+ - init: called by `alkane init`
+ - update: called by `alkane update`
+
+Several environment variables are available to those scripts:
+
+| Variable | Description |
+|---------------------|--------------------------------------------|
+| ALKANE_RECIPES_PATH | The *root* path to the recipes |
+| ALKANE_SITE_NAME | The site name, for example the FQDN |
+| ALKANE_SITE_PATH | The full path to the site content |
+| ALKANE_SITE_CONTEXT | Arbitrary context sent to HTTP API, if set |
+
+The following security consideration should be exercised:
+
+ - if you symlink or set the recipes root to a file in the site repository,
+ you trust the site to run arbitrary code as the alkane user
+ - don't trust blindly context information, it can be false or malformed,
+ and is a vector for attack if your CD is compromised
+ - if you run the server HTTP API, listen only to private IP address,
+ or use a firewall to block the port, it shouldn't be reachable publicly
+
+## 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
+
+(c) 2023, Sébastien Santoro, Nasqueron, some rights reserved.
+Released under BSD-2-Clause license. See [license](LICENSE).
+
+### Contribute
+
+Alkane is written in Rust using:
+
+ - Rocket and Limiting Factor for the HTTP API
+ - Clap to parse arguments
+ - serde_yaml to deserialize configuration files
+
+Run `make` to build the first time: it will download public suffix list
+and then run `cargo build`. Afterwards, you can directly use cargo.
+
+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/rustfmt.toml b/rustfmt.toml
new file mode 100644
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,2 @@
+normalize_comments = true
+short_array_element_width_threshold = 8
diff --git a/src/actions.rs b/src/actions.rs
new file mode 100644
--- /dev/null
+++ b/src/actions.rs
@@ -0,0 +1,104 @@
+// -------------------------------------------------------------
+// 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::store::RecipesStore;
+use crate::runner::RecipeStatus;
+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,
+ context: Option<String>,
+ 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, context)
+ .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,
+ context: Option<String>,
+ 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, context)
+ .expect("Can't get site path.");
+ let status = recipes.run_recipe(&site, "update");
+ Ok(status)
+}
+
+pub fn deploy(
+ site_name: &str,
+ context: Option<String>,
+ config: &AlkaneConfig,
+) -> Result<RecipeStatus, DeployError> {
+ if is_present(site_name, config) {
+ update(site_name, context, config)
+ } else {
+ initialize(site_name, context, 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,200 @@
+// -------------------------------------------------------------
+// Alkane :: Configuration
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// Project: Nasqueron
+// License: BSD-2-Clause
+// -------------------------------------------------------------
+
+use std::collections::HashMap;
+use std::fs::File;
+use std::path::{Path, MAIN_SEPARATOR_STR};
+
+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, context: Option<String>) -> Option<Site> {
+ self.get_site_path(site_name).map(|path| Site {
+ name: site_name.to_string(),
+ context,
+ 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,47 @@
+// -------------------------------------------------------------
+// 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,96 @@
+// -------------------------------------------------------------
+// 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 deploy;
+mod runner;
+mod server;
+mod services;
+
+// -------------------------------------------------------------
+// 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, None, &config);
+ deploy_exit(result);
+ }
+
+ AlkaneCommand::Init(args) => {
+ let result = initialize(&args.site_name, None, &config);
+ deploy_exit(result);
+ }
+
+ AlkaneCommand::Deploy(args) => {
+ let result = deploy(&args.site_name, None, &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,108 @@
+// -------------------------------------------------------------
+// Alkane :: Runner
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// Project: Nasqueron
+// License: BSD-2-Clause
+// Description: Run a recipe to initialize or update a site
+// -------------------------------------------------------------
+
+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,15 @@
+// -------------------------------------------------------------
+// Alkane :: Runner :: Site
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// Project: Nasqueron
+// License: BSD-2-Clause
+// Description: Represent the website metadata
+// -------------------------------------------------------------
+
+pub struct Site {
+ pub name: String,
+ pub path: String,
+
+ /// The build context, any metadata relevant to the build
+ pub context: Option<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,123 @@
+// -------------------------------------------------------------
+// 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::run;
+use crate::runner::site::Site;
+use crate::runner::RecipeStatus;
+
+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());
+
+ if let Some(context) = &site.context {
+ map.insert("ALKANE_SITE_CONTEXT".to_string(), context.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),
+ context: None,
+ };
+
+ assert_eq!(expected, store.get_environment(&site));
+ }
+
+ #[test]
+ pub fn test_get_environment_with_context() {
+ let site = Site {
+ name: "foo.acme.tld".to_string(),
+ path: "/path/to/site".to_string(),
+ context: Some("CH3-CH3".to_string()),
+ };
+
+ let environment = RecipesStore::new("/path/to/recipes").get_environment(&site);
+
+ assert!(environment.contains_key("ALKANE_SITE_CONTEXT"));
+ assert_eq!("CH3-CH3", environment["ALKANE_SITE_CONTEXT"])
+ }
+}
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,33 @@
+// -------------------------------------------------------------
+// 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,107 @@
+// -------------------------------------------------------------
+// Alkane :: Server :: Requests
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// Project: Nasqueron
+// License: BSD-2-Clause
+// -------------------------------------------------------------
+
+use limiting_factor::api::replies::{ApiJsonResponse, ApiResponse};
+use log::{debug, info, warn};
+use rocket::State;
+use rocket_codegen::{get, post};
+
+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()
+}
+
+#[post("/init/<site_name>", data = "<context>")]
+pub fn init(
+ site_name: String,
+ context: String,
+ config: State<AlkaneConfig>,
+) -> ApiJsonResponse<bool> {
+ info!("Deploying {}", &site_name);
+
+ let context = if context.is_empty() {
+ None
+ } else {
+ Some(context)
+ };
+ debug!("Context: {:?}", &context);
+
+ match actions::initialize(&site_name, context, &config) {
+ Ok(_) => true.into_json_response(),
+ Err(error) => {
+ warn!("Deployment error: {:?}", error);
+
+ false.into_json_response()
+ }
+ }
+}
+
+#[post("/update/<site_name>", data = "<context>")]
+pub fn update(
+ site_name: String,
+ context: String,
+ config: State<AlkaneConfig>,
+) -> ApiJsonResponse<bool> {
+ info!("Deploying {}", &site_name);
+
+ let context = if context.is_empty() {
+ None
+ } else {
+ Some(context)
+ };
+ debug!("Context: {:?}", &context);
+
+ match actions::update(&site_name, context, &config) {
+ Ok(_) => true.into_json_response(),
+ Err(error) => {
+ warn!("Deployment error: {:?}", error);
+
+ false.into_json_response()
+ }
+ }
+}
+
+#[post("/deploy/<site_name>", data = "<context>")]
+pub fn deploy(
+ site_name: String,
+ context: String,
+ config: State<AlkaneConfig>,
+) -> ApiJsonResponse<bool> {
+ info!("Deploying {}", &site_name);
+
+ let context = if context.is_empty() {
+ None
+ } else {
+ Some(context)
+ };
+ debug!("Context: {:?}", &context);
+
+ match actions::deploy(&site_name, context, &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,150 @@
+// -------------------------------------------------------------
+// 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 100755
--- /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 100755
--- /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
diff --git a/tests/data/wwwroot/acme.tld/foo/.gitkeep b/tests/data/wwwroot/acme.tld/foo/.gitkeep
new file mode 100644
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Tue, Nov 26, 03:26 (20 h, 25 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2263751
Default Alt Text
D2983.id7613.diff (47 KB)
Attached To
Mode
D2983: Trigger Alkane sites deployments from CLI or HTTP API
Attached
Detach File
Event Timeline
Log In to Comment