diff --git a/lib/farmbot/farmware/farmware.ex b/lib/farmbot/farmware/farmware.ex new file mode 100644 index 00000000..f28f79db --- /dev/null +++ b/lib/farmbot/farmware/farmware.ex @@ -0,0 +1,130 @@ +defmodule Farmbot.Farmware do + @moduledoc "Farmware is Farmbot's plugin system." + + defmodule Meta do + @moduledoc "Metadata about a Farmware." + + defstruct [:author,:language,:description] + end + + defstruct [ + :name, + :version, + :min_os_version_major, + :url, + :zip, + :executable, + :args, + :config, + :meta, + ] + + @doc "Lookup a farmware by it's name." + def lookup(name, version \\ nil) do + dir = Farmbot.Farmware.Installer.install_root_path + with {:ok, all_installed} <- File.ls(dir), + true <- name in all_installed, + {:ok, versions} <- File.ls(Path.join(dir, name)) + do + [newest | _] = Enum.sort(versions, fn(ver_a, ver_b) -> + case Version.compare(ver_a, ver_b) do + :eq -> true + :gt -> true + :lt -> false + end + end) + to_fetch = (version || newest) |> Version.parse!() + if "#{to_fetch}" in versions do + mani_path = Path.join(Farmbot.Farmware.Installer.install_path(name, to_fetch), "manifest.json") + File.read!(mani_path) |> Poison.decode! |> new() + else + {:error, :no_version} + end + else + false -> {:error, :not_installed} + {:error, _} = err -> err + end + end + + @doc "Creates a new Farmware Struct" + def new(map) do + with {:ok, name} <- extract_name(map), + {:ok, version} <- extract_version(map), + {:ok, os_req} <- extract_os_requirement(map), + {:ok, url} <- extract_url(map), + {:ok, zip} <- extract_zip(map), + {:ok, exe} <- extract_exe(map), + {:ok, args} <- extract_args(map), + {:ok, config} <- extract_config(map), + {:ok, meta} <- extract_meta(map) + do + res = struct(__MODULE__, [name: name, + version: version, + min_os_version_major: os_req, + url: url, + zip: zip, + executable: exe, + args: args, + config: config, + meta: meta]) + {:ok, res} + else + err -> err + end + end + + defp extract_name(%{"package" => name}) when is_binary(name), do: {:ok, name} + defp extract_name(_), do: {:error, "bad or missing farmware name"} + + defp extract_version(%{"version" => version}) do + case Version.parse(version) do + {:ok, _} = res -> res + :error -> {:error, "Could not parse version."} + end + end + + defp extract_version(_), do: {:error, "bad or missing farmware version"} + + defp extract_os_requirement(%{"min_os_version_major" => num}) when is_number(num) do + {:ok, num} + end + + defp extract_os_requirement(_), do: {:error, "bad or missing os requirement"} + + defp extract_zip(%{"zip" => zip}) when is_binary(zip), do: {:ok, zip} + defp extract_zip(_), do: {:error, "bad or missing farmware zip url"} + + defp extract_url(%{"url" => url}) when is_binary(url), do: {:ok, url} + defp extract_url(_), do: {:error, "bad or missing farmware url"} + + defp extract_exe(%{"executable" => exe}) when is_binary(exe) do + case System.find_executable(exe) do + nil -> {:error, "#{exe} is not installed"} + path -> {:ok, path} + end + end + + defp extract_exe(_), do: {:error, "bad or missing farmware executable"} + + defp extract_args(%{"args" => args}) when is_list(args) do + if Enum.all?(args, &is_binary(&1)) do + {:ok, args} + else + {:error, "invalid args"} + end + end + + defp extract_args(_), do: {:error, "bad or missing farmware args"} + + defp extract_config(map) do + {:ok, Map.get(map, "config", [])} + end + + defp extract_meta(map) do + desc = Map.get(map, "description", "no description provided.") + lan = Map.get(map, "language", "no language provided.") + auth = Map.get(map, "author", "no author provided") + {:ok, struct(Meta, [description: desc, language: lan, author: auth])} + end + +end diff --git a/lib/farmbot/farmware/inspect.ex b/lib/farmbot/farmware/inspect.ex new file mode 100644 index 00000000..5fdac45e --- /dev/null +++ b/lib/farmbot/farmware/inspect.ex @@ -0,0 +1,8 @@ +defimpl Inspect, for: Farmbot.Farmware do + def inspect(%{name: name, version: version}, _), do: "#Farmware<#{name}(#{version})>" + def inspect(_thing, _), do: "#Farmware<:invalid>" +end + +defimpl Inspect, for: Farmbot.Farmware.Meta do + def inspect(meta, _), do: "#FarmwareMeta<#{meta.description}>" +end diff --git a/lib/farmbot/farmware/installer/installer.ex b/lib/farmbot/farmware/installer/installer.ex new file mode 100644 index 00000000..b94469a6 --- /dev/null +++ b/lib/farmbot/farmware/installer/installer.ex @@ -0,0 +1,136 @@ +defmodule Farmbot.Farmware.Installer do + @moduledoc "Install Farmware from a URL." + + alias Farmbot.Farmware + alias Farmbot.Farmware.Installer.Repository + alias Farmbot.HTTP + + require Logger + + @current_os_version Mix.Project.config[:version] + + data_path = Application.get_env(:farmbot, :data_path) || raise "No configured data_path." + @farmware_install_path Path.join(data_path, "farmware") + + @doc "The root dir of farmware installs." + def install_root_path, do: @farmware_install_path + + @doc "Where on the filesystem is this Farmware installed." + def install_path(name, %Version{} = fw_version) do + Path.join([@farmware_install_path, name, fw_version |> to_string]) + end + + @doc "Where on the filesystem is this Farmware installed." + def install_path(%Farmware{name: name, version: version}) do + install_path(name, version) + end + + @doc "Enable a repo from a url." + def enable_repo(url_or_repo_struct, acc \\ []) + + def enable_repo(url, _acc) when is_binary(url) do + Logger.info "Enabling repo from: #{url}" + with {:ok, %{status_code: code, body: body}} when code > 199 and code < 300 <- HTTP.get(url), + {:ok, json_map} <- Poison.decode(body), + {:ok, repo} <- Repository.new(json_map) + do + case enable_repo(repo, []) do + [] -> + Logger.info "Successfully enabled repo." + :ok + list_of_entries -> + Logger.error "Failed to enable some entries: #{inspect list_of_entries}" + {:error, list_of_entries} + end + end + end + + def enable_repo(repo = %Repository{manifests: [entry = %Repository.Entry{manifest_url: manifest_url} | entries]}, acc) do + case install(manifest_url) do + :ok -> enable_repo(%{repo | manifests: entries}, acc) + {:error, _err} -> enable_repo(%{repo | manifests: entries}, [entry | acc]) + end + end + + def enable_repo(%Repository{manifests: []}, acc), do: acc + + @doc "Install a farmware from a URL." + def install(url) do + Logger.info "Installing farmware from #{url}." + with {:ok, %{status_code: code, body: body}} when code > 199 and code < 300 <- HTTP.get(url), + {:ok, json_map} <- Poison.decode(body), + {:ok, farmware} <- Farmware.new(json_map), + :ok <- preflight_checks(farmware) do + finish_install(farmware, json_map) + else + {:error, {name, version, :already_installed}} -> + Logger.info "Farmware #{name} - #{version} is already installed." + :ok + {:ok, %{status_code: code, body: body}} -> + Logger.error "Failed to fetch Farmware manifest: #{inspect code}: #{body}" + {:error, :bad_http_response} + {:error, {:invalid, _, _}} -> + Logger.error "Failed to parse json" + {:error, :bad_json} + {:error, reason} -> + Logger.error "Failed to install farmware from #{url}: #{inspect reason}" + {:error, reason} + + err -> + Logger.error "Unexpected error installing farmware. #{inspect err}" + {:error, err} + end + end + + defp preflight_checks(%Farmware{} = fw) do + Logger.info "Starting preflight checks for #{inspect fw}" + with :ok <- check_version(fw.min_os_version_major), + :ok <- check_directory(fw.name, fw.version) + do + :ok + end + end + + def check_version(required_os_version) when is_number(required_os_version) do + case Version.parse!(@current_os_version).major >= required_os_version do + true -> :ok + false -> {:error, "does not meet version requirement: #{required_os_version}"} + end + end + + # sets up directories or returns already_installed. + defp check_directory(fw_name, %Version{} = fw_version) do + Logger.info "Checking directories for #{fw_name} - #{fw_version}" + install_path = install_path(fw_name, fw_version) + manifest_path = Path.join(install_path, "manifest.json") + if File.exists?(manifest_path) do + {:error, {fw_name, fw_version, :already_installed}} + else + File.mkdir_p(install_path) + end + end + + # Fetches the package and unzips it. + defp finish_install(%Farmware{} = fw, json_map) do + Logger.info "Finishing install for #{inspect fw}" + zip_path = "/tmp/#{fw.name}-#{fw.version}.zip" + zip_url = fw.zip + with {:ok, ^zip_path} <- HTTP.download_file(zip_url, zip_path), + :ok <- unzip(fw, zip_path), + {:ok, json} <- Poison.encode(json_map) + do + manifest_path = Path.join(install_path(fw), "manifest.json") + File.write(manifest_path,json) + end + end + + defp unzip(%Farmware{} = fw, zip_path) do + install_dir = install_path(fw) + with {:ok, cur_dir} <- File.cwd, + :ok <- File.cd(install_dir) + do + :zip.unzip(zip_path |> to_charlist()) + File.cd cur_dir + end + end +end diff --git a/lib/farmbot/farmware/installer/repository.ex b/lib/farmbot/farmware/installer/repository.ex new file mode 100644 index 00000000..62f64d66 --- /dev/null +++ b/lib/farmbot/farmware/installer/repository.ex @@ -0,0 +1,21 @@ +defmodule Farmbot.Farmware.Installer.Repository do + @moduledoc "A Repository is a way of enabling multiple farmware's at a time." + + defmodule Entry do + @moduledoc "An entry in the Repository." + defstruct [:name, :manifest_url] + end + + defstruct [manifests: []] + + @doc "Turn a list into a Repo." + def new(list, acc \\ struct(__MODULE__)) + + def new([%{"name" => name, "manifest" => mani_url} | rest], %__MODULE__{manifests: manifests} = acc) do + entry = struct(Entry, name: name, manifest_url: mani_url) + new(rest, %{acc | manifests: [entry | manifests]}) + end + + def new([], acc), do: {:ok, acc} + +end diff --git a/lib/farmbot/farmware/supervisor.ex b/lib/farmbot/farmware/supervisor.ex new file mode 100644 index 00000000..9bd7e3b4 --- /dev/null +++ b/lib/farmbot/farmware/supervisor.ex @@ -0,0 +1,3 @@ +defmodule Farmbot.Farmware.Supervisor do + @moduledoc false +end