···11+defmodule Nix.Cache do
22+ @moduledoc """
33+ Behavior for binary cache upload backends.
44+ """
55+66+ @doc """
77+ Upload a batch of store paths to the configured cache.
88+99+ Returns `{:ok, result}` where result contains:
1010+ - `:uploaded` - List of successfully uploaded paths
1111+ - `:failed` - List of tuples `{path, reason}` for failures
1212+1313+ All paths in the input should appear in either `uploaded` or `failed`.
1414+1515+ For backends that upload all-or-nothing, a failure means all paths
1616+ are in the `failed` list, while success means all are in `uploaded`.
1717+1818+ ## Examples
1919+2020+ {:ok, %{uploaded: ["/nix/store/abc-foo", "/nix/store/xyz-bar"], failed: []}}
2121+2222+ {:ok, %{uploaded: ["/nix/store/abc-foo"], failed: [{"/nix/store/xyz-bar", "permission denied"}]}}
2323+2424+ {:error, "invalid destination"}
2525+ """
2626+ @callback upload(paths :: [String.t()] | String.t(), config :: map()) ::
2727+ {:ok, %{uploaded: [String.t()], failed: [{String.t(), term()}]}}
2828+ | {:error, term()}
2929+3030+ @doc """
3131+ Validate the backend configuration.
3232+3333+ Called before upload starts to fail fast on invalid config.
3434+3535+ ## Examples
3636+3737+ :ok
3838+3939+ {:error, "destination required"}
4040+ """
4141+ @callback validate_config(config :: map()) :: :ok | {:error, term()}
4242+4343+ @doc """
4444+ Return a human-readable name for this backend.
4545+4646+ Used for logging and error messages.
4747+ """
4848+ @callback name() :: String.t()
4949+5050+ defmacro __using__(_opts) do
5151+ quote do
5252+ @behaviour Nix.Cache
5353+5454+ defp ensure_store_paths(paths) when is_list(paths) do
5555+ paths
5656+ |> Enum.map(fn
5757+ path when is_binary(path) -> path
5858+ path when is_struct(path, Nix.Build) -> path.store_path
5959+ end)
6060+ end
6161+ end
6262+ end
6363+end
+175
apps/nix/lib/nix/cache/attic.ex
···11+defmodule Nix.Cache.Attic do
22+ @moduledoc """
33+ Binary cache backend using Attic.
44+55+ Attic is a Nix binary cache server with efficient uploads and built-in
66+ parallelism. See: https://github.com/zhaofengli/attic
77+88+ ## Configuration
99+1010+ %{
1111+ cache: "server:cache-name", # Format: "server-name:cache-name"
1212+ jobs: 5 # Optional: parallel upload jobs (default: 5)
1313+ }
1414+1515+ The server and cache must be configured in `~/.config/attic/config.toml`:
1616+1717+ [cache."server:cache-name"]
1818+ endpoint = "https://cache.example.com"
1919+ token = "your-auth-token"
2020+2121+ ## Example
2222+2323+ config = %{cache: "production:my-cache", jobs: 8}
2424+ {:ok, result} = Nix.Cache.Attic.upload(config, [
2525+ "/nix/store/abc123-foo",
2626+ "/nix/store/xyz789-bar"
2727+ ])
2828+ """
2929+3030+ use Nix.Cache
3131+3232+ require Logger
3333+3434+ @impl Nix.Cache
3535+ def name(), do: "attic"
3636+3737+ @impl Nix.Cache
3838+ def validate_config(%{cache: cache}) when is_binary(cache) and byte_size(cache) > 0 do
3939+ # Validate cache format: "server:cache-name"
4040+ if String.contains?(cache, ":") do
4141+ :ok
4242+ else
4343+ {:error, "cache must be in format 'server:cache-name'"}
4444+ end
4545+ end
4646+4747+ def validate_config(_) do
4848+ {:error, "cache required (format: 'server:cache-name')"}
4949+ end
5050+5151+ @impl Nix.Cache
5252+ def upload(path, config) when not is_list(path), do: upload([path], config)
5353+5454+ def upload(paths, config) when is_list(paths) do
5555+ if length(paths) == 0 do
5656+ {:ok, %{uploaded: [], failed: []}}
5757+ else
5858+ paths = ensure_store_paths(paths)
5959+6060+ %{cache: cache} = config
6161+ jobs = Map.get(config, :jobs, 5)
6262+6363+ attic_cmd = System.find_executable("attic")
6464+6565+ if is_nil(attic_cmd) do
6666+ {:error, "attic command not found in PATH"}
6767+ else
6868+ cmd = [attic_cmd, "push", "--stdin", "-j", to_string(jobs), cache]
6969+ stdin_input = Enum.join(paths, "\n")
7070+7171+ Logger.debug(
7272+ msg: "Uploading to cache",
7373+ backend: "attic",
7474+ cache: cache,
7575+ path_count: length(paths),
7676+ jobs: jobs
7777+ )
7878+7979+ case run_with_stdin(cmd, stdin_input) do
8080+ {:ok, _result} ->
8181+ Logger.info(
8282+ msg: "Upload succeeded",
8383+ backend: "attic",
8484+ cache: cache,
8585+ path_count: length(paths)
8686+ )
8787+8888+ {:ok, %{uploaded: paths, failed: []}}
8989+9090+ {:error, %{exit_status: exit_code} = result} ->
9191+ output = Map.get(result, :stderr, "") <> Map.get(result, :stdout, "")
9292+9393+ Logger.error(
9494+ msg: "Upload failed",
9595+ backend: "attic",
9696+ cache: cache,
9797+ exit_code: exit_code,
9898+ output: String.slice(output, 0, 500)
9999+ )
100100+101101+ error_reason = parse_error(output, exit_code)
102102+ {:error, error_reason}
103103+ end
104104+ end
105105+ end
106106+ end
107107+108108+ defp run_with_stdin(cmd, stdin_data) do
109109+ case :exec.run(cmd, [:stdin, :stdout, :stderr, :monitor]) do
110110+ {:ok, _pid, ospid} ->
111111+ :ok = :exec.send(ospid, stdin_data)
112112+ :ok = :exec.send(ospid, :eof)
113113+ await_completion(ospid, [], [])
114114+115115+ {:error, reason} ->
116116+ {:error, %{exit_status: 1, stderr: to_string(reason), stdout: ""}}
117117+ end
118118+ end
119119+120120+ defp await_completion(ospid, stdout, stderr) do
121121+ receive do
122122+ {:stdout, ^ospid, data} ->
123123+ await_completion(ospid, [data | stdout], stderr)
124124+125125+ {:stderr, ^ospid, data} ->
126126+ await_completion(ospid, stdout, [data | stderr])
127127+128128+ {:DOWN, _ospid, :process, _pid, :normal} ->
129129+ {:ok, finalize_output(stdout, stderr)}
130130+131131+ {:DOWN, _ospid, :process, _pid, {:exit_status, status}} ->
132132+ {:error, finalize_output(stdout, stderr) |> Map.put(:exit_status, status)}
133133+ after
134134+ # 30 minute timeout for large uploads
135135+ 30 * 60 * 1000 ->
136136+ :exec.kill(ospid, :sigterm)
137137+ {:error, %{exit_status: 1, stderr: "timeout", stdout: ""}}
138138+ end
139139+ end
140140+141141+ defp finalize_output(stdout, stderr) do
142142+ %{
143143+ stdout: stdout |> Enum.reverse() |> IO.iodata_to_binary(),
144144+ stderr: stderr |> Enum.reverse() |> IO.iodata_to_binary()
145145+ }
146146+ end
147147+148148+ defp parse_error(output, exit_code) do
149149+ cond do
150150+ String.contains?(output, "401 Unauthorized") or
151151+ String.contains?(output, "permission denied") ->
152152+ "authentication failed - check token in attic config"
153153+154154+ String.contains?(output, "404 Not Found") ->
155155+ "cache not found"
156156+157157+ String.contains?(output, "Connection refused") or
158158+ String.contains?(output, "failed to connect") ->
159159+ "connection refused - check cache endpoint"
160160+161161+ String.contains?(output, "No cache named") ->
162162+ "cache not configured in ~/.config/attic/config.toml"
163163+164164+ true ->
165165+ # Return exit code and first line of output
166166+ first_line =
167167+ output
168168+ |> String.split("\n", parts: 2)
169169+ |> List.first()
170170+ |> String.slice(0, 200)
171171+172172+ {exit_code, first_line}
173173+ end
174174+ end
175175+end
+118
apps/nix/lib/nix/cache/nix_copy.ex
···11+defmodule Nix.Cache.NixCopy do
22+ @moduledoc """
33+ Binary cache backend using `nix copy`.
44+55+ Supports uploading to various destinations:
66+ - SSH: `ssh://user@host`
77+ - S3: `s3://bucket-name` (requires AWS credentials configured)
88+ - HTTP: `http://cache.example.com` or `https://cache.example.com`
99+ - Local: `file:///path/to/cache`
1010+1111+ ## Configuration
1212+1313+ %{destination: "ssh://user@host/nix-cache"}
1414+ %{destination: "s3://my-bucket"}
1515+ %{destination: "https://cache.example.com"}
1616+1717+ ## Example
1818+1919+ config = %{destination: "ssh://builder@cache.example.com"}
2020+ {:ok, result} = Nix.Cache.NixCopy.upload(config, [
2121+ "/nix/store/abc123-foo",
2222+ "/nix/store/xyz789-bar"
2323+ ])
2424+ """
2525+2626+ use Nix.Cache
2727+2828+ require Logger
2929+3030+ @impl Nix.Cache
3131+ def name(), do: "nix copy"
3232+3333+ @impl Nix.Cache
3434+ def validate_config(%{destination: dest}) when is_binary(dest) and byte_size(dest) > 0 do
3535+ :ok
3636+ end
3737+3838+ def validate_config(_) do
3939+ {:error, "destination required (e.g., ssh://host, s3://bucket, https://cache)"}
4040+ end
4141+4242+ @impl Nix.Cache
4343+ def upload(path, config) when not is_list(path), do: upload([path], config)
4444+4545+ def upload(paths, %{destination: dest}) when is_list(paths) do
4646+ if length(paths) == 0 do
4747+ {:ok, %{uploaded: [], failed: []}}
4848+ else
4949+ nix_cmd = System.find_executable("nix")
5050+ paths = ensure_store_paths(paths)
5151+5252+ if is_nil(nix_cmd) do
5353+ {:error, "nix command not found in PATH"}
5454+ else
5555+ args = ["copy", "--to", dest] ++ paths
5656+5757+ Logger.debug(
5858+ msg: "Uploading to cache",
5959+ backend: "nix copy",
6060+ destination: dest,
6161+ path_count: length(paths)
6262+ )
6363+6464+ case System.cmd(nix_cmd, args, stderr_to_stdout: true) do
6565+ {_output, 0} ->
6666+ Logger.info(
6767+ msg: "Upload succeeded",
6868+ backend: "nix copy",
6969+ destination: dest,
7070+ path_count: length(paths)
7171+ )
7272+7373+ {:ok, %{uploaded: paths, failed: []}}
7474+7575+ {output, exit_code} ->
7676+ # nix copy is all-or-nothing - if it fails, all paths failed
7777+ Logger.error(
7878+ msg: "Upload failed",
7979+ backend: "nix copy",
8080+ destination: dest,
8181+ exit_code: exit_code,
8282+ output: String.slice(output, 0, 500)
8383+ )
8484+8585+ error_reason = parse_error(output, exit_code)
8686+ {:error, error_reason}
8787+ end
8888+ end
8989+ end
9090+ end
9191+9292+ defp parse_error(output, exit_code) do
9393+ cond do
9494+ String.contains?(output, "permission denied") or
9595+ String.contains?(output, "Permission denied") ->
9696+ "permission denied"
9797+9898+ String.contains?(output, "No such file or directory") ->
9999+ "destination not found"
100100+101101+ String.contains?(output, "Connection refused") ->
102102+ "connection refused"
103103+104104+ String.contains?(output, "Host key verification failed") ->
105105+ "SSH host key verification failed"
106106+107107+ true ->
108108+ # Return exit code and first line of output
109109+ first_line =
110110+ output
111111+ |> String.split("\n", parts: 2)
112112+ |> List.first()
113113+ |> String.slice(0, 200)
114114+115115+ {exit_code, first_line}
116116+ end
117117+ end
118118+end