Page MenuHomeDevCentral

D2983.id7613.diff
No OneTemporary

D2983.id7613.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,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

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)

Event Timeline