diff --git a/Cargo.lock b/Cargo.lock index 4bf7809..3c3162f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,84 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "darling" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4529658bdda7fd6769b8614be250cdcfc3aeb0ee72fe66f9e41e5e5eb73eac02" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "649c91bc01e8b1eac09fb91e8dbc7d517684ca6be8ebc75bb9cafc894f9fdb6f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc69c5bfcbd2fc09a0f38451d2daf0e372e367986a83906d1b0dbc88134fb5" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07adf7be193b71cc36b193d0f5fe60b918a3a9db4dad0449f57bcfd519704a3" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f91d4cfa921f1c05904dc3c57b4a32c38aed3340cce209f3a6fd1478babafc4" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0314b72bed045f3a68671b3c86328386762c93f82d98c65c3cb5e5f573dd68" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "docker-compose-types" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7213b15aa1b9e80b202b9e8c95dd0e2df45805dba8301dda2f72549436be67" +dependencies = [ + "derive_builder", + "indexmap", + "serde", + "serde_yaml 0.8.26", +] + [[package]] name = "dofigen" version = "1.0.0" @@ -88,7 +166,7 @@ dependencies = [ "clap", "serde", "serde_json", - "serde_yaml", + "serde_yaml 0.9.9", "thiserror", ] @@ -105,6 +183,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "hashbrown" version = "0.12.3" @@ -132,6 +216,12 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "1.9.1" @@ -140,6 +230,7 @@ checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" dependencies = [ "autocfg", "hashbrown", + "serde", ] [[package]] @@ -159,14 +250,16 @@ name = "lenra_cli" version = "0.0.0" dependencies = [ "clap", + "docker-compose-types", "dofigen", "env_logger", "lazy_static", "log", + "open", "regex", "serde", "serde_json", - "serde_yaml", + "serde_yaml 0.9.9", ] [[package]] @@ -175,6 +268,12 @@ version = "0.2.132" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "log" version = "0.4.17" @@ -196,12 +295,28 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "074864da206b4973b84eb91683020dbefd6a8c3f0f38e054d93954e891935e4e" +[[package]] +name = "open" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcea7a30d6b81a2423cc59c43554880feff7b57d12916f231a79f8d6d9470201" +dependencies = [ + "pathdiff", + "winapi", +] + [[package]] name = "os_str_bytes" version = "6.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -298,6 +413,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" +dependencies = [ + "indexmap", + "ryu", + "serde", + "yaml-rust", +] + [[package]] name = "serde_yaml" version = "0.9.9" @@ -411,3 +538,12 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] diff --git a/Cargo.toml b/Cargo.toml index 6b64d92..cd8f03f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,16 @@ name = "lenra_cli" version = "0.0.0" edition = "2021" license = "MIT" -description = "THe Lenra's command line interface" +description = "The Lenra command line interface" readme = "../README.md" repository = "https://github.com/lenra-io/lenra_cli" keywords = ["cli", "lenra"] categories = ["command-line-utilities"] +include = [ + "**/*.rs", + "Cargo.toml", + "README.md", +] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -26,3 +31,5 @@ env_logger = "0.9.0" regex = "1.6.0" lazy_static = "1.4.0" dofigen = "1" +docker-compose-types = "0.2.1" +open = "1" diff --git a/README.md b/README.md index 0242657..1d0d309 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,15 @@ The Lenra's command line interface. ### Prerequisites -Install the Lenra's cli using one of the next possibilities. +To build and run the Lenra elements that handle your app, the Lenra CLI needs [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/). + +You can also install the [Docker buildx command](https://docs.docker.com/build/buildx/install/) to use the [Buildkit optimization given by Dofigen](https://github.com/lenra-io/dofigen). + +Install the Lenra CLI using one of the next possibilities. + +#### Download the binary + +You can download the binary from [the release page](https://github.com/lenra-io/lenra_cli/releases) and add it to your path environment variable. #### Cargo install @@ -48,16 +56,15 @@ Then use the next command to install the Lenra's cli: cargo install lenra_cli ``` -#### Download the binary - -You can download the binary from [the release page](https://github.com/lenra-io/lenra_cli/releases) and add it to your path environment variable. +#### Build it from sources -#### Use it with Docker +First install Cargo, the Rust package manager: https://doc.rust-lang.org/cargo/getting-started/installation.html -You can run cli directly from it Docker image with the next command: +Then clone this repository and install it with Cargo: ```bash -docker run --rm -it -v $(pwd):/app lenra/cli +git clone https://github.com/lenra-io/lenra_cli.git +cargo install --path . ```

(back to top)

