feat: Commit initially
This commit is contained in:
commit
f162eb6cf9
16 changed files with 443 additions and 0 deletions
4
.formatter.exs
Normal file
4
.formatter.exs
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# Used by "mix format"
|
||||||
|
[
|
||||||
|
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# The directory Mix will write compiled artifacts to.
|
||||||
|
/_build/
|
||||||
|
|
||||||
|
# If you run "mix test --cover", coverage assets end up here.
|
||||||
|
/cover/
|
||||||
|
|
||||||
|
# The directory Mix downloads your dependencies sources to.
|
||||||
|
/deps/
|
||||||
|
|
||||||
|
# Where third-party dependencies like ExDoc output generated docs.
|
||||||
|
/doc/
|
||||||
|
|
||||||
|
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||||
|
/.fetch
|
||||||
|
|
||||||
|
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||||
|
erl_crash.dump
|
||||||
|
|
||||||
|
# Also ignore archive artifacts (built via "mix archive.build").
|
||||||
|
*.ez
|
||||||
|
|
||||||
|
# Ignore package tarball (built via "mix hex.build").
|
||||||
|
mash-*.tar
|
||||||
|
|
||||||
|
# Temporary files, for example, from tests.
|
||||||
|
/tmp/
|
5
.gitlab-ci.yml
Normal file
5
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
test:
|
||||||
|
image: "elixir:1.14.4-alpine"
|
||||||
|
script:
|
||||||
|
- mix deps.get
|
||||||
|
- mix mash
|
13
.mash.exs
Normal file
13
.mash.exs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
defmodule MashConfig do
|
||||||
|
import Mash.Helpers
|
||||||
|
|
||||||
|
def jobs do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
name: :test,
|
||||||
|
run: mix("test")
|
||||||
|
},
|
||||||
|
%{name: :credo, run: mix("credo", ["--all"])}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
21
README.md
Normal file
21
README.md
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# Mash
|
||||||
|
|
||||||
|
**TODO: Add description**
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
|
||||||
|
by adding `mash` to your list of dependencies in `mix.exs`:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
def deps do
|
||||||
|
[
|
||||||
|
{:mash, "~> 0.1.0"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
|
||||||
|
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
|
||||||
|
be found at <https://hexdocs.pm/mash>.
|
||||||
|
|
11
lib/mash.ex
Normal file
11
lib/mash.ex
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
defmodule Mash do
|
||||||
|
@moduledoc """
|
||||||
|
Documentation for `Mash`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run() do
|
||||||
|
Enum.map(Mash.Config.jobs(), fn %{run: run} ->
|
||||||
|
run.()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
67
lib/mash/config.ex
Normal file
67
lib/mash/config.ex
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
defmodule Mash.Config do
|
||||||
|
@moduledoc """
|
||||||
|
Configuration module backed by an agent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Agent
|
||||||
|
|
||||||
|
alias Mash.Job
|
||||||
|
|
||||||
|
def start_link() do
|
||||||
|
case Agent.start_link(&compile_config_module!/0, name: __MODULE__) do
|
||||||
|
{:error, {:already_started, pid}} ->
|
||||||
|
{:ok, pid}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def module do
|
||||||
|
with_pid(fn pid ->
|
||||||
|
Agent.get(pid, & &1)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def jobs do
|
||||||
|
jobs =
|
||||||
|
module().jobs()
|
||||||
|
|> List.flatten()
|
||||||
|
|> Enum.filter(& &1)
|
||||||
|
|> Enum.map(&Job.from_map/1)
|
||||||
|
|
||||||
|
errors =
|
||||||
|
jobs
|
||||||
|
|> Enum.map(&Job.validate/1)
|
||||||
|
|> Enum.reject(fn {status, _result} -> status == :ok end)
|
||||||
|
|> Enum.map(&elem(&1, 1))
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map(&{elem(&1, 1), elem(&1, 0)})
|
||||||
|
|
||||||
|
if Enum.any?(errors) do
|
||||||
|
raise "Invalid job specfication(s): #{inspect(errors)}"
|
||||||
|
else
|
||||||
|
jobs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp with_pid(fun) do
|
||||||
|
case start_link() do
|
||||||
|
{:ok, pid} ->
|
||||||
|
fun.(pid)
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compile_config_module! do
|
||||||
|
case Code.compile_file(".mash.exs", File.cwd!()) do
|
||||||
|
[{mod, _} | _] ->
|
||||||
|
mod
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
raise "No .mash.exs file found or it does not export a config module."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
16
lib/mash/execution.ex
Normal file
16
lib/mash/execution.ex
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
defmodule Mash.Execution do
|
||||||
|
@moduledoc """
|
||||||
|
Wraps execution of a job run function in a Task and a StringIO for tracking output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run(%{run: run} = job) do
|
||||||
|
{:ok, io_pid} = StringIO.open("")
|
||||||
|
|
||||||
|
task =
|
||||||
|
Task.async(fn ->
|
||||||
|
{%{job | io_pid: io_pid}, run.(io_pid)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
{task, io_pid}
|
||||||
|
end
|
||||||
|
end
|
54
lib/mash/helpers.ex
Normal file
54
lib/mash/helpers.ex
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
defmodule Mash.Helpers do
|
||||||
|
@moduledoc """
|
||||||
|
Helpers that provide a simple way to write Mash configs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def mix(task, args \\ []) do
|
||||||
|
preferred_mix_env = Mix.Task.preferred_cli_env(task) || Mix.env()
|
||||||
|
|
||||||
|
fn io_pid ->
|
||||||
|
cmd("mix", [task | args], io_pid, [{"MIX_ENV", Atom.to_string(preferred_mix_env)}])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def shell(command, args \\ []) do
|
||||||
|
fn io_pid ->
|
||||||
|
cmd(command, args, io_pid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fun(fun) do
|
||||||
|
fn io_pid ->
|
||||||
|
Process.group_leader(self(), io_pid)
|
||||||
|
|
||||||
|
try do
|
||||||
|
case fun.() do
|
||||||
|
:ok ->
|
||||||
|
{[], 0}
|
||||||
|
|
||||||
|
{:ok, _any} ->
|
||||||
|
{[], 0}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
IO.puts(IO.ANSI.red() <> "** (fun) failed: #{inspect(other)}" <> IO.ANSI.reset())
|
||||||
|
{[], 1}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
e ->
|
||||||
|
IO.puts(IO.ANSI.red() <> "** (fun) failed: #{Exception.message(e)}" <> IO.ANSI.reset())
|
||||||
|
{[], 1}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cmd(command, args, io_pid, env \\ []) do
|
||||||
|
exec_string = "-c #{command} #{Enum.join(args, " ")}"
|
||||||
|
|
||||||
|
System.cmd("script", ["-eqf", exec_string, "/dev/null"],
|
||||||
|
into: IO.stream(io_pid, :line),
|
||||||
|
env: env,
|
||||||
|
parallelism: true,
|
||||||
|
stderr_to_stdout: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
26
lib/mash/job.ex
Normal file
26
lib/mash/job.ex
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
defmodule Mash.Job do
|
||||||
|
@moduledoc """
|
||||||
|
Structure that represents the configuration and status of one Mash job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
defstruct name: nil, needs: [], run: nil, state: nil, task: nil, io_pid: nil
|
||||||
|
|
||||||
|
def from_map(map) do
|
||||||
|
struct(__MODULE__, map)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate(%__MODULE__{run: run, name: name} = job) do
|
||||||
|
errors =
|
||||||
|
[
|
||||||
|
if(!is_function(run, 1), do: "run is required and must be a function with arity 1"),
|
||||||
|
if(!is_atom(name), do: "name is required and must be an atom")
|
||||||
|
]
|
||||||
|
|> Enum.reject(&is_nil(&1))
|
||||||
|
|
||||||
|
if errors == [] do
|
||||||
|
{:ok, job}
|
||||||
|
else
|
||||||
|
{:error, errors}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
140
lib/mash/runner.ex
Normal file
140
lib/mash/runner.ex
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
defmodule Mash.Runner do
|
||||||
|
@moduledoc """
|
||||||
|
The process that runs the jobs through a series of Tasks, writes the IO, and monitors the results.
|
||||||
|
"""
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
@type job_state :: :to_run | :running | :passed | :failed | :skipped
|
||||||
|
|
||||||
|
def tick do
|
||||||
|
GenServer.cast(self(), :tick)
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
opts = Keyword.put(opts, :parent, self())
|
||||||
|
GenServer.start_link(__MODULE__, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl GenServer
|
||||||
|
def init(opts \\ []) do
|
||||||
|
parent = Keyword.get(opts, :parent)
|
||||||
|
|
||||||
|
tick()
|
||||||
|
|
||||||
|
jobs = Mash.Config.jobs() |> Enum.map(&Map.put(&1, :state, :to_run))
|
||||||
|
|
||||||
|
{:ok, {parent, jobs}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl GenServer
|
||||||
|
def handle_cast(:tick, {parent, jobs}) do
|
||||||
|
new_jobs = Enum.map(jobs, &tick_job(&1, jobs))
|
||||||
|
|
||||||
|
if any_running?(new_jobs) do
|
||||||
|
tick()
|
||||||
|
else
|
||||||
|
send(parent, {:result, Enum.count(jobs, &(&1.state == :failed))})
|
||||||
|
Process.exit(self(), :normal)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, {parent, new_jobs}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec tick_job(%{state: job_state()}, [Mash.Job.t()]) :: Mash.Job.t()
|
||||||
|
def tick_job(%{state: :to_run} = job, jobs) do
|
||||||
|
cond do
|
||||||
|
ready?(job, jobs) ->
|
||||||
|
%{job | state: :running}
|
||||||
|
|
||||||
|
skippable?(job, jobs) ->
|
||||||
|
%{job | state: :skipped}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
job
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def tick_job(%{name: name, state: :running, task: nil} = job, _jobs) do
|
||||||
|
IO.puts("Running #{name}")
|
||||||
|
|
||||||
|
{task, io_pid} = Mash.Execution.run(job)
|
||||||
|
|
||||||
|
%{job | task: task, io_pid: io_pid}
|
||||||
|
end
|
||||||
|
|
||||||
|
def tick_job(%{name: name, state: :running, task: task, io_pid: io_pid} = job, _jobs)
|
||||||
|
when is_struct(task, Task) do
|
||||||
|
flush_io(name, io_pid)
|
||||||
|
|
||||||
|
job
|
||||||
|
end
|
||||||
|
|
||||||
|
def tick_job(%{state: state} = job, _jobs) when state in [:passed, :failed, :skipped], do: job
|
||||||
|
|
||||||
|
def any_running?(jobs) do
|
||||||
|
Enum.any?(jobs, &(&1.state == :running))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp flush_io(name, io_pid) do
|
||||||
|
io_pid
|
||||||
|
|> StringIO.flush()
|
||||||
|
|> then(fn
|
||||||
|
"" ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
|> String.split("\n")
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|> Enum.map(&"[#{name}] #{&1}\n")
|
||||||
|
|> IO.puts()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ready?(%{needs: needs}, jobs) do
|
||||||
|
Enum.all?(needs, fn needed_job_name ->
|
||||||
|
needed_job = Enum.find(jobs, &(&1.name == needed_job_name))
|
||||||
|
|
||||||
|
needed_job.state == :passed
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp skippable?(%{needs: needs}, jobs) do
|
||||||
|
Enum.any?(needs, fn needed_job_name ->
|
||||||
|
needed_job = Enum.find(jobs, &(&1.name == needed_job_name))
|
||||||
|
|
||||||
|
needed_job.state in [:failed, :skipped]
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl GenServer
|
||||||
|
def handle_info({_ref, {job, {_io_stream, exit_code}}}, {parent, jobs}) do
|
||||||
|
%{name: job_name} = job
|
||||||
|
|
||||||
|
StringIO.close(job.io_pid)
|
||||||
|
new_state = if exit_code == 0, do: :passed, else: :failed
|
||||||
|
IO.puts("Finishing #{job_name} -- #{new_state}")
|
||||||
|
|
||||||
|
new_job = %{
|
||||||
|
job
|
||||||
|
| state: new_state,
|
||||||
|
task: nil,
|
||||||
|
io_pid: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
new_jobs =
|
||||||
|
Enum.map(jobs, fn
|
||||||
|
%{name: ^job_name} ->
|
||||||
|
new_job
|
||||||
|
|
||||||
|
job ->
|
||||||
|
job
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:noreply, {parent, new_jobs}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
end
|
20
lib/mix/tasks/mash.ex
Normal file
20
lib/mix/tasks/mash.ex
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
defmodule Mix.Tasks.Mash do
|
||||||
|
@moduledoc """
|
||||||
|
Task to run Mash jobs.
|
||||||
|
"""
|
||||||
|
@shortdoc "Run Mash jobs."
|
||||||
|
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
def run(_args) do
|
||||||
|
{:ok, _pid} = Mash.Runner.start_link()
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{:result, failure_count} when failure_count > 0 ->
|
||||||
|
Mix.raise("#{failure_count} jobs failed.", exit_status: 1)
|
||||||
|
|
||||||
|
{:result, 0} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
29
mix.exs
Normal file
29
mix.exs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
defmodule Mash.MixProject do
|
||||||
|
use Mix.Project
|
||||||
|
|
||||||
|
def project do
|
||||||
|
[
|
||||||
|
app: :mash,
|
||||||
|
version: "0.1.0",
|
||||||
|
elixir: "~> 1.14",
|
||||||
|
start_permanent: Mix.env() == :prod,
|
||||||
|
deps: deps()
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Run "mix help compile.app" to learn about applications.
|
||||||
|
def application do
|
||||||
|
[
|
||||||
|
extra_applications: [:logger]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Run "mix help deps" to learn about dependencies.
|
||||||
|
defp deps do
|
||||||
|
[
|
||||||
|
{:credo, "~> 1.7"}
|
||||||
|
# {:dep_from_hexpm, "~> 0.3.0"},
|
||||||
|
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
6
mix.lock
Normal file
6
mix.lock
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
%{
|
||||||
|
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
|
||||||
|
"credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"},
|
||||||
|
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||||
|
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
|
||||||
|
}
|
4
test/mash_test.exs
Normal file
4
test/mash_test.exs
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
defmodule MashTest do
|
||||||
|
use ExUnit.Case
|
||||||
|
doctest Mash
|
||||||
|
end
|
1
test/test_helper.exs
Normal file
1
test/test_helper.exs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
ExUnit.start()
|
Loading…
Reference in a new issue