feat: Commit initially

This commit is contained in:
Robert Prehn 2023-08-10 08:30:20 +00:00
commit f162eb6cf9
No known key found for this signature in database
16 changed files with 443 additions and 0 deletions

4
.formatter.exs Normal file
View file

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

26
.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,5 @@
test:
image: "elixir:1.14.4-alpine"
script:
- mix deps.get
- mix mash

13
.mash.exs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
defmodule MashTest do
use ExUnit.Case
doctest Mash
end

1
test/test_helper.exs Normal file
View file

@ -0,0 +1 @@
ExUnit.start()