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 { + progress.set_name(&service_name); + progress.info("starting builder".to_string()); + + let flake_path = input + .flake + .as_ref() + .and_then(|path| path.to_str()) + .unwrap_or("."); + let systems = self.detect_systems(flake_path).await?; + + let mut set = JoinSet::default(); + + self.eval( + progress.add_child("eval"), + &mut set, + &platform, + &systems, + &input.packages, + ) + .await?; + + progress.done("evaluation finished".to_string()); + + let out_paths = + set.join_all() + .await + .into_iter() + .try_fold(OutPaths::new(), |mut acc, paths| { + acc.extend(paths?); + progress.inc(); + Ok::<_, NixError>(acc) + })?; + + progress.done("finished building packages".to_string()); + + let mut artifacts = HashMap::default(); + + for (target, files) in out_paths { + let artifact = input + .packages + .iter() + .find(|(_, t)| t == &&target) + .map(|(artifact, _)| artifact.clone()) + .ok_or(NixError::MissingArtifact(target))?; + + artifacts.insert(artifact, image::load_from_path(files).await?); + } + + Ok(Output { artifacts }) + } +} diff --git a/src/config.rs b/src/config.rs index c019278..0559ac3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, mem, path::Path}; +use std::{ + collections::HashMap, + mem, + path::{Path, PathBuf}, +}; use miette::Diagnostic; use serde::Deserialize; @@ -34,12 +38,20 @@ pub struct Ko { pub import_path: Option, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Nix { + pub packages: HashMap, + pub flake: Option, +} + #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum Build { Ko(Ko), Bazel(Bazel), Docker(Docker), + Nix(Nix), } #[derive(Debug, Deserialize)] diff --git a/src/exec.rs b/src/exec.rs index be05602..208d303 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -12,10 +12,10 @@ use tokio::{ use crate::progress; -struct ChildWithStdio { - inner: Child, - stdout: ChildStdout, - stderr: ChildStderr, +pub struct ChildWithStdio { + pub inner: Child, + pub stdout: ChildStdout, + pub stderr: ChildStderr, } impl ChildWithStdio { @@ -32,7 +32,7 @@ impl ChildWithStdio { } } -async fn spawn(cmd: &mut Command) -> Result { +pub async fn spawn(cmd: &mut Command) -> Result { let mut inner = cmd .stdin(Stdio::null()) .stdout(Stdio::piped())