Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

cli: add initial build capability

+589 -39
+2 -1
apps/nix/lib/nix/eval/jobs.ex
··· 29 29 def run(target, opts \\ []) 30 30 31 31 def run(target, opts) when is_binary(target) do 32 - run(Nix.Eval.Request.parse(target, Keyword.get(opts, :attr)), opts) 32 + request_opts = Keyword.take(opts, [:attr, :type]) 33 + run(Nix.Eval.Request.parse(target, request_opts), opts) 33 34 end 34 35 35 36 def run(%Nix.Eval.Request{} = request, opts) do
+44 -22
apps/nix/lib/nix/eval/request.ex
··· 1 1 defmodule Nix.Eval.Request do 2 + @moduledoc """ 3 + Represents a Nix evaluation request. 4 + 5 + Supports both flake and path-based evaluation targets. 6 + """ 7 + 2 8 use TypedStruct 3 9 4 10 require Logger 11 + 12 + alias Nix.Eval.Type 5 13 6 14 typedstruct do 7 15 field :id, String.t() ··· 11 19 field :root_id, String.t() | nil, default: nil 12 20 end 13 21 14 - def parse(path, attr \\ nil) do 15 - type = detect_type(path) 22 + @doc """ 23 + Parse a path into an evaluation request. 24 + 25 + ## Options 26 + - `:attr` - Attribute path to evaluate (e.g., "packages.x86_64-linux") 27 + - `:type` - Explicit type (:flake, :path, or :auto for auto-detection). Default: :auto 28 + 29 + ## Examples 30 + 31 + iex> Nix.Eval.Request.parse(".") 32 + %Nix.Eval.Request{type: :flake, path: ".", ...} 16 33 17 - {path, attribute} = parse_path(type, path, attr) 34 + iex> Nix.Eval.Request.parse("./default.nix", type: :path) 35 + %Nix.Eval.Request{type: :path, path: "/absolute/path/default.nix", ...} 36 + """ 37 + def parse(path, opts \\ []) 38 + 39 + def parse(path, opts) when is_list(opts) do 40 + attr = Keyword.get(opts, :attr) 41 + explicit_type = Keyword.get(opts, :type, :auto) 42 + 43 + type = resolve_type(path, explicit_type) 44 + {parsed_path, attribute} = parse_path(type, path, attr) 18 45 19 46 %__MODULE__{ 20 47 id: new_id(), 21 48 type: type, 22 - path: path, 49 + path: parsed_path, 23 50 attr: attribute 24 51 } 25 52 end 26 53 27 - def new_id(), do: "eval_#{Cuid2Ex.create()}" 54 + # Backwards compatibility: parse(path, attr) where attr is a string 55 + def parse(path, attr) when is_binary(attr) or is_nil(attr) do 56 + parse(path, attr: attr) 57 + end 28 58 29 - def detect_type(path) do 30 - cond do 31 - String.match?(path, ~r{#}) -> 32 - :flake 59 + def new_id, do: "eval_#{Cuid2Ex.create()}" 33 60 34 - String.match?(path, ~r{://}) -> 35 - :flake 36 - 37 - String.ends_with?(path, ".nix") -> 38 - :path 39 - 40 - File.exists?(Path.expand("flake.nix", path)) -> 41 - :flake 42 - 43 - File.exists?(Path.expand("flake.nix", path)) -> 44 - :path 45 - 46 - true -> 61 + defp resolve_type(path, :auto) do 62 + case Type.detect(path) do 63 + {:error, :unknown_type} -> 47 64 Logger.error(msg: "Failed to detect type", path: path) 48 65 raise RuntimeError 66 + 67 + type -> 68 + type 49 69 end 50 70 end 71 + 72 + defp resolve_type(_path, type) when type in [:flake, :path], do: type 51 73 52 74 def parse_path(:flake, path, nil) do 53 75 case String.split(path, "#") do
+74
apps/nix/lib/nix/eval/type.ex
··· 1 + defmodule Nix.Eval.Type do 2 + @moduledoc """ 3 + Type detection for Nix evaluation targets. 4 + 5 + Determines whether a target should be evaluated as a flake or path-based expression. 6 + """ 7 + 8 + @type t :: :flake | :path 9 + 10 + @doc """ 11 + Detect the evaluation type for a given path. 12 + 13 + Returns `:flake` or `:path` based on the path characteristics: 14 + - Contains `#` → flake (has attribute fragment) 15 + - Contains `://` → flake (URL scheme) 16 + - Ends with `.nix` → path 17 + - Has `flake.nix` in directory → flake 18 + - Has `default.nix` in directory → path 19 + - Otherwise → `{:error, :unknown_type}` 20 + 21 + ## Examples 22 + 23 + iex> Nix.Eval.Type.detect(".") 24 + :flake # if flake.nix exists 25 + 26 + iex> Nix.Eval.Type.detect(".#packages") 27 + :flake 28 + 29 + iex> Nix.Eval.Type.detect("github:NixOS/nixpkgs") 30 + :flake 31 + 32 + iex> Nix.Eval.Type.detect("./default.nix") 33 + :path 34 + """ 35 + def detect(path) do 36 + cond do 37 + String.match?(path, ~r{#}) -> 38 + :flake 39 + 40 + String.match?(path, ~r{://}) -> 41 + :flake 42 + 43 + String.ends_with?(path, ".nix") -> 44 + :path 45 + 46 + File.exists?(Path.expand("flake.nix", path)) -> 47 + :flake 48 + 49 + File.exists?(Path.expand("default.nix", path)) -> 50 + :path 51 + 52 + true -> 53 + {:error, :unknown_type} 54 + end 55 + end 56 + 57 + @doc """ 58 + Check if a type is valid. 59 + 60 + ## Examples 61 + 62 + iex> Nix.Eval.Type.valid?(:flake) 63 + true 64 + 65 + iex> Nix.Eval.Type.valid?(:path) 66 + true 67 + 68 + iex> Nix.Eval.Type.valid?(:invalid) 69 + false 70 + """ 71 + def valid?(:flake), do: true 72 + def valid?(:path), do: true 73 + def valid?(_), do: false 74 + end
+3 -3
apps/nix/test/nix/eval/request_test.exs
··· 1 1 defmodule Nix.Eval.RequestTest do 2 2 use ExUnit.Case 3 3 4 - test "detect_type/1" do 5 - assert Nix.Eval.Request.detect_type("/tmp#") == :flake 6 - assert Nix.Eval.Request.detect_type(Path.expand("../../../../..", __DIR__)) == :flake 4 + test "Nix.Eval.Type.detect/1" do 5 + assert Nix.Eval.Type.detect("/tmp#") == :flake 6 + assert Nix.Eval.Type.detect(Path.expand("../../../../..", __DIR__)) == :flake 7 7 end 8 8 9 9 test "parse_path/3" do
+2 -1
apps/sower_cli/.formatter.exs
··· 1 1 # Used by "mix format" 2 2 [ 3 - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 + import_deps: [:typedstruct] 4 5 ]
+1
apps/sower_cli/config/runtime.exs
··· 1 + # import Config
+4 -1
apps/sower_cli/lib/mix/tasks/cli.ex
··· 1 1 defmodule Mix.Tasks.Cli.Run do 2 + @moduledoc "Run the Sower CLI" 3 + @shortdoc "Run the Sower CLI" 4 + 2 5 use Mix.Task 3 6 4 7 def run(args) do 5 - # Application.ensure_all_started(:sower_cli) 8 + {:ok, _} = Application.ensure_all_started(:erlexec) 6 9 7 10 SowerCli.main(args) 8 11 end
+85 -10
apps/sower_cli/lib/sower_cli.ex
··· 1 1 defmodule SowerCli do 2 + @moduledoc """ 3 + Sower CLI - Build and deploy Nix flakes. 4 + """ 5 + 2 6 def main(argv) do 3 7 config() 4 8 |> Optimus.parse!(argv) 5 9 |> run() 10 + end 11 + 12 + defp run({[:build], %{args: args, flags: flags, options: options}}) do 13 + SowerCli.Build.run(args.flake, flags, options) 6 14 end 7 15 8 16 defp run({subcommand_path, _}) when is_list(subcommand_path) do ··· 24 32 end 25 33 end 26 34 27 - def config() do 35 + def config do 28 36 Optimus.new!( 29 37 name: "sower", 30 - description: "sower", 31 - version: Keyword.get(Mix.Project.config(), :version, "dev"), 38 + description: "Build and deploy Nix flakes", 39 + version: version(), 32 40 subcommands: [ 33 41 build: [ 34 - name: "build" 35 - ], 36 - seed: [ 37 - name: "seed", 38 - subcommands: [ 39 - submit: [ 40 - name: "submit" 42 + name: "build", 43 + about: "Build derivations from a Nix flake", 44 + args: [ 45 + flake: [ 46 + value_name: "FLAKE", 47 + help: "Flake reference (e.g., '.', '.#attr', 'github:owner/repo')", 48 + required: true 49 + ] 50 + ], 51 + flags: [ 52 + eval_only: [ 53 + short: "-e", 54 + long: "--eval-only", 55 + help: "Only evaluate, don't build" 56 + ], 57 + push: [ 58 + short: "-p", 59 + long: "--push", 60 + help: "Push built paths to cache" 61 + ], 62 + seed: [ 63 + short: "-s", 64 + long: "--seed", 65 + help: "Full pipeline: build, push, and register with server" 66 + ], 67 + fail_fast: [ 68 + short: "-f", 69 + long: "--fail-fast", 70 + help: "Exit immediately if any step fails (default: continue with successful items)" 71 + ] 72 + ], 73 + options: [ 74 + cache: [ 75 + short: "-c", 76 + long: "--cache", 77 + value_name: "URL", 78 + help: "Cache destination (e.g., 'attic://server:cache', 'ssh://host')", 79 + required: false 80 + ], 81 + jobs: [ 82 + short: "-j", 83 + long: "--jobs", 84 + value_name: "N", 85 + help: "Number of parallel workers", 86 + parser: :integer, 87 + default: 4 88 + ], 89 + tag: [ 90 + short: "-t", 91 + long: "--tag", 92 + value_name: "KEY=VALUE", 93 + help: "Add metadata tag (can be repeated)", 94 + multiple: true 95 + ], 96 + type: [ 97 + long: "--type", 98 + value_name: "TYPE", 99 + help: "Evaluation type: auto, flake, or path (default: auto)", 100 + parser: &parse_nix_type/1, 101 + default: :auto 41 102 ] 42 103 ] 43 104 ] 44 105 ] 45 106 ) 107 + end 108 + 109 + defp version do 110 + Path.expand("../../../VERSION", __DIR__) 111 + |> File.read!() 112 + |> String.trim() 113 + end 114 + 115 + defp parse_nix_type("auto"), do: {:ok, :auto} 116 + defp parse_nix_type("flake"), do: {:ok, :flake} 117 + defp parse_nix_type("path"), do: {:ok, :path} 118 + 119 + defp parse_nix_type(other) do 120 + {:error, "invalid type '#{other}', expected: auto, flake, or path"} 46 121 end 47 122 end
+182
apps/sower_cli/lib/sower_cli/build.ex
··· 1 + defmodule SowerCli.Build do 2 + @moduledoc """ 3 + Build pipeline orchestration. 4 + 5 + Runs a sequence of steps based on flags: 6 + - `--eval-only` → [:eval] 7 + - (default) → [:eval, :build] 8 + - `--push` → [:eval, :build, :push] 9 + - `--seed` → [:eval, :build, :push, :seed] 10 + """ 11 + 12 + use TypedStruct 13 + 14 + alias SowerCli.{Cache, Output} 15 + 16 + typedstruct do 17 + field :flake, String.t() 18 + field :options, map() 19 + field :evals, [Nix.Eval.t()] 20 + field :builds, [Nix.Build.t()] 21 + field :cache_module, module() 22 + field :cache_config, map() 23 + end 24 + 25 + @doc """ 26 + Run the build pipeline based on flags. 27 + 28 + ## Options 29 + - `:cache` - Cache URL (required for push/seed) 30 + - `:jobs` - Number of parallel workers 31 + - `:tag` - Metadata tags (for seed) 32 + - `:fail_fast` - Exit immediately if any step fails (default: false, continue with successful items) 33 + """ 34 + def run(flake, flags, options) do 35 + steps = build_steps(flags) 36 + 37 + options = Map.put(options, :fail_fast, flags[:fail_fast] || false) 38 + 39 + state = %__MODULE__{ 40 + flake: flake, 41 + options: options 42 + } 43 + 44 + case validate_options(steps, options) do 45 + :ok -> 46 + run_steps(steps, state) 47 + 48 + {:error, _} = error -> 49 + error 50 + end 51 + end 52 + 53 + defp build_steps(%{eval_only: true}), do: [:eval] 54 + defp build_steps(%{push: true}), do: [:eval, :build, :push] 55 + defp build_steps(%{seed: true}), do: [:eval, :build, :push, :seed] 56 + defp build_steps(_), do: [:eval, :build] 57 + 58 + defp validate_options(steps, options) do 59 + cond do 60 + :push in steps and is_nil(options.cache) -> 61 + Output.error("--cache is required for --push") 62 + {:error, :missing_cache} 63 + 64 + :seed in steps and is_nil(options.cache) -> 65 + Output.error("--cache is required for --seed") 66 + {:error, :missing_cache} 67 + 68 + not is_nil(options.cache) -> 69 + case Cache.parse_url(options.cache) do 70 + {:ok, _} -> :ok 71 + {:error, msg} -> Output.error(msg) && {:error, :invalid_cache} 72 + end 73 + 74 + true -> 75 + :ok 76 + end 77 + end 78 + 79 + defp run_steps([], %__MODULE__{} = state) do 80 + Output.success("Done") 81 + {:ok, state} 82 + end 83 + 84 + defp run_steps([:eval | rest], %__MODULE__{} = state) do 85 + Output.step("Evaluating #{state.flake}") 86 + 87 + workers = state.options.jobs || 8 88 + eval_type = state.options.type || :auto 89 + 90 + opts = [workers: workers, type: eval_type] 91 + 92 + case Nix.Eval.Jobs.run(state.flake, opts) do 93 + {:ok, %{results: results}} -> 94 + Output.eval_summary(results) 95 + run_steps(rest, %{state | evals: results}) 96 + 97 + {:error, %{results: results}} -> 98 + Output.eval_summary(results) 99 + Output.eval_errors(results) 100 + 101 + if state.options.fail_fast do 102 + {:error, :eval_failed} 103 + else 104 + successful = Enum.filter(results, &(&1.status == :ok)) 105 + 106 + if Enum.empty?(successful) do 107 + {:error, :eval_failed} 108 + else 109 + run_steps(rest, %{state | evals: successful}) 110 + end 111 + end 112 + end 113 + end 114 + 115 + defp run_steps([:build | rest], %__MODULE__{} = state) do 116 + Output.step("Building #{length(state.evals)} derivation(s)") 117 + 118 + workers = state.options.jobs || 4 119 + 120 + case Nix.Build.Jobs.run(state.evals, max_workers: workers) do 121 + {:ok, result} -> 122 + builds = Output.build_summary(result) 123 + run_steps(rest, %{state | builds: builds}) 124 + 125 + {:error, result} -> 126 + builds = Output.build_summary(result) 127 + Output.build_errors(builds) 128 + 129 + if state.options.fail_fast do 130 + {:error, :build_failed} 131 + else 132 + successful = Enum.filter(builds, &(&1.status == :ok)) 133 + 134 + if Enum.empty?(successful) do 135 + {:error, :build_failed} 136 + else 137 + run_steps(rest, %{state | builds: successful}) 138 + end 139 + end 140 + end 141 + end 142 + 143 + defp run_steps([:push | rest], %__MODULE__{} = state) do 144 + Output.step("Pushing to cache") 145 + 146 + {:ok, {cache_module, cache_config}} = Cache.parse_url(state.options.cache) 147 + 148 + store_paths = 149 + state.builds 150 + |> Enum.filter(&(&1.status == :ok)) 151 + |> Enum.map(& &1.store_path) 152 + |> Enum.reject(&is_nil/1) 153 + 154 + if length(store_paths) == 0 do 155 + Output.info("No paths to push") 156 + run_steps(rest, state) 157 + else 158 + result = cache_module.upload(store_paths, cache_config) 159 + Output.push_summary(result) 160 + 161 + case result do 162 + {:ok, _} -> 163 + run_steps(rest, %{state | cache_module: cache_module, cache_config: cache_config}) 164 + 165 + {:error, _reason} -> 166 + {:error, :push_failed} 167 + end 168 + end 169 + end 170 + 171 + defp run_steps([:seed | rest], %__MODULE__{} = state) do 172 + Output.step("Registering seed") 173 + Output.info("TODO: seed registration not yet implemented") 174 + 175 + # Show what would be registered 176 + if state.options.tag do 177 + Output.info("Tags: #{inspect(state.options.tag)}") 178 + end 179 + 180 + run_steps(rest, state) 181 + end 182 + end
+51
apps/sower_cli/lib/sower_cli/cache.ex
··· 1 + defmodule SowerCli.Cache do 2 + @moduledoc """ 3 + Cache URL parsing and backend selection. 4 + 5 + Supports auto-detection of cache backends from URL prefixes: 6 + - `attic://server:cache` -> Nix.Cache.Attic 7 + - `ssh://`, `s3://`, `file://`, `https://` -> Nix.Cache.NixCopy 8 + """ 9 + 10 + @doc """ 11 + Parse a cache URL and return the appropriate backend module and config. 12 + 13 + ## Examples 14 + 15 + iex> SowerCli.Cache.parse_url("attic://myserver:mycache") 16 + {:ok, {Nix.Cache.Attic, %{cache: "myserver:mycache"}}} 17 + 18 + iex> SowerCli.Cache.parse_url("ssh://user@host") 19 + {:ok, {Nix.Cache.NixCopy, %{destination: "ssh://user@host"}}} 20 + 21 + iex> SowerCli.Cache.parse_url("invalid") 22 + {:error, "Unknown cache URL format: invalid"} 23 + """ 24 + def parse_url("attic://" <> rest) do 25 + {:ok, {Nix.Cache.Attic, %{cache: rest}}} 26 + end 27 + 28 + def parse_url("ssh://" <> _ = url) do 29 + {:ok, {Nix.Cache.NixCopy, %{destination: url}}} 30 + end 31 + 32 + def parse_url("s3://" <> _ = url) do 33 + {:ok, {Nix.Cache.NixCopy, %{destination: url}}} 34 + end 35 + 36 + def parse_url("file://" <> _ = url) do 37 + {:ok, {Nix.Cache.NixCopy, %{destination: url}}} 38 + end 39 + 40 + def parse_url("https://" <> _ = url) do 41 + {:ok, {Nix.Cache.NixCopy, %{destination: url}}} 42 + end 43 + 44 + def parse_url("http://" <> _ = url) do 45 + {:ok, {Nix.Cache.NixCopy, %{destination: url}}} 46 + end 47 + 48 + def parse_url(url) do 49 + {:error, "Unknown cache URL format: #{url}. Expected attic://, ssh://, s3://, file://, or https://"} 50 + end 51 + end
+130
apps/sower_cli/lib/sower_cli/output.ex
··· 1 + defmodule SowerCli.Output do 2 + @moduledoc """ 3 + Terminal output formatting for CLI feedback. 4 + """ 5 + 6 + @doc """ 7 + Print a step header. 8 + """ 9 + def step(name) do 10 + IO.puts("\n#{IO.ANSI.cyan()}#{IO.ANSI.bright()}==> #{name}#{IO.ANSI.reset()}") 11 + end 12 + 13 + @doc """ 14 + Print a success message. 15 + """ 16 + def success(message) do 17 + IO.puts("#{IO.ANSI.green()}✓#{IO.ANSI.reset()} #{message}") 18 + end 19 + 20 + @doc """ 21 + Print an error message. 22 + """ 23 + def error(message) do 24 + IO.puts("#{IO.ANSI.red()}✗#{IO.ANSI.reset()} #{message}") 25 + end 26 + 27 + @doc """ 28 + Print an info message. 29 + """ 30 + def info(message) do 31 + IO.puts(" #{message}") 32 + end 33 + 34 + @doc """ 35 + Print eval results summary. 36 + """ 37 + def eval_summary(results) do 38 + total = length(results) 39 + ok_count = Enum.count(results, &(&1.status == :ok)) 40 + error_count = total - ok_count 41 + 42 + if error_count == 0 do 43 + success("Evaluated #{total} derivation(s)") 44 + else 45 + error("Evaluated #{total} derivation(s): #{ok_count} ok, #{error_count} failed") 46 + end 47 + 48 + results 49 + end 50 + 51 + @doc """ 52 + Print build results summary. 53 + """ 54 + def build_summary(%Nix.Build.Jobs.Result{results: builds}) do 55 + total = length(builds) 56 + ok_count = Enum.count(builds, &(&1.status == :ok)) 57 + error_count = total - ok_count 58 + 59 + if error_count == 0 do 60 + success("Built #{total} derivation(s)") 61 + else 62 + error("Built #{total} derivation(s): #{ok_count} ok, #{error_count} failed") 63 + end 64 + 65 + builds 66 + end 67 + 68 + @doc """ 69 + Print push results summary. 70 + """ 71 + def push_summary({:ok, %{uploaded: uploaded, failed: failed}}) do 72 + if length(failed) == 0 do 73 + success("Pushed #{length(uploaded)} path(s) to cache") 74 + else 75 + error("Pushed #{length(uploaded)} path(s), #{length(failed)} failed") 76 + end 77 + end 78 + 79 + def push_summary({:error, reason}) do 80 + error("Push failed: #{inspect(reason)}") 81 + end 82 + 83 + @doc """ 84 + Print a list of store paths. 85 + """ 86 + def store_paths(paths) do 87 + Enum.each(paths, fn path -> 88 + info(path) 89 + end) 90 + end 91 + 92 + @doc """ 93 + Print errors from eval results. 94 + """ 95 + def eval_errors(results) do 96 + failed = Enum.filter(results, &(&1.status != :ok)) 97 + 98 + Enum.each(failed, fn %Nix.Eval{} = eval -> 99 + attr = eval.request.attr || "(root)" 100 + reason = format_eval_error(eval) 101 + error("#{attr}: #{reason}") 102 + end) 103 + end 104 + 105 + defp format_eval_error(%Nix.Eval{status: :memory_limit_exceeded} = eval) do 106 + peak_mb = eval.mem_samples |> Enum.max(fn -> 0 end) |> Kernel./(1024) |> Float.round(1) 107 + limit_mb = (eval.memory_limit_kb / 1024) |> Float.round(1) 108 + "memory limit exceeded (peak: #{peak_mb} MB, limit: #{limit_mb} MB)" 109 + end 110 + 111 + defp format_eval_error(%Nix.Eval{errors: errors}) do 112 + Enum.join(errors, ", ") 113 + end 114 + 115 + @doc """ 116 + Print errors from build results. 117 + """ 118 + def build_errors(builds) do 119 + failed = Enum.filter(builds, &(&1.status != :ok)) 120 + 121 + Enum.each(failed, fn build -> 122 + path = build.drv_path || "(unknown)" 123 + error("#{path}") 124 + 125 + build.log 126 + |> Enum.take(-10) 127 + |> Enum.each(&info/1) 128 + end) 129 + end 130 + end
+3 -1
apps/sower_cli/mix.exs
··· 32 32 # Run "mix help deps" to learn about dependencies. 33 33 defp deps do 34 34 [ 35 - {:optimus, "~> 0.5"} 35 + {:nix, in_umbrella: true}, 36 + {:optimus, "~> 0.5"}, 37 + {:typedstruct, "~> 0.5.4"} 36 38 ] 37 39 end 38 40 end
+8
mix.exs
··· 14 14 runtime_config_path: "config/runtime_agent.exs", 15 15 include_executables_for: [:unix] 16 16 ], 17 + cli: [ 18 + version: version, 19 + applications: [sower_cli: :permanent], 20 + config_path: "./apps/sower_cli/config/config.exs", 21 + runtime_config_path: "./apps/sower_cli/config/runtime.exs", 22 + include_executables_for: [:unix], 23 + steps: [:assemble] 24 + ], 17 25 server: [ 18 26 version: version, 19 27 applications: [sower: :permanent],