diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e505c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Elixir 1.20 / OTP ${{ matrix.otp }} + runs-on: ubuntu-24.04 + + strategy: + fail-fast: false + matrix: + otp: ["27", "28", "29"] + + env: + MIX_ENV: test + + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install Erlang and Elixir + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + with: + otp-version: ${{ matrix.otp }} + elixir-version: "1.20" + + - name: Restore dependency and build caches + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-otp-${{ matrix.otp }}-elixir-1.20-${{ hashFiles('mix.lock') }} + restore-keys: | + ${{ runner.os }}-otp-${{ matrix.otp }}-elixir-1.20- + + - name: Install Hex and Rebar + run: | + mix local.hex --force + mix local.rebar --force + + - name: Fetch dependencies + run: mix deps.get + + - name: Check formatting + run: mix format --check-formatted + + - name: Compile plugin + run: mix compile --warnings-as-errors + + - name: Run tests + run: mix test --warnings-as-errors diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..71e0f46 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,24 @@ +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +permissions: {} + +jobs: + zizmor: + name: Run zizmor 🌈 + runs-on: ubuntu-latest + permissions: + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index e64a890..2206dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Port the `rebar3_grisp` workflow to Mix with tasks for building custom OTP + systems, deploying releases, generating firmware, creating software update + packages, listing pre-built packages, and collecting bug reports. +- Add `mix grisp.configure` to create GRiSP-ready Elixir projects, including + release, Ethernet, optional Wi-Fi, distributed Erlang, and GRiSP.io + configuration. Existing project files are preserved in interactive mode. +- Add release selection, archive deployment, custom destinations, pre- and + post-deployment scripts, and forwarding of options to `mix release`. +- Add support for local and Docker GRiSP toolchains, custom OTP builds, and + pre-built OTP package selection. + ## [0.2.0] - 2024-03-21 ### Added diff --git a/README.md b/README.md index 4f9dd17..aad9c91 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,359 @@ -# GRiSP Mix plug-in +# mix_grisp -Mix plug-in to build and deploy GRiSP applications for the [GRiSP board][grisp]. +Mix tooling for building, deploying, and updating Elixir applications on +[GRiSP boards][grisp]. It provides the same GRiSP workflows as +`rebar3_grisp`, adapted to Mix releases and Elixir configuration. -## Summary +Run `mix help grisp.TASK` for task-specific help. -The package can be installed by adding `mix_grisp` to your list of dependencies -in `mix.exs`: +## Requirements + +- Elixir 1.20 or later +- A local Erlang/OTP installation whose major version matches the target OTP +- A GRiSP 2 board and SD card +- A GRiSP toolchain or Docker when building OTP, eMMC images, or bootloaders + +## Installation + +Add GRiSP and this build-time plugin to `mix.exs`: ```elixir -def deps do +defp deps do [ - {:mix_grisp, "~> 0.2.0", only: :dev} + {:grisp, "~> 2.12"}, + {:mix_grisp, "~> 0.2", runtime: false} ] end ``` -## New Project Step-By-Step - -### Create New Project - -Create a project using Elixir default project template: - - ``` - $ mix new testex --module TestEx - $ cd testex - ``` - -### Add Dependencies +Fetch dependencies with `mix deps.get`. Print the installed plugin and library +versions with: + +```console +mix grisp.version +``` + +## Create a new application + +The configure task creates a supervised Mix application with release and GRiSP +configuration, plus optional networking files: + +```console +mix grisp.configure +``` + +For non-interactive use: + +```console +mix grisp.configure --no-interactive --name my_grisp_app \ + --ssid mywifi --psk wifipsk +``` + +Important options are `--name`, `--otp-version`, `--jit true|false`, `--dest`, +`--wifi`, `--ssid`, `--psk`, `--grisp-io`, `--grisp-io-linking TOKEN`, +`--epmd`, and `--cookie`. +The JIT option uses the rebar-compatible explicit values `--jit true` and +`--jit false`. In non-interactive mode, optional features are disabled when +their flags are absent. Ethernet networking is always configured. Wi-Fi is +opt-in through `--wifi` and is also enabled implicitly when either `--ssid` or +`--psk` is supplied. Supplying `--grisp-io-linking TOKEN` also enables GRiSP.io +integration. + +If the target directory already exists, interactive mode asks for confirmation, +preserves every existing file, and creates only missing files. Non-interactive +mode refuses to configure an existing directory so scripts cannot overwrite a +project accidentally. -Add the following dependencies in the project configuration `mix.exs`: +## Configure an existing application - ``` - defp deps do - [ - ... - {:grisp, "~> 2.4"}, - {:mix_grisp, "~> 0.2.0", only: :dev}, - ] - end - ``` +Add GRiSP and release configuration to the project keyword list: + +```elixir +def project do + [ + app: :my_app, + version: "0.1.0", + elixir: "~> 1.20", + deps: deps(), + grisp: grisp(), + releases: releases() + ] +end -### Configure Grisp +defp grisp do + [ + platform: :grisp2, + otp: [version: "29", jit: true], + deploy: [ + destination: "/path/to/SD-card", + # pre_script: "rm -rf /path/to/SD-card/*", + # post_script: "diskutil unmount /path/to/SD-card" + ] + ] +end -Add the following configuration to your project configuration `mix.exs`: +defp releases do + [ + my_app: [ + overwrite: true, + cookie: "replace_with_a_long_random_cookie", + include_erts: &MixGrisp.Release.erts/0, + steps: [&MixGrisp.Release.init/1, :assemble], + include_executables_for: [], + strip_beams: Mix.env() == :prod + ] + ] +end +``` - ``` - def project do - [ - ... - grisp: grisp(), - releases: releases() - ] - end +`:platform` defaults to `:grisp2`. The configured OTP version requirement +selects a pre-built package unless a `:build` section enables a custom build. +Compile on the development host with the same OTP major version as the target. + +## Elixir shell and networking + +Add `grisp/grisp2/common/deploy/files/grisp.ini.mustache`. Mix releases need +the `RELEASE_LIB` boot variable. Elixir also expects native UTF-8 filename +encoding, so `-fnu` must be passed to `erl.rtems`. To boot into IEx, use the +Elixir user driver and `+iex`; `-s elixir start_iex` is obsolete and fails on +current Elixir releases. + +```ini +[erlang] +args = erl.rtems -C multi_time_warp -fnu -- -mode embedded -home . -pa . -root {{release_name}} -bindir {{release_name}}/erts-{{erts_vsn}}/bin -boot {{release_name}}/releases/{{release_version}}/start -boot_var RELEASE_LIB {{release_name}}/lib -config {{release_name}}/releases/{{release_version}}/sys.config -kernel inetrc "./erl_inetrc" -user elixir -extra +iex --no-halt +shell = none +on_exit = reboot +on_crash = reboot + +[network] +ip_self=dhcp +wlan=enable +hostname=GRISP_HOSTNAME +wpa=wpa_supplicant.conf +``` - def grisp do - [ - otp: [version: "27"], - deploy: [ - # pre_script: "rm -rf /Volumes/GRISP/*", - # destination: "tmp/grisp" - # post_script: "diskutil unmount /Volumes/GRISP", - ] - ] - end +For Wi-Fi, add `wpa_supplicant.conf` beside it: - def releases do - [ - {:myapp, - [ - overwrite: true, - cookie: "grisp", - include_erts: &MixGrisp.Release.erts/0, - steps: [&MixGrisp.Release.init/1, :assemble], - include_executables_for: [], - strip_beams: Mix.env() == :prod - ]} - ] - end - ``` +```ini +network={ + ssid="WLAN_SSID" + key_mgmt=WPA-PSK + psk="WLAN_PASSWORD" +} +``` -You can uncomment the lines in the `deploy` list after setting the proper mount -point for your SD card if you want to deploy directly to it. The destination can be -a normal path if you want to deploy to a local directory. +Do not commit real credentials. See the [GRiSP networking guide][networking]. +Ethernet is always configured. For Ethernet-only networking, omit the Wi-Fi +options; the configure task then leaves out `wlan=enable`, the `wpa` setting, +and `wpa_supplicant.conf`. -Add the following boot configuration files after changing `GRISP_HOSTNAME` to -the hostname you want the grisp board to have, `WLAN_SSID` and `WLAN_PASSWORD` -to the ssid and password of the WiFi network the Grisp board should connect to. +When `--grisp-io` is enabled, the generated project also includes the required +GRiSP.io dependencies and release applications. Its Logger level is set to +`:notice` to avoid emitting connection-level debug messages on the board. - * `grisp/grisp2/common/deploy/files/grisp.ini.mustache` +## Deploy a release - ``` - [erlang] - args = erl.rtems -C multi_time_warp -- -mode embedded -home . -pa . -root {{release_name}} -bindir {{release_name}}/erts-{{erts_vsn}}/bin -boot {{release_name}}/releases/{{release_version}}/start -boot_var RELEASE_LIB {{release_name}}/lib -config {{release_name}}/releases/{{release_version}}/sys.config -s elixir start_iex -extra --no-halt - shell = none +Deploy to the configured destination: - [network] - ip_self=dhcp - wlan=enable - hostname=GRISP_HOSTNAME - wpa=wpa_supplicant.conf - ``` +```console +mix grisp.deploy +mix grisp.deploy --relname my_app --relvsn 0.1.0 +mix grisp.deploy --destination /Volumes/GRISP --force +``` +If more than one release is configured, `--relname` is required. Use `--tar` +to create `_grisp/deploy/grisp2.RELNAME.RELVSN.tar.gz` instead of copying to a +destination: - * `grisp/grisp2/common/deploy/files/wpa_supplicant.conf` +```console +mix grisp.deploy --tar +``` - ``` - network={ - ssid="WLAN_SSID" - key_mgmt=WPA-PSK - psk="WLAN_PASSWORD" - } - ``` +Options are `--relname/-n`, `--relvsn/-v`, `--tar/-t`, +`--destination/-d`, `--force/-f`, `--pre-script`, and `--post-script`. +Options after `--` are forwarded to `mix release`: -### Add Configuration +```console +mix grisp.deploy --tar -- --quiet +``` -If not generated bu Mix template, add the file `config/config.exs`: +The task compiles with `GRISP=yes` and `GRISP_PLATFORM` set, resolves or reuses +the target OTP package, creates a Mix release with the target ERTS, and applies +all application `grisp/*/deploy` overlays. - ``` - import Config - ``` +## Generate GRiSP 2 firmware -### Check OTP Version +The default command generates a system-partition firmware under +`_grisp/firmware`: -Verify that your default erlang version matches the one configured -(26 in the example). +```console +mix grisp.firmware +``` -This is required because the beam files are compiled locally and need to be -compiled by the same version of the VM. +Available outputs are: -### Get Dependencies +- system firmware (`.sys.gz`), enabled by default and disabled with + `--no-system`; +- an eMMC image (`.emmc.gz`) with `--image`; +- bootloader firmware (`.boot.gz`) with `--bootloader`. -Get all the dependencies: - - ``` - $ mix deps.get - ``` +Examples: -### Deploy The Project +```console +mix grisp.firmware --relname my_app --relvsn 0.1.0 +mix grisp.firmware --image --bootloader --force --refresh +mix grisp.firmware --image --no-truncate +mix grisp.firmware --bundle path/to/release.tar.gz +``` -To deploy, use the grisp command provided by `mix_grisp`: +Other options are `--[no-]compress`, `--quiet`, and all release-selection +options. If no bundle is supplied, the task creates one through +`grisp.deploy --tar`; `--refresh` recreates it. Image and bootloader generation +require a toolchain. - ``` - $ mix grisp.deploy - ``` +To install firmware, copy it to the GRiSP SD card, unmount the card, open the +serial console, insert the card, reset, and interrupt barebox. Write the system +firmware to the active partition (`/dev/mmc1.0` or `/dev/mmc1.1`): -### Troubleshooting +```text +uncompress /mnt/mmc/grisp2.RELNAME.RELVSN.sys.gz /dev/mmc1.0 +``` -#### This BEAM file was compiled for a later version of the run-time system +Write an eMMC image or a bootloader image to `/dev/mmc1`: -Some bema files were compiled with a newer version of OTP, delete `_build` and -`deps`, get the fresh dependencies (`mix deps.get`), and redeploy -(`mix grisp.deploy`). +```text +uncompress /mnt/mmc/grisp2.RELNAME.RELVSN.emmc.gz /dev/mmc1 +uncompress /mnt/mmc/grisp2.RELNAME.RELVSN.boot.gz /dev/mmc1 +``` -[grisp]: https://www.grisp.org +A truncated image contains only the first system partition. Set the active +system to `0` before booting it. Writing system firmware to the inactive A/B +partition does not change which partition the board boots. +## Build a software update package -## Enabling Erlang Distribution +Create `_grisp/update/grisp2.RELNAME.RELVSN.tar`: +```console +mix grisp.pack +mix grisp.pack --with-bootloader +mix grisp.pack --refresh --force +mix grisp.pack --key private_key.pem +``` + +The task reuses or generates firmware automatically. Options include +`--system`, `--bootloader`, `--block-size`, `--key`, `--with-bootloader`, +`--refresh`, `--force`, and `--quiet`. If explicit firmware is used, an +explicit bootloader must be accompanied by an explicit system firmware. + +For A/B updates, include `grisp_updater_grisp2` and configure `:grisp_updater`: + +```elixir +config :grisp_updater, + signature_check: true, + signature_certificates: {:priv, :my_app, "certificates/updates"}, + system: {:grisp_updater_grisp2, %{}}, + sources: [ + {:grisp_updater_tarball, %{}}, + {:grisp_updater_http, %{backend: {:grisp_updater_grisp2, %{}}}} + ] +``` + +Extract the package under `releases/RELNAME/RELVSN`, serve `releases` over +HTTP, then update and validate from IEx: + +```elixir +:grisp_updater.update("http://HOST_IP:8000/RELNAME/RELVSN") +:grisp_updater.validate() +``` + +When signature checking is enabled, use `--key` and install the corresponding +public certificate in the configured directory. + +## List pre-built packages + +```console +mix grisp.package list +mix grisp.package list --type toolchain +mix grisp.package list --cached +mix grisp.package list --columns version,hash,url +``` + +Use `--platform` to override the configured platform. OTP columns are +`version`, `hash`, `name`, `size`, `etag`, `url`, and `last_modified`. +Toolchain results also provide `os`, `os_version`, `revision`, and `latest`. + +## Build OTP for GRiSP + +Custom drivers, NIFs, and GRiSP system changes require a custom OTP build. Add +a toolchain to the GRiSP configuration: -1. Add the erlang epmd to your release to be able to run Erlang distribution on GRiSP +```elixir +defp grisp do + [ + platform: :grisp2, + otp: [version: "29", jit: true], + build: [ + toolchain: [ + # Local installation takes precedence: + directory: "/PATH/TO/grisp2-rtems-toolchain/rtems/VERSION/" + # Or: docker: "grisp/grisp2-rtems-toolchain" + ] + ], + deploy: [destination: "/PATH/TO/DESTINATION"] + ] +end +``` + +`GRISP_TOOLCHAIN` overrides the configured directory. Build with: + +```console +mix grisp.build +mix grisp.build --no-configure +mix grisp.build --clean +mix grisp.build --tar +mix grisp.build --update-prebuild +``` + +The installation is stored under `_grisp/otp/VERSION/install`. Reconfigure +after adding C sources; `--no-configure` can speed up rebuilds after ordinary +source changes. - 1. Add the following line in your deps, this will ship epmd without starting it at boot. +## Bug reports - ```elixir - {:epmd, git: "https://github.com/erlang/epmd", ref: "4d1a59", runtime: false}, - ``` +```console +mix grisp.report +mix grisp.report --tar +``` + +Reports are written under `_grisp/report`. Review them for private information +before sharing. + +## Development checkouts - 2. Add epmd to the included applications so its modules are loaded at runtime - - ```elixir - def application do - [ - extra_applications: [:logger], - included_applications: [:epmd] - ] - end - ``` +To test local branches, place both repositories in the consuming project's +`_checkouts` directory: + +```console +git clone https://github.com/grisp/mix_grisp.git _checkouts/mix_grisp +git clone https://github.com/grisp/grisp_tools.git _checkouts/grisp_tools +``` -2. Read the GRiSP.ini chapter of the [wiki](https://github.com/grisp/grisp/wiki/Connecting-over-WiFI-and-Ethernet#grisp-ini) +Mix automatically gives checkout dependencies precedence over Hex packages. -3. Your grisp.ini.mustache file `args` should terminate with the following flags, choose a nodename and cookie of your liking. - ``` - ... -s elixir start_iex -kernel inetrc "./erl_inetrc" -internal_epmd epmd_sup -sname mynode -setcookie mycookie -extra --no-halt - ``` +## Troubleshooting +If boot fails with `cannot expand $RELEASE_LIB in bootfile`, ensure the +`-boot_var RELEASE_LIB {{release_name}}/lib` option is present. If BEAM files +were compiled for a later runtime, switch the development host to the target +OTP major and rebuild: + +```console +mix clean +mix deps.clean --all --build +mix deps.get +mix grisp.deploy +``` + +[grisp]: https://www.grisp.org +[networking]: https://github.com/grisp/grisp/wiki/Connecting-over-WiFI-and-Ethernet#grisp-ini diff --git a/lib/mix/tasks/grisp/build.ex b/lib/mix/tasks/grisp/build.ex new file mode 100644 index 0000000..bbf4c76 --- /dev/null +++ b/lib/mix/tasks/grisp/build.ex @@ -0,0 +1,152 @@ +defmodule Mix.Tasks.Grisp.Build do + use Mix.Task + + @shortdoc "Builds a custom Erlang/OTP system for GRiSP" + @moduledoc """ + Builds the custom Erlang/OTP system configured under `grisp: [build: ...]`. + + mix grisp.build [--clean] [--no-configure] [--tar] [--update-prebuild] + """ + + @switches [clean: :boolean, configure: :boolean, tar: :boolean, update_prebuild: :boolean] + @aliases [c: :clean, g: :configure, t: :tar, p: :update_prebuild] + + @impl Mix.Task + def run(args) do + {options, []} = MixGrisp.CLI.parse!(args, @switches, @aliases) + MixGrisp.ensure_started!() + + unless MixGrisp.Config.custom_build?() do + Mix.raise("There is no :build section in the :grisp configuration") + end + + toolchain = toolchain!() + + spec = %{ + project_root: to_charlist(MixGrisp.Project.root()), + apps: MixGrisp.Project.apps(), + otp_version_requirement: to_charlist(MixGrisp.Config.otp_version()), + jit: MixGrisp.Config.otp_jit(), + platform: MixGrisp.Config.platform(), + custom_build: true, + build: %{ + flags: %{ + clean: Keyword.get(options, :clean, false), + configure: Keyword.get(options, :configure, true), + tar: Keyword.get(options, :tar, false), + update_prebuild: Keyword.get(options, :update_prebuild, false) + } + }, + paths: %{toolchain: toolchain}, + handlers: MixGrisp.Handler.handlers(&event/2) + } + + spec |> :grisp_tools.build() |> MixGrisp.finalize() + MixGrisp.info("Done") + rescue + error in Mix.Error -> reraise(error, __STACKTRACE__) + catch + :error, reason -> Mix.raise(build_error(reason)) + end + + def event(event, state) do + case event do + [:build] -> + MixGrisp.header("Building OTP for GRiSP") + + [:build, {:platform, platform}] -> + MixGrisp.info("* Platform: #{platform}") + + [:build, :validate, :apps, {:grisp_dir_without_dep, app}] -> + MixGrisp.warn( + "Application #{app} has a grisp directory but does not depend on :grisp; its build files are ignored" + ) + + [:build, :validate, :version] -> + MixGrisp.info("* Resolving OTP version") + + [:build, :validate, :version, {:selected, version, target}] -> + MixGrisp.info(" #{version} (requirement was #{inspect(to_string(target))})") + + [:build, :download] -> + MixGrisp.info("* Downloading") + + [:build, :download, :_skip] -> + MixGrisp.info(" (skipped, using existing download)") + + [:build, :repo, :check, {:error, error}] -> + MixGrisp.warn("Repository integrity check failed: #{inspect(error)}") + + [:build, :prepare] -> + MixGrisp.info("Preparing") + + [:build, :prepare, :clean, :_run] -> + MixGrisp.info("* Cleaning...") + + [:build, :prepare, :patch] -> + MixGrisp.info("* Patching") + + [:build, :prepare, :patch, {action, %{app: app, name: file}}] -> + suffix = if action == :skip, do: " (already applied, skipping)", else: "" + MixGrisp.info(" [#{app}] #{file}#{suffix}") + + [:build, :prepare, :copy, type] -> + MixGrisp.info("* Copying #{type}") + + [:build, :prepare, :copy, _type, :_skip] -> + MixGrisp.info(" (none found)") + + [:build, :prepare, :copy, _type, %{app: app, name: file}] -> + MixGrisp.info(" [#{app}] #{file}") + + [:build, :compile] -> + MixGrisp.info("Compiling") + + [:build, :compile, :configure] -> + MixGrisp.info("* Configuring") + + [:build, :compile, :configure, {:_override, reason}] -> + MixGrisp.info(" (forced by #{reason})") + + [:build, :compile, :configure, :_skip] -> + MixGrisp.info(" (skipped)") + + [:build, :compile, :boot] -> + MixGrisp.info("* Compiling (this may take a while)") + + [:build, :compile, :install] -> + MixGrisp.info("* Installing") + + [:build, :compile, :install, :hook, :post_install, {:run, %{app: app, name: name}}] -> + MixGrisp.info(" [#{app}] #{name}") + + [:build, :tar, {:file, file}] -> + MixGrisp.info("* Packaging\n #{file}") + + _ -> + MixGrisp.debug(event) + end + + {:ok, state} + end + + defp toolchain! do + case MixGrisp.Config.toolchain() do + {:directory, _} = toolchain -> toolchain + {:docker, _} = toolchain -> toolchain + {:error, :docker_not_found} -> Mix.raise("Docker is not available") + nil -> Mix.raise("Configure grisp[:build][:toolchain] or set GRISP_TOOLCHAIN") + end + end + + defp build_error({:missing_toolchain_revision, source}), + do: "Could not determine toolchain revision (missing file: #{source})" + + defp build_error({:toolchain_root_invalid, directory}), + do: "The toolchain directory is invalid: #{inspect(directory)}" + + defp build_error({:otp_version_not_found, configured}), + do: "Could not find an OTP version matching #{inspect(configured)}" + + defp build_error(error), do: "Unexpected build error: #{inspect(error)}" +end diff --git a/lib/mix/tasks/grisp/configure.ex b/lib/mix/tasks/grisp/configure.ex new file mode 100644 index 0000000..671ca1a --- /dev/null +++ b/lib/mix/tasks/grisp/configure.ex @@ -0,0 +1,69 @@ +defmodule Mix.Tasks.Grisp.Configure do + use Mix.Task + + @shortdoc "Creates and configures a new GRiSP Mix application" + @moduledoc """ + Creates a new Mix project configured for GRiSP. + + mix grisp.configure [options] + + Interactive mode is enabled by default. If the target directory exists, + confirmation is required and existing files are preserved. Use + `--no-interactive` for scripts. + """ + + @switches [ + interactive: :boolean, + name: :string, + otp_version: :string, + jit: :string, + dest: :string, + desc: :string, + copyright_year: :string, + author_name: :string, + author_email: :string, + wifi: :boolean, + ssid: :string, + psk: :string, + grisp_io: :boolean, + grisp_io_linking: :string, + epmd: :boolean, + cookie: :string + ] + + @aliases [ + i: :interactive, + o: :otp_version, + d: :dest, + w: :wifi, + g: :grisp_io, + l: :grisp_io_linking, + e: :epmd, + c: :cookie + ] + + @impl Mix.Task + def run(args) do + {options, []} = MixGrisp.CLI.parse!(args, @switches, @aliases) + options = parse_jit(options) + result = MixGrisp.Configure.run(options) + Enum.each(result.created, &Mix.shell().info("Created #{&1}")) + Mix.shell().info("Configured GRiSP Mix project #{result.name}") + end + + defp parse_jit(options) do + case Keyword.fetch(options, :jit) do + {:ok, "true"} -> + Keyword.put(options, :jit, true) + + {:ok, "false"} -> + Keyword.put(options, :jit, false) + + {:ok, value} -> + Mix.raise("Invalid value for --jit: #{inspect(value)} (expected true or false)") + + :error -> + options + end + end +end diff --git a/lib/mix/tasks/grisp/deploy.ex b/lib/mix/tasks/grisp/deploy.ex index c3fe6f1..038c133 100644 --- a/lib/mix/tasks/grisp/deploy.ex +++ b/lib/mix/tasks/grisp/deploy.ex @@ -1,264 +1,41 @@ defmodule Mix.Tasks.Grisp.Deploy do - @moduledoc """ - Deploys a GRiSP application. - """ - alias Mix.Project - use Mix.Task - @recursive true - - @shortdoc "Deploys a GRiSP application" - - def run(_args) do - Mix.Task.run("compile", []) - - header("🐟 Deploying GRiSP application") - - {:ok, _} = Application.ensure_all_started(:grisp_tools) - config = Mix.Project.config()[:grisp] - - release_name = Project.config()[:app] - release_version = to_charlist(Project.config()[:version]) - - try do - %{ - project_root: to_charlist(File.cwd!()), - otp_version_requirement: to_charlist(config[:otp][:version] || "26"), - platform: Keyword.get(config, :platform, :grisp2), - apps: apps(), - custom_build: false, - distribute: [ - {:copy, %{ - type: :copy, - force: false, - destination: to_charlist(config[:deploy][:destination] || "tmp/grisp_sd"), - scripts: %{ - pre_script: config[:deploy][:pre_script] || :undefined, - post_script: config[:deploy][:post_script] || :undefined} - }} - ], - release: %{ - name: release_name, - version: release_version - }, - handlers: - :grisp_tools.handlers_init(%{ - event: - {&event_handler/2, - %{ - name: release_name, - version: release_version - }}, - shell: {&shell_handler/3, %{}}, - release: {&release_handler/2, nil} - }), - - } - |> :grisp_tools.deploy() - |> :grisp_tools.handlers_finalize() - - info("Deployment done") - catch - :error, {:otp_version_mismatch, target, current} -> - Mix.raise( - "Current Erlang version (#{current}) does not match target" <> - " Erlang version (#{target})" - ) - end - end - - defp event_handler(event, state) do - debug(event, label: "event") - {:ok, handle_event(event, state)} - end - - defp handle_event({:otp_type, hash, :custom_build}, state) do - header("Using custom OTP (#{short(hash)})") - state - end - - defp handle_event({:otp_type, hash, :package}, state) do - header("Downloading OTP (#{short(hash)})") - info("Version: #{short(hash)}") - state - end - - defp handle_event({:package, {:download_start, size}}, state) do - IO.write(" 0%") - Map.put(state, :progress, {0, size}) - end - - defp handle_event( - {:package, {:download_progress, current}}, - %{:progress => {tens, total}} = state - ) do - new_tens = round(current / total * 10) - - if new_tens > tens do - IO.write(" #{new_tens * 10}%") - end - - %{state | :progress => {new_tens, total}} - end - - defp handle_event({:package, {:download_complete, _etag}}, state) do - IO.write(" OK\n") - state - end - - defp handle_event({:package, :download_cached}, state) do - info("Download already cached") - state - end - - defp handle_event({:package, {:http_error, other}}, state) do - warn("Download error: #{inspect(other)}") - info("Using cached file") - state - end - - defp handle_event({:package, {:extract, :up_to_date}}, state) do - info("Current package up to date") - state - end - - defp handle_event({:package, {:extract, {:start, _package}}}, state) do - info("Extracting package") - state - end - - defp handle_event({:package, {:extract_failed, reason}}, _State) do - fail!("Tar extraction failed: #{inspect(reason)}") - end - defp handle_event({:release, {:start, _release}}, state) do - header("Creating release") - state - end - - defp handle_event({:release, {:done, release}}, state) do - info("Release complete: #{release.name}-#{release.version}") - state - end - - defp handle_event({:deployment, :init}, state) do - header("Deploying") - state - end - - defp handle_event({:deployment, :script, name, {:run, _script}}, state) do - info("Running #{name}") - state - end - - defp handle_event({:deployment, :script, _name, {:result, _output}}, state) do - state - end - - defp handle_event({:deployment, :release, {:copy, _source, _target}}, state) do - info("Copying release...") - state - end - - defp handle_event({:deployment, {:files, {:init, _dest}}}, state) do - info("Copying files...") - state - end - - defp handle_event({:deployment, :files, {:copy_error, {:exists, file}}}, _State) do - fail!("Destination #{file} already exists (use --force to overwrite)") - end - - defp handle_event({:deployment, :done}, state) do - header(IO.ANSI.format(["Deployment ", :green, "succesful", :blue, "!"])) - state - end - - defp handle_event(_event, state) do - state - end - - defp shell_handler(raw_cmd, opts, state) do - cmd = raw_cmd |> IO.iodata_to_binary() - debug(cmd, label: "cmd") - - [cmd | args] = String.split(cmd) - args = for arg <- args, do: String.trim(arg, "\"") - - opts = - Keyword.update!(opts, :env, fn env -> - for {k, v} <- env, do: {List.to_string(k), List.to_string(v)} - end) - - {result, ret} = System.cmd(cmd, args, opts) - case ret do - 0 -> {{:ok, result}, state} - _ -> Mix.raise("Error executing #{cmd} #{args}") - end - end - - defp release_handler(relspec, state) do - debug(relspec, label: "relspec") - Process.put(:relspec, relspec) - - Mix.Task.run("release", []) - - spec = Process.get(:spec) - - Process.delete(:relspec) - Process.delete(:spec) - - {%{ - dir: spec.path |> String.to_charlist(), - name: spec.name, - version: spec.version |> String.to_charlist() - }, state} - end - - # gathering the apps and their deps to build the grisp overlay later - # since mix keeps dependency sources and their build output separate it is - # important to use the source paths here. These paths will be used by :grisp_tools - # to assemble the overlays - @spec apps() :: [{Application.app(), %{dir: charlist(), deps: []}}] - defp apps do - old = Mix.env() - Mix.env(:grisp) - config = Project.config() - app = {config[:app], %{dir: File.cwd!(), deps: Project.deps_apps()}} - {_, %{name: bottom}} = Mix.ProjectStack.top_and_bottom() - {_, all_deps} = Mix.State.read_cache({:cached_deps, bottom}) - - all_apps = - all_deps - |> Enum.map(fn dep -> - sub_deps = - for d <- dep.deps do - d.app - end + @recursive true + @shortdoc "Deploys a GRiSP release to a destination" - {dep.app, %{dir: dep.opts[:dest], deps: sub_deps}} - end) + @moduledoc """ + Deploys a GRiSP release to a directory or creates a release bundle. - Mix.env(old) - all_apps ++ [app] - end + mix grisp.deploy [options] [-- MIX_RELEASE_OPTIONS] - defp short(string), do: String.slice(to_string(string), 0..8) + Options: - defp header(message), do: Mix.shell().info(IO.ANSI.format([:blue, "===> ", message])) - defp info(message), do: Mix.shell().info(message) - defp warn(message), do: Mix.shell().info(IO.ANSI.format([:yellow, message])) - defp fail!(message), do: Mix.shell().fail!(message) + * `--relname`, `-n` - release name + * `--relvsn`, `-v` - release version + * `--tar`, `-t` - create a bundle in `_grisp/deploy` + * `--destination`, `-d` - copy destination + * `--force`, `-f` - replace existing files + * `--pre-script` - command to run before copying + * `--post-script` - command to run after copying - defp debug(message, label: label) when is_binary(message) do - if Mix.debug?() do - IO.ANSI.format([:cyan, "mix_grisp[#{label}]: ", message]) - |> IO.puts() - end - end + Arguments after `--` are passed to `mix release`. + """ - defp debug(term, opts) do - debug(inspect(term), opts) - term + @switches [ + relname: :string, + relvsn: :string, + tar: :boolean, + destination: :string, + force: :boolean, + pre_script: :string, + post_script: :string + ] + @aliases [n: :relname, v: :relvsn, t: :tar, d: :destination, f: :force] + + @impl Mix.Task + def run(args) do + {options, release_args} = MixGrisp.CLI.parse!(args, @switches, @aliases) + MixGrisp.Deploy.run(options, release_args) end end diff --git a/lib/mix/tasks/grisp/firmware.ex b/lib/mix/tasks/grisp/firmware.ex new file mode 100644 index 0000000..cdced59 --- /dev/null +++ b/lib/mix/tasks/grisp/firmware.ex @@ -0,0 +1,45 @@ +defmodule Mix.Tasks.Grisp.Firmware do + use Mix.Task + + @shortdoc "Generates GRiSP firmware image files" + @moduledoc """ + Generates system, eMMC image, and/or bootloader firmware. + + mix grisp.firmware [options] [-- MIX_RELEASE_OPTIONS] + + Use `--no-system`, `--image`, and `--bootloader` to select outputs. If + `--bundle` is omitted, `mix grisp.deploy --tar` creates or reuses one. + """ + + @switches [ + relname: :string, + relvsn: :string, + bundle: :string, + refresh: :boolean, + force: :boolean, + compress: :boolean, + system: :boolean, + image: :boolean, + bootloader: :boolean, + truncate: :boolean, + quiet: :boolean + ] + @aliases [ + n: :relname, + v: :relvsn, + r: :refresh, + f: :force, + z: :compress, + s: :system, + i: :image, + b: :bootloader, + t: :truncate, + q: :quiet + ] + + @impl Mix.Task + def run(args) do + {options, release_args} = MixGrisp.CLI.parse!(args, @switches, @aliases) + MixGrisp.Firmware.run(options, release_args) + end +end diff --git a/lib/mix/tasks/grisp/pack.ex b/lib/mix/tasks/grisp/pack.ex new file mode 100644 index 0000000..b526da6 --- /dev/null +++ b/lib/mix/tasks/grisp/pack.ex @@ -0,0 +1,41 @@ +defmodule Mix.Tasks.Grisp.Pack do + use Mix.Task + + @shortdoc "Generates a GRiSP software update package" + @moduledoc """ + Generates a signed or unsigned software update package. + + mix grisp.pack [options] [-- MIX_RELEASE_OPTIONS] + + Firmware is generated automatically unless `--system` is supplied. With an + explicit bootloader, both `--system` and `--bootloader` are required. + """ + + @switches [ + relname: :string, + relvsn: :string, + system: :string, + bootloader: :string, + block_size: :integer, + key: :string, + with_bootloader: :boolean, + refresh: :boolean, + force: :boolean, + quiet: :boolean + ] + @aliases [ + n: :relname, + v: :relvsn, + k: :key, + b: :with_bootloader, + r: :refresh, + f: :force, + q: :quiet + ] + + @impl Mix.Task + def run(args) do + {options, release_args} = MixGrisp.CLI.parse!(args, @switches, @aliases) + MixGrisp.Pack.run(options, release_args) + end +end diff --git a/lib/mix/tasks/grisp/package.ex b/lib/mix/tasks/grisp/package.ex new file mode 100644 index 0000000..034e9fe --- /dev/null +++ b/lib/mix/tasks/grisp/package.ex @@ -0,0 +1,129 @@ +defmodule Mix.Tasks.Grisp.Package do + use Mix.Task + + @shortdoc "Lists pre-built GRiSP packages" + @moduledoc """ + Lists available packages. + + mix grisp.package list [--platform grisp2] [--type otp|toolchain] + [--columns version,hash] [--cached] + """ + + @switches [platform: :string, columns: :string, type: :string, cached: :boolean] + @aliases [p: :platform, c: :columns, t: :type] + @columns %{ + otp: [:version, :hash, :name, :size, :etag, :url, :last_modified], + toolchain: [:os, :os_version, :revision, :latest, :name, :size, :etag, :url, :last_modified] + } + + @impl Mix.Task + def run(["list" | args]) do + {options, []} = MixGrisp.CLI.parse!(args, @switches, @aliases) + MixGrisp.ensure_started!() + + type = parse_type(options[:type] || "otp") + platform = String.to_atom(options[:platform] || to_string(MixGrisp.Config.platform())) + source = if options[:cached], do: :cache, else: :online + columns = parse_columns(type, options[:columns]) + + title = + if type == :otp, + do: "GRiSP pre-built OTP versions for '#{platform}'", + else: "GRiSP toolchain packages" + + MixGrisp.info(title) + + %{type: type, platform: platform, source: source} + |> :grisp_tools.list_packages() + |> render(columns) + rescue + error in Mix.Error -> reraise(error, __STACKTRACE__) + catch + :error, reason -> Mix.raise(package_error(reason)) + end + + def run([]), do: Mix.raise("Expected a package command. Usage: mix grisp.package list") + def run([command | _]), do: Mix.raise("Unknown package command: #{command}") + + defp parse_type("otp"), do: :otp + defp parse_type("toolchain"), do: :toolchain + defp parse_type(type), do: Mix.raise("Unknown package type: #{type}") + + defp parse_columns(type, nil) do + if type == :otp, do: [:version], else: [:os, :latest, :os_version, :url] + end + + defp parse_columns(type, value) do + columns = value |> String.split(",", trim: true) |> Enum.map(&String.to_atom/1) + invalid = columns -- Map.fetch!(@columns, type) + + cond do + columns == [] -> Mix.raise("No columns specified") + invalid != [] -> Mix.raise("Unknown columns: #{Enum.join(invalid, ", ")}") + true -> columns + end + end + + defp render([], _columns), do: MixGrisp.warn("No packages found") + + defp render(items, columns) do + rows = + items + |> Enum.sort_by(fn item -> Enum.map(columns, &sort_value(&1, Map.get(item, &1))) end) + |> Enum.map(fn item -> Enum.map(columns, &format_value(&1, Map.get(item, &1))) end) + + headers = Enum.map(columns, &title/1) + widths = column_widths([headers | rows]) + print_row(headers, widths) + print_row(Enum.map(widths, &String.duplicate("-", &1)), widths) + Enum.each(rows, &print_row(&1, widths)) + end + + defp column_widths(rows) do + rows + |> Enum.zip_with(fn values -> values |> Enum.map(&String.length/1) |> Enum.max() end) + end + + defp print_row(values, widths) do + values + |> Enum.zip(widths) + |> Enum.map_join(" ", fn {value, width} -> String.pad_trailing(value, width) end) + |> MixGrisp.info() + end + + defp title(column), + do: column |> Atom.to_string() |> String.replace("_", " ") |> String.capitalize() + + defp sort_value(column, value) when column in [:version, :os_version], do: version_key(value) + defp sort_value(_column, value), do: to_string(value || "") + + defp version_key(value) do + value + |> to_string() + |> String.split(~r/[^0-9]+/, trim: true) + |> Enum.map(&String.to_integer/1) + end + + defp format_value(:size, value) when is_number(value), + do: format_size(value * 1.0, ["B", "KiB", "MiB", "GiB", "TiB"]) + + defp format_value(:latest, true), do: "true" + defp format_value(:latest, _), do: "" + + defp format_value(:last_modified, value) when is_integer(value), + do: value |> DateTime.from_unix!() |> DateTime.to_iso8601() + + defp format_value(_column, nil), do: "" + defp format_value(_column, value), do: to_string(value) + + defp format_size(size, [_unit | rest]) when size > 1000 and rest != [], + do: format_size(size / 1024, rest) + + defp format_size(size, [unit | _]), + do: "#{Float.round(size, if(size == trunc(size), do: 0, else: 1))} #{unit}" + + defp package_error({:not_implemented, type, source}), + do: "Listing #{source} #{type} packages is not supported" + + defp package_error(error), do: "Could not list packages: #{inspect(error)}" +end diff --git a/lib/mix/tasks/grisp/report.ex b/lib/mix/tasks/grisp/report.ex new file mode 100644 index 0000000..1f50a40 --- /dev/null +++ b/lib/mix/tasks/grisp/report.ex @@ -0,0 +1,68 @@ +defmodule Mix.Tasks.Grisp.Report do + use Mix.Task + + @shortdoc "Gathers a GRiSP project bug report" + @moduledoc "Run `mix grisp.report`, optionally with `--tar`." + + @impl Mix.Task + def run(args) do + {options, []} = MixGrisp.CLI.parse!(args, [tar: :boolean], t: :tar) + MixGrisp.ensure_started!() + report_dir = MixGrisp.Project.report_dir() + + %{ + project_root: to_charlist(MixGrisp.Project.root()), + report_dir: to_charlist(report_dir), + flags: %{tar: Keyword.get(options, :tar, false)}, + apps: MixGrisp.Project.apps(), + otp_version_requirement: to_charlist(MixGrisp.Config.otp_version()), + jit: MixGrisp.Config.otp_jit(), + custom_build: MixGrisp.Config.custom_build?(), + platform: MixGrisp.Config.platform(), + handlers: MixGrisp.Handler.handlers(&event/2) + } + |> :grisp_tools.report() + |> MixGrisp.finalize() + + MixGrisp.info("----------------------") + + MixGrisp.info( + "Please check that #{report_dir} contains no private information before sharing it." + ) + + MixGrisp.info("Done") + rescue + error in Mix.Error -> reraise(error, __STACKTRACE__) + error -> Mix.raise("Unexpected report error: #{inspect(error)}") + end + + def event(event, state) do + case event do + [:report] -> + MixGrisp.info("Grisp report\n======================") + + [:report, :write_report, :skip] -> + MixGrisp.info("Report directory is already present.") + + [:report, :write_report, {:new_report, path}] -> + MixGrisp.info("New report written at #{path}.") + + [:report, _, :files, {:copy, file}] -> + MixGrisp.info("Copied -> #{file}") + + [:report, _, :files, {:missing, file}] -> + MixGrisp.info("Missing -> #{file}") + + [:report, _, :write, file] -> + MixGrisp.info("Written -> #{file}") + + [:report, :tar, file] -> + MixGrisp.info("Created tarball -> #{file}") + + _ -> + MixGrisp.debug(event) + end + + {:ok, state} + end +end diff --git a/lib/mix/tasks/grisp/version.ex b/lib/mix/tasks/grisp/version.ex new file mode 100644 index 0000000..1c99e88 --- /dev/null +++ b/lib/mix/tasks/grisp/version.ex @@ -0,0 +1,24 @@ +defmodule Mix.Tasks.Grisp.Version do + use Mix.Task + + @shortdoc "Prints mix_grisp and dependency versions" + + @impl Mix.Task + def run([]) do + {:ok, _} = Application.ensure_all_started(:mix_grisp) + MixGrisp.ensure_started!() + + [:mix_grisp, :grisp_tools | applications(:mix_grisp) ++ applications(:grisp_tools)] + |> Enum.uniq() + |> Enum.reject(&(&1 in [:kernel, :stdlib])) + |> Enum.each(fn app -> + version = Application.spec(app, :vsn) || "unknown" + path = app |> :code.lib_dir() |> to_string() + Mix.shell().info("#{app}: #{version} (#{path})") + end) + end + + def run(args), do: Mix.raise("Unexpected arguments: #{Enum.join(args, " ")}") + + defp applications(app), do: Application.spec(app, :applications) || [] +end diff --git a/lib/mix_grisp.ex b/lib/mix_grisp.ex new file mode 100644 index 0000000..fdcb777 --- /dev/null +++ b/lib/mix_grisp.ex @@ -0,0 +1,32 @@ +defmodule MixGrisp do + @moduledoc false + + def ensure_started! do + case Application.ensure_all_started(:grisp_tools) do + {:ok, _} -> :ok + {:error, reason} -> Mix.raise("Could not start grisp_tools: #{inspect(reason)}") + end + end + + def finalize(result), do: :grisp_tools.handlers_finalize(result) + + def header(message), do: Mix.shell().info(IO.ANSI.format([:blue, "===> ", message])) + def info(message), do: Mix.shell().info(IO.iodata_to_binary(message)) + def warn(message), do: Mix.shell().info(IO.ANSI.format([:yellow, message])) + + def debug(term) do + if Mix.debug?(), do: Mix.shell().info(IO.ANSI.format([:cyan, "mix_grisp: ", inspect(term)])) + term + end + + def relative(path) do + path = to_string(path) + cwd = File.cwd!() + + case Path.relative_to(path, cwd) do + "../../../" <> _ -> path + "../../" <> _ -> path + relative -> relative + end + end +end diff --git a/lib/mix_grisp/cli.ex b/lib/mix_grisp/cli.ex new file mode 100644 index 0000000..ef3c984 --- /dev/null +++ b/lib/mix_grisp/cli.ex @@ -0,0 +1,27 @@ +defmodule MixGrisp.CLI do + @moduledoc false + + def parse!(args, switches, aliases \\ []) do + {own, extra} = split_extra(args) + + case OptionParser.parse(own, strict: switches, aliases: aliases) do + {options, [], []} -> {options, extra} + {_options, rest, []} -> Mix.raise("Unexpected arguments: #{Enum.join(rest, " ")}") + {_options, _rest, invalid} -> Mix.raise("Invalid options: #{format_invalid(invalid)}") + end + end + + defp split_extra(args) do + case Enum.split_while(args, &(&1 != "--")) do + {own, ["--" | extra]} -> {own, extra} + {own, []} -> {own, []} + end + end + + defp format_invalid(invalid) do + Enum.map_join(invalid, ", ", fn + {option, nil} -> option + {option, value} -> "#{option}=#{value}" + end) + end +end diff --git a/lib/mix_grisp/config.ex b/lib/mix_grisp/config.ex new file mode 100644 index 0000000..cf67639 --- /dev/null +++ b/lib/mix_grisp/config.ex @@ -0,0 +1,80 @@ +defmodule MixGrisp.Config do + @moduledoc false + + @default_otp "29" + @default_platform :grisp2 + + def get, do: Mix.Project.config()[:grisp] || [] + + def get(path, default \\ nil), do: deep_get(get(), List.wrap(path), default) + + def otp_version, do: get([:otp, :version], @default_otp) |> to_string() + def otp_jit, do: get([:otp, :jit], false) + + def platform do + case get(:platform) do + nil -> + case get(:board) do + nil -> + @default_platform + + board -> + Mix.shell().info("Configuration key :board is deprecated; use :platform instead") + normalize_atom(board) + end + + platform -> + normalize_atom(platform) + end + end + + def custom_build?, do: not is_nil(get(:build)) + + def toolchain do + directory = System.get_env("GRISP_TOOLCHAIN") || get([:build, :toolchain, :directory]) + docker = get([:build, :toolchain, :docker]) + + cond do + directory -> {:directory, to_charlist(directory)} + docker && docker_available?() -> {:docker, to_charlist(docker)} + docker -> {:error, :docker_not_found} + true -> nil + end + end + + def deploy(option, cli_options, default \\ nil) do + Keyword.get(cli_options, option, get([:deploy, option], default)) + end + + defp deep_get(value, [], _default), do: value + + defp deep_get(value, [key | rest], default) when is_list(value) do + case Keyword.fetch(value, key) do + {:ok, next} -> deep_get(next, rest, default) + :error -> default + end + end + + defp deep_get(value, [key | rest], default) when is_map(value) do + case Map.fetch(value, key) do + {:ok, next} -> deep_get(next, rest, default) + :error -> default + end + end + + defp deep_get(_value, _path, default), do: default + + defp normalize_atom(value) when is_atom(value), do: value + defp normalize_atom(value) when is_binary(value), do: String.to_atom(value) + + defp docker_available? do + case System.find_executable("docker") do + nil -> + false + + executable -> + {_output, status} = System.cmd(executable, ["info"], stderr_to_stdout: true) + status == 0 + end + end +end diff --git a/lib/mix_grisp/configure.ex b/lib/mix_grisp/configure.ex new file mode 100644 index 0000000..3b3f0b6 --- /dev/null +++ b/lib/mix_grisp/configure.ex @@ -0,0 +1,428 @@ +defmodule MixGrisp.Configure do + @moduledoc false + + @defaults [ + interactive: true, + name: "robot", + otp_version: "29", + jit: true, + dest: "/path/to/SD-card", + desc: "A GRiSP application", + copyright_year: nil, + author_name: "Anonymous", + author_email: "anonymous@example.org", + wifi: false, + grisp_io: false, + grisp_io_linking: nil, + epmd: false, + cookie: "grisp" + ] + + def run(options) do + options = infer_options(options) + supplied = options |> Keyword.keys() |> MapSet.new() + + config = + options + |> Keyword.merge(@defaults, fn _key, supplied, _default -> supplied end) + |> Keyword.update!(:copyright_year, &(&1 || Integer.to_string(Date.utc_today().year))) + |> prompt(supplied) + + validate!(config) + root = Path.expand(config[:name]) + project_exists? = File.exists?(root) + + if project_exists? do + confirm_existing!(root, config[:interactive]) + else + Mix.Task.reenable("new") + Mix.Tasks.New.run([root, "--app", config[:name], "--sup"]) + end + + created = + [ + write_elixir(Path.join(root, "mix.exs"), mix_exs(config), project_exists?), + write_elixir( + Path.join(root, "config/config.exs"), + app_config(config), + project_exists? + ), + write(Path.join(root, "README.md"), project_readme(config), project_exists?), + write(Path.join(root, "LICENSE"), license(config), project_exists?) + ] ++ network_files(root, config, project_exists?) + + created = Enum.reject(created, &is_nil/1) + + if project_exists? do + Mix.shell().info( + "The project directory already existed; existing files were not overwritten. " <> + "Review the project configuration before continuing." + ) + end + + %{name: config[:name], root: root, config: config, created: created} + end + + defp prompt(config, supplied) do + if config[:interactive] do + config + |> ask_valid_name_unless_supplied(supplied) + |> ask_unless_supplied(supplied, :otp_version, "Erlang/OTP version", &identity/1) + |> ask_bool_unless_supplied(supplied, :jit, "Enable the Arm32 JIT patches?") + |> ask_unless_supplied(supplied, :dest, "SD card path", &identity/1) + |> prompt_features(supplied) + else + config + end + end + + defp prompt_features(config, supplied) do + config + |> ask_bool_unless_supplied(supplied, :wifi, "Use Wi-Fi?") + |> prompt_wifi(supplied) + |> ask_bool_unless_supplied(supplied, :grisp_io, "Enable GRiSP.io integration?") + |> prompt_grisp_io(supplied) + |> ask_bool_unless_supplied(supplied, :epmd, "Enable distributed Erlang?") + |> prompt_epmd(supplied) + end + + defp prompt_wifi(config, supplied) do + if config[:wifi] do + config + |> ask_unless_supplied(supplied, :ssid, "Wi-Fi SSID", &identity/1) + |> ask_unless_supplied(supplied, :psk, "Wi-Fi password", &identity/1) + else + config + end + end + + defp prompt_grisp_io(config, supplied) do + if config[:grisp_io] do + ask_unless_supplied( + config, + supplied, + :grisp_io_linking, + "Device linking token (leave empty to skip linking)", + &String.trim/1 + ) + else + config + end + end + + defp prompt_epmd(config, supplied) do + if config[:epmd], + do: ask_unless_supplied(config, supplied, :cookie, "Erlang cookie", &identity/1), + else: config + end + + defp ask_unless_supplied(config, supplied, key, label, normalize) do + if MapSet.member?(supplied, key), do: config, else: ask(config, key, label, normalize) + end + + defp ask_bool_unless_supplied(config, supplied, key, label) do + if MapSet.member?(supplied, key), do: config, else: ask_bool(config, key, label) + end + + defp ask_valid_name_unless_supplied(config, supplied) do + if MapSet.member?(supplied, :name) and valid_name?(config[:name]) do + config + else + ask_valid_name(config) + end + end + + defp ask_valid_name(config) do + config = ask(config, :name, "App name", &identity/1) + + if valid_name?(config[:name]) do + config + else + Mix.shell().error(name_error()) + ask_valid_name(Keyword.put(config, :name, nil)) + end + end + + defp ask(config, key, label, normalize) do + default = config[key] + answer = Mix.shell().prompt("#{label} [#{default || ""}]: ") |> String.trim() + Keyword.put(config, key, if(answer == "", do: default, else: normalize.(answer))) + end + + defp ask_bool(config, key, label) do + default = config[key] + hint = if default, do: "Y/n", else: "y/N" + answer = Mix.shell().prompt("#{label} [#{hint}]: ") |> String.trim() |> String.downcase() + + case answer do + "" -> + Keyword.put(config, key, default) + + value when value in ["y", "yes", "true"] -> + Keyword.put(config, key, true) + + value when value in ["n", "no", "false"] -> + Keyword.put(config, key, false) + + _ -> + Mix.shell().error("Expected yes or no") + ask_bool(config, key, label) + end + end + + defp validate!(config) do + unless valid_name?(config[:name]), do: Mix.raise(name_error()) + + if config[:grisp_io_linking] && not config[:grisp_io], + do: Mix.raise("--grisp-io-linking requires --grisp-io") + end + + defp network_files(root, config, preserve?) do + base = Path.join(root, "grisp/grisp2/common/deploy/files") + files = [write(Path.join(base, "grisp.ini.mustache"), grisp_ini(config), preserve?)] + + files = + if config[:wifi], + do: + files ++ + [write(Path.join(base, "wpa_supplicant.conf"), wpa(config), preserve?)], + else: files + + files ++ [write(Path.join(base, "erl_inetrc"), inetrc(), preserve?)] + end + + defp write(path, contents, preserve?) + + defp write(path, contents, true) when is_binary(path) do + if File.exists?(path), do: nil, else: write(path, contents, false) + end + + defp write(path, contents, false) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, contents) + path + end + + defp write_elixir(path, contents, preserve?) do + formatted = contents |> Code.format_string!() |> IO.iodata_to_binary() + write(path, formatted <> "\n", preserve?) + end + + defp mix_exs(config) do + module = Macro.camelize(config[:name]) + included_epmd = if config[:epmd], do: ", included_applications: [:epmd]", else: "" + + epmd_dep = + if config[:epmd], + do: + "\n {:epmd, git: \"https://github.com/erlang/epmd\", ref: \"4d1a59\", runtime: false},", + else: "" + + grisp_io_deps = + if config[:grisp_io], + do: + "\n {:certifi, \">= 0.0.0\"},\n {:grisp_cryptoauth, \"~> 2.6\"},\n {:grisp_updater_grisp2, \"~> 1.0\", runtime: false},\n {:grisp_connect, \"~> 3.0.0\"},", + else: "" + + grisp_dep = + if config[:grisp_io], + do: "{:grisp, \"~> 2.12\", override: true}", + else: "{:grisp, \"~> 2.12\"}" + + grisp_io_release_apps = + if config[:grisp_io], + do: "\n applications: [sasl: :permanent, grisp_updater_grisp2: :load],", + else: "" + + """ + defmodule #{module}.MixProject do + use Mix.Project + + def project do + [ + app: :#{config[:name]}, + version: "0.1.0", + elixir: "~> 1.20", + description: #{inspect(config[:desc])}, + package: [ + licenses: ["Apache-2.0"], + maintainers: [#{inspect("#{config[:author_name]} <#{config[:author_email]}>")}] + ], + start_permanent: Mix.env() == :prod, + deps: deps(), + grisp: grisp(), + releases: releases() + ] + end + + def application do + [extra_applications: [:logger]#{included_epmd}, mod: {#{module}.Application, []}] + end + + defp deps do + [#{epmd_dep}#{grisp_io_deps} + #{grisp_dep}, + {:mix_grisp, "~> 0.2", runtime: false} + ] + end + + defp grisp do + [ + platform: :grisp2, + otp: [version: #{inspect(config[:otp_version])}, jit: #{config[:jit]}], + deploy: [destination: #{inspect(config[:dest])}] + ] + end + + defp releases do + [ + #{config[:name]}: [ + overwrite: true, + cookie: #{inspect(config[:cookie])},#{grisp_io_release_apps} + include_erts: &MixGrisp.Release.erts/0, + steps: [&MixGrisp.Release.init/1, :assemble], + include_executables_for: [], + strip_beams: Mix.env() == :prod + ] + ] + end + end + """ + end + + defp app_config(config) do + connect_config = + if config[:grisp_io_linking] do + "config :grisp_connect,\n device_linking_token: #{inspect(config[:grisp_io_linking])}\n" + else + "" + end + + base = "import Config\n" + + if config[:grisp_io] do + rendered = + "config/grisp_io.exs" + |> template() + |> String.replace("__GRISP_CONNECT_CONFIG__", connect_config) + + base <> "\n" <> rendered + else + base + end + end + + defp grisp_ini(config) do + wifi = if config[:wifi], do: "wlan=enable\nwpa=wpa_supplicant.conf\n", else: "" + grisp_io = if config[:grisp_io], do: " -kernel logger_level notice", else: "" + + epmd = + if config[:epmd], + do: " -internal_epmd epmd_sup -sname #{config[:name]} -setcookie #{config[:cookie]}", + else: "" + + "grisp/grisp2/common/deploy/files/grisp.ini.mustache" + |> template() + |> String.replace("__EPMD_ARGS__", epmd) + |> String.replace("__GRISP_IO_ARGS__", grisp_io) + |> String.replace("__WIFI_CONFIG__", wifi) + end + + defp wpa(config) do + "grisp/grisp2/common/deploy/files/wpa_supplicant.conf" + |> template() + |> String.replace("__WLAN_SSID__", inspect(config[:ssid] || "WLAN_SSID")) + |> String.replace("__WLAN_PASSWORD__", inspect(config[:psk] || "WLAN_PASSWORD")) + end + + defp inetrc, do: template("grisp/grisp2/common/deploy/files/erl_inetrc") + + defp template(relative_path) do + :mix_grisp + |> Application.app_dir("priv/templates") + |> Path.join(relative_path) + |> File.read!() + end + + defp project_readme(config) do + """ + # #{config[:name]} + + #{config[:desc]} + + ## Build + + mix compile + + ## Deploy + + mix grisp.deploy --relname #{config[:name]} --relvsn 0.1.0 + """ + end + + defp license(config) do + """ + Copyright #{config[:copyright_year]}, #{config[:author_name]} <#{config[:author_email]}>. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + """ + end + + defp identity(value), do: value + + defp infer_options(options) do + options = + case Keyword.fetch(options, :grisp_io_linking) do + {:ok, token} -> Keyword.put(options, :grisp_io_linking, String.trim(token)) + :error -> options + end + + options + |> enable_when(:wifi, Keyword.has_key?(options, :ssid) or Keyword.has_key?(options, :psk)) + |> enable_when(:grisp_io, present?(options[:grisp_io_linking])) + end + + defp enable_when(options, key, true), do: Keyword.put(options, key, true) + defp enable_when(options, _key, false), do: options + + defp present?(value), do: is_binary(value) and value != "" + + defp valid_name?(name) when is_binary(name), do: Regex.match?(~r/^[a-z][a-z0-9_]*$/, name) + defp valid_name?(_name), do: false + + defp name_error, + do: "Application name must contain lowercase letters, numbers, and underscores" + + defp confirm_existing!(root, false), do: Mix.raise("Project directory already exists: #{root}") + + defp confirm_existing!(root, true) do + answer = + Mix.shell().prompt( + "Project directory #{root} already exists. Continue without overwriting files? [y/N]: " + ) + |> String.trim() + |> String.downcase() + + case answer do + value when value in ["y", "yes", "true"] -> + :ok + + value when value in ["", "n", "no", "false"] -> + Mix.raise("Project directory already exists: #{root}") + + _ -> + Mix.shell().error("Expected yes or no") + confirm_existing!(root, true) + end + end +end diff --git a/lib/mix_grisp/deploy.ex b/lib/mix_grisp/deploy.ex new file mode 100644 index 0000000..60833b4 --- /dev/null +++ b/lib/mix_grisp/deploy.ex @@ -0,0 +1,265 @@ +defmodule MixGrisp.Deploy do + @moduledoc false + + alias MixGrisp.{Config, Handler, Project} + + def run(options, release_args \\ []) do + MixGrisp.ensure_started!() + compile_for_grisp() + + {name, version} = Project.select_release(options[:relname], options[:relvsn]) + tar? = Keyword.get(options, :tar, false) + destination = Config.deploy(:destination, options) + force? = Keyword.get(options, :force, false) + + distribute = + distribution_spec(tar?, %{ + destination: destination, + bundle: Project.bundle_file(name, version), + force: force?, + pre_script: normalize_script(Config.deploy(:pre_script, options)), + post_script: normalize_script(Config.deploy(:post_script, options)) + }) + + handlers = + Handler.handlers(&event/2, %{name: name, version: version}, %{ + release: {&release/2, %{name: name, args: release_args}} + }) + + %{ + project_root: to_charlist(Project.root()), + otp_version_requirement: to_charlist(Config.otp_version()), + jit: Config.otp_jit(), + platform: Config.platform(), + apps: Project.apps(), + custom_build: Config.custom_build?(), + distribute: distribute, + release: %{name: name, version: to_charlist(version), profiles: Project.profiles()}, + handlers: handlers + } + |> :grisp_tools.deploy() + |> MixGrisp.finalize() + + MixGrisp.info("Deployment done") + %{name: name, version: version, bundle: Project.bundle_file(name, version)} + rescue + error in Mix.Error -> reraise(error, __STACKTRACE__) + error -> Mix.raise(format_error(error)) + end + + def event(event, state) do + MixGrisp.debug(event) + {:ok, handle_event(event, state)} + end + + def release(relspec, %{name: name, args: args} = state) do + MixGrisp.debug({:release, relspec}) + Process.put(:relspec, relspec) + + try do + Mix.Task.reenable("release") + Mix.Task.run("release", [to_string(name) | args]) + spec = Process.get(:spec) || Mix.raise("mix release did not initialize MixGrisp.Release") + + {%{ + dir: to_charlist(spec.path), + name: spec.name, + version: to_charlist(spec.version) + }, state} + after + Process.delete(:relspec) + Process.delete(:spec) + end + end + + defp compile_for_grisp do + old_grisp = System.get_env("GRISP") + old_platform = System.get_env("GRISP_PLATFORM") + + try do + System.put_env("GRISP", "yes") + System.put_env("GRISP_PLATFORM", to_string(Config.platform())) + Mix.Task.reenable("compile") + Mix.Task.run("compile", []) + after + restore_env("GRISP", old_grisp) + restore_env("GRISP_PLATFORM", old_platform) + end + end + + defp distribution_spec(true, %{bundle: bundle, force: force?}) do + [ + {:bundle, + %{ + type: :archive, + force: force?, + compressed: true, + destination: to_charlist(bundle) + }} + ] + end + + defp distribution_spec(false, %{destination: nil}) do + Mix.raise(""" + No deploy destination specified. + Pass --tar, pass --destination PATH, or configure: + + grisp: [deploy: [destination: "/tmp/grisp"]] + """) + end + + defp distribution_spec(false, options) do + [ + {:copy, + %{ + type: :copy, + force: options.force, + destination: to_charlist(options.destination), + scripts: %{ + pre_script: options.pre_script, + post_script: options.post_script + } + }} + ] + end + + defp handle_event([:deploy, :validate, :version], state) do + MixGrisp.info("* Resolving OTP version") + state + end + + defp handle_event([:deploy, :validate, :version, {:selected, version, target}], state) do + MixGrisp.info(" #{version} (requirement was #{inspect(to_string(target))})") + state + end + + defp handle_event([:deploy, :validate, :version, {:mismatch, target, current}], _state) do + Mix.raise( + "Current Erlang version (#{inspect(current)}) does not match target (#{inspect(target)})" + ) + end + + defp handle_event([:deploy, :validate, :version, {:connection_error, error}], state) do + MixGrisp.warn("Could not list packages (#{inspect(error)}); using cache") + state + end + + defp handle_event([:deploy, :package, {:type, {type, hash}}], state) + when type in [:custom_build, :package] do + source = if type == :custom_build, do: "custom OTP", else: "pre-built OTP package" + MixGrisp.info("* Using #{source} (#{short(hash)})") + state + end + + defp handle_event([:deploy, :package, :download, {:start, size}], state) do + IO.write("* Downloading package\n 0%") + Map.put(state, :progress, {0, size}) + end + + defp handle_event( + [:deploy, :package, :download, {:progress, current}], + %{progress: {tens, total}} = state + ) + when is_integer(total) and total > 0 do + new_tens = round(current / total * 10) + if new_tens > tens, do: IO.write(" #{new_tens * 10}%") + %{state | progress: {new_tens, total}} + end + + defp handle_event([:deploy, :package, :download, {:complete, _etag}], state) do + IO.write(" OK\n") + state + end + + defp handle_event([:deploy, :package, :download, :_skip], state) do + MixGrisp.info(" (file cached)") + state + end + + defp handle_event([:deploy, :package, :download, {:error, reason}], state) do + MixGrisp.warn("Download error: #{inspect(reason)}\n (using cached file)") + state + end + + defp handle_event([:deploy, :package, :extract], state) do + MixGrisp.info("* Extracting package") + state + end + + defp handle_event([:deploy, :package, :extract, :_skip], state) do + MixGrisp.info(" (already extracted)") + state + end + + defp handle_event([:deploy, :package, :extract, {:error, reason}], _state) do + Mix.raise("Extraction failed: #{inspect(reason)}") + end + + defp handle_event([:deploy, :distribute, name, script, {:run, _command}], state) do + MixGrisp.info("* Running #{name} #{script}") + state + end + + defp handle_event([:deploy, :distribute, _name, _script, {:result, output}], state) do + if String.trim(to_string(output)) != "", do: MixGrisp.info(String.trim(to_string(output))) + state + end + + defp handle_event([:deploy, :distribute, :bundle, :release, {:archive, _, _}], state) do + MixGrisp.info("* Bundling release...") + state + end + + defp handle_event([:deploy, :distribute, :copy, :release, {:copy, _, _}], state) do + MixGrisp.info("* Copying release...") + state + end + + defp handle_event([:deploy, :distribute, name, :files, {:init, _}], state) do + MixGrisp.info("* #{if name == :bundle, do: "Bundling", else: "Copying"} files...") + state + end + + defp handle_event([:deploy, :distribute, _name, :files, {_, %{app: app, target: file}}], state) do + MixGrisp.info(" [#{app}] #{file}") + state + end + + defp handle_event([:deploy, :distribute, :bundle, :archive, {:closed, path}], state) do + MixGrisp.info("* GRiSP deploy bundle archived in #{MixGrisp.relative(path)}") + state + end + + defp handle_event([:deploy, :distribute, _name, :files, {:error, :file_exists, path}], _state), + do: + Mix.raise( + "Destination #{MixGrisp.relative(path)} already exists (use --force to overwrite)" + ) + + defp handle_event([:deploy, :distribute, _name, {:error, reason, path}], _state) do + Mix.raise("Deployment destination error for #{MixGrisp.relative(path)}: #{reason}") + end + + defp handle_event(event, state) do + case List.last(event) do + {:error, reason, path} -> + Mix.raise("Deploy error #{inspect(reason)}: #{MixGrisp.relative(path)}") + + {:error, reason} -> + Mix.raise("Deploy error: #{inspect(reason)}") + + _ -> + state + end + end + + defp normalize_script(nil), do: :undefined + defp normalize_script(script), do: to_charlist(script) + + defp restore_env(key, nil), do: System.delete_env(key) + defp restore_env(key, value), do: System.put_env(key, value) + defp short(value), do: value |> to_string() |> String.slice(0, 9) + + defp format_error(%{message: message}), do: message + defp format_error(error), do: "Unexpected deploy error: #{inspect(error)}" +end diff --git a/lib/mix_grisp/firmware.ex b/lib/mix_grisp/firmware.ex new file mode 100644 index 0000000..07bda7a --- /dev/null +++ b/lib/mix_grisp/firmware.ex @@ -0,0 +1,167 @@ +defmodule MixGrisp.Firmware do + @moduledoc false + + alias MixGrisp.{Config, Handler, Project} + + def run(options, release_args \\ []) do + MixGrisp.ensure_started!() + system? = Keyword.get(options, :system, true) + image? = Keyword.get(options, :image, false) + boot? = Keyword.get(options, :bootloader, false) + unless system? or image? or boot?, do: Mix.raise("No firmware selected") + + {name, version} = Project.select_release(options[:relname], options[:relvsn]) + bundle = bundle(options, name, version, release_args) + compress? = Keyword.get(options, :compress, true) + toolchain = toolchain(image? or boot?) + + spec = %{ + platform: Config.platform(), + force: Keyword.get(options, :force, false), + toolchain: toolchain, + bundle: to_charlist(bundle), + system: output(system?, :system, name, version, compress: compress?), + image: + output(image?, :image, name, version, + compress: compress?, + truncate: Keyword.get(options, :truncate, true) + ), + boot: output(boot?, :boot, name, version, compress: compress?), + handlers: Handler.handlers(&event/2) + } + + state = spec |> :grisp_tools.firmware() |> MixGrisp.finalize() + MixGrisp.info("Firmware(s) created") + unless Keyword.get(options, :quiet, false), do: MixGrisp.info(usage(spec)) + state + rescue + error in Mix.Error -> reraise(error, __STACKTRACE__) + error -> Mix.raise("Unexpected firmware error: #{inspect(error)}") + end + + def event(event, state) do + case event do + [:firmware, :prepare] -> + MixGrisp.info("* Preparing and validating...") + + [:firmware, :prepare, _, {:bootloader, name}] -> + MixGrisp.info(" Bootloader selected: #{name}") + + [:firmware, :build_firmware, :create_image] -> + MixGrisp.info("* Creating disk image...") + + [:firmware, :build_firmware, :copy_bootloader] -> + MixGrisp.info("* Writing bootloader...") + + [:firmware, :build_firmware, :create_partitions] -> + MixGrisp.info("* Creating disk partition table...") + + [:firmware, :build_firmware, :format_system] -> + MixGrisp.info("* Formatting system partitions...") + + [:firmware, :build_firmware, :deploy_bundle] -> + MixGrisp.info("* Deploying release bundle...") + + [:firmware, :build_firmware, :extract_system] -> + MixGrisp.info("* Extracting system firmware...") + + [:firmware, :build_firmware, :extract_image] -> + MixGrisp.info("* Extracting image firmware...") + + [:firmware, :build_firmware, :extract_boot] -> + MixGrisp.info("* Extracting bootloader firmware...") + + [:firmware, :build_firmware, :close_image] -> + MixGrisp.info("* Cleaning up...") + + [:firmware, :build_firmware, kind, {:extracted, path}] -> + MixGrisp.info(" #{kind} exported: #{MixGrisp.relative(path)}") + + _ -> + handle_unknown(event) + end + + {:ok, state} + end + + defp bundle(options, name, version, release_args) do + case options[:bundle] do + nil -> + path = Project.bundle_file(name, version) + + if File.regular?(path) and not Keyword.get(options, :refresh, false) do + MixGrisp.info("* Using existing bundle: #{MixGrisp.relative(path)}") + else + MixGrisp.info("* Deploying bundle...") + + deploy_options = + [tar: true, relname: to_string(name), relvsn: version] + |> maybe_force(Keyword.get(options, :refresh, false)) + + MixGrisp.Deploy.run(deploy_options, release_args) + end + + path + + path -> + unless File.regular?(path), do: Mix.raise("Bundle file not found: #{path}") + MixGrisp.info("* Using provided bundle: #{MixGrisp.relative(path)}") + path + end + end + + defp output(false, _type, _name, _version, _options), do: :undefined + + defp output(true, type, name, version, options) do + options + |> Map.new() + |> Map.put(:target, to_charlist(Project.firmware_file(type, name, version))) + end + + defp toolchain(false) do + case Config.toolchain() do + {:error, :docker_not_found} -> Mix.raise("Docker is not available") + toolchain -> toolchain + end + end + + defp toolchain(true) do + case Config.toolchain() do + nil -> Mix.raise("A valid toolchain is required to generate image or bootloader firmware") + {:error, :docker_not_found} -> Mix.raise("Docker is not available") + toolchain -> toolchain + end + end + + defp maybe_force(options, true), do: Keyword.put(options, :force, true) + defp maybe_force(options, false), do: options + + defp handle_unknown(event) do + MixGrisp.debug(event) + + case List.last(event) do + {:error, reason, info} -> Mix.raise("Firmware error #{inspect(reason)}: #{inspect(info)}") + {:error, reason} -> Mix.raise("Firmware error: #{inspect(reason)}") + _ -> :ok + end + end + + defp usage(spec) do + files = + [system: spec.system, image: spec.image, bootloader: spec.boot] + |> Enum.flat_map(fn + {_type, :undefined} -> [] + {type, %{target: target}} -> [" #{type}: #{MixGrisp.relative(target)}"] + end) + |> Enum.join("\n") + + """ + Generated firmware files: + #{files} + + Copy the required files to the GRISP SD card, unmount it, insert it in the + board, interrupt barebox during boot, and use `uncompress` to write system + firmware to /dev/mmc1.0 and image/bootloader firmware to /dev/mmc1. + """ + end +end diff --git a/lib/mix_grisp/handler.ex b/lib/mix_grisp/handler.ex new file mode 100644 index 0000000..646e7db --- /dev/null +++ b/lib/mix_grisp/handler.ex @@ -0,0 +1,71 @@ +defmodule MixGrisp.Handler do + @moduledoc false + + def shell(raw_command, options, state) do + command = IO.iodata_to_binary(raw_command) + MixGrisp.debug({:shell, command}) + + {return_on_error, options} = pop_flag(options, :return_on_error) + {_abort_on_error, options} = pop_flag(options, :abort_on_error) + cmd_options = normalize_options(options) + {output, status} = System.cmd(shell(), ["-c", command], cmd_options) + result = if status == 0, do: {:ok, output}, else: {:error, {status, output}} + + MixGrisp.debug({:shell_result, result}) + + case {result, return_on_error} do + {{:error, {code, output}}, false} -> + Mix.raise("Command failed with exit status #{code}:\n#{command}\n#{output}") + + _ -> + {result, state} + end + end + + def handlers(event_fun, event_state \\ %{}, extra \\ %{}) do + :grisp_tools.handlers_init( + Map.merge( + %{ + event: {event_fun, event_state}, + shell: {&shell/3, %{}} + }, + extra + ) + ) + end + + defp pop_flag(options, flag) do + {Enum.member?(options, flag), Enum.reject(options, &(&1 == flag))} + end + + defp normalize_options(options) do + options + |> Enum.flat_map(fn + {:env, env} -> + [{:env, Enum.map(env, fn {key, value} -> {to_string(key), to_string(value)} end)}] + + {:cd, directory} -> + [{:cd, to_string(directory)}] + + {:use_stdout, _} -> + [] + + {:debug_abort_on_error, _} -> + [] + + option when option in [:stderr_to_stdout] -> + [option] + + {_key, _value} = option -> + [option] + + _ -> + [] + end) + |> Keyword.put_new(:stderr_to_stdout, true) + end + + defp shell do + System.get_env("SHELL") || if(match?({:win32, _}, :os.type()), do: "cmd.exe", else: "/bin/sh") + end +end diff --git a/lib/mix_grisp/pack.ex b/lib/mix_grisp/pack.ex new file mode 100644 index 0000000..411c90e --- /dev/null +++ b/lib/mix_grisp/pack.ex @@ -0,0 +1,153 @@ +defmodule MixGrisp.Pack do + @moduledoc false + + alias MixGrisp.{Handler, Project} + + def run(options, release_args \\ []) do + MixGrisp.ensure_started!() + {name, version} = Project.select_release(options[:relname], options[:relvsn]) + {system, bootloader} = firmware_files(options, name, version, release_args) + + spec = %{ + name: Atom.to_string(name), + version: to_charlist(version), + block_size: options[:block_size] || :undefined, + key_file: charlist_or_undefined(options[:key]), + system: system, + bootloader: charlist_or_undefined(bootloader), + package: Project.update_file(name, version), + force: Keyword.get(options, :force, false), + handlers: Handler.handlers(&event/2) + } + + state = spec |> :grisp_tools.pack() |> MixGrisp.finalize() + MixGrisp.info("Package created") + unless Keyword.get(options, :quiet, false), do: MixGrisp.info(usage(name, version)) + state + rescue + error in Mix.Error -> reraise(error, __STACKTRACE__) + error -> Mix.raise("Unexpected pack error: #{inspect(error)}") + end + + def event(event, state) do + case event do + [:pack, :prepare] -> + MixGrisp.info("* Preparing and validating...") + + [:pack, :package, _, {:expanding, file}] -> + MixGrisp.info("* Expanding compressed file #{file}") + + [:pack, :package, :create_image] -> + MixGrisp.info("* Creating temporary disk image...") + + [:pack, :package, :create_partitions] -> + MixGrisp.info("* Creating disk partition table...") + + [:pack, :package, :copy_firmware] -> + MixGrisp.info("* Writing firmware...") + + [:pack, :package, :extract_manifest] -> + MixGrisp.info("* Extracting software manifest...") + + [:pack, :package, :extract_manifest, {:manifest, :undefined}] -> + MixGrisp.warn("Software manifest not found") + + [:pack, :package, :extract_manifest, {:manifest, manifest}] -> + MixGrisp.info(" Using software manifest #{inspect(manifest[:id])}") + + [:pack, :package, :close_image] -> + MixGrisp.info("* Cleaning up temporary disk image...") + + [:pack, :package, :build_package] -> + MixGrisp.info("* Creating software update package...") + + [:pack, :package, _, {:done, path}] -> + MixGrisp.info(" Package generated: #{MixGrisp.relative(path)}") + + _ -> + handle_unknown(event) + end + + {:ok, state} + end + + defp firmware_files(options, name, version, release_args) do + system = options[:system] + bootloader = options[:bootloader] + with_bootloader? = Keyword.get(options, :with_bootloader, false) + + case {system, bootloader, with_bootloader?} do + {nil, nil, include_boot?} -> + generated_firmware(options, name, version, release_args, include_boot?) + + {system, nil, false} -> + {existing!(system, "System firmware"), nil} + + {system, bootloader, _} when not is_nil(system) and not is_nil(bootloader) -> + {existing!(system, "System firmware"), existing!(bootloader, "Bootloader firmware")} + + _ -> + Mix.raise("When supplying a bootloader, --system and --bootloader must both be explicit") + end + end + + defp generated_firmware(options, name, version, release_args, include_boot?) do + system = Project.firmware_file(:system, name, version) + boot = Project.firmware_file(:boot, name, version) + refresh? = Keyword.get(options, :refresh, false) + ready? = File.regular?(system) and (not include_boot? or File.regular?(boot)) + + unless ready? and not refresh? do + MixGrisp.info("* Building firmware...") + + firmware_options = [ + relname: to_string(name), + relvsn: version, + force: true, + quiet: true, + refresh: refresh?, + bootloader: include_boot? + ] + + MixGrisp.Firmware.run(firmware_options, release_args) + end + + MixGrisp.info("* Using system firmware: #{MixGrisp.relative(system)}") + if include_boot?, do: MixGrisp.info("* Using bootloader firmware: #{MixGrisp.relative(boot)}") + {system, if(include_boot?, do: boot, else: nil)} + end + + defp existing!(path, label) do + unless File.regular?(path), do: Mix.raise("#{label} file not found: #{path}") + MixGrisp.info("* Using provided #{String.downcase(label)}: #{MixGrisp.relative(path)}") + path + end + + defp handle_unknown(event) do + MixGrisp.debug(event) + + case List.last(event) do + {:error, reason, info} -> Mix.raise("Pack error #{inspect(reason)}: #{inspect(info)}") + {:error, reason} -> Mix.raise("Pack error: #{inspect(reason)}") + _ -> :ok + end + end + + defp charlist_or_undefined(nil), do: :undefined + defp charlist_or_undefined(value), do: value + + defp usage(name, version) do + package = MixGrisp.relative(Project.update_file(name, version)) + + """ + Update package: #{package} + + Extract it under releases/#{name}/#{version}, serve the releases directory + over HTTP, then call on the board: + + :grisp_updater.update("http://HOST:8000/#{name}/#{version}") + + Reboot and validate with `:grisp_updater.validate()`. + """ + end +end diff --git a/lib/mix_grisp/project.ex b/lib/mix_grisp/project.ex new file mode 100644 index 0000000..134eef8 --- /dev/null +++ b/lib/mix_grisp/project.ex @@ -0,0 +1,139 @@ +defmodule MixGrisp.Project do + @moduledoc false + + alias Mix.Project + + def root do + case Project.project_file() do + nil -> File.cwd!() + file -> file |> Path.expand() |> Path.dirname() + end + end + + def grisp_root, do: Path.join(root(), "_grisp") + def report_dir, do: Path.join(grisp_root(), "report") + def deploy_dir, do: Path.join(grisp_root(), "deploy") + def firmware_dir, do: Path.join(grisp_root(), "firmware") + def update_dir, do: Path.join(grisp_root(), "update") + + def releases do + case Project.config()[:releases] || [] do + releases when is_list(releases) -> + releases + + other -> + Mix.raise("Expected :releases configuration to be a keyword list, got: #{inspect(other)}") + end + end + + def select_release(name \\ nil, version \\ nil) do + indexed = + for {release_name, options} <- releases() do + release_version = + Keyword.get(options, :version, Project.config()[:version]) |> to_string() + + {release_name, release_version} + end + + case indexed do + [] -> Mix.raise(no_release_message()) + [_] -> select_from(indexed, name, version) + _ when is_nil(name) -> Mix.raise(multiple_releases_message(indexed)) + _ -> select_from(indexed, name, version) + end + end + + def profiles do + case Mix.env() do + env when env in [:dev, :grisp, :test] -> [] + env -> [env] + end + end + + def profile_postfix do + case profiles() do + [] -> "" + profiles -> "." <> Enum.map_join(profiles, "+", &Atom.to_string/1) + end + end + + def bundle_file(name, version), do: artifact(deploy_dir(), name, version, "tar.gz") + def firmware_file(:system, name, version), do: artifact(firmware_dir(), name, version, "sys.gz") + def firmware_file(:image, name, version), do: artifact(firmware_dir(), name, version, "emmc.gz") + def firmware_file(:boot, name, version), do: artifact(firmware_dir(), name, version, "boot.gz") + def update_file(name, version), do: artifact(update_dir(), name, version, "tar") + + def apps do + old_env = Mix.env() + + try do + Mix.env(:grisp) + Mix.Dep.clear_cached() + + project = Project.config() + own = {project[:app], %{dir: to_charlist(root()), deps: Project.deps_apps()}} + + dependencies = + Mix.Dep.load_and_cache() + |> Enum.map(fn dependency -> + deps = Enum.map(dependency.deps, & &1.app) + {dependency.app, %{dir: to_charlist(dependency.opts[:dest]), deps: deps}} + end) + + dependencies ++ [own] + after + Mix.env(old_env) + Mix.Dep.clear_cached() + end + end + + defp artifact(directory, name, version, extension) do + platform = MixGrisp.Config.platform() + Path.join(directory, "#{platform}.#{name}.#{version}#{profile_postfix()}.#{extension}") + end + + defp select_from(indexed, name, version) do + wanted_name = if name, do: normalize_name(name), else: elem(hd(indexed), 0) + + case Enum.find(indexed, fn {candidate, _} -> candidate == wanted_name end) do + nil -> + valid = indexed |> Enum.map(&elem(&1, 0)) |> Enum.map_join("\n ", &to_string/1) + Mix.raise("Unknown release #{inspect(wanted_name)}\n\nMust be one of:\n #{valid}") + + {selected_name, selected_version} -> + if is_nil(version) or to_string(version) == selected_version do + {selected_name, selected_version} + else + Mix.raise( + "Release #{inspect(selected_name)} has no version #{version}\n\nMust be:\n #{selected_version}" + ) + end + end + end + + defp normalize_name(name) when is_atom(name), do: name + defp normalize_name(name), do: String.to_atom(name) + + defp no_release_message do + app = Project.config()[:app] + + """ + No release configured. + + Add a release to mix.exs, for example: + + releases: [#{app}: [include_executables_for: [:unix]]] + """ + end + + defp multiple_releases_message(releases) do + examples = + releases + |> Enum.map(fn {name, version} -> + " mix grisp.deploy --relname #{name} --relvsn #{version}" + end) + |> Enum.join("\n") + + "Multiple releases are configured; select one with --relname and optionally --relvsn.\n\n#{examples}" + end +end diff --git a/lib/mix_grisp/release.ex b/lib/mix_grisp/release.ex index 1c83dee..487ad28 100644 --- a/lib/mix_grisp/release.ex +++ b/lib/mix_grisp/release.ex @@ -4,12 +4,40 @@ defmodule MixGrisp.Release do Process.get(:relspec) |> Map.fetch!(:erts) - [name] = Path.wildcard(Path.join(otp, "erts-*")) - name + pattern = Path.join(otp, "erts-*") + + case Path.wildcard(pattern) do + [path] -> + path + + [] -> + Mix.raise(""" + No ERTS installation was found under #{otp}. + + Run `mix grisp.build` before deploying or generating firmware. + Expected exactly one directory matching: + + #{pattern} + """) + + paths -> + formatted = Enum.map_join(paths, "\n", &" #{&1}") + + Mix.raise(""" + Multiple ERTS installations were found under #{otp}; expected exactly one: + + #{formatted} + """) + end end def init(release) do Process.put(:spec, Map.take(release, [:version, :path, :name])) + + # Mix preserves an existing same-version ERTS directory, which can leave a + # stale GRiSP OTP package in the release when the selected package changes. + File.rm_rf!(Path.join(release.path, "erts-#{release.erts_version}")) + release end end diff --git a/lib/mix_grisp/util.ex b/lib/mix_grisp/util.ex new file mode 100644 index 0000000..1373505 --- /dev/null +++ b/lib/mix_grisp/util.ex @@ -0,0 +1,78 @@ +defmodule MixGrisp.Util do + @moduledoc false + + alias MixGrisp.{Config, Handler, Project} + + defdelegate apps(), to: Project + defdelegate root(), to: Project, as: :grisp_root + defdelegate report_dir(), to: Project + defdelegate deploy_dir(), to: Project + defdelegate firmware_dir(), to: Project + defdelegate update_dir(), to: Project + defdelegate config(), to: Config, as: :get + defdelegate otp_version(), to: Config + defdelegate otp_jit(), to: Config + defdelegate platform(), to: Config + defdelegate should_build(), to: Config, as: :custom_build? + defdelegate toolchain_root(), to: Config, as: :toolchain + defdelegate select_release(name, version), to: Project + defdelegate bundle_file_path(name, version), to: Project, as: :bundle_file + defdelegate firmware_file_path(type, name, version), to: Project, as: :firmware_file + defdelegate update_file_path(name, version), to: Project, as: :update_file + + def debug(term), do: MixGrisp.debug(term) + def info(message), do: MixGrisp.info(message) + def warn(message), do: MixGrisp.warn(message) + def abort(message), do: Mix.raise(to_string(message)) + + def shell(command, options \\ []) do + {result, _state} = Handler.shell(command, options, %{}) + result + end + + def get(path, term, default \\ nil), do: deep_get(term, List.wrap(path), default) + + def filenames_join_copy_destination(from_to, root) do + Map.new(from_to, fn {target, source} -> {Path.join(root, to_string(target)), source} end) + end + + def otp_build_root(version), do: Path.join([root(), "otp", to_string(version), "build"]) + + def otp_build_install_root(version), + do: Path.join([root(), "otp", to_string(version), "install"]) + + def otp_cache_file_name(version, hash), do: "grisp_otp_build_#{version}_#{hash}.tar.gz" + def otp_hash_listing_path(install_root), do: Path.join(install_root, "GRISP_PACKAGE_FILES") + + def bundle_file_name(name, version), do: Project.bundle_file(name, version) |> Path.basename() + + def firmware_file_name(type, name, version), + do: Project.firmware_file(type, name, version) |> Path.basename() + + def update_file_name(name, version), do: Project.update_file(name, version) |> Path.basename() + + def ensure_dir(file) do + case File.mkdir_p(Path.dirname(to_string(file))) do + :ok -> :ok + {:error, reason} -> Mix.raise("Could not create target directory: #{inspect(reason)}") + end + end + + defp deep_get(value, [], _default), do: value + + defp deep_get(value, [key | rest], default) when is_map(value) do + case Map.fetch(value, key) do + {:ok, next} -> deep_get(next, rest, default) + :error -> default + end + end + + defp deep_get(value, [key | rest], default) when is_list(value) do + case Keyword.fetch(value, key) do + {:ok, next} -> deep_get(next, rest, default) + :error -> default + end + end + + defp deep_get(_value, _path, default), do: default +end diff --git a/mix.exs b/mix.exs index 1941bcf..c166f2a 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule MixGrisp.MixProject do app: :mix_grisp, version: "0.2.0", description: "Mix plug-in for GRiSP.", - elixir: "~> 1.16", + elixir: "~> 1.20", start_permanent: Mix.env() == :prod, deps: deps(), package: package(), @@ -24,14 +24,14 @@ defmodule MixGrisp.MixProject do defp deps() do [ - {:grisp_tools, "~> 2.8"}, + {:grisp_tools, "~> 2.11"}, {:ex_doc, ">= 0.0.0", only: :dev} ] end defp package() do [ - files: ~w(lib .formatter.exs mix.exs README* LICENSE*), + files: ~w(lib priv .formatter.exs mix.exs README* LICENSE*), licenses: ["Apache 2.0"], links: %{ "GitHub" => @source_url, diff --git a/mix.lock b/mix.lock index ea1b143..f6618b3 100644 --- a/mix.lock +++ b/mix.lock @@ -1,25 +1,28 @@ %{ "bbmustache": {:hex, :bbmustache, "1.12.2", "0cabdce0db9fe6d3318131174b9f2b351328a4c0afbeb3e6e99bb0e02e9b621d", [:rebar3], [], "hexpm", "688b33a4d5cc2d51f575adf0b3683fc40a38314a2f150906edcfc77f5b577b3b"}, - "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "certifi": {:hex, :certifi, "2.17.0", "835748414307e15e05b17d0e518190228ce648b08d569a5cc93a85a40f3e5c9b", [:rebar3], [], "hexpm", "8122798a17f0293c80daada25d0f81c7f4d708c73fef782c7c9b1950e26e4d21"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, "edifa": {:hex, :edifa, "1.0.0", "0f1a01a0c79b7135f334b3fceeb624f0574c5ed3e4554b06c8664aada6a339c8", [:rebar3], [{:erlexec, "~> 2.0.7", [hex: :erlexec, repo: "hexpm", optional: false]}], "hexpm", "a1e010561e7d236a24c668d95626be2bfe082ed0331ce1e6798be0cd43f59a7b"}, "erlexec": {:hex, :erlexec, "2.0.8", "301184cdcc83d51fd6aa4ee5f9c23dc96930d68501100bcd580d950f40169a5e", [:rebar3], [], "hexpm", "cdc02ccf88d8845f8887fa18a19bc9349cfcd3c9b4fd98a45f738ac710b57bc4"}, - "ex_doc": {:hex, :ex_doc, "0.31.2", "8b06d0a5ac69e1a54df35519c951f1f44a7b7ca9a5bb7a260cd8a174d6322ece", [: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", "317346c14febaba9ca40fd97b5b5919f7751fb85d399cc8e7e8872049f37e0af"}, - "grisp_tools": {:hex, :grisp_tools, "2.8.0", "23cf651214f1885b98325b8340e2cc4591785399fa0b4d5b291d0e66f07f1ad7", [:rebar3], [{:bbmustache, "~> 1.12.2", [hex: :bbmustache, repo: "hexpm", optional: false]}, {:edifa, "~> 1.0.0", [hex: :edifa, repo: "hexpm", optional: false]}, {:grisp_update_packager, "~> 1.0.1", [hex: :grisp_update_packager, repo: "hexpm", optional: false]}, {:hackney, "~> 1.20.1", [hex: :hackney, repo: "hexpm", optional: false]}, {:mapz, "~> 2.2", [hex: :mapz, repo: "hexpm", optional: false]}], "hexpm", "0ba58d7c99010bee9f599d9555b4fc0d75969c92995162cb0b12af13daf34692"}, - "grisp_update_packager": {:hex, :grisp_update_packager, "1.0.1", "4548c7a5e0d4ebed0052e49f87e836d5a5c75ffdb76e793fdbdf5d11409ccbbe", [:rebar3], [{:termseal, "~> 0.1.1", [hex: :termseal, repo: "hexpm", optional: false]}, {:uuid, "~> 2.0.4", [hex: :uuid_erl, repo: "hexpm", optional: false]}], "hexpm", "b958039bead404a5e05d56e904b70c840f10f188070cfbff0a9adc92de9e5451"}, - "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "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"}, + "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, + "grisp_tools": {:hex, :grisp_tools, "2.11.2", "8fea4c094d99183f404f7130230e53c50b702e42d32cb28f2b3ad1793cf3eaee", [:rebar3], [{:bbmustache, "~> 1.12.2", [hex: :bbmustache, repo: "hexpm", optional: false]}, {:edifa, "~> 1.0.0", [hex: :edifa, repo: "hexpm", optional: false]}, {:grisp_update_packager, "~> 1.0.2", [hex: :grisp_update_packager, repo: "hexpm", optional: false]}, {:hackney, "~> 4.7.4", [hex: :hackney, repo: "hexpm", optional: false]}, {:mapz, "~> 2.2", [hex: :mapz, repo: "hexpm", optional: false]}], "hexpm", "6d975d35e275d75e809d0a6c9f530736ccab5798944d06658c16e90bde896f5f"}, + "grisp_update_packager": {:hex, :grisp_update_packager, "1.0.2", "a74922b6602159a6718b1ee13045d3866bff26d6a49c8c7a8eabc5f352e41bb9", [:rebar3], [{:termseal, "~> 0.1.1", [hex: :termseal, repo: "hexpm", optional: false]}, {:uuid, "~> 2.0.4", [hex: :uuid_erl, repo: "hexpm", optional: false]}], "hexpm", "6484c6910c7f4cf235119206a5fefd16ed08be2d3a16e62ec1820a207b4d5b20"}, + "h2": {:hex, :h2, "0.12.0", "f393539ee2728f8118fb2024b6d5f3e2c45e40ceb31b18b4e9bf5e50d028f80f", [:rebar3], [], "hexpm", "beaafc93c54cdc5d623247334d3970cdf4bc66b6b8b296b74ba1d7c7513c3dfc"}, + "hackney": {:hex, :hackney, "4.7.4", "8fe2ddaa3ca27de99d68e682d72b66d07d2331da680f77c8000580a0122c69e6", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.0", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.5", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "d07d7e1358353ab6cc75132f058c155287f3e013d43f709fbb79d79eeab98195"}, + "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "mapz": {:hex, :mapz, "2.4.0", "77a8e38b69bab16c5d3ebd44e6c619f8af1f1598b0caae301d266605a0865756", [:rebar3], [], "hexpm", "4b68df5cf0522e0d6545df7b681bc052865cdb78405ad4cc9c55fe45ee7b25be"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, - "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "parse_trans": {:hex, :parse_trans, "3.4.2", "c352ddc1a0d5e54f9b1654d45f9c432eef76f9cea371c55ddff769ef688fdb74", [:rebar3], [], "hexpm", "4c25347de3b7c35732d32e69ab43d1ceee0beae3f3b3ade1b59cbd3dd224d9ca"}, + "quic": {:hex, :quic, "1.8.1", "51415525c490d80f97073e39e17d289ebacda5468154cfb661ac04db5b1a5e95", [:rebar3], [], "hexpm", "884a7d30cb048d8d1acb97eb2b905d30d4c29b8928c0db3ab3060de7cdc97688"}, "quickrand": {:hex, :quickrand, "2.0.7", "d2bd76676a446e6a058d678444b7fda1387b813710d1af6d6e29bb92186c8820", [:rebar3], [], "hexpm", "b8acbf89a224bc217c3070ca8bebc6eb236dbe7f9767993b274084ea044d35f0"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "termseal": {:hex, :termseal, "0.1.1", "c9d93d4ff638ee99f9377d3438fc7ad132d2901ebbaf10c54f8dea1d7e24d61c", [:rebar3], [], "hexpm", "466280936214af1894fc431642e83341b7d13580a3f3485820a2d300c5caeb49"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, - "uuid": {:hex, :uuid_erl, "2.0.4", "77c3e3ee1e1701a2856ce945846d7ceb71931c60633a305d0b0feae03b2b3b5c", [:rebar3], [{:quickrand, ">= 2.0.4", [hex: :quickrand, repo: "hexpm", optional: false]}], "hexpm", "7a4ccd1c151d9b88b4383fa802bccf9bcb3754b7f53d7caa164d51a14a6652e4"}, + "uuid": {:hex, :uuid_erl, "2.0.7", "b2078d2cc814f53afa52d36c91e08962c7e7373585c623f4c0ea6dfb04b2af94", [:rebar3], [{:quickrand, ">= 2.0.7", [hex: :quickrand, repo: "hexpm", optional: false]}], "hexpm", "4e4c5ca3461dc47c5e157ed42aa3981a053b7a186792af972a27b14a9489324e"}, + "webtransport": {:hex, :webtransport, "0.4.5", "0e387202bbe707389fe81373ef8c56faa9d5aa321bb4800fa4765ee7c1399785", [:rebar3], [{:h2, "~> 0.12", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 1.8.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "bcb512239e48e551d5bd5c667312a9a7de4f29b89d84b1d33c5af44e1f3f730d"}, } diff --git a/priv/templates/config/grisp_io.exs b/priv/templates/config/grisp_io.exs new file mode 100644 index 0000000..ab9d7d2 --- /dev/null +++ b/priv/templates/config/grisp_io.exs @@ -0,0 +1,17 @@ +config :logger, + level: :notice + +config :grisp_keychain, + api_module: :grisp_cryptoauth + +config :grisp_cryptoauth, + tls_server_trusted_certs_cb: {:certifi, :cacerts, []} + +__GRISP_CONNECT_CONFIG__ + +config :grisp_updater, + system: {:grisp_updater_grisp2, %{}}, + sources: [ + {:grisp_updater_tarball, %{}}, + {:grisp_updater_http, %{backend: {:grisp_updater_grisp2, %{}}}} + ] diff --git a/priv/templates/grisp/grisp2/common/deploy/files/erl_inetrc b/priv/templates/grisp/grisp2/common/deploy/files/erl_inetrc new file mode 100644 index 0000000..429dac8 --- /dev/null +++ b/priv/templates/grisp/grisp2/common/deploy/files/erl_inetrc @@ -0,0 +1,13 @@ +%--- Erlang Inet Configuration ------------------------------------------------- + +% Add hosts +% {host, {X,X,X,X}, ["Host"]}. + +% Do not monitor the hosts file +{hosts_file, ""}. + +% Disable caching +{cache_size, 0}. + +% Specify lookup method +{lookup, [file, dns]}. diff --git a/priv/templates/grisp/grisp2/common/deploy/files/grisp.ini.mustache b/priv/templates/grisp/grisp2/common/deploy/files/grisp.ini.mustache new file mode 100644 index 0000000..0a80a0b --- /dev/null +++ b/priv/templates/grisp/grisp2/common/deploy/files/grisp.ini.mustache @@ -0,0 +1,9 @@ +[erlang] +args = erl.rtems -C multi_time_warp -fnu -- -mode embedded -home . -pa . -root {{release_name}} -bindir {{release_name}}/erts-{{erts_vsn}}/bin -boot {{release_name}}/releases/{{release_version}}/start -boot_var RELEASE_LIB {{release_name}}/lib -config {{release_name}}/releases/{{release_version}}/sys.config -kernel inetrc "./erl_inetrc"__GRISP_IO_ARGS____EPMD_ARGS__ -user elixir -extra +iex --no-halt +shell = none +on_exit = reboot +on_crash = reboot + +[network] +ip_self=dhcp +__WIFI_CONFIG__hostname=GRISP_HOSTNAME diff --git a/priv/templates/grisp/grisp2/common/deploy/files/wpa_supplicant.conf b/priv/templates/grisp/grisp2/common/deploy/files/wpa_supplicant.conf new file mode 100644 index 0000000..f4dfe5d --- /dev/null +++ b/priv/templates/grisp/grisp2/common/deploy/files/wpa_supplicant.conf @@ -0,0 +1,5 @@ +network={ + ssid=__WLAN_SSID__ + key_mgmt=WPA-PSK + psk=__WLAN_PASSWORD__ +} diff --git a/test/mix_grisp/cli_test.exs b/test/mix_grisp/cli_test.exs new file mode 100644 index 0000000..43f84e4 --- /dev/null +++ b/test/mix_grisp/cli_test.exs @@ -0,0 +1,18 @@ +defmodule MixGrisp.CLITest do + use ExUnit.Case, async: true + + test "parses task options and preserves arguments after the separator" do + assert {[force: true, relname: "demo"], ["--quiet", "--path", "some path"]} = + MixGrisp.CLI.parse!( + ["--force", "-n", "demo", "--", "--quiet", "--path", "some path"], + [force: :boolean, relname: :string], + n: :relname + ) + end + + test "rejects invalid switches" do + assert_raise Mix.Error, ~r/Invalid options/, fn -> + MixGrisp.CLI.parse!(["--unknown"], force: :boolean) + end + end +end diff --git a/test/mix_grisp/configure_test.exs b/test/mix_grisp/configure_test.exs new file mode 100644 index 0000000..48db3c6 --- /dev/null +++ b/test/mix_grisp/configure_test.exs @@ -0,0 +1,217 @@ +defmodule MixGrisp.ConfigureTest do + use ExUnit.Case, async: false + + setup do + previous_shell = Mix.shell() + Mix.shell(Mix.Shell.Process) + + root = + System.tmp_dir!() + |> Path.join("mix_grisp_configure_#{System.unique_integer([:positive])}") + + File.mkdir_p!(root) + + on_exit(fn -> + File.rm_rf!(root) + Mix.shell(previous_shell) + end) + + {:ok, root: root} + end + + test "does not prompt again for values supplied on the command line", %{root: root} do + File.cd!(root, fn -> + MixGrisp.Configure.run( + interactive: true, + name: "demo", + otp_version: "29", + jit: true, + dest: "/tmp/grisp", + wifi: false, + grisp_io: false, + epmd: false + ) + end) + + refute_received {:mix_shell, :prompt, _message} + end + + test "accepts true and false as explicit jit values", %{root: root} do + File.cd!(root, fn -> + Mix.Task.reenable("grisp.configure") + + Mix.Tasks.Grisp.Configure.run([ + "--no-interactive", + "--name", + "jit_enabled", + "--jit", + "true" + ]) + + Mix.Task.reenable("grisp.configure") + + Mix.Tasks.Grisp.Configure.run([ + "--no-interactive", + "--name", + "jit_disabled", + "--jit", + "false" + ]) + end) + + assert File.read!(Path.join(root, "jit_enabled/mix.exs")) =~ "jit: true" + assert File.read!(Path.join(root, "jit_disabled/mix.exs")) =~ "jit: false" + end + + test "re-prompts until a valid application name is entered", %{root: root} do + send(self(), {:mix_shell_input, :prompt, ""}) + send(self(), {:mix_shell_input, :prompt, "valid_name"}) + + result = + File.cd!(root, fn -> + MixGrisp.Configure.run( + interactive: true, + name: "", + otp_version: "29", + jit: true, + dest: "/tmp/grisp", + wifi: false, + grisp_io: false, + epmd: false + ) + end) + + assert result.name == "valid_name" + assert_received {:mix_shell, :error, [_message]} + end + + test "re-prompts for invalid yes or no answers", %{root: root} do + send(self(), {:mix_shell_input, :prompt, "maybe"}) + send(self(), {:mix_shell_input, :prompt, "n"}) + + File.cd!(root, fn -> + MixGrisp.Configure.run( + interactive: true, + name: "demo", + otp_version: "29", + jit: true, + dest: "/tmp/grisp", + grisp_io: false, + epmd: false + ) + end) + + assert_received {:mix_shell, :error, ["Expected yes or no"]} + end + + test "preserves files when the user accepts an existing project directory", %{root: root} do + project = Path.join(root, "demo") + File.mkdir_p!(project) + File.write!(Path.join(project, "mix.exs"), "# existing project\n") + send(self(), {:mix_shell_input, :prompt, "y"}) + + File.cd!(root, fn -> + MixGrisp.Configure.run( + interactive: true, + name: "demo", + otp_version: "29", + jit: true, + dest: "/tmp/grisp", + wifi: false, + grisp_io: false, + epmd: false + ) + end) + + assert File.read!(Path.join(project, "mix.exs")) == "# existing project\n" + assert File.exists?(Path.join(project, "config/config.exs")) + end + + test "generates an Ethernet configuration without enabling WLAN", %{root: root} do + result = + File.cd!(root, fn -> + MixGrisp.Configure.run( + interactive: false, + name: "demo" + ) + end) + + grisp_ini = + File.read!(Path.join(result.root, "grisp/grisp2/common/deploy/files/grisp.ini.mustache")) + + refute grisp_ini =~ "wlan=enable" + assert grisp_ini =~ ~s(-kernel inetrc "./erl_inetrc") + assert grisp_ini =~ "-user elixir -extra +iex --no-halt" + assert grisp_ini =~ "on_exit = reboot" + assert grisp_ini =~ "on_crash = reboot" + end + + test "enables Wi-Fi when either credential is supplied", %{root: root} do + for {name, option} <- [{"ssid_only", {:ssid, "test-network"}}, {"psk_only", {:psk, "secret"}}] do + result = + File.cd!(root, fn -> + MixGrisp.Configure.run([{:interactive, false}, {:name, name}, option]) + end) + + files = Path.join(result.root, "grisp/grisp2/common/deploy/files") + grisp_ini = File.read!(Path.join(files, "grisp.ini.mustache")) + + assert grisp_ini =~ "wlan=enable" + assert grisp_ini =~ "wpa=wpa_supplicant.conf" + assert File.exists?(Path.join(files, "wpa_supplicant.conf")) + end + end + + test "generates a bootable GRiSP.io release configuration", %{root: root} do + File.cd!(root, fn -> + Mix.Task.reenable("grisp.configure") + + Mix.Tasks.Grisp.Configure.run([ + "--no-interactive", + "--name", + "connected", + "--ssid", + "test-network", + "--psk", + "secret", + "--grisp-io-linking", + "link-token" + ]) + end) + + project = Path.join(root, "connected") + mix_exs = File.read!(Path.join(project, "mix.exs")) + config_exs = File.read!(Path.join(project, "config/config.exs")) + files = Path.join(project, "grisp/grisp2/common/deploy/files") + grisp_ini = File.read!(Path.join(files, "grisp.ini.mustache")) + + for dependency <- ~w(certifi grisp_cryptoauth grisp_updater_grisp2 grisp_connect) do + assert mix_exs =~ ":#{dependency}" + end + + assert mix_exs =~ ~s({:grisp_cryptoauth, "~> 2.6"}) + assert mix_exs =~ ~s({:grisp_connect, "~> 3.0.0"}) + assert mix_exs =~ ~s({:grisp_updater_grisp2, "~> 1.0", runtime: false}) + assert mix_exs =~ ~s({:grisp, "~> 2.12", override: true}) + assert mix_exs =~ "applications: [sasl: :permanent, grisp_updater_grisp2: :load]" + + assert config_exs =~ "config :grisp_keychain" + assert config_exs =~ "config :grisp_cryptoauth" + assert config_exs =~ "config :grisp_connect" + assert config_exs =~ ~s(device_linking_token: "link-token") + assert config_exs =~ "config :grisp_updater" + refute config_exs =~ "config :kernel" + refute config_exs =~ "logger: []" + assert {:ok, _quoted} = Code.string_to_quoted(config_exs) + + assert grisp_ini =~ "wlan=enable" + assert grisp_ini =~ "wpa=wpa_supplicant.conf" + assert grisp_ini =~ "-kernel logger_level notice" + refute grisp_ini =~ "__EPMD_ARGS__" + refute grisp_ini =~ "__GRISP_IO_ARGS__" + refute grisp_ini =~ "__WIFI_CONFIG__" + refute config_exs =~ "__GRISP_CONNECT_CONFIG__" + assert File.exists?(Path.join(files, "wpa_supplicant.conf")) + assert File.exists?(Path.join(files, "erl_inetrc")) + end +end diff --git a/test/mix_grisp/handler_test.exs b/test/mix_grisp/handler_test.exs new file mode 100644 index 0000000..14b9e1e --- /dev/null +++ b/test/mix_grisp/handler_test.exs @@ -0,0 +1,17 @@ +defmodule MixGrisp.HandlerTest do + use ExUnit.Case, async: true + + test "uses shell quoting and pipelines" do + assert {{:ok, "hello world\n"}, %{marker: true}} = + MixGrisp.Handler.shell( + ~s[printf '%s\\n' "hello world" | sed 's/hello/hello/'], + [], + %{marker: true} + ) + end + + test "returns command failures when requested" do + assert {{:error, {7, "problem\n"}}, :state} = + MixGrisp.Handler.shell("printf 'problem\\n'; exit 7", [:return_on_error], :state) + end +end diff --git a/test/mix_grisp/project_test.exs b/test/mix_grisp/project_test.exs new file mode 100644 index 0000000..755cf1d --- /dev/null +++ b/test/mix_grisp/project_test.exs @@ -0,0 +1,40 @@ +defmodule MixGrisp.ProjectTestProject do + use Mix.Project + + def project do + [ + app: :project_test, + version: "1.2.3", + releases: [first: [], second: [version: "2.0.0"]], + grisp: [platform: :grisp2, otp: [version: "29"]] + ] + end +end + +defmodule MixGrisp.ProjectTest do + use ExUnit.Case, async: false + + test "selects configured releases and versions" do + assert {:second, "2.0.0"} = MixGrisp.Project.select_release("second", "2.0.0") + + assert_raise Mix.Error, ~r/Multiple releases/, fn -> + MixGrisp.Project.select_release() + end + + assert_raise Mix.Error, ~r/has no version/, fn -> + MixGrisp.Project.select_release(:first, "9.9.9") + end + end + + test "uses rebar-compatible artifact names" do + assert String.ends_with?( + MixGrisp.Project.bundle_file(:first, "1.2.3"), + "_grisp/deploy/grisp2.first.1.2.3.tar.gz" + ) + + assert String.ends_with?( + MixGrisp.Project.firmware_file(:system, :first, "1.2.3"), + "_grisp/firmware/grisp2.first.1.2.3.sys.gz" + ) + end +end diff --git a/test/mix_grisp/release_test.exs b/test/mix_grisp/release_test.exs new file mode 100644 index 0000000..f3da166 --- /dev/null +++ b/test/mix_grisp/release_test.exs @@ -0,0 +1,42 @@ +defmodule MixGrisp.ReleaseTest do + use ExUnit.Case, async: true + + setup do + root = + System.tmp_dir!() + |> Path.join("mix_grisp_release_#{System.unique_integer([:positive])}") + + File.mkdir_p!(root) + Process.put(:relspec, %{erts: root}) + + on_exit(fn -> File.rm_rf!(root) end) + + {:ok, root: root} + end + + test "returns the only ERTS installation", %{root: root} do + erts = Path.join(root, "erts-17.0.6") + File.mkdir_p!(erts) + + assert MixGrisp.Release.erts() == erts + end + + test "explains how to build a missing custom ERTS installation", %{root: root} do + assert_raise Mix.Error, ~r/Run `mix grisp\.build`/, fn -> + MixGrisp.Release.erts() + end + + assert_raise Mix.Error, ~r/#{Regex.escape(Path.join(root, "erts-*"))}/, fn -> + MixGrisp.Release.erts() + end + end + + test "reports multiple ERTS installations", %{root: root} do + File.mkdir_p!(Path.join(root, "erts-17.0.5")) + File.mkdir_p!(Path.join(root, "erts-17.0.6")) + + assert_raise Mix.Error, ~r/Multiple ERTS installations/, fn -> + MixGrisp.Release.erts() + end + end +end diff --git a/test/mix_grisp/util_test.exs b/test/mix_grisp/util_test.exs new file mode 100644 index 0000000..669e598 --- /dev/null +++ b/test/mix_grisp/util_test.exs @@ -0,0 +1,17 @@ +defmodule MixGrisp.UtilTest do + use ExUnit.Case, async: true + + test "reads nested keyword and map configuration" do + value = [otp: %{version: "29"}] + assert MixGrisp.Util.get([:otp, :version], value) == "29" + assert MixGrisp.Util.get([:otp, :jit], value, false) == false + end + + test "ports utility artifact and destination helpers" do + assert MixGrisp.Util.otp_cache_file_name("29.0.6", "abc") == + "grisp_otp_build_29.0.6_abc.tar.gz" + + assert MixGrisp.Util.filenames_join_copy_destination(%{"bin/start" => :source}, "/tmp/card") == + %{"/tmp/card/bin/start" => :source} + end +end