├── .formatter.exs ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── exexec.ex └── exexec │ ├── extras.ex │ ├── stream_output.ex │ └── to_erl.ex ├── mix.exs ├── mix.lock └── test ├── exexec └── to_erl_test.exs ├── exexec_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | exexec-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: false 3 | language: elixir 4 | elixir: 5 | - 1.8.1 6 | otp_release: 7 | - 21.3 8 | env: 9 | - MIX_ENV=test 10 | - MIX_ENV=test ELIXIR_ERL_OPTIONS="+T 9" 11 | before_script: 12 | - mix compile --warnings-as-errors 13 | script: 14 | - mix test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ericentin/exexec.svg?branch=master)](https://travis-ci.org/ericentin/exexec) [![Hex.pm package version](https://img.shields.io/hexpm/v/exexec.svg)](https://hex.pm/packages/exexec) [![Hex.pm package license](https://img.shields.io/hexpm/l/exexec.svg)](https://github.com/ericentin/exexec/blob/master/LICENSE) 2 | 3 | # Exexec 4 | 5 | Execute and control OS processes from Elixir. 6 | 7 | An idiomatic Elixir wrapper for Serge Aleynikov's excellent 8 | [erlexec](https://github.com/saleyn/erlexec), Exexec provides an Elixir 9 | interface as well as some nice Elixir-y goodies on top. 10 | 11 | ## Installation 12 | 13 | The package can be installed 14 | by adding `exexec` to your list of dependencies in `mix.exs`: 15 | 16 | ```elixir 17 | def deps do 18 | [ 19 | {:exexec, "~> 0.2"} 20 | ] 21 | end 22 | ``` 23 | 24 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 25 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 26 | be found at [https://hexdocs.pm/exexec](https://hexdocs.pm/exexec). 27 | -------------------------------------------------------------------------------- /lib/exexec.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexec do 2 | @moduledoc """ 3 | Execute and control OS processes from Elixir. 4 | 5 | An idiomatic Elixir wrapper for Serge Aleynikov's excellent 6 | [erlexec](https://github.com/saleyn/erlexec), Exexec provides an Elixir 7 | interface as well as some nice Elixir-y goodies on top. 8 | """ 9 | 10 | import Exexec.ToErl 11 | import Kernel, except: [send: 2] 12 | 13 | @type command :: String.t() | [Path.t() | [String.t()]] 14 | 15 | @type os_pid :: non_neg_integer 16 | 17 | @type gid :: non_neg_integer 18 | 19 | @type output_file_option :: 20 | {:append, boolean} 21 | | {:mode, non_neg_integer} 22 | 23 | @type output_device :: :stdout | :stderr 24 | 25 | @type output_file_options :: [output_file_option] 26 | 27 | @type output_device_option :: 28 | boolean 29 | | :null 30 | | :close 31 | | :print 32 | | Path.t() 33 | | {Path.t(), output_file_options} 34 | | pid 35 | | :stream 36 | | (output_device, os_pid, binary -> any) 37 | 38 | @type command_option :: 39 | {:monitor, boolean} 40 | | {:sync, boolean} 41 | | {:executable, Path.t()} 42 | | {:cd, Path.t()} 43 | | {:env, %{String.t() => String.t()}} 44 | | {:kill_command, String.t()} 45 | | {:kill_timeout, non_neg_integer} 46 | | {:kill_group, boolean} 47 | | {:group, String.t()} 48 | | {:user, String.t()} 49 | | {:success_exit_code, exit_code} 50 | | {:nice, -20..20} 51 | | {:stdin, boolean | :null | :close | Path.t()} 52 | | {:stdout, :stderr | output_device_option} 53 | | {:stderr, :stdout | output_device_option} 54 | | {:pty, boolean} 55 | 56 | @type command_options :: [command_option] 57 | 58 | @type exec_option :: 59 | {:debug, boolean | non_neg_integer} 60 | | {:root, boolean} 61 | | {:verbose, boolean} 62 | | {:args, [String.t()]} 63 | | {:alarm, non_neg_integer} 64 | | {:user, String.t()} 65 | | {:limit_users, [String.t()]} 66 | | {:port_path, Path.t()} 67 | | {:env, %{String.t() => String.t()}} 68 | 69 | @type exec_options :: [exec_option] 70 | 71 | @type signal :: pos_integer 72 | 73 | @type on_run :: 74 | {:ok, pid, os_pid} 75 | | {:ok, [{output_device, [binary]}]} 76 | | {:ok, pid, os_pid, [{:stream, Enumerable.t(), pid}]} 77 | | {:error, any} 78 | 79 | @type exit_code :: non_neg_integer 80 | 81 | @doc """ 82 | Send `signal` to `pid`. 83 | 84 | `pid` can be an `Exexec` pid, OS pid, or port. 85 | """ 86 | @spec kill(pid | os_pid | port, signal) :: :ok | {:error, any} 87 | defdelegate kill(pid, signal), to: :exec 88 | 89 | @doc """ 90 | Start an `Exexec` process to manage existing `os_pid` with options `options`. 91 | 92 | `os_pid` can also be a port. 93 | """ 94 | @spec manage(os_pid | port) :: {:ok, pid, os_pid} | {:error, any} 95 | @spec manage(os_pid | port, command_options) :: {:ok, pid, os_pid} | {:error, any} 96 | def manage(os_pid, options \\ []), 97 | do: :exec.manage(os_pid, command_options_to_erl(options)) 98 | 99 | @doc """ 100 | Returns the OS pid for `Exexec` process `pid`. 101 | """ 102 | @spec os_pid(pid) :: {:ok, os_pid} | {:error, any} 103 | def os_pid(pid) do 104 | case :exec.ospid(pid) do 105 | {:error, reason} -> {:error, reason} 106 | os_pid -> {:ok, os_pid} 107 | end 108 | end 109 | 110 | @doc """ 111 | Returns the `Exexec` pid for `os_pid`. 112 | """ 113 | @spec pid(os_pid) :: {:ok, pid} | {:error, any} 114 | def pid(os_pid) do 115 | case :exec.pid(os_pid) do 116 | {:error, reason} -> {:error, reason} 117 | :undefined -> {:error, :undefined} 118 | pid -> {:ok, pid} 119 | end 120 | end 121 | 122 | @doc """ 123 | Run an external `command` with `options`. 124 | """ 125 | @spec run(command) :: on_run 126 | @spec run(command, command_options) :: on_run 127 | def run(command, options \\ []) do 128 | prepare_run_exec(:run, command, options) 129 | end 130 | 131 | @doc """ 132 | Run an external `command` with `options`, linking to the current process. 133 | 134 | If the external process exits with code 0, the linked process will not exit. 135 | """ 136 | @spec run_link(command) :: on_run 137 | @spec run_link(command, command_options) :: on_run 138 | def run_link(command, options \\ []) do 139 | prepare_run_exec(:run_link, command, options) 140 | end 141 | 142 | @doc """ 143 | Send `data` to the stdin of `pid`. 144 | 145 | `pid` can be an `Exexec` pid or an OS pid. 146 | """ 147 | @spec send(pid | os_pid, binary | :eof) :: :ok 148 | defdelegate send(pid, data), to: :exec 149 | 150 | @doc """ 151 | Change group ID of `os_pid` to `gid`. 152 | """ 153 | @spec set_gid(os_pid, gid) :: :ok | {:error, any} 154 | defdelegate set_gid(os_pid, gid), to: :exec, as: :setpgid 155 | 156 | @doc """ 157 | Convert integer `signal` to atom, or return `signal`. 158 | """ 159 | @spec signal(signal) :: atom | integer 160 | defdelegate signal(signal), to: :exec 161 | 162 | @doc """ 163 | Start `Exexec`. 164 | """ 165 | @spec start() :: {:ok, pid} | {:error, any} 166 | defdelegate start(), to: :exec 167 | 168 | @doc """ 169 | Start `Exexec` with `options`. 170 | """ 171 | @spec start(exec_options) :: {:ok, pid} | {:error, any} 172 | def start(options) do 173 | :exec.start(exec_options_to_erl(options)) 174 | end 175 | 176 | @doc """ 177 | Start `Exexec` and link to calling process. 178 | """ 179 | @spec start_link :: {:ok, pid} | {:error, any} 180 | def start_link(), do: start_link([]) 181 | 182 | @doc """ 183 | Start `Exexec` with `options` and link to calling process. 184 | """ 185 | @spec start_link(exec_options) :: {:ok, pid} | {:error, any} 186 | def start_link(options) do 187 | :exec.start_link(exec_options_to_erl(options)) 188 | end 189 | 190 | @doc """ 191 | Interpret `exit_code`. 192 | 193 | If the program exited by signal, returns `{:signal, signal, core}` where `signal` 194 | is the atom or integer signal and `core` is whether a core file was generated. 195 | """ 196 | @spec status(exit_code) :: {:status, signal} | {:signal, signal | :atom, boolean} 197 | defdelegate status(exit_code), to: :exec 198 | 199 | @doc """ 200 | Stop `pid`. 201 | 202 | `pid` can be an `Exexec` pid, OS pid, or port. 203 | 204 | The OS process is terminated gracefully. If `:kill_command` was specified, 205 | that command is executed and a timer is started. If the process doesn't exit 206 | immediately, then by default after 5 seconds SIGKILL will be sent to the process. 207 | """ 208 | @spec stop(pid | os_pid | port) :: :ok | {:error, any} 209 | defdelegate stop(pid), to: :exec 210 | 211 | @doc """ 212 | Stop `pid` and wait for it to exit for `timeout` milliseconds. 213 | 214 | See `Exexec.stop/1`. 215 | """ 216 | @spec stop_and_wait(pid | os_pid | port) :: :ok | {:error, any} 217 | @spec stop_and_wait(pid | os_pid | port, integer) :: :ok | {:error, any} 218 | def stop_and_wait(pid, timeout \\ 5_000), do: :exec.stop_and_wait(pid, timeout) 219 | 220 | @doc """ 221 | Return a list of OS pids managed by `Exexec`. 222 | """ 223 | @spec which_children() :: [os_pid] 224 | defdelegate which_children(), to: :exec 225 | 226 | defp handle_extras([{:stdout, :stream}]) do 227 | {:ok, stream, server} = Exexec.StreamOutput.create_line_stream() 228 | {:ok, [{:stdout, server}], [{:stream, stream, server}]} 229 | end 230 | 231 | defp handle_extras(_) do 232 | {:ok, [], []} 233 | end 234 | 235 | defp prepare_run_exec(type, command, options) do 236 | with :ok <- Exexec.Extras.validate(options) do 237 | command = command_to_erl(command) 238 | {extras, options} = Exexec.Extras.split(options) 239 | {:ok, additional_options, stream} = handle_extras(extras) 240 | options = command_options_to_erl(options) 241 | 242 | case {run_exec(type, command, options ++ additional_options), stream} do 243 | {result, []} -> 244 | result 245 | 246 | {{:ok, pid, os_pid}, [{:stream, stream, server_pid}]} -> 247 | Kernel.send(server_pid, {:monitor, pid}) 248 | {:ok, pid, os_pid, [{:stream, stream, server_pid}]} 249 | end 250 | else 251 | {:error, error} -> 252 | {:error, error} 253 | end 254 | end 255 | 256 | defp run_exec(:run, command, options) do 257 | :exec.run(command, options) 258 | end 259 | 260 | defp run_exec(:run_link, command, options) do 261 | :exec.run_link(command, options) 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /lib/exexec/extras.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexec.Extras do 2 | @moduledoc """ 3 | Extra functionality that is not handled by erlexec 4 | """ 5 | 6 | def extra_option?({:stdout, :stream}), do: true 7 | def extra_option?(_), do: false 8 | 9 | def split(options) do 10 | options |> Enum.split_with(&extra_option?/1) 11 | end 12 | 13 | def validate(options) do 14 | if :sync in options and {:stdout, :stream} in options do 15 | {:error, :badarg} 16 | else 17 | :ok 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/exexec/stream_output.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexec.StreamOutput do 2 | def create_line_stream do 3 | {:ok, pid} = Exexec.StreamOutput.Server.start() 4 | stream = Stream.unfold(pid, &get_line/1) 5 | {:ok, stream, pid} 6 | end 7 | 8 | def stop(pid) do 9 | send(pid, :stop) 10 | end 11 | 12 | defp get_line(pid) do 13 | try do 14 | case GenServer.call(pid, :get_data, :infinity) do 15 | nil -> nil 16 | data -> {data, pid} 17 | end 18 | catch 19 | :exit, {:noproc, _} -> nil 20 | end 21 | end 22 | 23 | defmodule Server do 24 | use GenServer 25 | require Record 26 | Record.defrecordp(:state, [:done, :chunks, :client, :port]) 27 | 28 | def start() do 29 | GenServer.start(__MODULE__, state(chunks: :queue.new())) 30 | end 31 | 32 | def init(state) do 33 | {:ok, state} 34 | end 35 | 36 | def handle_info({:stdout, _, data}, state(client: nil, chunks: q) = state) do 37 | # nobody is waiting for data 38 | {:noreply, state(state, chunks: :queue.in(data, q))} 39 | end 40 | 41 | def handle_info({:stdout, _, data}, state(client: from, chunks: q) = state) do 42 | # there is a client waiting for this piece of data 43 | true = :queue.is_empty(q) 44 | GenServer.reply(from, data) 45 | {:noreply, state(state, client: nil)} 46 | end 47 | 48 | def handle_info({:monitor, pid}, state) do 49 | Process.monitor(pid) 50 | {:noreply, state(state, port: pid)} 51 | end 52 | 53 | def handle_info( 54 | {:DOWN, _ref, :process, pid, _reason}, 55 | state(client: nil, port: port_pid) = state 56 | ) 57 | when port_pid == pid do 58 | {:noreply, state(state, done: true)} 59 | end 60 | 61 | def handle_info( 62 | {:DOWN, _ref, :process, pid, _reason}, 63 | state(client: from, port: port_pid) = state 64 | ) 65 | when port_pid == pid do 66 | GenServer.reply(from, nil) 67 | {:stop, :shutdown, state} 68 | end 69 | 70 | def handle_info(:stop, state) do 71 | {:stop, :shutdown, state} 72 | end 73 | 74 | def handle_call(:get_data, _from, state(done: true, chunks: q) = state) do 75 | if :queue.is_empty(q) do 76 | {:stop, :shutdown, nil, state} 77 | else 78 | {:reply, :queue.head(q), state(state, chunks: :queue.tail(q))} 79 | end 80 | end 81 | 82 | def handle_call(:get_data, from, state(chunks: q) = state) do 83 | if :queue.is_empty(q) do 84 | {:noreply, state(state, client: from)} 85 | else 86 | {:reply, :queue.head(q), state(state, chunks: :queue.tail(q))} 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/exexec/to_erl.ex: -------------------------------------------------------------------------------- 1 | defmodule Exexec.ToErl do 2 | @moduledoc false 3 | 4 | def command_to_erl(command) when is_list(command) do 5 | Enum.map(command, &to_charlist/1) 6 | end 7 | 8 | def command_to_erl(command) do 9 | to_charlist(command) 10 | end 11 | 12 | def command_options_to_erl(options) do 13 | for {key, value} <- options, 14 | option = command_option_to_erl(key, value), 15 | do: option 16 | end 17 | 18 | @boolean_options [:monitor, :sync, :kill_group, :pty] 19 | def command_option_to_erl(boolean_option, value) 20 | when boolean_option in @boolean_options do 21 | if value, do: boolean_option 22 | end 23 | 24 | @string_options [:executable, :cd, :kill_command, :group, :user] 25 | def command_option_to_erl(string_option, value) 26 | when string_option in @string_options do 27 | string_option = if string_option == :kill_command, do: :kill, else: string_option 28 | 29 | {string_option, to_charlist(value)} 30 | end 31 | 32 | @integer_options [:kill_timeout, :success_exit_code, :nice] 33 | def command_option_to_erl(integer_option, value) 34 | when integer_option in @integer_options do 35 | {integer_option, value} 36 | end 37 | 38 | def command_option_to_erl(:env, value) do 39 | env = to_env(value) 40 | 41 | {:env, env} 42 | end 43 | 44 | def command_option_to_erl(:stdin, value) do 45 | if value do 46 | case value do 47 | true -> :stdin 48 | string when is_binary(string) -> {:stdin, to_charlist(string)} 49 | other -> {:stdin, other} 50 | end 51 | end 52 | end 53 | 54 | @output_device_options [:stdout, :stderr] 55 | def command_option_to_erl(output_device_option, value) 56 | when output_device_option in @output_device_options do 57 | [other_option] = List.delete(@output_device_options, output_device_option) 58 | 59 | case value do 60 | true -> 61 | output_device_option 62 | 63 | false -> 64 | nil 65 | 66 | :stream -> 67 | {output_device_option, :stream} 68 | 69 | ^other_option -> 70 | {output_device_option, other_option} 71 | 72 | {filename, output_file_options} -> 73 | filename = to_charlist(filename) 74 | output_file_options = output_file_options_to_erl(output_file_options) 75 | {output_device_option, filename, output_file_options} 76 | 77 | _ -> 78 | {output_device_option, output_device_option_to_erl(value)} 79 | end 80 | end 81 | 82 | def output_device_option_to_erl(:null), do: :null 83 | def output_device_option_to_erl(:close), do: :close 84 | def output_device_option_to_erl(:print), do: :print 85 | def output_device_option_to_erl(path) when is_binary(path), do: to_charlist(path) 86 | def output_device_option_to_erl(pid) when is_pid(pid), do: pid 87 | def output_device_option_to_erl(fun) when is_function(fun, 3), do: fun 88 | 89 | def output_file_options_to_erl(output_file_options) do 90 | for {key, value} <- output_file_options, 91 | option = output_file_option_to_erl(key, value), 92 | do: option 93 | end 94 | 95 | def output_file_option_to_erl(:append, value), do: if(value, do: :append) 96 | def output_file_option_to_erl(:mode, value), do: {:mode, value} 97 | 98 | def exec_options_to_erl(options) do 99 | for {key, value} <- options, 100 | option = exec_option_to_erl(key, value), 101 | do: option 102 | end 103 | 104 | def exec_option_to_erl(:debug, value) do 105 | if value do 106 | case value do 107 | true -> :debug 108 | integer -> {:debug, integer} 109 | end 110 | end 111 | end 112 | 113 | def exec_option_to_erl(:root, value) do 114 | {:root, value} 115 | end 116 | 117 | def exec_option_to_erl(:verbose, value) do 118 | if value do 119 | :verbose 120 | end 121 | end 122 | 123 | def exec_option_to_erl(:alarm, value) do 124 | {:alarm, value} 125 | end 126 | 127 | def exec_option_to_erl(string_option, value) 128 | when string_option in [:user, :port_path] do 129 | string_option = if string_option == :port_path, do: :portexe, else: string_option 130 | {string_option, to_charlist(value)} 131 | end 132 | 133 | def exec_option_to_erl(string_list_option, value) 134 | when string_list_option in [:args, :limit_users] do 135 | {string_list_option, Enum.map(value, &to_charlist/1)} 136 | end 137 | 138 | def exec_option_to_erl(:env, value) do 139 | {:env, to_env(value)} 140 | end 141 | 142 | def to_env(value), 143 | do: for({key, value} <- value, do: {to_charlist(key), to_charlist(value)}) 144 | end 145 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Exexec.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.2.0" 5 | 6 | def project do 7 | [ 8 | app: :exexec, 9 | version: @version, 10 | elixir: "~> 1.8", 11 | start_permanent: Mix.env() == :prod, 12 | source_url: "https://github.com/ericentin/exexec", 13 | docs: [ 14 | main: "Exexec", 15 | extras: ["README.md"], 16 | source_ref: "v#{@version}" 17 | ], 18 | deps: deps(), 19 | description: description(), 20 | package: package() 21 | ] 22 | end 23 | 24 | def application do 25 | [ 26 | extra_applications: [:logger] 27 | ] 28 | end 29 | 30 | defp deps do 31 | [ 32 | {:erlexec, "~> 1.10"}, 33 | {:ex_doc, "~> 0.20", only: :dev} 34 | ] 35 | end 36 | 37 | defp description do 38 | "An idiomatic Elixir wrapper for erlexec." 39 | end 40 | 41 | defp package do 42 | [ 43 | files: ["lib", "mix.exs", "README.md", "LICENSE"], 44 | licenses: ["Apache 2.0"], 45 | links: %{ 46 | "GitHub" => "https://github.com/ericentin/exexec" 47 | } 48 | ] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm", "e3be2bc3ae67781db529b80aa7e7c49904a988596e2dbff897425b48b3581161"}, 3 | "erlexec": {:hex, :erlexec, "1.10.0", "cba7924cf526097d2082ceb0ec34e7db6bca2624b8f3867fb3fa89c4cf25d227", [:rebar3], [], "hexpm", "54901b6e565f4cb4d0de36532d3653d351a3d7bf700bcbe2cf6f242a434ade2f"}, 4 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "8e24fc8ff9a50b9f557ff020d6c91a03cded7e59ac3e0eec8a27e771430c7d27"}, 5 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5fbc8e549aa9afeea2847c0769e3970537ed302f93a23ac612602e805d9d1e7f"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "adf0218695e22caeda2820eaba703fa46c91820d53813a2223413da3ef4ba515"}, 7 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm", "5c040b8469c1ff1b10093d3186e2e10dbe483cd73d79ec017993fb3985b8a9b3"}, 8 | } 9 | -------------------------------------------------------------------------------- /test/exexec/to_erl_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exexec.ToErlTest do 2 | use ExUnit.Case 3 | 4 | import Exexec.ToErl 5 | 6 | test "converts string command to erl" do 7 | assert command_to_erl("hello") == 'hello' 8 | assert command_to_erl(["hello", "world"]) == ['hello', 'world'] 9 | end 10 | 11 | test "converts all command options to erl" do 12 | output_fun = fn _, _, _ -> :ok end 13 | 14 | assert command_options_to_erl( 15 | monitor: true, 16 | monitor: false, 17 | sync: true, 18 | sync: false, 19 | executable: "executable", 20 | cd: "cd", 21 | env: %{"hello" => "world", "env" => "var"}, 22 | kill_command: "kill_command", 23 | kill_timeout: 123, 24 | kill_group: true, 25 | kill_group: false, 26 | group: "group", 27 | user: "user", 28 | success_exit_code: 12, 29 | nice: 10, 30 | stdin: true, 31 | stdin: false, 32 | stdin: :null, 33 | stdin: :close, 34 | stdin: "stdin", 35 | stdout: true, 36 | stdout: false, 37 | stdout: :null, 38 | stdout: :close, 39 | stdout: :print, 40 | stdout: "stdout", 41 | stdout: {"stdout", append: true, append: false, mode: 0o123}, 42 | stdout: self(), 43 | stdout: output_fun, 44 | stderr: true, 45 | stderr: false, 46 | stderr: :null, 47 | stderr: :close, 48 | stderr: :print, 49 | stderr: "stderr", 50 | stderr: {"stderr", append: true, append: false, mode: 0o12}, 51 | stderr: self(), 52 | stderr: output_fun, 53 | pty: true, 54 | pty: false 55 | ) == [ 56 | :monitor, 57 | :sync, 58 | {:executable, 'executable'}, 59 | {:cd, 'cd'}, 60 | {:env, [{'env', 'var'}, {'hello', 'world'}]}, 61 | {:kill, 'kill_command'}, 62 | {:kill_timeout, 123}, 63 | :kill_group, 64 | {:group, 'group'}, 65 | {:user, 'user'}, 66 | {:success_exit_code, 12}, 67 | {:nice, 10}, 68 | :stdin, 69 | {:stdin, :null}, 70 | {:stdin, :close}, 71 | {:stdin, 'stdin'}, 72 | :stdout, 73 | {:stdout, :null}, 74 | {:stdout, :close}, 75 | {:stdout, :print}, 76 | {:stdout, 'stdout'}, 77 | {:stdout, 'stdout', [:append, {:mode, 83}]}, 78 | {:stdout, self()}, 79 | {:stdout, output_fun}, 80 | :stderr, 81 | {:stderr, :null}, 82 | {:stderr, :close}, 83 | {:stderr, :print}, 84 | {:stderr, 'stderr'}, 85 | {:stderr, 'stderr', [:append, {:mode, 10}]}, 86 | {:stderr, self()}, 87 | {:stderr, output_fun}, 88 | :pty 89 | ] 90 | end 91 | 92 | # {:debug, boolean | non_neg_integer} | 93 | # {:verbose, boolean} | 94 | # {:args, [String.t]} | 95 | # {:alarm, non_neg_integer} | 96 | # {:user, String.t} | 97 | # {:limit_users, [String.t]} | 98 | # {:port_path, Path.t} | 99 | # {:env, %{String.t => String.t}} 100 | test "converts all exec options to erl" do 101 | assert exec_options_to_erl( 102 | debug: true, 103 | debug: false, 104 | debug: 1, 105 | verbose: true, 106 | verbose: false, 107 | args: ["arg1", "arg2"], 108 | alarm: 2, 109 | user: "user", 110 | limit_users: ["user1", "user2"], 111 | port_path: "port_path", 112 | env: %{"hello" => "world", "env" => "var"} 113 | ) == [ 114 | :debug, 115 | {:debug, 1}, 116 | :verbose, 117 | {:args, ['arg1', 'arg2']}, 118 | {:alarm, 2}, 119 | {:user, 'user'}, 120 | {:limit_users, ['user1', 'user2']}, 121 | {:portexe, 'port_path'}, 122 | {:env, [{'env', 'var'}, {'hello', 'world'}]} 123 | ] 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /test/exexec_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExexecTest do 2 | use ExUnit.Case 3 | 4 | import Exexec 5 | import ExUnit.CaptureLog 6 | 7 | test "kill" do 8 | {:ok, sleep_pid, sleep_os_pid} = run("sleep 10", monitor: true) 9 | 10 | assert :ok = kill(sleep_os_pid, 9) 11 | 12 | assert_receive {:DOWN, _, :process, ^sleep_pid, {:exit_status, 9}} 13 | end 14 | 15 | test "manage" do 16 | bash = System.find_executable("bash") 17 | {:ok, _spawner_pid, spawner_os_pid} = run([bash, "-c", "sleep 100 & echo $!"], stdout: true) 18 | 19 | sleep_os_pid = 20 | receive do 21 | {:stdout, ^spawner_os_pid, sleep_pid_string} -> 22 | {sleep_pid, _} = Integer.parse(sleep_pid_string) 23 | sleep_pid 24 | end 25 | 26 | {:ok, sleep_pid, ^sleep_os_pid} = manage(sleep_os_pid) 27 | 28 | assert :ok = kill(sleep_pid, 9) 29 | 30 | {:ok, _ps_pid, ps_os_pid} = run("ps -p #{sleep_os_pid}", stdout: true) 31 | 32 | stdout = 33 | receive do 34 | {:stdout, ^ps_os_pid, stdout} -> stdout 35 | end 36 | 37 | refute stdout =~ to_string(sleep_os_pid) 38 | end 39 | 40 | test "os_pid" do 41 | {:ok, sleep_pid, sleep_os_pid} = run_link("sleep 100") 42 | 43 | assert os_pid(sleep_pid) == {:ok, sleep_os_pid} 44 | 45 | {:ok, pid} = 46 | Task.start_link(fn -> 47 | receive do 48 | {{pid, ref}, :ospid} -> Kernel.send(pid, {ref, {:error, :testing}}) 49 | end 50 | end) 51 | 52 | assert os_pid(pid) == {:error, :testing} 53 | end 54 | 55 | test "pid" do 56 | {:ok, sleep_pid, sleep_os_pid} = run_link("sleep 100") 57 | 58 | assert pid(sleep_os_pid) == {:ok, sleep_pid} 59 | 60 | assert pid(123_411_231_231) == {:error, :undefined} 61 | end 62 | 63 | test "run" do 64 | assert run("echo hi", sync: true, stdout: true) == {:ok, [stdout: ["hi\n"]]} 65 | end 66 | 67 | test "run_link" do 68 | Process.flag(:trap_exit, true) 69 | 70 | {:ok, pid, os_pid} = 71 | run_link("echo $EXEXEC_TEST_VAR; false", stdout: true, env: %{"EXEXEC_TEST_VAR" => "TRUE"}) 72 | 73 | assert_receive {:stdout, ^os_pid, "TRUE\n"} 74 | 75 | assert_receive {:EXIT, ^pid, {:exit_status, 256}} 76 | end 77 | 78 | test "send" do 79 | {:ok, cat_pid, cat_os_pid} = run_link("cat", stdin: true, stdout: true) 80 | 81 | assert :ok = Exexec.send(cat_pid, "hi\n") 82 | 83 | assert_receive {:stdout, ^cat_os_pid, "hi\n"} 84 | 85 | assert :ok = Exexec.send(cat_os_pid, "hi2\n") 86 | 87 | assert_receive {:stdout, ^cat_os_pid, "hi2\n"} 88 | end 89 | 90 | test "set_gid" do 91 | Process.flag(:trap_exit, true) 92 | 93 | {:ok, _sleep_pid, sleep_os_pid} = run_link("sleep 100") 94 | 95 | capture_log(fn -> 96 | try do 97 | set_gid(sleep_os_pid, 123_123) 98 | catch 99 | :exit, reason -> 100 | assert reason == 101 | {{:exit_status, 139}, 102 | {:gen_server, :call, [:exec, {:port, {:setpgid, sleep_os_pid, 123_123}}]}} 103 | end 104 | end) 105 | end 106 | 107 | test "status" do 108 | assert status(1) == {:signal, :sighup, false} 109 | assert status(256) == {:status, 1} 110 | assert status(0) == {:status, 0} 111 | end 112 | 113 | test "can pull n elements from stream" do 114 | cmd = "for i in 1 2 3; do sleep 0.001; echo \"Iter$i\"; done" 115 | assert {:ok, _pid, _, [{:stream, stream, _server}]} = Exexec.run(cmd, [{:stdout, :stream}]) 116 | assert 2 == length(Enum.take(stream, 2)) 117 | end 118 | 119 | test "can pull just as many elements from stream as is" do 120 | cmd = "for i in 1 2 3; do sleep 0.001; echo \"Iter$i\"; done" 121 | assert {:ok, _pid, _, [{:stream, stream, _server}]} = Exexec.run(cmd, [{:stdout, :stream}]) 122 | assert contains?(stream, "Iter3\n") 123 | end 124 | 125 | test "can close stream server without reading all the items" do 126 | cmd = "for i in 1 2 3; do sleep 0.001; echo \"Iter$i\"; done" 127 | assert {:ok, _pid, _, [{:stream, stream, server}]} = Exexec.run(cmd, [{:stdout, :stream}]) 128 | Enum.take(stream, 1) 129 | assert Process.alive?(server) 130 | Exexec.StreamOutput.stop(server) 131 | Process.sleep(100) 132 | assert not Process.alive?(server) 133 | end 134 | 135 | # test "stop" do 136 | # {:ok, sleep_pid, sleep_os_pid} = run_link("sleep 10") 137 | # 138 | # assert :ok = stop(sleep_os_pid) 139 | # 140 | # # Is this broken in erlexec? it doesn't really seem to do anything 141 | # end 142 | 143 | # test "stop_and_wait" do 144 | # {:ok, sleep_pid, sleep_os_pid} = run_link("sleep 1; echo ok") 145 | # 146 | # assert :ok = stop_and_wait(sleep_pid) 147 | # 148 | # {:ok, _ps_pid, ps_os_pid} = run("ps -p #{sleep_os_pid}", stdout: true) 149 | # 150 | # stdout = 151 | # receive do 152 | # {:stdout, ^ps_os_pid, stdout} -> stdout 153 | # end 154 | # 155 | # refute stdout =~ to_string(sleep_os_pid) 156 | # # Is this broken in erlexec? it doesn't really seem to do anything 157 | # end 158 | 159 | test "which_children" do 160 | {:ok, _sleep_pid, sleep_os_pid} = run_link("sleep 10") 161 | 162 | assert sleep_os_pid in which_children() 163 | end 164 | 165 | defp contains?(stream, look_for) do 166 | finder = fn line, _ -> 167 | case String.contains?(line, look_for) do 168 | true -> {:halt, true} 169 | false -> {false, false} 170 | end 171 | end 172 | 173 | Stream.transform(stream, false, finder) 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------