@@ -69,7 +76,7 @@ Use the help options to understand how to use it: ```bash $ lenra --help lenra_cli 0.0.0 -A Dockerfile generator using a simplified description in YAML or JSON format command line tool +The Lenra command line interface USAGE: lenra @@ -83,6 +90,7 @@ SUBCOMMANDS: help Print this message or the help of the given subcommand(s) new Create a new Lenra app project start Start your app previously built with the build command + stop Stop your app previously started with the start command ``` ### Subcommands @@ -91,6 +99,8 @@ This tools contains many subcommands to help you doing what you need. - [new](#new): creates a new Lenra app project - [build](#build): builds the Lenra app of the current directory +- [start](#start): starts your app previously built with the build command +- [stop](#stop): stops your app previously started with the start command #### new @@ -136,6 +146,40 @@ OPTIONS: -h, --help Print help information ``` +#### start + +This subcommand starts the Lenra app of the current directory previously built. + +```bash +$ lenra start --help +lenra-start +Start your app previously built with the build command + +USAGE: + lenra start [OPTIONS] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + -h, --help Print help information +``` + +#### stop + +This subcommand stops the Lenra app of the current directory and removes the Docker Compose elements. + +```bash +$ lenra stop --help +lenra-stop +Stop your app previously started with the start command + +USAGE: + lenra stop [OPTIONS] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + -h, --help Print help information +``` + ### Configuration file The Lenra's configuration file describes your Lenra app configurations, like API versions or how to build it. diff --git a/src/build.rs b/src/build.rs deleted file mode 100644 index a8b0f50..0000000 --- a/src/build.rs +++ /dev/null @@ -1,264 +0,0 @@ -use log; -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; -use std::process::{Command, Stdio}; - -use clap; -use dofigen_lib::{ - from_file_path, generate_dockerfile, generate_dockerignore, Artifact, Builder, Image, -}; - -use crate::cli::CliCommand; -use crate::config::{load_config_file, Generator, DEFAULT_CONFIG_FILE, LENRA_CACHE_DIRECTORY}; - -static OF_WATCHDOG_BUILDER: &str = "of-watchdog"; -static OF_WATCHDOG_IMAGE: &str = "ghcr.io/openfaas/of-watchdog"; -static OF_WATCHDOG_VERSION: &str = "0.9.6"; - -#[derive(clap::Args)] -pub struct Build { - /// The app configuration file. - #[clap(parse(from_os_str), long, default_value = DEFAULT_CONFIG_FILE)] - pub config: std::path::PathBuf, - - /// The app configuration file. - #[clap(value_enum, long, default_value = "local")] - pub cache: Cache, -} - -#[derive(clap::ValueEnum, Clone, Debug)] -pub enum Cache { - Local, - Inline, - Image, - No, -} - -impl Build { - /// Builds a Docker image from a Dofigen structure - fn build_dofigen(&self, image: Image) { - // Generate the Dofigen config with OpenFaaS overlay to handle the of-watchdog - let of_overlay = self.dofigen_of_overlay(image); - - // generate the Dockerfile and .dockerignore files with Dofigen - let dockerfile = generate_dockerfile(&of_overlay); - let dockerignore = generate_dockerignore(&of_overlay); - self.save_docker_content(dockerfile, Some(dockerignore)); - - // build the generated Dockerfile - self.build_docker_image(None); - } - - /// Add an overlay to the given Dofigen structure to manage OpenFaaS - fn dofigen_of_overlay(&self, image: Image) -> Image { - log::info!("Adding OpenFaaS overlay to the Dofigen descriptor"); - let mut builders = if let Some(vec) = image.builders { - vec - } else { - Vec::new() - }; - builders.push(Builder { - name: Some(String::from(OF_WATCHDOG_BUILDER)), - image: format!("{}:{}", OF_WATCHDOG_IMAGE, OF_WATCHDOG_VERSION), - ..Default::default() - }); - - let mut artifacts = if let Some(arts) = image.artifacts { - arts - } else { - Vec::new() - }; - artifacts.push(Artifact { - builder: OF_WATCHDOG_BUILDER.to_string(), - source: "/fwatchdog".to_string(), - destination: "/fwatchdog".to_string(), - }); - - let mut envs = if let Some(envs) = image.envs { - envs - } else { - HashMap::new() - }; - - if let Some(ports) = image.ports { - if ports.len() == 1 { - envs.insert("mode".to_string(), "http".to_string()); - envs.insert( - "upstream_url".to_string(), - format!("http://127.0.0.1:{}", ports[0]), - ); - } else if ports.len() > 1 { - panic!("More than one port has been defined in the Dofigen descriptor"); - } - }; - - if image.entrypoint.is_some() { - panic!("The Dofigen descriptor can't have entrypoint defined. Use cmd instead"); - } - - if let Some(cmd) = image.cmd { - envs.insert("fprocess".to_string(), cmd.join(" ")); - } else { - panic!("The Dofigen cmd property is not defined"); - } - - Image { - image: image.image, - builders: Some(builders), - artifacts: Some(artifacts), - ports: Some(vec![8080]), - envs: Some(envs), - entrypoint: None, - cmd: Some(vec!["/fwatchdog".to_string()]), - user: image.user, - workdir: image.workdir, - adds: image.adds, - root: image.root, - script: image.script, - caches: image.caches, - healthcheck: image.healthcheck, - ignores: image.ignores, - } - } - - /// Saves the Dockerfile and dockerignore (if present) files from their contents - fn save_docker_content( - &self, - dockerfile_content: String, - dockerignore_content: Option, - ) { - let dockerfile_path: PathBuf = [LENRA_CACHE_DIRECTORY, "Dockerfile"].iter().collect(); - let dockerignore_path: PathBuf = [LENRA_CACHE_DIRECTORY, ".dockerignore"].iter().collect(); - - fs::write(dockerfile_path, dockerfile_content).expect("Unable to write the Dockerfile"); - if let Some(content) = dockerignore_content { - fs::write(dockerignore_path, content).expect("Unable to write the .dockerignore file"); - } - } - - /// Builds a Dockerfile. If None, get's it at the default path: ./.lenra/Dockerfile - fn build_docker_image(&self, dockerfile: Option) { - log::info!("Build the Docker image"); - let dockerfile_path: PathBuf = - dockerfile.unwrap_or([LENRA_CACHE_DIRECTORY, "Dockerfile"].iter().collect()); - let cache_directory: PathBuf = [LENRA_CACHE_DIRECTORY, "dockercache"].iter().collect(); - let mut command = Command::new("docker"); - let image_name = "lenra/app"; - - // TODO: display std out & err - command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); - command - .arg("buildx") - .arg("build") - .arg("-f") - .arg(dockerfile_path); - - match self.cache { - Cache::Inline => command - .arg("--cache-to=type=inline") - .arg(format!("--cache-from={}", image_name)), - Cache::Local => command - .arg(format!( - "--cache-to=type=local,dest={}", - cache_directory.display() - )) - .arg(format!( - "--cache-from=type=local,src={}", - cache_directory.display() - )), - Cache::Image => command - .arg(format!("--cache-to={}:cache", image_name)) - .arg(format!("--cache-from={}:cache", image_name)), - Cache::No => &command, - }; - command.arg("-t").arg(image_name).arg("--load").arg("."); - - log::debug!("Build image: {:?}", command); - let output = command.output().expect("Failed building the Docker image"); - if !output.status.success() { - panic!( - "An error occured while building the Docker image:\n{}\n{}", - String::from_utf8(output.stdout).unwrap(), - String::from_utf8(output.stderr).unwrap() - ) - } - log::info!("Image built"); - } -} - -impl CliCommand for Build { - fn run(&self) { - let conf = load_config_file(&self.config); - // TODO: check the components API version - - // create the `.lenra` cache directory - fs::create_dir_all(LENRA_CACHE_DIRECTORY).unwrap(); - - match conf.generator { - Generator::Dofigen(dofigen) => self.build_dofigen(dofigen.dofigen), - Generator::DofigenFile(dofigen_file) => self.build_dofigen( - from_file_path(&dofigen_file.dofigen).expect("Failed loading the Dofigen file"), - ), - Generator::DofigenError { dofigen: _ } => { - panic!("Your Dofigen configuration is not correct") - } - Generator::Dockerfile(dockerfile) => self.build_docker_image(Some(dockerfile.docker)), - Generator::Docker(docker) => { - self.save_docker_content(docker.docker, docker.ignore); - self.build_docker_image(None); - } - } - } -} - -#[cfg(test)] -mod dofigen_of_overlay_tests { - use super::*; - - #[test] - fn simple_image() { - let build = Build { - config: DEFAULT_CONFIG_FILE.into(), - cache: Cache::Inline, - }; - let image = Image { - image: "my-dockerimage".into(), - cmd: Some(vec!["/app/my-app".into()]), - ..Default::default() - }; - let overlayed_image = Image { - builders: Some(vec![Builder { - name: Some("of-watchdog".into()), - image: format!("ghcr.io/openfaas/of-watchdog:{}", OF_WATCHDOG_VERSION), - ..Default::default() - }]), - image: String::from("my-dockerimage"), - envs: Some([("fprocess".to_string(), "/app/my-app".to_string())].into()), - artifacts: Some(vec![Artifact { - builder: "of-watchdog".into(), - source: "/fwatchdog".into(), - destination: "/fwatchdog".into(), - }]), - ports: Some(vec![8080]), - cmd: Some(vec!["/fwatchdog".into()]), - ..Default::default() - }; - - assert_eq!(build.dofigen_of_overlay(image), overlayed_image); - } - - #[test] - #[should_panic] - fn no_cmd() { - let build = Build { - config: DEFAULT_CONFIG_FILE.into(), - cache: Cache::Inline, - }; - let image = Image { - image: "my-dockerimage".into(), - ..Default::default() - }; - build.dofigen_of_overlay(image); - } -} diff --git a/src/cli/build.rs b/src/cli/build.rs new file mode 100644 index 0000000..89a3849 --- /dev/null +++ b/src/cli/build.rs @@ -0,0 +1,37 @@ +use log; + +use clap; + +use crate::cli::CliCommand; +use crate::config::{load_config_file, DEFAULT_CONFIG_FILE}; +use crate::docker_compose::compose_build; + +#[derive(clap::Args)] +pub struct Build { + /// The app configuration file. + #[clap(parse(from_os_str), long, default_value = DEFAULT_CONFIG_FILE)] + pub config: std::path::PathBuf, +} + +impl Build { + /// Builds a Dockerfile. If None, get's it at the default path: ./.lenra/Dockerfile + fn build_docker_compose(&self) { + log::info!("Build the Docker image"); + + compose_build(); + + log::info!("Image built"); + } +} + +impl CliCommand for Build { + fn run(&self) { + let conf = load_config_file(&self.config); + // TODO: check the components API version + + conf.generate_files(); + + // self.build_docker_image(conf); + self.build_docker_compose(); + } +} diff --git a/src/cli.rs b/src/cli/mod.rs similarity index 72% rename from src/cli.rs rename to src/cli/mod.rs index c2b94ef..017c76d 100644 --- a/src/cli.rs +++ b/src/cli/mod.rs @@ -1,8 +1,13 @@ pub use clap::{Args, Parser, Subcommand}; -use crate::{build::Build, new::New, start::Start}; +use self::{build::Build, new::New, start::Start, stop::Stop}; -/// The Lenra CLI arguments to manage your local app development. +mod build; +mod new; +mod start; +mod stop; + +/// The Lenra command line interface #[derive(Parser)] #[clap(author, version, about, long_about = None)] pub struct Cli { @@ -23,6 +28,8 @@ pub enum Command { Build(Build), /// Start your app previously built with the build command Start(Start), + /// Stop your app previously started with the start command + Stop(Stop), } impl CliCommand for Command { @@ -31,6 +38,7 @@ impl CliCommand for Command { Command::New(new) => new.run(), Command::Build(build) => build.run(), Command::Start(start) => start.run(), + Command::Stop(stop) => stop.run(), }; } } diff --git a/src/new.rs b/src/cli/new.rs similarity index 100% rename from src/new.rs rename to src/cli/new.rs diff --git a/src/cli/start.rs b/src/cli/start.rs new file mode 100644 index 0000000..eee821c --- /dev/null +++ b/src/cli/start.rs @@ -0,0 +1,48 @@ +use std::path::PathBuf; + +pub use clap::Args; + +use crate::cli::CliCommand; +use crate::config::{load_config_file, DEFAULT_CONFIG_FILE, DOCKERCOMPOSE_DEFAULT_PATH}; +use crate::docker_compose::{compose_up, execute_compose_service_command, DEVTOOL_SERVICE_NAME}; + +#[derive(Args)] +pub struct Start { + /// The app configuration file. + #[clap(parse(from_os_str), long, default_value = DEFAULT_CONFIG_FILE)] + pub config: std::path::PathBuf, +} + +impl Start { + /// Starts the docker-compose + fn start_docker_compose(&self) { + let dockercompose_path: PathBuf = DOCKERCOMPOSE_DEFAULT_PATH.iter().collect(); + if !dockercompose_path.exists() { + let conf = load_config_file(&self.config); + // TODO: check the components API version + + conf.generate_files(); + } + + // Start the containers + compose_up(); + // Stop the devtool app env to reset cache + execute_compose_service_command( + DEVTOOL_SERVICE_NAME, + &[ + "bin/dev_tools", + "rpc", + "ApplicationRunner.Environments.Managers.stop_env(1)", + ], + ); + // Open the app + open::that("http://localhost:4000").unwrap(); + } +} + +impl CliCommand for Start { + fn run(&self) { + log::info!("Starting the app"); + self.start_docker_compose(); + } +} diff --git a/src/cli/stop.rs b/src/cli/stop.rs new file mode 100644 index 0000000..67f4b60 --- /dev/null +++ b/src/cli/stop.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +pub use clap::Args; + +use crate::cli::CliCommand; +use crate::config::{DEFAULT_CONFIG_FILE, DOCKERCOMPOSE_DEFAULT_PATH}; + +#[derive(Args)] +pub struct Stop { + /// The app configuration file. + #[clap(parse(from_os_str), long, default_value = DEFAULT_CONFIG_FILE)] + pub config: std::path::PathBuf, +} + +impl Stop { + /// Starts the docker-compose + fn stop_docker_compose(&self) { + let dockercompose_path: PathBuf = DOCKERCOMPOSE_DEFAULT_PATH.iter().collect(); + if !dockercompose_path.exists() {} + + let mut command = Command::new("docker"); + + // TODO: display std out & err + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + command + .arg("compose") + .arg("-f") + .arg(dockercompose_path) + .arg("down"); + + log::debug!("cmd: {:?}", command); + let output = command + .output() + .expect("Failed to stop the docker-compose app"); + if !output.status.success() { + panic!( + "An error occured while stoping the docker-compose app:\n{}\n{}", + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap() + ) + } + } +} + +impl CliCommand for Stop { + fn run(&self) { + log::info!("Stoping the app"); + self.stop_docker_compose(); + } +} diff --git a/src/config.rs b/src/config.rs index 72d5526..74d5334 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,25 @@ -use std::fs; +use std::{collections::HashMap, fs, path::PathBuf}; -use dofigen_lib::Image; +use dofigen_lib::{ + from_file_path, generate_dockerfile, generate_dockerignore, Artifact, Builder, Image, +}; use serde::{Deserialize, Serialize}; use serde_yaml::Value; -pub static DEFAULT_CONFIG_FILE: &str = "lenra.yml"; -pub static LENRA_CACHE_DIRECTORY: &str = ".lenra"; +use crate::docker_compose::generate_docker_compose; + +pub const DEFAULT_CONFIG_FILE: &str = "lenra.yml"; +pub const LENRA_CACHE_DIRECTORY: &str = ".lenra"; + +pub const DEVTOOL_DEFAULT_TAG: &str = "beta"; + +pub const DOCKERFILE_DEFAULT_PATH: [&str; 2] = [LENRA_CACHE_DIRECTORY, "Dockerfile"]; +pub const DOCKERIGNORE_DEFAULT_PATH: [&str; 2] = [LENRA_CACHE_DIRECTORY, "Dockerfile.dockerignore"]; +pub const DOCKERCOMPOSE_DEFAULT_PATH: [&str; 2] = [LENRA_CACHE_DIRECTORY, "docker-compose.yml"]; + +pub const OF_WATCHDOG_BUILDER: &str = "of-watchdog"; +pub const OF_WATCHDOG_IMAGE: &str = "ghcr.io/openfaas/of-watchdog"; +pub const OF_WATCHDOG_VERSION: &str = "0.9.6"; pub fn load_config_file(path: &std::path::PathBuf) -> Application { let file = fs::File::open(path).unwrap(); @@ -21,11 +35,21 @@ pub fn load_config_file(path: &std::path::PathBuf) -> Application { } /** The main component of the config file */ -#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Default)] +#[serde(rename_all = "camelCase")] pub struct Application { #[serde(rename = "componentsApi")] pub components_api: String, pub generator: Generator, + pub dev: Option, +} + +/** The dev specific configuration */ +#[derive(Serialize, Deserialize, Debug, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct Dev { + #[serde(default = "devtool_default_tag")] + pub devtool_tag: String, } /** The application generator configuration */ @@ -37,6 +61,7 @@ pub enum Generator { DofigenError { dofigen: Value }, Dockerfile(Dockerfile), Docker(Docker), + Unknow, } /** The Dofigen configuration */ @@ -63,3 +88,211 @@ pub struct Docker { pub struct Dockerfile { pub docker: std::path::PathBuf, } + +impl Application { + /// Generates all the files needed to build and run the application + pub fn generate_files(&self) { + self.generate_docker_files(); + self.generate_docker_compose_file(); + } + + pub fn generate_docker_files(&self) { + log::info!("Docker files generation"); + // create the `.lenra` cache directory + fs::create_dir_all(LENRA_CACHE_DIRECTORY).unwrap(); + + match &self.generator { + Generator::Dofigen(dofigen) => self.build_dofigen(dofigen.dofigen.clone()), + Generator::DofigenFile(dofigen_file) => self.build_dofigen( + from_file_path(&dofigen_file.dofigen).expect("Failed loading the Dofigen file"), + ), + Generator::DofigenError { dofigen: _ } => { + panic!("Your Dofigen configuration is not correct") + } + Generator::Dockerfile(_dockerfile) => (), + Generator::Docker(docker) => { + self.save_docker_content(docker.docker.clone(), docker.ignore.clone()); + } + Generator::Unknow => panic!("Not managed generator"), + } + } + + pub fn generate_docker_compose_file(&self) { + log::info!("Docker Compose file generation"); + // create the `.lenra` cache directory + fs::create_dir_all(LENRA_CACHE_DIRECTORY).unwrap(); + + let dockerfile: PathBuf = if let Generator::Dockerfile(file_conf) = &self.generator { + file_conf.docker.clone() + } else { + DOCKERFILE_DEFAULT_PATH.iter().collect() + }; + + generate_docker_compose(dockerfile, &self.dev); + } + + /// Builds a Docker image from a Dofigen structure + fn build_dofigen(&self, image: Image) { + // Generate the Dofigen config with OpenFaaS overlay to handle the of-watchdog + let of_overlay = self.dofigen_of_overlay(image); + + // generate the Dockerfile and .dockerignore files with Dofigen + let dockerfile = generate_dockerfile(&of_overlay); + let dockerignore = generate_dockerignore(&of_overlay); + self.save_docker_content(dockerfile, Some(dockerignore)); + } + + /// Add an overlay to the given Dofigen structure to manage OpenFaaS + fn dofigen_of_overlay(&self, image: Image) -> Image { + log::info!("Adding OpenFaaS overlay to the Dofigen descriptor"); + let mut builders = if let Some(vec) = image.builders { + vec + } else { + Vec::new() + }; + builders.push(Builder { + name: Some(String::from(OF_WATCHDOG_BUILDER)), + image: format!("{}:{}", OF_WATCHDOG_IMAGE, OF_WATCHDOG_VERSION), + ..Default::default() + }); + + let mut artifacts = if let Some(arts) = image.artifacts { + arts + } else { + Vec::new() + }; + artifacts.push(Artifact { + builder: OF_WATCHDOG_BUILDER.to_string(), + source: "/fwatchdog".to_string(), + destination: "/fwatchdog".to_string(), + }); + + let mut envs = if let Some(envs) = image.envs { + envs + } else { + HashMap::new() + }; + + if let Some(ports) = image.ports { + if ports.len() == 1 { + envs.insert("mode".to_string(), "http".to_string()); + envs.insert( + "upstream_url".to_string(), + format!("http://127.0.0.1:{}", ports[0]), + ); + } else if ports.len() > 1 { + panic!("More than one port has been defined in the Dofigen descriptor"); + } + }; + + if image.entrypoint.is_some() { + panic!("The Dofigen descriptor can't have entrypoint defined. Use cmd instead"); + } + + if let Some(cmd) = image.cmd { + envs.insert("fprocess".to_string(), cmd.join(" ")); + } else { + panic!("The Dofigen cmd property is not defined"); + } + + Image { + image: image.image, + builders: Some(builders), + artifacts: Some(artifacts), + ports: Some(vec![8080]), + envs: Some(envs), + entrypoint: None, + cmd: Some(vec!["/fwatchdog".to_string()]), + user: image.user, + workdir: image.workdir, + adds: image.adds, + root: image.root, + script: image.script, + caches: image.caches, + healthcheck: image.healthcheck, + ignores: image.ignores, + } + } + + /// Saves the Dockerfile and dockerignore (if present) files from their contents + fn save_docker_content( + &self, + dockerfile_content: String, + dockerignore_content: Option, + ) { + let dockerfile_path: PathBuf = DOCKERFILE_DEFAULT_PATH.iter().collect(); + let dockerignore_path: PathBuf = DOCKERIGNORE_DEFAULT_PATH.iter().collect(); + + fs::write(dockerfile_path, dockerfile_content).expect("Unable to write the Dockerfile"); + if let Some(content) = dockerignore_content { + fs::write(dockerignore_path, content).expect("Unable to write the .dockerignore file"); + } + } +} + +fn devtool_default_tag() -> String { + DEVTOOL_DEFAULT_TAG.to_string() +} + +impl Default for Generator { + fn default() -> Self { + Generator::Unknow + } +} + +#[cfg(test)] +mod dofigen_of_overlay_tests { + use super::*; + + #[test] + fn simple_image() { + let image = Image { + image: "my-dockerimage".into(), + cmd: Some(vec!["/app/my-app".into()]), + ..Default::default() + }; + let overlayed_image = Image { + builders: Some(vec![Builder { + name: Some("of-watchdog".into()), + image: format!("ghcr.io/openfaas/of-watchdog:{}", OF_WATCHDOG_VERSION), + ..Default::default() + }]), + image: String::from("my-dockerimage"), + envs: Some([("fprocess".to_string(), "/app/my-app".to_string())].into()), + artifacts: Some(vec![Artifact { + builder: "of-watchdog".into(), + source: "/fwatchdog".into(), + destination: "/fwatchdog".into(), + }]), + ports: Some(vec![8080]), + cmd: Some(vec!["/fwatchdog".into()]), + ..Default::default() + }; + let config = Application { + components_api: "".to_string(), + generator: Generator::Dofigen(Dofigen { + dofigen: image.clone(), + }), + ..Default::default() + }; + + assert_eq!(config.dofigen_of_overlay(image), overlayed_image); + } + + #[test] + #[should_panic] + fn no_cmd() { + let image = Image { + image: "my-dockerimage".into(), + ..Default::default() + }; + let config = Application { + components_api: "".to_string(), + generator: Generator::Dofigen(Dofigen { + dofigen: image.clone(), + }), + ..Default::default() + }; + config.dofigen_of_overlay(image); + } +} diff --git a/src/docker_compose.rs b/src/docker_compose.rs new file mode 100644 index 0000000..d048f9e --- /dev/null +++ b/src/docker_compose.rs @@ -0,0 +1,225 @@ +use std::{ + fs, + path::PathBuf, + process::{Command, Stdio}, +}; + +use docker_compose_types::{ + AdvancedBuildStep, BuildStep, Compose, DependsCondition, DependsOnOptions, Environment, + Healthcheck, HealthcheckTest, Service, Services, +}; + +use crate::config::{Dev, DEVTOOL_DEFAULT_TAG, DOCKERCOMPOSE_DEFAULT_PATH}; + +pub const APP_SERVICE_NAME: &str = "app"; +pub const DEVTOOL_SERVICE_NAME: &str = "devtool"; +pub const POSTGRES_SERVICE_NAME: &str = "postgres"; +const DEVTOOL_IMAGE: &str = "lenra/devtools"; +const POSTGRES_IMAGE: &str = "postgres"; +const POSTGRES_IMAGE_TAG: &str = "13"; +const OF_WATCHDOG_PORT: u16 = 8080; +const DEVTOOL_PORT: u16 = 4000; + +/// Generates the docker-compose.yml file +pub fn generate_docker_compose(dockerfile: PathBuf, dev_conf: &Option) { + let compose_content = generate_docker_compose_content(dockerfile, dev_conf); + let compose_path: PathBuf = DOCKERCOMPOSE_DEFAULT_PATH.iter().collect(); + fs::write(compose_path, compose_content).expect("Unable to write the docker-compose file"); +} + +fn generate_docker_compose_content(dockerfile: PathBuf, dev_conf: &Option) -> String { + let postgres_envs = [ + ("POSTGRES_USER".to_string(), Some("postgres".to_string())), + ( + "POSTGRES_PASSWORD".to_string(), + Some("postgres".to_string()), + ), + ("POSTGRES_DB".to_string(), Some("lenra_devtool".to_string())), + ]; + let devtool_envs: [(String, Option); 6] = [ + postgres_envs.clone(), + [ + ( + "POSTGRES_HOST".to_string(), + Some(POSTGRES_SERVICE_NAME.to_string()), + ), + ( + "OF_WATCHDOG_URL".to_string(), + Some(format!("http://{}:{}", APP_SERVICE_NAME, OF_WATCHDOG_PORT)), + ), + ( + "LENRA_API_URL".to_string(), + Some(format!("http://{}:{}", DEVTOOL_SERVICE_NAME, DEVTOOL_PORT)), + ), + ], + ] + .concat() + .try_into() + .unwrap(); + + let devtool_tag = if let Some(conf) = dev_conf { + conf.devtool_tag.as_str() + } else { + DEVTOOL_DEFAULT_TAG + }; + let compose = Compose { + services: Some(Services( + [ + ( + APP_SERVICE_NAME.into(), + Some(Service { + image: Some("lenra/my-app".into()), + build_: Some(BuildStep::Advanced(AdvancedBuildStep { + context: "..".into(), + dockerfile: Some(dockerfile.to_str().unwrap().into()), + ..Default::default() + })), + depends_on: Some(DependsOnOptions::Conditional( + [( + DEVTOOL_SERVICE_NAME.into(), + DependsCondition { + condition: "service_healthy".into(), + }, + )] + .into(), + )), + // TODO: Add resources management when managed by the docker-compose-types lib + ..Default::default() + }), + ), + ( + DEVTOOL_SERVICE_NAME.into(), + Some(Service { + image: Some(format!("{}:{}", DEVTOOL_IMAGE, devtool_tag)), + ports: Some(vec![format!("{}:{}", DEVTOOL_PORT, DEVTOOL_PORT)]), + environment: Some(Environment::KvPair(devtool_envs.into())), + depends_on: Some(DependsOnOptions::Conditional( + [( + POSTGRES_SERVICE_NAME.into(), + DependsCondition { + condition: "service_healthy".into(), + }, + )] + .into(), + )), + healthcheck: Some(Healthcheck { + test: Some(HealthcheckTest::Multiple(vec![ + "CMD".into(), + "wget".into(), + "--spider".into(), + "-q".into(), + "http://localhost:4000".into(), + ])), + interval: Some("10s".into()), + timeout: Some("5s".into()), + retries: 5, + disable: false, + }), + ..Default::default() + }), + ), + ( + POSTGRES_SERVICE_NAME.into(), + Some(Service { + image: Some(format!("{}:{}", POSTGRES_IMAGE, POSTGRES_IMAGE_TAG)), + environment: Some(Environment::KvPair(postgres_envs.into())), + healthcheck: Some(Healthcheck { + test: Some(HealthcheckTest::Multiple(vec![ + "CMD".into(), + "pg_isready".into(), + "-U".into(), + "postgres".into(), + ])), + interval: Some("5s".into()), + timeout: None, + retries: 5, + disable: false, + }), + ..Default::default() + }), + ), + ] + .into(), + )), + ..Default::default() + }; + serde_yaml::to_string(&compose).expect("Error generating the docker-compose file content") +} + +pub fn create_compose_command() -> Command { + let dockercompose_path: PathBuf = DOCKERCOMPOSE_DEFAULT_PATH.iter().collect(); + let mut cmd = Command::new("docker"); + + cmd.arg("compose").arg("-f").arg(dockercompose_path); + + cmd +} + +pub fn compose_up() { + let mut command = create_compose_command(); + + command + .arg("up") + .arg("-d") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + log::debug!("cmd: {:?}", command); + let output = command + .output() + .expect("Failed to start the docker-compose app"); + + if !output.status.success() { + panic!( + "An error occured while running the docker-compose app:\n{}\n{}", + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap() + ) + } +} + +pub fn compose_build() { + let mut command = create_compose_command(); + command.arg("build"); + + // Use Buildkit to improve performance + command.env("DOCKER_BUILDKIT", "1"); + + // Display std out & err + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + + log::debug!("Build: {:?}", command); + let output = command.output().expect("Failed building the Docker image"); + if !output.status.success() { + panic!( + "An error occured while building the Docker image:\n{}\n{}", + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap() + ) + } +} + +pub fn execute_compose_service_command(service: &str, cmd: &[&str]) { + let mut command = create_compose_command(); + + command.arg("exec").arg(service); + + cmd.iter().for_each(|&part| { + command.arg(part); + () + }); + + let output = command + .output() + .expect("Failed to execute the docker-compose exec command"); + + if !output.status.success() { + panic!( + "docker-compose exec exited with code {}:\n\tcmd: {:?}\n\tstdout: {}\n\tstderr: {}", + output.status.code().unwrap(), + command, + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap() + ) + } +} diff --git a/src/main.rs b/src/main.rs index 030563a..b301bd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,11 +6,9 @@ use clap::Parser; use cli::{Cli, CliCommand}; use env_logger; -mod build; mod cli; mod config; -mod new; -mod start; +mod docker_compose; fn main() { env_logger::init(); diff --git a/src/start.rs b/src/start.rs deleted file mode 100644 index 6626f53..0000000 --- a/src/start.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub use clap::Args; - -use crate::cli::CliCommand; -use crate::config::DEFAULT_CONFIG_FILE; - -#[derive(Args)] -pub struct Start { - /// The app configuration file. - #[clap(parse(from_os_str), long, default_value = DEFAULT_CONFIG_FILE)] - pub config: std::path::PathBuf, -} - -impl CliCommand for Start { - fn run(&self) { - todo!() - } -}