diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..ad3d2a2 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,48 @@ +name: integration testing +on: + pull_request: + merge_group: + push: + branches: + - main + workflow_dispatch: + +jobs: + push-ghcr: + name: Build and test image + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + id-token: write + strategy: + fail-fast: false + matrix: + major_version: [40, 41] + include: + - major_version: 40 + is_latest_version: false + is_stable_version: true + - major_version: 41 + is_latest_version: true + is_stable_version: false + steps: + # Checkout push-to-registry action GitHub repository + - name: Checkout Push to Registry action + uses: actions/checkout@v4 + + - name: Install Deps + run: | + sudo apt-get install just podman + + - name: Build Image + id: build_image + env: + FEDORA_MAJOR_VERSION: ${{ matrix.major_version }} + run: | + just container-build + + - name: Test Image + id: test_image + run: | + just container-test diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b0a37c..853f34f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,11 +21,8 @@ jobs: strategy: fail-fast: false matrix: - major_version: [39, 40, 41] + major_version: [40, 41] include: - - major_version: 39 - is_latest_version: false - is_stable_version: true - major_version: 40 is_latest_version: true is_stable_version: false @@ -84,7 +81,7 @@ jobs: uses: redhat-actions/buildah-build@v2 with: containerfiles: | - ./Containerfile + ./Containerfile.builder image: ${{ env.IMAGE_NAME }} tags: | ${{ steps.generate-tags.outputs.alias_tags }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index a277460..900ab31 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -19,7 +19,7 @@ jobs: tag: ${{ steps.release-please.outputs.tag_name }} upload_url: ${{ steps.release-please.outputs.upload_url }} steps: - - uses: google-github-actions/release-please-action@v4 + - uses: googleapis/release-please-action@v4 id: release-please with: release-type: simple @@ -27,7 +27,7 @@ jobs: build-release: name: Build and push rpm package - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 permissions: contents: write packages: write diff --git a/Containerfile b/Containerfile index d5af966..45b425a 100644 --- a/Containerfile +++ b/Containerfile @@ -1,4 +1,5 @@ -ARG FEDORA_MAJOR_VERSION="${FEDORA_MAJOR_VERSION:-39}" +ARG TEST_IMAGE="${TEST_IMAGE:-ghcr.io/ublue-os/base-main:41}" +ARG FEDORA_MAJOR_VERSION="${FEDORA_MAJOR_VERSION:-41}" FROM registry.fedoraproject.org/fedora:${FEDORA_MAJOR_VERSION} AS builder @@ -8,35 +9,59 @@ WORKDIR /app ADD . /app -RUN dnf install \ - --disablerepo='*' \ - --enablerepo='fedora,updates' \ - --setopt install_weak_deps=0 \ - --nodocs \ - --assumeyes \ - 'dnf-command(builddep)' \ - rpkg \ - rpm-build && \ - mkdir -p "$UBLUE_ROOT" && \ - rpkg spec --outdir "$UBLUE_ROOT" && \ - dnf builddep -y output/ublue-update.spec && \ - make build-rpm - -# Dump a file list for each RPM for easier consumption -RUN \ - for RPM in ${UBLUE_ROOT}/noarch/*.rpm; do \ - NAME="$(rpm -q $RPM --queryformat='%{NAME}')"; \ - mkdir -p "${UBLUE_ROOT}/ublue-os/files/${NAME}"; \ - rpm2cpio "${RPM}" | cpio -idmv --directory "${UBLUE_ROOT}/ublue-os/files/${NAME}"; \ - mkdir -p ${UBLUE_ROOT}/ublue-os/rpms/; \ - cp "${RPM}" "${UBLUE_ROOT}/ublue-os/rpms/$(rpm -q "${RPM}" --queryformat='%{NAME}.%{ARCH}.rpm')"; \ - done - -FROM scratch +RUN dnf install -y just + +RUN just container-rpm-build + +FROM ${TEST_IMAGE} ENV UBLUE_ROOT=/app/output -# Copy RPMs -COPY --from=builder ${UBLUE_ROOT}/ublue-os/rpms /rpms -# Copy dumped contents -COPY --from=builder ${UBLUE_ROOT}/ublue-os/files /files + +COPY --from=builder ${UBLUE_ROOT}/ublue-os/rpms /tmp/rpms +RUN rpm-ostree install python3-pip +RUN pip3 install --prefix /usr topgrade && rpm-ostree install /tmp/rpms/ublue-update.noarch.rpm + +# FROM: https://github.com/containers/image_build/blob/main/podman/Containerfile, sets up podman to work in the container +RUN useradd -G wheel podman && \ + echo -e "podman:1:999\npodman:1001:64535" > /etc/subuid && \ + echo -e "podman:1:999\npodman:1001:64535" > /etc/subgid && \ + echo "podman:" | chpasswd + +ADD ./containers.conf /etc/containers/containers.conf +ADD ./podman-containers.conf /home/podman/.config/containers/containers.conf + +RUN mkdir -p /home/podman/.local/share/containers && \ + chown podman:podman -R /home/podman && \ + chmod 644 /etc/containers/containers.conf + +# Copy & modify the defaults to provide reference if runtime changes needed. +# Changes here are required for running with fuse-overlay storage inside container. +RUN sed -e 's|^#mount_program|mount_program|g' \ + -e '/additionalimage.*/a "/var/lib/shared",' \ + -e 's|^mountopt[[:space:]]*=.*$|mountopt = "nodev,fsync=0"|g' \ + /usr/share/containers/storage.conf \ + > /etc/containers/storage.conf + +# Setup internal Podman to pass subscriptions down from host to internal container +RUN printf '/run/secrets/etc-pki-entitlement:/run/secrets/etc-pki-entitlement\n/run/secrets/rhsm:/run/secrets/rhsm\n' > /etc/containers/mounts.conf + +# Note VOLUME options must always happen after the chown call above +# RUN commands can not modify existing volumes +VOLUME /var/lib/containers +VOLUME /home/podman/.local/share/containers + +RUN mkdir -p /var/lib/shared/overlay-images \ + /var/lib/shared/overlay-layers \ + /var/lib/shared/vfs-images \ + /var/lib/shared/vfs-layers && \ + touch /var/lib/shared/overlay-images/images.lock && \ + touch /var/lib/shared/overlay-layers/layers.lock && \ + touch /var/lib/shared/vfs-images/images.lock && \ + touch /var/lib/shared/vfs-layers/layers.lock + +ENV _CONTAINERS_USERNS_CONFIGURED="" \ + BUILDAH_ISOLATION=chroot +# RUN useradd -m -G wheel user && echo "user:" | chpasswd + +CMD [ "/sbin/init" ] diff --git a/Containerfile.builder b/Containerfile.builder index 6766e3a..e0ac169 100644 --- a/Containerfile.builder +++ b/Containerfile.builder @@ -1,4 +1,6 @@ -FROM registry.fedoraproject.org/fedora:latest AS builder +ARG FEDORA_MAJOR_VERSION="${FEDORA_MAJOR_VERSION:-41}" + +FROM registry.fedoraproject.org/fedora:${FEDORA_MAJOR_VERSION} AS builder ENV UBLUE_ROOT=/app/output @@ -6,21 +8,11 @@ WORKDIR /app ADD . /app -RUN dnf install --assumeyes python3-pip && pip install topgrade +RUN dnf install -y just git -RUN dnf install \ - --disablerepo='*' \ - --enablerepo='fedora,updates' \ - --setopt install_weak_deps=0 \ - --nodocs \ - --assumeyes \ - 'dnf-command(builddep)' \ - rpkg \ - rpm-build && \ - mkdir -p "$UBLUE_ROOT" && \ - rpkg spec --outdir "$UBLUE_ROOT" && \ - dnf builddep -y output/ublue-update.spec +RUN just container-rpm-build -FROM builder AS rpm +FROM scratch -RUN make build-rpm +ENV UBLUE_ROOT=/app/output +COPY --from=builder ${UBLUE_ROOT}/ublue-os/rpms /tmp/rpms diff --git a/Makefile b/Makefile deleted file mode 100644 index 35b3a21..0000000 --- a/Makefile +++ /dev/null @@ -1,55 +0,0 @@ -UBLUE_ROOT := $(UBLUE_ROOT) -TARGET := ublue-update -SOURCE_DIR := $(UBLUE_ROOT)/$(TARGET) -RPMBUILD := $(UBLUE_ROOT)/rpmbuild -ifeq ($(GITHUB_REF),) -export GITHUB_REF := refs/tags/v1.0.0+$(shell git rev-parse --short HEAD) -endif - -all: build - -build: - flake8 src - black --check src - python3 -m build - -spec: output - rpkg spec --outdir $(PWD)/output -build-rpm: - rpkg local --outdir $(PWD)/output -output: - mkdir -p output - -# Phony targets - utilities and helpers -.PHONY: format -format: - black src - -.PHONY: dnf-install -dnf-install: - dnf install -y output/noarch/*.rpm - -.PHONY: builder-image -builder-image: - podman build -t $(TARGET):builder -f Containerfile.builder . - -.PHONY: builder-exec -builder-exec: - podman run --rm -it \ - -v "$(PWD):$(PWD)" \ - -w "$(PWD)" \ - -e DISPLAY \ - -e DBUS_SESSION_BUS_ADDRESS \ - -e XDG_RUNTIME_DIR \ - --ipc host \ - -v "/tmp/.X11-unix:/tmp/.X11-unix" \ - -v /var/run/dbus:/var/run/dbus \ - -v /run/user/1000/bus:/run/user/1000/bus \ - -v /run/dbus:/run/dbus \ - -v "${XDG_RUNTIME_DIR}:${XDG_RUNTIME_DIR}" \ - --security-opt label=disable \ - $(TARGET):builder - -.PHONY: clean -clean: - rm -rf $(UBLUE_ROOT) diff --git a/README.md b/README.md index 647bcae..df4faf4 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,17 @@ $ pkexec ublue-update --system ``` ``` -usage: ublue-update [-h] [-f] [-c] [-u] [-w] [--system] +usage: ublue-update [-h] [-f] [--config CONFIG] [--system] [--check] [-u] [-w] [--dry-run] options: -h, --help show this help message and exit -f, --force force manual update, skipping update checks - -c, --check run update checks and exit - -u, --updatecheck check for updates and exit - -w, --wait wait for transactions to complete and exit --config CONFIG use the specified config file --system only run system updates (requires root) + --check run update checks and exit + -u, --updatecheck check for updates and exit + -w, --wait wait for transactions to complete and exit + --dry-run dry run ublue-update ``` ## Troubleshooting @@ -183,10 +184,9 @@ exit(1) You can build and test this package in a container by using the provided container file. -1. `make builder-image` will create a container image with all dependencies installed -2. `make builder-exec` will execute a shell inside the builder container to allow you easily build the rpm package with `make build-rpm` -3. `make` will trigger the build process and generate a `.whl` package that can be installed -4. `pip install --user -e .` will allow to install an editable version of this package so you quickly edit and test the program +1. `just venv-create` will create a python venv with `ublue-update` installed (installed with `-e` to make it editable) +2. `source venv/bin/activate` to activate the venv +3. `sudo $(which ublue-update)` to run the updater as root (`which ublue-update` makes sure the local `ublue-update` program is run) # Special Thanks diff --git a/containers.conf b/containers.conf new file mode 100644 index 0000000..220c1f8 --- /dev/null +++ b/containers.conf @@ -0,0 +1,12 @@ +[containers] +netns="host" +userns="host" +ipcns="host" +utsns="host" +cgroupns="host" +cgroups="disabled" +log_driver = "k8s-file" +[engine] +cgroup_manager = "cgroupfs" +events_logger="file" +runtime="crun" diff --git a/justfile b/justfile new file mode 100644 index 0000000..6d191db --- /dev/null +++ b/justfile @@ -0,0 +1,83 @@ +set shell := ["bash", "-uc"] +export UBLUE_ROOT := env_var_or_default("UBLUE_ROOT", "/app/output") +export TARGET := "ublue-update" +export SOURCE_DIR := UBLUE_ROOT + "/" + TARGET +export RPMBUILD := UBLUE_ROOT + "/rpmbuild" + +default: + just --list + +venv-create: + /usr/bin/python -m venv venv + source venv/bin/activate && pip3 install -e . + echo 'Enter: `source venv/bin/activate` to enter the venv' + +build: format + python3 -m build + +test: + pytest -v + +spec: output + rpkg spec --outdir "$PWD/output" + +build-rpm: + rpkg local --outdir "$PWD/output" + +builddep: + dnf builddep -y output/ublue-update.spec + +container-install-deps: + #!/usr/bin/env bash + set -eou pipefail + dnf install \ + --disablerepo='*' \ + --enablerepo='fedora,updates' \ + --setopt install_weak_deps=0 \ + --nodocs \ + --assumeyes \ + 'dnf-command(builddep)' \ + rpkg \ + rpm-build \ + git + +# Used internally by build containers +container-rpm-build: container-install-deps spec builddep build-rpm + #!/usr/bin/env bash + set -eou pipefail + + # clean up files + for RPM in ${UBLUE_ROOT}/noarch/*.rpm; do + NAME="$(rpm -q $RPM --queryformat='%{NAME}')" + mkdir -p "${UBLUE_ROOT}/ublue-os/rpms/" + cp "${RPM}" "${UBLUE_ROOT}/ublue-os/rpms/$(rpm -q "${RPM}" --queryformat='%{NAME}.%{ARCH}.rpm')" + done + +output: + mkdir -p output + +format: + black src tests + flake8 src tests + + +dnf-install: + dnf install -y "output/noarch/*.rpm" + +container-build: + podman build . -t test-container -f Containerfile + +container-test: + #!/usr/bin/env bash + set -eou pipefail + + podman run -d --replace --name ublue-update-test --security-opt label=disable --device /dev/fuse:rw --privileged --systemd true test-container + while [[ "$(podman exec ublue-update-test systemctl is-system-running)" != "running" && "$(podman exec ublue-update-test systemctl is-system-running)" != "degraded" ]]; do + echo "Waiting for systemd to finish booting..." + sleep 1 + done + # podman exec -t ublue-update-test systemd-run --user --machine podman@ --pipe --quiet sudo /usr/bin/ublue-update --dry-run + podman exec -t ublue-update-test systemd-run --machine 0@ --pipe --quiet /usr/bin/ublue-update --dry-run + podman rm -f ublue-update-test +clean: + rm -rf "$UBLUE_ROOT" diff --git a/podman-containers.conf b/podman-containers.conf new file mode 100644 index 0000000..2bdd95a --- /dev/null +++ b/podman-containers.conf @@ -0,0 +1,5 @@ +[containers] +volumes = [ + "/proc:/proc", +] +default_sysctls = [] diff --git a/rpkg.macros b/rpkg.macros index b92db9c..d251d03 100644 --- a/rpkg.macros +++ b/rpkg.macros @@ -1,6 +1,6 @@ function ublue_update_version { if [ "$GITHUB_REF_NAME" = "" ]; then - echo "1.3.1+$(git rev-parse --short HEAD)" + echo "1.3.2+$(git rev-parse --short HEAD)" else echo "$GITHUB_REF_NAME" fi diff --git a/setup.cfg b/setup.cfg index 52fc9f9..fc7d2c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,4 +23,4 @@ install_requires = [flake8] max-line-length = 90 -ignore = E501,W503,W504 +ignore = E501,W503,W504 E402 diff --git a/src/ublue_update/cli.py b/src/ublue_update/cli.py index 4b4cf43..482c01c 100644 --- a/src/ublue_update/cli.py +++ b/src/ublue_update/cli.py @@ -11,11 +11,14 @@ from ublue_update.update_inhibitors.hardware import check_hardware_inhibitors from ublue_update.update_inhibitors.custom import check_custom_inhibitors from ublue_update.config import cfg -from ublue_update.session import get_active_users +from ublue_update.session import get_active_users, run_uid +from ublue_update.update_drivers.brew import brew_update from ublue_update.filelock import acquire_lock, release_lock -def notify(title: str, body: str, actions: list = [], urgency: str = "normal"): +def notify( + title: str, body: str, actions: list = [], urgency: str = "normal" +) -> subprocess.CompletedProcess[bytes] | None: if not cfg.dbus_notify: return process_uid = os.getuid() @@ -30,26 +33,18 @@ def notify(title: str, body: str, actions: list = [], urgency: str = "normal"): if actions != []: for action in actions: args.append(f"--action={action}") + # If root run per user: if process_uid == 0: users = [] try: users = get_active_users() except KeyError as e: log.error("failed to get active logind session info", e) + out: subprocess.CompletedProcess[bytes] | None = None for user in users: - user_args = [ - "/usr/bin/systemd-run", - "--user", - "--machine", - f"{user[1]}@", # magic number, corresponds to user name in ListUsers (see session.py) - "--pipe", - "--quiet", - ] - user_args += args - out = subprocess.run(user_args, capture_output=True) - if actions != []: - return out - return + out = run_uid(user[0], args) + return out + out = subprocess.run(args, capture_output=True) return out @@ -67,7 +62,7 @@ def ask_for_updates(system): return # if the user has confirmed if "universal-blue-update-confirm" in out.stdout.decode("utf-8"): - run_updates(system, True) + run_updates(system, True, False) def inhibitor_checks_failed( @@ -83,19 +78,39 @@ def inhibitor_checks_failed( raise Exception(f"update failed to pass checks: \n - {exception_log}") -def run_updates(system, system_update_available): +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: xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR") - if os.path.isdir(xdg_runtime_dir): + if xdg_runtime_dir is not None and os.path.isdir(xdg_runtime_dir): filelock_path = f"{xdg_runtime_dir}/ublue-update.lock" fd = acquire_lock(filelock_path) if fd is None: raise Exception("updates are already running for this user") """Wait on any existing transactions to complete before updating""" - transaction_wait() + # remove backwards compat warnings in topgrade (requires user confirmation without this env var) + os.environ["TOPGRADE_SKIP_BRKC_NOTIFY"] = "true" + topgrade_args = [ + "/usr/bin/topgrade", + ] + + if dry_run: + topgrade_args.append("--dry-run") + # disable toolbox during dry run because it doesn't want to run in the container: github.com/containers/toolbox/issues/989 + topgrade_args.extend(["--disable", "toolbx"]) + else: + transaction_wait() + + topgrade_system = topgrade_args + [ + "--config", + "/usr/share/ublue-update/topgrade-system.toml", + ] + topgrade_user = topgrade_args + [ + "--config", + "/usr/share/ublue-update/topgrade-user.toml", + ] if process_uid == 0: if system_update_available: @@ -115,44 +130,29 @@ def run_updates(system, system_update_available): users = [] """System""" - # remove backwards compat warnings in topgrade (requires user confirmation without this env var) - os.environ["TOPGRADE_SKIP_BRKC_NOTIFY"] = "true" out = subprocess.run( - [ - "/usr/bin/topgrade", - "--config", - "/usr/share/ublue-update/topgrade-system.toml", - ], + topgrade_system, capture_output=True, ) log.debug(out.stdout.decode("utf-8")) if out.returncode != 0: - print(f"topgrade returned code {out.returncode}, program output:") - print(out.stdout.decode("utf-8")) + log.error(f"topgrade returned code {out.returncode}, program output:") + log.error(out.stderr.decode("utf-8")) os._exit(out.returncode) """Users""" for user in users: + log.info( f"""Running update for user: '{user[1]}'""" ) # magic number, corresponds to username (see session.py) - out = subprocess.run( - [ - "/usr/bin/systemd-run", - "--setenv=TOPGRADE_SKIP_BRKC_NOTIFY=true", - "--user", - "--machine", - f"{user[1]}@", - "--pipe", - "--quiet", - "/usr/bin/topgrade", - "--config", - "/usr/share/ublue-update/topgrade-user.toml", - ], - capture_output=True, - ) + + out = run_uid( + user[0], ["--setenv=TOPGRADE_SKIP_BRKC_NOTIFY=true"] + topgrade_user + ) # uid for user (session.py) log.debug(out.stdout.decode("utf-8")) + brew_update(dry_run) log.info("System update complete") if pending_deployment_check() and system_update_available and cfg.dbus_notify: out = notify( @@ -161,7 +161,9 @@ def run_updates(system, system_update_available): ["universal-blue-update-reboot=Reboot Now"], ) # if the user has confirmed the reboot - if "universal-blue-update-reboot" in out.stdout.decode("utf-8"): + if out is not None and "universal-blue-update-reboot" in out.stdout.decode( + "utf-8" + ): subprocess.run(["systemctl", "reboot"]) else: if system: @@ -189,8 +191,14 @@ def main(): action="store_true", help="force manual update, skipping update checks", ) + parser.add_argument("--config", help="use the specified config file") + parser.add_argument( + "--system", + action="store_true", + help="only run system updates (requires root)", + ) parser.add_argument( - "-c", "--check", action="store_true", help="run update checks and exit" + "--check", action="store_true", help="run update checks and exit" ) parser.add_argument( "-u", @@ -204,17 +212,25 @@ def main(): action="store_true", help="wait for transactions to complete and exit", ) - parser.add_argument("--config", help="use the specified config file") parser.add_argument( - "--system", + "--dry-run", action="store_true", - help="only run system updates (requires root)", + help="dry run ublue-update", ) cli_args = parser.parse_args() # Load the configuration file + cfg.load_config(cli_args.config) + if cli_args.dry_run: + # "dry run" the hardware tests as well + _, _ = check_hardware_inhibitors() + _, _ = check_custom_inhibitors() + # run the update function with "dry run" set to true + run_updates(False, True, True) + os._exit(0) + if cli_args.wait: transaction_wait() os._exit(0) @@ -245,7 +261,7 @@ def main(): # system checks passed log.info("System passed all update checks") try: - run_updates(cli_args.system, system_update_available) + run_updates(cli_args.system, system_update_available, False) except Exception as e: log.info(f"Failed to update: {e}") os._exit(1) diff --git a/src/ublue_update/session.py b/src/ublue_update/session.py index a54ce23..fc542a9 100644 --- a/src/ublue_update/session.py +++ b/src/ublue_update/session.py @@ -1,8 +1,11 @@ import subprocess import json +import logging +log = logging.getLogger(__name__) -def get_active_users(): + +def get_active_users() -> list: out = subprocess.run( [ "/usr/bin/busctl", @@ -21,3 +24,15 @@ def get_active_users(): users = json.loads(out.stdout.decode("utf-8")) # sample output: {'type': 'a(uso)', 'data': [[[1000, 'user', '/org/freedesktop/login1/user/_1000']]] return users["data"][0] + + +def run_uid(uid: int, args: list[str]) -> subprocess.CompletedProcess[bytes]: + run_args = [ + "/usr/bin/systemd-run", + "--user", + "--machine", + f"{uid}@", + "--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 new file mode 100644 index 0000000..88e835e --- /dev/null +++ b/src/ublue_update/update_drivers/brew.py @@ -0,0 +1,47 @@ +import os +from ublue_update.session import run_uid +import logging + +log = logging.getLogger(__name__) + +BREW_PREFIX = "/home/linuxbrew/.linuxbrew" +BREW_CELLAR = f"{BREW_PREFIX}/Cellar" +BREW_REPO = f"{BREW_PREFIX}/Homebrew" +BREW_PATH: str = f"{BREW_PREFIX}/bin/brew" + + + +def detect_user() -> int: + if not os.path.isdir(BREW_PREFIX): + return -1 + return os.stat(BREW_PREFIX).st_uid + + +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}") + args: list[str] = [ + f"--setenv=HOMEBREW_PREFIX='{BREW_PREFIX}'", + f"--setenv=HOMEBREW_CELLAR='{BREW_CELLAR}'", + f"--setenv=HOMEBREW_REPOSITORY='{BREW_REPO}'", + ] + + out = run_uid(uid, args + [f"{BREW_PATH}", "update"]) + if out.returncode != 0: + log.error( + f"brew update failed, returned code {out.returncode}, program output:" + ) + log.error(out.stderr.decode("utf-8")) + return + + out = run_uid(uid, args + [f"{BREW_PATH}", "upgrade"]) + if out.returncode != 0: + log.error( + f"brew upgrade failed, returned code {out.returncode}, program output:" + ) + log.error(out.stderr.decode("utf-8")) + return + + log.info("brew updates completed") diff --git a/src/ublue_update/update_inhibitors/custom.py b/src/ublue_update/update_inhibitors/custom.py index 6b85468..b587d60 100644 --- a/src/ublue_update/update_inhibitors/custom.py +++ b/src/ublue_update/update_inhibitors/custom.py @@ -65,7 +65,7 @@ def run_custom_check_scripts() -> List[dict]: return results -def check_custom_inhibitors() -> bool: +def check_custom_inhibitors() -> tuple[bool, list]: custom_inhibitors = run_custom_check_scripts() failures = [] diff --git a/src/ublue_update/update_inhibitors/hardware.py b/src/ublue_update/update_inhibitors/hardware.py index 81b42c3..df8b5b1 100644 --- a/src/ublue_update/update_inhibitors/hardware.py +++ b/src/ublue_update/update_inhibitors/hardware.py @@ -80,10 +80,11 @@ def check_battery_status() -> dict: def check_cpu_load() -> dict: - if cfg.max_cpu_load_percent: + cores = psutil.cpu_count() + if cfg.max_cpu_load_percent and cores is not None: # get load average percentage in last 5 minutes: # https://psutil.readthedocs.io/en/latest/index.html?highlight=getloadavg - cpu_load_percent = psutil.getloadavg()[1] / psutil.cpu_count() * 100 + cpu_load_percent = psutil.getloadavg()[1] / cores * 100 return { "passed": cpu_load_percent < cfg.max_cpu_load_percent, "message": f"CPU load is above {cfg.max_cpu_load_percent}%", @@ -109,7 +110,7 @@ def check_mem_percentage() -> dict: } -def check_hardware_inhibitors() -> bool: +def check_hardware_inhibitors() -> tuple[bool, list]: hardware_inhibitors = [ check_network_status(), check_network_not_metered(), diff --git a/tests/unit/test_brew.py b/tests/unit/test_brew.py new file mode 100644 index 0000000..5ada8f2 --- /dev/null +++ b/tests/unit/test_brew.py @@ -0,0 +1,102 @@ +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 ( + BREW_PATH, + 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"--setenv=HOMEBREW_PREFIX='{BREW_PREFIX}'", + f"--setenv=HOMEBREW_CELLAR='{BREW_CELLAR}'", + f"--setenv=HOMEBREW_REPOSITORY='{BREW_REPO}'", + ] + + mock_run_uid.assert_any_call(1001, env + [f"{BREW_PATH}", "update"]) + mock_run_uid.assert_any_call(1001, env + [f"{BREW_PATH}", "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.stderr= 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.stderr = b"Error" + + brew_update(False) + + mock_log.error.assert_any_call("Error") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index a681c41..8c4b0ed 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -47,6 +47,7 @@ def test_notify_uid_user(mock_run, mock_log, mock_os, mock_cfg): capture_output=True, ) + @patch("ublue_update.cli.cfg") def test_ask_for_updates_no_dbus_notify(mock_cfg): mock_cfg.dbus_notify = False @@ -81,7 +82,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 +99,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 +130,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 +144,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 +158,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 +191,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 +241,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 +285,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_config.py b/tests/unit/test_config.py index fcd9d45..1bad3c6 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,6 +1,6 @@ import sys import os -from unittest.mock import patch, MagicMock, mock_open +from unittest.mock import patch, mock_open # Add the src directory to the sys.path sys.path.insert( @@ -34,7 +34,7 @@ def test_find_default_config_file_success_second(mock_isfile, mock_log): mock_isfile.side_effect = [False, True] assert find_default_config_file() == test_path - mock_isfile.call_count == 2 + assert mock_isfile.call_count == 2 def test_load_value_success(): @@ -46,7 +46,7 @@ def test_load_value_success(): def test_load_value_fail(): dct = {"key": "val"} - assert load_value(dct, "key2") == None + assert load_value(dct, "key2") is None @patch("builtins.open", new_callable=mock_open, read_data=toml_example) @@ -118,8 +118,8 @@ def test_load_values(mock_load_value): instance = Config() instance.load_values(config) - mock_load_value.call_count == 6 - assert instance.dbus_notify == False + assert mock_load_value.call_count == 6 + assert not instance.dbus_notify assert instance.custom_check_scripts == [] mock_load_value.assert_any_call(config, "notify", "dbus_notify") mock_load_value.assert_any_call(config, "checks", "network_not_metered") diff --git a/tests/unit/test_filelock.py b/tests/unit/test_filelock.py index 30e2afb..2b73915 100644 --- a/tests/unit/test_filelock.py +++ b/tests/unit/test_filelock.py @@ -1,7 +1,7 @@ import sys import os import fcntl -from unittest.mock import patch, MagicMock +from unittest.mock import patch # Add the src directory to the sys.path sys.path.insert( diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index e097334..f6c4be1 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -1,13 +1,13 @@ import sys import os -from unittest.mock import patch, MagicMock, mock_open +from unittest.mock import patch, MagicMock # Add the src directory to the sys.path sys.path.insert( 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, + ) diff --git a/tests/unit/update_inhibitors/test_custom.py b/tests/unit/update_inhibitors/test_custom.py index c1ad08f..cb723b4 100644 --- a/tests/unit/update_inhibitors/test_custom.py +++ b/tests/unit/update_inhibitors/test_custom.py @@ -1,7 +1,7 @@ import pytest import sys import os -from unittest.mock import patch, MagicMock, mock_open +from unittest.mock import patch, MagicMock # Add the src directory to the sys.path sys.path.insert( diff --git a/tests/unit/update_inhibitors/test_hardware.py b/tests/unit/update_inhibitors/test_hardware.py index f440a23..b05699b 100644 --- a/tests/unit/update_inhibitors/test_hardware.py +++ b/tests/unit/update_inhibitors/test_hardware.py @@ -1,6 +1,6 @@ import sys import os -from unittest.mock import patch, MagicMock, mock_open +from unittest.mock import patch, MagicMock # Add the src directory to the sys.path sys.path.insert( diff --git a/ublue-update.spec b/ublue-update.spec index c9b97dc..d51aeb1 100644 --- a/ublue-update.spec +++ b/ublue-update.spec @@ -15,7 +15,7 @@ Source: {{{ git_dir_pack }}} BuildArch: noarch Supplements: rpm-ostree flatpak -BuildRequires: make +BuildRequires: just BuildRequires: systemd-rpm-macros BuildRequires: black BuildRequires: python-flake8