-
-
Notifications
You must be signed in to change notification settings - Fork 82
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added version compatibility check (#332)
* Added version compatibility check Lexical now outputs the erlang and elixir versions of the VM that was used to create them. It now checks the build and packaging directories for these versioning files and emits an error message if the vm starts with beam files that won't work. * Detected version change on call to package Another belt to wear with the suspenders. When we build a package that overwrites another package, if the version has changed, we delete the old compiled code and the old package and start compilation from scratch. This definitely prevents old beam files from ending up in the new package.
- Loading branch information
Showing
6 changed files
with
470 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,222 @@ | ||
defmodule Lexical.VM.Versions do | ||
@moduledoc """ | ||
Reads and writes version tags for elixir and erlang | ||
When compiling, it is important to node which version of the VM and elixir runtime | ||
were used to build the beam files, as beam files compiled on a newer version of the | ||
VM cannot be used on older versions. | ||
This module allows a directory to be tagged with the versions of elixir and erlang | ||
used as compilation artifacts, and also allows the user to ask if a certain version | ||
is compatible with the currently running VM. | ||
""" | ||
|
||
@type version_string :: String.t() | ||
|
||
@type t :: %{elixir: version_string(), erlang: version_string()} | ||
@type versioned_t :: %{elixir: Version.t(), erlang: Version.t()} | ||
|
||
@doc """ | ||
Returns the versions of elixir and erlang in the currently running VM | ||
""" | ||
@spec current() :: t | ||
def current do | ||
%{ | ||
elixir: elixir_version(), | ||
erlang: erlang_version() | ||
} | ||
end | ||
|
||
@doc """ | ||
Returns the compiled-in versions of elixir and erlang. | ||
This function uses the code server to find `.elixir` and `.erlang` files in the code path. | ||
Each of these files represent the version of the runtime the artifact was compiled with. | ||
""" | ||
@spec compiled() :: {:ok, t} | {:error, atom()} | ||
def compiled do | ||
with {:ok, elixir_path} <- code_find_file(version_file(:elixir)), | ||
{:ok, erlang_path} <- code_find_file(version_file(:erlang)), | ||
{:ok, elixir_version} <- read_file(elixir_path), | ||
{:ok, erlang_version} <- read_file(erlang_path) do | ||
{:ok, %{elixir: String.trim(elixir_version), erlang: String.trim(erlang_version)}} | ||
end | ||
end | ||
|
||
@doc """ | ||
Converts the values of a version map into `Version` structs | ||
""" | ||
@spec to_versions(t) :: versioned_t() | ||
def to_versions(%{elixir: elixir, erlang: erlang}) do | ||
%{elixir: to_version(elixir), erlang: to_version(erlang)} | ||
end | ||
|
||
@doc """ | ||
Tells whether or not the current version of VM is supported by | ||
Lexical's compiled artifacts. | ||
""" | ||
@spec compatible?() :: boolean | ||
@spec compatible?(Path.t()) :: boolean | ||
def compatible? do | ||
case code_find_file(version_file(:erlang)) do | ||
{:ok, path} -> | ||
path | ||
|> Path.dirname() | ||
|> compatible?() | ||
|
||
:error -> | ||
false | ||
end | ||
end | ||
|
||
def compatible?(directory) do | ||
system = current() | ||
|
||
case read(directory) do | ||
{:ok, tagged} -> | ||
system_erlang = to_version(system.erlang) | ||
tagged_erlang = to_version(tagged.erlang) | ||
|
||
tagged_erlang.major <= system_erlang.major | ||
|
||
_ -> | ||
false | ||
end | ||
end | ||
|
||
@doc """ | ||
Returns true if the current directory has version tags for | ||
both elixir and erlang in it. | ||
""" | ||
def tagged?(directory) do | ||
with true <- File.exists?(version_file_path(directory, :elixir)) do | ||
File.exists?(version_file_path(directory, :erlang)) | ||
end | ||
end | ||
|
||
@doc """ | ||
Writes version tags in the given directory, overwriting any that are present | ||
""" | ||
def write(directory) do | ||
write_erlang_version(directory) | ||
write_elixir_version(directory) | ||
end | ||
|
||
@doc """ | ||
Reads all the version tags in the given directory. | ||
This function will fail if one or both tags is missing | ||
""" | ||
def read(directory) do | ||
with {:ok, elixir} <- read_elixir_version(directory), | ||
{:ok, erlang} <- read_erlang_version(directory) do | ||
{:ok, %{elixir: String.trim(elixir), erlang: String.trim(erlang)}} | ||
end | ||
end | ||
|
||
defp write_erlang_version(directory) do | ||
directory | ||
|> version_file_path(:erlang) | ||
|> write_file!(erlang_version()) | ||
end | ||
|
||
defp write_elixir_version(directory) do | ||
directory | ||
|> version_file_path(:elixir) | ||
|> write_file!(elixir_version()) | ||
end | ||
|
||
defp read_erlang_version(directory) do | ||
directory | ||
|> version_file_path(:erlang) | ||
|> read_file() | ||
end | ||
|
||
defp read_elixir_version(directory) do | ||
directory | ||
|> version_file_path(:elixir) | ||
|> read_file() | ||
end | ||
|
||
defp elixir_version do | ||
System.version() | ||
end | ||
|
||
defp erlang_version do | ||
major = :otp_release |> :erlang.system_info() |> List.to_string() | ||
version_file = Path.join([:code.root_dir(), "releases", major, "OTP_VERSION"]) | ||
|
||
try do | ||
{:ok, contents} = read_file(version_file) | ||
String.split(contents, "\n", trim: true) | ||
else | ||
[full] -> full | ||
_ -> major | ||
catch | ||
:error -> | ||
major | ||
end | ||
end | ||
|
||
defp version_file_path(directory, language) do | ||
Path.join(directory, version_file(language)) | ||
end | ||
|
||
defp version_file(language) do | ||
".#{language}" | ||
end | ||
|
||
defp normalize(erlang_version) do | ||
# Erlang doesn't use versions compabible with semantic versioning, | ||
# this will make it compatible, as whatever the last number represents | ||
# won't introduce vm-level incompatibilities. | ||
|
||
version_components = | ||
erlang_version | ||
|> String.split(".") | ||
|> Enum.take(3) | ||
|
||
normalized = | ||
case version_components do | ||
[major] -> [major, "0", "0"] | ||
[major, minor] -> [major, minor, "0"] | ||
[_, _, _] = version -> version | ||
[major, minor, patch | _] -> [major, minor, patch] | ||
end | ||
|
||
Enum.join(normalized, ".") | ||
end | ||
|
||
require Logger | ||
|
||
defp code_find_file(file_name) when is_binary(file_name) do | ||
file_name | ||
|> String.to_charlist() | ||
|> code_find_file() | ||
end | ||
|
||
defp code_find_file(file_name) do | ||
Logger.info("file name is #{file_name}") | ||
|
||
case :code.where_is_file(file_name) do | ||
:non_existing -> | ||
:error | ||
|
||
path -> | ||
{:ok, List.to_string(path)} | ||
end | ||
end | ||
|
||
defp to_version(version) when is_binary(version) do | ||
version |> normalize() |> Version.parse!() | ||
end | ||
|
||
# these functions exist for testing. I was getting process killed with | ||
# patch if we patch the File module directly | ||
defp write_file!(path, contents) do | ||
File.write!(path, contents) | ||
end | ||
|
||
defp read_file(path) do | ||
File.read(path) | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
defmodule Lexical.VM.VersionTest do | ||
alias Lexical.VM.Versions | ||
use ExUnit.Case | ||
use Patch | ||
import Versions | ||
|
||
test "it gets the current version" do | ||
assert current().elixir == System.version() | ||
end | ||
|
||
test "it gets the current erlang version" do | ||
patch(Versions, :erlang_version, fn -> "25.3.2.1" end) | ||
assert current().erlang == "25.3.2.1" | ||
end | ||
|
||
test "it reads the versions in a directory" do | ||
patch(Versions, :read_file, fn "/foo/bar/baz/" <> file -> | ||
if String.ends_with?(file, ".erlang") do | ||
{:ok, "25.3.2.2"} | ||
else | ||
{:ok, "14.5.2"} | ||
end | ||
end) | ||
|
||
assert {:ok, tags} = read("/foo/bar/baz") | ||
|
||
assert tags.elixir == "14.5.2" | ||
assert tags.erlang == "25.3.2.2" | ||
end | ||
|
||
test "it writes the versions" do | ||
patch(Versions, :erlang_version, "25.3.2.1") | ||
patch(Versions, :write_file!, :ok) | ||
|
||
elixir_version = System.version() | ||
|
||
assert write("/foo/bar/baz") | ||
assert_called(Versions.write_file!("/foo/bar/baz/.erlang", "25.3.2.1")) | ||
assert_called(Versions.write_file!("/foo/bar/baz/.elixir", ^elixir_version)) | ||
end | ||
|
||
def patch_system_versions(elixir, erlang) do | ||
patch(Versions, :elixir_version, elixir) | ||
patch(Versions, :erlang_version, erlang) | ||
end | ||
|
||
def patch_tagged_versions(elixir, erlang) do | ||
patch(Versions, :read_file, fn file -> | ||
if String.ends_with?(file, ".elixir") do | ||
{:ok, elixir} | ||
else | ||
{:ok, erlang} | ||
end | ||
end) | ||
end | ||
|
||
def with_exposed_normalize(_) do | ||
expose(Versions, normalize: 1) | ||
:ok | ||
end | ||
|
||
describe "normalize/1" do | ||
setup [:with_exposed_normalize] | ||
|
||
test "fixes a two-element version" do | ||
assert "25.0.0" == private(Versions.normalize("25.0")) | ||
end | ||
|
||
test "keeps three-element versions the same" do | ||
assert "25.3.2" == private(Versions.normalize("25.3.2")) | ||
end | ||
|
||
test "truncates versions with more than three elements" do | ||
assert "25.3.2" == private(Versions.normalize("25.3.2.2")) | ||
|
||
# I can't imagine they'd do this, but, you know, belt and suspenders | ||
assert "25.3.2" == private(Versions.normalize("25.3.2.1.2")) | ||
assert "25.3.2" == private(Versions.normalize("25.3.2.4.2.3")) | ||
end | ||
end | ||
|
||
test "an untagged directory is not compatible" do | ||
refute compatible?(System.tmp_dir!()) | ||
end | ||
|
||
describe "compatible?/1" do | ||
test "lower major versions of erlang are compatible with later major versions" do | ||
patch_system_versions("1.14.5", "26.0") | ||
patch_tagged_versions("1.14.5", "25.0") | ||
|
||
assert compatible?("/foo/bar/baz") | ||
end | ||
|
||
test "higher major versions are not compatible with lower major versions" do | ||
patch_system_versions("1.14.5", "25.0") | ||
patch_tagged_versions("1.14.5", "26.0") | ||
|
||
refute compatible?("/foo/bar/baz") | ||
end | ||
|
||
test "the same versions are compatible with each other" do | ||
patch_system_versions("1.14.5", "25.3.3") | ||
patch_tagged_versions("1.14.5", "25.0") | ||
|
||
assert compatible?("/foo/bar/baz") | ||
end | ||
|
||
test "higher minor versions are compatible" do | ||
patch_system_versions("1.14.5", "25.3.0") | ||
patch_tagged_versions("1.14.5", "25.0") | ||
|
||
assert compatible?("/foo/bar/baz") | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.