diff --git a/.editorconfig b/.editorconfig index 6367fde790..d24ed170be 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,11 @@ root = true [*.sh] indent_style = space indent_size = 2 + +[*.{c,h,cxx,hxx}] +indent_style = space +indent_size = 2 +tab_width = 8 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 29d430d677..4ffe462359 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -72,28 +72,43 @@ jobs: timeout-minutes: 20 runs-on: ubuntu-latest - defaults: - run: - working-directory: ./rust + container: + image: registry.opensuse.org/opensuse/tumbleweed:latest + options: --security-opt seccomp=unconfined steps: + - name: Configure and refresh repositories + # disable unused repositories to have faster refresh + run: zypper modifyrepo -d repo-non-oss repo-openh264 repo-update && ( zypper ref || zypper ref || zypper ref ) + + - name: Install required packages + run: zypper --non-interactive install --allow-downgrade + clang-devel + gcc-c++ + git + libopenssl-3-devel + libsuseconnect + libzypp-devel + make + openssl-3 + pam-devel + rustup + + - name: Configure git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Git Checkout uses: actions/checkout@v4 + - name: Install Rust toolchains + run: rustup toolchain install stable + - name: Rust toolchain run: | rustup show cargo --version - - name: Install packages - run: | - sudo apt-get update - sudo apt-get -y install libclang-18-dev libpam0g-dev - - - name: Installed packages - run: apt list --installed - - name: Rust cache uses: actions/cache@v4 with: @@ -104,44 +119,70 @@ jobs: - name: Run clippy linter run: cargo clippy + working-directory: ./rust tests: # the default timeout is 6 hours, that's too much if the job gets stuck timeout-minutes: 30 runs-on: ubuntu-latest + + container: + image: registry.opensuse.org/opensuse/tumbleweed:latest + options: --security-opt seccomp=unconfined + env: COVERAGE: 1 - defaults: - run: - working-directory: ./rust - steps: + - name: Configure and refresh repositories + # disable unused repositories to have faster refresh + run: zypper modifyrepo -d repo-non-oss repo-openh264 repo-update && ( zypper ref || zypper ref || zypper ref ) + + - name: Install required packages + run: zypper --non-interactive install --allow-downgrade + clang-devel + dbus-1-daemon + gcc-c++ + git + glibc-locale + golang-github-google-jsonnet + jq + libopenssl-3-devel + libsuseconnect + libzypp-devel + make + openssl-3 + pam-devel + python-langtable-data + python3-openapi_spec_validator + rustup + timezone + util-linux-systemd + xkeyboard-config + + - name: Configure git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Git Checkout uses: actions/checkout@v4 + - name: Install Rust toolchains + run: rustup toolchain install stable + - name: Rust toolchain run: | rustup show cargo --version - - name: Install packages - run: | - sudo apt-get update - sudo apt-get -y install libclang-18-dev libpam0g-dev python3-langtable jsonnet - - name: Prepare for tests run: | - # the langtable data location is different in SUSE/openSUSE, create a symlink - sudo mkdir -p /usr/share/langtable - sudo ln -s /usr/lib/python3/dist-packages/langtable/data /usr/share/langtable/data # create the /etc/agama.d/locales file with list of locales - sudo mkdir /etc/agama.d - sudo bash -c 'ls -1 -d /usr/share/i18n/locales/* | sed -e "s#/usr/share/i18n/locales/##" >/etc/agama.d/locales' + mkdir -p /etc/agama.d + ls -1 -d /usr/lib/locale/*.utf8 | sed -e "s#/usr/lib/locale/##" -e "s#utf8#UTF-8#" >/etc/agama.d/locales - name: Installed packages - run: apt list --installed + run: rpm -qa - name: Rust cache id: cache-tests @@ -157,6 +198,7 @@ jobs: # this avoids refreshing the crates index and saves few seconds if: steps.cache-tests.outputs.cache-hit != 'true' run: cargo install cargo-tarpaulin + working-directory: ./rust - name: Run the tests # Compile into the ./target-coverage directory because tarpaulin uses special compilation @@ -164,11 +206,12 @@ jobs: # The --skip-clean skips the cleanup and allows using the cached results. # See https://github.com/xd009642/tarpaulin/discussions/772 run: cargo tarpaulin --workspace --all-targets --doc --engine llvm --out xml --target-dir target-coverage --skip-clean -- --nocapture + working-directory: ./rust env: # use the "stable" tool chain (installed by default) instead of the "nightly" default in tarpaulin RUSTC_BOOTSTRAP: 1 RUSTUP_TOOLCHAIN: stable - RUST_BACKTRACE: 1 + RUST_BACKTRACE: full RUSTFLAGS: --cfg ci # send the code coverage for the Rust part to the coveralls.io @@ -198,38 +241,42 @@ jobs: timeout-minutes: 30 runs-on: ubuntu-latest - defaults: - run: - working-directory: ./rust + container: + image: registry.opensuse.org/opensuse/tumbleweed:latest steps: + - name: Configure and refresh repositories + # disable unused repositories to have faster refresh + run: zypper modifyrepo -d repo-non-oss repo-openh264 repo-update && ( zypper ref || zypper ref || zypper ref ) + + - name: Install required packages + run: zypper --non-interactive install --allow-downgrade + clang-devel + gcc-c++ + git + libopenssl-3-devel + libsuseconnect + libzypp-devel + make + openssl-3 + pam-devel + python3-openapi_spec_validator + rustup + - name: Git Checkout uses: actions/checkout@v4 + - name: Install Rust toolchains + run: rustup toolchain install stable + - name: Rust toolchain run: | rustup show cargo --version - - name: Configure system - # disable updating initramfs (the system is not booted again) - # disable updating man db (to save some time) - run: | - sudo sed -i "s/yes/no/g" /etc/initramfs-tools/update-initramfs.conf - sudo rm -f /var/lib/man-db/auto-update - - - name: Install packages - run: | - sudo apt-get update - sudo apt-get -y install libclang-18-dev libpam0g-dev - # uninstall the python3-jsonschema package, openapi-spec-validator wants - # to install a newer version which would conflict with that - sudo apt-get purge python3-jsonschema - sudo pip install openapi-spec-validator - - name: Installed packages - run: apt list --installed + run: rpm -qa - name: Rust cache uses: actions/cache@v4 @@ -241,6 +288,8 @@ jobs: - name: Generate the OpenAPI specification run: cargo xtask openapi + working-directory: ./rust - name: Validate the OpenAPI specification run: openapi-spec-validator out/openapi/* + working-directory: ./rust diff --git a/.gitignore b/.gitignore index ea097847f6..218e45e77f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ /*.pot *.mo *.bz2 +*.o +*.a # Do NOT ignore .github: for git this is a no-op # but it helps ripgrep (rg) which would otherwise ignore dotfiles and dotdirs !/.github/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..e69de29bb2 diff --git a/doc/boot_arguments.md b/doc/boot_arguments.md deleted file mode 100644 index 693510ce5b..0000000000 --- a/doc/boot_arguments.md +++ /dev/null @@ -1,56 +0,0 @@ -# Reading arguments from kernel command line - -Agama configuration can be altered through the kernel command line. It is possible to load a -full configuration file or to change some specific values. - -## Loading a new configuration file - -It is possible to load a new configuration file specifying a URL through the `inst.config_url` -option. Here are some examples: - -* `inst.config_url=http://192.168.122.1/my-agama.yaml` -* `inst.config_url=usb:///agama.yaml` - -See [URL handling in the -installer](https://github.com/yast/yast-installation/blob/master/doc/url.md) to find more details -about the supported URLs. - -## Custom Installation URL Configuration - -You can override the default `installation_url` set in the product files [here](https://github.com/openSUSE/agama/tree/master/products.d) by passing the `inst.install_url` parameter as a boot option in the bootloader. -This is particularly useful for any pre-production testing in openQA. - -**Note:** Setting this variable will impact all products. - -### Example Usage - -To specify a custom installation URLs, pass following as a parameter to kernel in the bootloader. -You can specify multiple URLs by separating them with commas. - -``` -inst.install_url=https://myrepo,https://myrepo2 -``` - -## Changing configuration values - -Instead of loading a full configuration file, you might be interested in adjusting just a few -configuration values. You must specify the option name in dotted notation. A typical use-case might -be to use your own SSL certificates: - -``` -inst.web.ssl=true inst.web.ssl_cert=http://192.168.122.1/mycert.pem inst.web.ssl_key=http://192.168.122.1/mycert.key -``` - -Changing complex options (e.g., collections) is not supported yet. - -## Proxy Setup - -Agama supports proxy setup using the `proxy=` kernel command line option like -`proxy=http://192.168.122.1:3128` when the installation requires to use an HTTP, HTTPS or FTP -source. The supported proxy URL format is: protocol://[user[:password]@]host[:port] - -When the installation system boots, the agama-proxy-setup service will read the proxy URL to be -used from the kernel command line options or through the dracut ask prompt configuration file -writing it to the /etc/sysconfig/proxy. After that the microOS Tools setup-systemd-proxy-env -systemd service will make the proxy variables from that file available to all the systemd units -writing a systemd config file with all the variables as Enviroment ones. diff --git a/doc/dbus/bus/org.opensuse.Agama.Manager1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Manager1.bus.xml deleted file mode 100644 index 945966dedd..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama.Manager1.bus.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama.Security.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Security.bus.xml deleted file mode 120000 index d0feb248d1..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama.Security.bus.xml +++ /dev/null @@ -1 +0,0 @@ -org.opensuse.Agama.Software1.bus.xml \ No newline at end of file diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml deleted file mode 100644 index 360c277ef0..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.Proposal.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.Proposal.bus.xml deleted file mode 100644 index b501f0d052..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama.Software1.Proposal.bus.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml deleted file mode 100644 index 959cc521b8..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml deleted file mode 100644 index fcb0439338..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama.Users1.bus.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Manager.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Manager.bus.xml deleted file mode 100644 index 060c3de4d2..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama1.Manager.bus.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Progress.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Progress.bus.xml deleted file mode 120000 index d0feb248d1..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama1.Progress.bus.xml +++ /dev/null @@ -1 +0,0 @@ -org.opensuse.Agama.Software1.bus.xml \ No newline at end of file diff --git a/doc/dbus/bus/org.opensuse.Agama1.Questions.Generic.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Questions.Generic.bus.xml deleted file mode 100644 index 0bc9fe9d10..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama1.Questions.Generic.bus.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Questions.WithPassword.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Questions.WithPassword.bus.xml deleted file mode 100644 index f9dd49994c..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama1.Questions.WithPassword.bus.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Questions.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Questions.bus.xml deleted file mode 100644 index 6a85392d5c..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama1.Questions.bus.xml +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml deleted file mode 120000 index 9cfd11ce93..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml +++ /dev/null @@ -1 +0,0 @@ -org.opensuse.Agama.Software1.Product.bus.xml \ No newline at end of file diff --git a/doc/dbus/bus/seed.sh b/doc/dbus/bus/seed.sh index 664a52f6fe..d7a1fb44e7 100755 --- a/doc/dbus/bus/seed.sh +++ b/doc/dbus/bus/seed.sh @@ -1,6 +1,6 @@ #!/bin/bash abusctl() { - busctl --address=unix:path=/run/agama/bus "$@" + busctl --address=unix:path=/run/agama/bus "$@" } # a stdio filter for XML introspection, @@ -8,11 +8,11 @@ abusctl() { # - remove detailed introspection of _child_ nodes # - make interfaces order deterministic by sorting them cleanup() { - # also remove the DTD declaration - # otherwise xmlstarlet will complain about it not being available - sed -e '/^ $DD.$1.bus.xml + abusctl tree --list $DD.${1%.*} + abusctl introspect --xml-interface $DD.${1%.*} $SS/${1//./\/} | + cleanup \ + >$DD.$1.bus.xml } -look Manager1 -look Software1 -look Software1.Proposal look Storage1 - -abusctl introspect --xml-interface \ - ${DD}1 \ - ${SS}1/Questions \ - | cleanup \ - > ${DD}1.Questions.bus.xml - -abusctl call \ - ${DD}1 \ - ${SS}1/Questions \ - ${DD}1.Questions \ - New "ssassa{ss}" "org.bands.Clash" "should I stay or should I go" 2 yes no yes 0 -abusctl introspect --xml-interface \ - ${DD}1 \ - ${SS}1/Questions/0 | - cleanup \ - >${DD}1.Questions.Generic.bus.xml - -abusctl call \ - ${DD}1 \ - ${SS}1/Questions \ - ${DD}1.Questions \ - NewWithPassword "ssassa{ss}" "world.MiddleEarth.Moria.gate1" "Speak friend and enter" 2 enter giveup giveup 0 -abusctl introspect --xml-interface \ - ${DD}1 \ - ${SS}1/Questions/1 | - cleanup \ - >${DD}1.Questions.WithPassword.bus.xml - -abusctl introspect --xml-interface \ - ${DD}.Manager1 \ - ${SS}/Users1 \ - | cleanup \ - >${DD}.Users1.bus.xml diff --git a/doc/dbus/org.opensuse.Agama.Security.doc.xml b/doc/dbus/org.opensuse.Agama.Security.doc.xml deleted file mode 100644 index 6b183e682b..0000000000 --- a/doc/dbus/org.opensuse.Agama.Security.doc.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml deleted file mode 100644 index 165a67094b..0000000000 --- a/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama.Software1.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.doc.xml deleted file mode 100644 index 215acb0e81..0000000000 --- a/doc/dbus/org.opensuse.Agama.Software1.doc.xml +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama.Users1.doc.xml b/doc/dbus/org.opensuse.Agama.Users1.doc.xml deleted file mode 100644 index 50c3971583..0000000000 --- a/doc/dbus/org.opensuse.Agama.Users1.doc.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama1.Manager.doc.xml b/doc/dbus/org.opensuse.Agama1.Manager.doc.xml deleted file mode 100644 index 13dd26b9a5..0000000000 --- a/doc/dbus/org.opensuse.Agama1.Manager.doc.xml +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama1.Progress.doc.xml b/doc/dbus/org.opensuse.Agama1.Progress.doc.xml deleted file mode 100644 index 1a1a3ed0e0..0000000000 --- a/doc/dbus/org.opensuse.Agama1.Progress.doc.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama1.Questions.Generic.doc.xml b/doc/dbus/org.opensuse.Agama1.Questions.Generic.doc.xml deleted file mode 100644 index a58f40cbab..0000000000 --- a/doc/dbus/org.opensuse.Agama1.Questions.Generic.doc.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama1.Questions.WithPassword.doc.xml b/doc/dbus/org.opensuse.Agama1.Questions.WithPassword.doc.xml deleted file mode 100644 index 5e93a51921..0000000000 --- a/doc/dbus/org.opensuse.Agama1.Questions.WithPassword.doc.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama1.Questions.doc.xml b/doc/dbus/org.opensuse.Agama1.Questions.doc.xml deleted file mode 100644 index 9e47507d8a..0000000000 --- a/doc/dbus/org.opensuse.Agama1.Questions.doc.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama1.Registration.doc.xml b/doc/dbus/org.opensuse.Agama1.Registration.doc.xml deleted file mode 100644 index 750c11ee7d..0000000000 --- a/doc/dbus/org.opensuse.Agama1.Registration.doc.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus_api.md b/doc/dbus_api.md index 7352bf2d12..7e5ae4c7a0 100644 --- a/doc/dbus_api.md +++ b/doc/dbus_api.md @@ -44,15 +44,6 @@ We use these resources to get more familiar with D-Bus API designing. - network manager design https://people.freedesktop.org/~lkundrak/nm-docs/spec.html - anakonda D-Bus API ( spread in `*_interface.py` files https://github.com/rhinstaller/anaconda/tree/master/pyanaconda/modules -## Base Product - -Iface: o.o.Agama.Software1 - -See the new-style [reference][lang-ref] ([source][lang-src]). - -[lang-ref]: https://opensuse.github.io/agama/dbus/ref-org.opensuse.Agama.Software1.html -[lang-src]: dbus/org.opensuse.Agama.Software1.doc.xml - ## `org.opensuse.Agama.Storage1` Service Service for managing storage devices. @@ -594,17 +585,3 @@ Summary readable a{s(uub)} ##### Signals * `PropertiesChanged`, as standard from `org.freedesktop.DBus.Properties`. - -## Users - -See the new-style [reference][usr-ref] ([source][usr-src]). - -[usr-ref]: https://opensuse.github.io/agama/dbus/ref-org.opensuse.Agama.Users1.html -[usr-src]: dbus/org.opensuse.Agama.Users1.doc.xml - -## Manager - -See the new-style [reference][mgr-ref] ([source][mgr-src]). - -[mgr-ref]: https://opensuse.github.io/agama/dbus/ref-org.opensuse.Agama1.Manager.html -[mgr-src]: dbus/org.opensuse.Agama1.Manager.doc.xml diff --git a/doc/http_api.md b/doc/http_api.md index e394a0bc66..a7ab4e0eff 100644 --- a/doc/http_api.md +++ b/doc/http_api.md @@ -1,42 +1,102 @@ ---- -## HTTP API: An Overview +# HTTP API -This document outlines the **public HTTP API**. It provides an alternative way to interact with the system, complementing the Command Line Interface (CLI) and web user interface. It's important to note that both the CLI and web UI also leverage this HTTP API for their operations. +This document outlines the HTTP API of Agama. It provides an alternative way to interact with the system, complementing the Command Line Interface (CLI) and web user interface. It's important to note that both the CLI and web UI also leverage this HTTP API for their operations. ---- -### API Documentation - -Agama uses **OpenAPI** to document its HTTP API. You can generate the documentation using the following commands: +**Note**: Agama uses *OpenAPI* to document its HTTP API. You can generate the documentation using the following commands: ```shell (cd rust; cargo xtask openapi) cat rust/out/openapi/*.json ``` ---- -### Request and Response Body +## Overview + +The API is designed around 3 main concepts: *system*, *config* and *proposal*. + +* *system*: represents the current status of the running system. +* *config*: represents the configuration for installing the target system. +* *proposal*: represents what is going to be done in the target system. + +The *config* contains elements that can modify the *system*, the *proposal* or both. For example, the *dasd* config changes the *system*, and the *storage* config changes the *proposal*. In other cases like *network*, the config can affect to both *system* and *proposal*. + +~~~ +GET /status +GET /system +GET /extended_config +GET PUT PATCH /config +GET POST PATCH /questions +GET /proposal +GET /issues +POST /action +~~~ + +### GET /status + +Reports the status of the installation. It contains the installation state (*configuring*, *installing*, *finished*) and the active progresses. + +### GET /system + +Returns a JSON with the info of the system (storage devices, network connections, current localization, etc). + +### GET /extended_config + +Returns the *extended config* JSON. + +There is a distinction between *extended config* and *config*: + +* The *config* is the config explicitly set by the clients. +* The *extended config* is the config used for calculating the proposal and it is built by merging the the *config* with the default *extended config*. The default *extended config* is built from the *system info* and the *product info*. + +For example, if only the *locale* was configured by the user, then the *config* has no *keymap* property. Nevertheless, the *extended config* would have a *keymap* with the value from the default *extended config*. + +### GET PUT /config + +Reads or replaces the *config*. In case of patching, the given config is merged into the current *extended config*. + +### PATCH /config + +Applies changes in the *config*. There is an own patch document: + +~~~json +{ + "update": { + "l10n": { + "keymap": "es" + } + } +} +~~~ + +The given config from the *update* key is merged into current *extended config*. + +The patch document could be extended in the future with more options, for example for resetting some parts of the config. + +See https://datatracker.ietf.org/doc/html/rfc5789#section-2 + +### POST /action + +Allows performing actions that cannot be done as side effect of applying a config. For example, start the installation, reload the system, etc. The *actions schema* defines the possible actions, parameters, etc. -The Agama HTTP API uses **JSON** as its request and response body format. The schema for this JSON is thoroughly documented within the OpenAPI specification. +#### Example: reload the system ---- -### Configuration-Based API +In some cases, clients need to request a system reload. For example, if you create a RAID device using the terminal, then you need to reload the system in order to see the new device. In the future, reloading the system could be automatically done (e.g., by listening udisk D-Bus). For now, reloading has to be manually requested. -For automated installations, the system provides two primary API endpoints for managing configurations across various modules: +~~~ +POST /action { "reloadSystem": "storage" } +~~~ -* **GET `/api/${module}/config`**: Use this endpoint to **export** the current system configuration for a specific module. -* **PUT `/api/${module}/config`**: Use this endpoint to **load** an unattended installation profile for a specific module. In some cases, the loaded configuration is also immediately applied; further details are available below. +#### Example: change the system localization ---- -### Future Enhancements: PATCH `/api/${module}/config` for Targeted Modifications +Sometimes we need to directly modify the system without changing the config. For example, switching the locale of the running system (UI language). -Following internal discussions, we plan to introduce a **PATCH `/api/${module}/config`** endpoint. This new endpoint will enable more granular modifications and applications of configurations, and it will replace the existing HTTP API methods used for modifications. While not strictly required, the structure of the PATCH request will likely mirror the configuration's existing layout. +~~~ +POST /action { "configureL10n": { language: "es_ES" } } +~~~ -This enhancement will allow you to modify specific parts of the configuration. In some cases, these changes can be applied immediately without needing a full installation. This is especially useful for technologies that require configuration to be applied *before* an installation begins, such as: +#### Example: start installation -* Network settings -* System registration -* iSCSI -* DASD -* zFCP +The installation can be started by calling the proper action. -The key advantage of this PATCH approach is its ability to minimize **race conditions** and to more easily keep the configuration manipulation API closely aligned with the core configuration API. By only modifying the necessary parts of the configuration, it reduces conflicts, which is particularly helpful in scenarios like rapid clicks within the web user interface. +~~~ +POST /action "install" +~~~ diff --git a/live/src/agama-installer.changes b/live/src/agama-installer.changes index 8e66eb7845..dde7ba49a0 100644 --- a/live/src/agama-installer.changes +++ b/live/src/agama-installer.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Fri Jan 9 14:44:01 UTC 2026 - Imobach Gonzalez Sosa + +- Version 19.pre + ------------------------------------------------------------------- Tue Jan 6 16:21:35 UTC 2026 - Ladislav Slezák diff --git a/live/src/agama-installer.kiwi b/live/src/agama-installer.kiwi index 5cf042dde7..7cb2438eca 100644 --- a/live/src/agama-installer.kiwi +++ b/live/src/agama-installer.kiwi @@ -18,7 +18,7 @@ - 18.0.0 + 19.pre.0.0 zypper en_US us diff --git a/live/src/config.sh b/live/src/config.sh index 970ca53bf4..bacbac0ed6 100644 --- a/live/src/config.sh +++ b/live/src/config.sh @@ -61,7 +61,6 @@ systemctl enable agama-web-server.service systemctl enable agama-dbus-monitor.service systemctl enable agama-autoinstall.service systemctl enable agama-hostname.service -systemctl enable agama-proxy-setup.service systemctl enable agama-certificate-issue.path systemctl enable agama-certificate-wait.service systemctl enable agama-cmdline-process.service diff --git a/products.d/agama-products.changes b/products.d/agama-products.changes index b322aaa5b4..246032426f 100644 --- a/products.d/agama-products.changes +++ b/products.d/agama-products.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Jan 9 14:44:00 UTC 2026 - Imobach Gonzalez Sosa + +- Version 19.pre +- Do not offer or pre-install YaST2. + ------------------------------------------------------------------- Tue Dec 2 12:40:33 UTC 2025 - Dominique Leuenberger diff --git a/products.d/tumbleweed.yaml b/products.d/tumbleweed.yaml index 0a6d8aec5d..6a134c5282 100644 --- a/products.d/tumbleweed.yaml +++ b/products.d/tumbleweed.yaml @@ -134,6 +134,8 @@ software: - apparmor mandatory_packages: - NetworkManager + # TODO: dynamically propose kernel in agama code + - kernel-default - openSUSE-repos-Tumbleweed - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model optional_packages: null diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 97c1405370..9b323fd071 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.0" @@ -22,9 +13,11 @@ name = "agama-autoinstall" version = "0.1.0" dependencies = [ "agama-lib", + "agama-transfer", + "agama-utils", "anyhow", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "url", ] @@ -34,12 +27,14 @@ name = "agama-cli" version = "1.0.0" dependencies = [ "agama-lib", + "agama-transfer", + "agama-utils", "anyhow", "async-trait", "chrono", "clap", "console", - "fluent-uri", + "fluent-uri 0.3.2", "home", "indicatif", "inquire", @@ -47,24 +42,78 @@ dependencies = [ "reqwest", "serde_json", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "url", ] +[[package]] +name = "agama-files" +version = "0.1.0" +dependencies = [ + "agama-software", + "agama-utils", + "async-trait", + "serde_json", + "tempfile", + "test-context", + "thiserror 2.0.17", + "tokio", + "tokio-test", + "tracing", +] + +[[package]] +name = "agama-hostname" +version = "0.1.0" +dependencies = [ + "agama-utils", + "anyhow", + "async-trait", + "tempfile", + "test-context", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-test", + "tracing", + "zbus", +] + +[[package]] +name = "agama-l10n" +version = "0.1.0" +dependencies = [ + "agama-locale-data", + "agama-utils", + "anyhow", + "async-trait", + "gettext-rs", + "regex", + "test-context", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-test", + "tracing", + "zbus", +] + [[package]] name = "agama-lib" version = "1.0.0" dependencies = [ + "agama-l10n", "agama-locale-data", "agama-network", + "agama-transfer", "agama-utils", "anyhow", "async-trait", "chrono", "curl", "env_logger", - "fluent-uri", + "fluent-uri 0.3.2", "fs_extra", "futures-util", "home", @@ -81,31 +130,57 @@ dependencies = [ "serde_with", "strum", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-native-tls", "tokio-stream", "tokio-tungstenite 0.26.2", + "tracing", "url", "utoipa", "uuid", "zbus", + "zypp-agama", ] [[package]] name = "agama-locale-data" version = "0.1.0" dependencies = [ - "anyhow", "chrono-tz", "flate2", "quick-xml", "regex", "serde", - "thiserror 2.0.12", + "thiserror 2.0.17", "utoipa", ] +[[package]] +name = "agama-manager" +version = "0.1.0" +dependencies = [ + "agama-files", + "agama-hostname", + "agama-l10n", + "agama-network", + "agama-software", + "agama-storage", + "agama-utils", + "async-trait", + "gettext-rs", + "merge", + "serde", + "serde_json", + "serde_with", + "test-context", + "thiserror 2.0.17", + "tokio", + "tokio-test", + "tracing", + "zbus", +] + [[package]] name = "agama-network" version = "0.1.0" @@ -121,7 +196,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-test", @@ -135,18 +210,25 @@ dependencies = [ name = "agama-server" version = "0.1.0" dependencies = [ + "agama-l10n", "agama-lib", "agama-locale-data", + "agama-manager", + "agama-network", + "agama-software", + "agama-transfer", "agama-utils", "anyhow", "async-trait", "axum", "axum-extra", + "bindgen 0.69.5", "clap", "config", "futures-util", "gethostname", "gettext-rs", + "glob", "http-body-util", "hyper 1.6.0", "hyper-util", @@ -160,9 +242,12 @@ dependencies = [ "serde", "serde_json", "serde_with", + "serde_yaml", + "strum", "subprocess", "tempfile", - "thiserror 2.0.12", + "test-context", + "thiserror 2.0.17", "tokio", "tokio-openssl", "tokio-stream", @@ -177,14 +262,91 @@ dependencies = [ "utoipa", "uuid", "zbus", + "zypp-agama", +] + +[[package]] +name = "agama-software" +version = "0.1.0" +dependencies = [ + "agama-locale-data", + "agama-utils", + "async-trait", + "camino", + "gettext-rs", + "glob", + "regex", + "serde", + "serde_with", + "serde_yaml", + "strum", + "suseconnect-agama", + "tempfile", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", + "url", + "utoipa", + "zypp-agama", +] + +[[package]] +name = "agama-storage" +version = "0.1.0" +dependencies = [ + "agama-utils", + "async-trait", + "serde", + "serde_json", + "test-context", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-test", + "zbus", +] + +[[package]] +name = "agama-transfer" +version = "0.1.0" +dependencies = [ + "curl", + "regex", + "thiserror 2.0.17", + "url", ] [[package]] name = "agama-utils" version = "0.1.0" dependencies = [ + "agama-locale-data", + "agama-transfer", + "async-trait", + "cidr", + "fluent-uri 0.4.1", + "fs-err", + "gettext-rs", + "macaddr", + "merge", + "regex", + "serde", "serde_json", + "serde_with", + "serde_yaml", + "strum", + "tempfile", + "test-context", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-test", + "tracing", + "url", "utoipa", + "uuid", "zbus", "zvariant", ] @@ -294,9 +456,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arraydeque" @@ -555,9 +717,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -584,6 +746,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "axum-macros", "base64 0.22.1", "bytes", "futures-util", @@ -660,18 +823,14 @@ dependencies = [ ] [[package]] -name = "backtrace" -version = "0.3.74" +name = "axum-macros" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", + "proc-macro2", + "quote", + "syn 2.0.101", ] [[package]] @@ -709,10 +868,33 @@ dependencies = [ "itertools 0.12.1", "lazy_static", "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.101", + "which", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "shlex", "syn 2.0.101", ] @@ -792,9 +974,9 @@ dependencies = [ [[package]] name = "borrow-or-share" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "brotli" @@ -841,6 +1023,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "camino" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" + [[package]] name = "cc" version = "1.2.20" @@ -883,7 +1071,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -924,6 +1112,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", + "libloading", ] [[package]] @@ -1183,24 +1372,24 @@ dependencies = [ [[package]] name = "curl" -version = "0.4.47" +version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9fb4d13a1be2b58f14d60adba57c9834b78c62fd86c3e76a148f732686e9265" +checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc" dependencies = [ "curl-sys", "libc", "openssl-probe", "openssl-sys", "schannel", - "socket2", - "windows-sys 0.52.0", + "socket2 0.6.0", + "windows-sys 0.59.0", ] [[package]] name = "curl-sys" -version = "0.4.80+curl-8.12.1" +version = "0.4.84+curl-8.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f7df2eac63200c3ab25bde3b2268ef2ee56af3d238e76d61f01c3c49bff734" +checksum = "abc4294dc41b882eaff37973c2ec3ae203d0091341ee68fbadd1d06e0c18a73b" dependencies = [ "cc", "libc", @@ -1208,14 +1397,14 @@ dependencies = [ "openssl-sys", "pkg-config", "vcpkg", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "darling" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ "darling_core", "darling_macro", @@ -1223,9 +1412,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", @@ -1237,9 +1426,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", @@ -1468,8 +1657,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" dependencies = [ "bit-set 0.8.0", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] @@ -1505,6 +1694,17 @@ dependencies = [ "serde", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1534,9 +1734,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1551,12 +1751,36 @@ dependencies = [ "num", ] +[[package]] +name = "fs-err" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d91fd049c123429b018c47887d3f75a265540dd3c30ba9cb7bae9197edb03a" +dependencies = [ + "autocfg", +] + [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1564,6 +1788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1572,6 +1797,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1620,10 +1856,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1685,9 +1924,9 @@ dependencies = [ [[package]] name = "gettext-rs" -version = "0.7.2" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44e92f7dc08430aca7ed55de161253a22276dfd69c5526e5c5e95d1f7cf338a" +checksum = "5d5857dc1b7f0fee86961de833f434e29494d72af102ce5355738c0664222bdf" dependencies = [ "gettext-sys", "locale_config", @@ -1703,12 +1942,6 @@ dependencies = [ "temp-dir", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "glob" version = "0.3.2" @@ -1954,7 +2187,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.9", "tokio", "tower-service", "tracing", @@ -2029,7 +2262,7 @@ dependencies = [ "hyper 1.6.0", "libc", "pin-project-lite", - "socket2", + "socket2 0.5.9", "tokio", "tower-service", "tracing", @@ -2185,9 +2418,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2255,17 +2488,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "io-uring" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" -dependencies = [ - "bitflags 2.9.0", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -2367,7 +2589,7 @@ dependencies = [ "percent-encoding", "referencing", "regex", - "regex-syntax 0.8.5", + "regex-syntax", "serde", "serde_json", "uuid-simd", @@ -2411,7 +2633,7 @@ dependencies = [ "petgraph", "pico-args", "regex", - "regex-syntax 0.8.5", + "regex-syntax", "string_cache", "term", "tiny-keccak", @@ -2425,7 +2647,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata 0.4.9", + "regex-automata", ] [[package]] @@ -2452,6 +2674,16 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.3" @@ -2476,7 +2708,7 @@ dependencies = [ "once_cell", "serde", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.17", "uuid", ] @@ -2568,11 +2800,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -2596,6 +2828,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merge" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e520ba58faea3487f75df198b1d079644ec226ea3b0507d002c6fa4b8cf93a" +dependencies = [ + "merge_derive", + "num-traits", +] + +[[package]] +name = "merge_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f8ce6efff81cbc83caf4af0905c46e58cb46892f63ad3835e81b47eaf7968" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "mime" version = "0.3.17" @@ -2746,12 +3000,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -2874,15 +3127,6 @@ dependencies = [ "objc", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2959,12 +3203,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "pam" version = "0.8.0" @@ -2994,7 +3232,7 @@ version = "1.0.0-alpha5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b" dependencies = [ - "bindgen", + "bindgen 0.69.5", "libc", ] @@ -3054,9 +3292,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -3065,7 +3303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.17", "ucd-trie", ] @@ -3257,6 +3495,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +dependencies = [ + "proc-macro2", + "syn 2.0.101", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -3266,6 +3514,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -3422,7 +3692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" dependencies = [ "ahash", - "fluent-uri", + "fluent-uri 0.3.2", "once_cell", "parking_lot", "percent-encoding", @@ -3431,42 +3701,27 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.8.5" @@ -3563,16 +3818,16 @@ dependencies = [ ] [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "rustc-hash" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "1.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" @@ -3669,6 +3924,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3715,18 +3994,28 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -3735,14 +4024,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -3799,17 +4089,18 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.12.0" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.9.0", - "serde", - "serde_derive", + "schemars 0.9.0", + "schemars 1.1.0", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -3817,9 +4108,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.12.0" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling", "proc-macro2", @@ -3827,6 +4118,19 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.9.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3908,7 +4212,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", ] @@ -3943,6 +4247,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -3981,9 +4295,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] @@ -4017,6 +4331,27 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "suseconnect-agama" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "suseconnect-agama-sys", + "tempfile", + "thiserror 2.0.17", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "suseconnect-agama-sys" +version = "0.1.0" +dependencies = [ + "bindgen 0.72.1", +] + [[package]] name = "syn" version = "1.0.109" @@ -4088,15 +4423,15 @@ checksum = "bc1ee6eef34f12f765cb94725905c6312b6610ab2b0940889cfe58dae7bc3c72" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.2", "once_cell", "rustix 1.0.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4120,6 +4455,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "test-context" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb69cce03e432993e2dc1f93f7899b952300fcb6dc44191a1b830b60b8c3c8aa" +dependencies = [ + "futures", + "test-context-macros", +] + +[[package]] +name = "test-context-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e0639209021e54dbe19cafabfc0b5574b078c37358945e6d473eabe39bb974" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4131,11 +4487,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.17", ] [[package]] @@ -4151,9 +4507,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -4222,29 +4578,26 @@ dependencies = [ [[package]] name = "tokio" -version = "1.46.0" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio 1.0.3", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -4436,9 +4789,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", @@ -4448,9 +4801,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -4459,9 +4812,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -4491,14 +4844,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -4551,7 +4904,7 @@ dependencies = [ "native-tls", "rand 0.9.1", "sha1", - "thiserror 2.0.12", + "thiserror 2.0.17", "utf-8", ] @@ -4614,6 +4967,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -4622,9 +4981,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -4870,6 +5229,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4909,7 +5280,7 @@ checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.1", "windows-result", "windows-strings 0.4.0", ] @@ -4942,6 +5313,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" version = "0.4.0" @@ -4959,7 +5336,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -4968,7 +5345,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -4977,7 +5354,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -5007,6 +5384,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -5271,9 +5657,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.7.1" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3a7c7cee313d044fca3f48fa782cb750c79e4ca76ba7bc7718cd4024cdf6f68" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" dependencies = [ "async-broadcast", "async-executor", @@ -5296,7 +5682,8 @@ dependencies = [ "tokio", "tracing", "uds_windows", - "windows-sys 0.59.0", + "uuid", + "windows-sys 0.61.2", "winnow", "zbus_macros", "zbus_names", @@ -5305,9 +5692,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.7.1" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17e7e5eec1550f747e71a058df81a9a83813ba0f6a95f39c4e218bdc7ba366a" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -5459,3 +5846,20 @@ dependencies = [ "syn 2.0.101", "winnow", ] + +[[package]] +name = "zypp-agama" +version = "0.1.0" +dependencies = [ + "tracing", + "url", + "zypp-agama-sys", +] + +[[package]] +name = "zypp-agama-sys" +version = "0.1.0" +dependencies = [ + "bindgen 0.72.1", + "tracing", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4b93ca5f00..c440a2347e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,12 +2,23 @@ members = [ "agama-autoinstall", "agama-cli", - "agama-server", + "agama-files", + "agama-hostname", + "agama-l10n", "agama-lib", "agama-locale-data", + "agama-manager", "agama-network", + "agama-server", + "agama-software", + "agama-storage", + "agama-transfer", "agama-utils", + "suseconnect-agama", + "suseconnect-agama/suseconnect-agama-sys", "xtask", + "zypp-agama", + "zypp-agama/zypp-agama-sys", ] resolver = "2" diff --git a/rust/agama-autoinstall/Cargo.toml b/rust/agama-autoinstall/Cargo.toml index 48df1a7cc0..ae668af56a 100644 --- a/rust/agama-autoinstall/Cargo.toml +++ b/rust/agama-autoinstall/Cargo.toml @@ -6,6 +6,8 @@ edition.workspace = true [dependencies] agama-lib = { path = "../agama-lib" } +agama-utils = { path = "../agama-utils" } +agama-transfer = { path = "../agama-transfer" } anyhow = { version = "1.0.98" } tempfile = "3.20.0" thiserror = "2.0.12" diff --git a/rust/agama-autoinstall/src/lib.rs b/rust/agama-autoinstall/src/lib.rs index 64af4837ff..01f6a9efc3 100644 --- a/rust/agama-autoinstall/src/lib.rs +++ b/rust/agama-autoinstall/src/lib.rs @@ -18,9 +18,6 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -mod kernel_cmdline; -pub use kernel_cmdline::KernelCmdline; - mod loader; pub use loader::ConfigLoader; diff --git a/rust/agama-autoinstall/src/main.rs b/rust/agama-autoinstall/src/main.rs index d666e8a7fd..861f2c05e8 100644 --- a/rust/agama-autoinstall/src/main.rs +++ b/rust/agama-autoinstall/src/main.rs @@ -20,15 +20,15 @@ use std::str::FromStr; -use agama_autoinstall::{ConfigAutoLoader, KernelCmdline, ScriptsRunner}; +use agama_autoinstall::{ConfigAutoLoader, ScriptsRunner}; use agama_lib::{ auth::AuthToken, http::BaseHTTPClient, manager::{FinishMethod, ManagerHTTPClient}, }; +use agama_utils::kernel_cmdline::KernelCmdline; use anyhow::anyhow; -const CMDLINE_FILE: &str = "/run/agama/cmdline.d/agama.conf"; const API_URL: &str = "http://localhost/api"; pub fn build_base_client() -> anyhow::Result { @@ -43,7 +43,7 @@ pub fn insecure_from(cmdline: &KernelCmdline, key: &str) -> bool { #[tokio::main] async fn main() -> anyhow::Result<()> { - let args = KernelCmdline::parse_file(CMDLINE_FILE)?; + let args = KernelCmdline::parse()?; let http = build_base_client()?; let manager_client = ManagerHTTPClient::new(http.clone()); diff --git a/rust/agama-autoinstall/src/questions.rs b/rust/agama-autoinstall/src/questions.rs index 22113e929c..c91e3f1aba 100644 --- a/rust/agama-autoinstall/src/questions.rs +++ b/rust/agama-autoinstall/src/questions.rs @@ -20,15 +20,8 @@ //! This module offers a mechanism to ask questions to users. -use std::collections::HashMap; - -use agama_lib::{ - http::BaseHTTPClient, - questions::{ - http_client::HTTPClient as QuestionsHTTPClient, - model::{GenericQuestion, Question}, - }, -}; +use agama_lib::{http::BaseHTTPClient, questions::http_client::HTTPClient as QuestionsHTTPClient}; +use agama_utils::api::question::QuestionSpec; pub struct UserQuestions { questions: QuestionsHTTPClient, @@ -43,24 +36,13 @@ impl UserQuestions { /// Asks the user whether to retry loading the profile. pub async fn should_retry(&self, text: &str, error: &str) -> anyhow::Result { - let data = HashMap::from([("error".to_string(), error.to_string())]); - let generic = GenericQuestion { - id: None, - class: "load.retry".to_string(), - text: text.to_string(), - options: vec!["Yes".to_string(), "No".to_string()], - default_option: "No".to_string(), - data, - }; - let question = Question { - generic, - with_password: None, - }; + let question = QuestionSpec::new(text, "load.retry") + .with_actions(&[("yes", "Yes"), ("no", "No")]) + .with_default_action("no") + .with_data(&[("error", error)]); + let question = self.questions.create_question(&question).await?; - let answer = self - .questions - .get_answer(question.generic.id.unwrap()) - .await?; - Ok(answer.generic.answer == "Yes") + let answer = self.questions.get_answer(question.id).await?; + Ok(answer.action == "yes") } } diff --git a/rust/agama-autoinstall/src/scripts.rs b/rust/agama-autoinstall/src/scripts.rs index 6058c61c6b..bdda0cdde3 100644 --- a/rust/agama-autoinstall/src/scripts.rs +++ b/rust/agama-autoinstall/src/scripts.rs @@ -19,14 +19,15 @@ // find current contact information at www.suse.com. use std::{ - fs::{self, create_dir_all, File}, + fs::{self, create_dir_all}, io::Write, os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, - process::Output, }; -use agama_lib::{http::BaseHTTPClient, utils::Transfer}; +use agama_lib::http::BaseHTTPClient; +use agama_transfer::Transfer; +use agama_utils::command::{create_log_file, run_with_retry}; use anyhow::anyhow; use url::Url; @@ -60,6 +61,8 @@ impl ScriptsRunner { /// It downloads the script from the given URL to the runner directory. /// It saves the stdout, stderr and exit code to separate files. /// + /// It will retry if it cannot run the script. + /// /// * url: script URL, supporting agama-specific schemes. pub async fn run(&mut self, url: &str) -> anyhow::Result<()> { create_dir_all(&self.path)?; @@ -69,8 +72,17 @@ impl ScriptsRunner { let path = self.path.join(&file_name); self.save_script(url, &path).await?; - let output = std::process::Command::new(&path).output()?; - self.save_logs(&path, output)?; + let stdout_file = create_log_file(&path.with_extension("stdout"))?; + let stderr_file = create_log_file(&path.with_extension("stderr"))?; + + let mut command = tokio::process::Command::new(&path); + command.stdout(stdout_file).stderr(stderr_file); + let output = run_with_retry(command).await?; + + if let Some(code) = output.status.code() { + let mut file = create_log_file(&path.with_extension("exit"))?; + write!(&mut file, "{}", code)?; + } Ok(()) } @@ -96,44 +108,23 @@ impl ScriptsRunner { } async fn save_script(&self, url: &str, path: &PathBuf) -> anyhow::Result<()> { - let mut file = Self::create_file(&path, 0o700)?; + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o700) + .open(&path)?; + while let Err(error) = Transfer::get(url, &mut file, self.insecure) { - eprintln!("Could not load configuration from {url}: {error}"); + eprintln!("Could not load the script from {url}: {error}"); if !self.should_retry(&url, &error.to_string()).await? { return Err(anyhow!(error)); } } + file.sync_all()?; Ok(()) } - fn save_logs(&self, path: &Path, output: Output) -> anyhow::Result<()> { - if !output.stdout.is_empty() { - let mut file = Self::create_file(&path.with_extension("stdout"), 0o600)?; - file.write_all(&output.stdout)?; - } - - if !output.stderr.is_empty() { - let mut file = Self::create_file(&path.with_extension("stderr"), 0o600)?; - file.write_all(&output.stderr)?; - } - - if let Some(code) = output.status.code() { - let mut file = Self::create_file(&path.with_extension("exit"), 0o600)?; - write!(&mut file, "{}", code)?; - } - - Ok(()) - } - - fn create_file(path: &Path, perms: u32) -> std::io::Result { - fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .mode(perms) - .open(path) - } - async fn should_retry(&self, url: &str, error: &str) -> anyhow::Result { let msg = format!( r#" diff --git a/rust/agama-cli/Cargo.toml b/rust/agama-cli/Cargo.toml index 9daa10479d..4b1d87b9cc 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -8,6 +8,8 @@ edition = "2021" [dependencies] clap = { version = "4.5.19", features = ["derive", "wrap_help"] } agama-lib = { path = "../agama-lib" } +agama-utils = { path = "../agama-utils" } +agama-transfer = { path = "../agama-transfer" } serde_json = "1.0.128" indicatif = "0.17.8" thiserror = "2.0.12" diff --git a/rust/agama-cli/src/cli_input.rs b/rust/agama-cli/src/cli_input.rs index 61fa559d6b..fbe570e6a9 100644 --- a/rust/agama-cli/src/cli_input.rs +++ b/rust/agama-cli/src/cli_input.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::utils::Transfer; +use agama_transfer::Transfer; use anyhow::Context; use std::{ collections::HashMap, diff --git a/rust/agama-cli/src/config.rs b/rust/agama-cli/src/config.rs index 291ea08f0e..416e68bdb8 100644 --- a/rust/agama-cli/src/config.rs +++ b/rust/agama-cli/src/config.rs @@ -18,22 +18,27 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::{io::Write, path::PathBuf, process::Command}; +use std::{io::Write, path::PathBuf, process::Command, time::Duration}; -use agama_lib::profile::ProfileHTTPClient; use agama_lib::{ - context::InstallationContext, http::BaseHTTPClient, install_settings::InstallSettings, - profile::ProfileValidator, profile::ValidationOutcome, utils::FileFormat, - Store as SettingsStore, + context::InstallationContext, + http::BaseHTTPClient, + monitor::MonitorClient, + profile::{ProfileHTTPClient, ProfileValidator, ValidationOutcome}, + utils::FileFormat, }; +use agama_utils::api; use anyhow::{anyhow, Context}; use clap::Subcommand; use console::style; use fluent_uri::Uri; +use serde_json::json; use tempfile::Builder; +use tokio::time::sleep; use crate::{ - api_url, build_clients, cli_input::CliInput, cli_output::CliOutput, show_progress, GlobalOpts, + api_url, build_clients, build_http_client, cli_input::CliInput, cli_output::CliOutput, + show_progress, GlobalOpts, }; const DEFAULT_EDITOR: &str = "/usr/bin/vi"; @@ -60,7 +65,7 @@ pub enum ConfigCommands { /// Validate a profile using JSON Schema /// - /// Schema is available at /usr/share/agama-cli/profile.schema.json + /// Schema is available at /usr/share/agama/schema/profile.schema.json /// Note: validation is always done as part of all other "agama config" commands. Validate { /// JSON file, URL or path or `-` for standard input @@ -108,67 +113,71 @@ pub async fn run(subcommand: ConfigCommands, opts: GlobalOpts) -> anyhow::Result match subcommand { ConfigCommands::Show { output } => { - let (http_client, _monitor) = build_clients(api_url, opts.insecure).await?; - let store = SettingsStore::new(http_client.clone()).await?; - let model = store.load().await?; - let json = serde_json::to_string_pretty(&model)?; + let http_client = build_http_client(api_url, opts.insecure, true).await?; + let response: api::Config = http_client.get("/v2/config").await?; + let json = serde_json::to_string_pretty(&response)?; let destination = output.unwrap_or(CliOutput::Stdout); destination.write(&json)?; eprintln!(); validate(&http_client, CliInput::Full(json.clone()), false).await?; - Ok(()) } ConfigCommands::Load { url_or_path } => { let (http_client, monitor) = build_clients(api_url, opts.insecure).await?; - let store = SettingsStore::new(http_client.clone()).await?; let url_or_path = url_or_path.unwrap_or(CliInput::Stdin); let contents = url_or_path.read_to_string(opts.insecure)?; let valid = validate(&http_client, CliInput::Full(contents.clone()), false).await?; - if matches!(valid, ValidationOutcome::Valid) { - let result = - InstallSettings::from_json(&contents, &InstallationContext::from_env()?)?; - tokio::spawn(async move { - show_progress(monitor, true).await; - }); - store.store(&result).await?; + if !matches!(valid, ValidationOutcome::Valid) { + return Ok(()); } - Ok(()) + let model: api::Config = serde_json::from_str(&contents)?; + patch_config(&http_client, &model).await?; + + monitor_progress(monitor).await?; } ConfigCommands::Validate { url_or_path, local } => { let _ = if !local { - let (http_client, _monitor) = build_clients(api_url, opts.insecure).await?; + let http_client = build_http_client(api_url, opts.insecure, true).await?; validate(&http_client, url_or_path, false).await } else { validate_local(url_or_path, opts.insecure) }; - - Ok(()) } ConfigCommands::Generate { url_or_path } => { - let (http_client, _monitor) = build_clients(api_url, opts.insecure).await?; + let http_client = build_http_client(api_url, opts.insecure, true).await?; let url_or_path = url_or_path.unwrap_or(CliInput::Stdin); - generate(&http_client, url_or_path, opts.insecure).await + generate(&http_client, url_or_path, opts.insecure).await?; } ConfigCommands::Edit { editor } => { let (http_client, monitor) = build_clients(api_url, opts.insecure).await?; - let store = SettingsStore::new(http_client.clone()).await?; - let model = store.load().await?; + let response: api::Config = http_client.get("/v2/config").await?; let editor = editor .or_else(|| std::env::var("EDITOR").ok()) .unwrap_or(DEFAULT_EDITOR.to_string()); - let result = edit(&http_client, &model, &editor).await?; - tokio::spawn(async move { - show_progress(monitor, true).await; - }); - store.store(&result).await?; - Ok(()) + let result = edit(&http_client, &response, &editor).await?; + patch_config(&http_client, &result).await?; + + monitor_progress(monitor).await?; } } + + Ok(()) +} + +async fn patch_config( + http_client: &BaseHTTPClient, + model: &api::Config, +) -> Result<(), anyhow::Error> { + let model_json = json!(model); + let patch = api::Patch { + update: Some(model_json), + }; + http_client.patch_void("/v2/config", &patch).await?; + Ok(()) } /// Validates a JSON profile with locally available tools only @@ -244,7 +253,7 @@ async fn generate( url_or_path: CliInput, insecure: bool, ) -> anyhow::Result<()> { - let context = match &url_or_path { + let _context = match &url_or_path { CliInput::Stdin | CliInput::Full(_) => InstallationContext::from_env()?, CliInput::Url(url_str) => InstallationContext::from_url_str(url_str)?, CliInput::Path(pathbuf) => InstallationContext::from_file(pathbuf.as_path())?, @@ -288,8 +297,8 @@ async fn generate( return Ok(()); } - // resolves relative URL references - let model = InstallSettings::from_json(&profile_json, &context)?; + // TODO: resolves relative URL references + let model: api::Config = serde_json::from_str(&profile_json)?; let config_json = serde_json::to_string_pretty(&model)?; println!("{}", &config_json); @@ -344,9 +353,9 @@ async fn from_json_or_jsonnet( /// * `editor`: editor command. async fn edit( http_client: &BaseHTTPClient, - model: &InstallSettings, + model: &api::Config, editor: &str, -) -> anyhow::Result { +) -> anyhow::Result { let content = serde_json::to_string_pretty(model)?; let mut file = Builder::new().suffix(".json").tempfile()?; let path = PathBuf::from(file.path()); @@ -361,10 +370,7 @@ async fn edit( let contents = std::fs::read_to_string(&path).context(format!("Reading from file {:?}", path))?; validate(&http_client, CliInput::Full(contents), false).await?; - return Ok(InstallSettings::from_file( - path, - &InstallationContext::from_env()?, - )?); + return Ok(serde_json::from_str(&content)?); } Err(anyhow!( @@ -385,3 +391,15 @@ fn editor_command(command: &str) -> Command { command.args(parts.collect::>()); command } + +async fn monitor_progress(monitor: MonitorClient) -> anyhow::Result<()> { + // wait a bit to settle it down and avoid quick actions blinking + sleep(Duration::from_secs(1)).await; + + let task = tokio::spawn(async move { + show_progress(monitor, true).await; + }); + let _ = task.await?; + + Ok(()) +} diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs index e473c397bf..7b0d6a28fe 100644 --- a/rust/agama-cli/src/lib.rs +++ b/rust/agama-cli/src/lib.rs @@ -18,14 +18,35 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::auth::AuthToken; -use agama_lib::context::InstallationContext; -use agama_lib::manager::{FinishMethod, ManagerHTTPClient}; -use agama_lib::monitor::{Monitor, MonitorClient}; +use std::{ + fs, + os::unix::fs::OpenOptionsExt, + path::PathBuf, + process::{ExitCode, Termination}, + time::Duration, +}; + +use agama_lib::{ + auth::AuthToken, + context::InstallationContext, + error::ServiceError, + http::{BaseHTTPClient, WebSocketClient}, + manager::{FinishMethod, ManagerHTTPClient}, + monitor::{Monitor, MonitorClient}, +}; +use agama_transfer::Transfer; +use agama_utils::api::{self, status::Stage, IssueWithScope}; use anyhow::Context; -use auth_tokens_file::AuthTokensFile; use clap::{Args, Parser}; use fluent_uri::UriRef; +use tokio::time::sleep; +use url::Url; + +use crate::{ + auth::run as run_auth_cmd, auth_tokens_file::AuthTokensFile, commands::Commands, + config::run as run_config_cmd, error::CliError, events::run as run_events_cmd, + logs::run as run_logs_cmd, progress::ProgressMonitor, questions::run as run_questions_cmd, +}; mod auth; mod auth_tokens_file; @@ -39,26 +60,6 @@ mod logs; mod progress; mod questions; -use crate::error::CliError; -use agama_lib::http::{BaseHTTPClient, WebSocketClient}; -use agama_lib::{error::ServiceError, utils::Transfer}; -use auth::run as run_auth_cmd; -use commands::Commands; -use config::run as run_config_cmd; -use events::run as run_events_cmd; -use logs::run as run_logs_cmd; -use progress::ProgressMonitor; -use questions::run as run_questions_cmd; -use std::fs; -use std::os::unix::fs::OpenOptionsExt; -use std::path::PathBuf; -use std::{ - process::{ExitCode, Termination}, - thread::sleep, - time::Duration, -}; -use url::Url; - /// Agama's CLI global options #[derive(Args, Clone)] pub struct GlobalOpts { @@ -107,40 +108,27 @@ async fn probe(manager: ManagerHTTPClient, monitor: MonitorClient) -> anyhow::Re /// Before starting, it makes sure that the manager is idle. /// /// * `manager`: the manager client. -async fn install( - manager: ManagerHTTPClient, - monitor: MonitorClient, - max_attempts: usize, -) -> anyhow::Result<()> { +async fn install(http_client: BaseHTTPClient, monitor: MonitorClient) -> anyhow::Result<()> { wait_until_idle(monitor.clone()).await?; - let status = manager.status().await?; - if !status.can_install { + let status = monitor.get_status().await?; + // TODO: own client for issues? + let issues: Vec = http_client.get("/v2/issues").await?; + if status.stage != Stage::Configuring { + return Err(CliError::Installation)?; + } + if !issues.is_empty() { return Err(CliError::Validation)?; } + let action = api::Action::Install; + http_client.post_void("/v2/action", &action).await?; + + // wait a bit before start monitoring + sleep(Duration::from_secs(1)).await; let progress = tokio::spawn(async { show_progress(monitor, true).await; }); - // Try to start the installation up to max_attempts times. - let mut attempts = 1; - loop { - match manager.install().await { - Ok(()) => break, - Err(e) => { - eprintln!( - "Could not start the installation process: {e}. Attempt {}/{}.", - attempts, max_attempts - ); - } - } - if attempts == max_attempts { - eprintln!("Giving up."); - return Err(CliError::Installation)?; - } - attempts += 1; - sleep(Duration::from_secs(1)); - } let _ = progress.await; Ok(()) } @@ -167,7 +155,7 @@ async fn finish( async fn wait_until_idle(monitor: MonitorClient) -> anyhow::Result<()> { // FIXME: implement something like "wait_until_idle" in the monitor? let status = monitor.get_status().await?; - if status.installer_status.is_busy { + if !status.progresses.is_empty() { eprintln!("The Agama service is busy. Waiting for it to be available..."); show_progress(monitor.clone(), true).await; } @@ -305,9 +293,8 @@ pub async fn run_command(cli: Cli) -> anyhow::Result<()> { } Commands::Install => { let (client, monitor) = build_clients(api_url, cli.opts.insecure).await?; - let manager = ManagerHTTPClient::new(client.clone()); let _ = wait_until_idle(monitor.clone()).await; - install(manager, monitor, 3).await? + install(client, monitor).await? } Commands::Finish { method } => { let (client, monitor) = build_clients(api_url, cli.opts.insecure).await?; diff --git a/rust/agama-cli/src/progress.rs b/rust/agama-cli/src/progress.rs index 7752d35824..d9b42ba9a3 100644 --- a/rust/agama-cli/src/progress.rs +++ b/rust/agama-cli/src/progress.rs @@ -18,22 +18,15 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::{ - monitor::{MonitorClient, MonitorStatus}, - progress::Progress, -}; -use console::style; +use agama_lib::monitor::MonitorClient; +use agama_utils::api::{self, Scope}; use indicatif::{ProgressBar, ProgressStyle}; -use std::time::Duration; - -const MANAGER_PROGRESS_OBJECT_PATH: &str = "/org/opensuse/Agama/Manager1"; -const SOFTWARE_PROGRESS_OBJECT_PATH: &str = "/org/opensuse/Agama/Software1"; +use std::{collections::HashMap, time::Duration}; /// Displays the progress on the terminal. +#[derive(Debug)] pub struct ProgressMonitor { monitor: MonitorClient, - bar: Option, - current_step: u32, running: bool, stop_on_idle: bool, } @@ -45,9 +38,7 @@ impl ProgressMonitor { pub fn new(monitor: MonitorClient) -> Self { Self { monitor, - bar: None, - current_step: 0, - running: false, + running: true, stop_on_idle: true, } } @@ -62,13 +53,43 @@ impl ProgressMonitor { pub async fn run(&mut self) -> anyhow::Result<()> { let mut updates = self.monitor.subscribe(); let status = self.monitor.get_status().await?; - self.update(status).await; + self.update(&status).await; + if !self.running { + return Ok(()); + } + let multibar = indicatif::MultiProgress::new(); + let mut bars: HashMap = HashMap::new(); + multibar.println("Installaton Tasks:")?; loop { if let Ok(status) = updates.recv().await { - if !self.update(status).await { + if !self.update(&status).await { return Ok(()); } + + let mut active_scopes = vec![]; + for progress in status.progresses { + active_scopes.push(progress.scope); + let bar = bars.entry(progress.scope).or_insert_with(|| { + let style = ProgressStyle::with_template("{spinner:.green} {msg}").unwrap(); + let new_bar = ProgressBar::new(progress.size as u64).with_style(style); + new_bar.enable_steady_tick(Duration::from_millis(120)); + multibar.add(new_bar) + }); + bar.set_message(progress.step); + bar.set_position(progress.index as u64); + } + // and finish all that no longer have progress + let mut to_remove = vec![]; + for (scope, bar) in &bars { + if !active_scopes.contains(&scope) { + bar.finish_with_message("done"); + to_remove.push(scope.clone()); + } + } + for scope in to_remove { + bars.remove(&scope); + } } } } @@ -76,63 +97,19 @@ impl ProgressMonitor { /// Updates the progress. /// /// It returns true if the monitor should continue. - async fn update(&mut self, status: MonitorStatus) -> bool { - if status.progress.get(MANAGER_PROGRESS_OBJECT_PATH).is_none() && self.running { + async fn update(&mut self, status: &api::Status) -> bool { + if status.progresses.is_empty() && self.running { self.finish(); if self.stop_on_idle { return false; } } - if let Some(progress) = status.progress.get(MANAGER_PROGRESS_OBJECT_PATH) { - self.running = true; - if self.current_step != progress.current_step { - self.update_main(&progress).await; - self.current_step = progress.current_step; - } - } - - match status.progress.get(SOFTWARE_PROGRESS_OBJECT_PATH) { - Some(progress) => self.update_bar(progress), - None => self.remove_bar(), - } - true } - /// Updates the main bar. - async fn update_main(&mut self, progress: &Progress) { - let counter = format!("[{}/{}]", &progress.current_step, &progress.max_steps); - - println!( - "{} {}", - style(&counter).bold().green(), - &progress.current_title - ); - } - - fn update_bar(&mut self, progress: &Progress) { - let bar = self.bar.get_or_insert_with(|| { - let style = ProgressStyle::with_template("{spinner:.green} {msg}").unwrap(); - let bar = ProgressBar::new(0).with_style(style); - bar.enable_steady_tick(Duration::from_millis(120)); - bar - }); - - bar.set_length(progress.max_steps.into()); - bar.set_position(progress.current_step.into()); - bar.set_message(progress.current_title.to_owned()); - } - - fn remove_bar(&mut self) { - _ = self.bar.take() - } - /// Stops the representation. fn finish(&mut self) { self.running = false; - if let Some(bar) = self.bar.take() { - bar.finish_with_message("Done"); - } } } diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index 3cde3e6839..f15f018b42 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -18,10 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::{ - connection, http::BaseHTTPClient, proxies::questions::QuestionsProxy, - questions::http_client::HTTPClient, -}; +use std::{fs::File, io::BufReader}; + +use agama_lib::{http::BaseHTTPClient, questions::http_client::HTTPClient}; +use agama_utils::api::question::{AnswerRule, Policy, QuestionSpec}; use anyhow::anyhow; use clap::{Args, Subcommand, ValueEnum}; @@ -37,7 +37,7 @@ pub enum QuestionsCommands { /// mode or change the answer in automatic mode. /// /// Please check Agama documentation for more details and examples: - /// https://github.com/openSUSE/agama/blob/master/doc/questions.md + /// https://github.com/openSUSE/agama/blob/master/doc/questions. Answers { /// Path to a file containing the answers in JSON format. path: String, @@ -62,53 +62,47 @@ pub enum Modes { NonInteractive, } -async fn set_mode(proxy: QuestionsProxy<'_>, value: Modes) -> anyhow::Result<()> { - proxy - .set_interactive(value == Modes::Interactive) - .await - .map_err(|e| e.into()) +async fn set_mode(client: HTTPClient, value: Modes) -> anyhow::Result<()> { + let policy = match value { + Modes::Interactive => Policy::User, + Modes::NonInteractive => Policy::Auto, + }; + + client.set_mode(policy).await?; + Ok(()) } -async fn set_answers(proxy: QuestionsProxy<'_>, path: String) -> anyhow::Result<()> { - proxy - .add_answer_file(path.as_str()) - .await - .map_err(|e| e.into()) +async fn set_answers(client: HTTPClient, path: &str) -> anyhow::Result<()> { + let file = File::open(&path)?; + let reader = BufReader::new(file); + let rules: Vec = serde_json::from_reader(reader)?; + client.set_answers(rules).await?; + Ok(()) } -async fn list_questions(client: BaseHTTPClient) -> anyhow::Result<()> { - let client = HTTPClient::new(client); - let questions = client.list_questions().await?; - // FIXME: if performance is bad, we can skip converting json from http to struct and then - // serialize it, but it won't be pretty string +async fn list_questions(client: HTTPClient) -> anyhow::Result<()> { + let questions = client.get_questions().await?; let questions_json = serde_json::to_string_pretty(&questions)?; println!("{}", questions_json); Ok(()) } -async fn ask_question(client: BaseHTTPClient) -> anyhow::Result<()> { - let client = HTTPClient::new(client); - let question = serde_json::from_reader(std::io::stdin())?; - - let created_question = client.create_question(&question).await?; - let Some(id) = created_question.generic.id else { - return Err(anyhow!("The created question does not have an ID")); - }; - let answer = client.get_answer(id).await?; +async fn ask_question(client: HTTPClient) -> anyhow::Result<()> { + let spec: QuestionSpec = serde_json::from_reader(std::io::stdin())?; + let question = client.create_question(&spec).await?; + let answer = client.get_answer(question.id).await?; let answer_json = serde_json::to_string_pretty(&answer).map_err(|e| anyhow!(e.to_string()))?; println!("{}", answer_json); - client.delete_question(id).await?; + client.delete_question(question.id).await?; Ok(()) } pub async fn run(client: BaseHTTPClient, subcommand: QuestionsCommands) -> anyhow::Result<()> { - let connection = connection().await?; - let proxy = QuestionsProxy::new(&connection).await?; - + let client = HTTPClient::new(client); match subcommand { - QuestionsCommands::Mode(value) => set_mode(proxy, value.value).await, - QuestionsCommands::Answers { path } => set_answers(proxy, path).await, + QuestionsCommands::Mode(value) => set_mode(client, value.value).await, + QuestionsCommands::Answers { path } => set_answers(client, &path).await, QuestionsCommands::List => list_questions(client).await, QuestionsCommands::Ask => ask_question(client).await, } diff --git a/rust/agama-files/Cargo.toml b/rust/agama-files/Cargo.toml new file mode 100644 index 0000000000..ce812bd900 --- /dev/null +++ b/rust/agama-files/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "agama-files" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-software = { version = "0.1.0", path = "../agama-software" } +agama-utils = { path = "../agama-utils" } +async-trait = "0.1.89" +tempfile = "3.23.0" +thiserror = "2.0.17" +tokio = { version = "1.48.0", features = ["sync"] } +tracing = "0.1.41" + +[dev-dependencies] +tokio-test = "0.4.4" +test-context = "0.4.1" +serde_json = "1.0.145" diff --git a/rust/agama-files/src/lib.rs b/rust/agama-files/src/lib.rs new file mode 100644 index 0000000000..941fce548f --- /dev/null +++ b/rust/agama-files/src/lib.rs @@ -0,0 +1,157 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This crate implements the support for handling files and scripts in Agama. + +pub mod service; +pub use service::{Service, Starter}; + +pub mod message; +mod runner; +pub use runner::ScriptsRunner; + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use agama_software::test_utils::start_service as start_software_service; + use agama_utils::{ + actor::Handler, + api::{ + event, + files::{scripts::ScriptsGroup, Config}, + Event, + }, + issue, progress, question, + }; + use tempfile::TempDir; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + use crate::{message, service::Error, Service}; + + struct Context { + handler: Handler, + tmp_dir: TempDir, + events_rx: event::Receiver, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + // Set the PATH + let old_path = std::env::var("PATH").unwrap(); + let bin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../share/bin"); + std::env::set_var("PATH", format!("{}:{}", &bin_dir.display(), &old_path)); + + // Set up the chroot + let tmp_dir = TempDir::with_prefix("test").unwrap(); + std::fs::create_dir_all(tmp_dir.path().join("usr/bin")).unwrap(); + std::fs::copy("/usr/bin/install", tmp_dir.path().join("usr/bin/install")).unwrap(); + + // Set up the service + let (events_tx, events_rx) = broadcast::channel::(16); + let issues = issue::Service::starter(events_tx.clone()).start(); + let progress = progress::Service::starter(events_tx.clone()).start(); + let questions = question::start(events_tx.clone()).await.unwrap(); + let software = start_software_service( + events_tx.clone(), + issues, + progress.clone(), + questions.clone(), + ) + .await; + let handler = Service::starter(progress, questions, software) + .with_scripts_workdir(tmp_dir.path()) + .with_install_dir(tmp_dir.path()) + .start() + .await + .unwrap(); + Context { + handler, + tmp_dir, + events_rx, + } + } + } + + #[test_context(Context)] + #[tokio::test] + async fn test_add_and_run_scripts(ctx: &mut Context) -> Result<(), Error> { + let test_file_1 = ctx.tmp_dir.path().join("file-1.txt"); + let test_file_2 = ctx.tmp_dir.path().join("file-2.txt"); + + let pre_script_json = format!( + "{{ \"name\": \"pre.sh\", \"content\": \"#!/usr/bin/bash\\nset -x\\ntouch {}\" }}", + test_file_1.to_str().unwrap() + ); + + let init_script_json = format!( + "{{ \"name\": \"init.sh\", \"content\": \"#!/usr/bin/bash\\ntouch {}\" }}", + test_file_2.to_str().unwrap() + ); + + let config = format!( + "{{ \"scripts\": {{ \"pre\": [{}], \"init\": [{}] }} }}", + pre_script_json, init_script_json + ); + + let config: Config = serde_json::from_str(&config).unwrap(); + ctx.handler + .call(message::SetConfig::with(config)) + .await + .unwrap(); + + ctx.handler + .call(message::RunScripts::new(ScriptsGroup::Pre)) + .await + .unwrap(); + + // Wait until the scripts are executed. + while let Ok(event) = ctx.events_rx.recv().await { + if matches!(event, Event::ProgressFinished { scope: _ }) { + break; + } + } + // Check that only the pre-script ran + assert!(std::fs::exists(&test_file_1).unwrap()); + assert!(!std::fs::exists(&test_file_2).unwrap()); + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_add_and_write_files(ctx: &mut Context) -> Result<(), Error> { + let config = + r#"{ "files": [{ "destination": "/etc/README.md", "content": "Some text" }] }"#; + + let config: Config = serde_json::from_str(&config).unwrap(); + ctx.handler + .call(message::SetConfig::with(config)) + .await + .unwrap(); + + ctx.handler.call(message::WriteFiles).await.unwrap(); + + // Check that the file exists + let expected_path = ctx.tmp_dir.path().join("etc/README.md"); + assert!(std::fs::exists(&expected_path).unwrap()); + Ok(()) + } +} diff --git a/rust/agama-files/src/message.rs b/rust/agama-files/src/message.rs new file mode 100644 index 0000000000..54d24ad160 --- /dev/null +++ b/rust/agama-files/src/message.rs @@ -0,0 +1,67 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_utils::{ + actor::Message, + api::files::{scripts::ScriptsGroup, Config}, +}; + +#[derive(Clone)] +pub struct SetConfig { + pub config: Option, +} + +impl Message for SetConfig { + type Reply = (); +} + +impl SetConfig { + pub fn new(config: Option) -> Self { + Self { config } + } + + pub fn with(config: Config) -> Self { + Self { + config: Some(config), + } + } +} + +#[derive(Clone)] +pub struct RunScripts { + pub group: ScriptsGroup, +} + +impl RunScripts { + pub fn new(group: ScriptsGroup) -> Self { + RunScripts { group } + } +} + +impl Message for RunScripts { + type Reply = (); +} + +#[derive(Clone)] +pub struct WriteFiles; + +impl Message for WriteFiles { + type Reply = (); +} diff --git a/rust/agama-files/src/runner.rs b/rust/agama-files/src/runner.rs new file mode 100644 index 0000000000..37bb58a19d --- /dev/null +++ b/rust/agama-files/src/runner.rs @@ -0,0 +1,462 @@ +// Copyright (c) [2024-2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use std::{ + fs::{self, File}, + io::{self, Read, Seek, SeekFrom, Write}, + os::unix::fs::symlink, + path::{Path, PathBuf}, + process::ExitStatus, +}; + +use agama_utils::{ + actor::Handler, + api::{files::Script, question::QuestionSpec, Scope}, + command::{create_log_file, run_with_retry}, + progress, + question::{self, ask_question}, +}; +use tokio::process; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("The script failed")] + Script { status: ExitStatus, stderr: String }, + #[error(transparent)] + Question(#[from] question::AskError), +} + +// Relative path to the resolv.conf file. +const RESOLV_CONF_PATH: &str = "etc/resolv.conf"; +// Relative path to the NetworkManager resolv.conf file. +const NM_RESOLV_CONF_PATH: &str = "run/NetworkManager/resolv.conf"; + +/// Implements the logic to run a script. +/// +/// It takes care of running the script, reporting errors (and asking whether to retry) and write +/// the logs. +pub struct ScriptsRunner { + progress: Handler, + questions: Handler, + install_dir: PathBuf, + workdir: PathBuf, +} + +impl ScriptsRunner { + /// Creates a new runner. + /// + /// * `install_dir`: directory where the system is being installed. It is relevant for + /// chrooted scripts. + /// * `workdir`: scripts work directory. + /// * `progress`: handler to report the progress. + /// * `questions`: handler to interact with the user. + pub fn new>( + install_dir: P, + workdir: P, + progress: Handler, + questions: Handler, + ) -> Self { + Self { + progress, + questions, + install_dir: install_dir.as_ref().to_path_buf(), + workdir: workdir.as_ref().to_path_buf(), + } + } + + /// Runs the given scripts. + /// + /// It runs each script. If something goes wrong, it reports the problem to the user through + /// the questions mechanism. + /// + /// * `scripts`: scripts to run. + pub async fn run(&self, scripts: &[&Script]) -> Result<(), Error> { + self.start_progress(scripts); + + let mut resolv_linked = false; + if scripts.iter().any(|s| s.chroot()) { + resolv_linked = self.link_resolv()?; + } + + for script in scripts { + _ = self + .progress + .cast(progress::message::Next::new(Scope::Files)); + self.run_script(script).await?; + } + + if resolv_linked { + self.unlink_resolv(); + } + + _ = self + .progress + .cast(progress::message::Finish::new(Scope::Files)); + Ok(()) + } + + /// Runs the script. + /// + /// If the script fails, it asks the user whether it should try again. + async fn run_script(&self, script: &Script) -> Result<(), Error> { + loop { + let path = self + .workdir + .join(script.group().to_string()) + .join(script.name()); + + let Err(error) = self.run_command(&path, script.chroot()).await else { + return Ok(()); + }; + + if !self.should_retry(&script, error).await? { + return Ok(()); + } + } + } + + /// Asks the user whether it should try to run the script again. + async fn should_retry(&self, script: &Script, error: Error) -> Result { + let text = format!( + "Running the script '{}' failed. Do you want to try again?", + script.name() + ); + let mut question = QuestionSpec::new(&text, "scripts.retry").with_yes_no_actions(); + + if let Error::Script { status, stderr } = error { + let exit_status = status + .code() + .map(|c| c.to_string()) + .unwrap_or("unknown".to_string()); + question = question.with_data(&[ + ("name", script.name()), + ("stderr", &stderr), + ("exit_status", &exit_status), + ]); + } + + let answer = ask_question(&self.questions, question).await?; + return Ok(answer.action == "Yes"); + } + + /// Runs the script at the given path. + /// + /// * `path`: script's path. + /// * `chroot`: whether to run the script in a chroot. + async fn run_command>(&self, path: P, chroot: bool) -> Result<(), Error> { + const STDERR_SIZE: u64 = 512; + + let path = path.as_ref(); + let stdout_file = path.with_extension("stdout"); + let stderr_file = path.with_extension("stderr"); + + let mut command = if chroot { + let mut command = process::Command::new("chroot"); + command.args([&self.install_dir, path]); + command + } else { + process::Command::new(path) + }; + + command + .stdout(create_log_file(&stdout_file)?) + .stderr(create_log_file(&stderr_file)?); + + let output = run_with_retry(command) + .await + .inspect_err(|e| println!("Error executing the script: {e}"))?; + + if let Some(code) = output.status.code() { + let mut file = create_log_file(&path.with_extension("exit"))?; + write!(&mut file, "{}", code)?; + } + + if !output.status.success() { + let stderr = Self::read_n_last_bytes(&stderr_file, STDERR_SIZE)?; + return Err(Error::Script { + status: output.status, + stderr, + }); + } + + Ok(()) + } + + /// Ancillary function to start the progress. + fn start_progress(&self, scripts: &[&Script]) { + let steps: Vec<_> = scripts + .iter() + .map(|s| format!("Running user script '{}'", s.name())) + .collect(); + let progress_action = progress::message::StartWithSteps::new(Scope::Files, steps); + _ = self.progress.cast(progress_action); + } + + /// Reads the last n bytes of the file and returns them as a string. + fn read_n_last_bytes(path: &Path, n_bytes: u64) -> io::Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let offset = file_size.saturating_sub(n_bytes); + file.seek(SeekFrom::Start(offset))?; + let bytes_to_read = (file_size - offset) as usize; + let mut buffer = Vec::with_capacity(bytes_to_read); + _ = file.read_to_end(&mut buffer)?; + let string = String::from_utf8_lossy(&buffer); + Ok(string.into_owned()) + } + + /// Make sures that the resolv.conf is linked and returns true if any action was needed. + /// + /// It returns false if the resolv.conf was already linked and no action was required. + fn link_resolv(&self) -> Result { + let original = self.install_dir.join(NM_RESOLV_CONF_PATH); + let link = self.resolv_link_path(); + + if fs::exists(&link)? || !fs::exists(&original)? { + return Ok(false); + } + + // It assumes that the directory of the resolv.conf (/etc) exists. + symlink(original.as_path(), link.as_path())?; + Ok(true) + } + + /// Removes the resolv.conf file from the chroot. + fn unlink_resolv(&self) { + let link = self.resolv_link_path(); + if let Err(error) = fs::remove_file(link) { + tracing::warn!("Could not remove the resolv.conf link: {error}"); + } + } + + fn resolv_link_path(&self) -> PathBuf { + self.install_dir.join(RESOLV_CONF_PATH) + } +} + +#[cfg(test)] +mod tests { + use agama_utils::{ + api::{ + event, + files::{BaseScript, FileSource, PostScript}, + question::Answer, + Event, + }, + question::test_utils::wait_for_question, + }; + use tempfile::TempDir; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + use super::*; + + struct Context { + // runner: ScriptsRunner, + install_dir: PathBuf, + workdir: PathBuf, + progress: Handler, + questions: Handler, + events_rx: event::Receiver, + tmp_dir: TempDir, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + // Set the PATH + let old_path = std::env::var("PATH").unwrap(); + let bin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../share/bin"); + std::env::set_var("PATH", format!("{}:{}", &bin_dir.display(), &old_path)); + + let tmp_dir = TempDir::with_prefix("scripts-").expect("a temporary directory"); + + let (events_tx, events_rx) = broadcast::channel::(16); + let install_dir = tmp_dir.path().join("mnt"); + let workdir = tmp_dir.path().join("scripts"); + let questions = question::start(events_tx.clone()).await.unwrap(); + let progress = progress::Service::starter(events_tx.clone()).start(); + + Context { + events_rx, + install_dir, + workdir, + progress, + questions, + // runner, + tmp_dir, + } + } + } + + impl Context { + pub fn runner(&self) -> ScriptsRunner { + ScriptsRunner::new( + self.install_dir.clone(), + self.workdir.clone(), + self.progress.clone(), + self.questions.clone(), + ) + } + + pub fn setup_script(&self, content: &str, chroot: bool) -> Script { + let base = BaseScript { + name: "test.sh".to_string(), + source: FileSource::Text { + content: content.to_string(), + }, + }; + let script = Script::Post(PostScript { + base, + chroot: Some(chroot), + }); + script + .write(&self.workdir) + .expect("Could not write the script"); + script + } + + // Set up a fake chroot. + pub fn setup_chroot(&self) -> std::io::Result<()> { + let nm_dir = self.install_dir.join("run/NetworkManager"); + fs::create_dir_all(&nm_dir)?; + fs::create_dir_all(self.install_dir.join("etc"))?; + + let mut file = File::create(nm_dir.join("resolv.conf"))?; + file.write_all(b"nameserver 127.0.0.1\n")?; + + Ok(()) + } + + // Return the content of a script result file. + pub fn result_content(&self, script_type: &str, name: &str) -> String { + let path = &self.workdir.join(script_type).join(name); + let body: Vec = std::fs::read(path).unwrap(); + String::from_utf8(body).unwrap() + } + } + + #[test_context(Context)] + #[tokio::test] + async fn test_run_scripts_success(ctx: &mut Context) -> Result<(), Error> { + let file = ctx.tmp_dir.path().join("file-1.txt"); + let content = format!( + "#!/usr/bin/bash\necho hello\necho error >&2\ntouch {}", + file.display() + ); + let script = ctx.setup_script(&content, false); + let scripts = vec![&script]; + + let runner = ctx.runner(); + runner.run(&scripts).await.unwrap(); + + assert_eq!(ctx.result_content("post", "test.stdout"), "hello\n"); + assert_eq!(ctx.result_content("post", "test.stderr"), "error\n"); + assert_eq!(ctx.result_content("post", "test.exit"), "0"); + + assert!(std::fs::exists(file).unwrap()); + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_chrooted_script(ctx: &mut Context) -> Result<(), Error> { + ctx.setup_chroot()?; + + // Ideally, the script should check the existence of /etc/resolv.conf. + // However, it does not run on a real chroot (see share/bin/chroot), + // so it needs the whole path. + let file = ctx.install_dir.join("etc/resolv.conf"); + let content = format!("#!/usr/bin/bash\ntest -h {} && echo exists", file.display()); + let script = ctx.setup_script(&content, true); + + let runner = ctx.runner(); + let scripts = vec![&script]; + runner.run(&scripts).await.unwrap(); + + // It runs successfully because the resolv.conf link exists. + assert_eq!(ctx.result_content("post", "test.stdout"), "exists\n"); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_run_scripts_retry(ctx: &mut Context) -> Result<(), Error> { + let file = ctx.tmp_dir.path().join("file-1.txt"); + let content = format!( + "#!/usr/bin/bash\necho \"hello\"\necho \"line\" >>{}\nagama-unknown\n", + file.display() + ); + let script = ctx.setup_script(&content, false); + + let runner = ctx.runner(); + tokio::task::spawn(async move { + let scripts = vec![&script]; + _ = runner.run(&scripts).await; + }); + + // Retry + let id = wait_for_question(&mut ctx.events_rx) + .await + .expect("Did not receive a question"); + _ = ctx.questions.cast(question::message::Answer { + id, + answer: Answer::new("Yes"), + }); + + // Check the question content + let questions = ctx + .questions + .call(question::message::Get) + .await + .expect("Could not get the questions"); + let question = questions.first().unwrap(); + assert_eq!(question.spec.data.get("name"), Some(&"test.sh".to_string())); + assert_eq!( + question.spec.data.get("exit_status"), + Some(&"127".to_string()) + ); + let stderr = question.spec.data.get("stderr").unwrap(); + assert!(stderr.contains("agama-unknown")); + + // Do not retry + let id = wait_for_question(&mut ctx.events_rx) + .await + .expect("Did not receive a question"); + _ = ctx.questions.cast(question::message::Answer { + id, + answer: Answer::new("No"), + }); + + // Check the generated files + let path = &ctx.workdir.join("post").join("test.stderr"); + let body: Vec = std::fs::read(path).unwrap(); + let body = String::from_utf8(body).unwrap(); + assert!(body.contains("agama-unknown")); + + let body: Vec = std::fs::read(&file).unwrap(); + let body = String::from_utf8(body).unwrap(); + assert_eq!("line\nline\n", body); + + Ok(()) + } +} diff --git a/rust/agama-files/src/service.rs b/rust/agama-files/src/service.rs new file mode 100644 index 0000000000..62393fb9b4 --- /dev/null +++ b/rust/agama-files/src/service.rs @@ -0,0 +1,223 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use agama_software::{self as software, Resolvable, ResolvableType}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::files::{ + scripts::{self, ScriptsRepository}, + user_file, ScriptsConfig, UserFile, + }, + progress, question, +}; +use async_trait::async_trait; +use tokio::sync::Mutex; + +use crate::{message, ScriptsRunner}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Files(#[from] user_file::Error), + #[error(transparent)] + Scripts(#[from] scripts::Error), + #[error(transparent)] + Software(#[from] software::service::Error), + #[error(transparent)] + Actor(#[from] actor::Error), +} + +const DEFAULT_SCRIPTS_DIR: &str = "/run/agama/scripts"; +const DEFAULT_INSTALL_DIR: &str = "/mnt"; + +/// Builds and spawns the files service. +/// +/// This structs allows to build a files service. +pub struct Starter { + scripts_workdir: PathBuf, + install_dir: PathBuf, + software: Handler, + progress: Handler, + questions: Handler, +} + +impl Starter { + /// Creates a new starter. + /// + /// * `events`: channel to emit the [localization-specific events](crate::Event). + pub fn new( + progress: Handler, + questions: Handler, + software: Handler, + ) -> Self { + Self { + software, + progress, + questions, + scripts_workdir: PathBuf::from(DEFAULT_SCRIPTS_DIR), + install_dir: PathBuf::from(DEFAULT_INSTALL_DIR), + } + } + + /// Starts the service and returns the handler to communicate with it. + pub async fn start(self) -> Result, Error> { + let scripts = ScriptsRepository::new(self.scripts_workdir); + let service = Service { + progress: self.progress, + questions: self.questions, + software: self.software, + scripts: Arc::new(Mutex::new(scripts)), + files: vec![], + install_dir: self.install_dir, + }; + let handler = actor::spawn(service); + Ok(handler) + } + + pub fn with_scripts_workdir>(mut self, workdir: P) -> Self { + self.scripts_workdir = PathBuf::from(workdir.as_ref()); + self + } + + pub fn with_install_dir>(mut self, install_dir: P) -> Self { + self.install_dir = PathBuf::from(install_dir.as_ref()); + self + } +} + +pub struct Service { + software: Handler, + progress: Handler, + questions: Handler, + scripts: Arc>, + files: Vec, + install_dir: PathBuf, +} + +impl Service { + pub fn starter( + progress: Handler, + questions: Handler, + software: Handler, + ) -> Starter { + Starter::new(progress, questions, software) + } + + pub async fn clear_scripts(&mut self) -> Result<(), Error> { + let mut repo = self.scripts.lock().await; + repo.clear()?; + Ok(()) + } + + pub async fn add_scripts(&mut self, config: ScriptsConfig) -> Result<(), Error> { + let mut repo = self.scripts.lock().await; + if let Some(scripts) = config.pre { + for pre in scripts { + repo.add(pre.into())?; + } + } + + if let Some(scripts) = config.post_partitioning { + for post in scripts { + repo.add(post.into())?; + } + } + + if let Some(scripts) = config.post { + for post in scripts { + repo.add(post.into())?; + } + } + + let mut packages = vec![]; + if let Some(scripts) = config.init { + for init in scripts { + repo.add(init.into())?; + } + packages.push(Resolvable::new("agama-scripts", ResolvableType::Package)); + } + _ = self + .software + .call(agama_software::message::SetResolvables::new( + "agama-scripts".to_string(), + packages, + )) + .await?; + Ok(()) + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + let config = message.config.unwrap_or_default(); + + self.clear_scripts().await?; + if let Some(scripts) = config.scripts { + self.add_scripts(scripts.clone()).await?; + } + + if let Some(files) = config.files { + self.files = files; + } else { + self.files.clear(); + } + + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::RunScripts) -> Result<(), Error> { + let scripts = self.scripts.clone(); + let install_dir = self.install_dir.clone(); + let progress = self.progress.clone(); + let questions = self.questions.clone(); + + tokio::task::spawn(async move { + let scripts = scripts.lock().await; + let workdir = scripts.workdir.clone(); + let to_run = scripts.by_group(message.group).clone(); + let runner = ScriptsRunner::new(install_dir, workdir, progress, questions); + runner.run(&to_run).await.unwrap(); + }); + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::WriteFiles) -> Result<(), Error> { + for file in &self.files { + file.write(&self.install_dir).await?; + } + Ok(()) + } +} diff --git a/rust/agama-hostname/Cargo.toml b/rust/agama-hostname/Cargo.toml new file mode 100644 index 0000000000..187eefbf54 --- /dev/null +++ b/rust/agama-hostname/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "agama-hostname" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-utils = { version = "0.1.0", path = "../agama-utils" } +anyhow = "1.0.100" +async-trait = "0.1.89" +tempfile = "3.23.0" +thiserror = "2.0.17" +tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.17" +tokio-test = "0.4.4" +tracing = "0.1.43" +zbus = "5.12.0" + +[dev-dependencies] +tempfile = "3.23.0" +test-context = "0.4.1" diff --git a/rust/agama-hostname/src/dbus.rs b/rust/agama-hostname/src/dbus.rs new file mode 100644 index 0000000000..5c4630d61f --- /dev/null +++ b/rust/agama-hostname/src/dbus.rs @@ -0,0 +1,155 @@ +//! # D-Bus interface proxy for: `org.freedesktop.hostname1` +//! +//! This code was generated by `zbus-xmlgen` `4.1.0` from D-Bus introspection data. +//! Source: `Interface '/org/freedesktop/hostname1' from service 'org.freedesktop.hostname1' on system bus`. +//! +//! You may prefer to adapt it, instead of using it verbatim. +//! +//! More information can be found in the [Writing a client proxy] section of the zbus +//! documentation. +//! +//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the +//! following zbus API can be used: +//! +//! * [`zbus::fdo::PeerProxy`] +//! * [`zbus::fdo::IntrospectableProxy`] +//! * [`zbus::fdo::PropertiesProxy`] +//! +//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. +//! +//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html +//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, +use zbus::proxy; +#[proxy( + interface = "org.freedesktop.hostname1", + default_service = "org.freedesktop.hostname1", + default_path = "/org/freedesktop/hostname1" +)] +pub trait Hostname1 { + /// Describe method + fn describe(&self) -> zbus::Result; + + /// GetHardwareSerial method + fn get_hardware_serial(&self) -> zbus::Result; + + /// GetProductUUID method + #[zbus(name = "GetProductUUID")] + fn get_product_uuid(&self, interactive: bool) -> zbus::Result>; + + /// SetChassis method + fn set_chassis(&self, chassis: &str, interactive: bool) -> zbus::Result<()>; + + /// SetDeployment method + fn set_deployment(&self, deployment: &str, interactive: bool) -> zbus::Result<()>; + + /// SetHostname method + fn set_hostname(&self, hostname: &str, interactive: bool) -> zbus::Result<()>; + + /// SetIconName method + fn set_icon_name(&self, icon: &str, interactive: bool) -> zbus::Result<()>; + + /// SetLocation method + fn set_location(&self, location: &str, interactive: bool) -> zbus::Result<()>; + + /// SetPrettyHostname method + fn set_pretty_hostname(&self, hostname: &str, interactive: bool) -> zbus::Result<()>; + + /// SetStaticHostname method + fn set_static_hostname(&self, hostname: &str, interactive: bool) -> zbus::Result<()>; + + /// BootID property + #[zbus(property, name = "BootID")] + fn boot_id(&self) -> zbus::Result>; + + /// Chassis property + #[zbus(property)] + fn chassis(&self) -> zbus::Result; + + /// DefaultHostname property + #[zbus(property)] + fn default_hostname(&self) -> zbus::Result; + + /// Deployment property + #[zbus(property)] + fn deployment(&self) -> zbus::Result; + + /// FirmwareDate property + #[zbus(property)] + fn firmware_date(&self) -> zbus::Result; + + /// FirmwareVendor property + #[zbus(property)] + fn firmware_vendor(&self) -> zbus::Result; + + /// FirmwareVersion property + #[zbus(property)] + fn firmware_version(&self) -> zbus::Result; + + /// HardwareModel property + #[zbus(property)] + fn hardware_model(&self) -> zbus::Result; + + /// HardwareVendor property + #[zbus(property)] + fn hardware_vendor(&self) -> zbus::Result; + + /// HomeURL property + #[zbus(property, name = "HomeURL")] + fn home_url(&self) -> zbus::Result; + + /// Hostname property + #[zbus(property)] + fn hostname(&self) -> zbus::Result; + + /// HostnameSource property + #[zbus(property)] + fn hostname_source(&self) -> zbus::Result; + + /// IconName property + #[zbus(property)] + fn icon_name(&self) -> zbus::Result; + + /// KernelName property + #[zbus(property)] + fn kernel_name(&self) -> zbus::Result; + + /// KernelRelease property + #[zbus(property)] + fn kernel_release(&self) -> zbus::Result; + + /// KernelVersion property + #[zbus(property)] + fn kernel_version(&self) -> zbus::Result; + + /// Location property + #[zbus(property)] + fn location(&self) -> zbus::Result; + + /// MachineID property + #[zbus(property, name = "MachineID")] + fn machine_id(&self) -> zbus::Result>; + + /// OperatingSystemCPEName property + #[zbus(property, name = "OperatingSystemCPEName")] + fn operating_system_cpename(&self) -> zbus::Result; + + /// OperatingSystemPrettyName property + #[zbus(property)] + fn operating_system_pretty_name(&self) -> zbus::Result; + + /// OperatingSystemSupportEnd property + #[zbus(property)] + fn operating_system_support_end(&self) -> zbus::Result; + + /// PrettyHostname property + #[zbus(property)] + fn pretty_hostname(&self) -> zbus::Result; + + /// StaticHostname property + #[zbus(property)] + fn static_hostname(&self) -> zbus::Result; + + /// VSockCID property + #[zbus(property, name = "VSockCID")] + fn vsock_cid(&self) -> zbus::Result; +} diff --git a/rust/agama-hostname/src/lib.rs b/rust/agama-hostname/src/lib.rs new file mode 100644 index 0000000000..a4a09b8c4f --- /dev/null +++ b/rust/agama-hostname/src/lib.rs @@ -0,0 +1,114 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This crate implements the support for hostname handling in Agama. +//! It takes care of setting the hostname for Agama itself and copying it +//! to the target system in case of an static one. +//! +//! * The [Proposal] struct that describes how the system will look like after +//! the installation. +//! * The [SystemInfo] which includes information about the system +//! where Agama is running. +//! * An [specific event type](Event) for hostname-related events. +//! +//! The service can be started by calling the [start_service] function, which +//! returns a [agama_utils::actors::ActorHandler] to interact with the system. + +pub mod service; +pub use service::{Service, Starter}; + +mod dbus; +pub mod message; +mod model; +pub use model::{Model, ModelAdapter}; +mod monitor; +pub mod test_utils; + +#[cfg(test)] +mod tests { + use crate::{message, service::Service, test_utils::start_service}; + + use agama_utils::{ + actor::Handler, + api::{self, event::Event, scope::Scope}, + issue, + }; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + struct Context { + events_rx: broadcast::Receiver, + handler: Handler, + issues: Handler, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + let (events_tx, events_rx) = broadcast::channel::(16); + let issues = issue::Service::starter(events_tx.clone()).start(); + + let handler = start_service(events_tx, issues.clone()).await; + + Self { + events_rx, + handler, + issues, + } + } + } + + #[test_context(Context)] + #[tokio::test] + async fn test_get_and_set_config(ctx: &mut Context) -> Result<(), Box> { + let mut config = ctx.handler.call(message::GetConfig).await.unwrap(); + assert_eq!(config.r#static, Some("test-hostname".to_string())); + config.r#static = Some("".to_string()); + config.hostname = Some("test".to_string()); + + ctx.handler.call(message::SetConfig::with(config)).await?; + + let updated = ctx.handler.call(message::GetConfig).await?; + assert_eq!( + &updated, + &api::hostname::Config { + r#static: Some("".to_string()), + hostname: Some("test".to_string()) + } + ); + + let proposal = ctx.handler.call(message::GetProposal).await?; + assert!(proposal.is_some()); + + let event = ctx + .events_rx + .recv() + .await + .expect("Did not receive the event"); + + assert!(matches!( + event, + Event::ProposalChanged { + scope: Scope::Hostname + } + )); + + Ok(()) + } +} diff --git a/rust/agama-hostname/src/message.rs b/rust/agama-hostname/src/message.rs new file mode 100644 index 0000000000..94caa29bd0 --- /dev/null +++ b/rust/agama-hostname/src/message.rs @@ -0,0 +1,99 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_utils::{ + actor::Message, + api::hostname::{Config, Proposal, SystemInfo}, +}; + +#[derive(Clone)] +pub struct GetSystem; + +impl Message for GetSystem { + type Reply = SystemInfo; +} + +pub struct SetSystem { + pub config: T, +} + +impl Message for SetSystem { + type Reply = (); +} + +impl SetSystem { + pub fn new(config: T) -> Self { + Self { config } + } +} + +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = Config; +} + +pub struct SetConfig { + pub config: Option, +} + +impl Message for SetConfig { + type Reply = (); +} + +impl SetConfig { + pub fn new(config: Option) -> Self { + Self { config } + } + + pub fn with(config: T) -> Self { + Self { + config: Some(config), + } + } +} + +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option; +} + +pub struct UpdateHostname { + pub name: String, +} + +impl Message for UpdateHostname { + type Reply = (); +} + +pub struct UpdateStaticHostname { + pub name: String, +} + +impl Message for UpdateStaticHostname { + type Reply = (); +} + +pub struct Install; + +impl Message for Install { + type Reply = (); +} diff --git a/rust/agama-hostname/src/model.rs b/rust/agama-hostname/src/model.rs new file mode 100644 index 0000000000..919c506d4e --- /dev/null +++ b/rust/agama-hostname/src/model.rs @@ -0,0 +1,184 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::service; +use agama_utils::api::hostname::SystemInfo; +use std::{fs, path::PathBuf, process::Command}; + +/// Abstract the hostname-related configuration from the underlying system. +/// +/// It offers an API to query and set the transient or static hostname of a +/// system. This trait can be implemented to replace the real system during +/// tests. +pub trait ModelAdapter: Send + 'static { + /// Reads the system info. + fn system_info(&self) -> Result { + let name = self.static_hostname()?; + + Ok(SystemInfo { + r#static: (!name.is_empty()).then(|| name), + hostname: self.hostname()?, + }) + } + + /// Current system hostname. + fn hostname(&self) -> Result; + + /// Current system static hostname. + fn static_hostname(&self) -> Result; + + /// Change the system static hostname. + fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error>; + + /// Change the system hostname + fn set_hostname(&mut self, name: String) -> Result<(), service::Error>; + + /// Apply the changes to target system. It is expected to be called almost + /// at the end of the installation. + fn install(&self) -> Result<(), service::Error>; + + // Target directory to copy the static hostname at the end of the installation + fn static_target_dir(&self) -> &str; +} + +/// [ModelAdapter] implementation for systemd-based systems. +pub struct Model; + +impl ModelAdapter for Model { + fn static_hostname(&self) -> Result { + let mut cmd = Command::new("hostnamectl"); + cmd.args(["hostname", "--static"]); + tracing::info!("{:?}", &cmd); + let output = cmd.output()?; + tracing::info!("{:?}", &output); + + let output = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + Ok(output) + } + + fn hostname(&self) -> Result { + let output = Command::new("hostnamectl") + .args(["hostname", "--transient"]) + .output()?; + let output = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + Ok(output) + } + + fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error> { + Command::new("hostnamectl") + .args(["set-hostname", "--static", name.as_str()]) + .output()?; + + Ok(()) + } + + fn set_hostname(&mut self, name: String) -> Result<(), service::Error> { + Command::new("hostnamectl") + .args(["set-hostname", "--transient", name.as_str()]) + .output()?; + Ok(()) + } + + fn static_target_dir(&self) -> &str { + "/mnt" + } + + /// Copy the static hostname to the target system + fn install(&self) -> Result<(), service::Error> { + const HOSTNAME_PATH: &str = "/etc/hostname"; + let from = PathBuf::from(HOSTNAME_PATH); + if from.exists() { + let to = PathBuf::from(self.static_target_dir()).join(HOSTNAME_PATH); + fs::create_dir_all(to.parent().unwrap())?; + fs::copy(from, to)?; + } + Ok(()) + } +} +#[cfg(test)] +pub mod tests { + use super::*; + use tempfile::tempdir; + + #[derive(Clone)] + pub struct TestModel { + pub source_dir: PathBuf, + pub target_dir: PathBuf, + } + + impl ModelAdapter for TestModel { + fn hostname(&self) -> Result { + Ok("test-hostname".to_string()) + } + + fn static_hostname(&self) -> Result { + let path = self.source_dir.join("etc/hostname"); + fs::read_to_string(path).map_err(service::Error::from) + } + + fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error> { + let path = self.source_dir.join("etc/hostname"); + fs::write(path, name).map_err(service::Error::from) + } + + fn set_hostname(&mut self, _name: String) -> Result<(), service::Error> { + Ok(()) + } + + fn install(&self) -> Result<(), service::Error> { + let from = self.source_dir.join("etc/hostname"); + if from.exists() { + let to = self.target_dir.join("etc/hostname"); + fs::create_dir_all(to.parent().unwrap())?; + fs::copy(from, to)?; + } + Ok(()) + } + + fn static_target_dir(&self) -> &str { + self.target_dir.to_str().unwrap() + } + } + + #[test] + fn test_install() -> Result<(), service::Error> { + let temp_source = tempdir()?; + let temp_target = tempdir()?; + let hostname_path = temp_source.path().join("etc"); + fs::create_dir_all(&hostname_path)?; + fs::write(hostname_path.join("hostname"), "test-hostname")?; + + let model = TestModel { + source_dir: temp_source.path().to_path_buf(), + target_dir: temp_target.path().to_path_buf(), + }; + + model.install()?; + + let installed_hostname_path = temp_target.path().join("etc/hostname"); + assert!(fs::exists(&installed_hostname_path)?); + let content = fs::read_to_string(installed_hostname_path)?; + assert_eq!(content, "test-hostname"); + + Ok(()) + } +} diff --git a/rust/agama-hostname/src/monitor.rs b/rust/agama-hostname/src/monitor.rs new file mode 100644 index 0000000000..eb58990a35 --- /dev/null +++ b/rust/agama-hostname/src/monitor.rs @@ -0,0 +1,90 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{message, service::Service}; +use agama_utils::{ + actor::Handler, + dbus::{get_property, to_owned_hash}, +}; +use tokio_stream::StreamExt; +use zbus::fdo::{PropertiesChangedStream, PropertiesProxy}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + DBus(#[from] zbus::Error), +} + +pub struct Monitor { + handler: Handler, + stream: PropertiesChangedStream, +} + +// Monitors the DBUS hostname service notifying the static or transient system hostname change +// when them occurs +// +/// * `handler`: service handler to be monitorized. +impl Monitor { + pub async fn new(handler: Handler) -> Result { + let dbus = zbus::Connection::system().await?; + let proxy = PropertiesProxy::builder(&dbus) + .path("/org/freedesktop/hostname1")? + .destination("org.freedesktop.hostname1")? + .build() + .await?; + let stream = proxy + .receive_properties_changed() + .await + .map_err(Error::DBus)?; + Ok(Self { handler, stream }) + } + + pub async fn run(&mut self) { + while let Some(changes) = self.stream.next().await { + let Ok(args) = changes.args() else { + continue; + }; + + let changes = args.changed_properties(); + let Ok(changes) = to_owned_hash(changes) else { + continue; + }; + + if let Ok(name) = get_property::(&changes, "Hostname") { + let _ = self.handler.call(message::UpdateHostname { name }).await; + } + if let Ok(name) = get_property::(&changes, "StaticHostname") { + let _ = self + .handler + .call(message::UpdateStaticHostname { name }) + .await; + } + } + } +} + +/// Spawns a Tokio task for the monitor. +/// +/// * `monitor`: monitor to spawn. +pub fn spawn(mut monitor: Monitor) { + tokio::spawn(async move { + monitor.run().await; + }); +} diff --git a/rust/agama-hostname/src/service.rs b/rust/agama-hostname/src/service.rs new file mode 100644 index 0000000000..f9c337d408 --- /dev/null +++ b/rust/agama-hostname/src/service.rs @@ -0,0 +1,276 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::monitor::Monitor; +use crate::{message, monitor}; +use crate::{Model, ModelAdapter}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{ + self, + event::{self, Event}, + hostname::{Proposal, SystemInfo}, + Issue, Scope, + }, + issue, +}; +use async_trait::async_trait; +use tokio::sync::broadcast; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Invalid hostname")] + InvalidHostname, + #[error(transparent)] + Event(#[from] broadcast::error::SendError), + #[error(transparent)] + IssueService(#[from] issue::service::Error), + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + IO(#[from] std::io::Error), + #[error(transparent)] + Generic(#[from] anyhow::Error), + #[error("There is no proposal for hostname")] + MissingProposal, +} + +/// Builds and spawns the hostname service. +/// +/// This struct allows to build a hostname service. It allows replacing +/// the "model" for a custom one. +/// +/// It spawns two Tokio tasks: +/// +/// - The main service, which is reponsible for holding and applying the configuration. +/// - A monitor which checks for changes in the underlying system (e.g., changing the hostname) +/// and signals the main service accordingly. +/// - It depends on the issues service to keep the installation issues. +pub struct Starter { + model: Option>, + issues: Handler, + events: event::Sender, +} + +impl Starter { + /// Creates a new starter. + /// + /// * `events`: channel to emit the [hostname-specific events](crate::Event). + /// * `issues`: handler to the issues service. + pub fn new(events: event::Sender, issues: Handler) -> Self { + Self { + model: None, + events, + issues, + } + } + + /// Uses the given model. + /// + /// By default, the hostname service relies on systemd. However, it might be useful + /// to replace it in some scenarios (e.g., when testing). + /// + /// * `model`: model to use. It must implement the [ModelAdapter] trait. + pub fn with_model(mut self, model: T) -> Self { + self.model = Some(Box::new(model)); + self + } + + /// Starts the service and returns a handler to communicate with it. + /// + /// The service uses a separate monitor to listen to system configuration + /// changes. + pub async fn start(self) -> Result, Error> { + let model = match self.model { + Some(model) => model, + None => Box::new(Model), + }; + + let config = model.system_info()?; + + let service = Service { + config, + model, + issues: self.issues, + events: self.events, + }; + let handler = actor::spawn(service); + Self::start_monitor(handler.clone()).await; + Ok(handler) + } + + pub async fn start_monitor(handler: Handler) { + match Monitor::new(handler.clone()).await { + Ok(monitor) => monitor::spawn(monitor), + Err(error) => { + tracing::error!( + "Could not launch the hostname monitor, therefore changes from systemd will be ignored. \ + The original error was {error}" + ); + } + } + } +} + +/// Hostname service. +/// +/// It is responsible for handling the hostname part of the installation: +/// +/// * Reads the static and transient hostname +/// * Keeps track of the hostname settings of the underlying system (the installer). +/// * Persist the static hostname at the end of the installation. +pub struct Service { + config: SystemInfo, + model: Box, + issues: Handler, + events: event::Sender, +} + +impl Service { + pub fn starter(events: event::Sender, issues: Handler) -> Starter { + Starter::new(events, issues) + } + + fn get_proposal(&self) -> Option { + if !self.find_issues().is_empty() { + return None; + } + + Some(Proposal { + hostname: self.config.hostname.clone(), + r#static: self.config.r#static.clone(), + }) + } + + /// Returns configuration issues. + /// + /// It returns issues if the hostname are too long + fn find_issues(&self) -> Vec { + // TODO: add length checks + vec![] + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetSystem) -> Result { + Ok(self.config.clone()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + _message: message::GetConfig, + ) -> Result { + Ok(api::hostname::Config { + r#static: self.config.r#static.clone(), + hostname: Some(self.config.hostname.clone()), + }) + } +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle( + &mut self, + message: message::SetConfig, + ) -> Result<(), Error> { + let current = self.config.clone(); + + if let Some(config) = &message.config { + self.config.r#static = config.r#static.clone(); + self.model + .set_static_hostname(config.r#static.clone().unwrap_or_default())?; + if let Some(name) = &config.r#static { + self.config.hostname = name.clone(); + } + + if let Some(name) = &config.hostname { + // If static hostname is set the transient is basically the same + if self.config.r#static.clone().unwrap_or_default().is_empty() { + self.config.hostname = name.clone(); + self.model.set_hostname(name.clone())? + } + } + } else { + return Ok(()); + } + + if current == self.config { + return Ok(()); + } + + let issues = self.find_issues(); + self.issues + .cast(issue::message::Set::new(Scope::Hostname, issues))?; + self.events.send(Event::ProposalChanged { + scope: Scope::Hostname, + })?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + Ok(self.get_proposal()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::UpdateHostname) -> Result<(), Error> { + let current_name = self.config.hostname.clone(); + self.config.hostname = message.name.clone(); + if current_name != message.name { + self.events.send(Event::ProposalChanged { + scope: Scope::Hostname, + })?; + } + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::UpdateStaticHostname) -> Result<(), Error> { + // If static hostname is set the transient is basically the same + if !message.name.is_empty() { + self.config.r#static = Some(message.name.clone()); + self.config.hostname = message.name; + } else { + self.config.r#static = None; + } + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Install) -> Result<(), Error> { + self.model.install() + } +} diff --git a/rust/agama-hostname/src/test_utils.rs b/rust/agama-hostname/src/test_utils.rs new file mode 100644 index 0000000000..22b5a4502e --- /dev/null +++ b/rust/agama-hostname/src/test_utils.rs @@ -0,0 +1,94 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_utils::{actor::Handler, api::event, issue}; +use async_trait::async_trait; +use std::{fs, path::PathBuf}; +use tempfile::tempdir; + +use crate::{ + service::{self}, + ModelAdapter, Service, +}; + +pub struct TestModel { + source_dir: PathBuf, + target_dir: PathBuf, +} + +#[async_trait] +impl ModelAdapter for TestModel { + fn hostname(&self) -> Result { + Ok("test-hostname".to_string()) + } + + fn static_hostname(&self) -> Result { + let path = self.source_dir.join("etc/hostname"); + fs::read_to_string(path).map_err(service::Error::from) + } + + fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error> { + let dir = self.source_dir.join("etc"); + let path = dir.join("hostname"); + fs::create_dir_all(&dir).map_err(service::Error::from)?; + fs::write(path, name).map_err(service::Error::from) + } + + fn set_hostname(&mut self, _name: String) -> Result<(), service::Error> { + Ok(()) + } + + fn install(&self) -> Result<(), service::Error> { + let from = self.source_dir.join("etc/hostname"); + if fs::exists(&from)? { + let to = self.target_dir.join("etc/hostname"); + fs::create_dir_all(to.parent().unwrap())?; + fs::copy(from, to)?; + } + Ok(()) + } + + fn static_target_dir(&self) -> &str { + self.target_dir.to_str().unwrap() + } +} + +/// Starts a testing hostname service. +pub async fn start_service( + events: event::Sender, + issues: Handler, +) -> Handler { + let temp_source = tempdir().unwrap(); + let temp_target = tempdir().unwrap(); + let hostname_path = temp_source.path().join("etc"); + fs::create_dir_all(&hostname_path).unwrap(); + fs::write(hostname_path.join("hostname"), "test-hostname").unwrap(); + + let model = TestModel { + source_dir: temp_source.path().to_path_buf(), + target_dir: temp_target.path().to_path_buf(), + }; + + Service::starter(events, issues) + .with_model(model) + .start() + .await + .expect("Could not spawn a testing hostname service") +} diff --git a/rust/agama-l10n/Cargo.toml b/rust/agama-l10n/Cargo.toml new file mode 100644 index 0000000000..e83b570033 --- /dev/null +++ b/rust/agama-l10n/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "agama-l10n" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +anyhow = "1.0.99" +thiserror = "2.0.16" +agama-locale-data = { path = "../agama-locale-data" } +agama-utils = { path = "../agama-utils" } +regex = "1.11.2" +tracing = "0.1.41" +gettext-rs = { version = "0.7.2", features = ["gettext-system"] } +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.17" +zbus = "5.11.0" +async-trait = "0.1.89" + +[dev-dependencies] +test-context = "0.4.1" +tokio-test = "0.4.4" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ci)'] } diff --git a/rust/agama-l10n/src/config.rs b/rust/agama-l10n/src/config.rs new file mode 100644 index 0000000000..4c5eaf80e3 --- /dev/null +++ b/rust/agama-l10n/src/config.rs @@ -0,0 +1,58 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::service; +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use agama_utils::api::{self, l10n::SystemInfo}; + +#[derive(Clone, PartialEq)] +pub struct Config { + pub locale: LocaleId, + pub keymap: KeymapId, + pub timezone: TimezoneId, +} + +impl Config { + pub fn new_from(system: &SystemInfo) -> Self { + Self { + locale: system.locale.clone(), + keymap: system.keymap.clone(), + timezone: system.timezone.clone(), + } + } + + pub fn merge(&self, config: &api::l10n::Config) -> Result { + let mut merged = self.clone(); + + if let Some(language) = &config.locale { + merged.locale = language.parse()? + } + + if let Some(keyboard) = &config.keymap { + merged.keymap = keyboard.parse()? + } + + if let Some(timezone) = &config.timezone { + merged.timezone = timezone.parse()?; + } + + Ok(merged) + } +} diff --git a/rust/agama-l10n/src/dbus.rs b/rust/agama-l10n/src/dbus.rs new file mode 100644 index 0000000000..bfeedddb00 --- /dev/null +++ b/rust/agama-l10n/src/dbus.rs @@ -0,0 +1,81 @@ +//! # D-Bus interface proxy for: `org.freedesktop.locale1` +//! +//! This code was generated by `zbus-xmlgen` `5.1.0` from D-Bus introspection data. +//! Source: `Interface '/org/freedesktop/locale1' from service 'org.freedesktop.locale1' on system bus`. +//! +//! You may prefer to adapt it, instead of using it verbatim. +//! +//! More information can be found in the [Writing a client proxy] section of the zbus +//! documentation. +//! +//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the +//! following zbus API can be used: +//! +//! * [`zbus::fdo::PeerProxy`] +//! * [`zbus::fdo::IntrospectableProxy`] +//! * [`zbus::fdo::PropertiesProxy`] +//! +//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. +//! +//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html +//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, +use zbus::proxy; +#[proxy( + interface = "org.freedesktop.locale1", + default_service = "org.freedesktop.locale1", + default_path = "/org/freedesktop/locale1" +)] +pub trait Locale1 { + /// SetLocale method + fn set_locale(&self, locale: &[&str], interactive: bool) -> zbus::Result<()>; + + /// SetVConsoleKeyboard method + #[zbus(name = "SetVConsoleKeyboard")] + fn set_vconsole_keyboard( + &self, + keymap: &str, + keymap_toggle: &str, + convert: bool, + interactive: bool, + ) -> zbus::Result<()>; + + /// SetX11Keyboard method + #[zbus(name = "SetX11Keyboard")] + fn set_x11keyboard( + &self, + layout: &str, + model: &str, + variant: &str, + options: &str, + convert: bool, + interactive: bool, + ) -> zbus::Result<()>; + + /// Locale property + #[zbus(property)] + fn locale(&self) -> zbus::Result>; + + /// VConsoleKeymap property + #[zbus(property, name = "VConsoleKeymap")] + fn vconsole_keymap(&self) -> zbus::Result; + + /// VConsoleKeymapToggle property + #[zbus(property, name = "VConsoleKeymapToggle")] + fn vconsole_keymap_toggle(&self) -> zbus::Result; + + /// X11Layout property + #[zbus(property, name = "X11Layout")] + fn x11layout(&self) -> zbus::Result; + + /// X11Model property + #[zbus(property, name = "X11Model")] + fn x11model(&self) -> zbus::Result; + + /// X11Options property + #[zbus(property, name = "X11Options")] + fn x11options(&self) -> zbus::Result; + + /// X11Variant property + #[zbus(property, name = "X11Variant")] + fn x11variant(&self) -> zbus::Result; +} diff --git a/rust/agama-server/src/l10n/helpers.rs b/rust/agama-l10n/src/helpers.rs similarity index 95% rename from rust/agama-server/src/l10n/helpers.rs rename to rust/agama-l10n/src/helpers.rs index e39229529a..bb7f95d0dd 100644 --- a/rust/agama-server/src/l10n/helpers.rs +++ b/rust/agama-l10n/src/helpers.rs @@ -31,7 +31,7 @@ use std::env; /// It returns the used locale. Defaults to `en_US.UTF-8`. pub fn init_locale() -> Result> { let lang = env::var("LANG").unwrap_or("en_US.UTF-8".to_string()); - let locale: LocaleId = lang.as_str().try_into().unwrap_or_default(); + let locale = lang.parse().unwrap_or_default(); set_service_locale(&locale); textdomain("xkeyboard-config")?; diff --git a/rust/agama-l10n/src/lib.rs b/rust/agama-l10n/src/lib.rs new file mode 100644 index 0000000000..c8885f69c0 --- /dev/null +++ b/rust/agama-l10n/src/lib.rs @@ -0,0 +1,254 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This crate implements the support for localization handling in Agama. +//! It takes care of setting the locale, keymap and timezone for Agama itself +//! and the target system. +//! +//! From a technical point of view, it includes: +//! +//! * The [UserConfig] struct that defines the settings the user can +//! alter for the target system. +//! * The [Proposal] struct that describes how the system will look like after +//! the installation. +//! * The [SystemInfo] which includes information about the system +//! where Agama is running. +//! * An [specific event type](Event) for localization-related events. +//! +//! The service can be started by calling the [start_service] function, which +//! returns a [agama_utils::actors::ActorHandler] to interact with the system. + +pub mod service; +pub use service::{Service, Starter}; + +mod model; +pub use model::{KeymapsDatabase, LocalesDatabase, Model, ModelAdapter, TimezonesDatabase}; + +mod config; +mod dbus; +pub mod helpers; +pub mod message; +mod monitor; + +pub mod test_utils; + +#[cfg(test)] +mod tests { + use crate::{ + message, + service::{self, Service}, + test_utils::TestModel, + }; + + use agama_utils::{ + actor::Handler, + api::{self, event::Event, scope::Scope}, + issue, + }; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + struct Context { + events_rx: broadcast::Receiver, + handler: Handler, + issues: Handler, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + let (events_tx, events_rx) = broadcast::channel::(16); + let issues = issue::Service::starter(events_tx.clone()).start(); + + let model = TestModel::with_sample_data(); + let handler = Service::starter(events_tx, issues.clone()) + .with_model(model) + .start() + .await + .expect("Could not start the l10n service"); + + Self { + events_rx, + handler, + issues, + } + } + } + + #[test_context(Context)] + #[tokio::test] + async fn test_get_and_set_config(ctx: &mut Context) -> Result<(), Box> { + let config = ctx.handler.call(message::GetConfig).await.unwrap(); + assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); + + let input_config = api::l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }; + ctx.handler + .call(message::SetConfig::with(input_config.clone())) + .await?; + + let updated = ctx.handler.call(message::GetConfig).await?; + assert_eq!(&updated, &input_config); + + let proposal = ctx.handler.call(message::GetProposal).await?; + assert!(proposal.is_some()); + + let event = ctx + .events_rx + .recv() + .await + .expect("Did not receive the event"); + assert!(matches!( + event, + Event::ProposalChanged { scope: Scope::L10n } + )); + + let input_config = api::l10n::Config { + locale: None, + keymap: Some("es".to_string()), + timezone: None, + }; + + // Use system info for missing values. + ctx.handler + .call(message::SetConfig::with(input_config.clone())) + .await?; + + let updated = ctx.handler.call(message::GetConfig).await?; + assert_eq!( + updated, + api::l10n::Config { + locale: Some("en_US.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Europe/Berlin".to_string()), + } + ); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_reset_config(ctx: &mut Context) -> Result<(), Box> { + ctx.handler.call(message::SetConfig::new(None)).await?; + + let config = ctx.handler.call(message::GetConfig).await?; + assert_eq!( + config, + api::l10n::Config { + locale: Some("en_US.UTF-8".to_string()), + keymap: Some("us".to_string()), + timezone: Some("Europe/Berlin".to_string()), + } + ); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_set_invalid_config(ctx: &mut Context) -> Result<(), Box> { + let input_config = api::l10n::Config { + locale: Some("es-ES.UTF-8".to_string()), + ..Default::default() + }; + + let result = ctx + .handler + .call(message::SetConfig::with(input_config.clone())) + .await; + assert!(matches!(result, Err(service::Error::InvalidLocale(_)))); + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_set_config_without_changes( + ctx: &mut Context, + ) -> Result<(), Box> { + let config = ctx.handler.call(message::GetConfig).await?; + assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); + let message = message::SetConfig::with(config.clone()); + ctx.handler.call(message).await?; + // Wait until the action is dispatched. + let _ = ctx.handler.call(message::GetConfig).await?; + + let event = ctx.events_rx.try_recv(); + assert!(matches!(event, Err(broadcast::error::TryRecvError::Empty))); + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_set_config_unknown_values( + ctx: &mut Context, + ) -> Result<(), Box> { + let config = api::l10n::Config { + keymap: Some("jk".to_string()), + locale: Some("xx_XX.UTF-8".to_string()), + timezone: Some("Unknown/Unknown".to_string()), + }; + let _ = ctx.handler.call(message::SetConfig::with(config)).await?; + + let found_issues = ctx.issues.call(issue::message::Get).await?; + let l10n_issues = found_issues.get(&Scope::L10n).unwrap(); + assert_eq!(l10n_issues.len(), 3); + + let proposal = ctx.handler.call(message::GetProposal).await?; + assert!(proposal.is_none()); + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_get_system(ctx: &mut Context) -> Result<(), Box> { + let system = ctx.handler.call(message::GetSystem).await?; + assert_eq!(system.keymaps.len(), 2); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_get_proposal(ctx: &mut Context) -> Result<(), Box> { + let input_config = api::l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }; + let message = message::SetConfig::with(input_config.clone()); + ctx.handler.call(message).await?; + + let proposal = ctx + .handler + .call(message::GetProposal) + .await? + .expect("Could not get the proposal"); + assert_eq!(proposal.locale.to_string(), input_config.locale.unwrap()); + assert_eq!(proposal.keymap.to_string(), input_config.keymap.unwrap()); + assert_eq!( + proposal.timezone.to_string(), + input_config.timezone.unwrap() + ); + Ok(()) + } +} diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs new file mode 100644 index 0000000000..f64d7a2a75 --- /dev/null +++ b/rust/agama-l10n/src/message.rs @@ -0,0 +1,103 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_locale_data::{KeymapId, LocaleId}; +use agama_utils::{ + actor::Message, + api::{ + self, + l10n::{Proposal, SystemInfo}, + }, +}; + +#[derive(Clone)] +pub struct GetSystem; + +impl Message for GetSystem { + type Reply = SystemInfo; +} + +pub struct SetSystem { + pub config: T, +} + +impl Message for SetSystem { + type Reply = (); +} + +impl SetSystem { + pub fn new(config: T) -> Self { + Self { config } + } +} + +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = api::l10n::Config; +} + +pub struct SetConfig { + pub config: Option, +} + +impl Message for SetConfig { + type Reply = (); +} + +impl SetConfig { + pub fn new(config: Option) -> Self { + Self { config } + } + + pub fn with(config: T) -> Self { + Self { + config: Some(config), + } + } +} + +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option; +} + +pub struct Install; + +impl Message for Install { + type Reply = (); +} + +pub struct UpdateKeymap { + pub keymap: KeymapId, +} + +impl Message for UpdateKeymap { + type Reply = (); +} + +pub struct UpdateLocale { + pub locale: LocaleId, +} + +impl Message for UpdateLocale { + type Reply = (); +} diff --git a/rust/agama-l10n/src/model.rs b/rust/agama-l10n/src/model.rs new file mode 100644 index 0000000000..49799eb42a --- /dev/null +++ b/rust/agama-l10n/src/model.rs @@ -0,0 +1,235 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +mod keyboard; +pub use keyboard::KeymapsDatabase; + +mod locale; +pub use locale::LocalesDatabase; + +mod timezone; +pub use timezone::TimezonesDatabase; + +use crate::{helpers, service}; +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use agama_utils::api::l10n::SystemInfo; +use regex::Regex; +use std::env; +use std::fs::OpenOptions; +use std::io::Write; +use std::process::Command; + +/// Abstract the localization-related configuration from the underlying system. +/// +/// It offers an API to query and set different localization elements of a +/// system. This trait can be implemented to replace the real system during +/// tests. +pub trait ModelAdapter: Send + 'static { + /// Reads the system info. + fn read_system_info(&self) -> SystemInfo { + let locales = self.locales_db().entries().clone(); + let keymaps = self.keymaps_db().entries().clone(); + let timezones = self.timezones_db().entries().clone(); + + SystemInfo { + locales, + keymaps, + timezones, + locale: self.locale(), + keymap: self.keymap().unwrap(), + timezone: Default::default(), + } + } + + /// Locales database. + fn locales_db(&self) -> &LocalesDatabase; + + /// Timezones database. + fn timezones_db(&self) -> &TimezonesDatabase; + + /// Keymaps database. + fn keymaps_db(&self) -> &KeymapsDatabase; + + /// Current system locale. + fn locale(&self) -> LocaleId; + + /// Current system keymap. + fn keymap(&self) -> Result; + + /// Change the locale of the system. + fn set_locale(&mut self, _locale: LocaleId) -> Result<(), service::Error> { + Ok(()) + } + + /// Change the keymap of the system. + fn set_keymap(&mut self, _keymap: KeymapId) -> Result<(), service::Error> { + Ok(()) + } + + /// Apply the changes to target system. It is expected to be called almost + /// at the end of the installation. + fn install( + &self, + _locale: &LocaleId, + _keymap: &KeymapId, + _timezone: &TimezoneId, + ) -> Result<(), service::Error> { + Ok(()) + } +} + +/// [ModelAdapter] implementation for systemd-based systems. +pub struct Model { + pub timezones_db: TimezonesDatabase, + pub locales_db: LocalesDatabase, + pub keymaps_db: KeymapsDatabase, +} + +impl Default for Model { + fn default() -> Self { + Self { + locales_db: LocalesDatabase::new(), + timezones_db: TimezonesDatabase::new(), + keymaps_db: KeymapsDatabase::new(), + } + } +} + +impl Model { + /// Initializes the struct with the information from the underlying system. + pub fn from_system() -> Result { + let mut model = Self::default(); + model.read(&model.locale())?; + Ok(model) + } + + fn read(&mut self, locale: &LocaleId) -> Result<(), service::Error> { + self.locales_db.read(&locale.language)?; + self.timezones_db.read(&locale.language)?; + self.keymaps_db.read()?; + + Ok(()) + } +} + +impl ModelAdapter for Model { + fn locales_db(&self) -> &LocalesDatabase { + &self.locales_db + } + fn timezones_db(&self) -> &TimezonesDatabase { + &self.timezones_db + } + + fn keymaps_db(&self) -> &KeymapsDatabase { + &self.keymaps_db + } + + fn keymap(&self) -> Result { + let output = Command::new("localectl").output()?; + let output = String::from_utf8_lossy(&output.stdout); + + let keymap_regexp = Regex::new(r"(?m)VC Keymap: (.+)$").unwrap(); + let captures = keymap_regexp.captures(&output); + let keymap = captures + .and_then(|c| c.get(1).map(|e| e.as_str())) + .unwrap_or("us") + .to_string(); + + let keymap_id: KeymapId = keymap.parse().unwrap_or(KeymapId::default()); + Ok(keymap_id) + } + + // FIXME: we could use D-Bus to read the locale and the keymap (see ui_keymap). + fn locale(&self) -> LocaleId { + let lang = env::var("LANG") + .ok() + .and_then(|v| v.parse::().ok()); + lang.unwrap_or_default() + } + + fn set_locale(&mut self, locale: LocaleId) -> Result<(), service::Error> { + if !self.locales_db.exists(&locale) { + return Err(service::Error::UnknownLocale(locale)); + } + + Command::new("localectl") + .args(["set-locale", &format!("LANG={}", locale)]) + .output()?; + + helpers::set_service_locale(&locale); + self.timezones_db.read(&locale.language)?; + self.locales_db.read(&locale.language)?; + Ok(()) + } + + fn set_keymap(&mut self, keymap: KeymapId) -> Result<(), service::Error> { + if !self.keymaps_db.exists(&keymap) { + return Err(service::Error::UnknownKeymap(keymap)); + } + + Command::new("localectl") + .args(["set-keymap", &keymap.dashed()]) + .output()?; + Ok(()) + } + + fn install( + &self, + locale: &LocaleId, + keymap: &KeymapId, + timezone: &TimezoneId, + ) -> Result<(), service::Error> { + const ROOT: &str = "/mnt"; + const VCONSOLE_CONF: &str = "/etc/vconsole.conf"; + + let mut cmd = Command::new("/usr/bin/systemd-firstboot"); + cmd.args([ + "--root", + ROOT, + "--force", + "--locale", + &locale.to_string(), + "--keymap", + &keymap.dashed(), + "--timezone", + &timezone.to_string(), + ]); + tracing::info!("{:?}", &cmd); + + let output = cmd.output()?; + tracing::info!("{:?}", &output); + + // unfortunately the console font cannot be set via the "systemd-firstboot" tool, + // we need to write it directly to the config file + if let Some(entry) = self.locales_db.find_locale(&locale) { + if let Some(font) = &entry.consolefont { + // the font entry is missing in a file created by "systemd-firstboot", just append it at the end + let mut file = OpenOptions::new() + .append(true) + .open(format!("{ROOT}{VCONSOLE_CONF}"))?; + + tracing::info!("Configuring console font \"{:?}\"", font); + writeln!(file, "\nFONT={font}.psfu")?; + } + } + + Ok(()) + } +} diff --git a/rust/agama-server/src/l10n/model/keyboard.rs b/rust/agama-l10n/src/model/keyboard.rs similarity index 75% rename from rust/agama-server/src/l10n/model/keyboard.rs rename to rust/agama-l10n/src/model/keyboard.rs index e968959611..7cd84ccf21 100644 --- a/rust/agama-server/src/l10n/model/keyboard.rs +++ b/rust/agama-l10n/src/model/keyboard.rs @@ -18,45 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_locale_data::{get_localectl_keymaps, keyboard::XkbConfigRegistry, KeymapId}; -use gettextrs::*; -use serde::ser::{Serialize, SerializeStruct}; +use agama_locale_data::get_localectl_keymaps; +use agama_locale_data::keyboard::XkbConfigRegistry; +use agama_locale_data::KeymapId; +use agama_utils::api::l10n::Keymap; use std::collections::HashMap; -// Minimal representation of a keymap -#[derive(Clone, Debug, utoipa::ToSchema)] -pub struct Keymap { - /// Keymap identifier (e.g., "us") - pub id: KeymapId, - /// Keymap description - description: String, -} - -impl Keymap { - pub fn new(id: KeymapId, description: &str) -> Self { - Self { - id, - description: description.to_string(), - } - } - - pub fn localized_description(&self) -> String { - gettext(&self.description) - } -} - -impl Serialize for Keymap { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("Keymap", 2)?; - state.serialize_field("id", &self.id.to_string())?; - state.serialize_field("description", &self.localized_description())?; - state.end() - } -} - /// Represents the keymaps database. /// /// The list of supported keymaps is read from `systemd-localed` and the @@ -72,6 +39,12 @@ impl KeymapsDatabase { Self::default() } + pub fn with_entries(data: &[Keymap]) -> Self { + Self { + keymaps: data.to_vec(), + } + } + /// Reads the list of keymaps. pub fn read(&mut self) -> anyhow::Result<()> { self.keymaps = get_keymaps()?; diff --git a/rust/agama-server/src/l10n/model/locale.rs b/rust/agama-l10n/src/model/locale.rs similarity index 82% rename from rust/agama-server/src/l10n/model/locale.rs rename to rust/agama-l10n/src/model/locale.rs index 935e883a23..1953773f75 100644 --- a/rust/agama-server/src/l10n/model/locale.rs +++ b/rust/agama-l10n/src/model/locale.rs @@ -20,28 +20,11 @@ //! This module provides support for reading the locales database. -use crate::error::Error; use agama_locale_data::LocaleId; +use agama_utils::api::l10n::LocaleEntry; use anyhow::Context; -use serde::Serialize; -use serde_with::{serde_as, DisplayFromStr}; use std::{fs, process::Command}; -/// Represents a locale, including the localized language and territory. -#[serde_as] -#[derive(Debug, Serialize, Clone, utoipa::ToSchema)] -pub struct LocaleEntry { - /// The locale code (e.g., "es_ES.UTF-8"). - #[serde_as(as = "DisplayFromStr")] - pub id: LocaleId, - /// Localized language name (e.g., "Spanish", "Español", etc.) - pub language: String, - /// Localized territory name (e.g., "Spain", "España", etc.) - pub territory: String, - /// Console font - pub consolefont: Option, -} - /// Represents the locales database. /// /// The list of supported locales is read from `systemd-localed`. However, the @@ -57,13 +40,20 @@ impl LocalesDatabase { Self::default() } + pub fn with_entries(data: &[LocaleEntry]) -> Self { + Self { + known_locales: data.iter().map(|l| l.id.clone()).collect(), + locales: data.to_vec(), + } + } + /// Loads the list of locales. /// /// It checks for a file in /etc/agama.d/locales containing the list of supported locales (one per line). /// It it does not exists, calls `localectl list-locales`. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). - pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { + pub fn read(&mut self, ui_language: &str) -> anyhow::Result<()> { self.known_locales = Self::get_locales_list()?; self.locales = self.get_locales(ui_language)?; Ok(()) @@ -89,7 +79,7 @@ impl LocalesDatabase { /// Gets the supported locales information. /// /// * `ui_language`: language to use in the translations. - fn get_locales(&self, ui_language: &str) -> Result, Error> { + fn get_locales(&self, ui_language: &str) -> anyhow::Result> { const DEFAULT_LANG: &str = "en"; let mut result = Vec::with_capacity(self.known_locales.len()); let languages = agama_locale_data::get_languages()?; @@ -135,7 +125,7 @@ impl LocalesDatabase { Ok(result) } - fn get_locales_list() -> Result, Error> { + fn get_locales_list() -> anyhow::Result> { const LOCALES_LIST_PATH: &str = "/etc/agama.d/locales"; let locales = fs::read_to_string(LOCALES_LIST_PATH).map(Self::get_locales_from_string); @@ -159,10 +149,7 @@ impl LocalesDatabase { } fn get_locales_from_string(locales: String) -> Vec { - locales - .lines() - .filter_map(|line| TryInto::::try_into(line).ok()) - .collect() + locales.lines().filter_map(|l| l.parse().ok()).collect() } } @@ -178,7 +165,7 @@ mod tests { let mut db = LocalesDatabase::new(); db.read("de").unwrap(); let found_locales = db.entries(); - let spanish: LocaleId = "es_ES".try_into().unwrap(); + let spanish = "es_ES".parse::().unwrap(); let found = found_locales .iter() .find(|l| l.id == spanish) @@ -189,14 +176,14 @@ mod tests { #[test] fn test_try_into_locale() { - let locale = LocaleId::try_from("es_ES.UTF-16").unwrap(); + let locale = "es_ES.UTF-16".parse::().unwrap(); assert_eq!(&locale.language, "es"); assert_eq!(&locale.territory, "ES"); assert_eq!(&locale.encoding, "UTF-16"); assert_eq!(locale.to_string(), String::from("es_ES.UTF-16")); - let invalid = LocaleId::try_from("."); + let invalid = ".".parse::(); assert!(invalid.is_err()); } @@ -206,8 +193,8 @@ mod tests { fn test_locale_exists() { let mut db = LocalesDatabase::new(); db.read("en").unwrap(); - let en_us = LocaleId::try_from("en_US").unwrap(); - let unknown = LocaleId::try_from("unknown_UNKNOWN").unwrap(); + let en_us = "en_US".parse::().unwrap(); + let unknown = "unknown_UNKNOWN".parse::().unwrap(); assert!(db.exists(&en_us)); assert!(!db.exists(&unknown)); } diff --git a/rust/agama-server/src/l10n/model/timezone.rs b/rust/agama-l10n/src/model/timezone.rs similarity index 72% rename from rust/agama-server/src/l10n/model/timezone.rs rename to rust/agama-l10n/src/model/timezone.rs index 192c240cb1..9ac29a08e6 100644 --- a/rust/agama-server/src/l10n/model/timezone.rs +++ b/rust/agama-l10n/src/model/timezone.rs @@ -20,23 +20,10 @@ //! This module provides support for reading the timezones database. -use crate::error::Error; -use agama_locale_data::territory::Territories; -use agama_locale_data::timezone_part::TimezoneIdParts; -use serde::Serialize; +use agama_locale_data::{territory::Territories, timezone_part::TimezoneIdParts, TimezoneId}; +use agama_utils::api::l10n::TimezoneEntry; use std::collections::HashMap; -/// Represents a timezone, including each part as localized. -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -pub struct TimezoneEntry { - /// Timezone identifier (e.g. "Atlantic/Canary"). - pub code: String, - /// Localized parts (e.g., "Atlántico", "Canarias"). - pub parts: Vec, - /// Localized name of the territory this timezone is associated to - pub country: Option, -} - #[derive(Default)] pub struct TimezonesDatabase { timezones: Vec, @@ -47,17 +34,23 @@ impl TimezonesDatabase { Self::default() } + pub fn with_entries(data: &[TimezoneEntry]) -> Self { + Self { + timezones: data.to_vec(), + } + } + /// Initializes the list of known timezones. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). - pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { + pub fn read(&mut self, ui_language: &str) -> anyhow::Result<()> { self.timezones = self.get_timezones(ui_language)?; Ok(()) } /// Determines whether a timezone exists in the database. - pub fn exists(&self, timezone: &String) -> bool { - self.timezones.iter().any(|t| &t.code == timezone) + pub fn exists(&self, timezone: &TimezoneId) -> bool { + self.timezones.iter().any(|t| &t.id == timezone) } /// Returns the list of timezones. @@ -71,7 +64,7 @@ impl TimezonesDatabase { /// containing the translation of each part of the language. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). - fn get_timezones(&self, ui_language: &str) -> Result, Error> { + fn get_timezones(&self, ui_language: &str) -> anyhow::Result> { let timezones = agama_locale_data::get_timezones(); let tz_parts = agama_locale_data::get_timezone_parts()?; let territories = agama_locale_data::get_territories()?; @@ -81,15 +74,17 @@ impl TimezonesDatabase { let ret = timezones .into_iter() .filter_map(|tz| { - let parts = translate_parts(&tz, ui_language, &tz_parts); - let country = translate_country(&tz, ui_language, &tz_countries, &territories); + tz.parse::() + .inspect_err(|e| println!("Ignoring timezone {tz}: {e}")) + .ok() + }) + .filter_map(|id| { + let parts = translate_parts(id.as_str(), ui_language, &tz_parts); + let country = + translate_country(id.as_str(), ui_language, &tz_countries, &territories); match country { - None if !COUNTRYLESS.contains(&tz.as_str()) => None, - _ => Some(TimezoneEntry { - code: tz, - parts, - country, - }), + None if !COUNTRYLESS.contains(&id.as_str()) => None, + _ => Some(TimezoneEntry { id, parts, country }), } }) .collect(); @@ -137,9 +132,9 @@ mod tests { let found_timezones = db.entries(); let found = found_timezones .iter() - .find(|tz| tz.code == "Europe/Berlin") + .find(|tz| tz.id.as_str() == "Europe/Berlin") .unwrap(); - assert_eq!(&found.code, "Europe/Berlin"); + assert_eq!(found.id.as_str(), "Europe/Berlin"); assert_eq!( found.parts, vec!["Europa".to_string(), "Berlín".to_string()] @@ -151,7 +146,11 @@ mod tests { fn test_read_timezone_without_country() { let mut db = TimezonesDatabase::new(); db.read("es").unwrap(); - let timezone = db.entries().iter().find(|tz| tz.code == "UTC").unwrap(); + let timezone = db + .entries() + .iter() + .find(|tz| tz.id.as_str() == "UTC") + .unwrap(); assert_eq!(timezone.country, None); } @@ -162,7 +161,7 @@ mod tests { let timezone = db .entries() .iter() - .find(|tz| tz.code == "Europe/Kiev") + .find(|tz| tz.id.as_str() == "Europe/Kiev") .unwrap(); assert_eq!(timezone.country, Some("Ukraine".to_string())); } @@ -171,7 +170,9 @@ mod tests { fn test_timezone_exists() { let mut db = TimezonesDatabase::new(); db.read("es").unwrap(); - assert!(db.exists(&"Atlantic/Canary".to_string())); - assert!(!db.exists(&"Unknown/Unknown".to_string())); + let canary = "Atlantic/Canary".parse().unwrap(); + let unknown = "Unknown/Unknown".parse().unwrap(); + assert!(db.exists(&canary)); + assert!(!db.exists(&unknown)); } } diff --git a/rust/agama-l10n/src/monitor.rs b/rust/agama-l10n/src/monitor.rs new file mode 100644 index 0000000000..02a6a1bef0 --- /dev/null +++ b/rust/agama-l10n/src/monitor.rs @@ -0,0 +1,99 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{message, service::Service}; +use agama_locale_data::{KeymapId, LocaleId}; +use agama_utils::{ + actor::Handler, + dbus::{get_property, to_owned_hash}, +}; +use tokio_stream::StreamExt; +use zbus::fdo::{PropertiesChangedStream, PropertiesProxy}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + DBus(#[from] zbus::Error), +} + +pub struct Monitor { + handler: Handler, + stream: PropertiesChangedStream, +} + +impl Monitor { + pub async fn new(handler: Handler) -> Result { + let dbus = zbus::Connection::system().await?; + let proxy = PropertiesProxy::builder(&dbus) + .path("/org/freedesktop/locale1")? + .destination("org.freedesktop.locale1")? + .build() + .await?; + let stream = proxy + .receive_properties_changed() + .await + .map_err(Error::DBus)?; + Ok(Self { handler, stream }) + } + + pub async fn run(&mut self) { + while let Some(changes) = self.stream.next().await { + let Ok(args) = changes.args() else { + continue; + }; + + let changes = args.changed_properties(); + let Ok(changes) = to_owned_hash(changes) else { + continue; + }; + + if let Ok(locales) = get_property::>(&changes, "Locale") { + let Some(locale) = locales.first() else { + continue; + }; + + let locale_id = locale + .strip_prefix("LANG=") + .and_then(|l| l.parse::().ok()); + + if let Some(locale_id) = locale_id { + _ = self + .handler + .call(message::UpdateLocale { locale: locale_id }) + .await; + } + } + if let Ok(keymap) = get_property::(&changes, "VConsoleKeymap") { + if let Ok(keymap) = keymap.parse::() { + _ = self.handler.call(message::UpdateKeymap { keymap }).await; + } + } + } + } +} + +/// Spawns a Tokio task for the monitor. +/// +/// * `monitor`: monitor to spawn. +pub fn spawn(mut monitor: Monitor) { + tokio::spawn(async move { + monitor.run().await; + }); +} diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs new file mode 100644 index 0000000000..e81c1624f5 --- /dev/null +++ b/rust/agama-l10n/src/service.rs @@ -0,0 +1,317 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::model::ModelAdapter; +use crate::monitor::Monitor; +use crate::{config::Config, Model}; +use crate::{message, monitor}; +use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{ + self, + event::{self, Event}, + l10n::{Proposal, SystemConfig, SystemInfo}, + Issue, Scope, + }, + issue, +}; +use async_trait::async_trait; +use tokio::sync::broadcast; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Unknown locale: {0}")] + UnknownLocale(LocaleId), + #[error("Unknown keymap: {0}")] + UnknownKeymap(KeymapId), + #[error("Unknown timezone: {0}")] + UnknownTimezone(String), + #[error(transparent)] + InvalidLocale(#[from] InvalidLocaleId), + #[error(transparent)] + InvalidKeymap(#[from] InvalidKeymapId), + #[error(transparent)] + InvalidTimezone(#[from] InvalidTimezoneId), + #[error(transparent)] + Event(#[from] broadcast::error::SendError), + #[error(transparent)] + IssueService(#[from] issue::service::Error), + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + IO(#[from] std::io::Error), + #[error(transparent)] + Generic(#[from] anyhow::Error), + #[error("There is no proposal for localization")] + MissingProposal, +} + +/// Builds and spawns the l10n service. +/// +/// This struct allows to build a l10n service. It allows replacing +/// the "model" for a custom one. +/// +/// It spawns two Tokio tasks: +/// +/// - The main service, which is reponsible for holding and applying the configuration. +/// - A monitor which checks for changes in the underlying system (e.g., changing the keymap) +/// and signals the main service accordingly. +/// - It depends on the issues service to keep the installation issues. +pub struct Starter { + model: Option>, + issues: Handler, + events: event::Sender, +} + +impl Starter { + /// Creates a new starter. + /// + /// * `events`: channel to emit the [localization-specific events](crate::Event). + /// * `issues`: handler to the issues service. + pub fn new(events: event::Sender, issues: Handler) -> Self { + Self { + // FIXME: rename to "adapter" + model: None, + events, + issues, + } + } + + /// Uses the given model. + /// + /// By default, the l10n service relies on systemd. However, it might be useful + /// to replace it in some scenarios (e.g., when testing). + /// + /// * `model`: model to use. It must implement the [ModelAdapter] trait. + pub fn with_model(mut self, model: T) -> Self { + self.model = Some(Box::new(model)); + self + } + + /// Starts the service and returns a handler to communicate with it. + /// + /// The service uses a separate monitor to listen to system configuration + /// changes. + pub async fn start(self) -> Result, Error> { + let model = match self.model { + Some(model) => model, + None => Box::new(Model::from_system()?), + }; + + let system = model.read_system_info(); + let config = Config::new_from(&system); + + let service = Service { + system, + config, + model, + issues: self.issues, + events: self.events, + }; + let handler = actor::spawn(service); + Self::start_monitor(handler.clone()).await; + Ok(handler) + } + + pub async fn start_monitor(handler: Handler) { + match Monitor::new(handler.clone()).await { + Ok(monitor) => monitor::spawn(monitor), + Err(error) => { + tracing::error!( + "Could not launch the l10n monitor, therefore changes from systemd will be ignored. \ + The original error was {error}" + ); + } + } + } +} + +/// Localization service. +/// +/// It is responsible for handling the localization part of the installation: +/// +/// * Reads the list of known locales, keymaps and timezones. +/// * Keeps track of the localization settings of the underlying system (the installer). +/// * Holds the user configuration. +/// * Applies the user configuration at the end of the installation. +pub struct Service { + system: SystemInfo, + config: Config, + model: Box, + issues: Handler, + events: event::Sender, +} + +impl Service { + pub fn starter(events: event::Sender, issues: Handler) -> Starter { + Starter::new(events, issues) + } + + fn get_proposal(&self) -> Option { + if !self.find_issues().is_empty() { + return None; + } + + Some(Proposal { + keymap: self.config.keymap.clone(), + locale: self.config.locale.clone(), + timezone: self.config.timezone.clone(), + }) + } + + /// Returns configuration issues. + /// + /// It returns an issue for each unknown element (locale, keymap and timezone). + fn find_issues(&self) -> Vec { + let config = &self.config; + let mut issues = vec![]; + if !self.model.locales_db().exists(&config.locale) { + issues.push(Issue::new( + "unknown_locale", + &format!("Locale '{}' is unknown", config.locale), + )); + } + + if !self.model.keymaps_db().exists(&config.keymap) { + issues.push(Issue::new( + "unknown_keymap", + &format!("Keymap '{}' is unknown", config.keymap), + )); + } + + if !self.model.timezones_db().exists(&config.timezone) { + issues.push(Issue::new( + "unknown_timezone", + &format!("Timezone '{}' is unknown", config.timezone), + )); + } + + issues + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetSystem) -> Result { + Ok(self.system.clone()) + } +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle(&mut self, message: message::SetSystem) -> Result<(), Error> { + let config = &message.config; + if let Some(locale) = &config.locale { + self.model.set_locale(locale.parse()?)?; + } + + if let Some(keymap) = &config.keymap { + self.model.set_keymap(keymap.parse()?)?; + }; + + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetConfig) -> Result { + Ok(api::l10n::Config { + locale: Some(self.config.locale.to_string()), + keymap: Some(self.config.keymap.to_string()), + timezone: Some(self.config.timezone.to_string()), + }) + } +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle( + &mut self, + message: message::SetConfig, + ) -> Result<(), Error> { + let base_config = Config::new_from(&self.system); + + let config = if let Some(config) = &message.config { + base_config.merge(config)? + } else { + base_config + }; + + if config == self.config { + return Ok(()); + } + + self.config = config; + let issues = self.find_issues(); + self.issues + .cast(issue::message::Set::new(Scope::L10n, issues))?; + self.events + .send(Event::ProposalChanged { scope: Scope::L10n })?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + Ok(self.get_proposal()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Install) -> Result<(), Error> { + let Some(proposal) = self.get_proposal() else { + return Err(Error::MissingProposal); + }; + + self.model + .install(&proposal.locale, &proposal.keymap, &proposal.timezone)?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::UpdateLocale) -> Result<(), Error> { + self.system.locale = message.locale; + _ = self + .events + .send(Event::SystemChanged { scope: Scope::L10n }); + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::UpdateKeymap) -> Result<(), Error> { + self.system.keymap = message.keymap; + _ = self + .events + .send(Event::SystemChanged { scope: Scope::L10n }); + Ok(()) + } +} diff --git a/rust/agama-l10n/src/test_utils.rs b/rust/agama-l10n/src/test_utils.rs new file mode 100644 index 0000000000..ab2a7e4fde --- /dev/null +++ b/rust/agama-l10n/src/test_utils.rs @@ -0,0 +1,134 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a set of utilities for tests. + +use agama_locale_data::{KeymapId, LocaleId}; +use agama_utils::{ + actor::Handler, + api::{ + event, + l10n::{Keymap, LocaleEntry, TimezoneEntry}, + }, + issue, +}; + +use crate::{ + model::{KeymapsDatabase, LocalesDatabase, TimezonesDatabase}, + service, ModelAdapter, Service, Starter, +}; + +/// Test adapter. +/// +/// This adapter does not interact with systemd and/or D-Bus. It just +/// holds the databases and the given configuration. +#[derive(Default)] +pub struct TestModel { + pub locales: LocalesDatabase, + pub keymaps: KeymapsDatabase, + pub timezones: TimezonesDatabase, +} + +impl TestModel { + /// Builds a new adapter with the given databases. + /// + // FIXME: why not use the default databases instead? + pub fn new( + locales: LocalesDatabase, + keymaps: KeymapsDatabase, + timezones: TimezonesDatabase, + ) -> Self { + Self { + locales, + keymaps, + timezones, + } + } + + /// Builds a new adapter with some sample data. + pub fn with_sample_data() -> Self { + let locales = LocalesDatabase::with_entries(&[ + LocaleEntry { + id: "en_US.UTF-8".parse().unwrap(), + language: "English".to_string(), + territory: "United States".to_string(), + consolefont: None, + }, + LocaleEntry { + id: "es_ES.UTF-8".parse().unwrap(), + language: "Spanish".to_string(), + territory: "Spain".to_string(), + consolefont: None, + }, + ]); + let keymaps = KeymapsDatabase::with_entries(&[ + Keymap::new("us".parse().unwrap(), "English"), + Keymap::new("es".parse().unwrap(), "Spanish"), + ]); + let timezones = TimezonesDatabase::with_entries(&[ + TimezoneEntry { + id: "Europe/Berlin".parse().unwrap(), + parts: vec!["Europe".to_string(), "Berlin".to_string()], + country: Some("Germany".to_string()), + }, + TimezoneEntry { + id: "Atlantic/Canary".parse().unwrap(), + parts: vec!["Atlantic".to_string(), "Canary".to_string()], + country: Some("Spain".to_string()), + }, + ]); + Self::new(locales, keymaps, timezones) + } +} + +impl ModelAdapter for TestModel { + fn locales_db(&self) -> &LocalesDatabase { + &self.locales + } + + fn keymaps_db(&self) -> &KeymapsDatabase { + &self.keymaps + } + + fn timezones_db(&self) -> &TimezonesDatabase { + &self.timezones + } + + fn locale(&self) -> LocaleId { + LocaleId::default() + } + + fn keymap(&self) -> Result { + Ok(KeymapId::default()) + } +} + +/// Starts a testing l10n service. +pub async fn start_service( + events: event::Sender, + issues: Handler, +) -> Handler { + let model = TestModel::with_sample_data(); + Starter::new(events, issues) + .with_model(model) + .start() + .await + .expect("Could not spawn a testing l10n service") +} diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 86717c2b40..a75bcee906 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -10,6 +10,8 @@ anyhow = "1.0" agama-utils = { path = "../agama-utils" } agama-network = { path = "../agama-network" } agama-locale-data = { path = "../agama-locale-data" } +agama-l10n = { path = "../agama-l10n" } +agama-transfer = { path = "../agama-transfer" } async-trait = "0.1.83" futures-util = "0.3.30" jsonschema = { version = "0.30.0", default-features = false, features = [ @@ -24,6 +26,7 @@ tempfile = "3.13.0" thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1.16" +tracing = "0.1.40" url = { version = "2.5.2", features = ["serde"] } utoipa = { version = "5.2.0", features = ["url"] } zbus = { version = "5", default-features = false, features = ["tokio"] } @@ -46,6 +49,7 @@ tokio-tungstenite = { version = "0.26.2", features = ["native-tls"] } tokio-native-tls = "0.3.1" percent-encoding = "2.3.1" uuid = { version = "1.17.0", features = ["serde", "v4"] } +zypp-agama = { path = "../zypp-agama" } [dev-dependencies] httpmock = "0.7.0" diff --git a/rust/agama-lib/share/examples/profile_tw.json b/rust/agama-lib/share/examples/profile_tw.json index 2921de1c48..0240823880 100644 --- a/rust/agama-lib/share/examples/profile_tw.json +++ b/rust/agama-lib/share/examples/profile_tw.json @@ -2,7 +2,7 @@ "localization": { "keyboard": "es", "language": "es_ES.UTF-8", - "keymap": "es_ES.UTF-8" + "timezone": "Europe/Berlin" }, "software": { "patterns": ["gnome"], diff --git a/rust/agama-lib/share/package.json b/rust/agama-lib/share/package.json index 7d5210a4f5..90f7f687f5 100644 --- a/rust/agama-lib/share/package.json +++ b/rust/agama-lib/share/package.json @@ -1,6 +1,6 @@ { "scripts": { - "validate": "ajv compile --spec=draft2019 --verbose --all-errors -r storage.schema.json -r iscsi.schema.json -s profile.schema.json && ajv compile --spec=draft2019 --verbose --all-errors -s storage.model.schema.json" + "validate": "ajv compile --spec=draft2019 --verbose --all-errors -r storage.schema.json -r iscsi.schema.json -r software.schema.json -s profile.schema.json && ajv compile --spec=draft2019 --verbose --all-errors -s storage.model.schema.json" }, "dependencies": { "ajv-cli": "^5.0.0" diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 53fc19912e..d7f10b68cf 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -200,74 +200,7 @@ } }, "software": { - "title": "Software settings", - "type": "object", - "properties": { - "patterns": { - "anyOf": [ - { "$ref": "#/$defs/patternsArray" }, - { "$ref": "#/$defs/patternsObject" } - ] - }, - "packages": { - "title": "List of packages to install", - "type": "array", - "items": { - "type": "string", - "examples": ["vim"] - } - }, - "onlyRequired": { - "title": "Flag if only minimal hard dependencies should be used in solver", - "type": "boolean" - }, - "extraRepositories": { - "title": "List of user specified repositories that will be used on top of default ones", - "type": "array", - "items": { - "type": "object", - "required": ["alias", "url"], - "properties": { - "alias": { - "title": "alias used for repository. Acting as identifier", - "type": "string" - }, - "url": { - "title": "URL pointing to repository", - "type": "string" - }, - "priority": { - "title": "Repository priority", - "type": "integer" - }, - "name": { - "title": "User visible name. Defaults to alias", - "type": "string" - }, - "productDir": { - "title": "product directory on multi repo DVD. Usually not needed", - "type": "string" - }, - "enabled": { - "title": "If repository should be enabled. Defaults to true. Useful when adding additional repo that should not be immediately use.", - "type": "boolean" - }, - "allowUnsigned": { - "title": "If unsigned repositories are allowed. Mainly useful for repositories that is hand crafted without GPG signature.", - "type": "boolean" - }, - "gpgFingerprints": { - "title": "List of GPG fingerprints that is accepted for this repository. Useful for own repositories with proper GPG signature.", - "type": "array", - "items": { - "type": "string", - "pattern": "^[0-9a-fA-F ]+" - } - } - } - } - } - } + "$ref": "software.schema.json" }, "questions": { "title": "How to handle Agama questions", @@ -355,6 +288,29 @@ "type": "object", "additionalProperties": false, "properties": { + "state": { + "title": "Network general settings", + "type": "object", + "properties": { + "connectivity": { + "title": "Whether the user is able to access the Internet", + "type": "boolean", + "readOnly": true + }, + "copyNetwork": { + "title": "Whether the network configuration should be copied to the target system", + "type": "boolean" + }, + "networkingEnabled": { + "title": "Whether the network should be enabled", + "type": "boolean" + }, + "wirelessEnabled": { + "title": "Whether the wireless should be enabled", + "type": "boolean" + } + } + }, "connections": { "title": "Network connections to be defined", "type": "array", @@ -785,9 +741,33 @@ } } }, - "localization": { + "l10n": { "title": "Localization settings", "type": "object", + "additionalProperties": false, + "properties": { + "locale": { + "title": "Locale ID", + "type": "string", + "examples": ["en_US.UTF-8", "en_US"] + }, + "keymap": { + "title": "Keymap ID", + "type": "string", + "examples": ["us", "en", "es"] + }, + "timezone": { + "title": "Time zone ID", + "type": "string", + "examples": ["Europe/Berlin"] + } + } + }, + "localization": { + "deprecated": true, + "title": "Localization settings (old schema)", + "type": "object", + "additionalProperties": false, "properties": { "language": { "title": "System language ID", @@ -1000,18 +980,15 @@ "title": "Question class", "description": "Each question has a \"class\" which works as an identifier.", "type": "string", - "examples": ["storage.activate_multipath"] + "examples": [ + "storage.activate_multipath" + ] }, "text": { "title": "Question text", "description": "Question full text", "type": "string" }, - "answer": { - "title": "Question answer", - "description": "Answer to use for the question.", - "type": "string" - }, "password": { "title": "Password provided as response to a password-based question", "type": "string" @@ -1020,32 +997,21 @@ "title": "Additional data for matching questions", "description": "Additional data for matching questions and answers", "type": "object", - "examples": [{ "device": "/dev/sda" }] - } - } - }, - "patternsArray": { - "title": "List of user-selected patterns to install", - "type": "array", - "items": { - "type": "string", - "examples": ["minimal_base"] - } - }, - "patternsObject": { - "title": "Modifications for the list of user-selected patterns to install", - "type": "object", - "additionalProperties": false, - "properties": { - "add": { - "title": "List of user-selected patterns to add to the list", - "type": "array", - "items": { "type": "string" } + "examples": [ + { + "device": "/dev/sda" + } + ] }, - "remove": { - "title": "List of user-selected patterns to remove from the list", - "type": "array", - "items": { "type": "string" } + "action": { + "title": "Predefined question action", + "description": "Action to use for the question.", + "type": "string" + }, + "value": { + "title": "Predefined question value", + "description": "Value to use for the question.", + "type": "string" } } } diff --git a/rust/agama-lib/share/software.schema.json b/rust/agama-lib/share/software.schema.json new file mode 100644 index 0000000000..d615ee2eac --- /dev/null +++ b/rust/agama-lib/share/software.schema.json @@ -0,0 +1,117 @@ +{ + "$comment": "Software configuration", + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.com/agama-project/agama/blob/master/rust/agama-lib/share/software.schema.json", + "title": "Config", + "description": "Software configuration.", + "type": "object", + "properties": { + "patterns": { + "anyOf": [ + { + "$ref": "#/$defs/patternsArray" + }, + { + "$ref": "#/$defs/patternsObject" + } + ] + }, + "packages": { + "description": "List of packages to install", + "type": "array", + "items": { + "type": "string", + "examples": [ + "vim" + ] + } + }, + "onlyRequired": { + "description": "Flag if only minimal hard dependencies should be used in solver", + "type": "boolean" + }, + "extraRepositories": { + "description": "List of user specified repositories that will be used on top of default ones", + "type": "array", + "items": { + "$ref": "#/$defs/repository" + } + } + }, + "$defs": { + "patternsArray": { + "description": "List of user-selected patterns to install", + "type": "array", + "items": { + "type": "string", + "examples": [ + "minimal_base" + ] + } + }, + "patternsObject": { + "description": "Modifications for the list of user-selected patterns to install", + "type": "object", + "additionalProperties": false, + "properties": { + "add": { + "description": "List of user-selected patterns to add to the list", + "type": "array", + "items": { + "type": "string" + } + }, + "remove": { + "description": "List of user-selected patterns to remove from the list", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "repository": { + "description": "Packages repository", + "type": "object", + "additionalProperties": false, + "properties": { + "alias": { + "description": "alias used for repository. Acting as identifier", + "type": "string" + }, + "url": { + "description": "URL pointing to repository", + "type": "string" + }, + "priority": { + "description": "Repository priority", + "type": "integer" + }, + "name": { + "description": "User visible name. Defaults to alias", + "type": "string" + }, + "productDir": { + "description": "product directory on multi repo DVD. Usually not needed", + "type": "string" + }, + "enabled": { + "description": "If repository should be enabled. Defaults to true. Useful when adding additional repo that should not be immediately use.", + "type": "boolean" + }, + "allowUnsigned": { + "description": "If unsigned repositories are allowed. Mainly useful for repositories that is hand crafted without GPG signature.", + "type": "boolean" + }, + "gpgFingerprints": { + "description": "List of GPG fingerprints that is accepted for this repository. Useful for own repositories with proper GPG signature.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F ]+" + } + } + } + } + } +} diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 35732abe6a..c54566ce6a 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -23,7 +23,7 @@ use std::io; use thiserror::Error; use zbus::{self, zvariant}; -use crate::utils::TransferError; +use agama_transfer::Error as TransferError; #[derive(Error, Debug)] pub enum ServiceError { @@ -73,7 +73,7 @@ pub enum ProfileError { Unreachable(#[from] TransferError), #[error("Jsonnet evaluation failed:\n{0}")] EvaluationError(String), - #[error("I/O error")] + #[error("I/O error: {0}")] InputOutputError(#[from] io::Error), #[error("The profile is not a well-formed JSON file")] FormatError(#[from] serde_json::Error), diff --git a/rust/agama-lib/src/files.rs b/rust/agama-lib/src/files.rs index c51b9e9400..8f962ab049 100644 --- a/rust/agama-lib/src/files.rs +++ b/rust/agama-lib/src/files.rs @@ -20,8 +20,4 @@ //! Implements support for handling the file deployment -pub mod client; pub mod error; -pub mod model; -pub mod settings; -pub mod store; diff --git a/rust/agama-lib/src/files/client.rs b/rust/agama-lib/src/files/client.rs deleted file mode 100644 index 33716fbd17..0000000000 --- a/rust/agama-lib/src/files/client.rs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements a client to access Agama's HTTP API related to Bootloader management. - -use super::model::UserFile; -use crate::http::{BaseHTTPClient, BaseHTTPClientError}; - -#[derive(Debug, thiserror::Error)] -pub enum FilesHTTPClientError { - #[error(transparent)] - HTTP(#[from] BaseHTTPClientError), -} - -pub struct FilesClient { - client: BaseHTTPClient, -} - -impl FilesClient { - pub fn new(base: BaseHTTPClient) -> Self { - Self { client: base } - } - - /// returns list of files that will be manually deployed - pub async fn get_files(&self) -> Result, FilesHTTPClientError> { - Ok(self.client.get("/files").await?) - } - - /// Sets the list of files that will be manually deployed - pub async fn set_files(&self, config: &Vec) -> Result<(), FilesHTTPClientError> { - Ok(self.client.put_void("/files", config).await?) - } - - /// writes the files to target - pub async fn write_files(&self) -> Result<(), FilesHTTPClientError> { - Ok(self.client.post_void("/files/write", &()).await?) - } -} diff --git a/rust/agama-lib/src/files/store.rs b/rust/agama-lib/src/files/store.rs deleted file mode 100644 index ffefa1611e..0000000000 --- a/rust/agama-lib/src/files/store.rs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements the store for the files settings. - -use super::{ - client::{FilesClient, FilesHTTPClientError}, - model::UserFile, -}; -use crate::http::BaseHTTPClient; - -#[derive(Debug, thiserror::Error)] -pub enum FilesStoreError { - #[error("Error processing files settings: {0}")] - FilesHTTPClient(#[from] FilesHTTPClientError), -} - -type FilesStoreResult = Result; - -/// Loads and stores the files settings from/to the HTTP service. -pub struct FilesStore { - files_client: FilesClient, -} - -impl FilesStore { - pub fn new(client: BaseHTTPClient) -> Self { - Self { - files_client: FilesClient::new(client), - } - } - - /// loads the list of user files from http API - pub async fn load(&self) -> FilesStoreResult>> { - let res = self.files_client.get_files().await?; - if res.is_empty() { - Ok(None) - } else { - Ok(Some(res)) - } - } - - /// stores the list of user files via http API - pub async fn store(&self, files: &Vec) -> FilesStoreResult<()> { - Ok(self.files_client.set_files(files).await?) - } -} diff --git a/rust/agama-lib/src/http.rs b/rust/agama-lib/src/http.rs index b4ea8cbb2b..b3cdc051dc 100644 --- a/rust/agama-lib/src/http.rs +++ b/rust/agama-lib/src/http.rs @@ -21,8 +21,5 @@ mod base_http_client; pub use base_http_client::{BaseHTTPClient, BaseHTTPClientError}; -mod event; -pub use event::{Event, EventPayload}; - mod websocket; pub use websocket::{WebSocketClient, WebSocketError}; diff --git a/rust/agama-lib/src/http/base_http_client.rs b/rust/agama-lib/src/http/base_http_client.rs index bae02a1b93..1ceb64fa03 100644 --- a/rust/agama-lib/src/http/base_http_client.rs +++ b/rust/agama-lib/src/http/base_http_client.rs @@ -46,7 +46,7 @@ pub enum BaseHTTPClientError { /// Usage should be just thin layer in domain specific client. /// /// ```no_run -/// use agama_lib::questions::model::Question; +/// use agama_utils::api::question::Question; /// use agama_lib::http::{BaseHTTPClient, BaseHTTPClientError}; /// /// async fn get_questions() -> Result, BaseHTTPClientError> { @@ -333,7 +333,7 @@ impl BaseHTTPClient { // let text = String::from_utf8_lossy(&bytes); // eprintln!("Response body: {}", text); - serde_json::from_slice(&bytes).map_err(|e| e.into()) + Ok(serde_json::from_slice(&bytes)?) } else { Err(self.build_backend_error(response).await) } diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs deleted file mode 100644 index fb4e92ae21..0000000000 --- a/rust/agama-lib/src/http/event.rs +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::{ - auth::ClientId, - jobs::Job, - localization::model::LocaleConfig, - manager::InstallationPhase, - network::model::NetworkChange, - progress::Progress, - software::{model::Conflict, SelectedBy}, - storage::{ - model::{ - dasd::{DASDDevice, DASDFormatSummary}, - zfcp::{ZFCPController, ZFCPDisk}, - }, - ISCSINode, - }, - users::{FirstUser, RootUser}, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use crate::issue::Issue; - -/// Agama event. -/// -/// It represents an event that occurs in Agama. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Event { - /// The identifier of the client which caused the event. - #[serde(skip_serializing_if = "Option::is_none")] - pub client_id: Option, - /// Event payload. - #[serde(flatten)] - pub payload: EventPayload, -} - -impl Event { - /// Creates a new event. - /// - /// * `payload`: event payload. - pub fn new(payload: EventPayload) -> Self { - Event { - client_id: None, - payload, - } - } - - /// Creates a new event with a client ID. - /// - /// * `payload`: event payload. - /// * `client_id`: client ID. - pub fn new_with_client_id(payload: EventPayload, client_id: &ClientId) -> Self { - Event { - client_id: Some(client_id.clone()), - payload, - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type")] -pub enum EventPayload { - ClientConnected, - L10nConfigChanged(LocaleConfig), - LocaleChanged { - locale: String, - }, - DevicesDirty { - dirty: bool, - }, - ProgressChanged { - path: String, - #[serde(flatten)] - progress: Progress, - }, - ProductChanged { - id: String, - }, - RegistrationChanged, - FirstUserChanged(FirstUser), - RootUserChanged(RootUser), - NetworkChange { - #[serde(flatten)] - change: NetworkChange, - }, - StorageChanged, - // TODO: it should include the full software proposal or, at least, - // all the relevant changes. - SoftwareProposalChanged { - patterns: HashMap, - }, - ConflictsChanged { - conflicts: Vec, - }, - QuestionsChanged, - InstallationPhaseChanged { - phase: InstallationPhase, - }, - ServiceStatusChanged { - service: String, - status: u32, - }, - IssuesChanged { - path: String, - issues: Vec, - }, - ValidationChanged { - service: String, - path: String, - errors: Vec, - }, - ISCSINodeAdded { - node: ISCSINode, - }, - ISCSINodeChanged { - node: ISCSINode, - }, - ISCSINodeRemoved { - node: ISCSINode, - }, - ISCSIInitiatorChanged { - name: Option, - ibft: Option, - }, - DASDDeviceAdded { - device: DASDDevice, - }, - DASDDeviceChanged { - device: DASDDevice, - }, - DASDDeviceRemoved { - device: DASDDevice, - }, - JobAdded { - job: Job, - }, - JobChanged { - job: Job, - }, - JobRemoved { - job: Job, - }, - DASDFormatJobChanged { - #[serde(rename = "jobId")] - job_id: String, - summary: HashMap, - }, - ZFCPDiskAdded { - device: ZFCPDisk, - }, - ZFCPDiskChanged { - device: ZFCPDisk, - }, - ZFCPDiskRemoved { - device: ZFCPDisk, - }, - ZFCPControllerAdded { - device: ZFCPController, - }, - ZFCPControllerChanged { - device: ZFCPController, - }, - ZFCPControllerRemoved { - device: ZFCPController, - }, -} - -/// Makes it easier to create an event, reducing the boilerplate. -/// -/// # Event without additional data -/// -/// ``` -/// # use agama_lib::{event, http::EventPayload}; -/// let my_event = event!(ClientConnected); -/// assert!(matches!(my_event.payload, EventPayload::ClientConnected)); -/// assert!(my_event.client_id.is_none()); -/// ``` -/// -/// # Event with some additional data -/// -/// ``` -/// # use agama_lib::{event, http::EventPayload}; -/// let my_event = event!(LocaleChanged { locale: "es_ES".to_string() }); -/// assert!(matches!( -/// my_event.payload, -/// EventPayload::LocaleChanged { locale: _ } -/// )); -/// ``` -/// -/// # Adding the client ID -/// -/// ``` -/// # use agama_lib::{auth::ClientId, event, http::EventPayload}; -/// let client_id = ClientId::new(); -/// let my_event = event!(ClientConnected, &client_id); -/// assert!(matches!(my_event.payload, EventPayload::ClientConnected)); -/// assert!(my_event.client_id.is_some()); -/// ``` -/// -/// # Add the client ID to a complex event -/// -/// ``` -/// # use agama_lib::{auth::ClientId, event, http::EventPayload}; -/// let client_id = ClientId::new(); -/// let my_event = event!(LocaleChanged { locale: "es_ES".to_string() }, &client_id); -/// assert!(matches!( -/// my_event.payload, -/// EventPayload::LocaleChanged { locale: _ } -/// )); -/// assert!(my_event.client_id.is_some()); -/// ``` -#[macro_export] -macro_rules! event { - ($variant:ident) => { - agama_lib::http::Event::new(agama_lib::http::EventPayload::$variant) - }; - ($variant:ident, $client:expr) => { - agama_lib::http::Event::new_with_client_id( - agama_lib::http::EventPayload::$variant, - $client, - ) - }; - ($variant:ident $inner:tt, $client:expr) => { - agama_lib::http::Event::new_with_client_id( - agama_lib::http::EventPayload::$variant $inner, - $client - ) - }; - ($variant:ident $inner:tt) => { - agama_lib::http::Event::new(agama_lib::http::EventPayload::$variant $inner) - }; -} diff --git a/rust/agama-lib/src/http/websocket.rs b/rust/agama-lib/src/http/websocket.rs index 40afdcdd43..c32aa094d8 100644 --- a/rust/agama-lib/src/http/websocket.rs +++ b/rust/agama-lib/src/http/websocket.rs @@ -21,6 +21,7 @@ //! This module implements a WSClient to connect to Agama's WebSocket and //! listen for events. +use agama_utils::api::Event; use tokio::{net::TcpStream, sync::broadcast}; use tokio_native_tls::native_tls; use tokio_stream::StreamExt; @@ -34,7 +35,6 @@ use tokio_tungstenite::{ }; use url::Url; -use super::Event; use crate::auth::AuthToken; #[derive(Debug, thiserror::Error)] diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index bda21d4b34..e0f7a3add8 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -23,18 +23,10 @@ //! This module implements the mechanisms to load and store the installation settings. use crate::bootloader::model::BootloaderSettings; use crate::context::InstallationContext; -use crate::file_source::{FileSourceError, WithFileSource}; -use crate::files::model::UserFile; use crate::hostname::model::HostnameSettings; -use crate::questions::config::QuestionsConfig; use crate::security::settings::SecuritySettings; use crate::storage::settings::zfcp::ZFCPConfig; -use crate::{ - localization::LocalizationSettings, network::NetworkSettings, product::ProductSettings, - scripts::ScriptsConfig, software::SoftwareSettings, storage::settings::dasd::DASDConfig, - users::UserSettings, -}; -use fluent_uri::Uri; +use crate::{network::NetworkSettings, storage::settings::dasd::DASDConfig, users::UserSettings}; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::default::Default; @@ -46,15 +38,13 @@ pub enum InstallSettingsError { InputOuputError(#[from] std::io::Error), #[error("Could not parse the settings: {0}")] ParseError(#[from] serde_json::Error), - #[error(transparent)] - FileSourceError(#[from] FileSourceError), } /// Installation settings /// /// This struct represents installation settings. It serves as an entry point and it is composed of /// other structs which hold the settings for each area ("users", "software", etc.). -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] @@ -62,34 +52,25 @@ pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] pub dasd: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub files: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub hostname: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] pub iscsi: Option>, #[serde(flatten)] pub user: Option, #[serde(skip_serializing_if = "Option::is_none")] pub security: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub software: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub product: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] pub storage: Option>, #[serde(rename = "legacyAutoyastStorage")] #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] pub storage_autoyast: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub localization: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub scripts: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub zfcp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub questions: Option, } impl InstallSettings { @@ -109,28 +90,9 @@ impl InstallSettings { /// - `context`: Store context. pub fn from_json( json: &str, - context: &InstallationContext, + _context: &InstallationContext, ) -> Result { - let mut settings: InstallSettings = serde_json::from_str(json)?; - settings.resolve_urls(&context.source).unwrap(); + let settings: InstallSettings = serde_json::from_str(json)?; Ok(settings) } - - /// Resolves URLs in the settings. - /// - // Ideally, the context could be ready when deserializing the settings so - // the URLs can be resolved. One possible solution would be to use - // [DeserializeSeed](https://docs.rs/serde/1.0.219/serde/de/trait.DeserializeSeed.html). - fn resolve_urls(&mut self, source_uri: &Uri) -> Result<(), InstallSettingsError> { - if let Some(ref mut scripts) = self.scripts { - scripts.resolve_urls(source_uri)?; - } - - if let Some(ref mut files) = self.files { - for file in files.iter_mut() { - file.resolve_url(source_uri)?; - } - } - Ok(()) - } } diff --git a/rust/agama-lib/src/issue.rs b/rust/agama-lib/src/issue.rs deleted file mode 100644 index 103689d3fa..0000000000 --- a/rust/agama-lib/src/issue.rs +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] -pub struct Issue { - description: String, - details: Option, - source: u32, - severity: u32, - kind: String, -} - -impl Issue { - pub fn from_tuple( - (description, kind, details, source, severity): (String, String, String, u32, u32), - ) -> Self { - let details = if details.is_empty() { - None - } else { - Some(details) - }; - - Self { - description, - kind, - details, - source, - severity, - } - } -} - -impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { - type Error = zbus::zvariant::Error; - - fn try_from(value: &zbus::zvariant::Value<'_>) -> Result { - let value = value.downcast_ref::()?; - let fields = value.fields(); - - let Some([description, kind, details, source, severity]) = fields.get(0..5) else { - return Err(zbus::zvariant::Error::Message( - "Not enough elements for building an Issue.".to_string(), - )); - }; - - let description: String = description.try_into()?; - let kind: String = kind.try_into()?; - let details: String = details.try_into()?; - let source: u32 = source.try_into()?; - let severity: u32 = severity.try_into()?; - - Ok(Issue { - description, - kind, - details: if details.is_empty() { - None - } else { - Some(details.to_string()) - }, - severity, - source, - }) - } -} diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 02fa38d1a7..8684f2c6f7 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -30,7 +30,7 @@ //! //! Let's have a look to the components that are involved when dealing with the installation //! settings, as it is the most complex part of the library. The code is organized in a set of -//! modules, one for each topic, like [network], [software], and so on. +//! modules, one for each topic. //! //! Each of those modules contains, at least: //! @@ -47,26 +47,20 @@ pub mod auth; pub mod bootloader; pub mod context; pub mod error; -pub mod file_source; -pub mod files; pub mod hostname; pub mod http; pub mod install_settings; -pub mod issue; +pub use agama_utils::issue; pub mod jobs; -pub mod localization; pub mod logs; pub mod manager; pub mod monitor; pub mod network; -pub mod product; pub mod profile; pub mod progress; pub mod proxies; pub mod questions; -pub mod scripts; pub mod security; -pub mod software; pub mod storage; mod store; pub mod users; diff --git a/rust/agama-lib/src/localization.rs b/rust/agama-lib/src/localization.rs deleted file mode 100644 index 6d3ae18db3..0000000000 --- a/rust/agama-lib/src/localization.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements support for handling the localization settings - -mod http_client; -pub mod model; -mod settings; -mod store; - -pub use http_client::LocalizationHTTPClient; -pub use settings::LocalizationSettings; -pub use store::{LocalizationStore, LocalizationStoreError}; diff --git a/rust/agama-lib/src/localization/store.rs b/rust/agama-lib/src/localization/store.rs deleted file mode 100644 index 9bda06fb48..0000000000 --- a/rust/agama-lib/src/localization/store.rs +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements the store for the localization settings. -// TODO: for an overview see crate::store (?) - -use super::{ - http_client::LocalizationHTTPClientError, LocalizationHTTPClient, LocalizationSettings, -}; -use crate::{http::BaseHTTPClient, localization::model::LocaleConfig}; - -#[derive(Debug, thiserror::Error)] -#[error("Error processing localization settings: {0}")] -pub struct LocalizationStoreError(#[from] LocalizationHTTPClientError); - -type LocalizationStoreResult = Result; - -/// Loads and stores the storage settings from/to the D-Bus service. -pub struct LocalizationStore { - localization_client: LocalizationHTTPClient, -} - -impl LocalizationStore { - pub fn new(client: BaseHTTPClient) -> Self { - Self { - localization_client: LocalizationHTTPClient::new(client), - } - } - - pub fn new_with_client(client: LocalizationHTTPClient) -> Self { - Self { - localization_client: client, - } - } - - /// Consume *v* and return its first element, or None. - /// This is similar to VecDeque::pop_front but it consumes the whole Vec. - fn chestburster(mut v: Vec) -> Option { - if v.is_empty() { - None - } else { - Some(v.swap_remove(0)) - } - } - - pub async fn load(&self) -> LocalizationStoreResult { - let config = self.localization_client.get_config().await?; - - let opt_language = config.locales.and_then(Self::chestburster); - let opt_keyboard = config.keymap; - let opt_timezone = config.timezone; - - Ok(LocalizationSettings { - language: opt_language, - keyboard: opt_keyboard, - timezone: opt_timezone, - }) - } - - pub async fn store(&self, settings: &LocalizationSettings) -> LocalizationStoreResult<()> { - // clones are necessary as we have different structs owning their data - let opt_language = settings.language.clone(); - let opt_keymap = settings.keyboard.clone(); - let opt_timezone = settings.timezone.clone(); - - let config = LocaleConfig { - locales: opt_language.map(|s| vec![s]), - keymap: opt_keymap, - timezone: opt_timezone, - ui_locale: None, - ui_keymap: None, - }; - Ok(self.localization_client.set_config(&config).await?) - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::http::BaseHTTPClient; - use httpmock::prelude::*; - use httpmock::Method::PATCH; - use std::error::Error; - use tokio::test; // without this, "error: async functions cannot be used for tests" - - async fn localization_store( - mock_server_url: String, - ) -> Result> { - let bhc = - BaseHTTPClient::new(mock_server_url).map_err(LocalizationHTTPClientError::HTTP)?; - let client = LocalizationHTTPClient::new(bhc); - Ok(LocalizationStore::new_with_client(client)) - } - - #[test] - async fn test_getting_l10n() -> Result<(), Box> { - let server = MockServer::start(); - let l10n_mock = server.mock(|when, then| { - when.method(GET).path("/api/l10n/config"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "locales": ["fr_FR.UTF-8"], - "keymap": "fr(dvorak)", - "timezone": "Europe/Paris" - }"#, - ); - }); - let url = server.url("/api"); - - let store = localization_store(url).await?; - let settings = store.load().await?; - - let expected = LocalizationSettings { - language: Some("fr_FR.UTF-8".to_owned()), - keyboard: Some("fr(dvorak)".to_owned()), - timezone: Some("Europe/Paris".to_owned()), - }; - // main assertion - assert_eq!(settings, expected); - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - l10n_mock.assert(); - Ok(()) - } - - #[test] - async fn test_setting_l10n() -> Result<(), Box> { - let server = MockServer::start(); - let l10n_mock = server.mock(|when, then| { - when.method(PATCH) - .path("/api/l10n/config") - .header("content-type", "application/json") - .body( - r#"{"locales":["fr_FR.UTF-8"],"keymap":"fr(dvorak)","timezone":"Europe/Paris","uiLocale":null,"uiKeymap":null}"# - ); - then.status(204); - }); - let url = server.url("/api"); - - let store = localization_store(url).await?; - - let settings = LocalizationSettings { - language: Some("fr_FR.UTF-8".to_owned()), - keyboard: Some("fr(dvorak)".to_owned()), - timezone: Some("Europe/Paris".to_owned()), - }; - let result = store.store(&settings).await; - - // main assertion - result?; - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - l10n_mock.assert(); - Ok(()) - } -} diff --git a/rust/agama-lib/src/monitor.rs b/rust/agama-lib/src/monitor.rs index 8a2d5d4369..b66611f12d 100644 --- a/rust/agama-lib/src/monitor.rs +++ b/rust/agama-lib/src/monitor.rs @@ -22,14 +22,11 @@ //! //! The monitor tracks: //! -//! * Changes in the installer status (see InstallerStatus). -//! * Progress changes in any service. +//! * Changes in the installer status (see [api::Status]). //! //! Each time the installer status changes, it sends the new status using the -//! MonitorStatus struct. +//! [api::Status] struct. //! -//! Note: in the future we might send only the changes, but at this point -//! the monitor sends the full status. //! //! ```no_run //! # use agama_lib::{monitor::Monitor, auth::AuthToken, http::{BaseHTTPClient, WebSocketClient}}; @@ -44,26 +41,17 @@ //! //! loop { //! if let Ok(status) = updates.recv().await { -//! println!("Status: {:?}", &status.installer_status); +//! println!("Status: {:?}", &status.stage); //! } //! } //! } //! ``` //! -use std::collections::HashMap; +use agama_utils::api::{self, Event}; use tokio::sync::{broadcast, mpsc, oneshot}; -use crate::{ - http::{ - BaseHTTPClient, BaseHTTPClientError, Event, EventPayload, WebSocketClient, WebSocketError, - }, - manager::{InstallationPhase, InstallerStatus}, - progress::Progress, -}; - -const MANAGER_PROGRESS_OBJECT_PATH: &str = "/org/opensuse/Agama/Manager1"; -const SOFTWARE_PROGRESS_OBJECT_PATH: &str = "/org/opensuse/Agama/Software1"; +use crate::http::{BaseHTTPClient, BaseHTTPClientError, WebSocketClient, WebSocketError}; #[derive(thiserror::Error, Debug)] pub enum MonitorError { @@ -77,60 +65,18 @@ pub enum MonitorError { Recv(#[from] oneshot::error::RecvError), } -/// Represents the current status of the installer. -#[derive(Clone, Debug, Default)] -pub struct MonitorStatus { - /// The general installer status. - /// - /// FIXME: do not hold the full status (some elements are not updated) - pub installer_status: InstallerStatus, - /// Progress for each service using the D-Bus object path as the key. If the progress is - /// finished, the entry is removed from the map. - pub progress: HashMap, -} - -impl MonitorStatus { - /// Updates the progress for the given service. - /// - /// The entry is removed if the progress is finished. - /// - /// * `service`: D-Bus object path. - /// * `progress`: updated progress. - fn update_progress(&mut self, path: String, progress: Progress) { - if progress.finished { - _ = self.progress.remove_entry(&path); - } else { - _ = self.progress.insert(path, progress); - } - } - - /// Sets whether the installer is busy or not. - /// - /// * `is_busy`: whether the installer is busy. - fn set_is_busy(&mut self, is_busy: bool) { - self.installer_status.is_busy = is_busy; - } - - /// Sets the service phase. - /// - /// * `phase`: installation phase. - fn set_phase(&mut self, phase: InstallationPhase) { - self.installer_status.phase = phase; - } -} - /// It allows connecting to the Agama monitor to get the status or listen for changes. /// /// It can be cloned and moved between threads. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct MonitorClient { commands: mpsc::Sender, - pub updates: broadcast::Sender, + pub updates: broadcast::Sender, } impl MonitorClient { /// Returns the installer status. - pub async fn get_status(&self) -> Result { + pub async fn get_status(&self) -> Result { let (tx, rx) = tokio::sync::oneshot::channel(); _ = self.commands.send(MonitorCommand::GetStatus(tx)).await; Ok(rx.await?) @@ -139,7 +85,7 @@ impl MonitorClient { /// Subscribe to status updates from the monitor. /// /// It uses a regular broadcast channel from the Tokio library. - pub fn subscribe(&self) -> broadcast::Receiver { + pub fn subscribe(&self) -> broadcast::Receiver { self.updates.subscribe() } } @@ -150,14 +96,15 @@ pub struct Monitor { // Channel to receive commands. commands: mpsc::Receiver, // Channel to send updates. - updates: broadcast::Sender, - status: MonitorStatus, + updates: broadcast::Sender, + status: api::Status, ws_client: WebSocketClient, + status_reader: MonitorStatusReader, } #[derive(Debug)] enum MonitorCommand { - GetStatus(tokio::sync::oneshot::Sender), + GetStatus(tokio::sync::oneshot::Sender), } impl Monitor { @@ -180,13 +127,15 @@ impl Monitor { updates: updates.clone(), }; - let status = MonitorStatusReader::with_client(http_client).read().await?; + let status_reader = MonitorStatusReader::with_client(http_client); + let status = status_reader.read().await?; let mut monitor = Monitor { status, updates, commands: commands_rx, ws_client: websocket_client, + status_reader, }; tokio::spawn(async move { monitor.run().await }); @@ -201,7 +150,7 @@ impl Monitor { self.handle_command(cmd); } Ok(event) = self.ws_client.receive() => { - self.handle_event(event); + self.handle_event(event).await; } } } @@ -224,23 +173,29 @@ impl Monitor { /// sends the updated state to its subscribers. /// /// * `event`: Agama event. - fn handle_event(&mut self, event: Event) { - match event.payload { - EventPayload::ProgressChanged { path, progress } => { - self.status.update_progress(path, progress); - } - EventPayload::ServiceStatusChanged { service, status } => { - if service.as_str() == MANAGER_PROGRESS_OBJECT_PATH { - self.status.set_is_busy(status == 1); - } + async fn handle_event(&mut self, event: Event) { + match event { + // status related events is used here. + Event::ProgressFinished { scope: _ } => {} + Event::StageChanged => {} + Event::ProgressChanged { progress: _ } => {} + _ => { + return; } - EventPayload::InstallationPhaseChanged { phase } => { - self.status.set_phase(phase); - } - _ => {} } + self.reread_status().await; let _ = self.updates.send(self.status.clone()); } + + async fn reread_status(&mut self) { + let status_result = self.status_reader.read().await; + + let Ok(new_status) = status_result else { + tracing::warn!("Failed to read status {:?}", status_result); + return; + }; + self.status = new_status; + } } /// Ancillary struct to read the status from the API. @@ -253,39 +208,8 @@ impl MonitorStatusReader { Self { http } } - pub async fn read(self) -> Result { - let installer_status: InstallerStatus = self.http.get("/manager/installer").await?; - let mut status = MonitorStatus { - installer_status, - ..Default::default() - }; - - self.add_service_progress( - &mut status, - MANAGER_PROGRESS_OBJECT_PATH, - "/manager/progress", - ) - .await?; - self.add_service_progress( - &mut status, - SOFTWARE_PROGRESS_OBJECT_PATH, - "/software/progress", - ) - .await?; + pub async fn read(&self) -> Result { + let status: api::Status = self.http.get("/v2/status").await?; Ok(status) } - - async fn add_service_progress( - &self, - status: &mut MonitorStatus, - dbus_path: &str, - path: &str, - ) -> Result<(), MonitorError> { - let progress: Progress = self.http.get(path).await?; - if progress.finished { - return Ok(()); - } - status.progress.insert(dbus_path.to_string(), progress); - Ok(()) - } } diff --git a/rust/agama-lib/src/network.rs b/rust/agama-lib/src/network.rs index 41fa7fb7d9..5fe3da04f5 100644 --- a/rust/agama-lib/src/network.rs +++ b/rust/agama-lib/src/network.rs @@ -24,9 +24,9 @@ mod client; mod store; pub use agama_network::{ - error, model, settings, types, Action, Adapter, NetworkAdapterError, NetworkManagerAdapter, + error, model, types, Action, Adapter, NetworkAdapterError, NetworkManagerAdapter, NetworkSystem, NetworkSystemClient, NetworkSystemError, }; +pub use agama_utils::api::network::*; pub use client::{NetworkClient, NetworkClientError}; -pub use settings::NetworkSettings; pub use store::{NetworkStore, NetworkStoreError}; diff --git a/rust/agama-lib/src/network/client.rs b/rust/agama-lib/src/network/client.rs index 0fb0b6bb30..dbb3854beb 100644 --- a/rust/agama-lib/src/network/client.rs +++ b/rust/agama-lib/src/network/client.rs @@ -18,8 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{settings::NetworkConnection, types::Device}; use crate::http::{BaseHTTPClient, BaseHTTPClientError}; +use crate::network::{Device, NetworkConnection}; use crate::utils::url::encode; #[derive(Debug, thiserror::Error)] diff --git a/rust/agama-lib/src/network/store.rs b/rust/agama-lib/src/network/store.rs index a591b529dd..9527d212fd 100644 --- a/rust/agama-lib/src/network/store.rs +++ b/rust/agama-lib/src/network/store.rs @@ -18,11 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{settings::NetworkConnection, NetworkClientError}; +use super::NetworkClientError; use crate::{ http::BaseHTTPClient, network::{NetworkClient, NetworkSettings}, }; +use agama_network::types::NetworkConnectionsCollection; +use agama_utils::api::network::NetworkConnection; #[derive(Debug, thiserror::Error)] #[error("Error processing network settings: {0}")] @@ -44,15 +46,20 @@ impl NetworkStore { // TODO: read the settings from the service pub async fn load(&self) -> NetworkStoreResult { - let connections = self.network_client.connections().await?; - Ok(NetworkSettings { connections }) + let connections = NetworkConnectionsCollection(self.network_client.connections().await?); + + Ok(NetworkSettings { + connections, + ..Default::default() + }) } pub async fn store(&self, settings: &NetworkSettings) -> NetworkStoreResult<()> { - for id in ordered_connections(&settings.connections) { + let connections = &settings.connections.0; + for id in ordered_connections(connections) { let id = id.as_str(); let fallback = default_connection(id); - let conn = find_connection(id, &settings.connections).unwrap_or(&fallback); + let conn = find_connection(id, connections).unwrap_or(&fallback); self.network_client .add_or_update_connection(conn.clone()) .await?; @@ -129,7 +136,7 @@ fn default_connection(id: &str) -> NetworkConnection { #[cfg(test)] mod tests { use super::ordered_connections; - use crate::network::settings::{BondSettings, BridgeSettings, NetworkConnection}; + use crate::network::{BondSettings, BridgeSettings, NetworkConnection}; #[test] fn test_ordered_connections() { diff --git a/rust/agama-lib/src/product.rs b/rust/agama-lib/src/product.rs deleted file mode 100644 index 2532df7366..0000000000 --- a/rust/agama-lib/src/product.rs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements support for handling the product settings - -mod client; -mod http_client; -pub mod proxies; -mod settings; -mod store; - -pub use client::{Product, ProductClient}; -pub use http_client::ProductHTTPClient; -pub use settings::ProductSettings; -pub use store::{ProductStore, ProductStoreError}; diff --git a/rust/agama-lib/src/product/client.rs b/rust/agama-lib/src/product/client.rs deleted file mode 100644 index e9b33a2331..0000000000 --- a/rust/agama-lib/src/product/client.rs +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) [2024-2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::error::ServiceError; -use crate::software::model::{AddonParams, AddonProperties}; -use crate::software::proxies::SoftwareProductProxy; -use agama_utils::dbus::{get_optional_property, get_property}; -use serde::Serialize; -use std::collections::HashMap; -use zbus::Connection; - -use super::proxies::RegistrationProxy; - -/// Represents a software product -#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Product { - /// Product ID (eg., "ALP", "Tumbleweed", etc.) - pub id: String, - /// Product name (e.g., "openSUSE Tumbleweed") - pub name: String, - /// Product description - pub description: String, - /// Product icon (e.g., "default.svg") - pub icon: String, - /// Registration requirement - pub registration: bool, - /// License ID - pub license: Option, -} - -/// D-Bus client for the software service -#[derive(Clone)] -pub struct ProductClient<'a> { - product_proxy: SoftwareProductProxy<'a>, - registration_proxy: RegistrationProxy<'a>, -} - -impl<'a> ProductClient<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { - let product_proxy = SoftwareProductProxy::builder(&connection) - .cache_properties(zbus::proxy::CacheProperties::No) - .build() - .await?; - Ok(Self { - product_proxy, - registration_proxy: RegistrationProxy::new(&connection).await?, - }) - } - - /// Returns the available products - pub async fn products(&self) -> Result, ServiceError> { - let products: Vec = self - .product_proxy - .available_products() - .await? - .into_iter() - .map(|(id, name, data)| { - let description = match data.get("description") { - Some(value) => value.try_into().unwrap(), - None => "", - }; - let icon = match data.get("icon") { - Some(value) => value.try_into().unwrap(), - None => "default.svg", - }; - - let registration = get_property::(&data, "registration").unwrap_or(false); - - let license = get_optional_property::(&data, "license").unwrap_or_default(); - - Product { - id, - name, - description: description.to_string(), - icon: icon.to_string(), - registration, - license, - } - }) - .collect(); - Ok(products) - } - - /// Returns the id of the selected product to install - pub async fn product(&self) -> Result { - Ok(self.product_proxy.selected_product().await?) - } - - /// Selects the product to install - pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> { - let result = self.product_proxy.select_product(product_id).await?; - - match result { - (0, _) => Ok(()), - (3, description) => { - let products = self.products().await?; - let ids: Vec = products.into_iter().map(|p| p.id).collect(); - let error = format!("{0}. Available products: '{1:?}'", description, ids); - Err(ServiceError::UnsuccessfulAction(error)) - } - (_, description) => Err(ServiceError::UnsuccessfulAction(description)), - } - } - - /// flag if base product is registered - pub async fn registered(&self) -> Result { - Ok(self.registration_proxy.registered().await?) - } - - /// registration code used to register product - pub async fn registration_code(&self) -> Result { - Ok(self.registration_proxy.reg_code().await?) - } - - /// email used to register product - pub async fn email(&self) -> Result { - Ok(self.registration_proxy.email().await?) - } - - /// URL of the registration server - pub async fn registration_url(&self) -> Result { - Ok(self.registration_proxy.url().await?) - } - - /// set registration url - pub async fn set_registration_url(&self, url: &str) -> Result<(), ServiceError> { - Ok(self.registration_proxy.set_url(url).await?) - } - - /// list of already registered addons - pub async fn registered_addons(&self) -> Result, ServiceError> { - let addons: Vec = self - .registration_proxy - .registered_addons() - .await? - .into_iter() - .map(|(id, version, code)| AddonParams { - id, - version: if version.is_empty() { - None - } else { - Some(version) - }, - registration_code: if code.is_empty() { None } else { Some(code) }, - }) - .collect(); - Ok(addons) - } - - // details of available addons - pub async fn available_addons(&self) -> Result, ServiceError> { - self.registration_proxy - .available_addons() - .await? - .into_iter() - .map(|hash| { - Ok(AddonProperties { - id: get_property(&hash, "id")?, - version: get_property(&hash, "version")?, - label: get_property(&hash, "label")?, - available: get_property(&hash, "available")?, - free: get_property(&hash, "free")?, - recommended: get_property(&hash, "recommended")?, - description: get_property(&hash, "description")?, - release: get_property(&hash, "release")?, - r#type: get_property(&hash, "type")?, - }) - }) - .collect() - } - - /// register product - pub async fn register(&self, code: &str, email: &str) -> Result<(u32, String), ServiceError> { - let mut options: HashMap<&str, &zbus::zvariant::Value> = HashMap::new(); - let value = zbus::zvariant::Value::from(email); - if !email.is_empty() { - options.insert("Email", &value); - } - Ok(self.registration_proxy.register(code, options).await?) - } - - /// register addon - pub async fn register_addon(&self, addon: &AddonParams) -> Result<(u32, String), ServiceError> { - Ok(self - .registration_proxy - .register_addon( - &addon.id, - &addon.version.clone().unwrap_or_default(), - &addon.registration_code.clone().unwrap_or_default(), - ) - .await?) - } - - /// de-register product - pub async fn deregister(&self) -> Result<(u32, String), ServiceError> { - Ok(self.registration_proxy.deregister().await?) - } -} diff --git a/rust/agama-lib/src/product/http_client.rs b/rust/agama-lib/src/product/http_client.rs deleted file mode 100644 index 7f9d7fc5d2..0000000000 --- a/rust/agama-lib/src/product/http_client.rs +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::http::{BaseHTTPClient, BaseHTTPClientError}; -use crate::software::model::{ - AddonParams, RegistrationError, RegistrationInfo, RegistrationParams, SoftwareConfig, -}; - -use super::settings::AddonSettings; - -#[derive(Debug, thiserror::Error)] -pub enum ProductHTTPClientError { - #[error(transparent)] - HTTP(#[from] BaseHTTPClientError), - // If present, the number is already printed in the String part - #[error("Registration failed: {0}")] - FailedRegistration(String, Option), -} - -pub struct ProductHTTPClient { - client: BaseHTTPClient, -} - -impl ProductHTTPClient { - pub fn new(base: BaseHTTPClient) -> Self { - Self { client: base } - } - - pub async fn get_software(&self) -> Result { - Ok(self.client.get("/software/config").await?) - } - - pub async fn set_software( - &self, - config: &SoftwareConfig, - ) -> Result<(), ProductHTTPClientError> { - Ok(self.client.put_void("/software/config", config).await?) - } - - /// Returns the id of the selected product to install - pub async fn product(&self) -> Result { - let config = self.get_software().await?; - if let Some(product) = config.product { - Ok(product) - } else { - Ok("".to_owned()) - } - } - - /// Selects the product to install - pub async fn select_product(&self, product_id: &str) -> Result<(), ProductHTTPClientError> { - let config = SoftwareConfig { - product: Some(product_id.to_owned()), - patterns: None, - packages: None, - extra_repositories: None, - only_required: None, - }; - self.set_software(&config).await - } - - pub async fn get_registration(&self) -> Result { - Ok(self.client.get("/software/registration").await?) - } - - pub async fn set_registration_url(&self, url: &String) -> Result<(), ProductHTTPClientError> { - self.client - .put_void("/software/registration/url", url) - .await?; - Ok(()) - } - - // get list of registered addons - pub async fn get_registered_addons( - &self, - ) -> Result, ProductHTTPClientError> { - let addons = self - .client - .get("/software/registration/addons/registered") - .await?; - Ok(addons) - } - - /// register product - pub async fn register(&self, key: &str, email: &str) -> Result<(), ProductHTTPClientError> { - // note RegistrationParams != RegistrationInfo, fun! - let params = RegistrationParams { - key: key.to_owned(), - email: email.to_owned(), - }; - let result = self - .client - .post_void("/software/registration", ¶ms) - .await; - - let Err(error) = result else { - return Ok(()); - }; - - let mut id: Option = None; - - let message = match error { - BaseHTTPClientError::BackendError(_, details) => { - let details: RegistrationError = serde_json::from_str(&details).unwrap(); - id = Some(details.id); - format!("{} (error code: {})", details.message, details.id) - } - _ => format!("Could not register the product: #{error:?}"), - }; - - Err(ProductHTTPClientError::FailedRegistration(message, id)) - } - - /// register addon - pub async fn register_addon( - &self, - addon: &AddonSettings, - ) -> Result<(), ProductHTTPClientError> { - let addon_params = AddonParams { - id: addon.id.to_owned(), - version: addon.version.to_owned(), - registration_code: addon.registration_code.to_owned(), - }; - let result = self - .client - .post_void("/software/registration/addons/register", &addon_params) - .await; - - let Err(error) = result else { - return Ok(()); - }; - - let mut id: Option = None; - - let message = match error { - BaseHTTPClientError::BackendError(_, details) => { - println!("Details: {:?}", details); - let details: RegistrationError = serde_json::from_str(&details).unwrap(); - id = Some(details.id); - format!("{} (error code: {})", details.message, details.id) - } - _ => format!("Could not register the addon: #{error:?}"), - }; - - Err(ProductHTTPClientError::FailedRegistration(message, id)) - } -} diff --git a/rust/agama-lib/src/product/proxies.rs b/rust/agama-lib/src/product/proxies.rs deleted file mode 100644 index 97d7c4d3a9..0000000000 --- a/rust/agama-lib/src/product/proxies.rs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Registration` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama.Software1.Product.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::PropertiesProxy`] -//! * [`zbus::fdo::IntrospectableProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama.Software1", - default_path = "/org/opensuse/Agama/Software1/Product", - interface = "org.opensuse.Agama1.Registration", - assume_defaults = true -)] -pub trait Registration { - /// Deregister method - fn deregister(&self) -> zbus::Result<(u32, String)>; - - /// Register method - fn register( - &self, - reg_code: &str, - options: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, - ) -> zbus::Result<(u32, String)>; - - /// Register addon method - fn register_addon( - &self, - name: &str, - version: &str, - reg_code: &str, - ) -> zbus::Result<(u32, String)>; - - /// Email property - #[zbus(property)] - fn email(&self) -> zbus::Result; - - /// RegCode property - #[zbus(property)] - fn reg_code(&self) -> zbus::Result; - - /// Registered property - #[zbus(property)] - fn registered(&self) -> zbus::Result; - - /// Url property - #[zbus(property)] - fn url(&self) -> zbus::Result; - #[zbus(property)] - fn set_url(&self, value: &str) -> zbus::Result<()>; - - /// registered addons property, list of tuples (name, version, reg_code)) - #[zbus(property)] - fn registered_addons(&self) -> zbus::Result>; - - /// available addons property, a hash with string key - #[zbus(property)] - fn available_addons( - &self, - ) -> zbus::Result>>; -} diff --git a/rust/agama-lib/src/product/settings.rs b/rust/agama-lib/src/product/settings.rs deleted file mode 100644 index a1348b707e..0000000000 --- a/rust/agama-lib/src/product/settings.rs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Representation of the product settings - -use serde::{Deserialize, Serialize}; - -/// Addon settings for registration -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct AddonSettings { - pub id: String, - /// Optional version of the addon, if not specified the version is found - /// from the available addons - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - /// Free extensions do not require a registration code - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_code: Option, -} - -/// Software settings for installation -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ProductSettings { - /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub addons: Option>, -} diff --git a/rust/agama-lib/src/product/store.rs b/rust/agama-lib/src/product/store.rs deleted file mode 100644 index 4aaee1beda..0000000000 --- a/rust/agama-lib/src/product/store.rs +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright (c) [2024-2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements the store for the product settings. -use super::{http_client::ProductHTTPClientError, ProductHTTPClient, ProductSettings}; -use crate::{ - http::BaseHTTPClient, - manager::http_client::{ManagerHTTPClient, ManagerHTTPClientError}, -}; -use std::time; -use tokio::time::sleep; - -// registration retry attempts -const RETRY_ATTEMPTS: u32 = 4; -// initial delay for exponential backoff in seconds, it doubles after every retry (2,4,8,16) -const INITIAL_RETRY_DELAY: u64 = 2; - -#[derive(Debug, thiserror::Error)] -pub enum ProductStoreError { - #[error("Error processing product settings: {0}")] - Product(#[from] ProductHTTPClientError), - #[error("Error reading software repositories: {0}")] - Probe(#[from] ManagerHTTPClientError), -} - -type ProductStoreResult = Result; - -/// Loads and stores the product settings from/to the D-Bus service. -pub struct ProductStore { - product_client: ProductHTTPClient, - manager_client: ManagerHTTPClient, -} - -impl ProductStore { - pub fn new(client: BaseHTTPClient) -> ProductStore { - Self { - product_client: ProductHTTPClient::new(client.clone()), - manager_client: ManagerHTTPClient::new(client), - } - } - - fn non_empty_string(s: String) -> Option { - if s.is_empty() { - None - } else { - Some(s) - } - } - - pub async fn load(&self) -> ProductStoreResult { - let product = self.product_client.product().await?; - let registration_info = self.product_client.get_registration().await?; - let registered_addons = self.product_client.get_registered_addons().await?; - - let addons = if registered_addons.is_empty() { - None - } else { - Some(registered_addons) - }; - Ok(ProductSettings { - id: Some(product), - registration_code: Self::non_empty_string(registration_info.key), - registration_email: Self::non_empty_string(registration_info.email), - registration_url: Self::non_empty_string(registration_info.url), - addons, - }) - } - - pub async fn store(&self, settings: &ProductSettings) -> ProductStoreResult<()> { - let mut probe = false; - let mut reprobe = false; - if let Some(product) = &settings.id { - let existing_product = self.product_client.product().await?; - if *product != existing_product { - // avoid selecting same product and unnecessary probe - self.product_client.select_product(product).await?; - probe = true; - } - } - // register system if either URL or reg code is provided as RMT does not need reg code and SCC uses default url - // bsc#1246069 - if settings.registration_code.is_some() || settings.registration_url.is_some() { - if let Some(url) = &settings.registration_url { - self.product_client.set_registration_url(url).await?; - } - // lets use empty string if not defined - let reg_code = settings.registration_code.as_deref().unwrap_or(""); - let email = settings.registration_email.as_deref().unwrap_or(""); - - self.retry_registration(|| self.product_client.register(reg_code, email)) - .await?; - // TODO: avoid reprobing if the system has been already registered with the same code? - reprobe = true; - } - - // register the addons in the order specified in the profile - if let Some(addons) = &settings.addons { - for addon in addons.iter() { - self.retry_registration(|| self.product_client.register_addon(addon)) - .await?; - } - } - - if probe { - self.manager_client.probe().await?; - } else if reprobe { - self.manager_client.reprobe().await?; - } - - Ok(()) - } - - // shared retry logic for base product and addon registration - async fn retry_registration(&self, block: F) -> Result<(), ProductHTTPClientError> - where - F: AsyncFn() -> Result<(), ProductHTTPClientError>, - { - // retry counter - let mut attempt = 0; - loop { - // call the passed block - let result = block().await; - - match result { - // success, leave the loop - Ok(()) => return result, - Err(ref error) => { - match error { - ProductHTTPClientError::FailedRegistration(_msg, code) => { - match code { - // see service/lib/agama/dbus/software/product.rb - // 4 => network error, 5 => timeout error - Some(4) | Some(5) => { - if attempt >= RETRY_ATTEMPTS { - // still failing, report the error - return result; - } - - // wait a bit then retry (run the loop again) - let delay = INITIAL_RETRY_DELAY << attempt; - eprintln!("Retrying registration in {} seconds...", delay); - sleep(time::Duration::from_secs(delay)).await; - attempt += 1; - } - // fail for other or unknown problems, retry very likely won't help - _ => return result, - } - } - // an HTTP error, fail - _ => return result, - } - } - } - } - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::http::BaseHTTPClient; - use httpmock::prelude::*; - use std::error::Error; - use tokio::test; // without this, "error: async functions cannot be used for tests" - - fn product_store(mock_server_url: String) -> ProductStore { - let bhc = BaseHTTPClient::new(mock_server_url).unwrap(); - let p_client = ProductHTTPClient::new(bhc.clone()); - let m_client = ManagerHTTPClient::new(bhc); - ProductStore { - product_client: p_client, - manager_client: m_client, - } - } - - #[test] - async fn test_getting_product() -> Result<(), Box> { - let server = MockServer::start(); - let software_mock = server.mock(|when, then| { - when.method(GET).path("/api/software/config"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "patterns": {"xfce":true}, - "product": "Tumbleweed" - }"#, - ); - }); - let registration_mock = server.mock(|when, then| { - when.method(GET).path("/api/software/registration"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "registered": false, - "key": "", - "email": "", - "url": "" - }"#, - ); - }); - let addons_mock = server.mock(|when, then| { - when.method(GET) - .path("/api/software/registration/addons/registered"); - then.status(200) - .header("content-type", "application/json") - .body("[]"); - }); - let url = server.url("/api"); - - let store = product_store(url); - let settings = store.load().await?; - - let expected = ProductSettings { - id: Some("Tumbleweed".to_owned()), - registration_code: None, - registration_email: None, - registration_url: None, - addons: None, - }; - // main assertion - assert_eq!(settings, expected); - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - software_mock.assert(); - registration_mock.assert(); - addons_mock.assert(); - Ok(()) - } - - #[test] - async fn test_setting_product_ok() -> Result<(), Box> { - let server = MockServer::start(); - // no product selected at first - let get_software_mock = server.mock(|when, then| { - when.method(GET).path("/api/software/config"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "patterns": {}, - "packages": [], - "product": "" - }"#, - ); - }); - let software_mock = server.mock(|when, then| { - when.method(PUT) - .path("/api/software/config") - .header("content-type", "application/json") - .body(r#"{"patterns":null,"packages":null,"product":"Tumbleweed","extraRepositories":null,"onlyRequired":null}"#); - then.status(200); - }); - let manager_mock = server.mock(|when, then| { - when.method(POST) - .path("/api/manager/probe_sync") - .header("content-type", "application/json") - .body("null"); - then.status(200); - }); - let url = server.url("/api"); - - let store = product_store(url); - let settings = ProductSettings { - id: Some("Tumbleweed".to_owned()), - registration_code: None, - registration_email: None, - registration_url: None, - addons: None, - }; - - let result = store.store(&settings).await; - - // main assertion - result?; - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - get_software_mock.assert(); - software_mock.assert(); - manager_mock.assert(); - Ok(()) - } -} diff --git a/rust/agama-lib/src/profile.rs b/rust/agama-lib/src/profile.rs index 25d3d7d226..5715f831c8 100644 --- a/rust/agama-lib/src/profile.rs +++ b/rust/agama-lib/src/profile.rs @@ -22,13 +22,21 @@ use crate::error::ProfileError; use anyhow::Context; use log::info; use serde_json; -use std::{fs, io::Write, path::Path, process::Command}; +use std::{ + env, fs, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; use tempfile::{tempdir, TempDir}; use url::Url; pub mod http_client; pub use http_client::ProfileHTTPClient; +pub const DEFAULT_SCHEMA_DIR: &str = "/usr/share/agama/schema"; +pub const DEFAULT_JSONNET_DIR: &str = "/usr/share/agama/jsonnet"; + /// Downloads and converts autoyast profile. pub struct AutoyastProfileImporter { pub content: String, @@ -119,19 +127,22 @@ pub struct ProfileValidator { impl ProfileValidator { pub fn default_schema() -> Result { - let relative_path = Path::new("agama-lib/share/profile.schema.json"); + let relative_path = PathBuf::from("agama-lib/share/profile.schema.json"); let path = if relative_path.exists() { relative_path } else { - Path::new("/usr/share/agama-cli/profile.schema.json") + let schema_dir = env::var("AGAMA_SCHEMA_DIR").unwrap_or(DEFAULT_SCHEMA_DIR.to_string()); + PathBuf::from(schema_dir).join("profile.schema.json") }; info!("Validation with path {:?}", path); Self::new(path) } - pub fn new(schema_path: &Path) -> Result { - let contents = fs::read_to_string(schema_path) - .context(format!("Failed to read schema at {:?}", schema_path))?; + pub fn new>(schema_path: P) -> Result { + let contents = fs::read_to_string(&schema_path).context(format!( + "Failed to read schema at {}", + schema_path.as_ref().to_string_lossy() + ))?; let mut schema: serde_json::Value = serde_json::from_str(&contents)?; // Set $id of the main schema file to allow retrieving subschema files by using relative @@ -221,7 +232,9 @@ impl ProfileEvaluator { .output() .context("Failed to run lshw")?; let helpers = fs::read_to_string("share/agama.libsonnet") - .or_else(|_| fs::read_to_string("/usr/share/agama-cli/agama.libsonnet")) + .or_else(|_| { + fs::read_to_string(PathBuf::from(DEFAULT_JSONNET_DIR).join("agama.libsonnet")) + }) .context("Failed to read agama.libsonnet")?; let mut file = fs::File::create(path)?; file.write_all(b"{\n")?; diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index 7374bb057e..89e2435382 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -27,11 +27,6 @@ pub use service_status::ServiceStatusProxy; mod manager1; pub use manager1::Manager1Proxy; -pub mod questions; - -mod issues; -pub use issues::IssuesProxy; - mod locale; pub use locale::LocaleMixinProxy; diff --git a/rust/agama-lib/src/proxies/issues.rs b/rust/agama-lib/src/proxies/issues.rs deleted file mode 100644 index 87505aeb62..0000000000 --- a/rust/agama-lib/src/proxies/issues.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Issues` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama1.Progress.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::IntrospectableProxy`] -//! * [`zbus::fdo::PropertiesProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy(interface = "org.opensuse.Agama1.Issues", assume_defaults = true)] -pub trait Issues { - /// All property - #[zbus(property)] - fn all(&self) -> zbus::Result>; -} diff --git a/rust/agama-lib/src/proxies/questions.rs b/rust/agama-lib/src/proxies/questions.rs deleted file mode 100644 index 97d74f7517..0000000000 --- a/rust/agama-lib/src/proxies/questions.rs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -mod base; -pub use base::QuestionsProxy; - -mod generic; -pub use generic::GenericProxy as GenericQuestionProxy; - -mod with_password; -pub use with_password::WithPasswordProxy as QuestionWithPasswordProxy; diff --git a/rust/agama-lib/src/proxies/questions/base.rs b/rust/agama-lib/src/proxies/questions/base.rs deleted file mode 100644 index abb97ffbd9..0000000000 --- a/rust/agama-lib/src/proxies/questions/base.rs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Questions` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama1.Questions.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::IntrospectableProxy`] -//! * [`zbus::fdo::ObjectManagerProxy`] -//! * [`zbus::fdo::PeerProxy`] -//! * [`zbus::fdo::PropertiesProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama1", - interface = "org.opensuse.Agama1.Questions", - default_path = "/org/opensuse/Agama1/Questions", - assume_defaults = true -)] -pub trait Questions { - /// AddAnswerFile method - fn add_answer_file(&self, path: &str) -> zbus::Result<()>; - - /// Remove Answers method - fn remove_answers(&self) -> zbus::Result<()>; - - /// Delete method - fn delete(&self, question: &zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; - - /// New method - #[zbus(name = "New")] - fn new_question( - &self, - class: &str, - text: &str, - options: &[&str], - default_option: &str, - data: std::collections::HashMap<&str, &str>, - ) -> zbus::Result; - - /// NewWithPassword method - fn new_with_password( - &self, - class: &str, - text: &str, - options: &[&str], - default_option: &str, - data: std::collections::HashMap<&str, &str>, - ) -> zbus::Result; - - /// Interactive property - #[zbus(property)] - fn interactive(&self) -> zbus::Result; - #[zbus(property)] - fn set_interactive(&self, value: bool) -> zbus::Result<()>; -} diff --git a/rust/agama-lib/src/proxies/questions/generic.rs b/rust/agama-lib/src/proxies/questions/generic.rs deleted file mode 100644 index 69f8bddbbe..0000000000 --- a/rust/agama-lib/src/proxies/questions/generic.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Questions.Generic` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama1.Questions.WithPassword.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::IntrospectableProxy`] -//! * [`zbus::fdo::PeerProxy`] -//! * [`zbus::fdo::PropertiesProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama1", - interface = "org.opensuse.Agama1.Questions.Generic", - assume_defaults = true -)] -pub trait Generic { - /// Answer property - #[zbus(property)] - fn answer(&self) -> zbus::Result; - #[zbus(property)] - fn set_answer(&self, value: &str) -> zbus::Result<()>; - - /// Class property - #[zbus(property)] - fn class(&self) -> zbus::Result; - - /// Data property - #[zbus(property)] - fn data(&self) -> zbus::Result>; - - /// DefaultOption property - #[zbus(property)] - fn default_option(&self) -> zbus::Result; - - /// Id property - #[zbus(property)] - fn id(&self) -> zbus::Result; - - /// Options property - #[zbus(property)] - fn options(&self) -> zbus::Result>; - - /// Text property - #[zbus(property)] - fn text(&self) -> zbus::Result; -} diff --git a/rust/agama-lib/src/proxies/questions/with_password.rs b/rust/agama-lib/src/proxies/questions/with_password.rs deleted file mode 100644 index 5bb2af7082..0000000000 --- a/rust/agama-lib/src/proxies/questions/with_password.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Questions.WithPassword` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama1.Questions.WithPassword.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::IntrospectableProxy`] -//! * [`zbus::fdo::PeerProxy`] -//! * [`zbus::fdo::PropertiesProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama1", - interface = "org.opensuse.Agama1.Questions.WithPassword", - assume_defaults = true -)] -pub trait WithPassword { - /// Password property - #[zbus(property)] - fn password(&self) -> zbus::Result; - #[zbus(property)] - fn set_password(&self, value: &str) -> zbus::Result<()>; -} diff --git a/rust/agama-lib/src/questions.rs b/rust/agama-lib/src/questions.rs index 67fc17f103..04c0712c0e 100644 --- a/rust/agama-lib/src/questions.rs +++ b/rust/agama-lib/src/questions.rs @@ -20,113 +20,4 @@ //! Data model for Agama questions -use std::collections::HashMap; -pub mod answers; -pub mod config; -pub mod error; pub mod http_client; -pub mod model; -pub mod store; -pub use error::QuestionsError; - -/// Basic generic question that fits question without special needs -/// -/// structs living directly under questions namespace is for D-Bus usage and holds complete questions data -/// for user side data model see questions::model -#[derive(Clone, Debug)] -pub struct GenericQuestion { - /// numeric id used to identify question on D-Bus - pub id: u32, - /// class of questions. Similar kinds of questions share same class. - /// It is dot separated list of elements. Examples are - /// `storage.luks.actication` or `software.repositories.unknown_gpg` - pub class: String, - /// Textual representation of question. Expected to be read by people - pub text: String, - /// possible answers for question - pub options: Vec, - /// default answer. Can be used as hint or preselection and it is used as answer for unattended questions. - pub default_option: String, - /// additional data to help identify questions. Useful for automatic answers. It is question specific. - pub data: HashMap, - /// Confirmed answer. If empty then not answered yet. - pub answer: String, -} - -impl GenericQuestion { - pub fn new( - id: u32, - class: String, - text: String, - options: Vec, - default_option: String, - data: HashMap, - ) -> Self { - Self { - id, - class, - text, - options, - default_option, - data, - answer: String::from(""), - } - } - - /// Gets object path of given question. It is useful as parameter - /// for deleting it. - /// - /// # Examples - /// - /// ``` - /// use std::collections::HashMap; - /// use agama_lib::questions::GenericQuestion; - /// let question = GenericQuestion::new( - /// 2, - /// "test_class".to_string(), - /// "Really?".to_string(), - /// vec!["Yes".to_string(), "No".to_string()], - /// "No".to_string(), - /// HashMap::new() - /// ); - /// assert_eq!(question.object_path(), "/org/opensuse/Agama1/Questions/2".to_string()); - /// ``` - pub fn object_path(&self) -> String { - format!("/org/opensuse/Agama1/Questions/{}", self.id) - } -} - -/// Composition for questions which include password. -/// -/// ## Extension -/// If there is need to provide more mixins, then this structure does not work -/// well as it is hard do various combinations. Idea is when need for more -/// mixins arise to convert it to Question Struct that have optional mixins -/// inside like -/// -/// ```no_compile -/// struct Question { -/// base: GenericQuestion, -/// with_password: Option, -/// another_mixin: Option -/// } -/// ``` -/// -/// This way all handling code can check if given mixin is used and -/// act appropriate. -#[derive(Clone, Debug)] -pub struct WithPassword { - /// Luks password. Empty means no password set. - pub password: String, - /// rest of question data that is same as for other questions - pub base: GenericQuestion, -} - -impl WithPassword { - pub fn new(base: GenericQuestion) -> Self { - Self { - password: "".to_string(), - base, - } - } -} diff --git a/rust/agama-lib/src/questions/answers/custom.rs b/rust/agama-lib/src/questions/answers/custom.rs deleted file mode 100644 index e335e43ab8..0000000000 --- a/rust/agama-lib/src/questions/answers/custom.rs +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::collections::HashMap; - -use crate::questions::{GenericQuestion, QuestionsError}; -use serde::{Deserialize, Serialize}; - -use super::AnswerStrategy; - -/// Data structure for single JSON answer. For variables specification see -/// corresponding [agama_lib::questions::GenericQuestion] fields. -/// The *matcher* part is: `class`, `text`, `data`. -/// The *answer* part is: `answer`, `password`. -#[derive(Clone, Serialize, Deserialize, PartialEq, Debug, utoipa::ToSchema)] -pub struct Answer { - #[serde(skip_serializing_if = "Option::is_none")] - pub class: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, - /// A matching GenericQuestion can have other data fields too - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option>, - /// The answer text is the only mandatory part of an Answer - pub answer: String, - /// All possible mixins have to be here, so they can be specified in an Answer - #[serde(skip_serializing_if = "Option::is_none")] - pub password: Option, -} - -impl Answer { - /// Determines whether the answer responds to the given question. - /// - /// * `question`: question to compare with. - pub fn responds(&self, question: &GenericQuestion) -> bool { - if let Some(class) = &self.class { - if question.class != *class { - return false; - } - } - - if let Some(text) = &self.text { - if question.text != *text { - return false; - } - } - - if let Some(data) = &self.data { - return data.iter().all(|(key, value)| { - let Some(e_val) = question.data.get(key) else { - return false; - }; - - e_val == value - }); - } - - true - } -} - -/// Data structure holding list of Answer. -/// The first matching Answer is used, even if there is -/// a better (more specific) match later in the list. -#[derive(Serialize, Deserialize, PartialEq, Debug)] -pub struct Answers { - answers: Vec, -} - -impl Answers { - pub fn new(answers: Vec) -> Self { - Self { answers } - } - pub fn new_from_file(path: &str) -> Result { - let f = std::fs::File::open(path).map_err(QuestionsError::IO)?; - let result: Self = serde_json::from_reader(f).map_err(QuestionsError::Deserialize)?; - - Ok(result) - } - - pub fn id() -> u8 { - 2 - } - - fn find_answer(&self, question: &GenericQuestion) -> Option<&Answer> { - self.answers.iter().find(|a| a.responds(question)) - } -} - -impl AnswerStrategy for Answers { - fn id(&self) -> u8 { - Answers::id() - } - - fn answer(&self, question: &GenericQuestion) -> Option { - let answer = self.find_answer(question); - answer.map(|answer| answer.answer.clone()) - } - - fn answer_with_password( - &self, - question: &crate::questions::WithPassword, - ) -> (Option, Option) { - // use here fact that with password share same matchers as generic one - let answer = self.find_answer(&question.base); - if let Some(answer) = answer { - (Some(answer.answer.clone()), answer.password.clone()) - } else { - (None, None) - } - } -} - -#[cfg(test)] -mod tests { - use crate::questions::{answers::AnswerStrategy, GenericQuestion, WithPassword}; - - use super::*; - - // set of fixtures for test - fn get_answers() -> Answers { - Answers { - answers: vec![ - Answer { - class: Some("without_data".to_string()), - data: None, - text: None, - answer: "Ok".to_string(), - password: Some("testing pwd".to_string()), // ignored for generic question - }, - Answer { - class: Some("with_data".to_string()), - data: Some(HashMap::from([ - ("data1".to_string(), "value1".to_string()), - ("data2".to_string(), "value2".to_string()), - ])), - text: None, - answer: "Maybe".to_string(), - password: None, - }, - Answer { - class: Some("with_data".to_string()), - data: Some(HashMap::from([( - "data1".to_string(), - "another_value1".to_string(), - )])), - text: None, - answer: "Ok2".to_string(), - password: None, - }, - ], - } - } - - #[test] - fn test_class_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "without_data".to_string(), - text: "JFYI we will kill all bugs during installation.".to_string(), - options: vec!["Ok".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::new(), - answer: "".to_string(), - }; - assert_eq!(Some("Ok".to_string()), answers.answer(&question)); - } - - #[test] - fn test_no_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "non-existing".to_string(), - text: "Hard question?".to_string(), - options: vec!["Ok".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::new(), - answer: "".to_string(), - }; - assert_eq!(None, answers.answer(&question)); - } - - #[test] - fn test_with_password() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "without_data".to_string(), - text: "Please provide password for dooms day.".to_string(), - options: vec!["Ok".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::new(), - answer: "".to_string(), - }; - let with_password = WithPassword { - password: "".to_string(), - base: question, - }; - let expected = (Some("Ok".to_string()), Some("testing pwd".to_string())); - assert_eq!(expected, answers.answer_with_password(&with_password)); - } - - /// An Answer matches on *data* if all its keys and values are in the GenericQuestion *data*. - /// The GenericQuestion can have other *data* keys. - #[test] - fn test_partial_data_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "with_data".to_string(), - text: "Hard question?".to_string(), - options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::from([ - ("data1".to_string(), "value1".to_string()), - ("data2".to_string(), "value2".to_string()), - ("data3".to_string(), "value3".to_string()), - ]), - answer: "".to_string(), - }; - assert_eq!(Some("Maybe".to_string()), answers.answer(&question)); - } - - #[test] - fn test_full_data_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "with_data".to_string(), - text: "Hard question?".to_string(), - options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::from([ - ("data1".to_string(), "another_value1".to_string()), - ("data2".to_string(), "value2".to_string()), - ("data3".to_string(), "value3".to_string()), - ]), - answer: "".to_string(), - }; - assert_eq!(Some("Ok2".to_string()), answers.answer(&question)); - } - - #[test] - fn test_no_data_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "with_data".to_string(), - text: "Hard question?".to_string(), - options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::from([ - ("data1".to_string(), "different value".to_string()), - ("data2".to_string(), "value2".to_string()), - ("data3".to_string(), "value3".to_string()), - ]), - answer: "".to_string(), - }; - assert_eq!(None, answers.answer(&question)); - } - - // A "universal answer" with unspecified class+text+data is possible - #[test] - fn test_universal_match() { - let answers = Answers { - answers: vec![Answer { - class: None, - text: None, - data: None, - answer: "Yes".into(), - password: None, - }], - }; - let question = GenericQuestion { - id: 1, - class: "without_data".to_string(), - text: "JFYI we will kill all bugs during installation.".to_string(), - options: vec!["Ok".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::new(), - answer: "".to_string(), - }; - assert_eq!(Some("Yes".to_string()), answers.answer(&question)); - } - - #[test] - fn test_loading_json() { - let file = r#" - { - "answers": [ - { - "class": "without_data", - "answer": "OK" - }, - { - "class": "with_data", - "data": { - "testk": "testv", - "testk2": "testv2" - }, - "answer": "Cancel" - }] - } - "#; - let result: Answers = serde_json::from_str(file).expect("failed to load JSON string"); - assert_eq!(result.answers.len(), 2); - } -} diff --git a/rust/agama-lib/src/questions/answers/strategy.rs b/rust/agama-lib/src/questions/answers/strategy.rs deleted file mode 100644 index 3f4ef6b1c7..0000000000 --- a/rust/agama-lib/src/questions/answers/strategy.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::questions::{GenericQuestion, WithPassword}; - -/// Trait for objects that can provide answers to all kind of Question. -/// -/// If no strategy is selected or the answer is unknown, then ask to the user. -pub trait AnswerStrategy { - /// Id for quick runtime inspection of strategy type - fn id(&self) -> u8; - /// Provides answer for generic question - /// - /// I gets as argument the question to answer. Returned value is `answer` - /// property or None. If `None` is used, it means that this object does not - /// answer to given question. - fn answer(&self, question: &GenericQuestion) -> Option; - /// Provides answer and password for base question with password - /// - /// I gets as argument the question to answer. Returned value is pair - /// of `answer` and `password` properties. If `None` is used in any - /// position it means that this object does not respond to given property. - /// - /// It is object responsibility to provide correct pair. For example if - /// possible answer can be "Ok" and "Cancel". Then for `Ok` password value - /// should be provided and for `Cancel` it can be `None`. - fn answer_with_password(&self, question: &WithPassword) -> (Option, Option); -} diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index 0b2991f157..26ce6745e9 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -20,20 +20,26 @@ use std::time::Duration; -use reqwest::StatusCode; +use agama_utils::api::{ + patch::{self, Patch}, + question::{ + Answer, AnswerRule, Config as QuestionsConfig, Policy, Question, QuestionSpec, + UpdateQuestion, + }, + Config, +}; use tokio::time::sleep; use crate::http::{BaseHTTPClient, BaseHTTPClientError}; -use super::{ - config::QuestionsConfig, - model::{self, Answer, Question}, -}; - #[derive(Debug, thiserror::Error)] pub enum QuestionsHTTPClientError { #[error(transparent)] HTTP(#[from] BaseHTTPClientError), + #[error("Unknown question with ID {0}")] + UnknownQuestion(u32), + #[error(transparent)] + Patch(#[from] patch::Error), } pub struct HTTPClient { @@ -45,41 +51,37 @@ impl HTTPClient { Self { client } } - pub async fn list_questions(&self) -> Result, QuestionsHTTPClientError> { - Ok(self.client.get("/questions").await?) + pub async fn get_questions(&self) -> Result, QuestionsHTTPClientError> { + Ok(self.client.get("/v2/questions").await?) } /// Creates question and return newly created question including id pub async fn create_question( &self, - question: &Question, + question: &QuestionSpec, ) -> Result { - Ok(self.client.post("/questions", question).await?) + Ok(self.client.post("/v2/questions", question).await?) } - /// non blocking varient of checking if question has already answer - pub async fn try_answer( + pub async fn get_question( &self, - question_id: u32, - ) -> Result, QuestionsHTTPClientError> { - let path = format!("/questions/{}/answer", question_id); - let result: Result, _> = self.client.get(path.as_str()).await; - - if let Err(BaseHTTPClientError::BackendError(code, ref _body_s)) = result { - if code == StatusCode::NOT_FOUND { - return Ok(None); - } - } - - Ok(result?) + id: u32, + ) -> Result, QuestionsHTTPClientError> { + let questions = self.get_questions().await?; + Ok(questions.into_iter().find(|q| q.id == id)) } /// Blocking variant of getting answer for given question. pub async fn get_answer(&self, question_id: u32) -> Result { loop { - let answer = self.try_answer(question_id).await?; - if let Some(result) = answer { - return Ok(result); + let question = self.get_question(question_id).await?; + match question { + Some(question) => { + if let Some(answer) = question.answer { + return Ok(answer); + } + } + None => return Err(QuestionsHTTPClientError::UnknownQuestion(question_id)), } let duration = Duration::from_secs(1); sleep(duration).await; @@ -89,30 +91,52 @@ impl HTTPClient { } } - pub async fn delete_question(&self, question_id: u32) -> Result<(), QuestionsHTTPClientError> { - let path = format!("/questions/{}", question_id); - Ok(self.client.delete_void(path.as_str()).await?) - } + pub async fn set_mode(&self, policy: Policy) -> Result<(), QuestionsHTTPClientError> { + let questions = QuestionsConfig { + policy: Some(policy), + ..Default::default() + }; + let config = Config { + questions: Some(questions), + ..Default::default() + }; + + let patch = Patch::with_update(&config)?; - pub async fn get_config(&self) -> Result { - Ok(QuestionsConfig::default()) + _ = self.client.patch_void("/v2/config", &patch).await?; + Ok(()) } - pub async fn set_config( + pub async fn set_answers( &self, - config: &QuestionsConfig, + answers: Vec, ) -> Result<(), QuestionsHTTPClientError> { - Ok(self.client.put_void("/questions/config", config).await?) + let questions = QuestionsConfig { + answers: Some(answers), + ..Default::default() + }; + let config = Config { + questions: Some(questions), + ..Default::default() + }; + + let patch = Patch::with_update(&config)?; + self.client.patch_void("/v2/config", &patch).await?; + Ok(()) + } + + pub async fn delete_question(&self, id: u32) -> Result<(), QuestionsHTTPClientError> { + let update = UpdateQuestion::Delete { id }; + self.client.patch_void("/v2/questions", &update).await?; + Ok(()) } } #[cfg(test)] mod test { - use super::model::{GenericAnswer, GenericQuestion}; - use super::*; + use super::HTTPClient; use crate::http::BaseHTTPClient; use httpmock::prelude::*; - use std::collections::HashMap; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" @@ -122,136 +146,40 @@ mod test { } #[test] - async fn test_list_questions() -> Result<(), Box> { + async fn test_get_questions() -> Result<(), Box> { let server = MockServer::start(); let client = questions_client(server.url("/api")); let mock = server.mock(|when, then| { - when.method(GET).path("/api/questions"); + when.method(GET).path("/api/v2/questions"); then.status(200) .header("content-type", "application/json") .body( r#"[ { - "generic": { - "id": 42, - "class": "foo", - "text": "Shape", - "options": ["bouba","kiki"], - "defaultOption": "bouba", - "data": { "a": "A" } - }, - "withPassword":null + "id": 42, + "class": "foo", + "text": "Shape", + "actions": [ + { "id": "next", "label": "Next" }, + { "id": "skip", "label": "Skip" } + ], + "defaultAction": "skip", + "data": { "id": "42" } } ]"#, ); }); - let expected: Vec = vec![Question { - generic: GenericQuestion { - id: Some(42), - class: "foo".to_owned(), - text: "Shape".to_owned(), - options: vec!["bouba".to_owned(), "kiki".to_owned()], - default_option: "bouba".to_owned(), - data: HashMap::from([("a".to_owned(), "A".to_owned())]), - }, - with_password: None, - }]; - let actual = client.list_questions().await?; - assert_eq!(actual, expected); - - mock.assert(); - Ok(()) - } + let questions = client.get_questions().await?; - #[test] - async fn test_create_question() -> Result<(), Box> { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/api/questions") - .header("content-type", "application/json") - .body( - r#"{"generic":{"id":null,"class":"fiction.hamlet","text":"To be or not to be","options":["to be","not to be"],"defaultOption":"to be","data":{"a":"A"}},"withPassword":null}"# - ); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "generic": { - "id": 7, - "class": "fiction.hamlet", - "text": "To be or not to be", - "options": ["to be","not to be"], - "defaultOption": "to be", - "data": { "a": "A" } - }, - "withPassword":null - }"#, - ); - }); - let client = questions_client(server.url("/api")); - - let posted_question = Question { - generic: GenericQuestion { - id: None, - class: "fiction.hamlet".to_owned(), - text: "To be or not to be".to_owned(), - options: vec!["to be".to_owned(), "not to be".to_owned()], - default_option: "to be".to_owned(), - data: HashMap::from([("a".to_owned(), "A".to_owned())]), - }, - with_password: None, - }; - let mut expected_question = posted_question.clone(); - expected_question.generic.id = Some(7); - - let actual = client.create_question(&posted_question).await?; - assert_eq!(actual, expected_question); - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - mock.assert(); - Ok(()) - } - - #[test] - async fn test_try_answer() -> Result<(), Box> { - let server = MockServer::start(); - let client = questions_client(server.url("/api")); - - let mock = server.mock(|when, then| { - when.method(GET).path("/api/questions/42/answer"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "generic": { - "answer": "maybe" - }, - "withPassword":null - }"#, - ); - }); - - let expected = Some(Answer { - generic: GenericAnswer { - answer: "maybe".to_owned(), - }, - with_password: None, - }); - let actual = client.try_answer(42).await?; - assert_eq!(actual, expected); - - let mock2 = server.mock(|when, then| { - when.method(GET).path("/api/questions/666/answer"); - then.status(404); - }); - let actual = client.try_answer(666).await?; - assert_eq!(actual, None); + let question = questions.first().unwrap(); + assert_eq!(question.id, 42); + assert_eq!(question.spec.class, "foo"); + assert_eq!(question.spec.text, "Shape"); + assert_eq!(question.spec.default_action, Some("skip".to_string())); mock.assert(); - mock2.assert(); Ok(()) } } diff --git a/rust/agama-lib/src/questions/model.rs b/rust/agama-lib/src/questions/model.rs deleted file mode 100644 index a36da4d0f1..0000000000 --- a/rust/agama-lib/src/questions/model.rs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Question { - pub generic: GenericQuestion, - pub with_password: Option, -} - -/// Facade of agama_lib::questions::GenericQuestion -/// For fields details see it. -/// Reason why it does not use directly GenericQuestion from lib -/// is that it contain both question and answer. It works for dbus -/// API which has both as attributes, but web API separate -/// question and its answer. So here it is split into GenericQuestion -/// and GenericAnswer -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct GenericQuestion { - /// id is optional as newly created questions does not have it assigned - pub id: Option, - pub class: String, - pub text: String, - pub options: Vec, - pub default_option: String, - pub data: HashMap, -} - -/// Facade of agama_lib::questions::WithPassword -/// For fields details see it. -/// Reason why it does not use directly WithPassword from lib -/// is that it is not composition as used here, but more like -/// child of generic question and contain reference to Base. -/// Here for web API we want to have in json that separation that would -/// allow to compose any possible future specialization of question. -/// Also note that question is empty as QuestionWithPassword does not -/// provide more details for question, but require additional answer. -/// Can be potentionally extended in future e.g. with list of allowed characters? -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct QuestionWithPassword {} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Answer { - pub generic: GenericAnswer, - pub with_password: Option, -} - -/// Answer needed for GenericQuestion -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct GenericAnswer { - pub answer: String, -} - -/// Answer needed for Password specific questions. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PasswordAnswer { - pub password: String, -} diff --git a/rust/agama-lib/src/questions/store.rs b/rust/agama-lib/src/questions/store.rs deleted file mode 100644 index 8b9c726ce8..0000000000 --- a/rust/agama-lib/src/questions/store.rs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::http::BaseHTTPClient; - -use super::{ - config::QuestionsConfig, - http_client::{HTTPClient as QuestionsHTTPClient, QuestionsHTTPClientError}, -}; - -#[derive(Debug, thiserror::Error)] -#[error("Error processing questions settings: {0}")] -pub struct QuestionsStoreError(#[from] QuestionsHTTPClientError); - -type QuestionsStoreResult = Result; - -/// Loads and stores the questions settings from/to the HTTP API. -pub struct QuestionsStore { - questions_client: QuestionsHTTPClient, -} - -impl QuestionsStore { - pub fn new(client: BaseHTTPClient) -> Self { - Self { - questions_client: QuestionsHTTPClient::new(client), - } - } - - pub async fn load(&self) -> QuestionsStoreResult> { - Ok(None) - } - - pub async fn store(&self, config: &QuestionsConfig) -> QuestionsStoreResult<()> { - Ok(self.questions_client.set_config(config).await?) - } -} diff --git a/rust/agama-lib/src/scripts/client.rs b/rust/agama-lib/src/scripts/client.rs deleted file mode 100644 index fa4afdd271..0000000000 --- a/rust/agama-lib/src/scripts/client.rs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::http::{BaseHTTPClient, BaseHTTPClientError}; - -use super::{Script, ScriptsGroup}; - -#[derive(Debug, thiserror::Error)] -pub enum ScriptsClientError { - #[error(transparent)] - HTTP(#[from] BaseHTTPClientError), -} - -/// HTTP client to interact with scripts. -pub struct ScriptsClient { - client: BaseHTTPClient, -} - -impl ScriptsClient { - pub fn new(base: BaseHTTPClient) -> Self { - Self { client: base } - } - - /// Adds a script to the given group. - /// - /// * `script`: script's definition. - pub async fn add_script(&self, script: Script) -> Result<(), ScriptsClientError> { - Ok(self.client.post_void("/scripts", &script).await?) - } - - /// Runs user-defined scripts of the given group. - /// - /// * `group`: group of the scripts to run - pub async fn run_scripts(&self, group: ScriptsGroup) -> Result<(), ScriptsClientError> { - Ok(self.client.post_void("/scripts/run", &group).await?) - } - - /// Returns the user-defined scripts. - pub async fn scripts(&self) -> Result, ScriptsClientError> { - Ok(self.client.get("/scripts").await?) - } - - /// Remove all the user-defined scripts. - pub async fn delete_scripts(&self) -> Result<(), ScriptsClientError> { - Ok(self.client.delete_void("/scripts").await?) - } -} diff --git a/rust/agama-lib/src/scripts/settings.rs b/rust/agama-lib/src/scripts/settings.rs deleted file mode 100644 index dbb32d0ed7..0000000000 --- a/rust/agama-lib/src/scripts/settings.rs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use fluent_uri::Uri; -use serde::{Deserialize, Serialize}; - -use crate::file_source::{FileSourceError, WithFileSource}; - -use super::{InitScript, PostPartitioningScript, PostScript, PreScript}; - -#[derive(Debug, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScriptsConfig { - /// User-defined pre-installation scripts - #[serde(skip_serializing_if = "Option::is_none")] - pub pre: Option>, - /// User-defined post-partitioning scripts - #[serde(skip_serializing_if = "Option::is_none")] - pub post_partitioning: Option>, - /// User-defined post-installation scripts - #[serde(skip_serializing_if = "Option::is_none")] - pub post: Option>, - /// User-defined init scripts - #[serde(skip_serializing_if = "Option::is_none")] - pub init: Option>, -} - -impl ScriptsConfig { - pub fn to_option(self) -> Option { - if self.pre.is_none() - && self.post_partitioning.is_none() - && self.post.is_none() - && self.init.is_none() - { - None - } else { - Some(self) - } - } - - /// Resolve relative URLs in the scripts. - /// - /// * `base_uri`: The base URI to resolve relative URLs against. - pub fn resolve_urls(&mut self, base_uri: &Uri) -> Result<(), FileSourceError> { - Self::resolve_urls_for(&mut self.pre, base_uri)?; - Self::resolve_urls_for(&mut self.post_partitioning, base_uri)?; - Self::resolve_urls_for(&mut self.post, base_uri)?; - Self::resolve_urls_for(&mut self.init, base_uri)?; - Ok(()) - } - - fn resolve_urls_for( - scripts: &mut Option>, - base_uri: &Uri, - ) -> Result<(), FileSourceError> { - if let Some(ref mut scripts) = scripts { - for script in scripts { - script.resolve_url(&base_uri)?; - } - } - Ok(()) - } -} diff --git a/rust/agama-lib/src/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs deleted file mode 100644 index 5ab57ebff4..0000000000 --- a/rust/agama-lib/src/scripts/store.rs +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::{ - file_source::FileSourceError, - http::BaseHTTPClient, - software::{model::ResolvableType, SoftwareHTTPClient, SoftwareHTTPClientError}, -}; - -use super::{ - client::{ScriptsClient, ScriptsClientError}, - settings::ScriptsConfig, - Script, ScriptError, -}; - -#[derive(Debug, thiserror::Error)] -pub enum ScriptsStoreError { - #[error("Error processing script settings: {0}")] - Script(#[from] ScriptsClientError), - #[error("Error selecting software: {0}")] - Software(#[from] SoftwareHTTPClientError), - #[error(transparent)] - FileSourceError(#[from] FileSourceError), -} - -type ScriptStoreResult = Result; - -pub struct ScriptsStore { - scripts: ScriptsClient, - software: SoftwareHTTPClient, -} - -impl ScriptsStore { - pub fn new(client: BaseHTTPClient) -> Self { - Self { - scripts: ScriptsClient::new(client.clone()), - software: SoftwareHTTPClient::new(client), - } - } - - pub async fn load(&self) -> ScriptStoreResult { - let scripts = self.scripts.scripts().await?; - - Ok(ScriptsConfig { - pre: Self::scripts_by_type(&scripts), - post_partitioning: Self::scripts_by_type(&scripts), - post: Self::scripts_by_type(&scripts), - init: Self::scripts_by_type(&scripts), - }) - } - - pub async fn store(&self, settings: &ScriptsConfig) -> ScriptStoreResult<()> { - self.scripts.delete_scripts().await?; - - if let Some(scripts) = &settings.pre { - for pre in scripts { - self.scripts.add_script(pre.clone().into()).await?; - } - } - - if let Some(scripts) = &settings.post_partitioning { - for post in scripts { - self.scripts.add_script(post.clone().into()).await?; - } - } - - if let Some(scripts) = &settings.post { - for post in scripts { - self.scripts.add_script(post.clone().into()).await?; - } - } - - let mut packages = vec![]; - if let Some(scripts) = &settings.init { - for init in scripts { - self.scripts.add_script(init.clone().into()).await?; - } - packages.push("agama-scripts"); - } - self.software - .set_resolvables("agama-scripts", ResolvableType::Package, &packages, true) - .await?; - - Ok(()) - } - - fn scripts_by_type(scripts: &[Script]) -> Option> - where - T: TryFrom + Clone, - { - let scripts: Vec = scripts - .iter() - .cloned() - .filter_map(|s| s.try_into().ok()) - .collect(); - if scripts.is_empty() { - return None; - } - Some(scripts) - } -} diff --git a/rust/agama-lib/src/security/settings.rs b/rust/agama-lib/src/security/settings.rs index c67209c6b0..8c18cf3db9 100644 --- a/rust/agama-lib/src/security/settings.rs +++ b/rust/agama-lib/src/security/settings.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; use super::model::SSLFingerprint; /// Security settings for installation -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct SecuritySettings { /// List of user selected patterns to install. diff --git a/rust/agama-lib/src/software.rs b/rust/agama-lib/src/software.rs deleted file mode 100644 index 2a49d57ef7..0000000000 --- a/rust/agama-lib/src/software.rs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements support for handling the software settings - -mod client; -mod http_client; -pub mod model; -pub mod proxies; -mod settings; -mod store; - -pub use client::{Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy}; -pub use http_client::{SoftwareHTTPClient, SoftwareHTTPClientError}; -pub use settings::SoftwareSettings; -pub use store::{SoftwareStore, SoftwareStoreError}; diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs deleted file mode 100644 index 42d968b992..0000000000 --- a/rust/agama-lib/src/software/client.rs +++ /dev/null @@ -1,362 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use super::{ - model::{Conflict, ConflictSolve, Repository, RepositoryParams, ResolvableType}, - proxies::{ProposalProxy, Software1Proxy}, -}; -use crate::error::ServiceError; -use agama_utils::dbus::{get_optional_property, get_property}; -use serde::Serialize; -use serde_repr::{Deserialize_repr, Serialize_repr}; -use std::collections::HashMap; -use zbus::Connection; - -const USER_RESOLVABLES_LIST: &str = "user"; - -// TODO: move it to model? -/// Represents a software product -#[derive(Debug, Serialize, utoipa::ToSchema)] -pub struct Pattern { - /// Pattern name (eg., "aaa_base", "gnome") - pub name: String, - /// Pattern category (e.g., "Production") - pub category: String, - /// Pattern icon path locally on system - pub icon: String, - /// Pattern description - pub description: String, - /// Pattern summary - pub summary: String, - /// Pattern order - pub order: String, -} - -/// Represents the reason why a pattern is selected. -#[derive(Clone, Copy, Debug, PartialEq, Deserialize_repr, Serialize_repr, utoipa::ToSchema)] -#[repr(u8)] -pub enum SelectedBy { - /// The pattern was selected by the user. - User = 0, - /// The pattern was selected automatically. - Auto = 1, - /// The pattern has not be selected. - None = 2, -} - -#[derive(Debug, thiserror::Error)] -#[error("Unknown selected by value: '{0}'")] -pub struct UnknownSelectedBy(u8); - -impl TryFrom for SelectedBy { - type Error = UnknownSelectedBy; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(Self::User), - 1 => Ok(Self::Auto), - _ => Err(UnknownSelectedBy(value)), - } - } -} - -/// D-Bus client for the software service -#[derive(Clone)] -pub struct SoftwareClient<'a> { - software_proxy: Software1Proxy<'a>, - proposal_proxy: ProposalProxy<'a>, -} - -impl<'a> SoftwareClient<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { - Ok(Self { - software_proxy: Software1Proxy::new(&connection).await?, - proposal_proxy: ProposalProxy::new(&connection).await?, - }) - } - - /// Returns list of defined repositories - pub async fn repositories(&self) -> Result, ServiceError> { - let repositories: Vec = self - .software_proxy - .list_repositories() - .await? - .into_iter() - .map( - |(id, alias, name, url, product_dir, enabled, loaded)| Repository { - id, - alias, - name, - url, - product_dir, - enabled, - loaded, - }, - ) - .collect(); - Ok(repositories) - } - - /// Returns list of user defined repositories - pub async fn user_repositories(&self) -> Result, ServiceError> { - self.software_proxy - .list_user_repositories() - .await? - .into_iter() - .map(|params| - // unwrapping below is OK as it is our own dbus API, so we know what is in variants - Ok(RepositoryParams { - priority: get_optional_property(¶ms, "priority")?, - alias: get_property(¶ms, "alias")?, - name: get_optional_property(¶ms, "name")?, - url: get_property(¶ms, "url")?, - product_dir: get_optional_property(¶ms, "product_dir")?, - enabled: get_optional_property(¶ms, "enabled")?, - allow_unsigned: get_optional_property(¶ms, "allow_unsigned")?, - gpg_fingerprints: get_optional_property(¶ms, "gpg_fingerprints")?, - })) - .collect() - } - - pub async fn set_user_repositories( - &self, - repos: Vec, - ) -> Result<(), ServiceError> { - let dbus_repos: Vec>> = repos - .into_iter() - .map(|params| { - let mut result: HashMap<&str, zbus::zvariant::Value<'_>> = HashMap::new(); - result.insert("alias", params.alias.into()); - result.insert("url", params.url.into()); - if let Some(priority) = params.priority { - result.insert("priority", priority.into()); - } - if let Some(name) = params.name { - result.insert("name", name.into()); - } - if let Some(product_dir) = params.product_dir { - result.insert("product_dir", product_dir.into()); - } - if let Some(enabled) = params.enabled { - result.insert("enabled", enabled.into()); - } - if let Some(allow_unsigned) = params.allow_unsigned { - result.insert("allow_unsigned", allow_unsigned.into()); - } - if let Some(gpg_fingerprints) = params.gpg_fingerprints { - result.insert("gpg_fingerprints", gpg_fingerprints.into()); - } - result - }) - .collect(); - self.software_proxy - .set_user_repositories(&dbus_repos) - .await?; - Ok(()) - } - - /// Returns the available patterns - pub async fn patterns(&self, filtered: bool) -> Result, ServiceError> { - let patterns: Vec = self - .software_proxy - .list_patterns(filtered) - .await? - .into_iter() - .map( - |(name, (category, description, icon, summary, order))| Pattern { - name, - category, - icon, - description, - summary, - order, - }, - ) - .collect(); - Ok(patterns) - } - - /// Returns the ids of patterns selected by user - pub async fn user_selected_patterns(&self) -> Result, ServiceError> { - let patterns: Vec = self - .software_proxy - .selected_patterns() - .await? - .into_iter() - .filter_map(|(id, reason)| match SelectedBy::try_from(reason) { - Ok(SelectedBy::User) => Some(id), - Ok(_reason) => None, - Err(e) => { - log::warn!("Ignoring pattern {}. Error: {}", &id, e); - None - } - }) - .collect(); - Ok(patterns) - } - - /// Returns the selected pattern and the reason each one selected. - pub async fn selected_patterns(&self) -> Result, ServiceError> { - let patterns = self.software_proxy.selected_patterns().await?; - let patterns = patterns - .into_iter() - .filter_map(|(id, reason)| match SelectedBy::try_from(reason) { - Ok(reason) => Some((id, reason)), - Err(e) => { - log::warn!("Ignoring pattern {}. Error: {}", &id, e); - None - } - }) - .collect(); - Ok(patterns) - } - - /// returns current list of conflicts - pub async fn get_conflicts(&self) -> Result, ServiceError> { - let conflicts = self.software_proxy.conflicts().await?; - let conflicts = conflicts - .into_iter() - .map(|c| Conflict::from_dbus(c)) - .collect(); - - Ok(conflicts) - } - - /// Sets solutions ( not necessary for all conflicts ) and recompute conflicts - pub async fn solve_conflicts(&self, solutions: Vec) -> Result<(), ServiceError> { - let solutions: Vec<(u32, u32)> = solutions.into_iter().map(|s| s.into()).collect(); - - Ok(self.software_proxy.solve_conflicts(&solutions).await?) - } - - /// Selects patterns by user - pub async fn select_patterns( - &self, - patterns: HashMap, - ) -> Result<(), ServiceError> { - let (add, remove): (Vec<_>, Vec<_>) = - patterns.into_iter().partition(|(_, install)| *install); - - let add: Vec<_> = add.iter().map(|(name, _)| name.as_ref()).collect(); - let remove: Vec<_> = remove.iter().map(|(name, _)| name.as_ref()).collect(); - - let wrong_patterns = self - .software_proxy - .set_user_patterns(add.as_slice(), remove.as_slice()) - .await?; - if !wrong_patterns.is_empty() { - Err(ServiceError::UnknownPatterns(wrong_patterns)) - } else { - Ok(()) - } - } - - /// Selects packages by user - /// - /// Adds the given packages to the proposal. - /// - /// * `names`: package names. - pub async fn select_packages(&self, names: Vec) -> Result<(), ServiceError> { - let names: Vec<_> = names.iter().map(|n| n.as_ref()).collect(); - self.set_resolvables( - USER_RESOLVABLES_LIST, - ResolvableType::Package, - names.as_slice(), - true, - ) - .await?; - Ok(()) - } - - pub async fn user_selected_packages(&self) -> Result, ServiceError> { - self.get_resolvables(USER_RESOLVABLES_LIST, ResolvableType::Package, true) - .await - } - - /// Returns the required space for installing the selected patterns. - /// - /// It returns a formatted string including the size and the unit. - pub async fn used_disk_space(&self) -> Result { - Ok(self.software_proxy.used_disk_space().await?) - } - - /// Starts the process to read the repositories data. - pub async fn probe(&self) -> Result<(), ServiceError> { - Ok(self.software_proxy.probe().await?) - } - - /// Updates the resolvables list. - /// - /// * `id`: resolvable list ID. - /// * `r#type`: type of the resolvables. - /// * `resolvables`: resolvables to add. - /// * `optional`: whether the resolvables are optional. - pub async fn set_resolvables( - &self, - id: &str, - r#type: ResolvableType, - resolvables: &[&str], - optional: bool, - ) -> Result<(), ServiceError> { - self.proposal_proxy - .set_resolvables(id, r#type as u8, resolvables, optional) - .await?; - Ok(()) - } - - /// Gets a resolvables list. - /// - /// * `id`: resolvable list ID. - /// * `r#type`: type of the resolvables. - /// * `optional`: whether the resolvables are optional. - pub async fn get_resolvables( - &self, - id: &str, - r#type: ResolvableType, - optional: bool, - ) -> Result, ServiceError> { - let packages = self - .proposal_proxy - .get_resolvables(id, r#type as u8, optional) - .await?; - Ok(packages) - } - - /// Sets onlyRequired flag for proposal. - /// - /// * `value`: if flag is enabled or not. - pub async fn set_only_required(&self, value: bool) -> Result<(), ServiceError> { - let dbus_value = if value { 2 } else { 1 }; - self.software_proxy.set_only_required(dbus_value).await?; - Ok(()) - } - - /// Gets onlyRequired flag for proposal. - pub async fn get_only_required(&self) -> Result, ServiceError> { - let dbus_value = self.software_proxy.only_required().await?; - let res = match dbus_value { - 0 => None, - 1 => Some(false), - 2 => Some(true), - _ => None, // should not happen - }; - Ok(res) - } -} diff --git a/rust/agama-lib/src/software/http_client.rs b/rust/agama-lib/src/software/http_client.rs deleted file mode 100644 index 7b919fab1c..0000000000 --- a/rust/agama-lib/src/software/http_client.rs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::http::{BaseHTTPClient, BaseHTTPClientError}; -use crate::software::model::SoftwareConfig; -use std::collections::HashMap; - -use super::model::{ResolvableParams, ResolvableType}; - -#[derive(Debug, thiserror::Error)] -pub enum SoftwareHTTPClientError { - #[error(transparent)] - HTTP(#[from] BaseHTTPClientError), - #[error("Registration failed: {0}")] - FailedRegistration(String), -} - -pub struct SoftwareHTTPClient { - client: BaseHTTPClient, -} - -impl SoftwareHTTPClient { - pub fn new(base: BaseHTTPClient) -> Self { - Self { client: base } - } - - pub async fn get_config(&self) -> Result { - Ok(self.client.get("/software/config").await?) - } - - pub async fn set_config(&self, config: &SoftwareConfig) -> Result<(), SoftwareHTTPClientError> { - // FIXME: test how errors come out: - // unknown pattern name, - // D-Bus client returns - // Err(SoftwareHTTPClientError::UnknownPatterns(wrong_patterns)) - // CLI prints: - // Anyhow(Backend call failed with status 400 and text '{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}') - Ok(self.client.put_void("/software/config", config).await?) - } - - /// Returns the ids of patterns selected by user - pub async fn user_selected_patterns(&self) -> Result, SoftwareHTTPClientError> { - // TODO: this way we unnecessarily ask D-Bus (via web.rs) also for the product and then ignore it - let config = self.get_config().await?; - - let Some(patterns_map) = config.patterns else { - return Ok(vec![]); - }; - - let patterns: Vec = patterns_map - .into_iter() - .filter_map(|(name, is_selected)| if is_selected { Some(name) } else { None }) - .collect(); - - Ok(patterns) - } - - /// Selects patterns by user - pub async fn select_patterns( - &self, - patterns: HashMap, - ) -> Result<(), SoftwareHTTPClientError> { - let config = SoftwareConfig { - product: None, - // TODO: SoftwareStore only passes true bools, false branch is untested - patterns: Some(patterns), - packages: None, - extra_repositories: None, - only_required: None, - }; - self.set_config(&config).await - } - - /// Sets a resolvable list - pub async fn set_resolvables( - &self, - name: &str, - r#type: ResolvableType, - names: &[&str], - optional: bool, - ) -> Result<(), SoftwareHTTPClientError> { - let path = format!("/software/resolvables/{}", name); - let options = ResolvableParams { - names: names.iter().map(|n| n.to_string()).collect(), - r#type, - optional, - }; - self.client.put_void(&path, &options).await?; - Ok(()) - } -} diff --git a/rust/agama-lib/src/software/model/packages.rs b/rust/agama-lib/src/software/model/packages.rs deleted file mode 100644 index f18cd108fb..0000000000 --- a/rust/agama-lib/src/software/model/packages.rs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Software service configuration (product, patterns, etc.). -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SoftwareConfig { - /// A map where the keys are the pattern names and the values whether to install them or not. - pub patterns: Option>, - /// Packages to install. - pub packages: Option>, - /// Name of the product to install. - pub product: Option, - /// Extra repositories defined by user. - pub extra_repositories: Option>, - /// Flag if solver should use only hard dependencies. - pub only_required: Option, -} - -/// Software resolvable type (package or pattern). -#[derive(Deserialize, Serialize, strum::Display, utoipa::ToSchema)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum ResolvableType { - Package = 0, - Pattern = 1, -} - -/// Resolvable list specification. -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -pub struct ResolvableParams { - /// List of resolvables. - pub names: Vec, - /// Resolvable type. - pub r#type: ResolvableType, - /// Whether the resolvables are optional or not. - pub optional: bool, -} - -/// Repository specification. -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Repository { - /// repository identifier - pub id: i32, - /// repository alias. Has to be unique - pub alias: String, - /// repository name - pub name: String, - /// Repository url (raw format without expanded variables) - pub url: String, - /// product directory (currently not used, valid only for multiproduct DVDs) - pub product_dir: String, - /// Whether the repository is enabled - pub enabled: bool, - /// Whether the repository is loaded - pub loaded: bool, -} - -/// Parameters for creating new a repository -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RepositoryParams { - /// repository alias. Has to be unique - pub alias: String, - /// repository name, if not specified the alias is used - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Repository url (raw format without expanded variables) - pub url: String, - /// product directory (currently not used, valid only for multiproduct DVDs) - #[serde(skip_serializing_if = "Option::is_none")] - pub product_dir: Option, - /// Whether the repository is enabled, if missing the repository is enabled - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled: Option, - /// Repository priority, lower number means higher priority, the default priority is 99 - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - /// Whenever repository can be unsigned. Default is false - #[serde(skip_serializing_if = "Option::is_none")] - pub allow_unsigned: Option, - /// List of fingerprints for GPG keys used for repository signing. By default empty - #[serde(skip_serializing_if = "Option::is_none")] - pub gpg_fingerprints: Option>, -} diff --git a/rust/agama-lib/src/software/model/registration.rs b/rust/agama-lib/src/software/model/registration.rs deleted file mode 100644 index 29b05b62dd..0000000000 --- a/rust/agama-lib/src/software/model/registration.rs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; - -/// Software service configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct RegistrationParams { - /// Registration key. - pub key: String, - /// Registration email. - pub email: String, -} - -/// Addon registration -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AddonParams { - // Addon identifier - pub id: String, - // Addon version, if not specified the version is found from the available addons - pub version: Option, - // Optional registration code, not required for free extensions - pub registration_code: Option, -} - -/// Addon registration -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AddonProperties { - /// Addon identifier - pub id: String, - /// Version of the addon - pub version: String, - /// User visible name - pub label: String, - /// Whether the addon is mirrored on the RMT server, on SCC it is always `true` - pub available: bool, - /// Whether a registration code is required for registering the addon - pub free: bool, - /// Whether the addon is recommended for the users - pub recommended: bool, - /// Short description of the addon (translated) - pub description: String, - /// Type of the addon, like "extension" or "module" - pub r#type: String, - /// Release status of the addon, e.g. "beta" - pub release: String, -} - -/// Information about registration configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RegistrationInfo { - /// Registration status. True if base system is already registered. - pub registered: bool, - /// Registration key. Empty value mean key not used or not registered. - pub key: String, - /// Registration email. Empty value mean email not used or not registered. - pub email: String, - /// Registration URL. Empty value mean that de default value is used. - pub url: String, -} - -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct RegistrationError { - /// ID of error. See dbus API for possible values - pub id: u32, - /// human readable error string intended to be displayed to user - pub message: String, -} diff --git a/rust/agama-lib/src/software/proxies.rs b/rust/agama-lib/src/software/proxies.rs deleted file mode 100644 index 3eff9fd819..0000000000 --- a/rust/agama-lib/src/software/proxies.rs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -mod software; -pub use software::Software1Proxy; - -mod product; -pub use product::{Product, ProductProxy as SoftwareProductProxy}; - -mod proposal; -pub use proposal::ProposalProxy; diff --git a/rust/agama-lib/src/software/proxies/product.rs b/rust/agama-lib/src/software/proxies/product.rs deleted file mode 100644 index 199ece0186..0000000000 --- a/rust/agama-lib/src/software/proxies/product.rs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama.Software1.Product` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama.Software1.Product.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::PropertiesProxy`] -//! * [`zbus::fdo::IntrospectableProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; - -/// Product definition. -/// -/// It is composed of the following elements: -/// -/// * Product ID. -/// * Display name. -/// * Some additional data which includes a "description" key. -pub type Product = ( - String, - String, - std::collections::HashMap, -); - -#[proxy( - default_service = "org.opensuse.Agama.Software1", - default_path = "/org/opensuse/Agama/Software1/Product", - interface = "org.opensuse.Agama.Software1.Product", - assume_defaults = true -)] -pub trait Product { - /// AvailableProducts method - fn available_products(&self) -> zbus::Result>; - - /// SelectProduct method - fn select_product(&self, id: &str) -> zbus::Result<(u32, String)>; - - /// SelectedProduct property - #[zbus(property)] - fn selected_product(&self) -> zbus::Result; -} diff --git a/rust/agama-lib/src/software/proxies/proposal.rs b/rust/agama-lib/src/software/proxies/proposal.rs deleted file mode 100644 index bc88a686c0..0000000000 --- a/rust/agama-lib/src/software/proxies/proposal.rs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama.Software1.Proposal` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama.Software1.Proposal.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::PropertiesProxy`] -//! * [`zbus::fdo::IntrospectableProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, - -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama.Software1", - default_path = "/org/opensuse/Agama/Software1/Proposal", - interface = "org.opensuse.Agama.Software1.Proposal", - assume_defaults = true -)] -pub trait Proposal { - /// AddResolvables method - fn add_resolvables( - &self, - id: &str, - r#type: u8, - resolvables: &[&str], - optional: bool, - ) -> zbus::Result<()>; - - /// GetResolvables method - fn get_resolvables(&self, id: &str, type_: u8, optional: bool) -> zbus::Result>; - - /// RemoveResolvables method - fn remove_resolvables( - &self, - id: &str, - r#type: u8, - resolvables: &[&str], - optional: bool, - ) -> zbus::Result<()>; - - /// SetResolvables method - fn set_resolvables( - &self, - id: &str, - r#type: u8, - resolvables: &[&str], - optional: bool, - ) -> zbus::Result<()>; -} diff --git a/rust/agama-lib/src/software/proxies/software.rs b/rust/agama-lib/src/software/proxies/software.rs deleted file mode 100644 index f76ea97ffd..0000000000 --- a/rust/agama-lib/src/software/proxies/software.rs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama.Software1` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama.Software1.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::PropertiesProxy`] -//! * [`zbus::fdo::IntrospectableProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, - -use zbus::proxy; - -/// Software patterns map. -/// -/// It uses the pattern name as key and a tuple containing the following information as value: -/// -/// * Category. -/// * Description. -/// * Icon. -/// * Summary. -/// * Order. -pub type PatternsMap = std::collections::HashMap; - -pub type Repository = (i32, String, String, String, String, bool, bool); - -#[proxy( - default_service = "org.opensuse.Agama.Software1", - default_path = "/org/opensuse/Agama/Software1", - interface = "org.opensuse.Agama.Software1", - assume_defaults = true -)] -pub trait Software1 { - /// AddPattern method - fn add_pattern(&self, id: &str) -> zbus::Result; - - /// Finish method - fn finish(&self) -> zbus::Result<()>; - - /// Install method - fn install(&self) -> zbus::Result<()>; - - /// IsPackageAvailable method - fn is_package_available(&self, name: &str) -> zbus::Result; - - /// IsPackageInstalled method - fn is_package_installed(&self, name: &str) -> zbus::Result; - - /// ListPatterns method - fn list_patterns(&self, filtered: bool) -> zbus::Result; - - /// ListRepositories method - fn list_repositories(&self) -> zbus::Result>; - - /// ListUserRepositories method - fn list_user_repositories( - &self, - ) -> zbus::Result>>; - - /// Probe method - fn probe(&self) -> zbus::Result<()>; - - /// Propose method - fn propose(&self) -> zbus::Result<()>; - - /// ProvisionsSelected method - fn provisions_selected(&self, provisions: &[&str]) -> zbus::Result>; - - /// RemovePattern method - fn remove_pattern(&self, id: &str) -> zbus::Result; - - /// SetUserPatterns method - fn set_user_patterns(&self, add: &[&str], remove: &[&str]) -> zbus::Result>; - - /// SetUserRepositories method - fn set_user_repositories( - &self, - repos: &[std::collections::HashMap<&str, zbus::zvariant::Value<'_>>], - ) -> zbus::Result<()>; - - /// SolveConflicts method - fn solve_conflicts(&self, solutions: &[(u32, u32)]) -> zbus::Result<()>; - - /// UsedDiskSpace method - fn used_disk_space(&self) -> zbus::Result; - - /// ProbeFinished signal - #[zbus(signal)] - fn probe_finished(&self) -> zbus::Result<()>; - - /// Conflicts property - #[zbus(property)] - #[allow(clippy::type_complexity)] - fn conflicts(&self) -> zbus::Result)>>; - - /// OnlyRequired property - #[zbus(property)] - fn only_required(&self) -> zbus::Result; - #[zbus(property)] - fn set_only_required(&self, value: u32) -> zbus::Result<()>; - - /// SelectedPatterns property - #[zbus(property)] - fn selected_patterns(&self) -> zbus::Result>; -} diff --git a/rust/agama-lib/src/software/settings.rs b/rust/agama-lib/src/software/settings.rs deleted file mode 100644 index bf8d9b2dbb..0000000000 --- a/rust/agama-lib/src/software/settings.rs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Representation of the software settings - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use super::model::RepositoryParams; - -/// Software settings for installation -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SoftwareSettings { - /// List of user selected patterns to install. - #[serde(skip_serializing_if = "Option::is_none")] - pub patterns: Option, - /// List of user selected packages to install. - #[serde(skip_serializing_if = "Option::is_none")] - pub packages: Option>, - /// List of user specified repositories to use on top of default ones. - #[serde(skip_serializing_if = "Option::is_none")] - pub extra_repositories: Option>, - /// Flag indicating if only hard requirements should be used by solver. - #[serde(skip_serializing_if = "Option::is_none")] - pub only_required: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(untagged)] -pub enum PatternsSettings { - PatternsList(Vec), - PatternsMap(PatternsMap), -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub struct PatternsMap { - #[serde(skip_serializing_if = "Option::is_none")] - pub add: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub remove: Option>, -} - -impl From> for PatternsSettings { - fn from(list: Vec) -> Self { - Self::PatternsList(list) - } -} - -impl From>> for PatternsSettings { - fn from(map: HashMap>) -> Self { - let add = if let Some(to_add) = map.get("add") { - Some(to_add.to_owned()) - } else { - None - }; - - let remove = if let Some(to_remove) = map.get("remove") { - Some(to_remove.to_owned()) - } else { - None - }; - - Self::PatternsMap(PatternsMap { add, remove }) - } -} - -impl SoftwareSettings { - pub fn to_option(self) -> Option { - if self.patterns.is_none() - && self.packages.is_none() - && self.extra_repositories.is_none() - && self.only_required.is_none() - { - None - } else { - Some(self) - } - } -} diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs deleted file mode 100644 index 8e9f688735..0000000000 --- a/rust/agama-lib/src/software/store.rs +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements the store for the software settings. - -use std::collections::HashMap; - -use super::{ - http_client::SoftwareHTTPClientError, model::SoftwareConfig, settings::PatternsSettings, - SoftwareHTTPClient, SoftwareSettings, -}; -use crate::http::BaseHTTPClient; - -#[derive(Debug, thiserror::Error)] -#[error("Error processing software settings: {0}")] -pub struct SoftwareStoreError(#[from] SoftwareHTTPClientError); - -type SoftwareStoreResult = Result; - -/// Loads and stores the software settings from/to the HTTP API. -pub struct SoftwareStore { - software_client: SoftwareHTTPClient, -} - -impl SoftwareStore { - pub fn new(client: BaseHTTPClient) -> SoftwareStore { - Self { - software_client: SoftwareHTTPClient::new(client), - } - } - - pub async fn load(&self) -> SoftwareStoreResult { - let patterns = self.software_client.user_selected_patterns().await?; - // FIXME: user_selected_patterns is calling get_config too. - let config = self.software_client.get_config().await?; - Ok(SoftwareSettings { - patterns: if patterns.is_empty() { - None - } else { - Some(PatternsSettings::from(patterns)) - }, - packages: config.packages, - extra_repositories: config.extra_repositories, - only_required: config.only_required, - }) - } - - pub async fn store(&self, settings: &SoftwareSettings) -> SoftwareStoreResult<()> { - let patterns: Option> = - if let Some(patterns) = settings.patterns.clone() { - let mut current_patterns: Vec; - - match patterns { - PatternsSettings::PatternsList(list) => current_patterns = list, - PatternsSettings::PatternsMap(map) => { - current_patterns = self.software_client.user_selected_patterns().await?; - - if let Some(patterns_add) = map.add { - for pattern in patterns_add { - if !current_patterns.contains(&pattern) { - current_patterns.push(pattern); - } - } - } - - if let Some(patterns_remove) = map.remove { - let mut new_patterns: Vec = vec![]; - - for pattern in current_patterns { - if !patterns_remove.contains(&pattern) { - new_patterns.push(pattern) - } - } - - current_patterns = new_patterns; - } - } - } - - Some( - current_patterns - .iter() - .map(|n| (n.to_owned(), true)) - .collect(), - ) - } else { - None - }; - - let config = SoftwareConfig { - // do not change the product - product: None, - patterns, - packages: settings.packages.clone(), - extra_repositories: settings.extra_repositories.clone(), - only_required: settings.only_required, - }; - self.software_client.set_config(&config).await?; - - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::http::BaseHTTPClient; - use httpmock::prelude::*; - use std::error::Error; - use tokio::test; // without this, "error: async functions cannot be used for tests" - - fn software_store(mock_server_url: String) -> SoftwareStore { - let bhc = BaseHTTPClient::new(mock_server_url).unwrap(); - let client = SoftwareHTTPClient::new(bhc); - SoftwareStore { - software_client: client, - } - } - - #[test] - async fn test_getting_software() -> Result<(), Box> { - let server = MockServer::start(); - let software_mock = server.mock(|when, then| { - when.method(GET).path("/api/software/config"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "patterns": {"xfce":true}, - "packages": ["vim"], - "product": "Tumbleweed" - }"#, - ); - }); - let url = server.url("/api"); - - let store = software_store(url); - let settings = store.load().await?; - let patterns_settings = PatternsSettings::from(vec!["xfce".to_owned()]); - - let expected = SoftwareSettings { - patterns: Some(patterns_settings), - packages: Some(vec!["vim".to_owned()]), - extra_repositories: None, - only_required: None, - }; - // main assertion - assert_eq!(settings, expected); - - // FIXME: at this point it is calling the method twice - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - software_mock.assert_hits(2); - Ok(()) - } - - #[test] - async fn test_setting_software_ok() -> Result<(), Box> { - let server = MockServer::start(); - let software_mock = server.mock(|when, then| { - when.method(PUT) - .path("/api/software/config") - .header("content-type", "application/json") - .body(r#"{"patterns":{"xfce":true},"packages":["vim"],"product":null,"extraRepositories":null,"onlyRequired":null}"#); - then.status(200); - }); - let url = server.url("/api"); - - let store = software_store(url); - let patterns_settings = PatternsSettings::from(vec!["xfce".to_owned()]); - - let settings = SoftwareSettings { - patterns: Some(patterns_settings), - packages: Some(vec!["vim".to_owned()]), - extra_repositories: None, - only_required: None, - }; - - let result = store.store(&settings).await; - - // main assertion - result?; - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - software_mock.assert(); - Ok(()) - } - - #[test] - async fn test_setting_software_err() -> Result<(), Box> { - let server = MockServer::start(); - let software_mock = server.mock(|when, then| { - when.method(PUT) - .path("/api/software/config") - .header("content-type", "application/json") - .body(r#"{"patterns":{"no_such_pattern":true},"packages":["vim"],"product":null,"extraRepositories":null,"onlyRequired":null}"#); - then.status(400) - .body(r#"'{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}"#); - }); - let url = server.url("/api"); - - let store = software_store(url); - let patterns_settings = PatternsSettings::from(vec!["no_such_pattern".to_owned()]); - let settings = SoftwareSettings { - patterns: Some(patterns_settings), - packages: Some(vec!["vim".to_owned()]), - extra_repositories: None, - only_required: None, - }; - - let result = store.store(&settings).await; - - // main assertion - assert!(result.is_err()); - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - software_mock.assert(); - Ok(()) - } -} diff --git a/rust/agama-lib/src/storage/client/iscsi.rs b/rust/agama-lib/src/storage/client/iscsi.rs index eddeed34c2..01cb4dfa4c 100644 --- a/rust/agama-lib/src/storage/client/iscsi.rs +++ b/rust/agama-lib/src/storage/client/iscsi.rs @@ -275,7 +275,7 @@ impl<'a> ISCSIClient<'a> { Ok(()) } - pub async fn get_node_proxy(&self, id: u32) -> Result { + pub async fn get_node_proxy(&'a self, id: u32) -> Result, ServiceError> { let proxy = NodeProxy::builder(&self.connection) .path(format!("/org/opensuse/Agama/Storage1/iscsi_nodes/{}", id))? .build() diff --git a/rust/agama-lib/src/storage/client/zfcp.rs b/rust/agama-lib/src/storage/client/zfcp.rs index 9d63d0a2ca..63ad9c27b6 100644 --- a/rust/agama-lib/src/storage/client/zfcp.rs +++ b/rust/agama-lib/src/storage/client/zfcp.rs @@ -50,7 +50,7 @@ pub struct ZFCPClient<'a> { introspectable_proxy: IntrospectableProxy<'a>, } -impl ZFCPClient<'_> { +impl<'a> ZFCPClient<'a> { pub async fn new(connection: Connection) -> Result { let manager_proxy = ManagerProxy::new(&connection).await?; let object_manager_proxy = ObjectManagerProxy::builder(&connection) @@ -130,9 +130,9 @@ impl ZFCPClient<'_> { } async fn get_controller_proxy( - &self, + &'a self, controller_id: &str, - ) -> Result { + ) -> Result, ServiceError> { let dbus = ControllerProxy::builder(&self.connection) .path(ZFCP_CONTROLLER_PREFIX.to_string() + "/" + controller_id)? .build() diff --git a/rust/agama-lib/src/storage/http_client.rs b/rust/agama-lib/src/storage/http_client.rs index 27ce6d4032..0abc8a8516 100644 --- a/rust/agama-lib/src/storage/http_client.rs +++ b/rust/agama-lib/src/storage/http_client.rs @@ -19,7 +19,6 @@ // find current contact information at www.suse.com. //! Implements a client to access Agama's storage service. - pub mod dasd; pub mod iscsi; pub mod zfcp; diff --git a/rust/agama-lib/src/storage/settings/dasd.rs b/rust/agama-lib/src/storage/settings/dasd.rs index a310220e8e..f2e423cd4c 100644 --- a/rust/agama-lib/src/storage/settings/dasd.rs +++ b/rust/agama-lib/src/storage/settings/dasd.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct DASDConfig { pub devices: Vec, diff --git a/rust/agama-lib/src/storage/settings/zfcp.rs b/rust/agama-lib/src/storage/settings/zfcp.rs index 79c227e534..222c69b167 100644 --- a/rust/agama-lib/src/storage/settings/zfcp.rs +++ b/rust/agama-lib/src/storage/settings/zfcp.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ZFCPConfig { pub devices: Vec, diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 24c17559db..01052cfb7d 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -23,18 +23,11 @@ use crate::{ bootloader::store::{BootloaderStore, BootloaderStoreError}, - files::store::{FilesStore, FilesStoreError}, hostname::store::{HostnameStore, HostnameStoreError}, http::BaseHTTPClient, install_settings::InstallSettings, - localization::{LocalizationStore, LocalizationStoreError}, - manager::{http_client::ManagerHTTPClientError, InstallationPhase, ManagerHTTPClient}, network::{NetworkStore, NetworkStoreError}, - product::{ProductHTTPClient, ProductStore, ProductStoreError}, - questions::store::{QuestionsStore, QuestionsStoreError}, - scripts::{ScriptsClient, ScriptsClientError, ScriptsGroup, ScriptsStore, ScriptsStoreError}, security::store::{SecurityStore, SecurityStoreError}, - software::{SoftwareStore, SoftwareStoreError}, storage::{ http_client::{ iscsi::{ISCSIHTTPClient, ISCSIHTTPClientError}, @@ -56,40 +49,21 @@ pub enum StoreError { #[error(transparent)] DASD(#[from] DASDStoreError), #[error(transparent)] - Files(#[from] FilesStoreError), - #[error(transparent)] Hostname(#[from] HostnameStoreError), #[error(transparent)] Users(#[from] UsersStoreError), #[error(transparent)] Network(#[from] NetworkStoreError), #[error(transparent)] - Questions(#[from] QuestionsStoreError), - #[error(transparent)] - Product(#[from] ProductStoreError), - #[error(transparent)] Security(#[from] SecurityStoreError), #[error(transparent)] - Software(#[from] SoftwareStoreError), - #[error(transparent)] Storage(#[from] StorageStoreError), #[error(transparent)] ISCSI(#[from] ISCSIHTTPClientError), #[error(transparent)] - Localization(#[from] LocalizationStoreError), - #[error(transparent)] - Scripts(#[from] ScriptsStoreError), - // FIXME: it uses the client instead of the store. - #[error(transparent)] - ScriptsClient(#[from] ScriptsClientError), - #[error(transparent)] - Manager(#[from] ManagerHTTPClientError), - #[error(transparent)] ZFCP(#[from] ZFCPStoreError), #[error("Could not calculate the context")] InvalidStoreContext, - #[error("Cannot proceed with profile without specified product")] - MissingProduct, } /// Struct that loads/stores the settings from/to the D-Bus services. @@ -101,19 +75,12 @@ pub enum StoreError { pub struct Store { bootloader: BootloaderStore, dasd: DASDStore, - files: FilesStore, hostname: HostnameStore, users: UsersStore, network: NetworkStore, - questions: QuestionsStore, - product: ProductStore, security: SecurityStore, - software: SoftwareStore, storage: StorageStore, - localization: LocalizationStore, - scripts: ScriptsStore, iscsi_client: ISCSIHTTPClient, - manager_client: ManagerHTTPClient, http_client: BaseHTTPClient, zfcp: ZFCPStore, } @@ -123,18 +90,11 @@ impl Store { Ok(Self { bootloader: BootloaderStore::new(http_client.clone()), dasd: DASDStore::new(http_client.clone()), - files: FilesStore::new(http_client.clone()), hostname: HostnameStore::new(http_client.clone()), - localization: LocalizationStore::new(http_client.clone()), users: UsersStore::new(http_client.clone()), network: NetworkStore::new(http_client.clone()), - questions: QuestionsStore::new(http_client.clone()), - product: ProductStore::new(http_client.clone()), security: SecurityStore::new(http_client.clone()), - software: SoftwareStore::new(http_client.clone()), storage: StorageStore::new(http_client.clone()), - scripts: ScriptsStore::new(http_client.clone()), - manager_client: ManagerHTTPClient::new(http_client.clone()), iscsi_client: ISCSIHTTPClient::new(http_client.clone()), zfcp: ZFCPStore::new(http_client.clone()), http_client, @@ -146,17 +106,10 @@ impl Store { let mut settings = InstallSettings { bootloader: self.bootloader.load().await?, dasd: self.dasd.load().await?, - files: self.files.load().await?, hostname: Some(self.hostname.load().await?), network: Some(self.network.load().await?), - // FIXME: do not export questions yet. - questions: self.questions.load().await?, security: self.security.load().await?.to_option(), - software: self.software.load().await?.to_option(), user: Some(self.users.load().await?), - product: Some(self.product.load().await?), - localization: Some(self.localization.load().await?), - scripts: self.scripts.load().await?.to_option(), zfcp: self.zfcp.load().await?, ..Default::default() }; @@ -172,24 +125,11 @@ impl Store { /// Stores the given installation settings in the Agama service /// - /// As part of the process it runs pre-scripts and forces a probe if the installation phase is - /// "config". It causes the storage proposal to be reset. This behavior should be revisited in + /// It causes the storage proposal to be reset. This behavior should be revisited in /// the future but it might be the storage service the responsible for dealing with this. /// /// * `settings`: installation settings. pub async fn store(&self, settings: &InstallSettings) -> Result<(), StoreError> { - if let Some(scripts) = &settings.scripts { - self.scripts.store(scripts).await?; - - if scripts.pre.as_ref().is_some_and(|s| !s.is_empty()) { - self.run_pre_scripts().await?; - } - } - - if let Some(questions) = &settings.questions { - self.questions.store(questions).await?; - } - if let Some(network) = &settings.network { self.network.store(network).await?; } @@ -201,38 +141,18 @@ impl Store { if let Some(user) = &settings.user { self.users.store(user).await?; } - // order is important here as network can be critical for connection - // to registration server and selecting product is important for rest - if let Some(product) = &settings.product { - self.product.store(product).await?; - } - // here detect if product is properly selected, so later it can be checked - let is_product_selected = self.detect_selected_product().await?; - // ordering: localization after product as some product may miss some locales - if let Some(localization) = &settings.localization { - Store::ensure_selected_product(is_product_selected)?; - self.localization.store(localization).await?; - } - if let Some(software) = &settings.software { - Store::ensure_selected_product(is_product_selected)?; - self.software.store(software).await?; - } let mut dirty_flag_set = false; // iscsi has to be done before storage if let Some(iscsi) = &settings.iscsi { - Store::ensure_selected_product(is_product_selected)?; - dirty_flag_set = true; self.iscsi_client.set_config(iscsi).await? } // dasd devices has to be activated before storage if let Some(dasd) = &settings.dasd { - Store::ensure_selected_product(is_product_selected)?; dirty_flag_set = true; self.dasd.store(dasd).await? } // zfcp devices has to be activated before storage if let Some(zfcp) = &settings.zfcp { - Store::ensure_selected_product(is_product_selected)?; dirty_flag_set = true; self.zfcp.store(zfcp).await? } @@ -241,24 +161,18 @@ impl Store { // reprobe here before loading the storage settings. Otherwise, the new storage devices are // not used. if dirty_flag_set { - Store::ensure_selected_product(is_product_selected)?; self.reprobe_storage().await?; } if settings.storage.is_some() || settings.storage_autoyast.is_some() { - Store::ensure_selected_product(is_product_selected)?; self.storage.store(&settings.into()).await? } if let Some(bootloader) = &settings.bootloader { self.bootloader.store(bootloader).await?; } if let Some(hostname) = &settings.hostname { - Store::ensure_selected_product(is_product_selected)?; self.hostname.store(hostname).await?; } - if let Some(files) = &settings.files { - self.files.store(files).await?; - } Ok(()) } @@ -271,30 +185,4 @@ impl Store { } Ok(()) } - - async fn detect_selected_product(&self) -> Result { - let product_client = ProductHTTPClient::new(self.http_client.clone()); - let product = product_client.product().await?; - Ok(!product.is_empty()) - } - - fn ensure_selected_product(selected: bool) -> Result<(), StoreError> { - if selected { - Ok(()) - } else { - Err(StoreError::MissingProduct) - } - } - - /// Runs the pre-installation scripts and forces a probe if the installation phase is "config". - async fn run_pre_scripts(&self) -> Result<(), StoreError> { - let scripts_client = ScriptsClient::new(self.http_client.clone()); - scripts_client.run_scripts(ScriptsGroup::Pre).await?; - - let status = self.manager_client.status().await; - if status.is_ok_and(|s| s.phase == InstallationPhase::Config) { - self.manager_client.probe().await?; - } - Ok(()) - } } diff --git a/rust/agama-lib/src/users.rs b/rust/agama-lib/src/users.rs index bd529189a1..fb2aa9ff72 100644 --- a/rust/agama-lib/src/users.rs +++ b/rust/agama-lib/src/users.rs @@ -29,5 +29,5 @@ mod store; pub use client::{FirstUser, RootUser, UsersClient}; pub use http_client::UsersHTTPClient; -pub use settings::{FirstUserSettings, RootUserSettings, UserSettings}; +pub use settings::{FirstUserSettings, RootUserSettings, UserPassword, UserSettings}; pub use store::{UsersStore, UsersStoreError}; diff --git a/rust/agama-lib/src/users/settings.rs b/rust/agama-lib/src/users/settings.rs index 6ee63906ab..3e4805124b 100644 --- a/rust/agama-lib/src/users/settings.rs +++ b/rust/agama-lib/src/users/settings.rs @@ -25,7 +25,7 @@ use super::{FirstUser, RootUser}; /// User settings /// /// Holds the user settings for the installation. -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserSettings { #[serde(rename = "user")] @@ -38,7 +38,7 @@ pub struct UserSettings { /// First user settings /// /// Holds the settings for the first user. -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct FirstUserSettings { /// First user's full name @@ -92,7 +92,7 @@ impl From for FirstUserSettings { /// Represents a user password. /// /// It holds the password and whether it is a hashed or a plain text password. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserPassword { /// User password @@ -105,7 +105,7 @@ pub struct UserPassword { /// Root user settings /// /// Holds the settings for the root user. -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct RootUserSettings { /// Root user password diff --git a/rust/agama-lib/src/utils.rs b/rust/agama-lib/src/utils.rs index 6a98caca59..8f545f4583 100644 --- a/rust/agama-lib/src/utils.rs +++ b/rust/agama-lib/src/utils.rs @@ -21,8 +21,6 @@ //! Utility module for Agama. mod file_format; -mod transfer; pub mod url; pub use file_format::*; -pub use transfer::*; diff --git a/rust/agama-locale-data/Cargo.toml b/rust/agama-locale-data/Cargo.toml index b99d07b509..5dc9c4d888 100644 --- a/rust/agama-locale-data/Cargo.toml +++ b/rust/agama-locale-data/Cargo.toml @@ -6,7 +6,6 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1.0" serde = { version = "1.0.210", features = ["derive"] } quick-xml = { version = "0.37.5", features = ["serialize"] } flate2 = "1.0.34" diff --git a/rust/agama-lib/src/questions/error.rs b/rust/agama-locale-data/src/error.rs similarity index 69% rename from rust/agama-lib/src/questions/error.rs rename to rust/agama-locale-data/src/error.rs index 2b5b1140f8..ad5985b7b4 100644 --- a/rust/agama-lib/src/questions/error.rs +++ b/rust/agama-locale-data/src/error.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,10 +18,14 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use quick_xml::DeError; + #[derive(thiserror::Error, Debug)] -pub enum QuestionsError { - #[error("Could not read the answers file: {0}")] - IO(std::io::Error), - #[error("Could not deserialize the answers file: {0}")] - Deserialize(serde_json::Error), +pub enum LocaleDataError { + #[error("Could not read file {0}")] + IO(String, #[source] std::io::Error), + #[error("Could not deserialize langtable data from {0}")] + Deserialize(String, #[source] DeError), + #[error("Could not read the keymaps")] + CouldNotReadKeymaps(#[source] std::io::Error), } diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs index 7475d939a5..1215eb5be8 100644 --- a/rust/agama-locale-data/src/lib.rs +++ b/rust/agama-locale-data/src/lib.rs @@ -18,10 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use anyhow::Context; use flate2::bufread::GzDecoder; +use keyboard::xkeyboard; use quick_xml::de::Deserializer; -use serde::Deserialize; +use serde::de::DeserializeOwned; use std::collections::HashMap; use std::fs::File; use std::io::BufRead; @@ -29,6 +29,7 @@ use std::io::BufReader; use std::process::Command; pub mod deprecated_timezones; +mod error; pub mod keyboard; pub mod language; mod locale; @@ -37,27 +38,36 @@ pub mod ranked; pub mod territory; pub mod timezone_part; -use keyboard::xkeyboard; +pub use error::LocaleDataError; -pub use locale::{InvalidKeymap, InvalidLocaleCode, KeymapId, LocaleId}; +pub type LocaleDataResult = Result; -fn file_reader(file_path: &str) -> anyhow::Result { - let file = File::open(file_path) - .with_context(|| format!("Failed to read langtable-data ({})", file_path))?; +pub use locale::{ + InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId, TimezoneId, +}; + +fn file_reader(file_path: &str) -> LocaleDataResult { + let file = File::open(file_path).map_err(|e| LocaleDataError::IO(file_path.to_string(), e))?; let reader = BufReader::new(GzDecoder::new(BufReader::new(file))); Ok(reader) } -/// Gets list of X11 keyboards structs -pub fn get_xkeyboards() -> anyhow::Result { - const FILE_PATH: &str = "/usr/share/langtable/data/keyboards.xml.gz"; - let reader = file_reader(FILE_PATH)?; +fn get_xml_data(file_path: &str) -> LocaleDataResult +where + T: DeserializeOwned, +{ + let reader = file_reader(file_path)?; let mut deserializer = Deserializer::from_reader(reader); - let ret = xkeyboard::XKeyboards::deserialize(&mut deserializer) - .context("Failed to deserialize keyboard entry")?; + let ret = T::deserialize(&mut deserializer) + .map_err(|e| LocaleDataError::Deserialize(file_path.to_string(), e))?; Ok(ret) } +/// Gets list of X11 keyboards structs +pub fn get_xkeyboards() -> LocaleDataResult { + get_xml_data::("/usr/share/langtable/data/keyboards.xml.gz") +} + /// Gets list of available keymaps /// /// ## Examples @@ -70,55 +80,42 @@ pub fn get_xkeyboards() -> anyhow::Result { /// let us: KeymapId = "us".parse().unwrap(); /// assert!(key_maps.contains(&us)); /// ``` -pub fn get_localectl_keymaps() -> anyhow::Result> { +pub fn get_localectl_keymaps() -> LocaleDataResult> { let output = Command::new("localectl") .arg("list-keymaps") .output() - .context("failed to execute localectl list-maps")? + .map_err(LocaleDataError::CouldNotReadKeymaps)? .stdout; - let output = String::from_utf8(output).context("Strange localectl output formatting")?; + let output = String::from_utf8_lossy(&output); let ret: Vec<_> = output.lines().flat_map(|l| l.parse().ok()).collect(); Ok(ret) } /// Returns struct which contain list of known languages -pub fn get_languages() -> anyhow::Result { - const FILE_PATH: &str = "/usr/share/langtable/data/languages.xml.gz"; - let reader = file_reader(FILE_PATH)?; - let mut deserializer = Deserializer::from_reader(reader); - let ret = language::Languages::deserialize(&mut deserializer) - .context("Failed to deserialize language entry")?; - Ok(ret) +pub fn get_languages() -> LocaleDataResult { + get_xml_data::("/usr/share/langtable/data/languages.xml.gz") } /// Returns struct which contain list of known territories -pub fn get_territories() -> anyhow::Result { - const FILE_PATH: &str = "/usr/share/langtable/data/territories.xml.gz"; - let reader = file_reader(FILE_PATH)?; - let mut deserializer = Deserializer::from_reader(reader); - let ret = territory::Territories::deserialize(&mut deserializer) - .context("Failed to deserialize territory entry")?; - Ok(ret) +pub fn get_territories() -> LocaleDataResult { + get_xml_data::("/usr/share/langtable/data/territories.xml.gz") } /// Returns struct which contain list of known parts of timezones. Useful for translation -pub fn get_timezone_parts() -> anyhow::Result { - const FILE_PATH: &str = "/usr/share/langtable/data/timezoneidparts.xml.gz"; - let reader = file_reader(FILE_PATH)?; - let mut deserializer = Deserializer::from_reader(reader); - let ret = timezone_part::TimezoneIdParts::deserialize(&mut deserializer) - .context("Failed to deserialize timezone part entry")?; - Ok(ret) +pub fn get_timezone_parts() -> LocaleDataResult { + get_xml_data::( + "/usr/share/langtable/data/timezoneidparts.xml.gz", + ) } /// Returns a hash mapping timezones to its main country (typically, the country of /// the city that is used to name the timezone). The information is read from the /// file /usr/share/zoneinfo/zone.tab. -pub fn get_timezone_countries() -> anyhow::Result> { +pub fn get_timezone_countries() -> LocaleDataResult> { const FILE_PATH: &str = "/usr/share/zoneinfo/zone.tab"; let content = std::fs::read_to_string(FILE_PATH) - .with_context(|| format!("Failed to read {}", FILE_PATH))?; + .map_err(|e| LocaleDataError::IO(FILE_PATH.to_string(), e))?; let countries = content .lines() diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index 65bb72b91a..d1c5a72b4b 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -21,12 +21,46 @@ //! Defines useful types to deal with localization values use regex::Regex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::sync::OnceLock; use std::{fmt::Display, str::FromStr}; use thiserror::Error; -#[derive(Clone, Debug, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Debug, Clone, Serialize, PartialEq, utoipa::ToSchema)] +pub struct TimezoneId(String); + +impl Default for TimezoneId { + fn default() -> Self { + Self("Europe/Berlin".to_string()) + } +} + +impl Display for TimezoneId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TimezoneId { + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +#[derive(Clone, Error, Debug)] +#[error("Invalid timezone ID: {0}")] +pub struct InvalidTimezoneId(String); + +impl FromStr for TimezoneId { + type Err = InvalidTimezoneId; + + // TODO: implement real parsing of the string. + fn from_str(s: &str) -> Result { + Ok(Self(s.to_string())) + } +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] pub struct LocaleId { // ISO-639 pub language: String, @@ -55,20 +89,20 @@ impl Default for LocaleId { } } -#[derive(Error, Debug)] -#[error("Not a valid locale string: {0}")] -pub struct InvalidLocaleCode(String); +#[derive(Clone, Error, Debug)] +#[error("Invalid locale ID: {0}")] +pub struct InvalidLocaleId(String); -impl TryFrom<&str> for LocaleId { - type Error = InvalidLocaleCode; +impl FromStr for LocaleId { + type Err = InvalidLocaleId; - fn try_from(value: &str) -> Result { + fn from_str(s: &str) -> Result { let locale_regexp: Regex = Regex::new(r"^([[:alpha:]]+)_([[:alpha:]]+)(?:\.(.+))?").unwrap(); let captures = locale_regexp - .captures(value) - .ok_or_else(|| InvalidLocaleCode(value.to_string()))?; + .captures(s) + .ok_or_else(|| InvalidLocaleId(s.to_string()))?; let encoding = captures .get(3) @@ -100,7 +134,7 @@ static KEYMAP_ID_REGEX: OnceLock = OnceLock::new(); /// let id_with_dashes: KeymapId = "es-ast".parse().unwrap(); /// assert_eq!(id, id_with_dashes); /// ``` -#[derive(Clone, Debug, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] pub struct KeymapId { /// Keyboard layout (e.g., "es" in "es(ast)") pub layout: String, @@ -119,7 +153,7 @@ impl Default for KeymapId { #[derive(Error, Debug, PartialEq)] #[error("Invalid keymap ID: {0}")] -pub struct InvalidKeymap(String); +pub struct InvalidKeymapId(String); impl KeymapId { pub fn dashed(&self) -> String { @@ -142,7 +176,7 @@ impl Display for KeymapId { } impl FromStr for KeymapId { - type Err = InvalidKeymap; + type Err = InvalidKeymapId; fn from_str(s: &str) -> Result { let re = KEYMAP_ID_REGEX @@ -176,7 +210,7 @@ impl FromStr for KeymapId { variant, }) } else { - Err(InvalidKeymap(s.to_string())) + Err(InvalidKeymapId(s.to_string())) } } } diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml new file mode 100644 index 0000000000..074aef7b15 --- /dev/null +++ b/rust/agama-manager/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "agama-manager" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-files = { path = "../agama-files" } +agama-hostname = { path = "../agama-hostname" } +agama-l10n = { path = "../agama-l10n" } +agama-network = { path = "../agama-network" } +agama-software = { path = "../agama-software" } +agama-storage = { path = "../agama-storage" } +agama-utils = { path = "../agama-utils" } +thiserror = "2.0.12" +tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } +async-trait = "0.1.83" +zbus = { version = "5", default-features = false, features = ["tokio"] } +serde_json = "1.0.140" +tracing = "0.1.41" +serde = { version = "1.0.228", features = ["derive"] } +serde_with = "3.16.1" +gettext-rs = { version = "0.7.7", features = ["gettext-system"] } +merge = "0.2.0" + +[dev-dependencies] +test-context = "0.4.1" +tokio-test = "0.4.4" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ci)'] } diff --git a/rust/agama-manager/src/hardware.rs b/rust/agama-manager/src/hardware.rs new file mode 100644 index 0000000000..3db3a57aff --- /dev/null +++ b/rust/agama-manager/src/hardware.rs @@ -0,0 +1,337 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_utils::api::manager::HardwareInfo; +use serde::Deserialize; +use serde_with::{formats::PreferMany, serde_as, OneOrMany}; +use std::{ + path::{Path, PathBuf}, + process::ExitStatus, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("lshw command failed: {stderr}")] + Command { status: ExitStatus, stderr: String }, + #[error("Failed to parse lshw output: {source:?}")] + Parse { + json: String, + #[source] + source: serde_json::Error, + }, + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +#[derive(Clone)] +enum Source { + System, + File(PathBuf), +} + +pub struct Registry { + root: Option, + source: Source, +} + +impl Registry { + pub fn new_from_system() -> Self { + Self { + source: Source::System, + root: None, + } + } + + pub fn new_from_file>(path: P) -> Self { + Self { + source: Source::File(path.as_ref().to_path_buf()), + root: None, + } + } + + pub async fn read(&mut self) -> Result<(), Error> { + match &self.source { + Source::System => self.read_from_system().await, + Source::File(ref path) => self.read_from_file(path.clone()), + } + } + + async fn read_from_system(&mut self) -> Result<(), Error> { + let output = tokio::process::Command::new("lshw") + .arg("-json") + .output() + .await?; + + if !output.status.success() { + return Err(Error::Command { + status: output.status, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + self.root = Some(HardwareNode::from_json(&stdout)?); + Ok(()) + } + + /// Builds a registry using the lshw data from a file. + fn read_from_file>(&mut self, path: P) -> Result<(), Error> { + let json = std::fs::read_to_string(path)?; + self.root = Some(HardwareNode::from_json(&json)?); + Ok(()) + } + + /// Converts the information to a HardwareInfo struct. + pub fn to_hardware_info(&self) -> HardwareInfo { + let Some(root) = &self.root else { + return HardwareInfo::default(); + }; + + HardwareInfo::from(root) + } +} + +/// Hardware information from the underlying system. +/// +/// It relies on lshw to read the hardware information. +#[serde_as] +#[derive(Clone, Debug, Deserialize, PartialEq)] +struct HardwareNode { + pub id: String, + pub class: String, + pub claimed: Option, + pub description: Option, + pub vendor: Option, + pub product: Option, + pub version: Option, + pub serial: Option, + pub businfo: Option, + pub dev: Option, + pub driver: Option, + pub physid: Option, + pub size: Option, + pub capacity: Option, + #[serde(default)] + #[serde_as(as = "OneOrMany<_, PreferMany>")] + pub logicalname: Vec, + pub configuration: Option, + #[serde(default)] + pub capabilities: Option, + #[serde(default)] + pub children: Vec, +} + +impl HardwareNode { + /// Builds a node (including its children) from a JSON string. + /// + /// * `json`: JSON string reference. + fn from_json(json: &str) -> Result { + let node = serde_json::from_str(&json).map_err(|error| Error::Parse { + json: json.to_string(), + source: error, + })?; + + Ok(node) + } + + /// Searches hardware information using the id (e.g., "cpu"). + /// + /// It assumes that the id is unique. + /// + /// * `id`: id to search for (e.g., "cpu", "memory", etc.). + pub fn find_by_id(&self, id: &str) -> Option<&HardwareNode> { + if self.id == id { + return Some(&self); + } + + for children in &self.children { + let result = children.find_by_id(id); + if result.is_some() { + return result; + } + } + + None + } + + /// Searches hardware information by class (e.g., "disk"). + /// + /// It might be multiple elements of the same class. + /// + /// * `class`: class to search for (e.g., "disk", "processor", etc.). + pub fn find_by_class(&self, class: &str) -> Vec<&HardwareNode> { + let mut results = vec![]; + self.search_by_class(class, &mut results); + results + } + + fn search_by_class<'a>(&'a self, class: &str, results: &mut Vec<&'a HardwareNode>) { + if self.class == class { + results.push(&self); + } + + for children in &self.children { + children.search_by_class(class, results); + } + } +} + +impl From<&HardwareNode> for HardwareInfo { + fn from(value: &HardwareNode) -> Self { + let cpu = value + .find_by_class("processor") + .first() + .and_then(|c| c.product.clone()); + + let memory = value.find_by_id("memory").and_then(|m| m.size); + + let model = if let Some(system) = value.find_by_class("system").first() { + let model_str = format!( + "{} {}", + system.vendor.clone().unwrap_or_default(), + system.version.clone().unwrap_or_default() + ) + .trim() + .to_string(); + if model_str.is_empty() { + None + } else { + Some(model_str) + } + } else { + None + }; + + Self { cpu, memory, model } + } +} + +#[cfg(test)] +mod tests { + use std::{error::Error, path::PathBuf}; + + use super::*; + + #[tokio::test] + async fn test_read_from_system() { + let old_path = std::env::var("PATH").unwrap(); + let bin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../share/bin"); + std::env::set_var("PATH", format!("{}:{}", &bin_dir.display(), &old_path)); + let mut hardware = Registry::new_from_system(); + hardware.read().await.unwrap(); + + let info = hardware.to_hardware_info(); + assert!(info.cpu.is_some()); + } + + #[tokio::test] + async fn test_find_by_id() -> Result<(), Box> { + let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + let path = fixtures.join("lshw.json"); + let json = std::fs::read_to_string(path)?; + let node: HardwareNode = serde_json::from_str(&json)?; + + let cpu = node.find_by_id("cpu").unwrap(); + assert_eq!(cpu.class, "processor"); + assert_eq!( + cpu.product, + Some("AMD Ryzen 5 PRO 5650U with Radeon Graphics".to_string()) + ); + + let unknown = node.find_by_id("unknown"); + assert_eq!(unknown, None); + Ok(()) + } + + #[tokio::test] + async fn test_find_by_class() -> Result<(), Box> { + let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + let path = fixtures.join("lshw.json"); + let json = std::fs::read_to_string(path)?; + let node: HardwareNode = serde_json::from_str(&json)?; + + let disks = node.find_by_class("disk"); + assert_eq!(disks.len(), 3); + + let disk = disks.first().unwrap(); + assert_eq!(disk.description, Some("NVMe disk".to_string())); + assert_eq!(disk.logicalname, vec!["hwmon1".to_string()]); + + let unknown = node.find_by_class("unknown"); + assert!(unknown.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn test_to_hardware_info() -> Result<(), Box> { + let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + let mut registry = Registry::new_from_file(&fixtures.join("lshw.json")); + registry.read().await?; + let node = registry.to_hardware_info(); + assert_eq!( + node.cpu, + Some("AMD Ryzen 5 PRO 5650U with Radeon Graphics".to_string()) + ); + assert_eq!(node.memory, Some(17179869184)); + assert_eq!(node.model, Some("LENOVO ThinkPad T14s Gen 2a".to_string())); + Ok(()) + } + + #[tokio::test] + async fn test_to_hardware_info_qemu() -> Result<(), Box> { + let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + let mut registry = Registry::new_from_file(&fixtures.join("lshw-qemu.json")); + registry.read().await?; + let node = registry.to_hardware_info(); + assert_eq!( + node.cpu, + Some("AMD Ryzen 5 PRO 5650U with Radeon Graphics".to_string()) + ); + assert_eq!(node.memory, Some(4294967296)); + assert_eq!(node.model, Some("QEMU pc-q35-9.2".to_string())); + Ok(()) + } + + #[test] + fn test_parse_from_json_error() { + let invalid_json = "INVALID JSON"; + let error = HardwareNode::from_json(invalid_json).unwrap_err(); + println!("{error}"); + assert!(matches!( + error, + super::Error::Parse { + json: _json, + source: _source + } + )); + } + + #[tokio::test] + async fn test_to_hardware_incomplete() -> Result<(), Box> { + let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + let mut registry = Registry::new_from_file(&fixtures.join("lshw-incomplete.json")); + registry.read().await?; + let node = registry.to_hardware_info(); + assert_eq!(node.cpu, None); + assert_eq!(node.memory, None); + assert_eq!(node.model, None); + Ok(()) + } +} diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs new file mode 100644 index 0000000000..df30c29051 --- /dev/null +++ b/rust/agama-manager/src/lib.rs @@ -0,0 +1,204 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub mod service; +pub use service::Service; + +pub mod message; + +pub mod hardware; + +pub use agama_files as files; +pub use agama_hostname as hostname; +pub use agama_l10n as l10n; +pub use agama_network as network; +pub use agama_software as software; +pub use agama_storage as storage; + +pub mod test_utils; + +#[cfg(test)] +mod test { + use crate::{ + message, + service::{Error, Service}, + test_utils, + }; + use agama_utils::{ + actor::Handler, + api::{ + l10n, + software::{self, ProductConfig}, + Config, Event, + }, + test, + }; + use std::path::PathBuf; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + async fn select_product(handler: &Handler) -> Result<(), Error> { + let software = software::Config { + product: Some(ProductConfig { + id: Some("SLES".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let input_config = Config { + software: Some(software), + ..Default::default() + }; + + handler + .call(message::SetConfig::new(input_config.clone())) + .await?; + Ok(()) + } + + struct Context { + handler: Handler, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + let share_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + std::env::set_var("AGAMA_SHARE_DIR", share_dir.display().to_string()); + + let (events_tx, mut events_rx) = broadcast::channel::(16); + let dbus = test::dbus::connection().await.unwrap(); + + tokio::spawn(async move { + while let Ok(event) = events_rx.recv().await { + println!("{:?}", event); + } + }); + + let handler = test_utils::start_service(events_tx, dbus).await; + Context { handler } + } + } + + #[test_context(Context)] + #[tokio::test] + async fn test_update_config(ctx: &mut Context) -> Result<(), Error> { + let software = software::Config { + product: Some(ProductConfig { + id: Some("SLES".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let input_config = Config { + software: Some(software), + l10n: Some(l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }), + ..Default::default() + }; + + ctx.handler + .call(message::SetConfig::new(input_config.clone())) + .await?; + + let config = ctx.handler.call(message::GetConfig).await?; + assert_eq!(input_config.l10n.unwrap(), config.l10n.unwrap()); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_update_config_without_product(ctx: &mut Context) { + let input_config = Config { + l10n: Some(l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }), + ..Default::default() + }; + + let error = ctx + .handler + .call(message::SetConfig::new(input_config.clone())) + .await; + assert!(matches!(error, Err(crate::service::Error::MissingProduct))); + } + + #[test_context(Context)] + #[tokio::test] + async fn test_patch_config(ctx: &mut Context) -> Result<(), Error> { + select_product(&ctx.handler).await?; + + let input_config = Config { + l10n: Some(l10n::Config { + keymap: Some("es".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + ctx.handler + .call(message::UpdateConfig::new(input_config.clone())) + .await?; + + let config = ctx.handler.call(message::GetConfig).await?; + + assert_eq!(input_config.l10n.unwrap(), config.l10n.unwrap()); + + let extended_config = ctx.handler.call(message::GetExtendedConfig).await?; + let l10n_config = extended_config.l10n.unwrap(); + + assert!(l10n_config.locale.is_some()); + assert!(l10n_config.keymap.is_some()); + assert!(l10n_config.timezone.is_some()); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_patch_config_without_product(ctx: &mut Context) -> Result<(), Error> { + let input_config = Config { + l10n: Some(l10n::Config { + keymap: Some("es".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let result = ctx + .handler + .call(message::UpdateConfig::new(input_config.clone())) + .await; + assert!(matches!(result, Err(crate::service::Error::MissingProduct))); + + let extended_config = ctx.handler.call(message::GetExtendedConfig).await?; + let l10n_config = extended_config.l10n.unwrap(); + assert_eq!(l10n_config.keymap, Some("us".to_string())); + + Ok(()) + } +} diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs new file mode 100644 index 0000000000..b28fddc62e --- /dev/null +++ b/rust/agama-manager/src/message.rs @@ -0,0 +1,169 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_utils::{ + actor::Message, + api::{ + manager::{LanguageTag, LicenseContent}, + Action, Config, IssueMap, Proposal, SystemInfo, + }, +}; +use serde_json::Value; + +/// Gets the information of the underlying system. +#[derive(Debug)] +pub struct GetSystem; + +impl Message for GetSystem { + type Reply = SystemInfo; +} + +/// Gets the full config. +/// +/// It includes user and default values. +#[derive(Debug)] +pub struct GetExtendedConfig; + +impl Message for GetExtendedConfig { + type Reply = Config; +} + +/// Gets the current config set by the user. +#[derive(Debug)] +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = Config; +} + +/// Replaces the config. +#[derive(Debug)] +pub struct SetConfig { + pub config: Config, +} + +impl SetConfig { + pub fn new(config: Config) -> Self { + Self { config } + } +} + +impl Message for SetConfig { + type Reply = (); +} + +/// Updates the config. +#[derive(Debug)] +pub struct UpdateConfig { + pub config: Config, +} + +impl UpdateConfig { + pub fn new(config: Config) -> Self { + Self { config } + } +} + +impl Message for UpdateConfig { + type Reply = (); +} + +/// Gets the proposal. +#[derive(Debug)] +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option; +} + +/// Gets the installation issues. +pub struct GetIssues; + +impl Message for GetIssues { + type Reply = IssueMap; +} + +pub struct GetLicense { + pub id: String, + pub lang: LanguageTag, +} + +impl Message for GetLicense { + type Reply = Option; +} + +impl GetLicense { + pub fn new(id: String, lang: LanguageTag) -> Self { + Self { id, lang } + } +} + +/// Runs the given action. +#[derive(Debug)] +pub struct RunAction { + pub action: Action, +} + +impl RunAction { + pub fn new(action: Action) -> Self { + Self { action } + } +} + +impl Message for RunAction { + type Reply = (); +} + +// Gets the storage model. +pub struct GetStorageModel; + +impl Message for GetStorageModel { + type Reply = Option; +} + +// Sets the storage model. +pub struct SetStorageModel { + pub model: Value, +} + +impl SetStorageModel { + pub fn new(model: Value) -> Self { + Self { model } + } +} + +impl Message for SetStorageModel { + type Reply = (); +} + +#[derive(Clone)] +pub struct SolveStorageModel { + pub model: Value, +} + +impl SolveStorageModel { + pub fn new(model: Value) -> Self { + Self { model } + } +} + +impl Message for SolveStorageModel { + type Reply = Option; +} diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs new file mode 100644 index 0000000000..d63f926278 --- /dev/null +++ b/rust/agama-manager/src/service.rs @@ -0,0 +1,737 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{files, hardware, hostname, l10n, message, network, software, storage}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{ + self, event, + files::scripts::ScriptsGroup, + manager::{self, LicenseContent}, + status::Stage, + Action, Config, Event, Issue, IssueMap, Proposal, Scope, Status, SystemInfo, + }, + issue, licenses, + products::{self, ProductSpec}, + progress, question, +}; +use async_trait::async_trait; +use gettextrs::gettext; +use merge::Merge; +use network::NetworkSystemClient; +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::{broadcast, RwLock}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Missing product")] + MissingProduct, + #[error(transparent)] + Event(#[from] broadcast::error::SendError), + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + Hostname(#[from] hostname::service::Error), + #[error(transparent)] + L10n(#[from] l10n::service::Error), + #[error(transparent)] + Software(#[from] software::service::Error), + #[error(transparent)] + Storage(#[from] storage::service::Error), + #[error(transparent)] + Files(#[from] files::service::Error), + #[error(transparent)] + Issues(#[from] issue::service::Error), + #[error(transparent)] + Questions(#[from] question::service::Error), + #[error(transparent)] + Products(#[from] products::Error), + #[error(transparent)] + Licenses(#[from] licenses::Error), + #[error(transparent)] + Progress(#[from] progress::service::Error), + #[error(transparent)] + Network(#[from] network::error::Error), + // TODO: we could unify network errors when we refactor the network service to work like the + // rest. + #[error(transparent)] + NetworkSystem(#[from] network::NetworkSystemError), + #[error(transparent)] + Hardware(#[from] hardware::Error), + #[error("Cannot dispatch this action in {current} stage (expected {expected}).")] + UnexpectedStage { current: Stage, expected: Stage }, +} + +pub struct Starter { + questions: Handler, + events: event::Sender, + dbus: zbus::Connection, + hostname: Option>, + l10n: Option>, + network: Option, + software: Option>, + storage: Option>, + files: Option>, + issues: Option>, + progress: Option>, + hardware: Option, +} + +impl Starter { + pub fn new( + questions: Handler, + events: event::Sender, + dbus: zbus::Connection, + ) -> Self { + Self { + events, + dbus, + questions, + hostname: None, + l10n: None, + network: None, + software: None, + storage: None, + files: None, + issues: None, + progress: None, + hardware: None, + } + } + + pub fn with_hostname(mut self, hostname: Handler) -> Self { + self.hostname = Some(hostname); + self + } + pub fn with_network(mut self, network: NetworkSystemClient) -> Self { + self.network = Some(network); + self + } + + pub fn with_software(mut self, software: Handler) -> Self { + self.software = Some(software); + self + } + + pub fn with_storage(mut self, storage: Handler) -> Self { + self.storage = Some(storage); + self + } + + pub fn with_files(mut self, files: Handler) -> Self { + self.files = Some(files); + self + } + + pub fn with_l10n(mut self, l10n: Handler) -> Self { + self.l10n = Some(l10n); + self + } + + pub fn with_issues(mut self, issues: Handler) -> Self { + self.issues = Some(issues); + self + } + + pub fn with_progress(mut self, progress: Handler) -> Self { + self.progress = Some(progress); + self + } + + pub fn with_hardware(mut self, hardware: hardware::Registry) -> Self { + self.hardware = Some(hardware); + self + } + + /// Starts the service and returns a handler to communicate with it. + pub async fn start(self) -> Result, Error> { + let issues = match self.issues { + Some(issues) => issues, + None => issue::Service::starter(self.events.clone()).start(), + }; + + let progress = match self.progress { + Some(progress) => progress, + None => progress::Service::starter(self.events.clone()).start(), + }; + + let hostname = match self.hostname { + Some(hostname) => hostname, + None => { + hostname::Service::starter(self.events.clone(), issues.clone()) + .start() + .await? + } + }; + let l10n = match self.l10n { + Some(l10n) => l10n, + None => { + l10n::Service::starter(self.events.clone(), issues.clone()) + .start() + .await? + } + }; + + let software = match self.software { + Some(software) => software, + None => { + software::Service::starter( + self.events.clone(), + issues.clone(), + progress.clone(), + self.questions.clone(), + ) + .start() + .await? + } + }; + + let storage = match self.storage { + Some(storage) => storage, + None => { + storage::Service::starter( + self.events.clone(), + issues.clone(), + progress.clone(), + self.dbus.clone(), + ) + .start() + .await? + } + }; + + let files = match self.files { + Some(files) => files, + None => { + files::Service::starter(progress.clone(), self.questions.clone(), software.clone()) + .start() + .await? + } + }; + + let network = match self.network { + Some(network) => network, + None => network::start().await?, + }; + + let hardware = match self.hardware { + Some(hardware) => hardware, + None => hardware::Registry::new_from_system(), + }; + + let mut service = Service { + questions: self.questions, + progress, + issues, + hostname, + l10n, + network, + software, + storage, + files, + products: products::Registry::default(), + licenses: licenses::Registry::default(), + hardware, + config: Config::default(), + system: manager::SystemInfo::default(), + product: None, + }; + + service.setup().await?; + Ok(actor::spawn(service)) + } +} + +pub struct Service { + hostname: Handler, + l10n: Handler, + software: Handler, + network: NetworkSystemClient, + storage: Handler, + files: Handler, + issues: Handler, + progress: Handler, + questions: Handler, + products: products::Registry, + licenses: licenses::Registry, + hardware: hardware::Registry, + product: Option>>, + config: Config, + system: manager::SystemInfo, +} + +impl Service { + pub fn starter( + questions: Handler, + events: event::Sender, + dbus: zbus::Connection, + ) -> Starter { + Starter::new(questions, events, dbus) + } + + /// Set up the service by reading the registries, the hardware info and determining the default product. + /// + /// If a default product is set, it asks the other services to initialize their configurations. + pub async fn setup(&mut self) -> Result<(), Error> { + self.read_system_info().await?; + + if let Some(product) = self.products.default_product() { + let config = Config::with_product(product.id.clone()); + self.set_config(config).await?; + } else { + self.update_issues()?; + }; + + Ok(()) + } + + async fn read_system_info(&mut self) -> Result<(), Error> { + self.licenses.read()?; + self.products.read()?; + self.hardware.read().await?; + + self.system.licenses = self.licenses.licenses().into_iter().cloned().collect(); + self.system.products = self.products.products(); + self.system.hardware = self.hardware.to_hardware_info(); + + Ok(()) + } + + async fn set_config(&mut self, config: Config) -> Result<(), Error> { + self.set_product(&config)?; + + let Some(product) = &self.product else { + return Err(Error::MissingProduct); + }; + + self.hostname + .call(hostname::message::SetConfig::new(config.hostname.clone())) + .await?; + + self.files + .call(files::message::SetConfig::new(config.files.clone())) + .await?; + + self.files + .call(files::message::RunScripts::new(ScriptsGroup::Pre)) + .await?; + + self.questions + .call(question::message::SetConfig::new(config.questions.clone())) + .await?; + + self.software + .call(software::message::SetConfig::new( + Arc::clone(product), + config.software.clone(), + )) + .await?; + + self.l10n + .call(l10n::message::SetConfig::new(config.l10n.clone())) + .await?; + + self.storage + .call(storage::message::SetConfig::new( + Arc::clone(product), + config.storage.clone(), + )) + .await?; + + if let Some(network) = config.network.clone() { + self.network.update_config(network).await?; + self.network.apply().await?; + } + + self.config = config; + Ok(()) + } + + async fn configure_l10n(&self, config: api::l10n::SystemConfig) -> Result<(), Error> { + self.l10n + .call(l10n::message::SetSystem::new(config.clone())) + .await?; + if let Some(locale) = config.locale { + self.storage + .cast(storage::message::SetLocale::new(locale.as_str()))?; + } + Ok(()) + } + + async fn activate_storage(&self) -> Result<(), Error> { + self.storage.call(storage::message::Activate).await?; + Ok(()) + } + + async fn probe_storage(&self) -> Result<(), Error> { + self.storage.call(storage::message::Probe).await?; + Ok(()) + } + + fn set_product(&mut self, config: &Config) -> Result<(), Error> { + self.product = None; + self.update_product(config) + } + + fn update_product(&mut self, config: &Config) -> Result<(), Error> { + let product_id = config + .software + .as_ref() + .and_then(|s| s.product.as_ref()) + .and_then(|p| p.id.as_ref()); + + if let Some(id) = product_id { + if let Some(product_spec) = self.products.find(&id) { + let product = RwLock::new(product_spec.clone()); + self.product = Some(Arc::new(product)); + } else { + self.product = None; + tracing::warn!("Unknown product '{id}'"); + } + } + + self.update_issues()?; + Ok(()) + } + + fn update_issues(&self) -> Result<(), Error> { + if self.product.is_some() { + self.issues + .cast(issue::message::Clear::new(Scope::Manager))?; + } else { + let issue = Issue::new("no_product", "No product has been selected."); + self.issues + .cast(issue::message::Set::new(Scope::Manager, vec![issue]))?; + } + Ok(()) + } + + async fn check_stage(&self, expected: Stage) -> Result<(), Error> { + let current = self.progress.call(progress::message::GetStage).await?; + if current != expected { + return Err(Error::UnexpectedStage { expected, current }); + } + Ok(()) + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + /// It returns the status of the installation. + async fn handle(&mut self, message: progress::message::GetStatus) -> Result { + let status = self.progress.call(message).await?; + Ok(status) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It returns the information of the underlying system. + async fn handle(&mut self, _message: message::GetSystem) -> Result { + let hostname = self.hostname.call(hostname::message::GetSystem).await?; + let l10n = self.l10n.call(l10n::message::GetSystem).await?; + let manager = self.system.clone(); + let storage = self.storage.call(storage::message::GetSystem).await?; + let network = self.network.get_system().await?; + let software = self.software.call(software::message::GetSystem).await?; + Ok(SystemInfo { + hostname, + l10n, + manager, + network, + storage, + software, + }) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Gets the current configuration. + /// + /// It includes user and default values. + async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { + let hostname = self.hostname.call(hostname::message::GetConfig).await?; + let l10n = self.l10n.call(l10n::message::GetConfig).await?; + let software = self.software.call(software::message::GetConfig).await?; + let questions = self.questions.call(question::message::GetConfig).await?; + let network = self.network.get_config().await?; + let storage = self.storage.call(storage::message::GetConfig).await?; + + Ok(Config { + hostname: Some(hostname), + l10n: Some(l10n), + questions, + network: Some(network), + software: Some(software), + storage, + files: None, + }) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Gets the current configuration set by the user. + /// + /// It includes only the values that were set by the user. + async fn handle(&mut self, _message: message::GetConfig) -> Result { + Ok(self.config.clone()) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Sets the user configuration with the given values. + async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + self.check_stage(Stage::Configuring).await?; + self.set_config(message.config).await + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Patches the config. + /// + /// It merges the current config with the given one. If some scope is missing in the given + /// config, then it keeps the values from the current config. + async fn handle(&mut self, message: message::UpdateConfig) -> Result<(), Error> { + self.check_stage(Stage::Configuring).await?; + let mut new_config = message.config; + new_config.merge(self.config.clone()); + self.set_config(new_config).await + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It returns the current proposal, if any. + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + let hostname = self.hostname.call(hostname::message::GetProposal).await?; + let l10n = self.l10n.call(l10n::message::GetProposal).await?; + let software = self.software.call(software::message::GetProposal).await?; + let storage = self.storage.call(storage::message::GetProposal).await?; + let network = self.network.get_proposal().await?; + + Ok(Some(Proposal { + hostname, + l10n, + network, + software, + storage, + })) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It returns the current proposal, if any. + async fn handle(&mut self, _message: message::GetIssues) -> Result { + Ok(self.issues.call(issue::message::Get).await?) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + message: message::GetLicense, + ) -> Result, Error> { + Ok(self.licenses.find(&message.id, &message.lang)) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It runs the given action. + async fn handle(&mut self, message: message::RunAction) -> Result<(), Error> { + self.check_stage(Stage::Configuring).await?; + + match message.action { + Action::ConfigureL10n(config) => { + self.configure_l10n(config).await?; + } + Action::ActivateStorage => { + self.activate_storage().await?; + } + Action::ProbeStorage => { + self.probe_storage().await?; + } + Action::Install => { + let action = InstallAction { + hostname: self.hostname.clone(), + l10n: self.l10n.clone(), + network: self.network.clone(), + software: self.software.clone(), + storage: self.storage.clone(), + files: self.files.clone(), + progress: self.progress.clone(), + }; + action.run(); + } + } + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It returns the storage model. + async fn handle(&mut self, _message: message::GetStorageModel) -> Result, Error> { + Ok(self.storage.call(storage::message::GetConfigModel).await?) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It sets the storage model. + async fn handle(&mut self, message: message::SetStorageModel) -> Result<(), Error> { + self.check_stage(Stage::Configuring).await?; + Ok(self + .storage + .call(storage::message::SetConfigModel::new(message.model)) + .await?) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It solves the storage model. + async fn handle( + &mut self, + message: message::SolveStorageModel, + ) -> Result, Error> { + self.check_stage(Stage::Configuring).await?; + Ok(self + .storage + .call(storage::message::SolveConfigModel::new(message.model)) + .await?) + } +} + +// FIXME: write a macro to forward a message. +#[async_trait] +impl MessageHandler for Service { + /// It sets the software resolvables. + async fn handle(&mut self, message: software::message::SetResolvables) -> Result<(), Error> { + self.check_stage(Stage::Configuring).await?; + self.software.call(message).await?; + Ok(()) + } +} + +/// Implements the installation process. +/// +/// This action runs on a separate Tokio task to prevent the manager from blocking. +struct InstallAction { + hostname: Handler, + l10n: Handler, + network: NetworkSystemClient, + software: Handler, + storage: Handler, + files: Handler, + progress: Handler, +} + +impl InstallAction { + /// Runs the installation process on a separate Tokio task. + pub fn run(mut self) { + tokio::spawn(async move { + if let Err(error) = self.install().await { + tracing::error!("Installation failed: {error}"); + if let Err(error) = self + .progress + .call(progress::message::SetStage::new(Stage::Failed)) + .await + { + tracing::error!( + "It was not possible to set the stage to {}: {error}", + Stage::Failed + ); + } + } + }); + } + + async fn install(&mut self) -> Result<(), Error> { + // NOTE: consider a NextState message? + self.progress + .call(progress::message::SetStage::new(Stage::Installing)) + .await?; + + // + // Preparation + // + self.progress + .call(progress::message::StartWithSteps::new( + Scope::Manager, + vec![ + gettext("Prepare the system"), + gettext("Install software"), + gettext("Configure the system"), + ], + )) + .await?; + + self.storage.call(storage::message::Install).await?; + self.files + .call(files::message::RunScripts::new( + ScriptsGroup::PostPartitioning, + )) + .await?; + + // + // Installation + // + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.software.call(software::message::Install).await?; + + // + // Configuration + // + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.l10n.call(l10n::message::Install).await?; + self.software.call(software::message::Finish).await?; + self.files.call(files::message::WriteFiles).await?; + self.hostname.call(hostname::message::Install).await?; + self.storage.call(storage::message::Finish).await?; + + // + // Finish progress and changes + // + self.progress + .call(progress::message::Finish::new(Scope::Manager)) + .await?; + + self.progress + .call(progress::message::SetStage::new(Stage::Finished)) + .await?; + Ok(()) + } +} diff --git a/rust/agama-manager/src/test_utils.rs b/rust/agama-manager/src/test_utils.rs new file mode 100644 index 0000000000..d6d353e427 --- /dev/null +++ b/rust/agama-manager/src/test_utils.rs @@ -0,0 +1,55 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a set of utilities for tests. + +use std::path::PathBuf; + +use agama_hostname::test_utils::start_service as start_hostname_service; +use agama_l10n::test_utils::start_service as start_l10n_service; +use agama_network::test_utils::start_service as start_network_service; +use agama_software::test_utils::start_service as start_software_service; +use agama_storage::test_utils::start_service as start_storage_service; +use agama_utils::{actor::Handler, api::event, issue, progress, question}; + +use crate::{hardware, Service}; + +/// Starts a testing manager service. +pub async fn start_service(events: event::Sender, dbus: zbus::Connection) -> Handler { + let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + let issues = issue::Service::starter(events.clone()).start(); + let questions = question::start(events.clone()).await.unwrap(); + let progress = progress::Service::starter(events.clone()).start(); + + Service::starter(questions.clone(), events.clone(), dbus.clone()) + .with_hostname(start_hostname_service(events.clone(), issues.clone()).await) + .with_l10n(start_l10n_service(events.clone(), issues.clone()).await) + .with_storage( + start_storage_service(events.clone(), issues.clone(), progress.clone(), dbus).await, + ) + .with_software(start_software_service(events, issues, progress, questions).await) + .with_network(start_network_service().await) + .with_hardware(hardware::Registry::new_from_file( + fixtures.join("lshw.json"), + )) + .start() + .await + .expect("Could not spawn a testing manager service") +} diff --git a/rust/agama-network/src/action.rs b/rust/agama-network/src/action.rs index d1d18a83ba..4d4462f460 100644 --- a/rust/agama-network/src/action.rs +++ b/rust/agama-network/src/action.rs @@ -18,12 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::model::{AccessPoint, Connection, Device}; -use crate::types::{ConnectionState, DeviceType}; +use crate::model::{Connection, GeneralState}; +use crate::types::{AccessPoint, ConnectionState, Device, DeviceType, Proposal, SystemInfo}; +use agama_utils::api::network::Config; use tokio::sync::oneshot; use uuid::Uuid; -use super::{error::NetworkStateError, model::GeneralState, NetworkAdapterError}; +use super::{error::NetworkStateError, NetworkAdapterError}; pub type Responder = oneshot::Sender; pub type ControllerConnection = (Connection, Vec); @@ -42,6 +43,15 @@ pub enum Action { GetConnection(String, Responder>), /// Gets a connection by its Uuid GetConnectionByUuid(Uuid, Responder>), + /// Gets the internal state of the network configuration + GetConfig(Responder), + /// Gets the internal state of the network configuration proposal + GetProposal(Responder), + /// Updates the internal state of the network configuration applying the changes to the system + UpdateConfig(Box, Responder>), + /// Gets the current network system configuration containing connections, devices, access_points and + /// also the general state + GetSystem(Responder), /// Gets a connection GetConnections(Responder>), /// Gets a controller connection diff --git a/rust/agama-network/src/error.rs b/rust/agama-network/src/error.rs index 87a3498554..291f40317f 100644 --- a/rust/agama-network/src/error.rs +++ b/rust/agama-network/src/error.rs @@ -21,6 +21,16 @@ //! Error types. use thiserror::Error; +use crate::NetworkSystemError; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + NetworkStateError(#[from] NetworkStateError), + #[error(transparent)] + NetworkSystemError(#[from] NetworkSystemError), +} + /// Errors that are related to the network configuration. #[derive(Error, Debug)] pub enum NetworkStateError { diff --git a/rust/agama-network/src/lib.rs b/rust/agama-network/src/lib.rs index 01b992bc03..fbd0b6b4cd 100644 --- a/rust/agama-network/src/lib.rs +++ b/rust/agama-network/src/lib.rs @@ -27,7 +27,8 @@ pub mod adapter; pub mod error; pub mod model; mod nm; -pub mod settings; +pub mod start; +pub use start::start; mod system; pub mod types; @@ -36,3 +37,5 @@ pub use adapter::{Adapter, NetworkAdapterError}; pub use model::NetworkState; pub use nm::NetworkManagerAdapter; pub use system::{NetworkSystem, NetworkSystemClient, NetworkSystemError}; + +pub mod test_utils; diff --git a/rust/agama-network/src/model.rs b/rust/agama-network/src/model.rs index 30bf944fcd..061a814c8a 100644 --- a/rust/agama-network/src/model.rs +++ b/rust/agama-network/src/model.rs @@ -23,13 +23,9 @@ //! * This module contains the types that represent the network concepts. They are supposed to be //! agnostic from the real network service (e.g., NetworkManager). use crate::error::NetworkStateError; -use crate::settings::{ - BondSettings, BridgeSettings, IEEE8021XSettings, NetworkConnection, VlanSettings, - WirelessSettings, -}; -use crate::types::{BondMode, ConnectionState, DeviceState, DeviceType, Status, SSID}; +use crate::types::*; + use agama_utils::openapi::schemas; -use cidr::IpInet; use macaddr::MacAddr6; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, skip_serializing_none, DisplayFromStr}; @@ -37,12 +33,10 @@ use std::{ collections::HashMap, default::Default, fmt, - net::IpAddr, str::{self, FromStr}, }; use thiserror::Error; use uuid::Uuid; -use zbus::zvariant::Value; #[derive(PartialEq)] pub struct StateConfig { @@ -72,7 +66,8 @@ pub struct NetworkState { } impl NetworkState { - /// Returns a NetworkState struct with the given devices and connections. + /// Returns a NetworkState struct with the given general_state, access_points, devices + /// and connections. /// /// * `general_state`: General network configuration /// * `access_points`: Access points to include in the state. @@ -144,6 +139,7 @@ impl NetworkState { self.devices.iter_mut().find(|c| c.name == name) } + /// Returns the controller's connection for the givne connection Uuid. pub fn get_controlled_by(&mut self, uuid: Uuid) -> Vec<&Connection> { let uuid = Some(uuid); self.connections @@ -164,6 +160,58 @@ impl NetworkState { Ok(()) } + /// Updates the current [NetworkState] with the configuration provided. + /// + /// The config could contain a [NetworkConnectionsCollection] to be updated, in case of + /// provided it will iterate over the connections adding or updating them. + /// + /// If the general state is provided it will sets the options given. + pub fn update_state(&mut self, config: Config) -> Result<(), NetworkStateError> { + if let Some(connections) = config.connections { + let mut collection: ConnectionCollection = connections.clone().try_into()?; + for conn in collection.iter_mut() { + if let Some(current_conn) = self.get_connection(conn.id.as_str()) { + // Replaced the UUID with a real one + conn.uuid = current_conn.uuid; + self.update_connection(conn.to_owned())?; + } else { + self.add_connection(conn.to_owned())?; + } + } + + for conn in connections.0 { + if conn.bridge.is_some() | conn.bond.is_some() { + let mut ports = vec![]; + if let Some(model) = conn.bridge { + ports = model.ports; + } + if let Some(model) = conn.bond { + ports = model.ports; + } + + if let Some(controller) = self.get_connection(conn.id.as_str()) { + self.set_ports(&controller.clone(), ports)?; + } + } + } + } + + if let Some(state) = config.state { + if let Some(wireless_enabled) = state.wireless_enabled { + self.general_state.wireless_enabled = wireless_enabled; + } + + if let Some(networking_enabled) = state.networking_enabled { + self.general_state.networking_enabled = networking_enabled; + } + + if let Some(copy_network) = state.copy_network { + self.general_state.copy_network = copy_network; + } + } + Ok(()) + } + /// Updates a connection with a new one. /// /// It uses the `id` to decide which connection to update. @@ -260,57 +308,6 @@ mod tests { use crate::error::NetworkStateError; use uuid::Uuid; - #[test] - fn test_macaddress() { - let mut val: Option = None; - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Unset - )); - - val = Some(String::from("")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Unset - )); - - val = Some(String::from("preserve")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Preserve - )); - - val = Some(String::from("permanent")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Permanent - )); - - val = Some(String::from("random")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Random - )); - - val = Some(String::from("stable")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Stable - )); - - val = Some(String::from("This is not a MACAddr")); - assert!(matches!( - MacAddress::try_from(&val), - Err(InvalidMacAddress(_)) - )); - - val = Some(String::from("de:ad:be:ef:2b:ad")); - assert_eq!( - MacAddress::try_from(&val).unwrap().to_string(), - String::from("de:ad:be:ef:2b:ad").to_uppercase() - ); - } - #[test] fn test_add_connection() { let mut state = NetworkState::default(); @@ -461,9 +458,7 @@ mod tests { pub const NOT_COPY_NETWORK_PATH: &str = "/run/agama/not_copy_network"; /// Network state -#[serde_as] -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug, Default)] pub struct GeneralState { pub hostname: String, pub connectivity: bool, @@ -472,37 +467,6 @@ pub struct GeneralState { pub networking_enabled: bool, // pub network_state: NMSTATE } -/// Access Point -#[serde_as] -#[derive(Default, Debug, Clone, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AccessPoint { - #[serde_as(as = "DisplayFromStr")] - pub ssid: SSID, - pub hw_address: String, - pub strength: u8, - pub flags: u32, - pub rsn_flags: u32, - pub wpa_flags: u32, -} - -/// Network device -#[serde_as] -#[skip_serializing_none] -#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Device { - pub name: String, - #[serde(rename = "type")] - pub type_: DeviceType, - #[serde_as(as = "DisplayFromStr")] - pub mac_address: MacAddress, - pub ip_config: Option, - // Connection.id - pub connection: Option, - pub state: DeviceState, -} - /// Represents a known network connection. #[serde_as] #[skip_serializing_none] @@ -806,305 +770,6 @@ impl From for ConnectionConfig { } } -#[derive(Debug, Error)] -#[error("Invalid MAC address: {0}")] -pub struct InvalidMacAddress(String); - -#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] -pub enum MacAddress { - #[schema(value_type = String, format = "MAC address in EUI-48 format")] - MacAddress(macaddr::MacAddr6), - Preserve, - Permanent, - Random, - Stable, - #[default] - Unset, -} - -impl FromStr for MacAddress { - type Err = InvalidMacAddress; - - fn from_str(s: &str) -> Result { - match s { - "preserve" => Ok(Self::Preserve), - "permanent" => Ok(Self::Permanent), - "random" => Ok(Self::Random), - "stable" => Ok(Self::Stable), - "" => Ok(Self::Unset), - _ => Ok(Self::MacAddress(match macaddr::MacAddr6::from_str(s) { - Ok(mac) => mac, - Err(e) => return Err(InvalidMacAddress(e.to_string())), - })), - } - } -} - -impl TryFrom<&Option> for MacAddress { - type Error = InvalidMacAddress; - - fn try_from(value: &Option) -> Result { - match &value { - Some(str) => MacAddress::from_str(str), - None => Ok(Self::Unset), - } - } -} - -impl fmt::Display for MacAddress { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::MacAddress(mac) => mac.to_string(), - Self::Preserve => "preserve".to_string(), - Self::Permanent => "permanent".to_string(), - Self::Random => "random".to_string(), - Self::Stable => "stable".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - -impl From for zbus::fdo::Error { - fn from(value: InvalidMacAddress) -> Self { - zbus::fdo::Error::Failed(value.to_string()) - } -} - -#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum LinkLocal { - #[default] - Default = 0, - Auto = 1, - Disabled = 2, - Enabled = 3, - Fallback = 4, -} - -#[derive(Debug, Error)] -#[error("Invalid link-local value: {0}")] -pub struct InvalidLinkLocalValue(i32); - -impl TryFrom for LinkLocal { - type Error = InvalidLinkLocalValue; - - fn try_from(value: i32) -> Result { - match value { - 0 => Ok(LinkLocal::Default), - 1 => Ok(LinkLocal::Auto), - 2 => Ok(LinkLocal::Disabled), - 3 => Ok(LinkLocal::Enabled), - 4 => Ok(LinkLocal::Fallback), - _ => Err(InvalidLinkLocalValue(value)), - } - } -} - -#[skip_serializing_none] -#[derive(Default, Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct IpConfig { - pub method4: Ipv4Method, - pub method6: Ipv6Method, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[schema(schema_with = schemas::ip_inet_array)] - pub addresses: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[schema(schema_with = schemas::ip_addr_array)] - pub nameservers: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub dns_searchlist: Vec, - pub ignore_auto_dns: bool, - #[schema(schema_with = schemas::ip_addr)] - pub gateway4: Option, - #[schema(schema_with = schemas::ip_addr)] - pub gateway6: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub routes4: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub routes6: Vec, - pub dhcp4_settings: Option, - pub dhcp6_settings: Option, - pub ip6_privacy: Option, - pub dns_priority4: Option, - pub dns_priority6: Option, - pub link_local4: LinkLocal, -} - -#[skip_serializing_none] -#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -pub struct Dhcp4Settings { - pub send_hostname: Option, - pub hostname: Option, - pub send_release: Option, - pub client_id: DhcpClientId, - pub iaid: DhcpIaid, -} - -#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -pub enum DhcpClientId { - Id(String), - Mac, - PermMac, - Ipv6Duid, - Duid, - Stable, - None, - #[default] - Unset, -} - -impl From<&str> for DhcpClientId { - fn from(s: &str) -> Self { - match s { - "mac" => Self::Mac, - "perm-mac" => Self::PermMac, - "ipv6-duid" => Self::Ipv6Duid, - "duid" => Self::Duid, - "stable" => Self::Stable, - "none" => Self::None, - "" => Self::Unset, - _ => Self::Id(s.to_string()), - } - } -} - -impl From> for DhcpClientId { - fn from(value: Option) -> Self { - match &value { - Some(str) => Self::from(str.as_str()), - None => Self::Unset, - } - } -} - -impl fmt::Display for DhcpClientId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::Id(id) => id.to_string(), - Self::Mac => "mac".to_string(), - Self::PermMac => "perm-mac".to_string(), - Self::Ipv6Duid => "ipv6-duid".to_string(), - Self::Duid => "duid".to_string(), - Self::Stable => "stable".to_string(), - Self::None => "none".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - -#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -pub enum DhcpIaid { - Id(String), - Mac, - PermMac, - Ifname, - Stable, - #[default] - Unset, -} - -impl From<&str> for DhcpIaid { - fn from(s: &str) -> Self { - match s { - "mac" => Self::Mac, - "perm-mac" => Self::PermMac, - "ifname" => Self::Ifname, - "stable" => Self::Stable, - "" => Self::Unset, - _ => Self::Id(s.to_string()), - } - } -} - -impl From> for DhcpIaid { - fn from(value: Option) -> Self { - match value { - Some(str) => Self::from(str.as_str()), - None => Self::Unset, - } - } -} - -impl fmt::Display for DhcpIaid { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::Id(id) => id.to_string(), - Self::Mac => "mac".to_string(), - Self::PermMac => "perm-mac".to_string(), - Self::Ifname => "ifname".to_string(), - Self::Stable => "stable".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - -#[skip_serializing_none] -#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -pub struct Dhcp6Settings { - pub send_hostname: Option, - pub hostname: Option, - pub send_release: Option, - pub duid: DhcpDuid, - pub iaid: DhcpIaid, -} - -#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -pub enum DhcpDuid { - Id(String), - Lease, - Llt, - Ll, - StableLlt, - StableLl, - StableUuid, - #[default] - Unset, -} - -impl From<&str> for DhcpDuid { - fn from(s: &str) -> Self { - match s { - "lease" => Self::Lease, - "llt" => Self::Llt, - "ll" => Self::Ll, - "stable-llt" => Self::StableLlt, - "stable-ll" => Self::StableLl, - "stable-uuid" => Self::StableUuid, - "" => Self::Unset, - _ => Self::Id(s.to_string()), - } - } -} - -impl From> for DhcpDuid { - fn from(value: Option) -> Self { - match &value { - Some(str) => Self::from(str.as_str()), - None => Self::Unset, - } - } -} - -impl fmt::Display for DhcpDuid { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::Id(id) => id.to_string(), - Self::Lease => "lease".to_string(), - Self::Llt => "llt".to_string(), - Self::Ll => "ll".to_string(), - Self::StableLlt => "stable-llt".to_string(), - Self::StableLl => "stable-ll".to_string(), - Self::StableUuid => "stable-uuid".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - #[skip_serializing_none] #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct MatchConfig { @@ -1118,125 +783,6 @@ pub struct MatchConfig { pub kernel: Vec, } -#[derive(Debug, Error)] -#[error("Unknown IP configuration method name: {0}")] -pub struct UnknownIpMethod(String); - -#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum Ipv4Method { - Disabled = 0, - #[default] - Auto = 1, - Manual = 2, - LinkLocal = 3, -} - -impl fmt::Display for Ipv4Method { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - Ipv4Method::Disabled => "disabled", - Ipv4Method::Auto => "auto", - Ipv4Method::Manual => "manual", - Ipv4Method::LinkLocal => "link-local", - }; - write!(f, "{}", name) - } -} - -impl FromStr for Ipv4Method { - type Err = UnknownIpMethod; - - fn from_str(s: &str) -> Result { - match s { - "disabled" => Ok(Ipv4Method::Disabled), - "auto" => Ok(Ipv4Method::Auto), - "manual" => Ok(Ipv4Method::Manual), - "link-local" => Ok(Ipv4Method::LinkLocal), - _ => Err(UnknownIpMethod(s.to_string())), - } - } -} - -#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum Ipv6Method { - Disabled = 0, - #[default] - Auto = 1, - Manual = 2, - LinkLocal = 3, - Ignore = 4, - Dhcp = 5, -} - -impl fmt::Display for Ipv6Method { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - Ipv6Method::Disabled => "disabled", - Ipv6Method::Auto => "auto", - Ipv6Method::Manual => "manual", - Ipv6Method::LinkLocal => "link-local", - Ipv6Method::Ignore => "ignore", - Ipv6Method::Dhcp => "dhcp", - }; - write!(f, "{}", name) - } -} - -impl FromStr for Ipv6Method { - type Err = UnknownIpMethod; - - fn from_str(s: &str) -> Result { - match s { - "disabled" => Ok(Ipv6Method::Disabled), - "auto" => Ok(Ipv6Method::Auto), - "manual" => Ok(Ipv6Method::Manual), - "link-local" => Ok(Ipv6Method::LinkLocal), - "ignore" => Ok(Ipv6Method::Ignore), - "dhcp" => Ok(Ipv6Method::Dhcp), - _ => Err(UnknownIpMethod(s.to_string())), - } - } -} - -impl From for zbus::fdo::Error { - fn from(value: UnknownIpMethod) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(value.to_string()) - } -} - -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct IpRoute { - #[schema(schema_with = schemas::ip_inet_ref)] - pub destination: IpInet, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(schema_with = schemas::ip_addr)] - pub next_hop: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metric: Option, -} - -impl From<&IpRoute> for HashMap<&str, Value<'_>> { - fn from(route: &IpRoute) -> Self { - let mut map: HashMap<&str, Value> = HashMap::from([ - ("dest", Value::new(route.destination.address().to_string())), - ( - "prefix", - Value::new(route.destination.network_length() as u32), - ), - ]); - if let Some(next_hop) = route.next_hop { - map.insert("next-hop", Value::new(next_hop.to_string())); - } - if let Some(metric) = route.metric { - map.insert("metric", Value::new(metric)); - } - map - } -} - #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum VlanProtocol { #[default] @@ -1773,6 +1319,144 @@ pub struct BondConfig { pub options: BondOptions, } +#[derive(Clone, Debug, Default)] +pub struct ConnectionCollection(pub Vec); + +impl ConnectionCollection { + pub fn ports_for(&self, uuid: Uuid) -> Vec { + self.iter() + .filter(|c| c.controller == Some(uuid)) + .map(|c| c.interface.as_ref().unwrap_or(&c.id).clone()) + .collect() + } + + fn iter(&self) -> impl Iterator { + self.0.iter() + } + + fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut() + } +} + +impl TryFrom for NetworkConnectionsCollection { + type Error = NetworkStateError; + + fn try_from(collection: ConnectionCollection) -> Result { + let network_connections = collection + .iter() + .filter(|c| c.controller.is_none()) + .map(|c| { + let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); + if let Some(ref mut bond) = conn.bond { + bond.ports = collection.ports_for(c.uuid); + } + if let Some(ref mut bridge) = conn.bridge { + bridge.ports = collection.ports_for(c.uuid); + }; + conn + }) + .collect(); + + Ok(NetworkConnectionsCollection(network_connections)) + } +} + +impl TryFrom for ConnectionCollection { + type Error = NetworkStateError; + + fn try_from(collection: NetworkConnectionsCollection) -> Result { + let mut conns: Vec = vec![]; + let mut controller_ports: HashMap = HashMap::new(); + + for net_conn in &collection.0 { + let mut conn = Connection::try_from(net_conn.clone())?; + conn.uuid = Uuid::new_v4(); + let mut ports = vec![]; + if let Some(bridge) = &net_conn.bridge { + ports = bridge.ports.clone(); + } + if let Some(bond) = &net_conn.bond { + ports = bond.ports.clone(); + } + for port in &ports { + controller_ports.insert(port.to_string(), conn.uuid); + } + + conns.push(conn); + } + + for (port, uuid) in controller_ports { + let mut conn = conns + .iter() + .find(|c| c.id == port || c.interface.as_ref() == Some(&port)) + .cloned() + .unwrap_or_else(|| Connection::new(port, DeviceType::Ethernet)); + conn.controller = Some(uuid); + conns.push(conn); + } + + Ok(ConnectionCollection(conns)) + } +} + +impl TryFrom for StateSettings { + type Error = NetworkStateError; + + fn try_from(state: GeneralState) -> Result { + Ok(StateSettings { + connectivity: Some(state.connectivity), + copy_network: Some(state.copy_network), + wireless_enabled: Some(state.wireless_enabled), + networking_enabled: Some(state.networking_enabled), + }) + } +} + +impl TryFrom for Config { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = + ConnectionCollection(state.connections).try_into()?; + + Ok(Config { + connections: Some(connections), + state: Some(state.general_state.try_into()?), + }) + } +} + +impl TryFrom for SystemInfo { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = + ConnectionCollection(state.connections).try_into()?; + + Ok(SystemInfo { + access_points: state.access_points, + connections, + devices: state.devices, + state: state.general_state.try_into()?, + }) + } +} + +impl TryFrom for Proposal { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = + ConnectionCollection(state.connections).try_into()?; + + Ok(Proposal { + connections, + state: state.general_state.try_into()?, + }) + } +} + impl TryFrom for BondConfig { type Error = NetworkStateError; diff --git a/rust/agama-network/src/nm/builder.rs b/rust/agama-network/src/nm/builder.rs index 3f79fa659e..fa216c1dad 100644 --- a/rust/agama-network/src/nm/builder.rs +++ b/rust/agama-network/src/nm/builder.rs @@ -20,13 +20,12 @@ //! Conversion mechanism between proxies and model structs. -use crate::types::{DeviceState, DeviceType}; use crate::{ - model::{Device, IpConfig, IpRoute, MacAddress}, nm::{ model::NmDeviceType, proxies::{DeviceProxy, IP4ConfigProxy, IP6ConfigProxy}, }, + types::{Device, DeviceState, DeviceType, IpConfig, IpRoute, MacAddress}, }; use cidr::IpInet; use std::{collections::HashMap, net::IpAddr, str::FromStr}; diff --git a/rust/agama-network/src/nm/client.rs b/rust/agama-network/src/nm/client.rs index 27d4c4a32e..38678a4140 100644 --- a/rust/agama-network/src/nm/client.rs +++ b/rust/agama-network/src/nm/client.rs @@ -35,10 +35,9 @@ use super::proxies::{ SettingsProxy, WirelessProxy, }; use crate::model::{ - AccessPoint, Connection, ConnectionConfig, Device, GeneralState, SecurityProtocol, - NOT_COPY_NETWORK_PATH, + Connection, ConnectionConfig, GeneralState, SecurityProtocol, NOT_COPY_NETWORK_PATH, }; -use crate::types::{AddFlags, ConnectionFlags, DeviceType, UpdateFlags, SSID}; +use crate::types::{AccessPoint, AddFlags, ConnectionFlags, Device, DeviceType, UpdateFlags, SSID}; use agama_utils::dbus::get_optional_property; use semver::Version; use uuid::Uuid; @@ -159,6 +158,7 @@ impl<'a> NetworkManagerClient<'a> { .build() .await?; + let device = proxy.interface().await?; let ssid = SSID(wproxy.ssid().await?); let hw_address = wproxy.hw_address().await?; let strength = wproxy.strength().await?; @@ -167,6 +167,7 @@ impl<'a> NetworkManagerClient<'a> { let wpa_flags = wproxy.wpa_flags().await?; points.push(AccessPoint { + device, ssid, hw_address, strength, @@ -439,7 +440,7 @@ impl<'a> NetworkManagerClient<'a> { Ok(()) } - async fn get_connection_proxy(&self, uuid: Uuid) -> Result { + async fn get_connection_proxy(&self, uuid: Uuid) -> Result, NmError> { let proxy = SettingsProxy::new(&self.connection).await?; let uuid_s = uuid.to_string(); let path = proxy.get_connection_by_uuid(uuid_s.as_str()).await?; @@ -453,7 +454,7 @@ impl<'a> NetworkManagerClient<'a> { // Returns the DeviceProxy for the given device name // /// * `name`: Device name. - async fn get_device_proxy(&self, name: String) -> Result { + async fn get_device_proxy(&self, name: String) -> Result, NmError> { let mut device_path: Option = None; for path in &self.nm_proxy.get_all_devices().await? { let proxy = DeviceProxy::builder(&self.connection) diff --git a/rust/agama-network/src/nm/dbus.rs b/rust/agama-network/src/nm/dbus.rs index 7d80844c8d..b80e437001 100644 --- a/rust/agama-network/src/nm/dbus.rs +++ b/rust/agama-network/src/nm/dbus.rs @@ -24,7 +24,7 @@ //! with nested hash maps (see [NestedHash] and [OwnedNestedHash]). use super::{error::NmError, model::*}; use crate::model::*; -use crate::types::{BondMode, SSID}; +use crate::types::*; use agama_utils::dbus::{ get_optional_property, get_property, to_owned_hash, NestedHash, OwnedNestedHash, }; @@ -702,13 +702,13 @@ fn wireless_config_to_dbus(config: &'_ WirelessConfig) -> NestedHash<'_> { NestedHash::from([(WIRELESS_KEY, wireless), (WIRELESS_SECURITY_KEY, security)]) } -fn bond_config_to_dbus(config: &BondConfig) -> HashMap<&str, zvariant::Value> { +fn bond_config_to_dbus(config: &BondConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut options = config.options.0.clone(); options.insert("mode".to_string(), config.mode.to_string()); HashMap::from([("options", Value::new(options))]) } -fn bridge_config_to_dbus(bridge: &BridgeConfig) -> HashMap<&str, zvariant::Value> { +fn bridge_config_to_dbus(bridge: &BridgeConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut hash = HashMap::new(); if let Some(stp) = bridge.stp { @@ -748,7 +748,9 @@ fn bridge_config_from_dbus(conn: &OwnedNestedHash) -> Result HashMap<&str, zvariant::Value> { +fn bridge_port_config_to_dbus( + bridge_port: &BridgePortConfig, +) -> HashMap<&str, zvariant::Value<'_>> { let mut hash = HashMap::new(); if let Some(prio) = bridge_port.priority { @@ -774,7 +776,7 @@ fn bridge_port_config_from_dbus( })) } -fn infiniband_config_to_dbus(config: &InfinibandConfig) -> HashMap<&str, zvariant::Value> { +fn infiniband_config_to_dbus(config: &InfinibandConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut infiniband_config: HashMap<&str, zvariant::Value> = HashMap::from([ ( "transport-mode", @@ -810,7 +812,7 @@ fn infiniband_config_from_dbus( Ok(Some(config)) } -fn tun_config_to_dbus(config: &TunConfig) -> HashMap<&str, zvariant::Value> { +fn tun_config_to_dbus(config: &TunConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut tun_config: HashMap<&str, zvariant::Value> = HashMap::from([("mode", Value::new(config.mode.clone() as u32))]); @@ -842,7 +844,7 @@ fn tun_config_from_dbus(conn: &OwnedNestedHash) -> Result, NmE })) } -fn ovs_bridge_config_to_dbus(br: &OvsBridgeConfig) -> HashMap<&str, zvariant::Value> { +fn ovs_bridge_config_to_dbus(br: &OvsBridgeConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut br_config: HashMap<&str, zvariant::Value> = HashMap::new(); if let Some(mcast_snooping) = br.mcast_snooping_enable { @@ -872,7 +874,7 @@ fn ovs_bridge_from_dbus(conn: &OwnedNestedHash) -> Result HashMap<&str, zvariant::Value> { +fn ovs_port_config_to_dbus(config: &OvsPortConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut port_config: HashMap<&str, zvariant::Value> = HashMap::new(); if let Some(tag) = &config.tag { @@ -892,7 +894,7 @@ fn ovs_port_from_dbus(conn: &OwnedNestedHash) -> Result, N })) } -fn ovs_interface_config_to_dbus(config: &OvsInterfaceConfig) -> HashMap<&str, zvariant::Value> { +fn ovs_interface_config_to_dbus(config: &OvsInterfaceConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut ifc_config: HashMap<&str, zvariant::Value> = HashMap::new(); ifc_config.insert("type", config.interface_type.to_string().clone().into()); @@ -914,7 +916,7 @@ fn ovs_interface_from_dbus(conn: &OwnedNestedHash) -> Result HashMap<&str, zvariant::Value> { +fn match_config_to_dbus(match_config: &MatchConfig) -> HashMap<&str, zvariant::Value<'_>> { let drivers: Value = match_config.driver.to_vec().into(); let kernels: Value = match_config.kernel.to_vec().into(); @@ -1387,7 +1389,7 @@ fn bond_config_from_dbus(conn: &OwnedNestedHash) -> Result, N Ok(Some(bond)) } -fn vlan_config_to_dbus(cfg: &VlanConfig) -> NestedHash { +fn vlan_config_to_dbus(cfg: &VlanConfig) -> NestedHash<'_> { let vlan: HashMap<&str, zvariant::Value> = HashMap::from([ ("id", cfg.id.into()), ("parent", cfg.parent.clone().into()), @@ -1414,7 +1416,7 @@ fn vlan_config_from_dbus(conn: &OwnedNestedHash) -> Result, N })) } -fn ieee_8021x_config_to_dbus(config: &IEEE8021XConfig) -> HashMap<&str, zvariant::Value> { +fn ieee_8021x_config_to_dbus(config: &IEEE8021XConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut ieee_8021x_config: HashMap<&str, zvariant::Value> = HashMap::from([( "eap", config @@ -1586,7 +1588,6 @@ mod test { connection_from_dbus, connection_to_dbus, merge_dbus_connections, NestedHash, OwnedNestedHash, }; - use crate::types::{BondMode, SSID}; use crate::{ model::*, nm::{ @@ -1596,6 +1597,7 @@ mod test { }, error::NmError, }, + types::*, }; use cidr::IpInet; use macaddr::MacAddr6; diff --git a/rust/agama-network/src/nm/error.rs b/rust/agama-network/src/nm/error.rs index 6e90c7bd44..be85ef8a8a 100644 --- a/rust/agama-network/src/nm/error.rs +++ b/rust/agama-network/src/nm/error.rs @@ -69,7 +69,7 @@ pub enum NmError { #[error("Invalid infiniband transport mode: '{0}'")] InvalidInfinibandTranportMode(#[from] crate::model::InvalidInfinibandTransportMode), #[error("Invalid MAC address: '{0}'")] - InvalidMACAddress(#[from] crate::model::InvalidMacAddress), + InvalidMACAddress(#[from] crate::types::InvalidMacAddress), #[error("Invalid network prefix: '{0}'")] InvalidNetworkPrefix(#[from] NetworkLengthTooLongError), #[error("Invalid network address: '{0}'")] diff --git a/rust/agama-network/src/nm/model.rs b/rust/agama-network/src/nm/model.rs index 10a6719b2f..75b499e03a 100644 --- a/rust/agama-network/src/nm/model.rs +++ b/rust/agama-network/src/nm/model.rs @@ -27,9 +27,9 @@ /// Using the newtype pattern around an String is enough. For proper support, we might replace this /// struct with an enum. use crate::{ - model::{Ipv4Method, Ipv6Method, SecurityProtocol, WirelessMode}, + model::{SecurityProtocol, WirelessMode}, nm::error::NmError, - types::{ConnectionState, DeviceType}, + types::{ConnectionState, DeviceType, Ipv4Method, Ipv6Method}, }; use std::fmt; use std::str::FromStr; diff --git a/rust/agama-network/src/nm/watcher.rs b/rust/agama-network/src/nm/watcher.rs index 2f446848fc..141ca193e2 100644 --- a/rust/agama-network/src/nm/watcher.rs +++ b/rust/agama-network/src/nm/watcher.rs @@ -25,9 +25,8 @@ use std::collections::{hash_map::Entry, HashMap}; -use crate::{ - adapter::Watcher, model::Device, nm::proxies::DeviceProxy, Action, NetworkAdapterError, -}; +use crate::types::Device; +use crate::{adapter::Watcher, nm::proxies::DeviceProxy, Action, NetworkAdapterError}; use anyhow::anyhow; use async_trait::async_trait; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; @@ -359,14 +358,14 @@ impl<'a> ProxiesRegistry<'a> { pub fn remove_active_connection( &mut self, path: &OwnedObjectPath, - ) -> Option { + ) -> Option> { self.active_connections.remove(path) } /// Removes a device from the registry. /// /// * `path`: D-Bus object path. - pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy)> { + pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy<'_>)> { self.devices.remove(path) } diff --git a/rust/agama-network/src/start.rs b/rust/agama-network/src/start.rs new file mode 100644 index 0000000000..13b9cf1847 --- /dev/null +++ b/rust/agama-network/src/start.rs @@ -0,0 +1,28 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub use crate::error::Error; +use crate::{NetworkManagerAdapter, NetworkSystem, NetworkSystemClient}; + +pub async fn start() -> Result { + let system = NetworkSystem::::for_network_manager().await; + + Ok(system.start().await?) +} diff --git a/rust/agama-network/src/system.rs b/rust/agama-network/src/system.rs index 64a80cc623..a987457be6 100644 --- a/rust/agama-network/src/system.rs +++ b/rust/agama-network/src/system.rs @@ -21,11 +21,9 @@ use crate::{ action::Action, error::NetworkStateError, - model::{ - AccessPoint, Connection, Device, GeneralState, NetworkChange, NetworkState, StateConfig, - }, - types::DeviceType, - Adapter, NetworkAdapterError, + model::{Connection, GeneralState, NetworkChange, NetworkState, StateConfig}, + types::{AccessPoint, Config, Device, DeviceType, Proposal, SystemInfo}, + Adapter, NetworkAdapterError, NetworkManagerAdapter, }; use std::error::Error; use tokio::sync::{ @@ -87,6 +85,15 @@ impl NetworkSystem { Self { adapter } } + /// Returns a new instance of the network configuration system using the [NetworkManagerAdapter] for the system. + pub async fn for_network_manager() -> NetworkSystem> { + let adapter = NetworkManagerAdapter::from_system() + .await + .expect("Could not connect to NetworkManager"); + + NetworkSystem::new(adapter) + } + /// Starts the network configuration service and returns a client for communication purposes. /// /// This function starts the server (using [NetworkSystemServer]) on a separate @@ -164,6 +171,36 @@ impl NetworkSystemClient { Ok(rx.await?) } + /// Returns the cofiguration from the current network state as a [Config]. + pub async fn get_config(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetConfig(tx))?; + Ok(rx.await?) + } + + /// Returns the cofiguration from the current network state as a [Proposal]. + pub async fn get_proposal(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetProposal(tx))?; + Ok(rx.await?) + } + + /// Updates the current network state based on the configuration given. + pub async fn update_config(&self, config: Config) -> Result<(), NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions + .send(Action::UpdateConfig(Box::new(config.clone()), tx))?; + let result = rx.await?; + Ok(result?) + } + + /// Reads the current system network configuration returning it directly + pub async fn get_system(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetSystem(tx))?; + Ok(rx.await?) + } + /// Adds a new connection. pub async fn add_connection(&self, connection: Connection) -> Result<(), NetworkSystemError> { let (tx, rx) = oneshot::channel(); @@ -310,6 +347,23 @@ impl NetworkSystemServer { let conn = self.state.get_connection_by_uuid(uuid); tx.send(conn.cloned()).unwrap(); } + Action::GetSystem(tx) => { + let result = self.read().await?.try_into()?; + tx.send(result).unwrap(); + } + Action::GetConfig(tx) => { + let config: Config = self.state.clone().try_into()?; + tx.send(config).unwrap(); + } + Action::GetProposal(tx) => { + let config: Proposal = self.state.clone().try_into()?; + tx.send(config).unwrap(); + } + Action::UpdateConfig(config, tx) => { + let result = self.state.update_state(*config); + + tx.send(result).unwrap(); + } Action::GetConnections(tx) => { let connections = self .state @@ -424,6 +478,11 @@ impl NetworkSystemServer { Ok((conn, controlled)) } + /// Reads the system network configuration. + pub async fn read(&mut self) -> Result { + self.adapter.read(StateConfig::default()).await + } + /// Writes the network configuration. pub async fn write(&mut self) -> Result<(), NetworkAdapterError> { self.adapter.write(&self.state).await?; diff --git a/rust/agama-network/src/test_utils.rs b/rust/agama-network/src/test_utils.rs new file mode 100644 index 0000000000..365f01350c --- /dev/null +++ b/rust/agama-network/src/test_utils.rs @@ -0,0 +1,59 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a set of utilities for tests. + +use async_trait::async_trait; + +use crate::{ + adapter::Watcher, model::StateConfig, Adapter, NetworkAdapterError, NetworkState, + NetworkSystem, NetworkSystemClient, +}; + +/// Network adapter for tests. +/// +/// At this point, the adapter returns the default network state and does not write +/// any change. Additionally, it does not have an associated watcher. +pub struct TestAdapter; + +#[async_trait] +impl Adapter for TestAdapter { + async fn read(&self, _config: StateConfig) -> Result { + Ok(NetworkState::default()) + } + + async fn write(&self, _network: &NetworkState) -> Result<(), NetworkAdapterError> { + Ok(()) + } + + fn watcher(&self) -> Option> { + None + } +} + +/// Starts a testing network service. +pub async fn start_service() -> NetworkSystemClient { + let adapter = TestAdapter; + let system = NetworkSystem::new(adapter); + system + .start() + .await + .expect("Could not spawn a testing network service") +} diff --git a/rust/agama-network/src/types.rs b/rust/agama-network/src/types.rs index f063d63949..a1b78ad55a 100644 --- a/rust/agama-network/src/types.rs +++ b/rust/agama-network/src/types.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -18,171 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use cidr::errors::NetworkParseError; +pub use agama_utils::api::network::*; use serde::{Deserialize, Serialize}; -use std::{ - fmt, - str::{self, FromStr}, -}; +use std::str::{self}; use thiserror::Error; -use zbus; - -use super::settings::NetworkConnection; - -/// Network device -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub struct Device { - pub name: String, - pub type_: DeviceType, - pub state: DeviceState, -} - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct SSID(pub Vec); - -impl SSID { - pub fn to_vec(&self) -> &Vec { - &self.0 - } -} - -impl fmt::Display for SSID { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", str::from_utf8(&self.0).unwrap()) - } -} - -impl FromStr for SSID { - type Err = NetworkParseError; - - fn from_str(s: &str) -> Result { - Ok(SSID(s.as_bytes().into())) - } -} - -impl From for Vec { - fn from(value: SSID) -> Self { - value.0 - } -} - -#[derive(Default, Debug, PartialEq, Copy, Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum DeviceType { - Loopback = 0, - #[default] - Ethernet = 1, - Wireless = 2, - Dummy = 3, - Bond = 4, - Vlan = 5, - Bridge = 6, -} - -/// Network device state. -#[derive( - Default, - Serialize, - Deserialize, - Debug, - PartialEq, - Eq, - Clone, - Copy, - strum::Display, - strum::EnumString, - utoipa::ToSchema, -)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum DeviceState { - #[default] - /// The device's state is unknown. - Unknown, - /// The device is recognized but not managed by Agama. - Unmanaged, - /// The device is detected but it cannot be used (wireless switched off, missing firmware, etc.). - Unavailable, - /// The device is connecting to the network. - Connecting, - /// The device is successfully connected to the network. - Connected, - /// The device is disconnecting from the network. - Disconnecting, - /// The device is disconnected from the network. - Disconnected, - /// The device failed to connect to a network. - Failed, -} - -#[derive( - Default, - Serialize, - Deserialize, - Debug, - PartialEq, - Eq, - Clone, - Copy, - strum::Display, - strum::EnumString, - utoipa::ToSchema, -)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum ConnectionState { - /// The connection is getting activated. - Activating, - /// The connection is activated. - Activated, - /// The connection is getting deactivated. - Deactivating, - #[default] - /// The connection is deactivated. - Deactivated, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum Status { - #[default] - Up, - Down, - Removed, - // Workaound for not modify the connection status - Keep, -} - -impl fmt::Display for Status { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - Status::Up => "up", - Status::Down => "down", - Status::Keep => "keep", - Status::Removed => "removed", - }; - write!(f, "{}", name) - } -} - -#[derive(Debug, Error, PartialEq)] -#[error("Invalid status: {0}")] -pub struct InvalidStatus(String); - -impl TryFrom<&str> for Status { - type Error = InvalidStatus; - - fn try_from(value: &str) -> Result { - match value { - "up" => Ok(Status::Up), - "down" => Ok(Status::Down), - "keep" => Ok(Status::Keep), - "removed" => Ok(Status::Removed), - _ => Err(InvalidStatus(value.to_string())), - } - } -} // https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMSettingsConnectionFlags #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] @@ -232,159 +71,3 @@ pub enum UpdateFlags { BlockAutoconnect = 0x20, NoReapply = 0x40, } - -/// Bond mode -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] -pub enum BondMode { - #[serde(rename = "balance-rr")] - RoundRobin = 0, - #[serde(rename = "active-backup")] - ActiveBackup = 1, - #[serde(rename = "balance-xor")] - BalanceXOR = 2, - #[serde(rename = "broadcast")] - Broadcast = 3, - #[serde(rename = "802.3ad")] - LACP = 4, - #[serde(rename = "balance-tlb")] - BalanceTLB = 5, - #[serde(rename = "balance-alb")] - BalanceALB = 6, -} -impl Default for BondMode { - fn default() -> Self { - Self::RoundRobin - } -} - -impl std::fmt::Display for BondMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - BondMode::RoundRobin => "balance-rr", - BondMode::ActiveBackup => "active-backup", - BondMode::BalanceXOR => "balance-xor", - BondMode::Broadcast => "broadcast", - BondMode::LACP => "802.3ad", - BondMode::BalanceTLB => "balance-tlb", - BondMode::BalanceALB => "balance-alb", - } - ) - } -} - -#[derive(Debug, Error, PartialEq)] -#[error("Invalid bond mode: {0}")] -pub struct InvalidBondMode(String); - -impl TryFrom<&str> for BondMode { - type Error = InvalidBondMode; - - fn try_from(value: &str) -> Result { - match value { - "balance-rr" => Ok(BondMode::RoundRobin), - "active-backup" => Ok(BondMode::ActiveBackup), - "balance-xor" => Ok(BondMode::BalanceXOR), - "broadcast" => Ok(BondMode::Broadcast), - "802.3ad" => Ok(BondMode::LACP), - "balance-tlb" => Ok(BondMode::BalanceTLB), - "balance-alb" => Ok(BondMode::BalanceALB), - _ => Err(InvalidBondMode(value.to_string())), - } - } -} -impl TryFrom for BondMode { - type Error = InvalidBondMode; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(BondMode::RoundRobin), - 1 => Ok(BondMode::ActiveBackup), - 2 => Ok(BondMode::BalanceXOR), - 3 => Ok(BondMode::Broadcast), - 4 => Ok(BondMode::LACP), - 5 => Ok(BondMode::BalanceTLB), - 6 => Ok(BondMode::BalanceALB), - _ => Err(InvalidBondMode(value.to_string())), - } - } -} - -impl From for zbus::fdo::Error { - fn from(value: InvalidBondMode) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(format!("Network error: {value}")) - } -} - -#[derive(Debug, Error, PartialEq)] -#[error("Invalid device type: {0}")] -pub struct InvalidDeviceType(u8); - -impl TryFrom for DeviceType { - type Error = InvalidDeviceType; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(DeviceType::Loopback), - 1 => Ok(DeviceType::Ethernet), - 2 => Ok(DeviceType::Wireless), - 3 => Ok(DeviceType::Dummy), - 4 => Ok(DeviceType::Bond), - 5 => Ok(DeviceType::Vlan), - 6 => Ok(DeviceType::Bridge), - _ => Err(InvalidDeviceType(value)), - } - } -} - -impl From for zbus::fdo::Error { - fn from(value: InvalidDeviceType) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(format!("Network error: {value}")) - } -} - -// FIXME: found a better place for the HTTP types. -// -// TODO: If the client ignores the additional "state" field, this struct -// does not need to be here. -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct NetworkConnectionWithState { - #[serde(flatten)] - pub connection: NetworkConnection, - pub state: ConnectionState, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_display_ssid() { - let ssid = SSID(vec![97, 103, 97, 109, 97]); - assert_eq!(format!("{}", ssid), "agama"); - } - - #[test] - fn test_ssid_to_vec() { - let vec = vec![97, 103, 97, 109, 97]; - let ssid = SSID(vec.clone()); - assert_eq!(ssid.to_vec(), &vec); - } - - #[test] - fn test_device_type_from_u8() { - let dtype = DeviceType::try_from(0); - assert_eq!(dtype, Ok(DeviceType::Loopback)); - - let dtype = DeviceType::try_from(128); - assert_eq!(dtype, Err(InvalidDeviceType(128))); - } - - #[test] - fn test_display_bond_mode() { - let mode = BondMode::try_from(1).unwrap(); - assert_eq!(format!("{}", mode), "active-backup"); - } -} diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index cde9f6c8a6..a7bb9db837 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -8,19 +8,24 @@ rust-version.workspace = true [dependencies] anyhow = "1.0" -agama-locale-data = { path = "../agama-locale-data" } agama-lib = { path = "../agama-lib" } agama-utils = { path = "../agama-utils" } +agama-l10n = { path = "../agama-l10n" } +agama-locale-data = { path = "../agama-locale-data" } +agama-manager = { path = "../agama-manager" } +agama-network = { path = "../agama-network" } +agama-software = { path = "../agama-software" } +agama-transfer = { path = "../agama-transfer" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "2.0.12" serde = { version = "1.0.210", features = ["derive"] } -tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1.16" gettext-rs = { version = "0.7.1", features = ["gettext-system"] } regex = "1.11.0" async-trait = "0.1.83" -axum = { version = "0.7.7", features = ["ws"] } +axum = { version = "0.7.7", features = ["ws", "macros"] } serde_json = "1.0.128" tower-http = { version = "0.6.2", features = [ "compression-br", @@ -54,12 +59,12 @@ libsystemd = "0.7.0" subprocess = "0.2.9" gethostname = "1.0.0" tokio-util = "0.7.12" +zypp-agama = { path = "../zypp-agama" } +glob = "0.3.1" tempfile = "3.13.0" url = "2.5.2" - -[[bin]] -name = "agama-dbus-server" -path = "src/agama-dbus-server.rs" +serde_yaml = "0.9.34" +strum = { version = "0.27.2", features = ["derive"] } [[bin]] name = "agama-web-server" @@ -67,7 +72,12 @@ path = "src/agama-web-server.rs" [dev-dependencies] http-body-util = "0.1.2" +test-context = "0.4.1" tokio-test = "0.4.4" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ci)'] } + +# here we force runtime for bindgen otherwise pam-sys fails +[build-dependencies] +bindgen = { version = "0.69", features = ["runtime"] } diff --git a/rust/agama-server/src/agama-dbus-server.rs b/rust/agama-server/src/agama-dbus-server.rs deleted file mode 100644 index 8cb57f4b47..0000000000 --- a/rust/agama-server/src/agama-dbus-server.rs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_server::{ - l10n::{self, helpers}, - logs::init_logging, - questions, -}; - -use agama_lib::connection_to; -use anyhow::Context; -use std::future::pending; - -const ADDRESS: &str = "unix:path=/run/agama/bus"; -const SERVICE_NAME: &str = "org.opensuse.Agama1"; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let locale = helpers::init_locale()?; - init_logging().context("Could not initialize the logger")?; - - let connection = connection_to(ADDRESS) - .await - .expect("Could not connect to the D-Bus daemon"); - - // When adding more services here, the order might be important. - questions::export_dbus_objects(&connection).await?; - tracing::info!("Started questions interface"); - - connection - .request_name(SERVICE_NAME) - .await - .context(format!("Requesting name {SERVICE_NAME}"))?; - - // Do other things or go to wait forever - pending::<()>().await; - - Ok(()) -} diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index d64d3c3ff8..fd149ba0eb 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -24,13 +24,14 @@ use std::{ process::{ExitCode, Termination}, }; +use agama_l10n::helpers as l10n_helpers; use agama_lib::{auth::AuthToken, connection_to}; use agama_server::{ cert::Certificate, - l10n::helpers, logs::init_logging, - web::{self, run_monitor}, + web::{self}, }; +use agama_utils::api::event::Receiver; use anyhow::Context; use axum::{ extract::Request as AxumRequest, @@ -316,11 +317,11 @@ async fn start_server(address: String, service: Router, ssl_acceptor: SslAccepto /// Start serving the API. /// `options`: command-line arguments. async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { - _ = helpers::init_locale(); + _ = l10n_helpers::init_locale(); init_logging().context("Could not initialize the logger")?; - let (tx, _) = channel(16); - run_monitor(tx.clone()).await?; + let (events_tx, events_rx) = channel(16); + monitor_events_channel(events_rx); let config = web::ServiceConfig::load()?; @@ -331,7 +332,7 @@ async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { .web_ui_dir .clone() .unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_UI_DIR)); - let service = web::service(config, tx, dbus, web_ui_dir).await?; + let service = web::service(config, events_tx, dbus, web_ui_dir).await?; // TODO: Move elsewhere? Use a singleton? (It would be nice to use the same // generated self-signed certificate on both ports.) let ssl_acceptor = if let Ok(ssl_acceptor) = ssl_acceptor(&args.to_certificate()?) { @@ -379,6 +380,18 @@ fn write_token(path: &str, secret: &str) -> anyhow::Result<()> { Ok(token.write(path)?) } +// Keep the receiver running to avoid the channel being closed. +fn monitor_events_channel(mut events_rx: Receiver) { + tokio::spawn(async move { + loop { + if let Err(error) = events_rx.recv().await { + eprintln!("Error receiving events: {error}"); + break; + } + } + }); +} + /// Represents the result of execution. pub enum CliResult { /// Successful execution. diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index b632342b62..27d0df6f53 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::{error::ServiceError, questions::QuestionsError}; +use agama_lib::error::ServiceError; use axum::{ http::StatusCode, response::{IntoResponse, Response}, @@ -26,11 +26,7 @@ use axum::{ }; use serde_json::json; -use crate::{ - l10n::LocaleError, - users::password::PasswordCheckerError, - web::common::{IssuesServiceError, ProgressServiceError}, -}; +use crate::users::password::PasswordCheckerError; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -40,14 +36,6 @@ pub enum Error { Anyhow(String), #[error("Agama service error: {0}")] Service(#[from] ServiceError), - #[error("Questions service error: {0}")] - Questions(QuestionsError), - #[error("Software service error: {0}")] - Locale(#[from] LocaleError), - #[error("Issues service error: {0}")] - Issues(#[from] IssuesServiceError), - #[error("Progress service error: {0}")] - Progress(#[from] ProgressServiceError), #[error("Could not check the password")] PasswordCheck(#[from] PasswordCheckerError), } diff --git a/rust/agama-server/src/files/web.rs b/rust/agama-server/src/files/web.rs deleted file mode 100644 index 5b56efcb32..0000000000 --- a/rust/agama-server/src/files/web.rs +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the files deployment. -//! -//! The module offers one public function: -//! -//! * `files_service` which returns the Axum service. -//! -//! stream is not needed, as we do not need to emit signals (for NOW). - -use std::sync::Arc; - -use agama_lib::{ - error::ServiceError, - files::{error::FileError, model::UserFile, settings::FilesConfig}, -}; -use axum::{ - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{post, put}, - Json, Router, -}; -use serde_json::json; -use tokio::sync::RwLock; - -use thiserror::Error; - -#[derive(Error, Debug)] -#[error("Files error: {0}")] -struct FilesServiceError(#[from] FileError); - -impl IntoResponse for FilesServiceError { - fn into_response(self) -> Response { - // TODO: is there better way to hook any error response to be logged with body? - tracing::warn!("Server return error {}", self); - let body = json!({ - "error": self.to_string() - }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() - } -} - -#[derive(Clone, Default, Debug)] -struct FilesState { - files: Arc>, -} - -/// Sets up and returns the axum service for the files module. -pub async fn files_service() -> Result { - let state = FilesState::default(); - let router = Router::new() - .route("/", put(set_config).get(get_config)) - .route("/write", post(write_config)) - .with_state(state); - Ok(router) -} - -/// Returns the bootloader configuration. -/// -/// * `state` : service state. -#[utoipa::path( - get, - path = "/", - context_path = "/api/files", - responses( - (status = 200, description = "files configuration"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_config( - State(state): State, -) -> Result>, FilesServiceError> { - // StorageSettings is just a wrapper over serde_json::value::RawValue - let settings = state.files.read().await; - Ok(Json(settings.files.to_vec())) -} - -/// Sets the files configuration. -/// -/// * `state`: service state. -/// * `config`: files configuration. -#[utoipa::path( - put, - path = "/", - context_path = "/api/files", - responses( - (status = 200, description = "Set the files configuration"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn set_config( - State(state): State, - Json(settings): Json>, -) -> Result, FilesServiceError> { - let mut files = state.files.write().await; - files.files = settings; - Ok(Json(())) -} - -/// Writes the files. -/// -/// * `state`: service state. -#[utoipa::path( - put, - path = "/write", - context_path = "/api/files", - responses( - (status = 200, description = "Writes the files"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn write_config(State(state): State) -> Result, FilesServiceError> { - let files = state.files.read().await; - for file in files.files.iter() { - file.write().await?; - } - Ok(Json(())) -} diff --git a/rust/agama-server/src/hostname/web.rs b/rust/agama-server/src/hostname/web.rs deleted file mode 100644 index 94b7e0c526..0000000000 --- a/rust/agama-server/src/hostname/web.rs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the hostname service. -//! -//! The module offers one public function: -//! -//! * `hostname_service` which returns the Axum service. -//! -//! stream is not needed, as we do not need to emit signals (for NOW). - -use agama_lib::{ - error::ServiceError, - hostname::{client::HostnameClient, model::HostnameSettings}, -}; -use axum::{extract::State, routing::put, Json, Router}; - -use crate::error; - -#[derive(Clone)] -struct HostnameState<'a> { - client: HostnameClient<'a>, -} - -/// Sets up and returns the axum service for the hostname module. -pub async fn hostname_service() -> Result { - let client = HostnameClient::new().await?; - let state = HostnameState { client }; - let router = Router::new() - .route("/config", put(set_config).get(get_config)) - .with_state(state); - Ok(router) -} - -/// Returns the hostname configuration. -/// -/// * `state` : service state. -#[utoipa::path( - get, - path = "/config", - context_path = "/api/hostname", - operation_id = "get_hostname_config", - responses( - (status = 200, description = "hostname configuration", body = HostnameSettings), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_config( - State(state): State>, -) -> Result, error::Error> { - // HostnameSettings is just a wrapper over serde_json::value::RawValue - let settings = state.client.get_config().await?; - Ok(Json(settings)) -} - -/// Sets the hostname configuration. -/// -/// * `state`: service state. -/// * `config`: hostname configuration. -#[utoipa::path( - put, - path = "/config", - context_path = "/api/hostname", - operation_id = "set_hostname_config", - responses( - (status = 200, description = "Set the hostname configuration"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn set_config( - State(state): State>, - Json(settings): Json, -) -> Result, error::Error> { - state.client.set_config(&settings).await?; - Ok(Json(())) -} diff --git a/rust/agama-server/src/l10n.rs b/rust/agama-server/src/l10n.rs deleted file mode 100644 index 401062f3fa..0000000000 --- a/rust/agama-server/src/l10n.rs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub mod error; -pub mod helpers; -mod model; -pub mod web; - -pub use agama_lib::localization::model::LocaleConfig; -pub use error::LocaleError; -pub use model::{Keymap, L10n, LocaleEntry, TimezoneEntry}; diff --git a/rust/agama-server/src/l10n/error.rs b/rust/agama-server/src/l10n/error.rs deleted file mode 100644 index deb350dc08..0000000000 --- a/rust/agama-server/src/l10n/error.rs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_locale_data::{InvalidKeymap, InvalidLocaleCode, KeymapId, LocaleId}; - -#[derive(thiserror::Error, Debug)] -pub enum LocaleError { - #[error("Unknown locale code: {0}")] - UnknownLocale(LocaleId), - #[error("Invalid locale: {0}")] - InvalidLocale(#[from] InvalidLocaleCode), - #[error("Unknown timezone: {0}")] - UnknownTimezone(String), - #[error("Unknown keymap: {0}")] - UnknownKeymap(KeymapId), - #[error("Invalid keymap: {0}")] - InvalidKeymap(#[from] InvalidKeymap), - #[error("Could not apply the l10n settings: {0}")] - Commit(#[from] std::io::Error), -} diff --git a/rust/agama-server/src/l10n/model.rs b/rust/agama-server/src/l10n/model.rs deleted file mode 100644 index a55f20d163..0000000000 --- a/rust/agama-server/src/l10n/model.rs +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::fs::OpenOptions; -use std::io::Write; -use std::process::Command; - -use crate::error::Error; -use agama_locale_data::InvalidLocaleCode; -use agama_locale_data::{KeymapId, LocaleId}; -use regex::Regex; - -pub mod keyboard; -pub mod locale; -pub mod timezone; - -pub use keyboard::Keymap; -pub use locale::LocaleEntry; -pub use timezone::TimezoneEntry; - -use super::{helpers, LocaleError}; -use keyboard::KeymapsDatabase; -use locale::LocalesDatabase; -use timezone::TimezonesDatabase; - -pub struct L10n { - pub timezone: String, - pub timezones_db: TimezonesDatabase, - pub locales: Vec, - pub locales_db: LocalesDatabase, - pub keymap: KeymapId, - pub keymaps_db: KeymapsDatabase, - pub ui_locale: LocaleId, - pub ui_keymap: KeymapId, -} - -impl L10n { - pub fn new_with_locale(ui_locale: &LocaleId) -> Result { - const DEFAULT_TIMEZONE: &str = "Europe/Berlin"; - - let locale = ui_locale.to_string(); - let mut locales_db = LocalesDatabase::new(); - locales_db.read(&locale)?; - - let mut default_locale = ui_locale.clone(); - if !locales_db.exists(ui_locale) { - // TODO: handle the case where the database is empty (not expected!) - default_locale = locales_db.entries().first().unwrap().id.clone(); - }; - - let mut timezones_db = TimezonesDatabase::new(); - timezones_db.read(&ui_locale.language)?; - - let mut default_timezone = DEFAULT_TIMEZONE.to_string(); - if !timezones_db.exists(&default_timezone) { - default_timezone = timezones_db.entries().first().unwrap().code.to_string(); - }; - - let mut keymaps_db = KeymapsDatabase::new(); - keymaps_db.read()?; - - let locale = Self { - keymap: "us".parse().unwrap(), - timezone: default_timezone, - locales: vec![default_locale], - locales_db, - timezones_db, - keymaps_db, - ui_locale: ui_locale.clone(), - ui_keymap: Self::ui_keymap()?, - }; - - Ok(locale) - } - - pub fn set_locales(&mut self, locales: &Vec) -> Result<(), LocaleError> { - let locale_ids: Result, InvalidLocaleCode> = locales - .iter() - .cloned() - .map(|l| l.as_str().try_into()) - .collect(); - let locale_ids = locale_ids?; - - for loc in &locale_ids { - if !self.locales_db.exists(loc) { - return Err(LocaleError::UnknownLocale(loc.clone())); - } - } - - self.locales = locale_ids; - Ok(()) - } - - pub fn set_timezone(&mut self, timezone: &str) -> Result<(), LocaleError> { - // TODO: modify exists() to receive an `&str` - if !self.timezones_db.exists(&timezone.to_string()) { - return Err(LocaleError::UnknownTimezone(timezone.to_string()))?; - } - timezone.clone_into(&mut self.timezone); - Ok(()) - } - - pub fn set_keymap(&mut self, keymap_id: KeymapId) -> Result<(), LocaleError> { - if !self.keymaps_db.exists(&keymap_id) { - return Err(LocaleError::UnknownKeymap(keymap_id)); - } - - self.keymap = keymap_id; - Ok(()) - } - - // TODO: use LocaleError - pub fn translate(&mut self, locale: &LocaleId) -> Result<(), Error> { - helpers::set_service_locale(locale); - self.timezones_db.read(&locale.language)?; - self.locales_db.read(&locale.language)?; - self.ui_locale = locale.clone(); - Ok(()) - } - - // TODO: use LocaleError - pub fn set_ui_keymap(&mut self, keymap_id: KeymapId) -> Result<(), LocaleError> { - if !self.keymaps_db.exists(&keymap_id) { - return Err(LocaleError::UnknownKeymap(keymap_id)); - } - - self.ui_keymap = keymap_id; - - Command::new("localectl") - .args(["set-keymap", &self.ui_keymap.dashed()]) - .output() - .map_err(LocaleError::Commit)?; - Ok(()) - } - - // TODO: what should be returned value for commit? - pub fn commit(&self) -> Result<(), LocaleError> { - const ROOT: &str = "/mnt"; - const VCONSOLE_CONF: &str = "/etc/vconsole.conf"; - - let locale = self.locales.first().cloned().unwrap_or_default(); - let mut cmd = Command::new("/usr/bin/systemd-firstboot"); - cmd.args([ - "--root", - ROOT, - "--force", - "--locale", - &locale.to_string(), - "--keymap", - &self.keymap.dashed(), - "--timezone", - &self.timezone, - ]); - tracing::info!("{:?}", &cmd); - - let output = cmd.output()?; - tracing::info!("{:?}", &output); - - // unfortunately the console font cannot be set via the "systemd-firstboot" tool, - // we need to write it directly to the config file - if let Some(entry) = self.locales_db.find_locale(&locale) { - if let Some(font) = &entry.consolefont { - // the font entry is missing in a file created by "systemd-firstboot", just append it at the end - let mut file = OpenOptions::new() - .append(true) - .open(format!("{}{}", ROOT, VCONSOLE_CONF))?; - - tracing::info!("Configuring console font \"{:?}\"", font); - writeln!(file, "\nFONT={}.psfu", font)?; - } - } - - Ok(()) - } - - fn ui_keymap() -> Result { - let output = Command::new("localectl") - .output() - .map_err(LocaleError::Commit)?; - let output = String::from_utf8_lossy(&output.stdout); - - let keymap_regexp = Regex::new(r"(?m)VC Keymap: (.+)$").unwrap(); - let captures = keymap_regexp.captures(&output); - let keymap = captures - .and_then(|c| c.get(1).map(|e| e.as_str())) - .unwrap_or("us") - .to_string(); - - let keymap_id: KeymapId = keymap.parse().unwrap_or(KeymapId::default()); - Ok(keymap_id) - } -} diff --git a/rust/agama-server/src/l10n/web.rs b/rust/agama-server/src/l10n/web.rs deleted file mode 100644 index 9a430df27d..0000000000 --- a/rust/agama-server/src/l10n/web.rs +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the localization module. - -use super::{ - error::LocaleError, - model::{keyboard::Keymap, locale::LocaleEntry, timezone::TimezoneEntry, L10n}, -}; -use crate::{error::Error, web::EventsSender}; -use agama_lib::{ - auth::ClientId, error::ServiceError, event, localization::model::LocaleConfig, - proxies::LocaleMixinProxy as ManagerLocaleProxy, -}; -use agama_locale_data::LocaleId; -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - routing::{get, patch, post}, - Extension, Json, Router, -}; -use std::sync::Arc; -use tokio::sync::RwLock; - -#[derive(Clone)] -struct LocaleState<'a> { - locale: Arc>, - manager_proxy: ManagerLocaleProxy<'a>, - events: EventsSender, -} - -/// Sets up and returns the axum service for the localization module. -/// -/// * `events`: channel to send the events to the main service. -pub async fn l10n_service( - dbus: zbus::Connection, - events: EventsSender, -) -> Result { - let id = LocaleId::default(); - let locale = L10n::new_with_locale(&id).unwrap(); - let manager_proxy = ManagerLocaleProxy::new(&dbus).await?; - let state = LocaleState { - locale: Arc::new(RwLock::new(locale)), - manager_proxy, - events, - }; - - let router = Router::new() - .route("/keymaps", get(keymaps)) - .route("/locales", get(locales)) - .route("/timezones", get(timezones)) - .route("/config", patch(set_config).get(get_config)) - .route("/finish", post(finish)) - .with_state(state); - Ok(router) -} - -#[utoipa::path( - get, - path = "/locales", - context_path = "/api/l10n", - responses( - (status = 200, description = "List of known locales", body = Vec) - ) -)] -async fn locales(State(state): State>) -> Json> { - let data = state.locale.read().await; - let locales = data.locales_db.entries().to_vec(); - Json(locales) -} - -#[utoipa::path( - get, - path = "/timezones", - context_path = "/api/l10n", - responses( - (status = 200, description = "List of known timezones", body = Vec) - ) -)] -async fn timezones(State(state): State>) -> Json> { - let data = state.locale.read().await; - let timezones = data.timezones_db.entries().to_vec(); - Json(timezones) -} - -#[utoipa::path( - get, - path = "/keymaps", - context_path = "/api/l10n", - responses( - (status = 200, description = "List of known keymaps", body = Vec) - ) -)] -async fn keymaps(State(state): State>) -> Json> { - let data = state.locale.read().await; - let keymaps = data.keymaps_db.entries().to_vec(); - Json(keymaps) -} - -// TODO: update all or nothing -// TODO: send only the attributes that have changed -#[utoipa::path( - patch, - path = "/config", - context_path = "/api/l10n", - operation_id = "set_l10n_config", - responses( - (status = 204, description = "Set the locale configuration", body = LocaleConfig) - ) -)] -async fn set_config( - State(state): State>, - Extension(client_id): Extension>, - Json(value): Json, -) -> Result { - let mut data = state.locale.write().await; - let mut changes = LocaleConfig::default(); - - if let Some(locales) = &value.locales { - data.set_locales(locales)?; - changes.locales.clone_from(&value.locales); - } - - if let Some(timezone) = &value.timezone { - data.set_timezone(timezone)?; - changes.timezone.clone_from(&value.timezone); - } - - if let Some(keymap_id) = &value.keymap { - let keymap_id = keymap_id.parse().map_err(LocaleError::InvalidKeymap)?; - data.set_keymap(keymap_id)?; - changes.keymap.clone_from(&value.keymap); - } - - if let Some(ui_locale) = &value.ui_locale { - let locale = ui_locale - .as_str() - .try_into() - .map_err(LocaleError::InvalidLocale)?; - data.translate(&locale)?; - let locale_string = locale.to_string(); - state.manager_proxy.set_locale(&locale_string).await?; - changes.ui_locale = Some(locale_string); - _ = state.events.send(event!(LocaleChanged { - locale: locale.to_string(), - })); - } - - if let Some(ui_keymap) = &value.ui_keymap { - let ui_keymap = ui_keymap.parse().map_err(LocaleError::InvalidKeymap)?; - data.set_ui_keymap(ui_keymap)?; - } - - _ = state - .events - .send(event!(L10nConfigChanged(changes), client_id.as_ref())); - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - get, - path = "/config", - context_path = "/api/l10n", - operation_id = "get_l10n_config", - responses( - (status = 200, description = "Localization configuration", body = LocaleConfig) - ) -)] -async fn get_config(State(state): State>) -> Json { - let data = state.locale.read().await; - let locales = data.locales.iter().map(ToString::to_string).collect(); - Json(LocaleConfig { - locales: Some(locales), - keymap: Some(data.keymap.to_string()), - timezone: Some(data.timezone.to_string()), - ui_locale: Some(data.ui_locale.to_string()), - ui_keymap: Some(data.ui_keymap.to_string()), - }) -} - -#[utoipa::path( - get, - path = "/finish", - context_path = "/api/l10n", - operation_id = "l10n_finish", - responses( - (status = 200, description = "Finish the l10n configuration") - ) -)] -async fn finish(State(state): State>) -> Result { - let data = state.locale.read().await; - data.commit()?; - Ok(StatusCode::NO_CONTENT) -} diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index bf7a8c5889..c721bc6e1c 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -22,18 +22,10 @@ pub mod bootloader; pub mod cert; pub mod dbus; pub mod error; -pub mod files; -pub mod hostname; -pub mod l10n; pub mod logs; -pub mod manager; -pub mod network; pub mod profile; -pub mod questions; -pub mod scripts; pub mod security; -pub mod software; -pub mod storage; pub mod users; pub mod web; pub use web::service; +pub mod server; diff --git a/rust/agama-server/src/manager.rs b/rust/agama-server/src/manager.rs deleted file mode 100644 index 7540a15f61..0000000000 --- a/rust/agama-server/src/manager.rs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub mod web; -pub use web::manager_service; diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs deleted file mode 100644 index 3cc7a49bc4..0000000000 --- a/rust/agama-server/src/manager/web.rs +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright (c) [2024-2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the manager service. -//! -//! The module offers two public functions: -//! -//! * `manager_service` which returns the Axum service. -//! * `manager_stream` which offers an stream that emits the manager events coming from D-Bus. - -use agama_lib::{ - auth::ClientId, - error::ServiceError, - event, logs, - manager::{FinishMethod, InstallationPhase, InstallerStatus, ManagerClient}, - proxies::Manager1Proxy, -}; -use anyhow::Context; -use axum::{ - body::Body, - extract::State, - http::{header, status::StatusCode, HeaderMap, HeaderValue}, - response::IntoResponse, - routing::{get, post}, - Extension, Json, Router, -}; -use std::collections::HashMap; -use std::pin::Pin; -use std::sync::Arc; -use tokio_stream::{Stream, StreamExt}; -use tokio_util::io::ReaderStream; - -use crate::{ - error::Error, - web::common::{service_status_router, ProgressClient, ProgressRouterBuilder}, -}; -use agama_lib::http::Event; - -#[derive(Clone)] -pub struct ManagerState<'a> { - dbus: zbus::Connection, - manager: ManagerClient<'a>, -} - -/// Returns a stream that emits manager related events coming from D-Bus. -/// -/// It emits the Event::InstallationPhaseChanged event. -/// -/// * `connection`: D-Bus connection to listen for events. -pub async fn manager_stream( - dbus: zbus::Connection, -) -> Result + Send>>, Error> { - let proxy = Manager1Proxy::new(&dbus).await?; - let stream = proxy - .receive_current_installation_phase_changed() - .await - .then(|change| async move { - if let Ok(phase) = change.get().await { - match InstallationPhase::try_from(phase) { - Ok(phase) => Some(event!(InstallationPhaseChanged { phase })), - Err(error) => { - tracing::warn!("Ignoring the installation phase change. Error: {}", error); - None - } - } - } else { - None - } - }) - .filter_map(|e| e); - Ok(Box::pin(stream)) -} - -/// Sets up and returns the axum service for the manager module -pub async fn manager_service( - dbus: zbus::Connection, - progress: ProgressClient, -) -> Result { - const DBUS_SERVICE: &str = "org.opensuse.Agama.Manager1"; - const DBUS_PATH: &str = "/org/opensuse/Agama/Manager1"; - - let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; - // FIXME: use anyhow temporarily until we adapt all these methods to return - // the crate::error::Error instead of ServiceError. - let progress_router = ProgressRouterBuilder::new(DBUS_SERVICE, DBUS_PATH, progress) - .build() - .context("Could not build the progress router")?; - let manager = ManagerClient::new(dbus.clone()).await?; - let state = ManagerState { manager, dbus }; - Ok(Router::new() - .route("/probe", post(probe_action)) - .route("/probe_sync", post(probe_sync_action)) - .route("/reprobe_sync", post(reprobe_sync_action)) - .route("/install", post(install_action)) - .route("/finish", post(finish_action)) - .route("/installer", get(installer_status)) - .nest("/logs", logs_router()) - .merge(status_router) - .merge(progress_router) - .with_state(state)) -} - -/// Starts the probing process. -// The Probe D-Bus method is blocking and will not return until the probing is finished. To avoid a -// long-lived HTTP connection, this method returns immediately (with a 200) and runs the request on -// a separate task. -#[utoipa::path( - post, - path = "/probe", - context_path = "/api/manager", - responses( - ( - status = 200, - description = "The probing was requested but there is no way to know whether it succeeded." - ) - ) -)] -async fn probe_action( - State(state): State>, - Extension(client_id): Extension>, -) -> Result<(), Error> { - let dbus = state.dbus.clone(); - tokio::spawn(async move { - let result = dbus - .call_method( - Some("org.opensuse.Agama.Manager1"), - "/org/opensuse/Agama/Manager1", - Some("org.opensuse.Agama.Manager1"), - "Probe", - &HashMap::from([("client_id", client_id.to_string())]), - ) - .await; - if let Err(error) = result { - tracing::error!("Could not start probing: {:?}", error); - } - }); - Ok(()) -} - -/// Starts the probing process and waits until it is done. -/// We need this because the CLI (agama_lib::Store) only does sync calls. -#[utoipa::path( - post, - path = "/probe_sync", - context_path = "/api/manager", - responses( - (status = 200, description = "Probing done.") - ) -)] -async fn probe_sync_action( - State(state): State>, - Extension(client_id): Extension>, -) -> Result<(), Error> { - state.manager.probe(client_id.to_string()).await?; - Ok(()) -} - -/// Starts the reprobing process and waits until it is done. -#[utoipa::path( - post, - path = "/reprobe_sync", - context_path = "/api/manager", - responses( - (status = 200, description = "Re-probing done.") - ) -)] -async fn reprobe_sync_action( - State(state): State>, - Extension(client_id): Extension>, -) -> Result<(), Error> { - state.manager.reprobe(client_id.to_string()).await?; - Ok(()) -} - -/// Starts the installation process. -#[utoipa::path( - post, - path = "/install", - context_path = "/api/manager", - responses( - (status = 200, description = "The installation process was started.") - ) -)] -async fn install_action(State(state): State>) -> Result<(), Error> { - state.manager.install().await?; - Ok(()) -} - -/// Executes the post installation tasks (e.g., rebooting the system). -#[utoipa::path( - post, - path = "/finish", - context_path = "/api/manager", - responses( - (status = 200, description = "The installation tasks are executed.", body = Option) - ) -)] -async fn finish_action( - State(state): State>, - method: Option>, -) -> Result, Error> { - let method = match method { - Some(Json(method)) => method, - _ => FinishMethod::default(), - }; - Ok(Json(state.manager.finish(method).await?)) -} - -/// Returns the manager status. -#[utoipa::path( - get, - path = "/installer", - context_path = "/api/manager", - responses( - (status = 200, description = "Installation status.", body = InstallerStatus) - ) -)] -async fn installer_status( - State(state): State>, -) -> Result, Error> { - let phase = state.manager.current_installation_phase().await?; - // CanInstall gets blocked during installation - let can_install = match phase { - InstallationPhase::Install => false, - _ => state.manager.can_install().await?, - }; - let status = InstallerStatus { - phase, - can_install, - is_busy: state.manager.is_busy().await, - use_iguana: state.manager.use_iguana().await?, - }; - Ok(Json(status)) -} - -/// Creates router for handling /logs/* endpoints -fn logs_router() -> Router> { - Router::new() - .route("/store", get(download_logs)) - .route("/list", get(list_logs)) -} - -#[utoipa::path(get, - path = "/logs/store", - context_path = "/api/manager", - responses( - (status = 200, description = "Compressed Agama logs", content_type="application/octet-stream"), - (status = 500, description = "Cannot collect the logs"), - (status = 507, description = "Server is probably out of space"), - ) -)] -async fn download_logs() -> impl IntoResponse { - let mut headers = HeaderMap::new(); - let err_response = (headers.clone(), Body::empty()); - - match logs::store() { - Ok(path) => { - if let Ok(file) = tokio::fs::File::open(path.clone()).await { - let stream = ReaderStream::new(file); - let body = Body::from_stream(stream); - let _ = std::fs::remove_file(path.clone()); - - // See RFC2046, RFC2616 and - // https://www.iana.org/assignments/media-types/media-types.xhtml - // or /etc/mime.types - headers.insert( - header::CONTENT_TYPE, - HeaderValue::from_static("application/x-compressed-tar"), - ); - if let Some(file_name) = path.file_name() { - let disposition = - format!("attachment; filename=\"{}\"", &file_name.to_string_lossy()); - headers.insert( - header::CONTENT_DISPOSITION, - HeaderValue::from_str(&disposition) - .unwrap_or_else(|_| HeaderValue::from_static("attachment")), - ); - } - headers.insert( - header::CONTENT_ENCODING, - HeaderValue::from_static(logs::DEFAULT_COMPRESSION.1), - ); - - (StatusCode::OK, (headers, body)) - } else { - (StatusCode::INSUFFICIENT_STORAGE, err_response) - } - } - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, err_response), - } -} - -#[utoipa::path(get, - path = "/logs/list", - context_path = "/api/manager", - responses( - (status = 200, description = "Lists of collected logs", body = logs::LogsLists) - ) -)] -pub async fn list_logs() -> Json { - Json(logs::list()) -} diff --git a/rust/agama-server/src/network.rs b/rust/agama-server/src/network.rs deleted file mode 100644 index 95e80f2639..0000000000 --- a/rust/agama-server/src/network.rs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub mod web; - -pub use agama_lib::network::{ - model::NetworkState, Action, Adapter, NetworkAdapterError, NetworkManagerAdapter, NetworkSystem, -}; diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs deleted file mode 100644 index 37303ecd22..0000000000 --- a/rust/agama-server/src/network/web.rs +++ /dev/null @@ -1,490 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the network module. - -use crate::{error::Error, web::EventsSender}; -use anyhow::Context; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{delete, get, post}, - Json, Router, -}; -use uuid::Uuid; - -use agama_lib::{ - error::ServiceError, - event, - network::{ - error::NetworkStateError, - model::{AccessPoint, Connection, Device, GeneralState}, - settings::NetworkConnection, - types::NetworkConnectionWithState, - Adapter, NetworkSystem, NetworkSystemClient, NetworkSystemError, - }, -}; - -use serde::Deserialize; -use serde_json::json; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum NetworkError { - #[error("Unknown connection id: {0}")] - UnknownConnection(String), - #[error("Cannot translate: {0}")] - CannotTranslate(#[from] Error), - #[error("Cannot add new connection: {0}")] - CannotAddConnection(String), - #[error("Cannot update configuration: {0}")] - CannotUpdate(String), - #[error("Cannot apply configuration")] - CannotApplyConfig, - // TODO: to be removed after adapting to the NetworkSystemServer API - #[error("Network state error: {0}")] - Error(#[from] NetworkStateError), - #[error("Network system error: {0}")] - SystemError(#[from] NetworkSystemError), -} - -impl IntoResponse for NetworkError { - fn into_response(self) -> Response { - let body = json!({ - "error": self.to_string() - }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() - } -} - -#[derive(Clone)] -struct NetworkServiceState { - network: NetworkSystemClient, -} - -/// Sets up and returns the axum service for the network module. -/// * `adapter`: networking configuration adapter. -/// * `events`: sending-half of the broadcast channel. -pub async fn network_service( - adapter: T, - events: EventsSender, -) -> Result { - let network = NetworkSystem::new(adapter); - // FIXME: we are somehow abusing ServiceError. The HTTP/JSON API should have its own - // error type. - let client = network - .start() - .await - .context("Could not start the network configuration service.")?; - - let mut changes = client.subscribe(); - tokio::spawn(async move { - loop { - match changes.recv().await { - Ok(message) => { - let change = event!(NetworkChange { change: message }); - if let Err(e) = events.send(change) { - eprintln!("Could not send the event: {}", e); - } - } - Err(e) => { - eprintln!("Could not send the event: {}", e); - } - } - } - }); - - let state = NetworkServiceState { network: client }; - - Ok(Router::new() - .route("/state", get(general_state).put(update_general_state)) - .route("/connections", get(connections).post(add_connection)) - .route( - "/connections/:id", - delete(delete_connection) - .put(update_connection) - .get(connection), - ) - .route("/connections/:id/connect", post(connect)) - .route("/connections/:id/disconnect", post(disconnect)) - .route("/connections/persist", post(persist)) - .route("/devices", get(devices)) - .route("/system/apply", post(apply)) - .route("/wifi", get(wifi_networks)) - .with_state(state)) -} - -#[utoipa::path( - get, - path = "/state", - context_path = "/api/network", - responses( - (status = 200, description = "Get general network config", body = GeneralState) - ) -)] -async fn general_state( - State(state): State, -) -> Result, NetworkError> { - let general_state = state.network.get_state().await?; - Ok(Json(general_state)) -} - -#[utoipa::path( - put, - path = "/state", - context_path = "/api/network", - responses( - (status = 200, description = "Update general network config", body = GeneralState) - ) -)] -async fn update_general_state( - State(state): State, - Json(value): Json, -) -> Result, NetworkError> { - state.network.update_state(value)?; - let state = state.network.get_state().await?; - Ok(Json(state)) -} - -#[utoipa::path( - get, - path = "/wifi", - context_path = "/api/network", - responses( - (status = 200, description = "List of wireless networks", body = Vec) - ) -)] -async fn wifi_networks( - State(state): State, -) -> Result>, NetworkError> { - state.network.wifi_scan().await?; - let access_points = state.network.get_access_points().await?; - - let mut networks = vec![]; - for ap in access_points { - if !ap.ssid.to_string().is_empty() { - networks.push(ap); - } - } - - Ok(Json(networks)) -} - -#[utoipa::path( - get, - path = "/devices", - context_path = "/api/network", - responses( - (status = 200, description = "List of devices", body = Vec) - ) -)] -async fn devices( - State(state): State, -) -> Result>, NetworkError> { - Ok(Json(state.network.get_devices().await?)) -} - -#[utoipa::path( - get, - path = "/connections", - context_path = "/api/network", - responses( - (status = 200, description = "List of known connections", body = Vec) - ) -)] -async fn connections( - State(state): State, -) -> Result>, NetworkError> { - let connections = state.network.get_connections().await?; - - let network_connections = connections - .iter() - .filter(|c| c.controller.is_none()) - .map(|c| { - let state = c.state; - let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); - if let Some(ref mut bond) = conn.bond { - bond.ports = ports_for(connections.to_owned(), c.uuid); - } - if let Some(ref mut bridge) = conn.bridge { - bridge.ports = ports_for(connections.to_owned(), c.uuid); - }; - NetworkConnectionWithState { - connection: conn, - state, - } - }) - .collect(); - - Ok(Json(network_connections)) -} - -fn ports_for(connections: Vec, uuid: Uuid) -> Vec { - return connections - .iter() - .filter(|c| c.controller == Some(uuid)) - .map(|c| { - if let Some(interface) = c.interface.to_owned() { - interface - } else { - c.clone().id - } - }) - .collect(); -} - -#[utoipa::path( - post, - path = "/connections", - context_path = "/api/network", - responses( - (status = 200, description = "Add a new connection", body = Connection) - ) -)] -async fn add_connection( - State(state): State, - Json(net_conn): Json, -) -> Result, NetworkError> { - let bond = net_conn.bond.clone(); - let bridge = net_conn.bridge.clone(); - let conn = Connection::try_from(net_conn)?; - let id = conn.id.clone(); - - state.network.add_connection(conn.clone()).await?; - - match state.network.get_connection(&id).await? { - None => Err(NetworkError::CannotAddConnection(id.clone())), - Some(conn) => { - if let Some(bond) = bond { - state.network.set_ports(conn.uuid, bond.ports).await?; - } - if let Some(bridge) = bridge { - state.network.set_ports(conn.uuid, bridge.ports).await?; - } - Ok(Json(conn)) - } - } -} - -#[utoipa::path( - get, - path = "/connections/:id", - context_path = "/api/network", - responses( - (status = 200, description = "Get connection given by its ID", body = NetworkConnection) - ) -)] -async fn connection( - State(state): State, - Path(id): Path, -) -> Result, NetworkError> { - let conn = state - .network - .get_connection(&id) - .await? - .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; - - let conn = NetworkConnection::try_from(conn)?; - - Ok(Json(conn)) -} - -#[utoipa::path( - delete, - path = "/connections/:id", - context_path = "/api/network", - responses( - (status = 200, description = "Delete connection", body = Connection) - ) -)] -async fn delete_connection( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - if state.network.remove_connection(&id).await.is_ok() { - StatusCode::NO_CONTENT - } else { - StatusCode::NOT_FOUND - } -} - -#[utoipa::path( - put, - path = "/connections/:id", - context_path = "/api/network", - responses( - (status = 204, description = "Update connection", body = Connection) - ) -)] -async fn update_connection( - State(state): State, - Path(id): Path, - Json(conn): Json, -) -> Result { - let orig_conn = state - .network - .get_connection(&id) - .await? - .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; - let bond = conn.bond.clone(); - let bridge = conn.bridge.clone(); - - let mut conn = Connection::try_from(conn)?; - conn.uuid = orig_conn.uuid; - - state.network.update_connection(conn.clone()).await?; - - if let Some(bond) = bond { - state.network.set_ports(conn.uuid, bond.ports).await?; - } - if let Some(bridge) = bridge { - state.network.set_ports(conn.uuid, bridge.ports).await?; - } - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/connections/:id/connect", - context_path = "/api/network", - responses( - (status = 204, description = "Connect to the given connection", body = String) - ) -)] -async fn connect( - State(state): State, - Path(id): Path, -) -> Result { - let Some(mut conn) = state.network.get_connection(&id).await? else { - return Err(NetworkError::UnknownConnection(id)); - }; - conn.set_up(); - - state - .network - .update_connection(conn) - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/connections/:id/disconnect", - context_path = "/api/network", - responses( - (status = 204, description = "Connect to the given connection", body = String) - ) -)] -async fn disconnect( - State(state): State, - Path(id): Path, -) -> Result { - let Some(mut conn) = state.network.get_connection(&id).await? else { - return Err(NetworkError::UnknownConnection(id)); - }; - conn.set_down(); - - state - .network - .update_connection(conn) - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct PersistParams { - pub only: Option>, - pub value: bool, -} - -#[utoipa::path( - post, - path = "/connections/persist", - context_path = "/api/network", - responses( - (status = 204, description = "Persist the given connection to disk", body = PersistParams) - ) -)] -async fn persist( - State(state): State, - Json(persist): Json, -) -> Result { - let mut connections = state.network.get_connections().await?; - let ids = persist.only.unwrap_or(vec![]); - - for conn in connections.iter_mut() { - if ids.is_empty() || ids.contains(&conn.id) { - conn.persistent = persist.value; - conn.keep_status(); - - state - .network - .update_connection(conn.to_owned()) - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - } - } - - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/system/apply", - context_path = "/api/network", - responses( - (status = 204, description = "Apply configuration") - ) -)] -async fn apply( - State(state): State, -) -> Result { - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} diff --git a/rust/agama-server/src/profile/web.rs b/rust/agama-server/src/profile/web.rs index 4535d8d57d..109233f31f 100644 --- a/rust/agama-server/src/profile/web.rs +++ b/rust/agama-server/src/profile/web.rs @@ -18,9 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_transfer::Transfer; use anyhow::Context; -use agama_lib::utils::Transfer; use agama_lib::{ error::ServiceError, profile::{AutoyastProfileImporter, ProfileEvaluator, ProfileValidator, ValidationOutcome}, diff --git a/rust/agama-server/src/questions.rs b/rust/agama-server/src/questions.rs deleted file mode 100644 index 7046c7f77b..0000000000 --- a/rust/agama-server/src/questions.rs +++ /dev/null @@ -1,318 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::collections::HashMap; - -use agama_lib::questions::{ - self, - answers::{AnswerStrategy, Answers, DefaultAnswers}, - GenericQuestion, WithPassword, -}; -use zbus::{fdo::ObjectManager, interface, zvariant::ObjectPath, Connection}; - -pub mod web; - -#[derive(Clone, Debug)] -struct GenericQuestionObject(questions::GenericQuestion); - -#[interface(name = "org.opensuse.Agama1.Questions.Generic")] -impl GenericQuestionObject { - #[zbus(property)] - pub fn id(&self) -> u32 { - self.0.id - } - - #[zbus(property)] - pub fn class(&self) -> &str { - &self.0.class - } - - #[zbus(property)] - pub fn data(&self) -> HashMap { - self.0.data.to_owned() - } - - #[zbus(property)] - pub fn text(&self) -> &str { - self.0.text.as_str() - } - - #[zbus(property)] - pub fn options(&self) -> Vec { - self.0.options.to_owned() - } - - #[zbus(property)] - pub fn default_option(&self) -> &str { - self.0.default_option.as_str() - } - - #[zbus(property)] - pub fn answer(&self) -> &str { - &self.0.answer - } - - #[zbus(property)] - pub fn set_answer(&mut self, value: &str) -> zbus::fdo::Result<()> { - // TODO verify if answer exists in options or if it is valid in other way - self.0.answer = value.to_string(); - - Ok(()) - } -} - -/// Mixin interface for questions that are base + contain question for password -struct WithPasswordObject(questions::WithPassword); - -#[interface(name = "org.opensuse.Agama1.Questions.WithPassword")] -impl WithPasswordObject { - #[zbus(property)] - pub fn password(&self) -> &str { - self.0.password.as_str() - } - - #[zbus(property)] - pub fn set_password(&mut self, value: &str) { - self.0.password = value.to_string(); - } -} - -/// Question types used to be able to properly remove object from dbus -enum QuestionType { - Base, - BaseWithPassword, -} - -pub struct Questions { - questions: HashMap, - connection: Connection, - last_id: u32, - answer_strategies: Vec>, -} - -#[interface(name = "org.opensuse.Agama1.Questions")] -impl Questions { - /// creates new generic question without answer - #[zbus(name = "New")] - async fn new_question( - &mut self, - class: &str, - text: &str, - options: Vec<&str>, - default_option: &str, - data: HashMap, - ) -> zbus::fdo::Result { - tracing::info!("Creating new question with text: {}.", text); - let id = self.last_id; - self.last_id += 1; // TODO use some thread safety - let options = options.iter().map(|o| o.to_string()).collect(); - let mut question = questions::GenericQuestion::new( - id, - class.to_string(), - text.to_string(), - options, - default_option.to_string(), - data, - ); - self.fill_answer(&mut question); - let object_path = ObjectPath::try_from(question.object_path()).unwrap(); - let question_object = GenericQuestionObject(question); - - self.connection - .object_server() - .at(object_path.clone(), question_object) - .await?; - self.questions.insert(id, QuestionType::Base); - Ok(object_path) - } - - /// creates new specialized luks activation question without answer and password - async fn new_with_password( - &mut self, - class: &str, - text: &str, - options: Vec<&str>, - default_option: &str, - data: HashMap, - ) -> zbus::fdo::Result { - tracing::info!("Creating new question with password with text: {}.", text); - let id = self.last_id; - self.last_id += 1; // TODO use some thread safety - // TODO: share code better - let options = options.iter().map(|o| o.to_string()).collect(); - let base = questions::GenericQuestion::new( - id, - class.to_string(), - text.to_string(), - options, - default_option.to_string(), - data, - ); - let mut question = questions::WithPassword::new(base); - let object_path = ObjectPath::try_from(question.base.object_path()).unwrap(); - - self.fill_answer_with_password(&mut question); - let base_question = question.base.clone(); - let base_object = GenericQuestionObject(base_question); - - self.connection - .object_server() - .at(object_path.clone(), WithPasswordObject(question)) - .await?; - // NOTE: order here is important as each interface cause signal, so frontend should wait only for GenericQuestions - // which should be the last interface added - self.connection - .object_server() - .at(object_path.clone(), base_object) - .await?; - - self.questions.insert(id, QuestionType::BaseWithPassword); - Ok(object_path) - } - - /// Removes question at given object path - /// TODO: use id as parameter ( need at first check other users of method ) - async fn delete(&mut self, question: ObjectPath<'_>) -> zbus::fdo::Result<()> { - // TODO: error checking - let id: u32 = question.rsplit('/').next().unwrap().parse().unwrap(); - let qtype = self.questions.get(&id).unwrap(); - match qtype { - QuestionType::Base => { - self.connection - .object_server() - .remove::(question.clone()) - .await?; - } - QuestionType::BaseWithPassword => { - self.connection - .object_server() - .remove::(question.clone()) - .await?; - self.connection - .object_server() - .remove::(question.clone()) - .await?; - } - }; - self.questions.remove(&id); - Ok(()) - } - - /// property that defines if questions is interactive or automatically answered with - /// default answer - #[zbus(property)] - fn interactive(&self) -> bool { - self.answer_strategies - .iter() - .all(|s| s.id() != DefaultAnswers::id()) - } - - #[zbus(property)] - fn set_interactive(&mut self, value: bool) { - if value == self.interactive() { - tracing::info!("interactive value unchanged - {}", value); - return; - } - - tracing::info!("set interactive to {}", value); - if value { - self.answer_strategies - .retain(|s| s.id() == DefaultAnswers::id()); - } else { - self.answer_strategies.push(Box::new(DefaultAnswers {})); - } - } - - fn add_answer_file(&mut self, path: String) -> zbus::fdo::Result<()> { - tracing::info!("Adding answer file {}", path); - let answers = Answers::new_from_file(path.as_str()) - .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; - self.answer_strategies.insert(0, Box::new(answers)); - Ok(()) - } - - fn remove_answers(&mut self) -> zbus::fdo::Result<()> { - self.answer_strategies - .retain(|s| s.id() == DefaultAnswers::id()); - Ok(()) - } -} - -impl Questions { - /// Creates new questions interface with clone of connection to be able to - /// attach or detach question objects - fn new(connection: &Connection) -> Self { - Self { - questions: HashMap::new(), - connection: connection.to_owned(), - last_id: 0, - answer_strategies: vec![], - } - } - - /// tries to provide answer to question using answer strategies - /// - /// What happens under the hood is that it uses answer_strategies vector - /// and try to find the first strategy that provides answer. When - /// answer is provided, it returns immediately. - fn fill_answer(&self, question: &mut GenericQuestion) { - for strategy in self.answer_strategies.iter() { - match strategy.answer(question) { - None => (), - Some(answer) => { - question.answer = answer; - return; - } - } - } - } - - /// tries to provide answer to question using answer strategies - /// - /// What happens under the hood is that it uses answer_strategies vector - /// and try to find the first strategy that provides answer. When - /// answer is provided, it returns immediately. - fn fill_answer_with_password(&self, question: &mut WithPassword) { - for strategy in self.answer_strategies.iter() { - let (answer, password) = strategy.answer_with_password(question); - if let Some(password) = password { - question.password = password; - } - if let Some(answer) = answer { - question.base.answer = answer; - return; - } - } - } -} - -/// Starts questions dbus service together with Object manager -pub async fn export_dbus_objects( - connection: &Connection, -) -> Result<(), Box> { - const PATH: &str = "/org/opensuse/Agama1/Questions"; - - // When serving, request the service name _after_ exposing the main object - let questions = Questions::new(connection); - connection.object_server().at(PATH, questions).await?; - connection.object_server().at(PATH, ObjectManager).await?; - - Ok(()) -} diff --git a/rust/agama-server/src/questions/web.rs b/rust/agama-server/src/questions/web.rs deleted file mode 100644 index bd34f708de..0000000000 --- a/rust/agama-server/src/questions/web.rs +++ /dev/null @@ -1,448 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the questions module. -//! -//! The module offers two public functions: -//! -//! * `questions_service` which returns the Axum service. -//! * `questions_stream` which offers an stream that emits questions related signals. - -use crate::error::Error; -use agama_lib::{ - error::ServiceError, - event, - http::Event, - proxies::questions::{GenericQuestionProxy, QuestionWithPasswordProxy, QuestionsProxy}, - questions::{ - answers::{self, Answers}, - config::{QuestionsConfig, QuestionsPolicy}, - model::{self, GenericQuestion, PasswordAnswer, Question, QuestionWithPassword}, - }, -}; -use agama_utils::dbus::{extract_id_from_path, get_property}; -use anyhow::{anyhow, Context}; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{delete, get, put}, - Json, Router, -}; -use std::{collections::HashMap, io::Write, pin::Pin}; -use tempfile::NamedTempFile; -use tokio_stream::{Stream, StreamExt}; -use zbus::{ - fdo::ObjectManagerProxy, - names::{InterfaceName, OwnedInterfaceName}, - zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}, -}; - -// TODO: move to lib or maybe not and just have in lib client for http API? -#[derive(Clone)] -struct QuestionsClient<'a> { - connection: zbus::Connection, - objects_proxy: ObjectManagerProxy<'a>, - questions_proxy: QuestionsProxy<'a>, - generic_interface: OwnedInterfaceName, - with_password_interface: OwnedInterfaceName, -} - -impl QuestionsClient<'_> { - pub async fn new(dbus: zbus::Connection) -> Result { - let question_path = - OwnedObjectPath::from(ObjectPath::try_from("/org/opensuse/Agama1/Questions")?); - Ok(Self { - connection: dbus.clone(), - questions_proxy: QuestionsProxy::new(&dbus).await?, - objects_proxy: ObjectManagerProxy::builder(&dbus) - .path(question_path)? - .destination("org.opensuse.Agama1")? - .build() - .await?, - generic_interface: InterfaceName::from_str_unchecked( - "org.opensuse.Agama1.Questions.Generic", - ) - .into(), - with_password_interface: InterfaceName::from_str_unchecked( - "org.opensuse.Agama1.Questions.WithPassword", - ) - .into(), - }) - } - - pub async fn create_question(&self, question: Question) -> Result { - // TODO: ugly API is caused by dbus method to create question. It can be changed in future as DBus is internal only API - let generic = &question.generic; - let options: Vec<&str> = generic.options.iter().map(String::as_ref).collect(); - let data: HashMap<&str, &str> = generic - .data - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - let path = if question.with_password.is_some() { - tracing::info!("creating a question with password"); - self.questions_proxy - .new_with_password( - &generic.class, - &generic.text, - &options, - &generic.default_option, - data, - ) - .await? - } else { - tracing::info!("creating a generic question"); - self.questions_proxy - .new_question( - &generic.class, - &generic.text, - &options, - &generic.default_option, - data, - ) - .await? - }; - let mut res = question.clone(); - res.generic.id = Some(extract_id_from_path(&path)?); - tracing::info!("new question gets id {:?}", res.generic.id); - Ok(res) - } - - pub async fn questions(&self) -> Result, ServiceError> { - let objects = self - .objects_proxy - .get_managed_objects() - .await - .context("failed to get managed object with Object Manager")?; - let mut result: Vec = Vec::with_capacity(objects.len()); - - for (_path, interfaces_hash) in objects.iter() { - let generic_properties = interfaces_hash - .get(&self.generic_interface) - .context("Failed to create interface name for generic question")?; - // skip if question is already answered - let answer: String = get_property(generic_properties, "Answer")?; - if !answer.is_empty() { - continue; - } - let mut question = self.build_generic_question(generic_properties)?; - - if interfaces_hash.contains_key(&self.with_password_interface) { - question.with_password = Some(QuestionWithPassword {}); - } - - result.push(question); - } - Ok(result) - } - - fn build_generic_question( - &self, - properties: &HashMap, - ) -> Result { - let result = Question { - generic: GenericQuestion { - id: Some(get_property(properties, "Id")?), - class: get_property(properties, "Class")?, - text: get_property(properties, "Text")?, - options: get_property(properties, "Options")?, - default_option: get_property(properties, "DefaultOption")?, - data: get_property(properties, "Data")?, - }, - with_password: None, - }; - - Ok(result) - } - - pub async fn delete(&self, id: u32) -> Result<(), ServiceError> { - let question_path = ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) - .context("Failed to create a D-Bus path")?; - - self.questions_proxy - .delete(&question_path) - .await - .map_err(|e| e.into()) - } - - pub async fn get_answer(&self, id: u32) -> Result, ServiceError> { - let question_path = OwnedObjectPath::from( - ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) - .context("Failed to create dbus path")?, - ); - let objects = self.objects_proxy.get_managed_objects().await?; - let password_interface = OwnedInterfaceName::from( - InterfaceName::from_static_str("org.opensuse.Agama1.Questions.WithPassword") - .context("Failed to create interface name for question with password")?, - ); - let mut result = model::Answer::default(); - let question = objects - .get(&question_path) - .ok_or(ServiceError::QuestionNotExist(id))?; - - if let Some(password_iface) = question.get(&password_interface) { - result.with_password = Some(PasswordAnswer { - password: get_property(password_iface, "Password")?, - }); - } - let generic_interface = OwnedInterfaceName::from( - InterfaceName::from_static_str("org.opensuse.Agama1.Questions.Generic") - .context("Failed to create interface name for generic question")?, - ); - let generic_iface = question - .get(&generic_interface) - .context("Question does not have generic interface")?; - let answer: String = get_property(generic_iface, "Answer")?; - if answer.is_empty() { - Ok(None) - } else { - result.generic.answer = answer; - Ok(Some(result)) - } - } - - pub async fn answer(&self, id: u32, answer: model::Answer) -> Result<(), ServiceError> { - let question_path = OwnedObjectPath::from( - ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) - .context("Failed to create dbus path")?, - ); - if let Some(password) = answer.with_password { - let dbus_password = QuestionWithPasswordProxy::builder(&self.connection) - .path(&question_path)? - .cache_properties(zbus::proxy::CacheProperties::No) - .build() - .await?; - dbus_password - .set_password(password.password.as_str()) - .await? - } - let dbus_generic = GenericQuestionProxy::builder(&self.connection) - .path(&question_path)? - .cache_properties(zbus::proxy::CacheProperties::No) - .build() - .await?; - dbus_generic - .set_answer(answer.generic.answer.as_str()) - .await?; - Ok(()) - } - - pub async fn set_interactive(&self, value: bool) -> Result<(), ServiceError> { - self.questions_proxy.set_interactive(value).await?; - Ok(()) - } - - pub async fn set_answers(&self, answers: Vec) -> Result<(), ServiceError> { - let mut file = NamedTempFile::new().context("Cannot create the answers file")?; - let all_answers = Answers::new(answers); - let json = serde_json::to_string(&all_answers)?; - write!(file, "{}", &json).context("Cannot write the answers file")?; - self.questions_proxy.remove_answers().await?; - let path = file - .path() - .to_str() - .ok_or(anyhow!("Could not create the answers file"))?; - self.questions_proxy.add_answer_file(path).await?; - Ok(()) - } -} - -#[derive(Clone)] -struct QuestionsState<'a> { - questions: QuestionsClient<'a>, -} - -/// Sets up and returns the axum service for the questions module. -pub async fn questions_service(dbus: zbus::Connection) -> Result { - let questions = QuestionsClient::new(dbus.clone()).await?; - let state = QuestionsState { questions }; - let router = Router::new() - .route("/", get(list_questions).post(create_question)) - .route("/:id", delete(delete_question)) - .route("/:id/answer", get(get_answer).put(answer_question)) - .route("/config", put(set_config)) - .with_state(state); - Ok(router) -} - -pub async fn questions_stream( - dbus: zbus::Connection, -) -> Result + Send>>, Error> { - let question_path = OwnedObjectPath::from( - ObjectPath::try_from("/org/opensuse/Agama1/Questions") - .context("failed to create object path")?, - ); - let proxy = ObjectManagerProxy::builder(&dbus) - .path(question_path) - .context("Failed to create object manager path")? - .destination("org.opensuse.Agama1")? - .build() - .await - .context("Failed to create Object MAnager proxy")?; - let add_stream = proxy - .receive_interfaces_added() - .await? - .then(|_| async move { event!(QuestionsChanged) }); - let remove_stream = proxy - .receive_interfaces_removed() - .await? - .then(|_| async move { event!(QuestionsChanged) }); - let stream = StreamExt::merge(add_stream, remove_stream); - Ok(Box::pin(stream)) -} - -/// Returns the list of questions that waits for answer. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/", - context_path = "/api/questions", - responses( - (status = 200, description = "List of open questions", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn list_questions( - State(state): State>, -) -> Result>, Error> { - Ok(Json(state.questions.questions().await?)) -} - -/// Get answer to question. -/// -/// * `state`: service state. -/// * `questions_id`: id of question -#[utoipa::path( - put, - path = "/:id/answer", - context_path = "/api/questions", - responses( - (status = 200, description = "Answer"), - (status = 400, description = "The D-Bus service could not perform the action"), - (status = 404, description = "Answer was not yet provided"), - ) -)] -async fn get_answer( - State(state): State>, - Path(question_id): Path, -) -> Result { - let res = state.questions.get_answer(question_id).await?; - if let Some(answer) = res { - Ok(Json(answer).into_response()) - } else { - Ok(StatusCode::NOT_FOUND.into_response()) - } -} - -/// Provide answer to question. -/// -/// * `state`: service state. -/// * `questions_id`: id of question -/// * `answer`: struct with answer and possible other data needed for answer like password -#[utoipa::path( - put, - path = "/:id/answer", - context_path = "/api/questions", - responses( - (status = 200, description = "answer question"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn answer_question( - State(state): State>, - Path(question_id): Path, - Json(answer): Json, -) -> Result<(), Error> { - let res = state.questions.answer(question_id, answer).await; - Ok(res?) -} - -/// Deletes question. -/// -/// * `state`: service state. -/// * `questions_id`: id of question -#[utoipa::path( - delete, - path = "/:id", - context_path = "/api/questions", - responses( - (status = 200, description = "question deleted"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn delete_question( - State(state): State>, - Path(question_id): Path, -) -> Result<(), Error> { - let res = state.questions.delete(question_id).await; - Ok(res?) -} - -/// Create new question. -/// -/// * `state`: service state. -/// * `question`: struct with question where id of question is ignored and will be assigned -#[utoipa::path( - post, - path = "/", - context_path = "/api/questions", - responses( - (status = 200, description = "answer question"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn create_question( - State(state): State>, - Json(question): Json, -) -> Result, Error> { - let res = state.questions.create_question(question).await?; - Ok(Json(res)) -} - -#[utoipa::path( - put, - path = "/config", - context_path = "/api/questions", - responses( - (status = 200, description = "set questions configuration"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn set_config( - State(state): State>, - Json(config): Json, -) -> Result<(), Error> { - if let Some(policy) = config.policy { - let interactive = match policy { - QuestionsPolicy::User => true, - QuestionsPolicy::Auto => false, - }; - - state.questions.set_interactive(interactive).await?; - } - - if let Some(answers) = config.answers { - state.questions.set_answers(answers).await?; - } - - Ok(()) -} diff --git a/rust/agama-server/src/scripts/web.rs b/rust/agama-server/src/scripts/web.rs deleted file mode 100644 index ad45b39c8e..0000000000 --- a/rust/agama-server/src/scripts/web.rs +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::sync::Arc; - -use agama_lib::{ - error::ServiceError, - scripts::{Script, ScriptError, ScriptsGroup, ScriptsRepository}, -}; -use axum::{ - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{get, post}, - Json, Router, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use thiserror::Error; -use tokio::sync::RwLock; - -#[derive(Clone, Default)] -struct ScriptsState { - scripts: Arc>, -} - -#[derive(Error, Debug)] -#[error("Script error: {0}")] -struct ScriptServiceError(#[from] ScriptError); - -impl IntoResponse for ScriptServiceError { - fn into_response(self) -> Response { - tracing::warn!("Server return error {}", self); - let body = json!({ - "error": self.to_string() - }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() - } -} - -/// Sets up and returns the axum service for the auto-installation scripts. -pub async fn scripts_service() -> Result { - let state = ScriptsState::default(); - let router = Router::new() - .route( - "/", - get(list_scripts).post(add_script).delete(remove_scripts), - ) - .route("/run", post(run_scripts)) - .with_state(state); - Ok(router) -} - -#[utoipa::path( - post, - path = "/", - context_path = "/api/scripts", - request_body(content = [Script], description = "Script definition"), - responses( - (status = 200, description = "The script was added.") - ) -)] -async fn add_script( - state: State, - Json(script): Json