From f162eb6cf90d3961afce60da18baa1d98e3a828b Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Thu, 10 Aug 2023 08:30:20 +0000 Subject: [PATCH] feat: Commit initially --- .formatter.exs | 4 ++ .gitignore | 26 ++++++++ .gitlab-ci.yml | 5 ++ .mash.exs | 13 ++++ README.md | 21 +++++++ lib/mash.ex | 11 ++++ lib/mash/config.ex | 67 ++++++++++++++++++++ lib/mash/execution.ex | 16 +++++ lib/mash/helpers.ex | 54 ++++++++++++++++ lib/mash/job.ex | 26 ++++++++ lib/mash/runner.ex | 140 ++++++++++++++++++++++++++++++++++++++++++ lib/mix/tasks/mash.ex | 20 ++++++ mix.exs | 29 +++++++++ mix.lock | 6 ++ test/mash_test.exs | 4 ++ test/test_helper.exs | 1 + 16 files changed, 443 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .mash.exs create mode 100644 README.md create mode 100644 lib/mash.ex create mode 100644 lib/mash/config.ex create mode 100644 lib/mash/execution.ex create mode 100644 lib/mash/helpers.ex create mode 100644 lib/mash/job.ex create mode 100644 lib/mash/runner.ex create mode 100644 lib/mix/tasks/mash.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/mash_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1269f14 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e1cd1db --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,5 @@ +test: + image: "elixir:1.14.4-alpine" + script: + - mix deps.get + - mix mash diff --git a/.mash.exs b/.mash.exs new file mode 100644 index 0000000..e1d3a39 --- /dev/null +++ b/.mash.exs @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f6c68c --- /dev/null +++ b/README.md @@ -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 . + diff --git a/lib/mash.ex b/lib/mash.ex new file mode 100644 index 0000000..9b9e851 --- /dev/null +++ b/lib/mash.ex @@ -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 diff --git a/lib/mash/config.ex b/lib/mash/config.ex new file mode 100644 index 0000000..b0943d0 --- /dev/null +++ b/lib/mash/config.ex @@ -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 diff --git a/lib/mash/execution.ex b/lib/mash/execution.ex new file mode 100644 index 0000000..49853aa --- /dev/null +++ b/lib/mash/execution.ex @@ -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 diff --git a/lib/mash/helpers.ex b/lib/mash/helpers.ex new file mode 100644 index 0000000..b1cf76b --- /dev/null +++ b/lib/mash/helpers.ex @@ -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 diff --git a/lib/mash/job.ex b/lib/mash/job.ex new file mode 100644 index 0000000..dc88687 --- /dev/null +++ b/lib/mash/job.ex @@ -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 diff --git a/lib/mash/runner.ex b/lib/mash/runner.ex new file mode 100644 index 0000000..dd45d94 --- /dev/null +++ b/lib/mash/runner.ex @@ -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 diff --git a/lib/mix/tasks/mash.ex b/lib/mix/tasks/mash.ex new file mode 100644 index 0000000..03c1193 --- /dev/null +++ b/lib/mix/tasks/mash.ex @@ -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 diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..c8aaba6 --- /dev/null +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..2c9087a --- /dev/null +++ b/mix.lock @@ -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"}, +} diff --git a/test/mash_test.exs b/test/mash_test.exs new file mode 100644 index 0000000..0d5cdb5 --- /dev/null +++ b/test/mash_test.exs @@ -0,0 +1,4 @@ +defmodule MashTest do + use ExUnit.Case + doctest Mash +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()