Current section
6 Versions
Jump to
Current section
6 Versions
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, |