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