-
Notifications
You must be signed in to change notification settings - Fork 90
bluez: new sysext shipping the BlueZ Bluetooth userspace #253
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
ananthb
wants to merge
3
commits into
flatcar:main
Choose a base branch
from
ananthb:bluez-sysext
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| #!/usr/bin/env bash | ||
| # vim: et ts=2 syn=bash | ||
| # | ||
| # The BlueZ sysext. | ||
| # | ||
| # BlueZ is the Linux Bluetooth userspace: bluetoothd plus the bluetoothctl / | ||
| # btmon / btmgmt tools. It has no upstream static or portable release, and it | ||
| # links glib, dbus and readline, so this extension bundles the binaries with | ||
| # their shared-library closure (including glibc + the dynamic loader) extracted | ||
| # from a Debian container. tools/flix.sh resolves the closure and patchelf's the | ||
| # binaries onto a private loader/rpath, isolating them from the host's libraries | ||
| # (same approach as qemu.sysext and tilde.sysext). Debian packages BlueZ, so the | ||
| # version parameter selects the Debian suite (stable/testing) that ships it. | ||
| # | ||
| # The kernel side is not part of this extension: Bluetooth modules have to match | ||
| # the running kernel exactly, so they belong in the Flatcar image rather than in | ||
| # a sysext. Flatcar images built after flatcar/scripts#4197 carry them. | ||
|
|
||
| RELOAD_SERVICES_ON_MERGE="true" | ||
|
|
||
| DEBIAN_BLUEZ_API="https://sources.debian.org/api/src/bluez/" | ||
|
|
||
| # A transient network hiccup should not fail a build; same retry policy the | ||
| # other recipes in this repo use (consul, haproxy, nomad, vault). | ||
| CURL_RETRY=(--retry-delay 1 --retry 60 --retry-connrefused | ||
| --retry-max-time 60 --connect-timeout 20) | ||
|
|
||
| # Resolve a Debian suite alias (stable/testing) to its codename from the archive | ||
| # Release file, e.g. stable -> trixie. The sources API tags versions by codename | ||
| # only, so we need this mapping. Fails if the lookup can't be made. | ||
| function _debian_codename() { | ||
| local alias="$1" codename | ||
| codename="$(curl -fsSL "${CURL_RETRY[@]}" "https://deb.debian.org/debian/dists/${alias}/Release" 2>/dev/null \ | ||
| | sed -nE 's/^Codename:[[:space:]]*([^[:space:]]+).*/\1/p')" || return 1 | ||
| [[ -n "${codename}" ]] || return 1 | ||
| printf '%s\n' "${codename}" | ||
| } | ||
| # -- | ||
|
|
||
| # Read a Debian sources API response on stdin and print the x.y BlueZ versions | ||
| # shipped by the given suite codenames (args), newest first. | ||
| function _bluez_versions_in() { | ||
| jq -r --args ' | ||
| ($ARGS.positional) as $cn | ||
| | .versions[]? | ||
| | select((.suites // []) | any(. as $s | $cn | index($s))) | ||
| | .version' "$@" \ | ||
| | sed -nE 's/^([0-9]+\.[0-9]+).*/\1/p' \ | ||
| | sort -Vru | ||
| } | ||
| # -- | ||
|
|
||
| # Return 0 if the given Debian codename ships the requested x.y BlueZ version. | ||
| function _bluez_version_in() { | ||
| local api="$1" ver="$2" codename="$3" | ||
| echo "${api}" | jq -e --arg v "${ver}" --arg c "${codename}" ' | ||
| .versions[]? | ||
| | select((.suites // []) | index($c)) | ||
| | select(.version | test("^" + ($v | gsub("\\.";"\\.")) + "([-.+~]|$)")) | ||
| ' >/dev/null | ||
| } | ||
| # -- | ||
|
|
||
| function list_available_versions() { | ||
| local api stable testing | ||
| stable="$(_debian_codename stable)" && testing="$(_debian_codename testing)" || { | ||
| echo "ERROR: failed to resolve Debian stable/testing codenames." >&2 | ||
| return 1 | ||
| } | ||
| api="$(curl -fsSL "${CURL_RETRY[@]}" "${DEBIAN_BLUEZ_API}")" || { | ||
| echo "ERROR: failed to query the Debian sources API (${DEBIAN_BLUEZ_API})." >&2 | ||
| return 1 | ||
| } | ||
| echo "${api}" | _bluez_versions_in "${stable}" "${testing}" | ||
| } | ||
| # -- | ||
|
|
||
| function populate_sysext_root() { | ||
| local sysextroot="$1" | ||
| local arch="$2" | ||
| local version="$3" | ||
|
|
||
| local img_arch | ||
| img_arch="$(arch_transform 'x86-64' 'amd64' "${arch}")" | ||
| img_arch="$(arch_transform 'arm64' 'arm64' "${img_arch}")" | ||
|
|
||
| # Map the stable/testing aliases to codenames (the sources API only tags by | ||
| # codename) and fetch the version list ONCE. Fail hard on any lookup/parse | ||
| # error so a transient failure can't silently change which suite (and thus | ||
| # which BlueZ version) we build against. | ||
| local stable testing api | ||
| stable="$(_debian_codename stable)" && testing="$(_debian_codename testing)" || { | ||
| echo "ERROR: failed to resolve Debian stable/testing codenames." >&2 | ||
| return 1 | ||
| } | ||
| api="$(curl -fsSL "${CURL_RETRY[@]}" "${DEBIAN_BLUEZ_API}")" || { | ||
| echo "ERROR: failed to query the Debian sources API (${DEBIAN_BLUEZ_API})." >&2 | ||
| return 1 | ||
| } | ||
| echo "${api}" | jq -e '.versions' >/dev/null 2>&1 || { | ||
| echo "ERROR: unexpected response from the Debian sources API." >&2 | ||
| return 1 | ||
| } | ||
|
|
||
| # 'latest' resolves to the newest version in stable/testing so the checks below | ||
| # have a concrete target. | ||
| if [[ "${version}" == "latest" ]]; then | ||
| version="$(echo "${api}" | _bluez_versions_in "${stable}" "${testing}" | head -1)" | ||
| [[ -n "${version}" ]] || { | ||
| echo "ERROR: could not determine the latest bluez version from Debian." >&2 | ||
| return 1 | ||
| } | ||
| fi | ||
|
|
||
| # Choose the suite that actually ships the requested version: prefer stable, | ||
| # else testing, else fail. Never fall back blindly. | ||
| local suite="" | ||
| if _bluez_version_in "${api}" "${version}" "${stable}"; then | ||
| suite="stable" | ||
| elif _bluez_version_in "${api}" "${version}" "${testing}"; then | ||
| suite="testing" | ||
| else | ||
| echo "ERROR: bluez ${version} is not in Debian stable (${stable}) or testing (${testing})." >&2 | ||
| return 1 | ||
| fi | ||
|
|
||
| announce "Building bluez ${version} (Debian ${suite}) for ${arch}" | ||
|
|
||
| # Build the sysext inside a Debian container of the chosen suite: install | ||
| # BlueZ, verify bluetoothd actually matches the requested version (a moved | ||
| # suite must not silently produce a different build), then hand the binaries | ||
| # and the D-Bus policy to tools/flix.sh, which resolves the library closure | ||
| # and patchelf's them onto a private loader/rpath. | ||
| docker run --rm -i \ | ||
| -v "${scriptroot}/tools/":/tools \ | ||
| -v "${sysextroot}":/install_root \ | ||
| --platform "linux/${img_arch}" \ | ||
| --pull always \ | ||
| --network host \ | ||
| -e WANT_VERSION="${version}" \ | ||
| "docker.io/debian:${suite}-slim" bash -euc ' | ||
| export DEBIAN_FRONTEND=noninteractive | ||
| apt-get update -qq | ||
| apt-get install -y -qq --no-install-recommends bluez patchelf >/dev/null | ||
|
|
||
| # bluetoothd moved from /usr/lib to /usr/libexec across Debian releases. | ||
| bluetoothd="" | ||
| for c in /usr/libexec/bluetooth/bluetoothd /usr/lib/bluetooth/bluetoothd; do | ||
| [ -x "${c}" ] && bluetoothd="${c}" && break | ||
| done | ||
| if [ -z "${bluetoothd}" ]; then | ||
| echo "ERROR: bluetoothd not found in the bluez package." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| got="$("${bluetoothd}" --version | sed -nE "s/^([0-9]+\.[0-9]+).*/\1/p" | head -1)" | ||
| if [ "${got}" != "${WANT_VERSION}" ]; then | ||
| echo "ERROR: requested bluez ${WANT_VERSION} but Debian installs ${got}." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| # bluetoothd plus every CLI tool this suite's bluez package ships. Take | ||
| # the list from the package rather than hardcoding it: the tool set drifts | ||
| # across Debian releases (the legacy hciconfig/hcitool/... tools are | ||
| # deprecated upstream and already gone from newer ones), and a hardcoded | ||
| # subset silently drops tools that are still shipped -- hciattach in | ||
| # particular, which is what attaches a UART controller. | ||
| paths="${bluetoothd}" | ||
| for b in $(dpkg -L bluez | sed -nE 's|^(/usr/s?bin/[^/]+)$|\1|p' | sort -u); do | ||
| [ -f "${b}" ] && [ -x "${b}" ] && paths="${paths} ${b}" | ||
| done | ||
|
|
||
| # bluetoothd will not take its name on the system bus without this policy. | ||
| dbus_conf="" | ||
| for c in /usr/share/dbus-1/system.d/bluetooth.conf \ | ||
| /etc/dbus-1/system.d/bluetooth.conf; do | ||
| [ -f "${c}" ] && dbus_conf="${c}" && break | ||
| done | ||
| if [ -z "${dbus_conf}" ]; then | ||
| echo "ERROR: the bluez D-Bus policy file was not found." >&2 | ||
| exit 1 | ||
| fi | ||
| # flix.sh only accepts the "source:target" form for paths outside /usr; | ||
| # a path that is already under /usr must be passed as-is. | ||
| case "${dbus_conf}" in | ||
| /usr/*) paths="${paths} ${dbus_conf}" ;; | ||
| *) paths="${paths} ${dbus_conf}:/usr/share/dbus-1/system.d/bluetooth.conf" ;; | ||
| esac | ||
|
|
||
| cd /install_root | ||
| /tools/flix.sh / bluez ${paths} | ||
|
|
||
| # bluetoothd is looked up at a fixed path by our unit; normalise the | ||
| # location so the unit does not have to care which Debian release we used. | ||
| if [ "${bluetoothd}" != "/usr/libexec/bluetooth/bluetoothd" ]; then | ||
| mkdir -p bluez/usr/libexec/bluetooth | ||
| mv "bluez${bluetoothd}" bluez/usr/libexec/bluetooth/bluetoothd | ||
| fi | ||
|
|
||
| owner="$(stat -c "%u:%g" /install_root)" | ||
| if [ "${owner}" != "$(id -u):$(id -g)" ]; then | ||
| chown -R "${owner}" /install_root/bluez | ||
| fi | ||
| ' | ||
|
|
||
| # Merge rather than move: the bakery has already copied this extension's | ||
| # static files/ tree into ${sysextroot}/usr before calling us. | ||
| mkdir -p "${sysextroot}/usr" | ||
| cp -a "${sysextroot}/bluez/usr/." "${sysextroot}/usr/" | ||
| rm -rf "${sysextroot}/bluez" | ||
| } | ||
| # -- |
50 changes: 50 additions & 0 deletions
50
bluez.sysext/files/usr/lib/systemd/system/bluetooth.service
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| [Unit] | ||
| Description=Bluetooth service | ||
| Documentation=man:bluetoothd(8) | ||
| # Only present once a Bluetooth controller has been detected and its driver | ||
| # (btusb, hci_uart, ...) has loaded. Without an adapter the unit is skipped | ||
| # rather than failed, so the Upholds= drop-in does not retry in a loop. A | ||
| # controller that shows up later is caught by the udev rule this sysext ships, | ||
| # since a Condition= is only evaluated when the unit is queued. | ||
| ConditionPathIsDirectory=/sys/class/bluetooth | ||
| After=dbus.service | ||
| Requires=dbus.service | ||
|
|
||
| [Service] | ||
| Type=dbus | ||
| BusName=org.bluez | ||
| # The sysext drops a new D-Bus policy into /usr/share/dbus-1/system.d, which a | ||
| # running dbus-daemon has not read yet. Without this reload bluetoothd cannot | ||
| # take its name on the system bus on first merge. busctl ships with systemd, so | ||
| # it is always present on Flatcar. | ||
| ExecStartPre=-/usr/bin/busctl call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus ReloadConfig | ||
| ExecStart=/usr/libexec/bluetooth/bluetoothd | ||
| NotifyAccess=main | ||
| LimitNPROC=1 | ||
| Restart=on-failure | ||
| RestartSec=2 | ||
|
|
||
| # Adapter settings and pairing keys. Replaces a tmpfiles.d rule: systemd creates | ||
| # the directory before the daemon starts, and ProtectSystem=strict below needs | ||
| # it declared here to leave it writable. | ||
| StateDirectory=bluetooth | ||
| StateDirectoryMode=0700 | ||
|
Comment on lines
+27
to
+31
|
||
|
|
||
| # Sandboxing, as shipped by upstream BlueZ in src/bluetooth.service.in. | ||
| CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN CAP_NET_BIND_SERVICE | ||
| NoNewPrivileges=true | ||
| ProtectHome=true | ||
| ProtectSystem=strict | ||
| PrivateTmp=true | ||
| ProtectKernelTunables=true | ||
| ProtectControlGroups=true | ||
| MemoryDenyWriteExecute=true | ||
| RestrictRealtime=true | ||
|
|
||
| [Install] | ||
| # Upstream BlueZ uses bluetooth.target here. systemd does ship that target, but | ||
| # nothing pulls it in by default, so 'systemctl enable bluetooth.service' would | ||
| # leave the service unstarted. multi-user.target matches what the other sysexts | ||
| # in this repo do and makes enabling behave as expected. | ||
| WantedBy=multi-user.target | ||
| Alias=dbus-org.bluez.service | ||
2 changes: 2 additions & 0 deletions
2
bluez.sysext/files/usr/lib/systemd/system/multi-user.target.d/10-bluez.conf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| [Unit] | ||
| Upholds=bluetooth.service |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # A controller can appear long after multi-user.target has been reached: a USB | ||
| # dongle gets hot-plugged, or btusb/hci_uart loads late. bluetooth.service is | ||
| # guarded by ConditionPathIsDirectory=/sys/class/bluetooth, and a Condition= is | ||
| # only evaluated when the unit is queued -- nothing re-queues it when the | ||
| # directory finally shows up, so the daemon would stay stopped indefinitely. | ||
| # Pulling the unit in from the device event closes that gap; starting an | ||
| # already-running service is a no-op, so this is safe to fire per controller. | ||
| ACTION=="add", SUBSYSTEM=="bluetooth", TAG+="systemd", ENV{SYSTEMD_WANTS}+="bluetooth.service" |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| # BlueZ sysext | ||
|
|
||
| This extension ships [BlueZ](http://www.bluez.org/), the Linux Bluetooth userspace: the `bluetoothd` daemon plus the `bluetoothctl`, `btmon`, `btmgmt` and `btattach` tools. | ||
|
|
||
| BlueZ has no upstream static or portable release and it links glib, dbus and readline, so this extension bundles the binaries together with their shared-library closure (including glibc and the dynamic loader) extracted from a Debian container, the same way the `qemu` and `tilde` extensions do. Because of that the version numbers you can build are the BlueZ versions Debian stable/testing ship, not arbitrary upstream releases. | ||
|
|
||
| ## Kernel requirements | ||
|
|
||
| The extension ships **userspace only**. Bluetooth kernel modules have to match the running kernel exactly, so they belong in the Flatcar image rather than in a sysext. | ||
|
|
||
| Flatcar images built before [flatcar/scripts#4197](https://github.com/flatcar/scripts/pull/4197) contain no Bluetooth support at all — `CONFIG_BT` was never enabled — so `bluetoothd` has nothing to talk to. On such an image the `bluetooth.service` unit shipped here is skipped rather than failed, because it is guarded by `ConditionPathIsDirectory=/sys/class/bluetooth`. | ||
|
|
||
| On an image that does have the modules, the driver for your controller (`btusb` for USB adapters, `hci_uart` for UART-attached ones) is autoloaded when the device is detected, `/sys/class/bluetooth` appears, and `bluetooth.service` starts. | ||
|
|
||
| ## Usage | ||
|
|
||
| Download and merge the sysext at provisioning time using the below butane snippet. | ||
|
|
||
| The snippet includes automated updates via systemd-sysupdate. | ||
| Sysupdate will stage updates and request a reboot by creating a flag file at `/run/reboot-required`. | ||
| You can deactivate updates by changing `enabled: true` to `enabled: false` in `systemd-sysupdate.timer`. | ||
|
|
||
| Note that the snippet is for the x86-64 version of BlueZ 5.82. Other architectures are also available. | ||
|
|
||
| Check out the metadata release at https://github.com/flatcar/sysext-bakery/releases/tag/bluez for a list of all versions available in the bakery. | ||
|
|
||
| ```yaml | ||
| variant: flatcar | ||
| version: 1.0.0 | ||
|
|
||
| storage: | ||
| files: | ||
| - path: /opt/extensions/bluez/bluez-5.82-x86-64.raw | ||
| mode: 0644 | ||
| contents: | ||
| source: https://extensions.flatcar.org/extensions/bluez-5.82-x86-64.raw | ||
| - path: /etc/sysupdate.bluez.d/bluez.conf | ||
| contents: | ||
| source: https://extensions.flatcar.org/extensions/bluez.conf | ||
| - path: /etc/sysupdate.d/noop.conf | ||
| contents: | ||
| source: https://extensions.flatcar.org/extensions/noop.conf | ||
| links: | ||
| - target: /opt/extensions/bluez/bluez-5.82-x86-64.raw | ||
| path: /etc/extensions/bluez.raw | ||
| hard: false | ||
| systemd: | ||
| units: | ||
| - name: systemd-sysupdate.timer | ||
| enabled: true | ||
| - name: systemd-sysupdate.service | ||
| dropins: | ||
| - name: bluez.conf | ||
| contents: | | ||
| [Service] | ||
| ExecStartPre=/usr/bin/sh -c "readlink --canonicalize /etc/extensions/bluez.raw > /tmp/bluez" | ||
| ExecStartPre=/usr/lib/systemd/systemd-sysupdate -C bluez update | ||
| ExecStartPost=/usr/bin/sh -c "readlink --canonicalize /etc/extensions/bluez.raw > /tmp/bluez-new" | ||
| ExecStartPost=/usr/bin/sh -c "if ! cmp --silent /tmp/bluez /tmp/bluez-new; then touch /run/reboot-required; fi" | ||
| ``` | ||
|
|
||
| ## What the extension starts | ||
|
|
||
| `bluetooth.service` is upheld by `multi-user.target`, so it starts on merge and is restarted if it dies. It runs `bluetoothd` as a D-Bus service under the `org.bluez` name. | ||
|
|
||
| The extension drops a D-Bus policy file into `/usr/share/dbus-1/system.d/`, which a *running* `dbus-daemon` has not read yet. The unit therefore asks D-Bus to reload its configuration before starting `bluetoothd`; without that, `bluetoothd` cannot take its name on the system bus until the next reboot. | ||
|
|
||
| Adapter settings and pairing keys are stored under `/var/lib/bluetooth`, created by the unit's `StateDirectory=`. | ||
|
|
||
| The unit is guarded by `ConditionPathIsDirectory=/sys/class/bluetooth`, so on a host without a controller it is skipped rather than restarted in a loop. A controller that appears later — a hot-plugged USB dongle, or `btusb` loading after boot — is picked up by the udev rule the extension ships, which pulls the unit in from the device event. | ||
|
|
||
| ## Configuration | ||
|
|
||
| `bluetoothd` reads `/etc/bluetooth/main.conf` if present and runs with built-in defaults otherwise. To change settings, drop your own file there via Ignition, for example to power adapters on automatically: | ||
|
|
||
| ```yaml | ||
| storage: | ||
| files: | ||
| - path: /etc/bluetooth/main.conf | ||
| mode: 0644 | ||
| contents: | ||
| inline: | | ||
| [Policy] | ||
| AutoEnable=true | ||
| ``` | ||
|
|
||
| ## Checking it works | ||
|
|
||
| ```sh | ||
| # the controller shows up once the kernel driver has bound it | ||
| bluetoothctl list | ||
|
|
||
| # live HCI trace, useful when a controller misbehaves | ||
| btmon | ||
| ``` | ||
|
|
||
| If `bluetoothctl list` prints nothing, check that a driver actually bound the controller: | ||
|
|
||
| ```sh | ||
| systemctl status bluetooth | ||
| ls /sys/class/bluetooth | ||
| journalctl -u bluetooth | ||
| ``` | ||
|
|
||
| An empty `/sys/class/bluetooth` means no Bluetooth driver is loaded — either the hardware is absent, or the image predates the kernel change linked above. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -109,6 +109,9 @@ haproxy latest | |
| bird 3.1.2 | ||
| bird latest | ||
|
|
||
| bluez 5.82 | ||
| bluez latest | ||
|
|
||
| btop 1.4.0 | ||
| btop latest | ||
|
|
||
|
|
||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.