diff --git a/.gitignore b/.gitignore
index ea8c4bf..d787b70 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
/target
+/result
diff --git a/Cargo.lock b/Cargo.lock
index b875575..400648c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3013,6 +3013,17 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -3175,6 +3186,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
name = "steiger"
version = "0.1.0"
dependencies = [
+ "aho-corasick",
"async-tempfile",
"clap",
"docker_credential",
@@ -3186,9 +3198,11 @@ dependencies = [
"miette",
"oci-client",
"olpc-cjson",
+ "once_cell",
"prodash",
"serde",
"serde_json",
+ "serde_repr",
"serde_yml",
"sha2",
"subst",
diff --git a/Cargo.toml b/Cargo.toml
index a039f57..643eba1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
+aho-corasick = "1.1.3"
async-tempfile = "0.7.0"
clap = { version = "4.5.45", features = ["derive"] }
docker_credential = "1.3.2"
@@ -17,12 +18,14 @@ oci-client = { version = "0.15.0", default-features = false, features = [
"rustls-tls",
] }
olpc-cjson = "0.1.4"
+once_cell = "1.21.3"
prodash = { version = "30.0.1", features = [
"render-line",
"render-line-crossterm",
] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
+serde_repr = "0.1.20"
serde_yml = "0.0.12"
sha2 = "0.10.9"
subst = "0.3.8"
diff --git a/README.md b/README.md
index bf50e48..2f2b186 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,58 @@ Key difference from Skaffold: Steiger works directly with OCI image layouts, ski
Supports [Ko](https://ko.build/) for building Go applications into container images without Dockerfiles.
+### Nix
+Integrates with [Nix](https://nixos.org/) flake outputs that produce OCI images.
+
+Requirements
+
+- Flakes enabled (`--extra-experimental-features 'nix-command flakes'`)
+- `pkgs.ociTools.buildImage` (available via Steiger overlay or [nixpkgs#390624](https://github.com/NixOS/nixpkgs/pull/390624))
+
+
+Example flake
+
+```nix
+{
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+ steiger.url = "github:brainhivenl/steiger";
+ };
+
+ outputs = {
+ nixpkgs,
+ steiger,
+ ...
+ }: let
+ system = "x86_64-linux";
+ overlays = [steiger.overlays.ociTools];
+ pkgs = import nixpkgs { inherit system overlays; };
+ in {
+ packages.${system} = {
+ default = pkgs.ociTools.buildImage {
+ name = "hello";
+
+ copyToRoot = pkgs.buildEnv {
+ name = "hello-env";
+ paths = [pkgs.hello];
+ pathsToLink = ["/bin"];
+ };
+
+ config.Cmd = ["/bin/hello"];
+ compressor = "none";
+ };
+ };
+
+ devShells.${system} = {
+ default = pkgs.mkShell {
+ packages = [steiger.packages.${system}.default];
+ };
+ };
+ };
+}
+```
+
+
## Build Caching
Steiger delegates caching to the underlying build systems rather than implementing its own cache layer:
@@ -40,19 +92,30 @@ Steiger delegates caching to the underlying build systems rather than implementi
- **Docker BuildKit**: Leverages BuildKit's native layer caching and build cache
- **Bazel**: Uses Bazel's extensive caching system (action cache, remote cache, etc.)
- **Ko**: Benefits from Go's build cache and Ko's layer caching
+- **Nix**: Utilizes Nix's content-addressed store and binary cache system for reproducible, cached builds
This approach avoids cache invalidation issues and performs comparably to Skaffold in cached scenarios, with better performance in some cases.
## Installation
+### Using cargo
+
```bash
cargo install steiger --git https://github.com/brainhivenl/steiger.git
```
-Or build from source:
+### Using nix
+
+Run directly without installation:
+
+```bash
+nix run github:brainhivenl/steiger -- build
+```
+
+### Build from source
```bash
-git clone https://github.com/yourusername/steiger
+git clone https://github.com/brainhivenl/steiger
cd steiger
cargo build --release
```
@@ -83,6 +146,13 @@ services:
type: ko
importPath: ./cmd/service
+ flake:
+ build:
+ type: nix
+ systems: ["x86_64-linux"]
+ packages:
+ api: default # attribute path to package e.g. `outputs.packages..default`
+
profiles:
prod:
env: prod
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..63ef074
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,81 @@
+{
+ "nodes": {
+ "crane": {
+ "locked": {
+ "lastModified": 1755537552,
+ "narHash": "sha256-Tg+P8kFIneqnQLT8E0QqlCrldtdLo1n1y619/mxRD44=",
+ "owner": "ipetkov",
+ "repo": "crane",
+ "rev": "3c40c97e1881fff381e4615e82557b333edf65c4",
+ "type": "github"
+ },
+ "original": {
+ "owner": "ipetkov",
+ "repo": "crane",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1755577059,
+ "narHash": "sha256-5hYhxIpco8xR+IpP3uU56+4+Bw7mf7EMyxS/HqUYHQY=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "97eb7ee0da337d385ab015a23e15022c865be75c",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs-ocitools": {
+ "locked": {
+ "lastModified": 1749738911,
+ "narHash": "sha256-XKXtCm32NTGJ4AZRcX9hz8tjcF5bwGiK/9Z3C3OEANs=",
+ "owner": "msanft",
+ "repo": "nixpkgs",
+ "rev": "3a858b3070e928d6eb79e0fcf21d0c16337edbb5",
+ "type": "github"
+ },
+ "original": {
+ "owner": "msanft",
+ "ref": "msanft/oci/refactor",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "crane": "crane",
+ "nixpkgs": "nixpkgs",
+ "nixpkgs-ocitools": "nixpkgs-ocitools",
+ "rust-overlay": "rust-overlay"
+ }
+ },
+ "rust-overlay": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1755657401,
+ "narHash": "sha256-rPHuWPAcwW63wH1SUtDCqAnf2+60pi/pGMCIhVobzXc=",
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "rev": "292ca754b0f679b842fbfc4734f017c351f0e9eb",
+ "type": "github"
+ },
+ "original": {
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..2f6b8a8
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,72 @@
+{
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+ nixpkgs-ocitools.url = "github:msanft/nixpkgs/msanft/oci/refactor";
+
+ crane.url = "github:ipetkov/crane";
+ rust-overlay = {
+ url = "github:oxalica/rust-overlay";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs = {
+ self,
+ nixpkgs,
+ crane,
+ ...
+ } @ inputs: let
+ inherit (nixpkgs) lib;
+
+ forEachSystem = fun:
+ lib.genAttrs (lib.systems.flakeExposed) (
+ system: fun (import nixpkgs {inherit system;})
+ );
+ in {
+ packages = forEachSystem (
+ pkgs: let
+ craneLib = crane.mkLib pkgs;
+ commonArgs = {
+ src = craneLib.cleanCargoSource ./.;
+ strictDeps = true;
+ buildInputs =
+ [pkgs.nix pkgs.nix-eval-jobs]
+ ++ lib.optionals pkgs.stdenv.isDarwin [pkgs.libiconv];
+ };
+ in {
+ default = craneLib.buildPackage (
+ commonArgs
+ // {
+ cargoArtifacts = craneLib.buildDepsOnly commonArgs;
+ meta.mainProgram = "steiger";
+
+ NIX_BINARY = lib.getExe pkgs.nix;
+ NIX_EVAL_JOBS_BINARY = lib.getExe pkgs.nix-eval-jobs;
+ }
+ );
+ }
+ );
+
+ checks = forEachSystem (pkgs: {
+ inherit (self.packages.${pkgs.system}) default;
+ });
+
+ devShells = forEachSystem (
+ pkgs: let
+ craneLib = crane.mkLib pkgs;
+ in {
+ default = craneLib.devShell {
+ packages = [
+ pkgs.nix-eval-jobs
+ ];
+ };
+ }
+ );
+
+ overlays.ociTools = final: prev: let
+ pkgs = import inputs.nixpkgs-ocitools {inherit (final) system;};
+ in {
+ inherit (pkgs) ociTools;
+ };
+ };
+}
diff --git a/src/builder/bazel.rs b/src/builder/bazel.rs
index db7d530..ea3f346 100644
--- a/src/builder/bazel.rs
+++ b/src/builder/bazel.rs
@@ -27,7 +27,7 @@ pub enum BazelError {
Exit(#[from] ExitError),
#[error("failed to deserialize cquery output")]
Serde(#[from] serde_json::Error),
- #[error("unable to find artifact for target")]
+ #[error("unable to find artifact for target: {0}")]
MissingArtifact(String),
}
diff --git a/src/builder/mod.rs b/src/builder/mod.rs
index 043774f..601d988 100644
--- a/src/builder/mod.rs
+++ b/src/builder/mod.rs
@@ -1,5 +1,5 @@
use crate::{
- builder::{bazel::BazelBuilder, docker::DockerBuilder, ko::KoBuilder},
+ builder::{bazel::BazelBuilder, docker::DockerBuilder, ko::KoBuilder, nix::NixBuilder},
config::{Build, Config},
image::Image,
};
@@ -12,18 +12,22 @@ use tokio::{task::JoinSet, time::Instant};
mod bazel;
mod docker;
mod ko;
+mod nix;
#[derive(Debug, Diagnostic, thiserror::Error)]
pub enum BuildError {
#[error("ko error")]
#[diagnostic(transparent)]
Ko(#[from] ErrorOf),
+ #[error("bazel error")]
+ #[diagnostic(transparent)]
+ Bazel(#[from] ErrorOf),
#[error("docker error")]
#[diagnostic(transparent)]
Docker(#[from] ErrorOf),
- #[error("bazel error")]
+ #[error("nix error")]
#[diagnostic(transparent)]
- Bazel(#[from] ErrorOf),
+ Nix(#[from] ErrorOf),
}
#[derive(Debug, Default)]
@@ -107,6 +111,7 @@ pub struct MetaBuild {
ko: Option,
bazel: Option,
docker: Option,
+ nix: Option,
}
impl MetaBuild {
@@ -116,6 +121,7 @@ impl MetaBuild {
ko: None,
bazel: None,
docker: None,
+ nix: None,
}
}
@@ -150,6 +156,12 @@ impl MetaBuild {
.map_err(BuildError::Docker),
);
}
+ Build::Nix(nix) => {
+ set.spawn(
+ run_builder(&mut self.nix, progress, ctx.with(nix.clone()))?
+ .map_err(BuildError::Nix),
+ );
+ }
};
}
diff --git a/src/builder/nix.rs b/src/builder/nix.rs
new file mode 100644
index 0000000..970aaa5
--- /dev/null
+++ b/src/builder/nix.rs
@@ -0,0 +1,363 @@
+use std::{collections::HashMap, path::PathBuf, process::ExitStatus, sync::Arc};
+
+use aho_corasick::AhoCorasick;
+use miette::Diagnostic;
+use once_cell::sync::Lazy;
+use prodash::{Progress, messages::MessageLevel, tree::Item};
+use serde::Deserialize;
+use serde_repr::Deserialize_repr;
+use tokio::{
+ io::{AsyncBufReadExt, BufReader},
+ process::Command,
+ task::JoinSet,
+};
+use which::which;
+
+use crate::{
+ builder::Context,
+ builder::{Builder, Output},
+ config::Nix,
+ exec::{self, ExitError},
+ image, progress,
+};
+
+#[derive(Debug, Diagnostic, thiserror::Error)]
+pub enum NixError {
+ #[error("failed to find nix binary")]
+ Path(#[from] which::Error),
+ #[error("IO error")]
+ IO(#[from] std::io::Error),
+ #[error("failed to join worker tasks")]
+ Join(#[from] tokio::task::JoinError),
+ #[error("failed to parse image")]
+ #[diagnostic(transparent)]
+ Image(#[from] image::ImageError),
+ #[error("failed to query for output")]
+ Exit(#[from] ExitError),
+ #[error("failed to deserialize nix message")]
+ Serde(#[from] serde_json::Error),
+ #[error("failed to evaluate: {0}")]
+ Eval(String),
+ #[error("failed to run nix build: {0}")]
+ Build(ExitStatus),
+ #[error("invalid platform: {0}")]
+ InvalidPlatform(String),
+ #[error("failed to convert nix system to platform: {0}")]
+ UnsupportedPlatform(String),
+ #[error("unable to find artifact for target: {0}")]
+ MissingArtifact(String),
+}
+
+type OutPaths = HashMap;
+
+static ANSI_REPLACER: Lazy =
+ Lazy::new(|| AhoCorasick::new([r"\u001b", r"\033", r"\x1b", r"\e"]).unwrap());
+
+fn unescape_ansi(text: &str) -> String {
+ ANSI_REPLACER.replace_all(text, &["\x1b"; 4])
+}
+
+fn try_system(platform: &str) -> Result {
+ let Some((os, arch)) = platform.split_once("/") else {
+ return Err(NixError::InvalidPlatform(platform.to_string()));
+ };
+
+ let arch = match arch {
+ "arm64" => "aarch64",
+ "amd64" => "x86_64",
+ _ => return Err(NixError::UnsupportedPlatform(platform.to_string())),
+ };
+
+ Ok([arch, os].join("-"))
+}
+
+#[derive(Debug, Deserialize_repr, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+#[repr(u8)]
+enum Verbosity {
+ Error,
+ Warn,
+ Notice,
+ Info,
+ Talkative,
+ Chatty,
+ Debug,
+ Vomit,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(tag = "action", rename_all = "lowercase")]
+enum BuildAction {
+ Result {
+ fields: Vec,
+ #[serde(rename = "type")]
+ ty: u8,
+ },
+ Msg {
+ level: Verbosity,
+ msg: String,
+ },
+ Start {},
+ Stop {},
+}
+
+impl BuildAction {
+ const FILE_LINKED: u8 = 100;
+ const BUILD_LOG_LINE: u8 = 101;
+ const UNTRUSTED_PATH: u8 = 102;
+ const CORRUPTED_PATH: u8 = 103;
+ const SET_PHASE: u8 = 104;
+ const _PROGRESS: u8 = 105;
+ const _SET_EXPECTED: u8 = 106;
+ const POST_BUILD_LOG_LINE: u8 = 107;
+
+ fn report(&self, progress: &Item) -> Option<()> {
+ match self {
+ Self::Result { fields, ty, .. } => match *ty {
+ Self::BUILD_LOG_LINE | Self::POST_BUILD_LOG_LINE => {
+ let text = fields[0].as_str()?;
+ progress.info(unescape_ansi(text));
+ }
+ Self::FILE_LINKED => {
+ let output_path = fields[0].as_str()?;
+ let store_path = fields[1].as_str()?;
+ progress.done(format!("✓ Linked {output_path} → {store_path}"));
+ }
+ Self::UNTRUSTED_PATH => {
+ let path = fields[0].as_str()?;
+ progress.fail(format!("⚠ Untrusted: {path}"));
+ }
+ Self::CORRUPTED_PATH => {
+ let path = fields[0].as_str()?;
+ let corrupted_msg = format!("✗ Corrupted: {path}");
+ progress.fail(corrupted_msg);
+ }
+ Self::SET_PHASE => {
+ let phase = fields[0].as_str()?;
+ progress.set_name(phase);
+ progress.info(format!("→ Entering {phase}"));
+ }
+ _ => {}
+ },
+ Self::Msg { level, msg } => {
+ if !msg.is_empty() && level <= &Verbosity::Info {
+ progress.info(msg.to_string());
+ }
+ }
+ _ => {}
+ }
+ Some(())
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EvalResult {
+ attr: String,
+ attr_path: Vec,
+ drv_path: Option,
+ error: Option,
+ outputs: Option>,
+}
+
+impl EvalResult {
+ async fn build(
+ self,
+ nix_binary: Arc,
+ mut progress: Item,
+ ) -> Result {
+ if let Some(error) = self.error {
+ progress.message(MessageLevel::Failure, error.clone());
+ return Err(NixError::Eval(error));
+ }
+
+ let mut out_paths = OutPaths::new();
+
+ if let (Some(drv_path), Some(out_path)) = (
+ self.drv_path.as_ref(),
+ self.outputs.as_ref().and_then(|o| o.get("out")),
+ ) {
+ progress.info(format!("starting build for package: {}", &self.attr));
+
+ let mut root_cmd = Command::new(nix_binary.as_ref());
+ let cmd = root_cmd
+ .arg("build")
+ .arg("--no-link")
+ .arg("--log-format")
+ .arg("internal-json")
+ .arg([drv_path, "out"].join("^"));
+
+ let mut child = exec::spawn(cmd).await?;
+
+ let progress = progress.add_child(&self.attr);
+ let reader = BufReader::new(child.stderr);
+ let mut lines = reader.lines();
+
+ while let Some(line) = lines.next_line().await? {
+ let Some(json) = line.strip_prefix("@nix ") else {
+ continue;
+ };
+
+ let action: BuildAction = serde_json::from_str(json)?;
+ action.report(&progress);
+ }
+
+ let status = child.inner.wait().await?;
+ progress.inc();
+
+ if status.success() {
+ progress.done(format!("successfully built package: {}", self.attr));
+ out_paths.insert(self.attr, PathBuf::from(out_path));
+ } else {
+ let exit_code = status.code().unwrap_or_default();
+ progress.fail(format!("build failed with exit code: {exit_code}"));
+ return Err(NixError::Build(status));
+ }
+ }
+
+ Ok(out_paths)
+ }
+}
+
+#[derive(Clone)]
+pub struct NixBuilder {
+ nix_binary: Arc,
+ eval_binary: PathBuf,
+}
+
+impl NixBuilder {
+ async fn eval(
+ &self,
+ mut progress: Item,
+ set: &mut JoinSet>,
+ platform: &str,
+ systems: &[String],
+ packages: &HashMap,
+ ) -> Result<(), NixError> {
+ let system = try_system(platform)?;
+ let Some(system) = systems.iter().find(|s| s == &&system) else {
+ return Ok(());
+ };
+
+ let mut root_cmd = Command::new(&self.eval_binary);
+ let cmd = root_cmd
+ .arg("--verbose")
+ .arg("--log-format")
+ .arg("internal-json")
+ .arg("--gc-roots-dir")
+ .arg(std::env::temp_dir())
+ .arg("--flake")
+ .arg(format!(".#packages.{system}"));
+
+ progress.message(MessageLevel::Info, format!("using platform: {system}"));
+
+ let child = exec::spawn(cmd).await?;
+ progress::proxy_stdio(child.stderr, progress.add_child("nix").into());
+
+ let reader = BufReader::new(child.stdout);
+ let mut lines = reader.lines();
+
+ while let Some(line) = lines.next_line().await? {
+ let drv: EvalResult = serde_json::from_str(&line)?;
+ let attr_path = drv.attr_path.join(".");
+
+ if packages.values().any(|v| v == &attr_path) {
+ progress.init(Some(set.len() + 1), None);
+ let binary = self.nix_binary.clone();
+ let progress = progress.add_child(format!("{attr_path} › nix"));
+ set.spawn(drv.build(binary, progress));
+ }
+ }
+
+ Ok(())
+ }
+
+ async fn detect_systems(&self, flake_path: &str) -> Result, NixError> {
+ let mut root_cmd = Command::new(self.nix_binary.as_os_str());
+ let cmd = root_cmd
+ .arg("eval")
+ .arg([flake_path, "packages"].join("#"))
+ .arg("--apply")
+ .arg("builtins.attrNames")
+ .arg("--json");
+
+ let stdout = exec::run_with_output(cmd).await?;
+ Ok(serde_json::from_str(&stdout)?)
+ }
+}
+
+impl Builder for NixBuilder {
+ type Error = NixError;
+ type Input = Nix;
+
+ fn try_init() -> Result {
+ Ok(Self {
+ nix_binary: option_env!("NIX_BINARY")
+ .map(PathBuf::from)
+ .unwrap_or(which("nix")?)
+ .into(),
+ eval_binary: option_env!("NIX_EVAL_JOBS_BINARY")
+ .map(PathBuf::from)
+ .unwrap_or(which("nix-eval-jobs")?),
+ })
+ }
+
+ async fn build(
+ self,
+ mut progress: Item,
+ Context {
+ service_name,
+ platform,
+ input,
+ }: Context,
+ ) -> Result