From 636f926276c066a32f1ee12e48425aaf94110db5 Mon Sep 17 00:00:00 2001 From: Thiago Esteves Date: Fri, 3 May 2024 09:57:19 -0300 Subject: [PATCH] Initial commit --- .formatter.exs | 4 + .gitignore | 26 ++ LICENSE.md | 9 + README.md | 48 +++ lib/jellyfish/releases/appups.ex | 411 +++++++++++++++++++++++ lib/jellyfish/releases/copy.ex | 20 ++ lib/jellyfish/tasks/compile/appup.ex | 58 ++++ lib/jellyfish/tasks/compile/gen_appup.ex | 210 ++++++++++++ mix.exs | 59 ++++ mix.lock | 13 + test/test_helper.exs | 1 + 11 files changed, 859 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 lib/jellyfish/releases/appups.ex create mode 100644 lib/jellyfish/releases/copy.ex create mode 100644 lib/jellyfish/tasks/compile/appup.ex create mode 100644 lib/jellyfish/tasks/compile/gen_appup.ex create mode 100644 mix.exs create mode 100644 mix.lock 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..fba2690 --- /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"). +jellyfish-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..41764ff --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2016 Paul Schoenfelder + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..467073b --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Jellyfish: Simplifying Hot-Upgrades for Elixir Applications + +[![Hex.pm Version](http://img.shields.io/hexpm/v/jellyfish.svg?style=flat)](https://hex.pm/packages/jellyfish) + +Jellyfish is a library designed to streamline the management of appup and release files, enabling hot-upgrades for Elixir applications. Born from the integration of concepts and functionalities from three influential repositories, Jellyfish empowers developers with efficient tools for maintaining and deploying their Elixir projects with confidence. + + * [Distillery](https://github.com/bitwalker/distillery) - While currently deprecated, its appup generation remains a valuable asset within Jellyfish, ensuring compatibility and reliability in managing upgrades. + * [Forecastle](https://github.com/ausimian/forecastle) - Offering robust capabilities for release package management. + * [Relx](https://github.com/erlware/relx/blob/main/priv/templates/install_upgrade_escript) - Providing crucial insights into storing, unpacking, and executing hot upgrades using release files + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `jellyfish` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:jellyfish, "~> 0.1.0"} + ] +end +``` + +You also need to add the following lines in the mix project +```elixir + def project do + [ + ... + compilers: Mix.compilers() ++ [:gen_appup, :appup], + releases: [ + your_app_name: [ + steps: [:assemble, &Jellyfish.Releases.Copy.relfile/1, :tar] + ] + ], + ... + ] + end +``` + +Once the mix release file is generated, it will contain all the appup/release files to execute a hot-upgrade or full deployment. The application that will be able to consume and execute full deployment or hot-upgrade is coming soon. + +# References + +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 . + +# jellyfish diff --git a/lib/jellyfish/releases/appups.ex b/lib/jellyfish/releases/appups.ex new file mode 100644 index 0000000..54283de --- /dev/null +++ b/lib/jellyfish/releases/appups.ex @@ -0,0 +1,411 @@ +defmodule Jellyfish.Releases.Appups do + @moduledoc """ + This module is responsible for generating appups between two releases. + + Copied and/or modified from https://github.com/bitwalker/distillery/blob/master/lib/distillery/releases/appups.ex + """ + + @type app :: atom + @type version_str :: String.t() + @type path_str :: String.t() + @type change :: :soft | {:advanced, [term]} + @type dep_mods :: [module] + + # Appup versions can be a version string as a charlist, + # or a regular expression as a binary. The regex must match + # the entire version string for an application, or it is rejected. + @type appup_ver :: charlist | binary + @type instruction :: + {:add_module, module} + | {:delete_module, module} + | {:update, module, :supervisor | change} + | {:update, module, change, dep_mods} + | {:load_module, module} + | {:load_module, module, dep_mods} + | {:apply, {module, atom, [term]}} + | {:add_application, atom} + | {:remove_application, atom} + | {:restart_application, atom} + | :restart_new_emulator + | :restart_emulator + @type upgrade_instructions :: [{appup_ver, instruction}] + @type downgrade_instructions :: [{appup_ver, instruction}] + @type appup :: {appup_ver, upgrade_instructions, downgrade_instructions} + + @doc """ + Given an application name, and two versions, look for a custom appup which applies. + """ + @spec locate(app, version_str, version_str) :: nil | String.t() + def locate(app, v1, v2) do + # First check the application's own priv directory + priv_path = Application.app_dir(app, Path.join(["priv", "appups"])) + + v1 = String.to_charlist(v1) + v2 = String.to_charlist(v2) + + case do_locate(Path.wildcard(Path.join(priv_path, "*.appup")), v1, v2) do + nil -> + # Fallback to user-provided appups for this app + appup_dir = Path.join(["rel", "appups", "#{app}"]) + do_locate(Path.wildcard(Path.join(appup_dir, "*.appup")), v1, v2) + + path -> + path + end + end + + @spec do_locate([String.t()], charlist, charlist) :: nil | String.t() + defp do_locate(paths, v1, v2) + + defp do_locate([], _v1, _v2), + do: nil + + defp do_locate([path | rest], v1, v2) when is_binary(path) do + case read_terms(path) do + {:ok, [{^v2, ups, downs}]} when is_list(ups) and is_list(downs) -> + if List.keyfind(ups, v1, 0) && List.keyfind(downs, v1, 0) do + path + else + do_locate(rest, v1, v2) + end + + {:ok, [{v2p, [{^v1, _}], [{^v1, _}]}]} when is_binary(v2p) -> + v2p = Regex.compile!(v2p) + + if String.match?(List.to_string(v2), v2p) do + # This matches the current configuration + path + else + do_locate(rest, v1, v2) + end + + {:ok, [{v2p, [{v1p, _}], [{v1p, _}]}]} when is_binary(v2p) and is_binary(v1p) -> + v2p = Regex.compile!(v2p) + v1p = Regex.compile!(v1p) + + if String.match?(List.to_string(v2), v2p) and String.match?(List.to_string(v1), v1p) do + # This matches the current configuration + path + else + do_locate(rest, v1, v2) + end + + _ -> + do_locate(rest, v1, v2) + end + end + + defp read_terms(path) when is_binary(path) do + case :file.consult(path) do + {:ok, _} = result -> + result + + {:error, reason} -> + {:error, {:read_terms, :file, reason}} + end + end + + @doc """ + Generate a .appup for the given application, start version, and upgrade version. + + ## Parameters + + - application: the application name as an atom + - v1: the previous version, such as "0.0.1" + - v2: the new version, such as "0.0.2" + - v1_path: the path to the v1 artifacts (rel//lib/-0.0.1) + - v2_path: the path to the v2 artifacts (_build/prod/lib/) + + """ + @spec make(app, version_str, version_str, path_str, path_str) :: {:ok, appup} | {:error, term} + @spec make(app, version_str, version_str, path_str, path_str, [module]) :: + {:ok, appup} | {:error, term} + def make(application, v1, v2, v1_path, v2_path, transforms \\ []) do + v1_dotapp = + v1_path + |> Path.join("/ebin/") + |> Path.join(Atom.to_string(application) <> ".app") + |> String.to_charlist() + + v2_dotapp = + v2_path + |> Path.join("/ebin/") + |> Path.join(Atom.to_string(application) <> ".app") + |> String.to_charlist() + + case :file.consult(v1_dotapp) do + {:ok, [{:application, ^application, v1_props}]} -> + consulted_v1_vsn = vsn(v1_props) + + case consulted_v1_vsn === v1 do + true -> + case :file.consult(v2_dotapp) do + {:ok, [{:application, ^application, v2_props}]} -> + consulted_v2_vsn = vsn(v2_props) + + case consulted_v2_vsn === v2 do + true -> + {:ok, + make_appup( + application, + v1, + v1_path, + v1_props, + v2, + v2_path, + v2_props, + transforms + )} + + false -> + {:error, + {:appups, + {:mismatched_versions, + [version: :next, expected: v2, got: consulted_v2_vsn]}}} + end + + {:error, reason} -> + {:error, {:appups, :file, {:invalid_dotapp, reason}}} + end + + false -> + {:error, + {:appups, + {:mismatched_versions, [version: :previous, expected: v1, got: consulted_v1_vsn]}}} + end + + {:error, reason} -> + {:error, {:appups, :file, {:invalid_dotapp, reason}}} + end + end + + defp make_appup(_app, v1, v1_path, _v1_props, v2, v2_path, _v2_props, _transforms) do + v1 = String.to_charlist(v1) + v2 = String.to_charlist(v2) + v1_path = String.to_charlist(Path.join(v1_path, "ebin")) + v2_path = String.to_charlist(Path.join(v2_path, "ebin")) + + {deleted, added, changed} = :beam_lib.cmp_dirs(v1_path, v2_path) + + actually_changed = + changed + |> Enum.filter(fn {v1_beam, v2_beam} -> + case :beam_lib.cmp(v1_beam, v2_beam) do + {:error, :beam_lib, {:chunks_different, ~c"Dbgi"}} -> + # Due to the way Elixir generates core ast, all beams will always show as changed, + # so we ignore this chunk in the comparison for changed beams + false + + _ -> + true + end + end) + + up_instructions = + generate_instructions(:added, added) + |> Enum.concat(generate_instructions(:changed, actually_changed)) + |> Enum.concat(generate_instructions(:deleted, deleted)) + + down_instructions = + generate_instructions(:deleted, added) + |> Enum.concat(generate_instructions(:changed, actually_changed)) + |> Enum.concat(generate_instructions(:added, deleted)) + + { + # New version + v2, + # Upgrade instructions from version v1 + [{v1, up_instructions}], + # Downgrade instructions to version v1 + [{v1, down_instructions}] + } + end + + # For modules which have changed, we must make sure + # that they are loaded/updated in such an order that + # modules they depend upon are loaded/updated first, + # where possible (due to cyclic dependencies, this is + # not always feasible). After generating the instructions, + # we perform a best-effort topological sort of the modules + # involved, such that an optimal ordering of the instructions + # is generated + defp generate_instructions(:changed, files) do + files + |> Enum.map(&generate_instruction(:changed, &1)) + |> topological_sort + end + + defp generate_instructions(type, files) do + Enum.map(files, &generate_instruction(type, &1)) + end + + defp generate_instruction(:added, file), do: {:add_module, module_name(file)} + defp generate_instruction(:deleted, file), do: {:delete_module, module_name(file)} + + defp generate_instruction(:changed, {v1_file, v2_file}) do + module_name = module_name(v1_file) + attributes = beam_attributes(v1_file) + exports = beam_exports(v1_file) + imports = beam_imports(v2_file) + is_supervisor = is_supervisor?(attributes) + is_special_proc = is_special_process?(exports) + + depends_on = + imports + |> Enum.map(fn {m, _f, _a} -> m end) + |> Enum.uniq() + + generate_instruction_advanced(module_name, is_supervisor, is_special_proc, depends_on) + end + + defp beam_attributes(file) do + {:ok, {_, [attributes: attributes]}} = :beam_lib.chunks(file, [:attributes]) + attributes + end + + defp beam_imports(file) do + {:ok, {_, [imports: imports]}} = :beam_lib.chunks(file, [:imports]) + imports + end + + defp beam_exports(file) do + {:ok, {_, [exports: exports]}} = :beam_lib.chunks(file, [:exports]) + exports + end + + defp is_special_process?(exports) do + Keyword.get(exports, :system_code_change) == 4 || Keyword.get(exports, :code_change) == 3 || + Keyword.get(exports, :code_change) == 4 + end + + defp is_supervisor?(attributes) do + behaviours = Keyword.get(attributes, :behavior, []) ++ Keyword.get(attributes, :behaviour, []) + :supervisor in behaviours || Supervisor in behaviours + end + + # supervisor + defp generate_instruction_advanced(m, true, _is_special, _dep_mods), + do: {:update, m, :supervisor} + + # special process (i.e. exports code_change/3 or system_code_change/4) + defp generate_instruction_advanced(m, _is_sup, true, []), do: {:update, m, {:advanced, []}} + + defp generate_instruction_advanced(m, _is_sup, true, dep_mods), + do: {:update, m, {:advanced, []}, dep_mods} + + # non-special process (i.e. neither code_change/3 nor system_code_change/4 are exported) + defp generate_instruction_advanced(m, _is_sup, false, []), do: {:load_module, m} + defp generate_instruction_advanced(m, _is_sup, false, dep_mods), do: {:load_module, m, dep_mods} + + # This "topological" sort is not truly topological, since module dependencies + # are represented as a directed, cyclic graph, and it is not actually + # possible to sort such a graph due to the cycles which occur. However, one + # can "break" loops, until one reaches a point that the graph becomes acyclic, + # and those topologically sortable. That's effectively what happens here: + # we perform the sort, breaking loops where they exist by attempting to + # weight each of the two dependencies based on the number of outgoing dependencies + # they have, where the fewer number of outgoing dependencies always comes first. + # I have experimented with various different approaches, including algorithms for + # feedback arc sets, and none appeared to work as well as the one below. I'm definitely + # open to better algorithms, because I don't particularly like this one. + defp topological_sort(instructions) do + mods = Enum.map(instructions, fn i -> elem(i, 1) end) + + instructions + |> Enum.sort(&do_sort_instructions(mods, &1, &2)) + |> Enum.map(fn + {:update, _, _} = i -> + i + + {:load_module, _} = i -> + i + + {:update, m, type, deps} -> + {:update, m, type, + Enum.filter(deps, fn + ^m -> false + d -> d in mods + end)} + + {:load_module, m, deps} -> + {:load_module, m, + Enum.filter(deps, fn + ^m -> false + d -> d in mods + end)} + end) + end + + defp do_sort_instructions(mods, a, b) do + am = elem(a, 1) + bm = elem(b, 1) + ad = extract_deps(a) + bd = extract_deps(b) + do_sort_instructions(mods, am, bm, ad, bd) + end + + defp do_sort_instructions(mods, am, bm, ad, bd) do + ad = + Enum.filter(ad, fn + ^am -> false + d -> d in mods + end) + + bd = + Enum.filter(bd, fn + ^bm -> false + d -> d in mods + end) + + lad = length(ad) + lbd = length(bd) + + cond do + lad == 0 and lbd != 0 -> + true + + lad != 0 and lbd == 0 -> + false + + # If a depends on b and b doesn't depend on a + # Then b comes first, and vice versa + am in bd and bm not in ad -> + true + + am not in bd and bm in ad -> + false + + # If either they don't depend on each other, + # or they both depend on each other, then the + # module with the least outgoing dependencies + # comes first. Otherwise we treat them as equal + lad > lbd -> + false + + lbd > lad -> + true + + :else -> + true + end + end + + defp extract_deps({:update, _m, deps}) when is_list(deps), do: deps + defp extract_deps({:update, _m, _change}), do: [] + defp extract_deps({:update, _m, _change, deps}), do: deps + defp extract_deps({:update, _m, _change, _pre_purge, _post_purge, deps}), do: deps + defp extract_deps({:load_module, _m, deps}), do: deps + defp extract_deps({:load_module, _m, _pre_purge, _post_purge, deps}), do: deps + defp extract_deps({:delete_module, _m, deps}), do: deps + defp extract_deps({:add_module, _m, deps}), do: deps + defp extract_deps(_), do: [] + + defp module_name(file) do + Keyword.fetch!(:beam_lib.info(file), :module) + end + + defp vsn(props) do + {:value, {:vsn, vsn}} = :lists.keysearch(:vsn, 1, props) + List.to_string(vsn) + end +end diff --git a/lib/jellyfish/releases/copy.ex b/lib/jellyfish/releases/copy.ex new file mode 100644 index 0000000..fd08d44 --- /dev/null +++ b/lib/jellyfish/releases/copy.ex @@ -0,0 +1,20 @@ +defmodule Jellyfish.Releases.Copy do + @moduledoc """ + This module is responsible for providing the release file copy method + + Copied/modified from https://preview.hex.pm/preview/forecastle/0.1.2/show/lib/forecastle.ex + """ + + @spec relfile(Mix.Release.t()) :: Mix.Release.t() + def relfile(%Mix.Release{name: name, version: vsn, path: path, version_path: vp} = release) do + rel_source = Path.join(vp, "#{name}.rel") + rel_dest = Path.join([path, "releases", "#{name}-#{vsn}.rel"]) + + message = "copying release file to #{rel_dest}" + Mix.shell().info([:green, "* hot-upgrade ", :reset, message]) + + File.cp!(rel_source, rel_dest) + + release + end +end diff --git a/lib/jellyfish/tasks/compile/appup.ex b/lib/jellyfish/tasks/compile/appup.ex new file mode 100644 index 0000000..3d0d559 --- /dev/null +++ b/lib/jellyfish/tasks/compile/appup.ex @@ -0,0 +1,58 @@ +defmodule Mix.Tasks.Compile.Appup do + @moduledoc """ + This module is responsible for generating appups between two releases. + + Copied/modified from https://preview.hex.pm/preview/forecastle/0.1.2/show/lib/mix/tasks/compile/appup.ex + """ + @shortdoc "Compiles appup files" + use Mix.Task.Compiler + + require Logger + + @impl true + @spec run(any()) :: :ok | {:error, [Mix.Task.Compiler.Diagnostic.t(), ...]} + def run(_args) do + # make sure loadpaths are updated + Mix.Task.run("loadpaths", []) + + version = Mix.Project.config()[:version] + app_name = Mix.Project.config()[:app] + + with [file] <- Path.wildcard("rel/appups/#{app_name}/*_to_#{version}.appup") do + dst = Path.join(Mix.Project.compile_path(), "holidex.appup") + + edit_appup? = System.get_env("EDIT_APPUP") + + if edit_appup? do + IO.puts( + "#{IO.ANSI.cyan()}------------------------------------------------------------------------------" + ) + + IO.gets( + "Press any key when you're done editing #{IO.ANSI.magenta()}#{file}\n#{IO.ANSI.cyan()}------------------------------------------------------------------------------\n" + ) + end + + File.copy(file, dst) + :ok + else + [] = _appups -> + :ok + + error -> + Logger.error("Error copying appup to release, #{inspect(error)}") + + {:error, [diagnostic(:warning, "Appup file not found: #{Mix.Project.config()[:appup]}")]} + end + end + + defp diagnostic(severity, message, file \\ Mix.Project.project_file()) do + %Mix.Task.Compiler.Diagnostic{ + compiler_name: "Appup", + file: file, + position: nil, + severity: severity, + message: message + } + end +end diff --git a/lib/jellyfish/tasks/compile/gen_appup.ex b/lib/jellyfish/tasks/compile/gen_appup.ex new file mode 100644 index 0000000..3f33bac --- /dev/null +++ b/lib/jellyfish/tasks/compile/gen_appup.ex @@ -0,0 +1,210 @@ +defmodule Mix.Tasks.Compile.GenAppup do + @moduledoc """ + Generate appup files for hot upgrades + + Copied/modified from https://github.com/bitwalker/distillery/blob/master/lib/distillery/tasks/gen.appup.ex + + The generated appup will be written to `rel/appups//_to_.appup`. You may name + appups anything you wish in this directory, as long as they have a `.appup` extension. When you + build a release, the appup generator will look for missing appups in this directory structure, and + scan all `.appup` files for matching versions. If you have multiple appup files which match the current + release, then the first one encountered will take precedence, which more than likely will depend on the + sort order of the names. + """ + @shortdoc "Genrates appup files" + use Mix.Task.Compiler + + alias Jellyfish.Releases.Appups + + @recursive true + + @impl true + @spec run(term()) :: no_return + def run(_args) do + # make sure loadpaths are updated + Mix.Task.run("loadpaths", []) + + app_name = Mix.Project.config()[:app] + + opts = %{ + app: app_name, + upgrade_from: :latest, + output_dir: "_build/#{Mix.env()}/rel/#{app_name}/" + } + + case do_gen_appup(opts) do + :ok -> + IO.puts( + "You can find your generated appups in rel/appups/#{app_name}/ with the .appup extension" + ) + + {:error, _} -> + IO.puts("No appups, nothing to move to the release") + end + end + + defp do_gen_appup(opts) do + app = opts[:app] + output_dir = opts[:output_dir] + + # Does app exist? + case Application.load(app) do + :ok -> + :ok + + {:error, {:already_loaded, _}} -> + :ok + + {:error, _} -> + System.halt(1) + end + + v2 = + app + |> Application.spec() + |> Keyword.get(:vsn) + |> List.to_string() + + v2_path = Application.app_dir(app) + + # Look for app versions in release directory + available_versions = + Path.join([output_dir, "lib", "#{app}-*"]) + |> Path.wildcard() + |> Enum.map(fn appdir -> + {:ok, [{:application, ^app, meta}]} = + Path.join([appdir, "ebin", "#{app}.app"]) + |> read_terms() + + version = + meta + |> Keyword.fetch!(:vsn) + |> List.to_string() + + {version, appdir} + end) + |> Map.new() + |> Map.delete(v2) + + sorted_versions = + available_versions + |> Map.keys() + |> sort_versions() + + if map_size(available_versions) == 0 do + {:error, "No previous releases exist"} + else + {v1, v1_path} = + case opts[:upgrade_from] do + :latest -> + version = List.first(sorted_versions) + {version, Map.fetch!(available_versions, version)} + + version -> + case Map.get(available_versions, version) do + nil -> + System.halt(1) + + path -> + {version, path} + end + end + + case Appups.make(app, v1, v2, v1_path, v2_path, _transforms = []) do + {:error, _} = err -> + err + + {:ok, appup} -> + appup_path = Path.join(["rel", "appups", "#{app}", "#{v1}_to_#{v2}.appup"]) + File.mkdir_p!(Path.dirname(appup_path)) + :ok = write_term(appup_path, appup) + end + end + end + + defp read_terms(path) when is_binary(path) do + case :file.consult(path) do + {:ok, _} = result -> + result + + {:error, reason} -> + {:error, {:read_terms, :file, reason}} + end + end + + @spec sort_versions([binary]) :: [binary] + defp sort_versions(versions) do + versions + |> classify_versions() + |> parse_versions() + |> Enum.sort(&compare_versions/2) + |> Enum.map(&elem(&1, 0)) + end + + @git_describe_pattern ~r/(?\d+\.\d+\.\d+)-(?\d+)-(?[A-Ga-g0-9]+)/ + defp classify_versions([]), do: [] + + defp classify_versions([ver | versions]) when is_binary(ver) do + # Special handling for git-describe versions + compare_ver = + case Regex.named_captures(@git_describe_pattern, ver) do + nil -> + {:standard, ver} + + %{"ver" => version, "commits" => n, "sha" => sha} -> + {:describe, <>, String.to_integer(n)} + end + + [{ver, compare_ver} | classify_versions(versions)] + end + + defp parse_versions([]), + do: [] + + defp parse_versions([{raw, {:standard, ver}} | versions]) when is_binary(ver) do + [{raw, parse_version(ver), 0} | parse_versions(versions)] + end + + defp parse_versions([{raw, {:describe, ver, commits_since}} | versions]) when is_binary(ver) do + [{raw, parse_version(ver), commits_since} | parse_versions(versions)] + end + + defp parse_version(ver) when is_binary(ver) do + parsed = Version.parse!(ver) + {:v, parsed} + rescue + Version.InvalidVersionError -> + {:other, ver} + end + + defp compare_versions({_, {:v, v1}, v1_commits_since}, {_, {:v, v2}, v2_commits_since}) do + case Version.compare(v1, v2) do + :gt -> + true + + :lt -> + false + + :eq -> + # Same version, so compare any incremental changes + # This is based on the describe syntax, but is defaulted to 0 + # for non-describe versions + v1_commits_since > v2_commits_since + end + end + + defp compare_versions({_, {_, v1}, _}, {_, {_, v2}, _}), do: v1 > v2 + + defp write_term(path, term) do + path = String.to_charlist(path) + contents = :io_lib.fwrite(~c"~p.\n", [term]) + + case :file.write_file(path, contents, encoding: :utf8) do + :ok -> + :ok + + {:error, reason} -> + {:error, {:write_terms, :file, reason}} + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..911e4c9 --- /dev/null +++ b/mix.exs @@ -0,0 +1,59 @@ +defmodule Jellyfish.MixProject do + use Mix.Project + + def project do + [ + app: :jellyfish, + version: "0.1.0", + elixir: "~> 1.16", + start_permanent: Mix.env() == :prod, + deps: deps(), + docs: docs(), + package: package(), + description: description() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger, :runtime_tools] + ] + end + + defp description do + """ + Build appup files for your Elixir app release + """ + end + + defp package do + [ + files: ["lib", "priv", "mix.exs", "README.md", "LICENSE.md", ".formatter.exs"], + maintainers: ["Paul Schoenfelder"], + licenses: ["MIT"], + links: %{ + Documentation: "https://hexdocs.pm/distillery", + Changelog: "https://hexdocs.pm/distillery/changelog.html", + GitHub: "https://github.com/bitwalker/distillery" + } + ] + end + + defp docs do + [ + source_url: "https://github.com/thiagoesteves/jellyfish", + homepage_url: "https://github.com/thiagoesteves/jellyfish", + main: "home" + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:ex_doc, "~> 0.18", only: :dev}, + {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..e454493 --- /dev/null +++ b/mix.lock @@ -0,0 +1,13 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "ex_doc": {:hex, :ex_doc, "0.32.1", "21e40f939515373bcdc9cffe65f3b3543f05015ac6c3d01d991874129d173420", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5142c9db521f106d61ff33250f779807ed2a88620e472ac95dc7d59c380113da"}, + "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, +} 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()