diff --git a/.github/workflows/integration_delivery.yml b/.github/workflows/integration_delivery.yml index 542bdd40..cc3ef79c 100644 --- a/.github/workflows/integration_delivery.yml +++ b/.github/workflows/integration_delivery.yml @@ -185,15 +185,6 @@ jobs: ~/.local key: poetry-${{ hashFiles('poetry.lock') }} - - name: Add SENTRY_DSN to .env - run: | - echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> ubo_app/.env - pwd - cat ubo_app/.env - - - name: Build - run: poetry build - - name: Extract Version id: extract_version run: | @@ -227,6 +218,20 @@ jobs: echo "Versions are consistent." fi + - name: Configure Sentry + run: | + echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> ubo_app/.env + # conditionally set it based on whether it's a tag or not using github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + if [ "${{ github.event_name }}" == "push" ] && [ "$(echo ${{ github.ref }} | grep -c 'refs/tags/v')" -eq 1 ]; then + echo "SENTRY_RELEASE=ubo-app@${{ steps.extract_version.outputs.VERSION }}" >> ubo_app/.env + else + echo "SENTRY_RELEASE=ubo-app@${{ github.sha }}" >> ubo_app/.env + fi + cat ubo_app/.env + + - name: Build + run: poetry build + - name: Upload wheel uses: actions/upload-artifact@v4 with: @@ -241,39 +246,6 @@ jobs: path: dist/*.tar.gz if-no-files-found: error - pypi-publish: - name: Publish to PyPI - if: >- - github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - needs: - - type-check - - lint - - test - - build - runs-on: ubuntu-latest - environment: - name: release - url: https://pypi.org/p/${{ needs.build.outputs.name }} - permissions: - id-token: write - steps: - - uses: actions/download-artifact@v4 - with: - name: wheel - path: dist - - - uses: actions/download-artifact@v4 - with: - name: binary - path: dist - - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: dist - verbose: true - skip-existing: true - images: name: Create Images needs: @@ -361,6 +333,40 @@ jobs: steps.generate_image_url.outputs.dashed_suffix }}.img.gz if-no-files-found: error + pypi-publish: + name: Publish to PyPI + if: >- + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: + - type-check + - lint + - test + - build + - images + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/${{ needs.build.outputs.name }} + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: wheel + path: dist + + - uses: actions/download-artifact@v4 + with: + name: binary + path: dist + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + verbose: true + skip-existing: true + release: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') name: Release diff --git a/CHANGELOG.md b/CHANGELOG.md index c444ecee..196917e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Version 0.12.2 + +- feat(lightdm): add lightdm service +- build(packer): disable lightdm service by default +- fix(sound): try restarting `pulseaudio` every 5 seconds when it is not ready + (it may be not available in the first boot of desktop image at least) +- refactor(ssh): use our own `monitor_unit` utility function and drop `cysystemd` +- ci: set `SENTRY_RELEASE` +- feat(system): setup sentry for `system_manager` +- refactor(core): `send_command` is now an async function utilizing `asyncio` streams + ## Version 0.12.1 - feat(system_manager): commands for starting/stopping/enabling/disabling services @@ -29,7 +40,8 @@ - feat(test): introduce `UBO_DEBUG_TEST_UUID` environment variable for tracking the sequence of uuid generations in the tests, it prints the traceback for each call to `uuid.uuid4` if it is set -- fix(wifi): change `_remote_object_path` to `_dbus.object_path` for sdbus objects #57 +- fix(wifi): change `_remote_object_path` to `_dbus.object_path` for sdbus objects + #57 ## Version 0.11.7 diff --git a/README.md b/README.md index bf1601f5..a6deb99e 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,12 @@ botting from that image, you can ignore this section. Note that as part of the installation process, these debian packages are installed: -- build-essential - git - i2c-tools - libcap-dev - libegl1 - libgl1 - libmtdev1 -- libsystemd-dev - libzbar0 - python3 - python3-dev diff --git a/poetry.lock b/poetry.lock index 67816213..2935cea9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -522,16 +522,6 @@ files = [ [package.extras] toml = ["tomli"] -[[package]] -name = "cysystemd" -version = "1.6.0" -description = "systemd wrapper in Cython" -optional = false -python-versions = ">3.6, <4" -files = [ - {file = "cysystemd-1.6.0.tar.gz", hash = "sha256:5224dd8fee146de08528bbf685edb177568246c7728bbb548b2257c9a44a2454"}, -] - [[package]] name = "cython" version = "3.0.10" @@ -1901,4 +1891,4 @@ dev = ["ubo-gui", "ubo-gui"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "b5338d3eb3dbc0dc68bb7b79f73be5af4e739827039a048e3065480b8c040b01" +content-hash = "556143de7c94c63e9cc8c06f18aa3bfda7e6f91df9fe7af27fd6768d1d7ab11f" diff --git a/pyproject.toml b/pyproject.toml index f24d4124..b90d36b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ubo-app" -version = "0.12.1" +version = "0.12.2" description = "Ubo main app, running on device initialization. A platform for running other apps." authors = ["Sassan Haradji "] license = "Apache-2.0" @@ -29,7 +29,6 @@ python-redux = "^0.14.0" pyzbar = "^0.1.9" sdbus-networkmanager = { version = "^2.0.0", markers = "platform_machine=='aarch64'" } rpi_ws281x = { version = "^5.0.0", markers = "platform_machine=='aarch64'" } -cysystemd = { version = "^1.6.0", markers = "platform_machine=='aarch64'" } python-debouncer = "^0.1.4" adafruit-circuitpython-neopixel = "^6.3.11" pulsectl = "^23.5.2" diff --git a/scripts/consume.sh b/scripts/consume.sh index d7abb2c1..96c05b91 100755 --- a/scripts/consume.sh +++ b/scripts/consume.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -set -e -o errexit +set -o errexit +set -o pipefail +set -o nounset FILE=$1 CHUNK_SIZE=$((1024*1024*1024)) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 64e66690..b59a46ee 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,10 +1,15 @@ #!/usr/bin/env sh -set -e -o errexit +set -o errexit +set -o pipefail +set -o nounset poetry build LATEST_VERSION=$(basename $(ls -rt dist/*.whl | tail -n 1)) +deps=${deps:-"False"} +bootstrap=${bootstrap:-"False"} +run=${run:-"False"} scp dist/$LATEST_VERSION pi@ubo-development-pod:/tmp/ diff --git a/scripts/packer/image.pkr.hcl b/scripts/packer/image.pkr.hcl index f2b87a31..958964d1 100644 --- a/scripts/packer/image.pkr.hcl +++ b/scripts/packer/image.pkr.hcl @@ -41,6 +41,7 @@ build { "chmod +x /install.sh", "/install.sh --for-packer --with-docker --source=/ubo_app-${var.ubo_app_version}-py3-none-any.whl", "rm /install.sh /ubo_app-${var.ubo_app_version}-py3-none-any.whl", + "/usr/bin/env systemctl disable lightdm", "apt clean", "echo DF; df -h" ] diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc index 1b3e192a..406d02bf 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc @@ -162,7 +162,7 @@ "Main", "Settings", "WiFi Settings", - "a3f2c9bf9c6316b950f244556f25e2a2" + "8d723104f77383c13458a748e9bb17bc" ] }, "status_icons": { diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc index 4eb86343..50c2cc8a 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc @@ -162,7 +162,7 @@ "Main", "Settings", "WiFi Settings", - "a3f2c9bf9c6316b950f244556f25e2a2" + "8d723104f77383c13458a748e9bb17bc" ] }, "status_icons": { diff --git a/tests/integration/results/test_services/all_services_register/store-000.jsonc b/tests/integration/results/test_services/all_services_register/store-000.jsonc index c0906859..f0b6ace2 100644 --- a/tests/integration/results/test_services/all_services_register/store-000.jsonc +++ b/tests/integration/results/test_services/all_services_register/store-000.jsonc @@ -1,4 +1,4 @@ -// MISMATCH: store-000 +// store-000 { "_id": "e3e70682c2094cac629f6fbed82c07cd", "camera": { @@ -7,7 +7,7 @@ "queue": [] }, "docker": { - "_id": "a3f2c9bf9c6316b950f244556f25e2a2", + "_id": "8d723104f77383c13458a748e9bb17bc", "home_assistant": { "container_ip": null, "docker_id": null, @@ -113,6 +113,10 @@ } ] }, + "lightdm": { + "is_active": true, + "is_enabled": true + }, "main": { "menu": { "items": [ @@ -222,6 +226,7 @@ } }, { + "action": "", "background_color": "#68B7FF", "color": [ 1, @@ -229,87 +234,22 @@ 1, 1 ], - "icon": "󰣀", + "icon": "[color=#008000]󰪥[/color]", "is_short": false, - "label": "SSH", - "sub_menu": { - "items": [ - { - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "", - "is_short": false, - "label": "Create account", - "sub_menu": { - "heading": "Create an SSH account", - "items": [ - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󰗹", - "is_short": false, - "label": "Create" - } - ], - "placeholder": null, - "sub_heading": "This will create a temporary SSH account, you should delete it after use.", - "title": "SSH Setup" - } - }, - { - "application": "ubo_app/services/050-ssh/setup.py:ClearTemporaryUsersPrompt", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󱈊", - "is_short": false, - "label": "Remove all users" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "󰓛", - "is_short": false, - "label": "Stop" - }, - { - "action": "", - "background_color": "#68B7FF", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "[color=#008000]󰯄[/color]", - "is_short": false, - "label": "Disable" - } - ], - "placeholder": null, - "title": "SSH [color=#008000]󰧞[/color]" - } + "label": "LightDM" + }, + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "[color=#008000]󰪥[/color]", + "is_short": false, + "label": "SSH" }, { "background_color": "#68B7FF", diff --git a/tests/integration/test_services.py b/tests/integration/test_services.py index ebf38a19..4b0a31f3 100644 --- a/tests/integration/test_services.py +++ b/tests/integration/test_services.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - import pytest from redux_pytest.fixtures import StoreSnapshot from tests.fixtures import AppContext, LoadServices, Stability, WindowSnapshot @@ -18,6 +17,7 @@ 'wifi', 'keyboard', 'keypad', + 'lightdm', 'notifications', 'camera', 'sensors', @@ -33,19 +33,9 @@ async def test_all_services_register( needs_finish: None, load_services: LoadServices, stability: Stability, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test all services load.""" _ = needs_finish - from ubo_app.utils.fake import Fake - - class FakeProcess(Fake): - returncode = 0 - - monkeypatch.setattr( - 'asyncio.create_subprocess_exec', - lambda *args, **kwargs: FakeProcess(args, kwargs), - ) from ubo_app.menu import MenuApp app = MenuApp() diff --git a/tests/monkeypatch.py b/tests/monkeypatch.py index 788b2bf2..b102e66e 100644 --- a/tests/monkeypatch.py +++ b/tests/monkeypatch.py @@ -7,6 +7,7 @@ import random import sys import tracemalloc +from typing import cast import pytest @@ -144,7 +145,7 @@ async def json(self: FakeUpdateResponse) -> dict[str, object]: } class FakeAiohttp(Fake): - def get(self: FakeAiohttp, url: str, **kwargs: dict[str, object]) -> Fake: + def get(self: FakeAiohttp, url: str, **kwargs: dict[str, object]) -> object: if url == 'https://pypi.org/pypi/ubo-app/json': return FakeUpdateResponse() parent = super() @@ -164,11 +165,6 @@ def fake_subprocess_run( **kwargs: object, ) -> Fake: _ = args, kwargs - if command in ( - ['/usr/bin/env', 'systemctl', 'is-enabled', 'ssh'], - ['/usr/bin/env', 'systemctl', 'is-active', 'ssh'], - ): - return Fake(stdout='enabled') if command == ['/usr/bin/env', 'systemctl', 'poweroff', '-i']: return Fake() msg = f'Unexpected `subprocess.run` command in test environment: {command}' @@ -177,6 +173,43 @@ def fake_subprocess_run( monkeypatch.setattr(subprocess, 'run', fake_subprocess_run) +def _monkeypatch_asyncio_subprocess(monkeypatch: pytest.MonkeyPatch) -> None: + import asyncio + + from ubo_app.utils.fake import Fake + + class FakeAsyncProcess(Fake): + async def communicate(self: FakeAsyncProcess) -> tuple[bytes, bytes]: + return cast(bytes, self.output), b'' + + async def fake_create_subprocess_exec( + command: str, + *args: list[str], + **kwargs: object, + ) -> FakeAsyncProcess: + _ = kwargs + if command == '/usr/bin/env' and args == ('systemctl', 'is-enabled', 'ssh'): + return FakeAsyncProcess(output=b'enabled') + if command == '/usr/bin/env' and args == ('systemctl', 'is-active', 'ssh'): + return FakeAsyncProcess(output=b'active') + if command == '/usr/bin/env' and args == ('which', 'docker'): + return FakeAsyncProcess(output=b'/bin/docker') + if command == '/usr/bin/env' and args[0] == 'pulseaudio': + return FakeAsyncProcess() + msg = ( + 'Unexpected `asyncio.create_subprocess_exec` command in test ' + f'environment: {command} - {args}' + ) + raise ValueError(msg) + + monkeypatch.setattr(asyncio, 'create_subprocess_exec', fake_create_subprocess_exec) + monkeypatch.setattr( + asyncio, + 'open_unix_connection', + Fake(_Fake__return_value=Fake(_Fake__await_value=(Fake(), Fake()))), + ) + + @pytest.fixture(autouse=True) def _monkeypatch(monkeypatch: pytest.MonkeyPatch) -> None: """Mock external resources.""" @@ -195,6 +228,7 @@ def _monkeypatch(monkeypatch: pytest.MonkeyPatch) -> None: _monkeypatch_aiohttp() _monkeypatch_rpi_modules() _monkeypatch_subprocess(monkeypatch) + _monkeypatch_asyncio_subprocess(monkeypatch) _ = _monkeypatch diff --git a/ubo_app/constants.py b/ubo_app/constants.py index 6044a6e5..e816bdb8 100644 --- a/ubo_app/constants.py +++ b/ubo_app/constants.py @@ -29,5 +29,3 @@ DEBUG_MODE_DOCKER = strtobool(os.environ.get('UBO_DEBUG_DOCKER', 'False')) == 1 DOCKER_PREFIX = os.environ.get('UBO_DOCKER_PREFIX', '') DOCKER_INSTALLATION_LOCK_FILE = Path('/var/run/ubo/docker_installation.lock') - -SENTRY_DSN = os.environ.get('SENTRY_DSN', '') diff --git a/ubo_app/error_handlers.py b/ubo_app/error_handlers.py index 4124d33d..f1bbfae5 100644 --- a/ubo_app/error_handlers.py +++ b/ubo_app/error_handlers.py @@ -6,20 +6,21 @@ import traceback from typing import TYPE_CHECKING -import sentry_sdk - if TYPE_CHECKING: from types import TracebackType def setup_sentry() -> None: # pragma: no cover - from ubo_app.constants import SENTRY_DSN + import os + from asyncio import CancelledError + + import sentry_sdk - if SENTRY_DSN: + if 'SENTRY_DSN' in os.environ: sentry_sdk.init( - dsn=SENTRY_DSN, traces_sample_rate=1.0, profiles_sample_rate=1.0, + ignore_errors=[KeyboardInterrupt, CancelledError], ) diff --git a/ubo_app/services/000-sound/audio_manager.py b/ubo_app/services/000-sound/audio_manager.py index 0353d66e..da2b1722 100644 --- a/ubo_app/services/000-sound/audio_manager.py +++ b/ubo_app/services/000-sound/audio_manager.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio import contextlib import math import time @@ -12,8 +13,10 @@ import pulsectl import pyaudio -from ubo_app.logging import logger +from ubo_app.logging import get_logger +from ubo_app.utils.async_ import create_task +logger = get_logger('ubo-app') CHUNK_SIZE = 1024 @@ -41,29 +44,50 @@ def __init__(self: AudioManager) -> None: self.should_stop = False self.cardindex = None - try: - cards = alsaaudio.cards() - self.cardindex = cards.index( - next(card for card in cards if 'wm8960' in card), - ) - try: - with pulsectl.Pulse('set-default-sink') as pulse: - for sink in pulse.sink_list(): - if str(sink.proplist['alsa.card']) == str(self.cardindex): - pulse.sink_default_set(sink) - break - except pulsectl.PulseError: - logger.error('Not able to connect to pulseaudio') - except StopIteration: - logger.error('No audio card found') + + async def initialize_audio() -> None: + while True: + try: + cards = alsaaudio.cards() + self.cardindex = cards.index( + next(card for card in cards if 'wm8960' in card), + ) + try: + with pulsectl.Pulse('set-default-sink') as pulse: + for sink in pulse.sink_list(): + if 'alsa.card' in sink.proplist and str( + sink.proplist['alsa.card'], + ) == str(self.cardindex): + pulse.sink_default_set(sink) + break + except pulsectl.PulseError: + logger.exception('Not able to connect to pulseaudio') + except StopIteration: + logger.exception('No audio card found') + process = await asyncio.create_subprocess_exec( + '/usr/bin/env', + 'pulseaudio', + '--kill', + ) + await process.wait() + process = await asyncio.create_subprocess_exec( + '/usr/bin/env', + 'pulseaudio', + '--start', + ) + await process.wait() + + await asyncio.sleep(5) + + create_task(initialize_audio()) def find_respeaker_index(self: AudioManager) -> int: """Find the index of the ReSpeaker device.""" for index in range(self.pyaudio.get_device_count()): info = self.pyaudio.get_device_info_by_index(index) if not isinstance(info['name'], (int, float)) and 'wm8960' in info['name']: - logger.debug(f'ReSpeaker found at index: {index}') - logger.debug(f'Device Info: {info}') + logger.debug('ReSpeaker found at index', extra={'index': index}) + logger.debug('Device Info', extra={'info': info}) return index msg = 'ReSpeaker for default device not found' raise ValueError(msg) @@ -111,11 +135,8 @@ def play(self: AudioManager, filename: str) -> None: while data and not self.should_stop and stream.is_active(): stream.write(data) data = wf.readframes(CHUNK_SIZE) - except Exception as exception: # noqa: BLE001 - logger.error( - 'Something went wrong while playing an audio file', - exc_info=exception, - ) + except Exception: + logger.exception('Something went wrong while playing an audio file') finally: self.is_playing = False self.close_stream() diff --git a/ubo_app/services/040-rgb-ring/rgb_ring_client.py b/ubo_app/services/040-rgb-ring/rgb_ring_client.py index fbe20371..d7d2ee2e 100644 --- a/ubo_app/services/040-rgb-ring/rgb_ring_client.py +++ b/ubo_app/services/040-rgb-ring/rgb_ring_client.py @@ -24,9 +24,9 @@ class RgbRingClient: manner. """ - def send(self: RgbRingClient, cmd: Sequence[str]) -> None: + async def send(self: RgbRingClient, cmd: Sequence[str]) -> None: try: - send_command(' '.join(['led', *cmd])) + await send_command(' '.join(['led', *cmd])) dispatch(RgbRingSetIsConnectedAction(is_connected=True)) except Exception as exception: # noqa: BLE001 dispatch(RgbRingSetIsConnectedAction(is_connected=False)) diff --git a/ubo_app/services/040-rgb-ring/setup.py b/ubo_app/services/040-rgb-ring/setup.py index acc2f896..2e2a0a75 100644 --- a/ubo_app/services/040-rgb-ring/setup.py +++ b/ubo_app/services/040-rgb-ring/setup.py @@ -8,8 +8,8 @@ def init_service() -> None: rgb_ring_client = RgbRingClient() - def handle_rgb_ring_command(event: RgbRingCommandEvent) -> None: - rgb_ring_client.send(event.command) + async def handle_rgb_ring_command(event: RgbRingCommandEvent) -> None: + await rgb_ring_client.send(event.command) subscribe_event(RgbRingCommandEvent, handle_rgb_ring_command) diff --git a/ubo_app/services/050-lightdm/reducer.py b/ubo_app/services/050-lightdm/reducer.py new file mode 100644 index 00000000..b5746ad5 --- /dev/null +++ b/ubo_app/services/050-lightdm/reducer.py @@ -0,0 +1,34 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107, N999 +from __future__ import annotations + +from dataclasses import replace + +from redux import InitAction, InitializationActionError + +from ubo_app.store.services.lightdm import ( + LightDMAction, + LightDMClearEnabledStateAction, + LightDMState, + LightDMUpdateStateAction, +) + + +def reducer( + state: LightDMState | None, + action: LightDMAction | InitAction, +) -> LightDMState: + if state is None: + if isinstance(action, InitAction): + return LightDMState(is_active=False, is_enabled=False) + raise InitializationActionError(action) + + if isinstance(action, LightDMClearEnabledStateAction): + return replace(state, is_enabled=None) + + if isinstance(action, LightDMUpdateStateAction): + if action.is_active is not None: + state = replace(state, is_active=action.is_active) + if action.is_enabled is not None: + state = replace(state, is_enabled=action.is_enabled) + return state + return state diff --git a/ubo_app/services/050-lightdm/setup.py b/ubo_app/services/050-lightdm/setup.py new file mode 100644 index 00000000..73009c6b --- /dev/null +++ b/ubo_app/services/050-lightdm/setup.py @@ -0,0 +1,162 @@ +"""LightDM service module.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Sequence + +from ubo_gui.menu.types import ActionItem, HeadlessMenu, Item, Menu + +from ubo_app.store import autorun, dispatch +from ubo_app.store.main import RegisterSettingAppAction +from ubo_app.store.services.lightdm import ( + LightDMClearEnabledStateAction, + LightDMUpdateStateAction, +) +from ubo_app.utils.async_ import create_task +from ubo_app.utils.monitor_unit import is_unit_active, is_unit_enabled, monitor_unit +from ubo_app.utils.server import send_command + +if TYPE_CHECKING: + from ubo_app.store.services.lightdm import LightDMState + + +def start_lightdm_service() -> None: + """Start the LightDM service.""" + + async def act() -> None: + await send_command('service lightdm start') + + create_task(act()) + + +def stop_lightdm_service() -> None: + """Stop the LightDM service.""" + + async def act() -> None: + await send_command('service lightdm stop') + + create_task(act()) + + +def enable_lightdm_service() -> None: + """Enable the LightDM service.""" + + async def act() -> None: + dispatch(LightDMClearEnabledStateAction()) + await send_command('service lightdm enable') + await asyncio.sleep(5) + await check_is_lightdm_enabled() + + create_task(act()) + + +def disable_lightdm_service() -> None: + """Disable the LightDM service.""" + + async def act() -> None: + dispatch(LightDMClearEnabledStateAction()) + await send_command('service lightdm disable') + await asyncio.sleep(5) + await check_is_lightdm_enabled() + + create_task(act()) + + +@autorun(lambda state: state.lightdm) +def lightdm_items(state: LightDMState) -> Sequence[Item]: + """Get the LightDM menu items.""" + return [ + ActionItem( + label='Stop' if state.is_active else 'Start', + icon='󰓛' if state.is_active else '󰐊', + action=stop_lightdm_service if state.is_active else start_lightdm_service, + ), + Item( + label='...', + icon='', + ) + if state.is_enabled is None + else ActionItem( + label='Disable', + icon='[color=#008000]󰯄[/color]', + action=disable_lightdm_service, + ) + if state.is_enabled + else ActionItem( + label='Enable', + icon='[color=#ffff00]󰯅[/color]', + action=enable_lightdm_service, + ), + ] + + +@autorun(lambda state: state.lightdm) +def lightdm_icon(state: LightDMState) -> str: + """Get the LightDM icon.""" + return '[color=#008000]󰪥[/color]' if state.is_active else '[color=#ffff00]󰝦[/color]' + + +@autorun(lambda state: state.lightdm) +def lightdm_title(_: LightDMState) -> str: + """Get the LightDM title.""" + return lightdm_icon() + ' LightDM' + + +async def check_is_lightdm_active() -> None: + """Check if the LightDM service is active.""" + if is_unit_active('lightdm'): + dispatch(LightDMUpdateStateAction(is_enabled=True)) + else: + dispatch(LightDMUpdateStateAction(is_enabled=False)) + + +async def check_is_lightdm_enabled() -> None: + """Check if the LightDM service is enabled.""" + if is_unit_enabled('lightdm'): + dispatch(LightDMUpdateStateAction(is_enabled=True)) + else: + dispatch(LightDMUpdateStateAction(is_enabled=False)) + + +def open_lightdm_menu() -> Menu: + """Open the LightDM menu.""" + create_task( + asyncio.gather( + check_is_lightdm_active(), + check_is_lightdm_enabled(), + ), + ) + + return HeadlessMenu( + title=lightdm_title, + items=lightdm_items, + ) + + +def init_service() -> None: + """Initialize the LightDM service.""" + dispatch( + RegisterSettingAppAction( + menu_item=ActionItem( + label='LightDM', + icon=lightdm_icon, + action=open_lightdm_menu, + ), + ), + ) + + create_task( + asyncio.gather( + check_is_lightdm_active(), + check_is_lightdm_enabled(), + monitor_unit( + 'lightdm.service', + lambda status: dispatch( + LightDMUpdateStateAction( + is_active=status in ('active', 'activating', 'reloading'), + ), + ), + ), + ), + ) diff --git a/ubo_app/services/050-lightdm/ubo_handle.py b/ubo_app/services/050-lightdm/ubo_handle.py new file mode 100644 index 00000000..e334dff8 --- /dev/null +++ b/ubo_app/services/050-lightdm/ubo_handle.py @@ -0,0 +1,23 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107, N999 +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ubo_app.services import Service, register + + +def setup(service: Service) -> None: + from reducer import reducer + from setup import init_service + + service.register_reducer(reducer) + + init_service() + + +register( + service_id='lightdm', + label='LightDM', + setup=setup, +) diff --git a/ubo_app/services/050-ssh/reducer.py b/ubo_app/services/050-ssh/reducer.py index 52d88b4c..3adca1b0 100644 --- a/ubo_app/services/050-ssh/reducer.py +++ b/ubo_app/services/050-ssh/reducer.py @@ -5,7 +5,12 @@ from redux import InitAction, InitializationActionError -from ubo_app.store.services.ssh import SSHAction, SSHState, SSHUpdateStateAction +from ubo_app.store.services.ssh import ( + SSHAction, + SSHClearEnabledStateAction, + SSHState, + SSHUpdateStateAction, +) def reducer( @@ -17,6 +22,9 @@ def reducer( return SSHState(is_active=False, is_enabled=False) raise InitializationActionError(action) + if isinstance(action, SSHClearEnabledStateAction): + return replace(state, is_enabled=None) + if isinstance(action, SSHUpdateStateAction): if action.is_active is not None: state = replace(state, is_active=action.is_active) diff --git a/ubo_app/services/050-ssh/setup.py b/ubo_app/services/050-ssh/setup.py index 502f2340..8a434035 100644 --- a/ubo_app/services/050-ssh/setup.py +++ b/ubo_app/services/050-ssh/setup.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +import asyncio from typing import TYPE_CHECKING, Sequence from ubo_gui.menu.types import ( @@ -11,6 +11,7 @@ HeadedMenu, HeadlessMenu, Item, + Menu, SubMenuItem, ) from ubo_gui.prompt import PromptWidget @@ -23,8 +24,9 @@ NotificationDisplayType, NotificationsAddAction, ) -from ubo_app.store.services.ssh import SSHUpdateStateAction +from ubo_app.store.services.ssh import SSHClearEnabledStateAction, SSHUpdateStateAction from ubo_app.utils.async_ import create_task +from ubo_app.utils.monitor_unit import is_unit_active, is_unit_enabled, monitor_unit from ubo_app.utils.server import send_command if TYPE_CHECKING: @@ -40,19 +42,23 @@ def first_option_callback(self: ClearTemporaryUsersPrompt) -> None: def second_option_callback(self: ClearTemporaryUsersPrompt) -> None: """Close the prompt.""" - send_command('ssh clear_all_temporary_accounts') - self.dispatch('on_close') - dispatch( - NotificationsAddAction( - notification=Notification( - title='All SSH Accounts Removed', - content='All SSH accounts have been removed.', - importance=Importance.MEDIUM, - icon='󰣀', - display_type=NotificationDisplayType.FLASH, + + async def act() -> None: + await send_command('service ssh clear_all_temporary_accounts') + self.dispatch('on_close') + dispatch( + NotificationsAddAction( + notification=Notification( + title='All SSH Accounts Removed', + content='All SSH accounts have been removed.', + importance=Importance.MEDIUM, + icon='󰣀', + display_type=NotificationDisplayType.FLASH, + ), ), - ), - ) + ) + + create_task(act()) def __init__(self: ClearTemporaryUsersPrompt, **kwargs: object) -> None: """Initialize the prompt.""" @@ -69,43 +75,73 @@ def __init__(self: ClearTemporaryUsersPrompt, **kwargs: object) -> None: def create_ssh_account() -> None: """Create a temporary SSH account.""" - result = send_command('ssh create_temporary_ssh_account', has_output=True) - username, password = result.split(':') - dispatch( - NotificationsAddAction( - notification=Notification( - title='Temporary SSH Account Created', - content=f"""Username: {username} + + async def act() -> None: + result = await send_command( + 'service ssh create_temporary_ssh_account', + has_output=True, + ) + username, password = result.split(':') + dispatch( + NotificationsAddAction( + notification=Notification( + title='Temporary SSH Account Created', + content=f"""Username: {username} Password: {password} Make sure to delete it after use. Note that in order to make things work for you, we \ had to make sure password authentication for ssh server is enabled, you may want to \ -disable it later.""", - importance=Importance.MEDIUM, - icon='󰣀', - display_type=NotificationDisplayType.STICKY, +disable it later. Clearing all temporary users will disable password authentication \ +too.""", + importance=Importance.MEDIUM, + icon='󰣀', + display_type=NotificationDisplayType.STICKY, + ), ), - ), - ) + ) + + create_task(act()) def start_ssh_service() -> None: """Start the SSH service.""" - send_command('ssh start') + + async def act() -> None: + await send_command('service ssh start') + + create_task(act()) def stop_ssh_service() -> None: """Stop the SSH service.""" - send_command('ssh stop') + + async def act() -> None: + await send_command('service ssh stop') + + create_task(act()) def enable_ssh_service() -> None: """Enable the SSH service.""" - send_command('ssh enable') + + async def act() -> None: + dispatch(SSHClearEnabledStateAction()) + await send_command('service ssh enable') + await asyncio.sleep(5) + await check_is_ssh_enabled() + + create_task(act()) def disable_ssh_service() -> None: """Disable the SSH service.""" - send_command('ssh disable') + + async def act() -> None: + dispatch(SSHClearEnabledStateAction()) + await send_command('service ssh disable') + await asyncio.sleep(5) + await check_is_ssh_enabled() + + create_task(act()) @autorun(lambda state: state.ssh) @@ -139,102 +175,91 @@ def ssh_items(state: SSHState) -> Sequence[Item]: icon='󰓛' if state.is_active else '󰐊', action=stop_ssh_service if state.is_active else start_ssh_service, ), - ActionItem( - label='Disable' if state.is_enabled else 'Enable', - icon='[color=#008000]󰯄[/color]' - if state.is_enabled - else '[color=#ffff00]󰯅[/color]', - action=disable_ssh_service if state.is_enabled else enable_ssh_service, + Item( + label='...', + icon='', + ) + if state.is_enabled is None + else ActionItem( + label='Disable', + icon='[color=#008000]󰯄[/color]', + action=disable_ssh_service, + ) + if state.is_enabled + else ActionItem( + label='Enable', + icon='[color=#ffff00]󰯅[/color]', + action=enable_ssh_service, ), ] @autorun(lambda state: state.ssh) -def ssh_title(state: SSHState) -> str: +def ssh_icon(state: SSHState) -> str: + """Get the SSH icon.""" + return '[color=#008000]󰪥[/color]' if state.is_active else '[color=#ffff00]󰝦[/color]' + + +@autorun(lambda state: state.ssh) +def ssh_title(_: SSHState) -> str: """Get the SSH title.""" - return ( - 'SSH [color=#008000]󰧞[/color]' - if state.is_active - else 'SSH [color=#ffff00]󱃓[/color]' - ) + return ssh_icon() + ' SSH' -def check_is_ssh_active() -> None: +async def check_is_ssh_active() -> None: """Check if the SSH service is active.""" - result = subprocess.run( - ['/usr/bin/env', 'systemctl', 'is-active', 'ssh'], # noqa: S603 - capture_output=True, - text=True, - check=False, - ) - if result.stdout.strip() == 'active': - dispatch(SSHUpdateStateAction(is_active=True)) + if is_unit_active('sshd'): + dispatch(SSHUpdateStateAction(is_enabled=True)) else: - dispatch(SSHUpdateStateAction(is_active=False)) + dispatch(SSHUpdateStateAction(is_enabled=False)) -def check_is_ssh_enabled() -> None: +async def check_is_ssh_enabled() -> None: """Check if the SSH service is enabled.""" - result = subprocess.run( - ['/usr/bin/env', 'systemctl', 'is-enabled', 'ssh'], # noqa: S603 - capture_output=True, - text=True, - check=False, - ) - if result.stdout.strip() == 'enabled': + if is_unit_enabled('sshd'): dispatch(SSHUpdateStateAction(is_enabled=True)) else: dispatch(SSHUpdateStateAction(is_enabled=False)) -async def monitor_ssh_service() -> None: - """Monitor the SSH service.""" - from cysystemd.async_reader import ( # pyright: ignore[reportMissingImports] - AsyncJournalReader, - ) - from cysystemd.reader import ( # pyright: ignore[reportMissingImports] - JournalOpenMode, - Rule, +def open_ssh_menu() -> Menu: + """Open the SSH menu.""" + create_task( + asyncio.gather( + check_is_ssh_active(), + check_is_ssh_enabled(), + ), ) - reader = AsyncJournalReader() - await reader.open(JournalOpenMode.SYSTEM) - await reader.add_filter(Rule('_SYSTEMD_UNIT', 'init.scope')) - await reader.seek_tail() - - check_is_ssh_enabled() - check_is_ssh_active() - - while await reader.wait(): - async for record in reader: - if 'MESSAGE' in record.data: - if 'UNIT' in record.data and record.data['UNIT'] == 'ssh.service': - if ( - 'Started ssh.service - OpenBSD Secure Shell server' - in record.data['MESSAGE'] - ): - dispatch(SSHUpdateStateAction(is_active=True)) - elif ( - 'Stopped ssh.service - OpenBSD Secure Shell server' - in record.data['MESSAGE'] - ): - dispatch(SSHUpdateStateAction(is_active=False)) - elif record.data['MESSAGE'] == 'Reloading.': - check_is_ssh_enabled() + return HeadlessMenu( + title=ssh_title, + items=ssh_items, + ) def init_service() -> None: """Initialize the SSH service.""" dispatch( RegisterSettingAppAction( - menu_item=SubMenuItem( + menu_item=ActionItem( label='SSH', - icon='󰣀', - sub_menu=HeadlessMenu( - title=ssh_title, - items=ssh_items, + icon=ssh_icon, + action=open_ssh_menu, + ), + ), + ) + + create_task( + asyncio.gather( + check_is_ssh_active(), + check_is_ssh_enabled(), + monitor_unit( + 'ssh.service', + lambda status: dispatch( + SSHUpdateStateAction( + is_active=status in ('active', 'activating', 'reloading'), + ), ), ), ), ) - create_task(monitor_ssh_service()) diff --git a/ubo_app/services/080-docker/setup.py b/ubo_app/services/080-docker/setup.py index 351c67c6..a66dff49 100644 --- a/ubo_app/services/080-docker/setup.py +++ b/ubo_app/services/080-docker/setup.py @@ -29,24 +29,33 @@ def install_docker() -> None: """Install Docker.""" - dispatch(DockerSetStatusAction(status=DockerStatus.INSTALLING)) - if Path(SERVER_SOCKET_PATH).exists(): - send_command('docker install') + + async def act() -> None: + await send_command('docker install') + dispatch(DockerSetStatusAction(status=DockerStatus.INSTALLING)) + + create_task(act()) def run_docker() -> None: """Install Docker.""" - send_command('docker start') - dispatch(DockerSetStatusAction(status=DockerStatus.UNKNOWN)) + async def act() -> None: + await send_command('docker start') + dispatch(DockerSetStatusAction(status=DockerStatus.UNKNOWN)) + + create_task(act()) def stop_docker() -> None: """Install Docker.""" - send_command('docker stop') - dispatch(DockerSetStatusAction(status=DockerStatus.UNKNOWN)) + async def act() -> None: + await send_command('docker stop') + dispatch(DockerSetStatusAction(status=DockerStatus.UNKNOWN)) + + create_task(act()) async def check_docker() -> None: diff --git a/ubo_app/setup.py b/ubo_app/setup.py index 27d136aa..dcef6031 100644 --- a/ubo_app/setup.py +++ b/ubo_app/setup.py @@ -17,5 +17,3 @@ def setup() -> None: sys.modules['sdbus_async'] = Fake() sys.modules['sdbus_async.networkmanager'] = Fake() sys.modules['sdbus_async.networkmanager.enums'] = Fake() - sys.modules['cysystemd.async_reader'] = Fake() - sys.modules['cysystemd.reader'] = Fake() diff --git a/ubo_app/store/__init__.py b/ubo_app/store/__init__.py index a65d5861..254b0d75 100644 --- a/ubo_app/store/__init__.py +++ b/ubo_app/store/__init__.py @@ -24,6 +24,7 @@ from ubo_app.store.services.docker import DockerAction, DockerState from ubo_app.store.services.ip import IpAction, IpEvent, IpState from ubo_app.store.services.keypad import KeypadEvent +from ubo_app.store.services.lightdm import LightDMAction, LightDMState from ubo_app.store.services.notifications import NotificationsAction, NotificationsState from ubo_app.store.services.rgb_ring import RgbRingAction from ubo_app.store.services.sensors import SensorsAction, SensorsState @@ -49,6 +50,7 @@ def scheduler(callback: Callable[[], None], *, interval: bool) -> None: class RootState(BaseCombineReducerState): main: MainState + lightdm: LightDMState status_icons: StatusIconsState update_manager: UpdateManagerState sensors: SensorsState @@ -65,6 +67,7 @@ class RootState(BaseCombineReducerState): | StatusIconsAction | UpdateManagerAction | MainAction + | LightDMAction | SensorsAction | SSHAction | SoundAction diff --git a/ubo_app/store/main/__init__.py b/ubo_app/store/main/__init__.py index f997a918..50e6ea62 100644 --- a/ubo_app/store/main/__init__.py +++ b/ubo_app/store/main/__init__.py @@ -22,6 +22,11 @@ class RegisterAppAction(BaseAction): menu_item: Item +class UpdateLightDMState(BaseAction): + is_active: bool + is_enable: bool + + class RegisterRegularAppAction(RegisterAppAction): ... diff --git a/ubo_app/store/services/lightdm.py b/ubo_app/store/services/lightdm.py new file mode 100644 index 00000000..d198cca3 --- /dev/null +++ b/ubo_app/store/services/lightdm.py @@ -0,0 +1,21 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107 +from __future__ import annotations + +from immutable import Immutable +from redux import BaseAction + + +class LightDMAction(BaseAction): ... + + +class LightDMUpdateStateAction(LightDMAction): + is_active: bool | None = None + is_enabled: bool | None = None + + +class LightDMClearEnabledStateAction(LightDMAction): ... + + +class LightDMState(Immutable): + is_active: bool = False + is_enabled: bool | None = None diff --git a/ubo_app/store/services/ssh.py b/ubo_app/store/services/ssh.py index b466dc49..fe7f681c 100644 --- a/ubo_app/store/services/ssh.py +++ b/ubo_app/store/services/ssh.py @@ -13,6 +13,9 @@ class SSHUpdateStateAction(SSHAction): is_enabled: bool | None = None +class SSHClearEnabledStateAction(SSHAction): ... + + class SSHState(Immutable): is_active: bool = False - is_enabled: bool = False + is_enabled: bool | None = None diff --git a/ubo_app/system/bootstrap.py b/ubo_app/system/bootstrap.py index 59e25357..c0efa7f4 100644 --- a/ubo_app/system/bootstrap.py +++ b/ubo_app/system/bootstrap.py @@ -26,7 +26,7 @@ class Service(TypedDict): scope: Literal['system', 'user'] -services: list[Service] = [ +SERVICES: list[Service] = [ { 'name': 'ubo-system', 'template': 'system', @@ -135,7 +135,7 @@ def reload_daemon() -> None: def enable_services() -> None: """Enable the services to start on boot.""" - for service in services: + for service in SERVICES: # Enable the service to start on boot if service['scope'] == 'user': subprocess.run( @@ -213,7 +213,7 @@ def bootstrap(*, with_docker: bool = False, for_packer: bool = False) -> None: create_user_service_directory() - for service in services: + for service in SERVICES: create_service_file(service) if for_packer: diff --git a/ubo_app/system/install.sh b/ubo_app/system/install.sh index 79e826ce..24bc5e42 100755 --- a/ubo_app/system/install.sh +++ b/ubo_app/system/install.sh @@ -72,16 +72,13 @@ echo "User $USERNAME created successfully." # Install required packages apt-get -y update apt-get -y upgrade -apt-get -y remove orca || true apt-get -y install \ - build-essential \ git \ i2c-tools \ libcap-dev \ libegl1 \ libgl1 \ libmtdev1 \ - libsystemd-dev \ libzbar0 \ python3-dev \ python3-libcamera \ diff --git a/ubo_app/system/system_manager/clear_all_temporary_accounts.sh b/ubo_app/system/system_manager/clear_all_temporary_accounts.sh index 78972e16..4c8616d0 100755 --- a/ubo_app/system/system_manager/clear_all_temporary_accounts.sh +++ b/ubo_app/system/system_manager/clear_all_temporary_accounts.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -set -e -o errexit +set -o errexit +set -o pipefail +set -o nounset # Check for root privileges if [ "$(id -u)" != "0" ]; then @@ -16,3 +18,7 @@ for user in $(awk -F: '($3 >= 1000) && ($3 != 65534) {print $1}' /etc/passwd); d rm -f /etc/sudoers.d/$user fi done + +# Disable password authentication +sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config +systemctl restart sshd diff --git a/ubo_app/system/system_manager/create_temporary_ssh_account.sh b/ubo_app/system/system_manager/create_temporary_ssh_account.sh index 68ed3619..384884c5 100755 --- a/ubo_app/system/system_manager/create_temporary_ssh_account.sh +++ b/ubo_app/system/system_manager/create_temporary_ssh_account.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -set -e -o errexit +set -o errexit +set -o pipefail +set -o nounset # Check for root privileges if [ "$(id -u)" != "0" ]; then @@ -23,7 +25,7 @@ done useradd -m -s /bin/bash $USERNAME # Set the password -PASSWORD=$(openssl rand -base64 8) +PASSWORD=$(openssl rand -base64 6) echo "${USERNAME}:${PASSWORD}" | chpasswd printf "${USERNAME}:${PASSWORD}" diff --git a/ubo_app/system/system_manager/led.py b/ubo_app/system/system_manager/led.py index 8d3f99ee..f438b03a 100644 --- a/ubo_app/system/system_manager/led.py +++ b/ubo_app/system/system_manager/led.py @@ -23,7 +23,7 @@ class LEDManager: def __init__(self: LEDManager) -> None: - self.logger = get_logger('led-manager') + self.logger = get_logger('system-manager') self._last_thread = None add_file_handler(self.logger, logging.DEBUG) add_stdout_handler(self.logger, logging.DEBUG) diff --git a/ubo_app/system/system_manager/main.py b/ubo_app/system/system_manager/main.py index 8a7efbae..a976a007 100644 --- a/ubo_app/system/system_manager/main.py +++ b/ubo_app/system/system_manager/main.py @@ -12,10 +12,11 @@ from threading import Thread from ubo_app.constants import USERNAME +from ubo_app.error_handlers import setup_error_handling from ubo_app.logging import add_file_handler, add_stdout_handler, get_logger from ubo_app.system.system_manager.docker import docker_handler from ubo_app.system.system_manager.led import LEDManager -from ubo_app.system.system_manager.ssh import ssh_handler +from ubo_app.system.system_manager.service_manager import system_handler SOCKET_PATH = Path(os.environ.get('RUNTIME_DIRECTORY', '/run/ubo')).joinpath( 'system_manager.sock', @@ -36,13 +37,14 @@ def handle_command(command: str) -> str | None: elif header == 'docker': thread = Thread(target=docker_handler, args=(incoming[0],)) thread.start() - elif header == 'ssh': - return ssh_handler(incoming[0]) + elif header == 'service': + return system_handler(incoming[0], incoming[1]) return None def main() -> None: """Initialise the System-Manager.""" + setup_error_handling() logger.debug('Initialising System-Manager...') led_manager.run_command_thread_safe('spinning_wheel 255 255 255 50 6 100'.split()) diff --git a/ubo_app/system/system_manager/service_manager.py b/ubo_app/system/system_manager/service_manager.py new file mode 100644 index 00000000..4438aad9 --- /dev/null +++ b/ubo_app/system/system_manager/service_manager.py @@ -0,0 +1,80 @@ +"""provides a function to interact with system services.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from ubo_app.logging import get_logger + + +def ssh_handler(command: str) -> str | None: + """Handle ssh commands.""" + if command == 'create_temporary_ssh_account': + result = subprocess.run( + Path(__file__).parent.joinpath('create_temporary_ssh_account.sh'), # noqa: S603 + check=True, + text=True, + stdout=subprocess.PIPE, + ) + result.check_returncode() + return result.stdout + if command == 'clear_all_temporary_accounts': + subprocess.run( + Path(__file__).parent.joinpath('clear_all_temporary_accounts.sh'), # noqa: S603 + check=False, + ) + if command == 'start': + subprocess.run( + ['/usr/bin/env', 'systemctl', 'start', 'sshd'], # noqa: S603 + check=True, + ) + if command == 'stop': + subprocess.run( + ['/usr/bin/env', 'systemctl', 'stop', 'sshd'], # noqa: S603 + check=True, + ) + if command == 'enable': + subprocess.run( + ['/usr/bin/env', 'systemctl', 'enable', 'sshd'], # noqa: S603 + check=True, + ) + if command == 'disable': + subprocess.run( + ['/usr/bin/env', 'systemctl', 'disable', 'sshd'], # noqa: S603 + check=True, + ) + msg = f'Invalid ssh command "{command}"' + raise ValueError(msg) + + +def system_handler(service: str, command: str) -> str | None: + """Interact with system services.""" + logger = get_logger('system-manager') + try: + if service == 'ssh': + return ssh_handler(command) + if service == 'lightdm': + if command == 'start': + subprocess.run( + ['/usr/bin/env', 'systemctl', 'start', 'lightdm'], # noqa: S603 + check=True, + ) + elif command == 'stop': + subprocess.run( + ['/usr/bin/env', 'systemctl', 'stop', 'lightdm'], # noqa: S603 + check=True, + ) + elif command == 'enable': + subprocess.run( + ['/usr/bin/env', 'systemctl', 'enable', 'lightdm'], # noqa: S603 + check=True, + ) + elif command == 'disable': + subprocess.run( + ['/usr/bin/env', 'systemctl', 'disable', 'lightdm'], # noqa: S603 + check=True, + ) + except Exception: + logger.exception('Failed to handle SSH command.') + return None diff --git a/ubo_app/system/system_manager/ssh.py b/ubo_app/system/system_manager/ssh.py deleted file mode 100644 index aa356a31..00000000 --- a/ubo_app/system/system_manager/ssh.py +++ /dev/null @@ -1,45 +0,0 @@ -"""provides a function to install and start Docker on the host machine.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def ssh_handler(command: str) -> str | None: - """Install and start Docker on the host machine.""" - if command == 'create_temporary_ssh_account': - result = subprocess.run( - Path(__file__).parent.joinpath('create_temporary_ssh_account.sh'), # noqa: S603 - check=True, - text=True, - stdout=subprocess.PIPE, - ) - result.check_returncode() - return result.stdout - if command == 'clear_all_temporary_accounts': - subprocess.run( - Path(__file__).parent.joinpath('clear_all_temporary_accounts.sh'), # noqa: S603 - check=False, - ) - if command == 'start': - subprocess.run( - ['/usr/bin/env', 'systemctl', 'start', 'ssh'], # noqa: S603 - check=True, - ) - if command == 'stop': - subprocess.run( - ['/usr/bin/env', 'systemctl', 'stop', 'ssh'], # noqa: S603 - check=True, - ) - if command == 'enable': - subprocess.run( - ['/usr/bin/env', 'systemctl', 'enable', 'ssh'], # noqa: S603 - check=True, - ) - if command == 'disable': - subprocess.run( - ['/usr/bin/env', 'systemctl', 'disable', 'ssh'], # noqa: S603 - check=True, - ) - return None diff --git a/ubo_app/utils/async_.py b/ubo_app/utils/async_.py index 79568347..abcb1421 100644 --- a/ubo_app/utils/async_.py +++ b/ubo_app/utils/async_.py @@ -6,8 +6,6 @@ from typing_extensions import TypeVar -from ubo_app.logging import logger - if TYPE_CHECKING: from asyncio import Future, Handle @@ -23,6 +21,9 @@ def create_task( ) -> Handle: async def wrapper() -> None: from ubo_app.load_services import UboServiceThread + from ubo_app.logging import get_logger + + logger = get_logger('ubo-app') if awaitable is None: return @@ -39,10 +40,10 @@ async def wrapper() -> None: }, ) await awaitable - except BaseException as exception: # noqa: BLE001 + except Exception: thread = current_thread() logger.exception( - exception, + 'Task failed', extra={ 'awaitable': awaitable, **( diff --git a/ubo_app/utils/fake.py b/ubo_app/utils/fake.py index 38771b5d..68e144e5 100644 --- a/ubo_app/utils/fake.py +++ b/ubo_app/utils/fake.py @@ -11,13 +11,26 @@ class Fake(ModuleType): def __init__( self: Fake, *args: object, + __return_value: object | None = None, + __await_value: object | None = None, __props: dict[str, object] | None = None, **kwargs: object, ) -> None: - logger.verbose('Initializing `Fake`', extra={'args_': args, 'kwargs': kwargs}) + logger.verbose( + 'Initializing `Fake`', + extra={ + 'args_': args, + 'kwargs': kwargs, + '__return_value': __return_value, + '__await_value': __await_value, + '__props': __props, + }, + ) if __props is not None: for key, value in __props.items(): super().__setattr__(key, value) + self.__return_value = __return_value + self.__await_value = __await_value self.iterated = False super().__init__('') @@ -47,16 +60,20 @@ def __getitem__(self: Fake, key: object) -> Fake: ) return self - def __call__(self: Fake, *args: object, **kwargs: dict[str, Any]) -> Fake: + def __call__(self: Fake, *args: object, **kwargs: dict[str, Any]) -> object: logger.verbose( 'Calling a `Fake` instance', - extra={'args_': args, 'kwargs': kwargs}, + extra={ + 'args_': args, + 'kwargs': kwargs, + '__return_value': self.__return_value, + }, ) - return self + return self.__return_value or self - def __await__(self: Fake) -> Generator[Fake | None, Any, Any]: + def __await__(self: Fake) -> Generator[Any, Any, object]: yield - return Fake() + return self.__await_value or Fake() def __next__(self: Fake) -> Fake: if self.iterated: diff --git a/ubo_app/utils/monitor_unit.py b/ubo_app/utils/monitor_unit.py index b9a8b2c3..87e772ac 100644 --- a/ubo_app/utils/monitor_unit.py +++ b/ubo_app/utils/monitor_unit.py @@ -2,6 +2,8 @@ # ruff: noqa: D100, D101, D102, D103, D104, D105, D107 from __future__ import annotations +import asyncio +import subprocess from typing import Callable from sdbus import DbusInterfaceCommonAsync, dbus_property_async @@ -30,6 +32,7 @@ def to_dbus_string(string: str) -> str: async def monitor_unit(unit_name: str, callback: Callable[[str], None]) -> None: + """Monitor the active state of a systemd unit.""" bus = get_system_bus() system_service = SystemdUnitInterface.new_proxy( bus=bus, @@ -40,3 +43,29 @@ async def monitor_unit(unit_name: str, callback: Callable[[str], None]) -> None: async for _ in system_service.properties_changed: active_state = await system_service.active_state callback(active_state) + + +async def is_unit_active(unit: str) -> bool: + """Check if the systemd unit is active.""" + process = await asyncio.create_subprocess_exec( + '/usr/bin/env', + 'systemctl', + 'is-active', + unit, + stdout=subprocess.PIPE, + ) + stdout, _ = await process.communicate() + return stdout.strip() == b'active' + + +async def is_unit_enabled(unit: str) -> bool: + """Check if the systemd unit is enabled.""" + process = await asyncio.create_subprocess_exec( + '/usr/bin/env', + 'systemctl', + 'is-enabled', + unit, + stdout=subprocess.PIPE, + ) + stdout, _ = await process.communicate() + return stdout.strip() == b'enabled' diff --git a/ubo_app/utils/server.py b/ubo_app/utils/server.py index 7d80b5ba..232ea656 100644 --- a/ubo_app/utils/server.py +++ b/ubo_app/utils/server.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import threading from typing import Literal, overload @@ -12,40 +13,31 @@ @overload -def send_command(command: str) -> None: ... +async def send_command(command: str) -> None: ... @overload -def send_command(command: str, *, has_output: Literal[True]) -> str: ... +async def send_command(command: str, *, has_output: Literal[True]) -> str: ... -def send_command(command: str, *, has_output: bool = False) -> str | None: +async def send_command(command: str, *, has_output: bool = False) -> str | None: """Send a command to the system manager socket.""" - import socket - - client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - client.connect(SERVER_SOCKET_PATH) - except Exception as exception: # noqa: BLE001 - logger.error('Unable to connect to the socket', exc_info=exception) - if has_output: - return '' - return None - - output = None + reader, writer = await asyncio.open_unix_connection(SERVER_SOCKET_PATH) + + logger.debug('Sending command:', extra={'command': command}) + + response = None with thread_lock: - client.sendall(f'{command}'.encode() + b'\0') - remaining = b'' + writer.write(f'{command}'.encode() + b'\0') while has_output: - datagram = remaining + client.recv(1024) + datagram = (await reader.readuntil(b'\0'))[:-1] if not datagram: break - if b'\0' not in datagram: - remaining = datagram - continue - response, remaining = datagram.split(b'\0', 1) - output = response.decode('utf-8') + response = datagram.decode('utf-8') logger.debug('Server response:', extra={'response': response}) - client.close() + writer.close() + await writer.wait_closed() + + logger.debug('Received response:', extra={'response': response}) - return output + return response