Packages

Safe OS process execution for Elixir with NIF-based backpressure, zero zombie processes, PTY support, and cgroup isolation.

Current section

6 Versions

Jump to

Compare versions

4 files changed
+235 additions
-13 deletions
  @@ -3,7 +3,7 @@
3 3 <<"https://github.com/nyo16/net_runner/blob/master/CHANGELOG.md">>},
4 4 {<<"GitHub">>,<<"https://github.com/nyo16/net_runner">>}]}.
5 5 {<<"name">>,<<"net_runner">>}.
6 - {<<"version">>,<<"1.0.4">>}.
6 + {<<"version">>,<<"1.1.0">>}.
7 7 {<<"description">>,
8 8 <<"Safe OS process execution for Elixir with NIF-based backpressure, zero zombie processes, PTY support, and cgroup isolation.">>}.
9 9 {<<"elixir">>,<<"~> 1.17">>}.
  @@ -15,13 +15,14 @@
15 15 <<"lib/net_runner/process/stats.ex">>,<<"lib/net_runner/process/pipe.ex">>,
16 16 <<"lib/net_runner/process/nif.ex">>,<<"lib/net_runner/process/exec.ex">>,
17 17 <<"lib/net_runner/process/state.ex">>,<<"lib/net_runner/application.ex">>,
18 - <<"lib/net_runner/stream.ex">>,<<"lib/net_runner/daemon.ex">>,
19 - <<"lib/net_runner.ex">>,<<"c_src/net_runner_nif.c">>,<<"c_src/shepherd.c">>,
20 - <<"c_src/protocol.h">>,<<"c_src/utils.h">>,<<"docs">>,
21 - <<"docs/backpressure.md">>,<<"docs/comparison.md">>,<<"docs/decisions.md">>,
22 - <<"docs/protocol.md">>,<<"docs/phases.md">>,<<"docs/architecture.md">>,
23 - <<"docs/modules.md">>,<<"priv/.gitkeep">>,<<"Makefile">>,<<"mix.exs">>,
24 - <<"README.md">>,<<"CHANGELOG.md">>,<<"LICENSE">>]}.
18 + <<"lib/net_runner/command.ex">>,<<"lib/net_runner/stream.ex">>,
19 + <<"lib/net_runner/daemon.ex">>,<<"lib/net_runner.ex">>,
20 + <<"c_src/net_runner_nif.c">>,<<"c_src/shepherd.c">>,<<"c_src/protocol.h">>,
21 + <<"c_src/utils.h">>,<<"docs">>,<<"docs/backpressure.md">>,
22 + <<"docs/comparison.md">>,<<"docs/decisions.md">>,<<"docs/protocol.md">>,
23 + <<"docs/phases.md">>,<<"docs/architecture.md">>,<<"docs/modules.md">>,
24 + <<"priv/.gitkeep">>,<<"Makefile">>,<<"mix.exs">>,<<"README.md">>,
25 + <<"CHANGELOG.md">>,<<"LICENSE">>]}.
25 26 {<<"licenses">>,[<<"Apache-2.0">>]}.
26 27 {<<"requirements">>,
27 28 [[{<<"name">>,<<"elixir_make">>},
  @@ -27,6 +27,8 @@ defmodule NetRunner do
27 27 @doc """
28 28 Runs a command and collects all output.
29 29
30 + Accepts either a command list `[executable | args]` or a `%NetRunner.Command{}` struct.
31 +
30 32 Returns `{output, exit_status}` where output is the concatenated stdout.
31 33
32 34 ## Options
  @@ -50,8 +52,25 @@ defmodule NetRunner do
50 52
51 53 {:error, {:max_output_exceeded, _partial}} =
52 54 NetRunner.run(["sh", "-c", "yes"], max_output_size: 1000)
55 +
56 + # With a Command struct:
57 + cmd = NetRunner.Command.new("echo", ["hello"], timeout: 5_000)
58 + {output, 0} = NetRunner.run(cmd)
53 59 """
54 - def run([cmd | args], opts \\ []) do
60 + @spec run(NetRunner.Command.t() | [String.t()], keyword()) ::
61 + {binary(), non_neg_integer()} | {:error, term()}
62 + def run(command, opts \\ [])
63 +
64 + def run(%NetRunner.Command{} = command, opts) do
65 + {cmd, args, merged_opts} = NetRunner.Command.to_cmd_args_opts(command, opts)
66 + run_impl(cmd, args, merged_opts)
67 + end
68 +
69 + def run([cmd | args], opts) do
70 + run_impl(cmd, args, opts)
71 + end
72 +
73 + defp run_impl(cmd, args, opts) do
55 74 input = Keyword.get(opts, :input, nil)
56 75 timeout = Keyword.get(opts, :timeout, nil)
57 76 max_output_size = Keyword.get(opts, :max_output_size, nil)
  @@ -84,6 +103,8 @@ defmodule NetRunner do
84 103 @doc """
85 104 Creates a stream for incremental I/O with the command.
86 105
106 + Accepts either a command list `[executable | args]` or a `%NetRunner.Command{}` struct.
107 +
87 108 Returns a `Stream` that yields stdout binary chunks.
88 109 Raises on process start failure.
89 110
  @@ -102,15 +123,38 @@ defmodule NetRunner do
102 123 NetRunner.stream!(~w(tr a-z A-Z), input: "hello")
103 124 |> Enum.join()
104 125 # => "HELLO"
126 +
127 + # With a Command struct:
128 + cmd = NetRunner.Command.new("cat", [], input: "hello")
129 + NetRunner.stream!(cmd) |> Enum.to_list()
105 130 """
106 - def stream!([cmd | args], opts \\ []) do
131 + @spec stream!(NetRunner.Command.t() | [String.t()], keyword()) :: Enumerable.t()
132 + def stream!(command, opts \\ [])
133 +
134 + def stream!(%NetRunner.Command{} = command, opts) do
135 + {cmd, args, merged_opts} = NetRunner.Command.to_cmd_args_opts(command, opts)
136 + NRStream.stream!(cmd, args, merged_opts)
137 + end
138 +
139 + def stream!([cmd | args], opts) do
107 140 NRStream.stream!(cmd, args, opts)
108 141 end
109 142
110 143 @doc """
111 144 Like `stream!/2` but returns `{:ok, stream}` or `{:error, reason}`.
145 +
146 + Accepts either a command list `[executable | args]` or a `%NetRunner.Command{}` struct.
112 147 """
113 - def stream([cmd | args], opts \\ []) do
148 + @spec stream(NetRunner.Command.t() | [String.t()], keyword()) ::
149 + {:ok, Enumerable.t()} | {:error, term()}
150 + def stream(command, opts \\ [])
151 +
152 + def stream(%NetRunner.Command{} = command, opts) do
153 + {cmd, args, merged_opts} = NetRunner.Command.to_cmd_args_opts(command, opts)
154 + NRStream.stream(cmd, args, merged_opts)
155 + end
156 +
157 + def stream([cmd | args], opts) do
114 158 NRStream.stream(cmd, args, opts)
115 159 end
  @@ -0,0 +1,171 @@
1 + defmodule NetRunner.Command do
2 + @moduledoc """
3 + Reusable command templates with default arguments and options.
4 +
5 + Define commands once, reuse them everywhere:
6 +
7 + defmodule MyApp.Commands do
8 + use NetRunner.Command
9 +
10 + defcommand :curl, "curl",
11 + args: ["-s", "--compressed", "-L"],
12 + timeout: 10_000
13 +
14 + defcommand :rg, "rg",
15 + args: ["--no-heading", "--color=never"],
16 + stderr: :consume
17 + end
18 +
19 + # Returns a %Command{} struct:
20 + cmd = MyApp.Commands.curl(["https://example.com"])
21 +
22 + # Pass to NetRunner API:
23 + NetRunner.run(cmd)
24 + NetRunner.run(cmd, timeout: 30_000)
25 + NetRunner.stream!(cmd)
26 +
27 + # Introspection:
28 + MyApp.Commands.__commands__()
29 + #=> [:curl, :rg]
30 +
31 + Commands can also be built at runtime without macros:
32 +
33 + cmd = NetRunner.Command.new("echo", ["hello"], timeout: 5_000)
34 + NetRunner.run(cmd)
35 + """
36 +
37 + @enforce_keys [:executable]
38 + defstruct [:executable, args: [], opts: []]
39 +
40 + @type t :: %__MODULE__{
41 + executable: String.t(),
42 + args: [String.t()],
43 + opts: keyword()
44 + }
45 +
46 + @doc """
47 + Creates a new command struct.
48 +
49 + ## Examples
50 +
51 + iex> NetRunner.Command.new("echo", ["hello"])
52 + %NetRunner.Command{executable: "echo", args: ["hello"], opts: []}
53 +
54 + iex> NetRunner.Command.new("curl", ["-s"], timeout: 10_000)
55 + %NetRunner.Command{executable: "curl", args: ["-s"], opts: [timeout: 10_000]}
56 + """
57 + @spec new(String.t(), [String.t()], keyword()) :: t()
58 + def new(executable, args \\ [], opts \\ []) do
59 + unless is_binary(executable) do
60 + raise ArgumentError, "executable must be a string, got: #{inspect(executable)}"
61 + end
62 +
63 + unless is_list(args) do
64 + raise ArgumentError, "args must be a list, got: #{inspect(args)}"
65 + end
66 +
67 + %__MODULE__{executable: executable, args: args, opts: opts}
68 + end
69 +
70 + @doc """
71 + Decomposes a command struct into `{executable, args, opts}`.
72 +
73 + Runtime `override_opts` are merged on top of the command's default opts,
74 + so callers can override specific options per invocation.
75 +
76 + ## Examples
77 +
78 + iex> cmd = NetRunner.Command.new("echo", ["hello"], timeout: 5_000)
79 + iex> NetRunner.Command.to_cmd_args_opts(cmd)
80 + {"echo", ["hello"], [timeout: 5_000]}
81 +
82 + iex> cmd = NetRunner.Command.new("echo", ["hello"], timeout: 5_000)
83 + iex> NetRunner.Command.to_cmd_args_opts(cmd, timeout: 30_000)
84 + {"echo", ["hello"], [timeout: 30_000]}
85 + """
86 + @spec to_cmd_args_opts(t(), keyword()) :: {String.t(), [String.t()], keyword()}
87 + def to_cmd_args_opts(%__MODULE__{} = command, override_opts \\ []) do
88 + {command.executable, command.args, Keyword.merge(command.opts, override_opts)}
89 + end
90 +
91 + @doc false
92 + defmacro __using__(_opts) do
93 + quote do
94 + import NetRunner.Command, only: [defcommand: 2, defcommand: 3]
95 + Module.register_attribute(__MODULE__, :net_runner_commands, accumulate: true)
96 + @before_compile NetRunner.Command
97 + end
98 + end
99 +
100 + @doc false
101 + defmacro __before_compile__(env) do
102 + commands = Module.get_attribute(env.module, :net_runner_commands) |> Enum.reverse()
103 +
104 + quote do
105 + @doc "Returns the list of command names defined in this module."
106 + @spec __commands__() :: [atom()]
107 + def __commands__, do: unquote(commands)
108 + end
109 + end
110 +
111 + @doc """
112 + Defines a reusable command template.
113 +
114 + Generates a function with the given `name` that returns a `%NetRunner.Command{}` struct.
115 +
116 + ## Options
117 +
118 + * `:args` - default arguments prepended to any extra args at call time
119 + * All other options (`:timeout`, `:stderr`, `:pty`, etc.) become default
120 + process options, overridable when passed to `NetRunner.run/2` et al.
121 +
122 + ## Examples
123 +
124 + defcommand :echo, "echo"
125 +
126 + defcommand :curl, "curl",
127 + args: ["-s", "--compressed"],
128 + timeout: 10_000
129 +
130 + The above generates:
131 +
132 + def curl(extra_args \\\\ [])
133 +
134 + So that:
135 +
136 + curl(["https://example.com"])
137 + #=> %NetRunner.Command{
138 + #=> executable: "curl",
139 + #=> args: ["-s", "--compressed", "https://example.com"],
140 + #=> opts: [timeout: 10_000]
141 + #=> }
142 + """
143 + defmacro defcommand(name, executable, definition_opts \\ []) do
144 + quote bind_quoted: [name: name, executable: executable, definition_opts: definition_opts] do
145 + unless is_binary(executable) do
146 + raise ArgumentError,
147 + "defcommand executable must be a string, got: #{inspect(executable)}"
148 + end
149 +
150 + default_args = Keyword.get(definition_opts, :args, [])
151 +
152 + unless is_list(default_args) do
153 + raise ArgumentError, "defcommand :args must be a list, got: #{inspect(default_args)}"
154 + end
155 +
156 + default_opts = Keyword.drop(definition_opts, [:args])
157 +
158 + @net_runner_commands name
159 +
160 + @doc "Builds a `%NetRunner.Command{}` for `#{executable}` with optional extra args."
161 + @spec unquote(name)([String.t()]) :: NetRunner.Command.t()
162 + def unquote(name)(extra_args \\ []) do
163 + %NetRunner.Command{
164 + executable: unquote(executable),
165 + args: unquote(default_args) ++ extra_args,
166 + opts: unquote(Macro.escape(default_opts))
167 + }
168 + end
169 + end
170 + end
171 + end
  @@ -1,7 +1,7 @@
1 1 defmodule NetRunner.MixProject do
2 2 use Mix.Project
3 3
4 - @version "1.0.4"
4 + @version "1.1.0"
5 5 @source_url "https://github.com/nyo16/net_runner"
6 6
7 7 def project do
  @@ -76,7 +76,13 @@ defmodule NetRunner.MixProject do
76 76 "docs/modules.md"
77 77 ],
78 78 groups_for_modules: [
79 - "Public API": [NetRunner, NetRunner.Process, NetRunner.Stream, NetRunner.Daemon],
79 + "Public API": [
80 + NetRunner,
81 + NetRunner.Command,
82 + NetRunner.Process,
83 + NetRunner.Stream,
84 + NetRunner.Daemon
85 + ],
80 86 Internals: [
81 87 NetRunner.Process.Exec,
82 88 NetRunner.Process.Nif,