diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 401986f..ad3d2a2 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -6,13 +6,10 @@ on: branches: - main workflow_dispatch: -env: - IMAGE_NAME: ublue-update - IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }} jobs: push-ghcr: - name: Build and push image + name: Build and test image runs-on: ubuntu-24.04 permissions: contents: read @@ -34,47 +31,6 @@ jobs: - name: Checkout Push to Registry action uses: actions/checkout@v4 - - name: Generate tags - id: generate-tags - shell: bash - run: | - # Generate a timestamp for creating an image version history - TIMESTAMP="$(date +%Y%m%d)" - MAJOR_VERSION="${{ matrix.major_version }}" - COMMIT_TAGS=() - BUILD_TAGS=() - # Have tags for tracking builds during pull request - SHA_SHORT="${GITHUB_SHA::7}" - COMMIT_TAGS+=("pr-${{ github.event.pull_request.number }}-${MAJOR_VERSION}") - COMMIT_TAGS+=("${SHA_SHORT}-${MAJOR_VERSION}") - if [[ "${{ matrix.is_latest_version }}" == "true" ]] && \ - [[ "${{ matrix.is_stable_version }}" == "true" ]]; then - COMMIT_TAGS+=("pr-${{ github.event.pull_request.number }}") - COMMIT_TAGS+=("${SHA_SHORT}") - fi - - BUILD_TAGS=("${MAJOR_VERSION}" "${MAJOR_VERSION}-${TIMESTAMP}") - - if [[ "${{ matrix.is_latest_version }}" == "true" ]] && \ - [[ "${{ matrix.is_stable_version }}" == "true" ]]; then - BUILD_TAGS+=("latest") - fi - - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "Generated the following commit tags: " - for TAG in "${COMMIT_TAGS[@]}"; do - echo "${TAG}" - done - alias_tags=("${COMMIT_TAGS[@]}") - else - alias_tags=("${BUILD_TAGS[@]}") - fi - echo "Generated the following build tags: " - for TAG in "${BUILD_TAGS[@]}"; do - echo "${TAG}" - done - echo "alias_tags=${alias_tags[*]}" >> $GITHUB_OUTPUT - - name: Install Deps run: | sudo apt-get install just podman @@ -90,54 +46,3 @@ jobs: id: test_image run: | just container-test - - # Workaround bug where capital letters in your GitHub username make it impossible to push to GHCR. - # https://github.com/macbre/push-to-ghcr/issues/12 - - name: Lowercase Registry - id: registry_case - uses: ASzc/change-string-case-action@v6 - with: - string: ${{ env.IMAGE_REGISTRY }} - - # Push the image to GHCR (Image Registry) - - name: Push To GHCR - uses: redhat-actions/push-to-registry@v2 - id: push - if: github.event_name != 'pull_request' - env: - REGISTRY_USER: ${{ github.actor }} - REGISTRY_PASSWORD: ${{ github.token }} - with: - image: ${{ steps.build_image.outputs.image }} - tags: ${{ steps.build_image.outputs.tags }} - registry: ${{ steps.registry_case.outputs.lowercase }} - username: ${{ env.REGISTRY_USER }} - password: ${{ env.REGISTRY_PASSWORD }} - extra-args: | - --disable-content-trust - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Sign container - - uses: sigstore/cosign-installer@v3.7.0 - if: github.event_name != 'pull_request' - - - name: Sign container image - if: github.event_name != 'pull_request' - run: | - cosign sign -y --key env://COSIGN_PRIVATE_KEY ${{ steps.registry_case.outputs.lowercase }}/${{ env.IMAGE_NAME }}@${TAGS} - env: - TAGS: ${{ steps.push.outputs.digest }} - COSIGN_EXPERIMENTAL: false - COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }} - - - name: Echo outputs - if: github.event_name != 'pull_request' - run: | - echo "${{ toJSON(steps.push.outputs) }}" diff --git a/src/ublue_update/cli.py b/src/ublue_update/cli.py index 5cee9e8..01bbcc2 100644 --- a/src/ublue_update/cli.py +++ b/src/ublue_update/cli.py @@ -76,7 +76,7 @@ def inhibitor_checks_failed( raise Exception(f"update failed to pass checks: \n - {exception_log}") -def run_updates(system, system_update_available, dry_run): +def run_updates(system: bool, system_update_available: bool, dry_run: bool): process_uid = os.getuid() filelock_path = "/run/ublue-update.lock" if process_uid != 0: diff --git a/src/ublue_update/session.py b/src/ublue_update/session.py index 56a141c..fc542a9 100644 --- a/src/ublue_update/session.py +++ b/src/ublue_update/session.py @@ -5,7 +5,7 @@ log = logging.getLogger(__name__) -def get_active_users(): +def get_active_users() -> list: out = subprocess.run( [ "/usr/bin/busctl", @@ -26,7 +26,7 @@ def get_active_users(): return users["data"][0] -def run_uid(uid: int, args): +def run_uid(uid: int, args: list[str]) -> subprocess.CompletedProcess[bytes]: run_args = [ "/usr/bin/systemd-run", "--user", @@ -35,5 +35,4 @@ def run_uid(uid: int, args): "--pipe", "--quiet", ] - return subprocess.run(run_args + args, capture_output=True) diff --git a/src/ublue_update/update_drivers/brew.py b/src/ublue_update/update_drivers/brew.py index e971b89..3670286 100644 --- a/src/ublue_update/update_drivers/brew.py +++ b/src/ublue_update/update_drivers/brew.py @@ -4,41 +4,45 @@ log = logging.getLogger(__name__) -brew_prefix = "/home/linuxbrew/.linuxbrew" -brew_cellar = f"{brew_prefix}/Cellar" -brew_repo = f"{brew_prefix}/Homebrew" +BREW_PREFIX = "/home/linuxbrew/.linuxbrew" +BREW_CELLAR = f"{BREW_PREFIX}/Cellar" +BREW_REPO = f"{BREW_PREFIX}/Homebrew" -def detect_user(): - if not os.path.isdir(brew_prefix): +def detect_user() -> int: + if not os.path.isdir(BREW_PREFIX): return -1 - return os.stat(brew_prefix).st_uid + return os.stat(BREW_PREFIX).st_uid -def brew_update(dry_run): - uid = detect_user() +def brew_update(dry_run: bool): + uid: int = detect_user() if uid == -1 or dry_run: return log.info(f"running brew updates for uid: {uid}") - path = f"{os.environ["PATH"]}:{brew_prefix}/bin:{brew_prefix}/sbin" - args = [ - f"--E=HOMEBREW_PREFIX='{brew_prefix}'", - f"--E=HOMEBREW_CELLAR='{brew_cellar}'", - f"--E=HOMEBREW_REPOSITORY='{brew_repo}'", + env_path: str = os.environ["PATH"] + path: str = f"{env_path}:{BREW_PREFIX}/bin:{BREW_PREFIX}/sbin" + args: list[str] = [ + f"--E=HOMEBREW_PREFIX='{BREW_PREFIX}'", + f"--E=HOMEBREW_CELLAR='{BREW_CELLAR}'", + f"--E=HOMEBREW_REPOSITORY='{BREW_REPO}'", f"--E=PATH='{path}'", ] - out = run_uid(args + ["brew", "update"]) + + out = run_uid(uid, args + ["brew", "update"]) if out.returncode != 0: log.error( f"brew update failed, returned code {out.returncode}, program output:" ) log.error(out.stdout.decode("utf-8")) return - out = run_uid(args + ["brew", "upgrade"]) + + out = run_uid(uid, args + ["brew", "upgrade"]) if out.returncode != 0: log.error( f"brew upgrade failed, returned code {out.returncode}, program output:" ) log.error(out.stdout.decode("utf-8")) return + log.info("brew updates completed") diff --git a/tests/unit/test_brew.py b/tests/unit/test_brew.py new file mode 100644 index 0000000..3382a92 --- /dev/null +++ b/tests/unit/test_brew.py @@ -0,0 +1,94 @@ +import os +import sys +from unittest.mock import patch + +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")) +) + +from ublue_update.update_drivers.brew import detect_user, brew_update, BREW_PREFIX, BREW_CELLAR, BREW_REPO + + +@patch('os.path.isdir') +@patch('os.stat') +def test_detect_user_success(mock_stat, mock_isdir): + mock_isdir.return_value = True + mock_stat.return_value.st_uid = 1001 + + assert detect_user() == 1001 + + mock_isdir.assert_called_once_with(BREW_PREFIX) + mock_stat.assert_called_once_with(BREW_PREFIX) + +@patch('os.path.isdir') +def test_detect_user_failure(mock_isdir): + mock_isdir.return_value = False + + assert detect_user() == -1 + + mock_isdir.assert_called_once_with(BREW_PREFIX) + +@patch('ublue_update.update_drivers.brew.run_uid') +@patch('os.environ', {'PATH': '/usr/bin'}) +@patch('os.path.isdir') +@patch('os.stat') +@patch('ublue_update.update_drivers.brew.log') +def test_brew_update(mock_log, mock_stat, mock_isdir, mock_run_uid): + # Setup + mock_isdir.return_value = True + mock_stat.return_value.st_uid = 1001 + mock_run_uid.return_value.returncode = 0 # Simulate a successful command + + brew_update(True) + + # Test that brew_update returns early when dry_run is True + mock_run_uid.assert_not_called() + mock_log.info.assert_not_called() + + brew_update(False) + env = [ + f"--E=HOMEBREW_PREFIX='{BREW_PREFIX}'", + f"--E=HOMEBREW_CELLAR='{BREW_CELLAR}'", + f"--E=HOMEBREW_REPOSITORY='{BREW_REPO}'", + f"--E=PATH='/usr/bin:{BREW_PREFIX}/bin:{BREW_PREFIX}/sbin'", + ] + + mock_run_uid.assert_any_call(1001, env + [ + "brew", "update" + ]) + mock_run_uid.assert_any_call(1001, env + [ + "brew", "upgrade" + ]) + +@patch('ublue_update.update_drivers.brew.run_uid') +@patch('os.environ', {'PATH': '/usr/local/bin'}) +@patch('os.path.isdir') +@patch('os.stat') +@patch('ublue_update.update_drivers.brew.log') +def test_brew_update_failure(mock_log, mock_stat, mock_isdir, mock_run_uid): + mock_isdir.return_value = True + mock_stat.return_value.st_uid = 1001 + mock_run_uid.return_value.returncode = 1 # Simulate a failure in the `brew update` command + mock_run_uid.return_value.stdout = b"Error" + + brew_update(False) + + mock_log.error.assert_any_call("Error") + +@patch('ublue_update.update_drivers.brew.run_uid') +@patch('os.environ', {'PATH': '/usr/local/bin'}) +@patch('os.path.isdir') +@patch('os.stat') +@patch('ublue_update.update_drivers.brew.log') +def test_brew_update_upgrade_failure(mock_log, mock_stat, mock_isdir, mock_run_uid): + mock_isdir.return_value = True + mock_stat.return_value.st_uid = 1001 + mock_run_uid.return_value.returncode = 0 # Simulate a successful `brew update` + mock_run_uid.return_value.stdout = b"Update complete" + + mock_run_uid.return_value.returncode = 1 # Simulate a failure during `brew upgrade` + mock_run_uid.return_value.stdout = b'Upgrade error' + + brew_update(False) + + mock_log.error.assert_any_call('Upgrade error') diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index a681c41..7b61903 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -81,7 +81,7 @@ def test_ask_for_updates_system(mock_run_updates, mock_notify, mock_cfg): ["universal-blue-update-confirm=Confirm"], "critical", ) - mock_run_updates.assert_called_once_with(system, True) + mock_run_updates.assert_called_once_with(system, True, False) @patch("ublue_update.cli.cfg") @@ -98,7 +98,7 @@ def test_ask_for_updates_user(mock_run_updates, mock_notify, mock_cfg): ["universal-blue-update-confirm=Confirm"], "critical", ) - mock_run_updates.assert_called_once_with(system, True) + mock_run_updates.assert_called_once_with(system, True, False) def test_inhibitor_checks_failed(): @@ -129,7 +129,7 @@ def test_run_updates_user_in_progress(mock_acquire_lock, mock_os): mock_os.path.isdir.return_value = True mock_acquire_lock.return_value = None with pytest.raises(Exception, match="updates are already running for this user"): - run_updates(False, True) + run_updates(False, True, False) @patch("ublue_update.cli.os") @@ -143,7 +143,7 @@ def test_run_updates_user_system(mock_transaction_wait, mock_acquire_lock, mock_ Exception, match="ublue-update needs to be run as root to perform system updates!", ): - run_updates(True, True) + run_updates(True, True, False) @patch("ublue_update.cli.os") @@ -157,7 +157,7 @@ def test_run_updates_user_no_system( mock_os.getuid.return_value = 1001 mock_acquire_lock.return_value = fd mock_os.path.isdir.return_value = False - run_updates(False, True) + run_updates(False, True, False) mock_release_lock.assert_called_once_with(fd) @@ -190,7 +190,7 @@ def test_run_updates_system( mock_run.return_value = output mock_pending_deployment_check.return_value = True mock_cfg.dbus_notify.return_value = True - run_updates(True, True) + run_updates(True, True, False) mock_notify.assert_any_call( "System Updater", "System passed checks, updating ...", @@ -240,7 +240,7 @@ def test_run_updates_without_image_update( mock_pending_deployment_check.return_value = True mock_cfg.dbus_notify.return_value = True # System Update, but no Image Update Available - run_updates(True, False) + run_updates(True, False, False) mock_notify.assert_not_called() mock_run.assert_any_call( [ @@ -284,7 +284,7 @@ def test_run_updates_system_reboot( mock_cfg.dbus_notify.return_value = True reboot = MagicMock(stdout=b"universal-blue-update-reboot") mock_notify.side_effect = [None, reboot] - run_updates(True, True) + run_updates(True, True, False) mock_notify.assert_any_call( "System Updater", "System passed checks, updating ...", diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index e097334..27283f3 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -7,7 +7,7 @@ 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")) ) -from ublue_update.session import get_active_users +from ublue_update.session import get_active_users, run_uid busctl_json_output = b"""{"type":"a(uso)","data":[[[1000,"user","/org/freedesktop/login1/user/_1000"]]]}""" @@ -37,3 +37,24 @@ def test_get_active_users(mock_run): ], capture_output=True, ) + +@patch("ublue_update.session.subprocess.run") +def test_run_uid(mock_run): + mock_run.side_effect = [ + MagicMock(stdout=b"hi"), + ] + assert run_uid(0, ["echo", "hi"]).stdout.decode("utf-8") == "hi" + mock_run.assert_called_once_with( + [ + "/usr/bin/systemd-run", + "--user", + "--machine", + "0@", + "--pipe", + "--quiet", + "echo", + "hi", + ], + capture_output=True, + ) +