From 76738dbc183fc74c06d1bd9fcfd2c2899b03f1bf Mon Sep 17 00:00:00 2001 From: Luca Succi Date: Fri, 4 Sep 2026 12:23:45 +0200 Subject: [PATCH 1/9] Restore compatibility with grisp_tools 2.11 --- lib/mix/tasks/grisp/deploy.ex | 126 ++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 13 deletions(-) diff --git a/lib/mix/tasks/grisp/deploy.ex b/lib/mix/tasks/grisp/deploy.ex index c3fe6f1..ba37e8e 100644 --- a/lib/mix/tasks/grisp/deploy.ex +++ b/lib/mix/tasks/grisp/deploy.ex @@ -9,13 +9,20 @@ defmodule Mix.Tasks.Grisp.Deploy do @shortdoc "Deploys a GRiSP application" - def run(_args) do + def run(args) do Mix.Task.run("compile", []) header("🐟 Deploying GRiSP application") {:ok, _} = Application.ensure_all_started(:grisp_tools) config = Mix.Project.config()[:grisp] + deploy_config = config[:deploy] || [] + destination = deploy_config[:destination] || "tmp/grisp_sd" + force = "--force" in args + + if is_nil(deploy_config[:destination]) do + File.mkdir_p!(destination) + end release_name = Project.config()[:app] release_version = to_charlist(Project.config()[:version]) @@ -23,19 +30,22 @@ defmodule Mix.Tasks.Grisp.Deploy do try do %{ project_root: to_charlist(File.cwd!()), - otp_version_requirement: to_charlist(config[:otp][:version] || "26"), + otp_version_requirement: to_charlist(config[:otp][:version] || "29"), + jit: Keyword.get(config[:otp] || [], :jit, false), 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} - }} + {:copy, + %{ + type: :copy, + force: force, + destination: to_charlist(destination), + scripts: %{ + pre_script: deploy_config[:pre_script] || :undefined, + post_script: deploy_config[:post_script] || :undefined + } + }} ], release: %{ name: release_name, @@ -51,8 +61,7 @@ defmodule Mix.Tasks.Grisp.Deploy do }}, shell: {&shell_handler/3, %{}}, release: {&release_handler/2, nil} - }), - + }) } |> :grisp_tools.deploy() |> :grisp_tools.handlers_finalize() @@ -72,6 +81,96 @@ defmodule Mix.Tasks.Grisp.Deploy do {:ok, handle_event(event, state)} end + defp handle_event([:deploy, :package, {:type, {:custom_build, hash}}], state) do + header("Using custom OTP (#{short(hash)})") + state + end + + defp handle_event([:deploy, :package, {:type, {:package, hash}}], state) do + header("Using pre-built OTP package (#{short(hash)})") + state + end + + defp handle_event([:deploy, :package, :download, {:start, size}], state) do + IO.write(" 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}%") + end + + %{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 + info("Package already cached") + state + end + + defp handle_event([:deploy, :package, :download, {:error, reason}], state) do + warn("Download error: #{inspect(reason)}") + info("Using cached file") + state + end + + defp handle_event([:deploy, :package, :extract, :_skip], state) do + info("Package already extracted") + state + end + + defp handle_event([:deploy, :package, :extract, {:error, reason}], _state) do + fail!("Tar extraction failed: #{inspect(reason)}") + end + + defp handle_event([:deploy, :release, {:start, _release}], state) do + header("Creating release") + state + end + + defp handle_event([:deploy, :release, {:done, release}], state) do + info("Release complete: #{release.name}-#{release.version}") + state + end + + defp handle_event([:deploy, :distribute, _name, _script, {:run, _command}], state) do + state + end + + defp handle_event([:deploy, :distribute, _name, _script, {:result, _output}], state) do + state + end + + defp handle_event([:deploy, :distribute, :copy, :release, {:copy, _source, _target}], state) do + info("Copying release...") + state + end + + defp handle_event([:deploy, :distribute, :copy, :files, {:init, _destination}], state) do + info("Copying files...") + state + end + + defp handle_event([:deploy, :distribute, _name, :files, {:error, :file_exists, path}], _state) do + fail!("Destination #{path} already exists (use --force to overwrite)") + end + + defp handle_event([:deploy, :distribute, _name, {:error, reason, path}], _state) do + fail!("Deployment destination error for #{path}: #{reason}") + end + defp handle_event({:otp_type, hash, :custom_build}, state) do header("Using custom OTP (#{short(hash)})") state @@ -191,6 +290,7 @@ defmodule Mix.Tasks.Grisp.Deploy do end) {result, ret} = System.cmd(cmd, args, opts) + case ret do 0 -> {{:ok, result}, state} _ -> Mix.raise("Error executing #{cmd} #{args}") @@ -248,7 +348,7 @@ defmodule Mix.Tasks.Grisp.Deploy do 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) + defp fail!(message), do: Mix.raise(message) defp debug(message, label: label) when is_binary(message) do if Mix.debug?() do From 33877f26b9f6415d781e28891e2e7fae191c3715 Mon Sep 17 00:00:00 2001 From: Luca Succi Date: Fri, 4 Sep 2026 12:33:59 +0200 Subject: [PATCH 2/9] Remove obsolete code from deploy.ex --- lib/mix/tasks/grisp/deploy.ex | 183 +++++++--------------------------- 1 file changed, 34 insertions(+), 149 deletions(-) diff --git a/lib/mix/tasks/grisp/deploy.ex b/lib/mix/tasks/grisp/deploy.ex index ba37e8e..95e6880 100644 --- a/lib/mix/tasks/grisp/deploy.ex +++ b/lib/mix/tasks/grisp/deploy.ex @@ -27,53 +27,40 @@ defmodule Mix.Tasks.Grisp.Deploy do 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] || "29"), - jit: Keyword.get(config[:otp] || [], :jit, false), - platform: Keyword.get(config, :platform, :grisp2), - apps: apps(), - custom_build: false, - distribute: [ - {:copy, - %{ - type: :copy, - force: force, - destination: to_charlist(destination), - scripts: %{ - pre_script: deploy_config[:pre_script] || :undefined, - post_script: deploy_config[: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 + %{ + project_root: to_charlist(File.cwd!()), + otp_version_requirement: to_charlist(config[:otp][:version] || "29"), + jit: Keyword.get(config[:otp] || [], :jit, false), + platform: Keyword.get(config, :platform, :grisp2), + apps: apps(), + custom_build: false, + distribute: [ + {:copy, + %{ + type: :copy, + force: force, + destination: to_charlist(destination), + scripts: %{ + pre_script: deploy_config[:pre_script] || :undefined, + post_script: deploy_config[:post_script] || :undefined + } + }} + ], + release: %{ + name: release_name, + version: release_version + }, + handlers: + :grisp_tools.handlers_init(%{ + event: {&event_handler/2, %{}}, + shell: {&shell_handler/3, %{}}, + release: {&release_handler/2, nil} + }) + } + |> :grisp_tools.deploy() + |> :grisp_tools.handlers_finalize() + + info("Deployment done") end defp event_handler(event, state) do @@ -171,108 +158,6 @@ defmodule Mix.Tasks.Grisp.Deploy do fail!("Deployment destination error for #{path}: #{reason}") 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 From b55717abc556b8526068825c0692d03c7b2901b0 Mon Sep 17 00:00:00 2001 From: Luca Succi Date: Fri, 4 Sep 2026 13:17:03 +0200 Subject: [PATCH 3/9] Update readme --- README.md | 302 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 166 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 4f9dd17..c15da8d 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,216 @@ # GRiSP Mix plug-in -Mix plug-in to build and deploy GRiSP applications for the [GRiSP board][grisp]. +Mix plug-in for building and deploying Elixir applications to a [GRiSP board][grisp]. -## Summary +## Requirements -The package can be installed by adding `mix_grisp` to your list of dependencies -in `mix.exs`: +- Elixir 1.20 or later +- A local Erlang/OTP installation whose major version matches the configured + target OTP version +- A supported GRiSP board and SD card + +The examples below use Erlang/OTP 29 and a GRiSP 2 board. If you select another +supported OTP version, use that same major OTP version on the development host +when compiling and deploying the application. + +## Installation + +Add `grisp` and `mix_grisp` to the dependencies in `mix.exs`: ```elixir -def deps do +defp deps do [ + {:grisp, "~> 2.12"}, {:mix_grisp, "~> 0.2.0", only: :dev} ] 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 - -Add the following dependencies in the project configuration `mix.exs`: - - ``` - defp deps do - [ - ... - {:grisp, "~> 2.4"}, - {:mix_grisp, "~> 0.2.0", only: :dev}, - ] - end - ``` +## Creating and configuring a project -### Configure Grisp +Create a standard Mix project: -Add the following configuration to your project configuration `mix.exs`: +```console +mix new testex --module TestEx +cd testex +``` - ``` - def project do - [ - ... - grisp: grisp(), - releases: releases() - ] - end +Add the GRiSP and release configuration to the project module in `mix.exs`: - def grisp do - [ - otp: [version: "27"], - deploy: [ - # pre_script: "rm -rf /Volumes/GRISP/*", - # destination: "tmp/grisp" - # post_script: "diskutil unmount /Volumes/GRISP", - ] - ] - end +```elixir +def project do + [ + app: :testex, + version: "0.1.0", + elixir: "~> 1.20", + start_permanent: Mix.env() == :prod, + deps: deps(), + grisp: grisp(), + releases: releases() + ] +end - 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 - ``` +defp grisp do + [ + platform: :grisp2, + otp: [version: "29", jit: true], + deploy: [ + # Use a local directory while testing the deployment: + destination: "tmp/grisp_sd", + + # To deploy directly to a mounted SD card, replace `destination` with its + # mount path. Optional scripts can prepare and unmount the card: + # pre_script: "rm -rf /Volumes/GRISP/*", + # destination: "/Volumes/GRISP", + # post_script: "diskutil unmount /Volumes/GRISP" + ] + ] +end -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. +defp releases do + [ + testex: [ + 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 +``` -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. +The `:platform` option defaults to `:grisp2`. The `:jit` option controls whether +the available Arm32 JIT patches are applied to the selected OTP build. - * `grisp/grisp2/common/deploy/files/grisp.ini.mustache` +Replace the example release cookie before enabling Erlang distribution. Use the +same cookie wherever the node cookie is configured. - ``` - [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 +## Network configuration - [network] - ip_self=dhcp - wlan=enable - hostname=GRISP_HOSTNAME - wpa=wpa_supplicant.conf - ``` +Mix releases use `$RELEASE_LIB` in their boot files. Add an overlay file at +`grisp/grisp2/common/deploy/files/grisp.ini.mustache` in your project so the +GRiSP runtime can expand that variable. The `-boot_var RELEASE_LIB +{{release_name}}/lib` argument is required; omitting it causes boot to terminate +with `cannot expand $RELEASE_LIB in bootfile`. +```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 - * `grisp/grisp2/common/deploy/files/wpa_supplicant.conf` +[network] +ip_self=dhcp +wlan=enable +hostname=GRISP_HOSTNAME +wpa=wpa_supplicant.conf +``` - ``` - network={ - ssid="WLAN_SSID" - key_mgmt=WPA-PSK - psk="WLAN_PASSWORD" - } - ``` +Replace `GRISP_HOSTNAME` with the desired board hostname. For Wi-Fi, also add +`grisp/grisp2/common/deploy/files/wpa_supplicant.conf`: -### Add Configuration +```ini +network={ + ssid="WLAN_SSID" + key_mgmt=WPA-PSK + psk="WLAN_PASSWORD" +} +``` -If not generated bu Mix template, add the file `config/config.exs`: +Replace `WLAN_SSID` and `WLAN_PASSWORD` with the Wi-Fi network credentials. Do +not commit real credentials to source control. - ``` - import Config - ``` +See the [GRiSP networking guide][networking] for the available `grisp.ini` +settings. -### Check OTP Version +## Deploying -Verify that your default erlang version matches the one configured -(26 in the example). +Fetch the dependencies: -This is required because the beam files are compiled locally and need to be -compiled by the same version of the VM. +```console +mix deps.get +``` -### Get Dependencies +Confirm that the local OTP major version matches `grisp[:otp][:version]`: -Get all the dependencies: - - ``` - $ mix deps.get - ``` +```console +erl -noshell -eval 'io:format("~s~n", [erlang:system_info(otp_release)]), halt().' +``` -### Deploy The Project +Deploy the application: -To deploy, use the grisp command provided by `mix_grisp`: +```console +mix grisp.deploy +``` - ``` - $ mix grisp.deploy - ``` +Without an explicit destination, `mix_grisp` writes the deployment to +`tmp/grisp_sd`. Set `grisp[:deploy][:destination]` to the SD card mount point to +deploy directly to the card. -### Troubleshooting +## Enabling Erlang distribution -#### This BEAM file was compiled for a later version of the run-time system +GRiSP can run Erlang distribution with an internal EPMD implementation. -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`). +Add the tested EPMD revision to the project dependencies. `runtime: false` +prevents Mix from starting it as a regular OTP application on the development +host: -[grisp]: https://www.grisp.org +```elixir +{:epmd, + git: "https://github.com/erlang/epmd", + ref: "4d1a59", + runtime: false} +``` +Include EPMD in the release so its modules are available on the board: -## Enabling Erlang Distribution +```elixir +def application do + [ + extra_applications: [:logger], + included_applications: [:epmd] + ] +end +``` + +Insert the distribution options in the `[erlang]` `args` value in +`grisp.ini.mustache` before `-user elixir -extra +iex --no-halt`: + +```text +-internal_epmd epmd_sup -sname mynode -setcookie replace_with_a_long_random_cookie +``` +Choose a unique node name and use the same cookie configured for the release. +Keep the existing `-kernel inetrc "./erl_inetrc"` option in the argument list. -1. Add the erlang epmd to your release to be able to run Erlang distribution on GRiSP +## Troubleshooting - 1. Add the following line in your deps, this will ship epmd without starting it at boot. +### Cannot expand `$RELEASE_LIB` in bootfile - ```elixir - {:epmd, git: "https://github.com/erlang/epmd", ref: "4d1a59", runtime: false}, - ``` +The GRiSP boot configuration is missing the Mix release library path. Add the +project `grisp.ini.mustache` overlay shown under +[Network configuration](#network-configuration), redeploy, and verify that the +generated `grisp.ini` contains: - 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 - ``` +```text +-boot_var RELEASE_LIB /lib +``` + +### This BEAM file was compiled for a later version of the runtime system -2. Read the GRiSP.ini chapter of the [wiki](https://github.com/grisp/grisp/wiki/Connecting-over-WiFI-and-Ethernet#grisp-ini) +The project or one of its dependencies was compiled with a different OTP major +version. Switch the local Erlang installation to the configured target version, +then rebuild and deploy: -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 - ``` +```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 From fab3d604b741e48d9952a6dd9f2422d2f6996f7e Mon Sep 17 00:00:00 2001 From: Luca Succi Date: Fri, 4 Sep 2026 14:14:45 +0200 Subject: [PATCH 4/9] Port rebar3_grisp feature set to MIX --- lib/mix/tasks/grisp/build.ex | 152 ++++++++++++++ lib/mix/tasks/grisp/configure.ex | 55 +++++ lib/mix/tasks/grisp/deploy.ex | 268 +++--------------------- lib/mix/tasks/grisp/firmware.ex | 45 ++++ lib/mix/tasks/grisp/pack.ex | 41 ++++ lib/mix/tasks/grisp/package.ex | 129 ++++++++++++ lib/mix/tasks/grisp/report.ex | 68 +++++++ lib/mix/tasks/grisp/version.ex | 24 +++ lib/mix_grisp.ex | 32 +++ lib/mix_grisp/cli.ex | 27 +++ lib/mix_grisp/config.ex | 80 ++++++++ lib/mix_grisp/configure.ex | 340 +++++++++++++++++++++++++++++++ lib/mix_grisp/deploy.ex | 265 ++++++++++++++++++++++++ lib/mix_grisp/firmware.ex | 167 +++++++++++++++ lib/mix_grisp/handler.ex | 71 +++++++ lib/mix_grisp/pack.ex | 153 ++++++++++++++ lib/mix_grisp/project.ex | 139 +++++++++++++ lib/mix_grisp/util.ex | 78 +++++++ test/mix_grisp/cli_test.exs | 18 ++ test/mix_grisp/handler_test.exs | 17 ++ test/mix_grisp/project_test.exs | 40 ++++ test/mix_grisp/util_test.exs | 17 ++ 22 files changed, 1988 insertions(+), 238 deletions(-) create mode 100644 lib/mix/tasks/grisp/build.ex create mode 100644 lib/mix/tasks/grisp/configure.ex create mode 100644 lib/mix/tasks/grisp/firmware.ex create mode 100644 lib/mix/tasks/grisp/pack.ex create mode 100644 lib/mix/tasks/grisp/package.ex create mode 100644 lib/mix/tasks/grisp/report.ex create mode 100644 lib/mix/tasks/grisp/version.ex create mode 100644 lib/mix_grisp.ex create mode 100644 lib/mix_grisp/cli.ex create mode 100644 lib/mix_grisp/config.ex create mode 100644 lib/mix_grisp/configure.ex create mode 100644 lib/mix_grisp/deploy.ex create mode 100644 lib/mix_grisp/firmware.ex create mode 100644 lib/mix_grisp/handler.ex create mode 100644 lib/mix_grisp/pack.ex create mode 100644 lib/mix_grisp/project.ex create mode 100644 lib/mix_grisp/util.ex create mode 100644 test/mix_grisp/cli_test.exs create mode 100644 test/mix_grisp/handler_test.exs create mode 100644 test/mix_grisp/project_test.exs create mode 100644 test/mix_grisp/util_test.exs 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..b9242ed --- /dev/null +++ b/lib/mix/tasks/grisp/configure.ex @@ -0,0 +1,55 @@ +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. Use `--no-interactive` for scripts. + """ + + @switches [ + interactive: :boolean, + name: :string, + otp_version: :string, + jit: :boolean, + dest: :string, + desc: :string, + copyright_year: :string, + author_name: :string, + author_email: :string, + network: :boolean, + wifi: :boolean, + ssid: :string, + psk: :string, + grisp_io: :boolean, + grisp_io_linking: :boolean, + token: :string, + epmd: :boolean, + cookie: :string + ] + + @aliases [ + i: :interactive, + o: :otp_version, + j: :jit, + d: :dest, + n: :network, + w: :wifi, + g: :grisp_io, + l: :grisp_io_linking, + t: :token, + e: :epmd, + c: :cookie + ] + + @impl Mix.Task + def run(args) do + {options, []} = MixGrisp.CLI.parse!(args, @switches, @aliases) + result = MixGrisp.Configure.run(options) + Enum.each(result.created, &Mix.shell().info("Created #{&1}")) + Mix.shell().info("Configured GRiSP Mix project #{result.name}") + end +end diff --git a/lib/mix/tasks/grisp/deploy.ex b/lib/mix/tasks/grisp/deploy.ex index 95e6880..038c133 100644 --- a/lib/mix/tasks/grisp/deploy.ex +++ b/lib/mix/tasks/grisp/deploy.ex @@ -1,249 +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] - deploy_config = config[:deploy] || [] - destination = deploy_config[:destination] || "tmp/grisp_sd" - force = "--force" in args - - if is_nil(deploy_config[:destination]) do - File.mkdir_p!(destination) - end - release_name = Project.config()[:app] - release_version = to_charlist(Project.config()[:version]) - - %{ - project_root: to_charlist(File.cwd!()), - otp_version_requirement: to_charlist(config[:otp][:version] || "29"), - jit: Keyword.get(config[:otp] || [], :jit, false), - platform: Keyword.get(config, :platform, :grisp2), - apps: apps(), - custom_build: false, - distribute: [ - {:copy, - %{ - type: :copy, - force: force, - destination: to_charlist(destination), - scripts: %{ - pre_script: deploy_config[:pre_script] || :undefined, - post_script: deploy_config[:post_script] || :undefined - } - }} - ], - release: %{ - name: release_name, - version: release_version - }, - handlers: - :grisp_tools.handlers_init(%{ - event: {&event_handler/2, %{}}, - shell: {&shell_handler/3, %{}}, - release: {&release_handler/2, nil} - }) - } - |> :grisp_tools.deploy() - |> :grisp_tools.handlers_finalize() - - info("Deployment done") - end - - defp event_handler(event, state) do - debug(event, label: "event") - {:ok, handle_event(event, state)} - end - - defp handle_event([:deploy, :package, {:type, {:custom_build, hash}}], state) do - header("Using custom OTP (#{short(hash)})") - state - end - - defp handle_event([:deploy, :package, {:type, {:package, hash}}], state) do - header("Using pre-built OTP package (#{short(hash)})") - state - end - - defp handle_event([:deploy, :package, :download, {:start, size}], state) do - IO.write(" 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}%") - end - - %{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 - info("Package already cached") - state - end - - defp handle_event([:deploy, :package, :download, {:error, reason}], state) do - warn("Download error: #{inspect(reason)}") - info("Using cached file") - state - end - - defp handle_event([:deploy, :package, :extract, :_skip], state) do - info("Package already extracted") - state - end - - defp handle_event([:deploy, :package, :extract, {:error, reason}], _state) do - fail!("Tar extraction failed: #{inspect(reason)}") - end - - defp handle_event([:deploy, :release, {:start, _release}], state) do - header("Creating release") - state - end - - defp handle_event([:deploy, :release, {:done, release}], state) do - info("Release complete: #{release.name}-#{release.version}") - state - end - - defp handle_event([:deploy, :distribute, _name, _script, {:run, _command}], state) do - state - end - - defp handle_event([:deploy, :distribute, _name, _script, {:result, _output}], state) do - state - end - - defp handle_event([:deploy, :distribute, :copy, :release, {:copy, _source, _target}], state) do - info("Copying release...") - state - end - - defp handle_event([:deploy, :distribute, :copy, :files, {:init, _destination}], state) do - info("Copying files...") - state - end - - defp handle_event([:deploy, :distribute, _name, :files, {:error, :file_exists, path}], _state) do - fail!("Destination #{path} already exists (use --force to overwrite)") - end - - defp handle_event([:deploy, :distribute, _name, {:error, reason, path}], _state) do - fail!("Deployment destination error for #{path}: #{reason}") - 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}) + @recursive true + @shortdoc "Deploys a GRiSP release to a destination" - all_apps = - all_deps - |> Enum.map(fn dep -> - sub_deps = - for d <- dep.deps do - d.app - end + @moduledoc """ + Deploys a GRiSP release to a directory or creates a release bundle. - {dep.app, %{dir: dep.opts[:dest], deps: sub_deps}} - end) + mix grisp.deploy [options] [-- MIX_RELEASE_OPTIONS] - Mix.env(old) - all_apps ++ [app] - end + Options: - defp short(string), do: String.slice(to_string(string), 0..8) + * `--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 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.raise(message) - - 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..bc2dee9 --- /dev/null +++ b/lib/mix_grisp/configure.ex @@ -0,0 +1,340 @@ +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", + network: false, + wifi: false, + grisp_io: false, + grisp_io_linking: false, + epmd: false, + cookie: "grisp" + ] + + def run(options) do + config = + options + |> Keyword.merge(@defaults, fn _key, supplied, _default -> supplied end) + |> Keyword.update!(:copyright_year, &(&1 || Integer.to_string(Date.utc_today().year))) + |> prompt() + + validate!(config) + root = Path.expand(config[:name]) + + if File.exists?(root), do: Mix.raise("Project directory already exists: #{root}") + + Mix.Task.reenable("new") + Mix.Tasks.New.run([root, "--app", config[:name], "--sup"]) + + created = + [ + write_elixir(Path.join(root, "mix.exs"), mix_exs(config)), + write_elixir(Path.join(root, "config/config.exs"), app_config(config)), + write(Path.join(root, "README.md"), project_readme(config)), + write(Path.join(root, "LICENSE"), license(config)) + ] ++ network_files(root, config) + + %{name: config[:name], root: root, config: config, created: created} + end + + defp prompt(config) do + if config[:interactive] do + config + |> ask(:name, "App name", &identity/1) + |> ask(:otp_version, "Erlang/OTP version", &identity/1) + |> ask_bool(:jit, "Enable the Arm32 JIT?") + |> ask(:dest, "SD card path", &identity/1) + |> ask_bool(:network, "Generate network configuration?") + |> prompt_network() + else + config + end + end + + defp prompt_network(config) do + if config[:network] do + config + |> ask_bool(:wifi, "Use Wi-Fi?") + |> prompt_wifi() + |> ask_bool(:grisp_io, "Enable GRiSP.io integration?") + |> prompt_grisp_io() + |> ask_bool(:epmd, "Enable distributed Erlang?") + |> prompt_epmd() + else + config + end + end + + defp prompt_wifi(config) do + if config[:wifi] do + config |> ask(:ssid, "Wi-Fi SSID", &identity/1) |> ask(:psk, "Wi-Fi password", &identity/1) + else + config + end + end + + defp prompt_grisp_io(config) do + if config[:grisp_io] do + config |> ask_bool(:grisp_io_linking, "Link a GRiSP2 board?") |> maybe_ask_token() + else + config + end + end + + defp maybe_ask_token(config) do + if config[:grisp_io_linking], + do: ask(config, :token, "Device linking token", &String.trim/1), + else: config + end + + defp prompt_epmd(config) do + if config[:epmd], do: ask(config, :cookie, "Erlang cookie", &identity/1), else: config + 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() + + value = + case answer do + "" -> default + value when value in ["y", "yes", "true"] -> true + value when value in ["n", "no", "false"] -> false + _ -> Mix.raise("Expected yes or no") + end + + Keyword.put(config, key, value) + end + + defp validate!(config) do + unless config[:name] =~ ~r/^[a-z][a-z0-9_]*$/ do + Mix.raise("Application name must contain lowercase letters, numbers, and underscores") + end + + if config[:wifi] and not config[:network], do: Mix.raise("--wifi requires --network") + + if (config[:ssid] || config[:psk]) && not config[:wifi], + do: Mix.raise("--ssid and --psk require --wifi") + + if config[:grisp_io] and not config[:network], do: Mix.raise("--grisp-io requires --network") + + if config[:grisp_io_linking] and not config[:grisp_io], + do: Mix.raise("--grisp-io-linking requires --grisp-io") + + if config[:token] && not config[:grisp_io_linking], + do: Mix.raise("--token requires --grisp-io-linking") + + if config[:epmd] and not config[:network], do: Mix.raise("--epmd requires --network") + end + + defp network_files(root, config) do + if config[:network] do + base = Path.join(root, "grisp/grisp2/common/deploy/files") + files = [write(Path.join(base, "grisp.ini.mustache"), grisp_ini(config))] + + files = + if config[:wifi], + do: files ++ [write(Path.join(base, "wpa_supplicant.conf"), wpa(config))], + else: files + + if config[:grisp_io], + do: files, + else: files ++ [write(Path.join(base, "erl_inetrc"), inetrc())] + else + [] + end + end + + defp write(path, contents) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, contents) + path + end + + defp write_elixir(path, contents) do + formatted = contents |> Code.format_string!() |> IO.iodata_to_binary() + write(path, formatted <> "\n") + 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, \">= 0.0.0\"},\n {:grisp_updater_grisp2, \">= 0.0.0\"},\n {:grisp_connect, \">= 0.0.0\"},", + 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, "~> 2.12"}, + {: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])}, + 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 + linking = + if config[:token], do: "\n device_linking_token: #{inspect(config[:token])},", else: "" + + base = "import Config\n" + + if config[:grisp_io] do + base <> + """ + + config :grisp_keychain, api_module: :grisp_cryptoauth + config :grisp_cryptoauth, tls_server_trusted_certs_cb: {:certifi, :cacerts, []} + config :grisp_connect,#{linking} + logger: [] + config :grisp_updater, + system: {:grisp_updater_grisp2, %{}}, + sources: [ + {:grisp_updater_tarball, %{}}, + {:grisp_updater_http, %{backend: {:grisp_updater_grisp2, %{}}}} + ] + """ + else + base + end + end + + defp grisp_ini(config) do + wpa = if config[:wifi], do: "wpa=wpa_supplicant.conf\n", else: "" + + dist = + if config[:epmd], + do: + " -kernel inetrc \"./erl_inetrc\" -internal_epmd epmd_sup -sname #{config[:name]} -setcookie #{config[:cookie]}", + else: "" + + """ + [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#{dist} -user elixir -extra +iex --no-halt + shell = none + + [network] + ip_self=dhcp + wlan=enable + #{wpa}hostname=GRISP_HOSTNAME + """ + end + + defp wpa(config), + do: + "network={\n ssid=#{inspect(config[:ssid] || "WLAN_SSID")}\n key_mgmt=WPA-PSK\n psk=#{inspect(config[:psk] || "WLAN_PASSWORD")}\n}\n" + + defp inetrc do + """ + {hosts_file, ""}. + {cache_size, 0}. + {lookup, [file, dns]}. + """ + 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 +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/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/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/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/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 From 3bc41cbfc53ecf686f18733e56286266e7f0b42d Mon Sep 17 00:00:00 2001 From: Luca Succi Date: Fri, 4 Sep 2026 14:15:04 +0200 Subject: [PATCH 5/9] Add CI tests --- .github/workflows/ci.yml | 68 ++++++++++++++++++++++++++++++++++++ .github/workflows/zizmor.yml | 24 +++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/zizmor.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2180492 --- /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@0f75c29430f34bb5af4cce5e3b7f6a8860fca236 # v1 + 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..78d0899 --- /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@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0 From bf8d0667c320755def4446207460ad2e1dd5b509 Mon Sep 17 00:00:00 2001 From: Luca Succi Date: Fri, 4 Sep 2026 14:15:30 +0200 Subject: [PATCH 6/9] Update deps --- mix.exs | 4 ++-- mix.lock | 31 +++++++++++++++++-------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/mix.exs b/mix.exs index 1941bcf..271d63d 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,7 +24,7 @@ defmodule MixGrisp.MixProject do defp deps() do [ - {:grisp_tools, "~> 2.8"}, + {:grisp_tools, "~> 2.11"}, {:ex_doc, ">= 0.0.0", only: :dev} ] end diff --git a/mix.lock b/mix.lock index ea1b143..38a4a8d 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.1", "897b76aa4c32be5934435ba9d955f7a942a2c89013e704de321289775fcf95d8", [: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", "cdf8f496a8957cc9d6067c313a516697f24ff518914365cae21b4e5f8d7ee136"}, + "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"}, } From 15d68e9077cf14e4ab6a8da0c4397a30773bdb79 Mon Sep 17 00:00:00 2001 From: Luca Succi Date: Fri, 4 Sep 2026 14:15:53 +0200 Subject: [PATCH 7/9] Update Readme --- README.md | 295 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 210 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index c15da8d..f4b06f6 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,69 @@ -# GRiSP Mix plug-in +# mix_grisp -Mix plug-in for building and deploying Elixir applications to a [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. + +Run `mix help grisp.TASK` for task-specific help. ## Requirements - Elixir 1.20 or later -- A local Erlang/OTP installation whose major version matches the configured - target OTP version -- A supported GRiSP board and SD card - -The examples below use Erlang/OTP 29 and a GRiSP 2 board. If you select another -supported OTP version, use that same major OTP version on the development host -when compiling and deploying the application. +- 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 `mix_grisp` to the dependencies in `mix.exs`: +Add GRiSP and this build-time plugin to `mix.exs`: ```elixir defp deps do [ {:grisp, "~> 2.12"}, - {:mix_grisp, "~> 0.2.0", only: :dev} + {:mix_grisp, "~> 0.2", runtime: false} ] end ``` -## Creating and configuring a project +Fetch dependencies with `mix deps.get`. Print the installed plugin and library +versions with: + +```console +mix grisp.version +``` -Create a standard Mix project: +## Create a new application + +The configure task creates a supervised Mix application, release and GRiSP +configuration, and optional networking files: ```console -mix new testex --module TestEx -cd testex +mix grisp.configure ``` -Add the GRiSP and release configuration to the project module in `mix.exs`: +For non-interactive use: + +```console +mix grisp.configure --no-interactive --name my_grisp_app \ + --network --wifi --ssid mywifi --psk wifipsk +``` + +Important options are `--name`, `--otp-version`, `--[no-]jit`, `--dest`, +`--network`, `--wifi`, `--ssid`, `--psk`, `--grisp-io`, +`--grisp-io-linking`, `--token`, `--epmd`, and `--cookie`. Wi-Fi requires +networking; credentials require Wi-Fi; GRiSP.io and EPMD require networking. + +## Configure an existing application + +Add GRiSP and release configuration to the project keyword list: ```elixir def project do [ - app: :testex, + app: :my_app, version: "0.1.0", elixir: "~> 1.20", - start_permanent: Mix.env() == :prod, deps: deps(), grisp: grisp(), releases: releases() @@ -55,21 +75,16 @@ defp grisp do platform: :grisp2, otp: [version: "29", jit: true], deploy: [ - # Use a local directory while testing the deployment: - destination: "tmp/grisp_sd", - - # To deploy directly to a mounted SD card, replace `destination` with its - # mount path. Optional scripts can prepare and unmount the card: - # pre_script: "rm -rf /Volumes/GRISP/*", - # destination: "/Volumes/GRISP", - # post_script: "diskutil unmount /Volumes/GRISP" + destination: "/path/to/SD-card", + # pre_script: "rm -rf /path/to/SD-card/*", + # post_script: "diskutil unmount /path/to/SD-card" ] ] end defp releases do [ - testex: [ + my_app: [ overwrite: true, cookie: "replace_with_a_long_random_cookie", include_erts: &MixGrisp.Release.erts/0, @@ -81,19 +96,17 @@ defp releases do end ``` -The `:platform` option defaults to `:grisp2`. The `:jit` option controls whether -the available Arm32 JIT patches are applied to the selected OTP build. - -Replace the example release cookie before enabling Erlang distribution. Use the -same cookie wherever the node cookie is configured. +`:platform` defaults to `:grisp2`. The configured OTP 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. -## Network configuration +## Elixir shell and networking -Mix releases use `$RELEASE_LIB` in their boot files. Add an overlay file at -`grisp/grisp2/common/deploy/files/grisp.ini.mustache` in your project so the -GRiSP runtime can expand that variable. The `-boot_var RELEASE_LIB -{{release_name}}/lib` argument is required; omitting it causes boot to terminate -with `cannot expand $RELEASE_LIB in bootfile`. +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] @@ -109,8 +122,7 @@ hostname=GRISP_HOSTNAME wpa=wpa_supplicant.conf ``` -Replace `GRISP_HOSTNAME` with the desired board hostname. For Wi-Fi, also add -`grisp/grisp2/common/deploy/files/wpa_supplicant.conf`: +For Wi-Fi, add `wpa_supplicant.conf` beside it: ```ini network={ @@ -120,90 +132,203 @@ network={ } ``` -Replace `WLAN_SSID` and `WLAN_PASSWORD` with the Wi-Fi network credentials. Do -not commit real credentials to source control. +Do not commit real credentials. See the [GRiSP networking guide][networking]. -See the [GRiSP networking guide][networking] for the available `grisp.ini` -settings. +## Deploy a release -## Deploying +Deploy to the configured destination: + +```console +mix grisp.deploy +mix grisp.deploy --relname my_app --relvsn 0.1.0 +mix grisp.deploy --destination /Volumes/GRISP --force +``` -Fetch the dependencies: +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: ```console -mix deps.get +mix grisp.deploy --tar ``` -Confirm that the local OTP major version matches `grisp[:otp][:version]`: +Options are `--relname/-n`, `--relvsn/-v`, `--tar/-t`, +`--destination/-d`, `--force/-f`, `--pre-script`, and `--post-script`. +Options after `--` are forwarded to `mix release`: ```console -erl -noshell -eval 'io:format("~s~n", [erlang:system_info(otp_release)]), halt().' +mix grisp.deploy --tar -- --quiet ``` -Deploy the application: +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. + +## Generate GRiSP 2 firmware + +The default command generates a system-partition firmware under +`_grisp/firmware`: ```console -mix grisp.deploy +mix grisp.firmware +``` + +Available outputs are: + +- 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`. + +Examples: + +```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 +``` + +Other options are `--[no-]compress`, `--quiet`, and all release selection +options. A bundle is created through `grisp.deploy --tar` when not supplied; +`--refresh` recreates it. Image and bootloader generation requires a toolchain. + +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 system +firmware to the active partition (`/dev/mmc1.0` or `/dev/mmc1.1`): + +```text +uncompress /mnt/mmc/grisp2.RELNAME.RELVSN.sys.gz /dev/mmc1.0 +``` + +Write an eMMC image or bootloader to `/dev/mmc1`: + +```text +uncompress /mnt/mmc/grisp2.RELNAME.RELVSN.emmc.gz /dev/mmc1 +uncompress /mnt/mmc/grisp2.RELNAME.RELVSN.boot.gz /dev/mmc1 ``` -Without an explicit destination, `mix_grisp` writes the deployment to -`tmp/grisp_sd`. Set `grisp[:deploy][:destination]` to the SD card mount point to -deploy directly to the card. +A truncated image contains only the first system partition. Set the active +system to `0` before booting it. Writing a system firmware to the inactive A/B +partition does not change what the board currently boots. -## Enabling Erlang distribution +## Build a software update package -GRiSP can run Erlang distribution with an internal EPMD implementation. +Create `_grisp/update/grisp2.RELNAME.RELVSN.tar`: -Add the tested EPMD revision to the project dependencies. `runtime: false` -prevents Mix from starting it as a regular OTP application on the development -host: +```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 -{:epmd, - git: "https://github.com/erlang/epmd", - ref: "4d1a59", - runtime: false} +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, %{}}}} + ] ``` -Include EPMD in the release so its modules are available on the board: +Extract the package under `releases/RELNAME/RELVSN`, serve `releases` over +HTTP, then update and validate from IEx: ```elixir -def application do +: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: + +```elixir +defp grisp do [ - extra_applications: [:logger], - included_applications: [:epmd] + 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 ``` -Insert the distribution options in the `[erlang]` `args` value in -`grisp.ini.mustache` before `-user elixir -extra +iex --no-halt`: +`GRISP_TOOLCHAIN` overrides the configured directory. Build with: -```text --internal_epmd epmd_sup -sname mynode -setcookie replace_with_a_long_random_cookie +```console +mix grisp.build +mix grisp.build --no-configure +mix grisp.build --clean +mix grisp.build --tar +mix grisp.build --update-prebuild ``` -Choose a unique node name and use the same cookie configured for the release. -Keep the existing `-kernel inetrc "./erl_inetrc"` option in the argument list. +The installation is stored under `_grisp/otp/VERSION/install`. Reconfigure +after adding C sources; `--no-configure` can speed up rebuilds after ordinary +source changes. -## Troubleshooting +## Bug reports -### Cannot expand `$RELEASE_LIB` in bootfile +```console +mix grisp.report +mix grisp.report --tar +``` -The GRiSP boot configuration is missing the Mix release library path. Add the -project `grisp.ini.mustache` overlay shown under -[Network configuration](#network-configuration), redeploy, and verify that the -generated `grisp.ini` contains: +Reports are written under `_grisp/report`. Review them for private information +before sharing. -```text --boot_var RELEASE_LIB /lib +## Development checkouts + +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 ``` -### This BEAM file was compiled for a later version of the runtime system +Mix automatically gives checkout dependencies precedence over Hex packages. + +## Troubleshooting -The project or one of its dependencies was compiled with a different OTP major -version. Switch the local Erlang installation to the configured target version, -then rebuild and deploy: +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 From 16454a5cf0c345a0b598549b768f0f0c08ddfd3c Mon Sep 17 00:00:00 2001 From: Luca Succi Date: Fri, 4 Sep 2026 14:25:56 +0200 Subject: [PATCH 8/9] Fix CI --- .github/workflows/ci.yml | 2 +- .github/workflows/zizmor.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2180492..7e505c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: persist-credentials: false - name: Install Erlang and Elixir - uses: erlef/setup-beam@0f75c29430f34bb5af4cce5e3b7f6a8860fca236 # v1 + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 with: otp-version: ${{ matrix.otp }} elixir-version: "1.20" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 78d0899..71e0f46 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -21,4 +21,4 @@ jobs: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0 + uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 From 256f1c5da5320a95972168f8c24b8fca6b9acb82 Mon Sep 17 00:00:00 2001 From: Luca Succi Date: Fri, 4 Sep 2026 16:35:59 +0200 Subject: [PATCH 9/9] Add workaround to refresh the erts directory on every deployment --- lib/mix_grisp/release.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/mix_grisp/release.ex b/lib/mix_grisp/release.ex index 1c83dee..fc0f805 100644 --- a/lib/mix_grisp/release.ex +++ b/lib/mix_grisp/release.ex @@ -10,6 +10,11 @@ defmodule MixGrisp.Release do 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