Repository: railwayapp/nixpacks
Pull request #65 (2022-05-06 02:05:02)

Config through environment variables
Allow specifying config through `NIXPACKS_*` environment variables. This is useful when using Nixpacks on Railway. The knobs that can be tweaked are

- Nix packages to install
- Build command
- Start command
- Node version
- Rust version

Other properties (e.g. Python version) will comes soon once they are setup properly.



Patch:
diff --git a/src/nixpacks/environment.rs b/src/nixpacks/environment.rs
index e44d9f2..423bff4 100644
--- a/src/nixpacks/environment.rs
+++ b/src/nixpacks/environment.rs
@@ -16,6 +16,10 @@ impl Environment {
         self.variables.get(name)
     }
 
+    pub fn get_config_variable(&self, name: &str) -> Option<&String> {
+        self.get_variable(format!("NIXPACKS_{}", name).as_str())
+    }
+
     pub fn set_variable(&mut self, name: String, value: String) {
         self.variables.insert(name, value);
     }
diff --git a/src/nixpacks/mod.rs b/src/nixpacks/mod.rs
index 809c6d4..faf0e75 100644
--- a/src/nixpacks/mod.rs
+++ b/src/nixpacks/mod.rs
@@ -97,7 +97,7 @@ impl<'a> AppBuilder<'a> {
             .get_install_phase()
             .context("Generating install phase")?;
         let build_phase = self.get_build_phase().context("Generating build phase")?;
-        let start_phase = self.get_start_cmd().context("Generating start phase")?;
+        let start_phase = self.get_start_phase().context("Generating start phase")?;
         let variables = self.get_variables().context("Getting plan variables")?;
 
         let plan = BuildPlan {
@@ -213,8 +213,19 @@ impl<'a> AppBuilder<'a> {
             None => SetupPhase::default(),
         };
 
+        let env_var_pkgs = self
+            .environment
+            .get_config_variable("PKGS")
+            .map(|pkg_string| {
+                pkg_string
+                    .split(" ")
+                    .map(|s| Pkg::new(s))
+                    .collect::<Vec<_>>()
+            })
+            .unwrap_or(Vec::new());
+
         // Add custom user packages
-        let mut pkgs = self.options.custom_pkgs.clone();
+        let mut pkgs = [self.options.custom_pkgs.clone(), env_var_pkgs].concat();
         setup_phase.add_pkgs(&mut pkgs);
 
         if self.options.pin_pkgs {
@@ -243,14 +254,23 @@ impl<'a> AppBuilder<'a> {
             None => BuildPhase::default(),
         };
 
-        if let Some(custom_build_cmd) = self.options.custom_build_cmd.clone() {
-            build_phase.cmd = Some(custom_build_cmd);
-        }
+        let env_build_cmd = self.environment.get_config_variable("BUILD_CMD").cloned();
+
+        // Build command priority
+        // - custom build command
+        // - environment variable
+        // - provider
+        build_phase.cmd = self
+            .options
+            .custom_build_cmd
+            .clone()
+            .or(env_build_cmd)
+            .or(build_phase.cmd);
 
         Ok(build_phase)
     }
 
-    fn get_start_cmd(&self) -> Result<StartPhase> {
+    fn get_start_phase(&self) -> Result<StartPhase> {
         let procfile_cmd = self.parse_procfile()?;
 
         let mut start_phase = match self.provider {
@@ -260,15 +280,18 @@ impl<'a> AppBuilder<'a> {
             None => StartPhase::default(),
         };
 
+        let env_start_cmd = self.environment.get_config_variable("START_CMD").cloned();
+
         // Start command priority
         // - custom start command
+        // - environment variable
         // - procfile
         // - provider
         start_phase.cmd = self
             .options
             .custom_start_cmd
             .clone()
-            .or_else(|| procfile_cmd.or(start_phase.cmd));
+            .or_else(|| env_start_cmd.or_else(|| procfile_cmd.or(start_phase.cmd)));
 
         Ok(start_phase)
     }
diff --git a/src/providers/npm.rs b/src/providers/npm.rs
index 96b96cf..b345271 100644
--- a/src/providers/npm.rs
+++ b/src/providers/npm.rs
@@ -34,9 +34,9 @@ impl Provider for NpmProvider {
         Ok(app.includes_file("package.json"))
     }
 
-    fn setup(&self, app: &App, _env: &Environment) -> Result<Option<SetupPhase>> {
+    fn setup(&self, app: &App, env: &Environment) -> Result<Option<SetupPhase>> {
         let package_json: PackageJson = app.read_json("package.json")?;
-        let node_pkg = NpmProvider::get_nix_node_pkg(&package_json)?;
+        let node_pkg = NpmProvider::get_nix_node_pkg(&package_json, env)?;
 
         Ok(Some(SetupPhase::new(vec![node_pkg])))
     }
@@ -110,12 +110,16 @@ impl NpmProvider {
     }
 
     /// Parses the package.json engines field and returns a Nix package if available
-    pub fn get_nix_node_pkg(package_json: &PackageJson) -> Result<Pkg> {
-        let node_version = package_json
+    pub fn get_nix_node_pkg(package_json: &PackageJson, environment: &Environment) -> Result<Pkg> {
+        let env_node_version = environment.get_config_variable("NODE_VERSION");
+
+        let pkg_node_version = package_json
             .engines
             .as_ref()
             .and_then(|engines| engines.get("node"));
 
+        let node_version = env_node_version.or(pkg_node_version);
+
         let node_version = match node_version {
             Some(node_version) => node_version,
             None => return Ok(Pkg::new(DEFAULT_NODE_PKG_NAME)),
@@ -173,13 +177,16 @@ mod test {
     #[test]
     fn test_no_engines() -> Result<()> {
         assert_eq!(
-            NpmProvider::get_nix_node_pkg(&PackageJson {
-                name: String::default(),
-                main: None,
-                scripts: None,
-                workspaces: None,
-                engines: None
-            })?,
+            NpmProvider::get_nix_node_pkg(
+                &PackageJson {
+                    name: String::default(),
+                    main: None,
+                    scripts: None,
+                    workspaces: None,
+                    engines: None
+                },
+                &Environment::default()
+            )?,
             Pkg::new(DEFAULT_NODE_PKG_NAME)
         );
 
@@ -189,13 +196,16 @@ mod test {
     #[test]
     fn test_star_engine() -> Result<()> {
         assert_eq!(
-            NpmProvider::get_nix_node_pkg(&PackageJson {
-                name: String::default(),
-                main: None,
-                scripts: None,
-                workspaces: None,
-                engines: engines_node("*")
-            })?,
+            NpmProvider::get_nix_node_pkg(
+                &PackageJson {
+                    name: String::default(),
+                    main: None,
+                    scripts: None,
+                    workspaces: None,
+                    engines: engines_node("*")
+                },
+                &Environment::default()
+            )?,
             Pkg::new(DEFAULT_NODE_PKG_NAME)
         );
 
@@ -205,13 +215,16 @@ mod test {
     #[test]
     fn test_simple_engine() -> Result<()> {
         assert_eq!(
-            NpmProvider::get_nix_node_pkg(&PackageJson {
-                name: String::default(),
-                main: None,
-                scripts: None,
-                workspaces: None,
-                engines: engines_node("14"),
-            })?,
+            NpmProvider::get_nix_node_pkg(
+                &PackageJson {
+                    name: String::default(),
+                    main: None,
+                    scripts: None,
+                    workspaces: None,
+                    engines: engines_node("14"),
+                },
+                &Environment::default()
+            )?,
             Pkg::new("nodejs-14_x")
         );
 
@@ -221,24 +234,30 @@ mod test {
     #[test]
     fn test_simple_engine_x() -> Result<()> {
         assert_eq!(
-            NpmProvider::get_nix_node_pkg(&PackageJson {
-                name: String::default(),
-                main: None,
-                scripts: None,
-                workspaces: None,
-                engines: engines_node("12.x"),
-            })?,
+            NpmProvider::get_nix_node_pkg(
+                &PackageJson {
+                    name: String::default(),
+                    main: None,
+                    scripts: None,
+                    workspaces: None,
+                    engines: engines_node("12.x"),
+                },
+                &Environment::default()
+            )?,
             Pkg::new("nodejs-12_x")
         );
 
         assert_eq!(
-            NpmProvider::get_nix_node_pkg(&PackageJson {
-                name: String::default(),
-                main: None,
-                scripts: None,
-                workspaces: None,
-                engines: engines_node("14.X"),
-            })?,
+            NpmProvider::get_nix_node_pkg(
+                &PackageJson {
+                    name: String::default(),
+                    main: None,
+                    scripts: None,
+                    workspaces: None,
+                    engines: engines_node("14.X"),
+                },
+                &Environment::default()
+            )?,
             Pkg::new("nodejs-14_x")
         );
 
@@ -248,13 +267,38 @@ mod test {
     #[test]
     fn test_engine_range() -> Result<()> {
         assert_eq!(
-            NpmProvider::get_nix_node_pkg(&PackageJson {
-                name: String::default(),
-                main: None,
-                scripts: None,
-                workspaces: None,
-                engines: engines_node(">=14.10.3 <16"),
-            })?,
+            NpmProvider::get_nix_node_pkg(
+                &PackageJson {
+                    name: String::default(),
+                    main: None,
+                    scripts: None,
+                    workspaces: None,
+                    engines: engines_node(">=14.10.3 <16"),
+                },
+                &Environment::default()
+            )?,
+            Pkg::new("nodejs-14_x")
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_version_from_environment_variable() -> Result<()> {
+        assert_eq!(
+            NpmProvider::get_nix_node_pkg(
+                &PackageJson {
+                    name: String::default(),
+                    main: None,
+                    scripts: None,
+                    workspaces: None,
+                    engines: None,
+                },
+                &Environment::new(HashMap::from([(
+                    "NIXPACKS_NODE_VERSION".to_string(),
+                    "14".to_string()
+                )]))
+            )?,
             Pkg::new("nodejs-14_x")
         );
 
@@ -263,13 +307,16 @@ mod test {
 
     #[test]
     fn test_engine_invalid_version() -> Result<()> {
-        assert!(NpmProvider::get_nix_node_pkg(&PackageJson {
-            name: String::default(),
-            main: None,
-            scripts: None,
-            workspaces: None,
-            engines: engines_node("15"),
-        })
+        assert!(NpmProvider::get_nix_node_pkg(
+            &PackageJson {
+                name: String::default(),
+                main: None,
+                scripts: None,
+                workspaces: None,
+                engines: engines_node("15"),
+            },
+            &Environment::default()
+        )
         .is_err());
 
         Ok(())
diff --git a/src/providers/rust.rs b/src/providers/rust.rs
index 998176e..34b8bc9 100644
--- a/src/providers/rust.rs
+++ b/src/providers/rust.rs
@@ -35,14 +35,14 @@ impl Provider for RustProvider {
         Ok(app.includes_file("Cargo.toml"))
     }
 
-    fn setup(&self, app: &App, _env: &Environment) -> Result<Option<SetupPhase>> {
-        let rust_pkg: Pkg = self.get_rust_pkg(app)?;
+    fn setup(&self, app: &App, env: &Environment) -> Result<Option<SetupPhase>> {
+        let rust_pkg: Pkg = RustProvider::get_rust_pkg(app, env)?;
 
         let mut setup_phase =
             SetupPhase::new(vec![Pkg::new("gcc"), rust_pkg.from_overlay(RUST_OVERLAY)]);
 
         // Include the rust toolchain file so we can install that rust version with Nix
-        if let Some(toolchain_file) = self.get_rust_toolchain_file(app)? {
+        if let Some(toolchain_file) = RustProvider::get_rust_toolchain_file(app)? {
             setup_phase.add_file_dependency(toolchain_file);
         }
 
@@ -54,7 +54,7 @@ impl Provider for RustProvider {
     }
 
     fn start(&self, app: &App, _env: &Environment) -> Result<Option<StartPhase>> {
-        if let Some(toml_file) = self.parse_cargo_toml(app)? {
+        if let Some(toml_file) = RustProvider::parse_cargo_toml(app)? {
             let name = toml_file.package.name;
             Ok(Some(StartPhase::new(format!("./target/release/{}", name))))
         } else {
@@ -74,7 +74,7 @@ impl Provider for RustProvider {
 }
 
 impl RustProvider {
-    fn parse_cargo_toml(&self, app: &App) -> Result<Option<CargoToml>> {
+    fn parse_cargo_toml(app: &App) -> Result<Option<CargoToml>> {
         if app.includes_file("Cargo.toml") {
             let cargo_toml: CargoToml =
                 app.read_toml("Cargo.toml").context("Reading Cargo.toml")?;
@@ -84,7 +84,7 @@ impl RustProvider {
         Ok(None)
     }
 
-    fn get_rust_toolchain_file(&self, app: &App) -> Result<Option<String>> {
+    fn get_rust_toolchain_file(app: &App) -> Result<Option<String>> {
         if app.includes_file("rust-toolchain") {
             Ok(Some("rust-toolchain".to_string()))
         } else if app.includes_file("rust-toolchain.toml") {
@@ -95,14 +95,20 @@ impl RustProvider {
     }
 
     // Get the rust package version by parsing the `rust-version` field in `Cargo.toml`
-    fn get_rust_pkg(&self, app: &App) -> Result<Pkg> {
-        if let Some(toolchain_file) = self.get_rust_toolchain_file(app)? {
+    fn get_rust_pkg(app: &App, env: &Environment) -> Result<Pkg> {
+        if let Some(version) = env.get_config_variable("RUST_VERSION") {
+            return Ok(Pkg::new(
+                format!("rust-bin.stable.\"{}\".default", version).as_str(),
+            ));
+        }
+
+        if let Some(toolchain_file) = RustProvider::get_rust_toolchain_file(app)? {
             return Ok(Pkg::new(
                 format!("(rust-bin.fromRustupToolchainFile ./{})", toolchain_file).as_str(),
             ));
         }
 
-        let pkg = match self.parse_cargo_toml(app)? {
+        let pkg = match RustProvider::parse_cargo_toml(app)? {
             Some(toml_file) => {
                 let version = toml_file.package.rust_version;
 
@@ -118,3 +124,67 @@ impl RustProvider {
         Ok(pkg)
     }
 }
+
+#[cfg(test)]
+mod test {
+    use std::collections::HashMap;
+
+    use crate::nixpacks::{app::App, environment::Environment, nix::Pkg};
+
+    use super::*;
+
+    #[test]
+    fn test_no_version() -> Result<()> {
+        assert_eq!(
+            RustProvider::get_rust_pkg(
+                &App::new("./examples/rust-rocket")?,
+                &Environment::default()
+            )?,
+            Pkg::new(DEFAULT_RUST_PACKAGE)
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_custom_version() -> Result<()> {
+        assert_eq!(
+            RustProvider::get_rust_pkg(
+                &App::new("./examples/rust-custom-version")?,
+                &Environment::default()
+            )?,
+            Pkg::new("rust-bin.stable.\"1.56.0\".default")
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_toolchain_file() -> Result<()> {
+        assert_eq!(
+            RustProvider::get_rust_pkg(
+                &App::new("./examples/rust-custom-toolchain")?,
+                &Environment::default()
+            )?,
+            Pkg::new("(rust-bin.fromRustupToolchainFile ./rust-toolchain.toml)")
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_version_from_environment_variable() -> Result<()> {
+        assert_eq!(
+            RustProvider::get_rust_pkg(
+                &App::new("./examples/rust-custom-toolchain")?,
+                &Environment::new(HashMap::from([(
+                    "NIXPACKS_RUST_VERSION".to_string(),
+                    "1.54.0".to_string()
+                )]))
+            )?,
+            Pkg::new("rust-bin.stable.\"1.54.0\".default")
+        );
+
+        Ok(())
+    }
+}
diff --git a/src/providers/yarn.rs b/src/providers/yarn.rs
index d2b5a6a..52502d0 100644
--- a/src/providers/yarn.rs
+++ b/src/providers/yarn.rs
@@ -21,9 +21,9 @@ impl Provider for YarnProvider {
         Ok(app.includes_file("package.json") && app.includes_file("yarn.lock"))
     }
 
-    fn setup(&self, app: &App, _env: &Environment) -> Result<Option<SetupPhase>> {
+    fn setup(&self, app: &App, env: &Environment) -> Result<Option<SetupPhase>> {
         let package_json: PackageJson = app.read_json("package.json")?;
-        let node_pkg = NpmProvider::get_nix_node_pkg(&package_json)?;
+        let node_pkg = NpmProvider::get_nix_node_pkg(&package_json, env)?;
         let mut yarn_pkg = Pkg::new("yarn");
 
         // Only override the node package if not the default one


Test patch:
diff --git a/tests/generate_plan_tests.rs b/tests/generate_plan_tests.rs
index 3acb3b8..b36c89d 100644
--- a/tests/generate_plan_tests.rs
+++ b/tests/generate_plan_tests.rs
@@ -324,3 +324,27 @@ fn test_overriding_environment_variables() -> Result<()> {
 
     Ok(())
 }
+
+#[test]
+fn test_config_from_environment_variables() -> Result<()> {
+    let plan = gen_plan(
+        "./examples/hello",
+        Vec::new(),
+        None,
+        None,
+        vec![
+            "NIXPACKS_PKGS=cowsay ripgrep",
+            "NIXPACKS_BUILD_CMD=build",
+            "NIXPACKS_START_CMD=start",
+        ],
+        false,
+    )?;
+    assert_eq!(plan.build.unwrap().cmd, Some("build".to_string()));
+    assert_eq!(plan.start.unwrap().cmd, Some("start".to_string()));
+    assert_eq!(
+        plan.setup.unwrap().pkgs,
+        vec![Pkg::new("cowsay"), Pkg::new("ripgrep")]
+    );
+
+    Ok(())
+}