NervesGithubUpdater.Updater (nerves_github_updater v0.1.0)

Copy Markdown View Source

Firmware update orchestration GenServer.

Library-shape: no host dependencies. All wiring (PubSub module, topic, download dir, fwup devpath/task, GithubClient module, asset-matcher closure) comes through start_link/1 opts.

State machine

:idle  :checking  :idle
:idle  :downloading  :flashing  :idle                    (legacy, unverified)
:idle  :verifying  :downloading  :flashing  :idle      (manifest, verified)
              any phase above  :error  (next check/1 clears to :idle)

Every phase transition broadcasts {:fw_update_progress, payload} on the configured PubSub topic. :error clears to :idle on the next check/1. There is no retry, no cancel, no HTTP-range resume (conservative error policy — see plan Q7).

Progress snapshot caveat

state.pct is updated on phase transitions only (so a state/1 snapshot reports 0 at the start of :downloading / :flashing). Intermediate per-byte progress is broadcast via PubSub but does not mutate the GenServer state — the install runs inside a single handle_info/2 that blocks the GenServer, so routing progress through self-messages would just buffer them all until the install completes. A subscriber that reads a fresh state/1 snapshot mid-install therefore sees a stale pct for the brief window until the next PubSub event lands and corrects it.

Conditional verification

When opts :verification_required is false (v1 default), the install flow is the legacy path: asset_matcher.(tag, assets) resolves a single firmware asset, which is downloaded unverified and flashed (:downloading → :flashing, no :verifying phase). A Logger.warning fires on every such install so the unverified state is auditable in RingLogger.

When :verification_required is true, the flow is the manifest path (:verifying → :downloading → :flashing) — see "## Manifest verification" below.

Downgrade gate

Before either path runs, release.tag_name is compared against :current_version_fn.() via VersionCompare.compare/2. A :lt result (release older than the running firmware) is refused unless :allow_downgrade is true. This is belt-and-braces: the manifest counter (see below) is the primary rollback control and is the only check that applies on the manifest path; this semver check also guards the legacy (unverified) path, which has no counter at all. :gt, :eq, :missing, and :incomparable all proceed.

Manifest verification

Wire contract: docs/manifest-format.md. Summary of the device-side pipeline (entered when :verification_required is true):

  1. Transition to :verifying.
  2. Download release-manifest.json + release-manifest.sig from the release assets (missing either ⇒ :missing_manifest / :missing_manifest_signature).
  3. sig_mod.verify_manifest(manifest_bytes, sig_bytes, pubkey) — signed message is sha512(manifest_bytes), not the raw bytes.
  4. Manifest.parse/1 — schema + version validation.
  5. Manifest.check_expiry/2 — policy-gated, default off (:enforce_expiry).
  6. Manifest.check_counter/2 against :kv_get.("fw_manifest_counter") — rejects a lower counter (rollback), accepts equal or absent.
  7. :target_fn.() resolves the device's Nerves target; Manifest.target/2 looks up its %{asset, sha256, size} entry.
  8. Transition to :downloading; download the named release asset with expected_sha256 / expected_size — the client verifies the streamed hash before the atomic rename.
  9. Flash (:flashing). On success, :kv_put.("fw_manifest_counter", counter) before reboot — the counter anchor only advances once the new firmware is actually on disk.

Any failure at any step broadcasts {:fw_update_progress, %{phase: :error, ...}} and resets to :idle on the next check/1.

Opts

  • :owner_repo, :github_token, :public_key — as before.
  • :verification_required (default false) — selects legacy vs manifest install path (see above).
  • :channel (default :stable) — forwarded to client.latest_release/2; :stable or :prerelease.
  • :allow_downgrade (default false) — see "Downgrade gate".
  • :enforce_expiry (default false) — see "Manifest verification".
  • :kv_get(String.t() -> String.t() | nil), reads the persisted counter anchor. Missing ⇒ fn _ -> nil end (no anchor, first-contact trust).

  • :kv_put(String.t(), String.t() -> :ok | {:error, term()}), persists the new counter anchor after a successful flash. A non-:ok return is logged (rollback protection may be stale) but does not fail the already-completed install. Missing ⇒ fn _, _ -> :ok end (no-op — degrades gracefully rather than crashing the install on a host/test without Nerves.Runtime.KV).

  • :target_fn(-> String.t()), resolves the device's Nerves target for the manifest path. Missing ⇒ {:error, :missing_target_fn} (fails the install via the normal fail/3 path; the legacy path never calls it).
  • :current_version_fn(-> String.t() | nil), the device's current firmware version for the downgrade gate. Missing ⇒ fn -> nil end (comparison against nil is :missing, which proceeds).

  • :asset_matcher — legacy-path only. Contract is (tag, assets) -> {:ok, fw_asset} | {:error, reason} (no more sig asset — the manifest path replaces per-asset signatures entirely).

Test seams

Summary

Functions

Kicks off a check for a new release. Returns :ok immediately; the actual result is broadcast via PubSub.

Returns a specification to start this module under a supervisor.

Starts download → (optionally verify) → flash for the most recently fetched release. Returns {:error, :no_release_cached} if check/1 has not produced a release yet, or {:error, :busy} if a phase is in flight.

Synchronous snapshot for a subscriber's initial render.

Update mutable opts. Takes effect for the next check/install — in-flight operations continue with the opts they started with.

Functions

check(server \\ __MODULE__)

@spec check(GenServer.server()) :: :ok

Kicks off a check for a new release. Returns :ok immediately; the actual result is broadcast via PubSub.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

install_latest(server \\ __MODULE__)

@spec install_latest(GenServer.server()) :: :ok | {:error, :no_release_cached | :busy}

Starts download → (optionally verify) → flash for the most recently fetched release. Returns {:error, :no_release_cached} if check/1 has not produced a release yet, or {:error, :busy} if a phase is in flight.

start_link(opts)

state(server \\ __MODULE__)

@spec state(GenServer.server()) :: map()

Synchronous snapshot for a subscriber's initial render.

update_config(server \\ __MODULE__, updates)

@spec update_config(
  GenServer.server(),
  keyword()
) :: :ok | {:error, :immutable | :unknown}

Update mutable opts. Takes effect for the next check/install — in-flight operations continue with the opts they started with.

Returns :ok | {:error, :immutable | :unknown}.