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..5a04811228 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -72,28 +72,42 @@ 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 + clang-devel + libzypp-devel + gcc-c++ + git + libopenssl-3-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 +118,68 @@ 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 + clang-devel + dbus-1-daemon + libzypp-devel + gcc-c++ + git + glibc-locale + golang-github-google-jsonnet + jq + libopenssl-3-devel + make + openssl-3 + pam-devel + python-langtable-data + python3-openapi_spec_validator + rustup + timezone + 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 +195,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,6 +203,7 @@ 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 @@ -198,38 +238,41 @@ 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 + clang-devel + gcc-c++ + git + libopenssl-3-devel + 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 +284,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/live/src/agama-installer.changes b/live/src/agama-installer.changes index 93a512c38f..b40e4dbe00 100644 --- a/live/src/agama-installer.changes +++ b/live/src/agama-installer.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Tue Sep 30 14:54:18 UTC 2025 - Ladislav Slezák + +- Fixed creating the /LiveOS/.packages.json.gz file in SLE builds + (gh#agama-project/agama#2771) + ------------------------------------------------------------------- Tue Sep 16 07:21:28 UTC 2025 - Josef Reidinger diff --git a/live/src/fix_bootconfig b/live/src/fix_bootconfig index 4fe76f192e..74214ef9ec 100644 --- a/live/src/fix_bootconfig +++ b/live/src/fix_bootconfig @@ -82,7 +82,8 @@ if [ -n "\$target_dir" ]; then # array and also for formatting the final JSON output rpm --root "\$mount_dir2" -qa --queryformat \\ '\\{"name":"%{NAME}","version":"%{VERSION}","release":"%{RELEASE}","arch":"%{ARCH}"\\}' \\ - | jq -s 'sort_by(.name|ascii_downcase)' | gzip > "\$target_dir/LiveOS/.packages.json.gz" + | chroot "\$mount_dir2" /usr/bin/jq -s 'sort_by(.name|ascii_downcase)' \\ + | gzip > "\$target_dir/LiveOS/.packages.json.gz" # copy the build info file if present if [ -f "\$mount_dir2"/var/log/build/info ]; then diff --git a/products.d/agama-products.changes b/products.d/agama-products.changes index 2eba8fdf89..a426958406 100644 --- a/products.d/agama-products.changes +++ b/products.d/agama-products.changes @@ -1,3 +1,11 @@ +------------------------------------------------------------------- +Mon Sep 29 04:56:47 UTC 2025 - Lubos Kocman + +- Update patterns and packages Leap Micro 6.2 + Fixes install issues of Leap Micro 6.2 (boo#1250435) +- Explicitly add opensuse-migration-tool + + ------------------------------------------------------------------- Fri Sep 12 14:30:16 UTC 2025 - Ladislav Slezák diff --git a/products.d/leap_micro_62.yaml b/products.d/leap_micro_62.yaml index 4f54352710..8d920492d4 100644 --- a/products.d/leap_micro_62.yaml +++ b/products.d/leap_micro_62.yaml @@ -25,8 +25,7 @@ software: mandatory_patterns: - cockpit - base - - transactional - - traditional + - base_transactional - hardware - selinux @@ -44,8 +43,18 @@ software: - sssd_ldap mandatory_packages: + - firewalld + - kernel-firmware-all + - kernel-default-extra + - libpwquality-tools + - lzop - NetworkManager - openSUSE-repos-LeapMicro + - suseconnect-ng + - systemd-default-settings-branding-SLE-Micro + - wpa_supplicant + - opensuse-migration-tool + optional_packages: null base_product: Leap-Micro diff --git a/products.d/tumbleweed.yaml b/products.d/tumbleweed.yaml index 4f05a5a570..834baebbbf 100644 --- a/products.d/tumbleweed.yaml +++ b/products.d/tumbleweed.yaml @@ -137,6 +137,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 e1aece7139..1b1b6f774d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -111,6 +111,7 @@ dependencies = [ "utoipa", "uuid", "zbus", + "zypp-agama", ] [[package]] @@ -132,6 +133,7 @@ version = "0.1.0" dependencies = [ "agama-l10n", "agama-network", + "agama-software", "agama-storage", "agama-utils", "async-trait", @@ -140,6 +142,7 @@ dependencies = [ "thiserror 2.0.16", "tokio", "tokio-test", + "tracing", "zbus", ] @@ -177,16 +180,19 @@ dependencies = [ "agama-locale-data", "agama-manager", "agama-network", + "agama-software", "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", @@ -200,6 +206,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "serde_yaml", "strum", "subprocess", "tempfile", @@ -218,6 +225,28 @@ dependencies = [ "utoipa", "uuid", "zbus", + "zypp-agama", +] + +[[package]] +name = "agama-software" +version = "0.1.0" +dependencies = [ + "agama-locale-data", + "agama-utils", + "async-trait", + "glob", + "regex", + "serde", + "serde_with", + "serde_yaml", + "strum", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "tracing", + "utoipa", + "zypp-agama", ] [[package]] @@ -243,14 +272,17 @@ dependencies = [ "cidr", "gettext-rs", "macaddr", + "regex", "serde", "serde_json", "serde_with", + "serde_yaml", "strum", "thiserror 2.0.16", "tokio", "tokio-stream", "tokio-test", + "tracing", "utoipa", "uuid", "zbus", @@ -789,10 +821,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", ] @@ -1004,6 +1059,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", + "libloading", ] [[package]] @@ -1548,7 +1604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" dependencies = [ "bit-set 0.8.0", - "regex-automata 0.4.9", + "regex-automata 0.4.13", "regex-syntax 0.8.5", ] @@ -1614,9 +1670,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", ] @@ -2265,9 +2321,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", @@ -2505,7 +2561,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 0.4.13", ] [[package]] @@ -2532,6 +2588,16 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.53.3", +] + [[package]] name = "libredox" version = "0.1.3" @@ -3084,7 +3150,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", ] @@ -3144,9 +3210,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" @@ -3347,6 +3413,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" @@ -3521,13 +3597,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", + "regex-automata 0.4.13", "regex-syntax 0.8.5", ] @@ -3542,9 +3618,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -3664,6 +3740,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "0.38.44" @@ -3859,14 +3941,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -3953,6 +4036,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" @@ -4750,6 +4846,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" @@ -4758,9 +4860,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", @@ -5006,6 +5108,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" @@ -5605,3 +5719,18 @@ dependencies = [ "syn 2.0.101", "winnow", ] + +[[package]] +name = "zypp-agama" +version = "0.1.0" +dependencies = [ + "url", + "zypp-agama-sys", +] + +[[package]] +name = "zypp-agama-sys" +version = "0.1.0" +dependencies = [ + "bindgen 0.72.1", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a5d68b1aba..72f3c67f58 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,9 +7,13 @@ members = [ "agama-locale-data", "agama-manager", "agama-network", - "agama-server", "agama-storage", + "agama-server", + "agama-software", + "agama-storage", "agama-utils", "xtask", + "zypp-agama", + "zypp-agama/zypp-agama-sys", ] resolver = "2" diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index 87d09b2531..f15f018b42 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -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, @@ -63,10 +63,9 @@ pub enum Modes { } async fn set_mode(client: HTTPClient, value: Modes) -> anyhow::Result<()> { - let policy = if value == Modes::Interactive { - Policy::User - } else { - Policy::Auto + let policy = match value { + Modes::Interactive => Policy::User, + Modes::NonInteractive => Policy::Auto, }; client.set_mode(policy).await?; diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 3303376ce4..5dcd9f5d00 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -47,6 +47,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/iscsi.schema.json b/rust/agama-lib/share/iscsi.schema.json index aa9dfb0409..47613787e1 100644 --- a/rust/agama-lib/share/iscsi.schema.json +++ b/rust/agama-lib/share/iscsi.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "https://github.com/openSUSE/agama/blob/master/rust/agama-lib/share/iscsi.schema.json", + "$id": "https://github.com/agama-project/agama/blob/master/rust/agama-lib/share/iscsi.schema.json", "title": "Config", "description": "iSCSI config.", "type": "object", diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 8c5df5597e..8ddb120f88 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -1,11 +1,20 @@ { "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "https://github.com/openSUSE/agama/blob/master/rust/agama-lib/share/profile.schema.json", + "$id": "https://github.com/agama-project/agama/blob/master/rust/agama-lib/share/profile.schema.json", "title": "Profile", "description": "Profile definition for automated installation", "type": "object", "additionalProperties": false, "properties": { + "$schema": { + "title": "URL of the JSON validation schema", + "description": "The schema location is ignored by the installer, it always uses the built-in schema, but it can be useful for automatic validation and code completion in some editors", + "type": "string", + "examples": [ + "https://raw.githubusercontent.com/agama-project/agama/refs/heads/SLE-16/rust/agama-lib/share/profile.schema.json", + "https://raw.githubusercontent.com/agama-project/agama/refs/heads/master/rust/agama-lib/share/profile.schema.json" + ] + }, "files": { "title": "User-defined files to deploy", "description": "User-defined files to deploy after installation just before post install scripts", @@ -290,7 +299,17 @@ "id": { "title": "Product identifier", "description": "The id field from a products.d/foo.yaml file", - "type": "string" + "type": "string", + "examples": [ + "Kalpa", + "MicroOS", + "openSUSE_Leap_Micro", + "openSUSE_Leap", + "SLES_SAP", + "SLES", + "Slowroll", + "Tumbleweed" + ] }, "registrationCode": { "title": "Product registration code", @@ -1028,18 +1047,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" @@ -1048,7 +1064,21 @@ "title": "Additional data for matching questions", "description": "Additional data for matching questions and answers", "type": "object", - "examples": [{ "device": "/dev/sda" }] + "examples": [ + { + "device": "/dev/sda" + } + ] + }, + "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/storage.schema.json b/rust/agama-lib/share/storage.schema.json index b045ea3b24..c01588d992 100644 --- a/rust/agama-lib/share/storage.schema.json +++ b/rust/agama-lib/share/storage.schema.json @@ -1,7 +1,7 @@ { "$comment": "Based on doc/auto_storage.md", "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "https://github.com/openSUSE/agama/blob/master/rust/agama-lib/share/storage.schema.json", + "$id": "https://github.com/agama-project/agama/blob/master/rust/agama-lib/share/storage.schema.json", "title": "Config", "description": "Storage config.", "type": "object", diff --git a/rust/agama-lib/src/http/base_http_client.rs b/rust/agama-lib/src/http/base_http_client.rs index 3895da60bf..1ceb64fa03 100644 --- a/rust/agama-lib/src/http/base_http_client.rs +++ b/rust/agama-lib/src/http/base_http_client.rs @@ -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 index 048c06e22e..378e0ea320 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -24,7 +24,6 @@ use crate::{ manager::InstallationPhase, network::model::NetworkChange, progress::Progress, - software::{model::Conflict, SelectedBy}, storage::{ model::{ dasd::{DASDDevice, DASDFormatSummary}, @@ -104,14 +103,6 @@ pub enum EventPayload { 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, diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index ffeded93e8..676b40a490 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -29,8 +29,8 @@ use crate::hostname::model::HostnameSettings; use crate::security::settings::SecuritySettings; use crate::storage::settings::zfcp::ZFCPConfig; use crate::{ - network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, - software::SoftwareSettings, storage::settings::dasd::DASDConfig, users::UserSettings, + network::NetworkSettings, scripts::ScriptsConfig, storage::settings::dasd::DASDConfig, + users::UserSettings, }; use fluent_uri::Uri; use serde::{Deserialize, Serialize}; @@ -71,10 +71,6 @@ pub struct InstallSettings { #[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")] diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 3c3e3094c7..b859d77ba1 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: //! @@ -58,14 +58,12 @@ 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/product.rs b/rust/agama-lib/src/product.rs deleted file mode 100644 index cc8974480a..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::{AddonSettings, 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 ed284e17d5..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(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[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(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[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/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs index 5ab57ebff4..767ce7ca99 100644 --- a/rust/agama-lib/src/scripts/store.rs +++ b/rust/agama-lib/src/scripts/store.rs @@ -18,11 +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 crate::{ - file_source::FileSourceError, - http::BaseHTTPClient, - software::{model::ResolvableType, SoftwareHTTPClient, SoftwareHTTPClientError}, -}; +use crate::{file_source::FileSourceError, http::BaseHTTPClient}; use super::{ client::{ScriptsClient, ScriptsClientError}, @@ -34,8 +30,6 @@ use super::{ 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), } @@ -44,14 +38,12 @@ 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), } } @@ -94,9 +86,10 @@ impl ScriptsStore { } packages.push("agama-scripts"); } - self.software - .set_resolvables("agama-scripts", ResolvableType::Package, &packages, true) - .await?; + // TODO: use the new API. + // self.software + // .set_resolvables("agama-scripts", ResolvableType::Package, &packages, true) + // .await?; Ok(()) } diff --git a/rust/agama-lib/src/software.rs b/rust/agama-lib/src/software.rs deleted file mode 100644 index b04e09f7be..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::{PatternsMap, PatternsSettings, 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/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 16406a7eca..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(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[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, utoipa::ToSchema)] -#[serde(untagged)] -pub enum PatternsSettings { - PatternsList(Vec), - PatternsMap(PatternsMap), -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -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/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/store.rs b/rust/agama-lib/src/store.rs index 1774654c59..f858ec633b 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -29,10 +29,8 @@ use crate::{ install_settings::InstallSettings, manager::{http_client::ManagerHTTPClientError, InstallationPhase, ManagerHTTPClient}, network::{NetworkStore, NetworkStoreError}, - product::{ProductHTTPClient, ProductStore, ProductStoreError}, scripts::{ScriptsClient, ScriptsClientError, ScriptsGroup, ScriptsStore, ScriptsStoreError}, security::store::{SecurityStore, SecurityStoreError}, - software::{SoftwareStore, SoftwareStoreError}, storage::{ http_client::{ iscsi::{ISCSIHTTPClient, ISCSIHTTPClientError}, @@ -62,12 +60,8 @@ pub enum StoreError { #[error(transparent)] Network(#[from] NetworkStoreError), #[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), @@ -82,8 +76,6 @@ pub enum StoreError { 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. @@ -99,9 +91,7 @@ pub struct Store { hostname: HostnameStore, users: UsersStore, network: NetworkStore, - product: ProductStore, security: SecurityStore, - software: SoftwareStore, storage: StorageStore, scripts: ScriptsStore, iscsi_client: ISCSIHTTPClient, @@ -119,9 +109,7 @@ impl Store { hostname: HostnameStore::new(http_client.clone()), users: UsersStore::new(http_client.clone()), network: NetworkStore::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()), @@ -140,9 +128,7 @@ impl Store { hostname: Some(self.hostname.load().await?), network: Some(self.network.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?), scripts: self.scripts.load().await?.to_option(), zfcp: self.zfcp.load().await?, ..Default::default() @@ -184,33 +170,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?; - 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? } @@ -219,19 +190,16 @@ 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 { @@ -250,20 +218,6 @@ 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()); diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 5004fffb6c..08b9754d5c 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -8,6 +8,7 @@ edition.workspace = true agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } agama-network = { path = "../agama-network" } +agama-software = { path = "../agama-software" } agama-storage = { path = "../agama-storage" } thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } @@ -15,6 +16,7 @@ async-trait = "0.1.83" zbus = { version = "5", default-features = false, features = ["tokio"] } merge-struct = "0.1.0" serde_json = "1.0.140" +tracing = "0.1.41" [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 39260e92ad..04df260a4b 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -28,4 +28,5 @@ pub mod message; pub use agama_l10n as l10n; pub use agama_network as network; +pub use agama_software as software; pub use agama_storage as storage; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index eaacf002f3..68a21d32fc 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,20 +18,25 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{l10n, message, network, storage}; +use std::sync::Arc; + +use crate::{l10n, message, network, software, storage}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ - self, event, status::State, Action, Config, Event, IssueMap, Proposal, Scope, Status, - SystemInfo, + self, event, manager, status::State, Action, Config, Event, Issue, IssueMap, IssueSeverity, + Proposal, Scope, Status, SystemInfo, }, - issue, progress, question, + issue, + license::{Error as LicenseError, LicensesRegistry}, + products::{ProductSpec, ProductsRegistry, ProductsRegistryError}, + progress, question, }; use async_trait::async_trait; use merge_struct::merge; use network::NetworkSystemClient; use serde_json::Value; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, RwLock}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -44,12 +49,18 @@ pub enum Error { #[error(transparent)] L10n(#[from] l10n::service::Error), #[error(transparent)] + Software(#[from] software::service::Error), + #[error(transparent)] Storage(#[from] storage::service::Error), #[error(transparent)] Issues(#[from] issue::service::Error), #[error(transparent)] Questions(#[from] question::service::Error), #[error(transparent)] + ProductsRegistry(#[from] ProductsRegistryError), + #[error(transparent)] + License(#[from] LicenseError), + #[error(transparent)] Progress(#[from] progress::service::Error), #[error(transparent)] Network(#[from] network::NetworkSystemError), @@ -57,13 +68,18 @@ pub enum Error { pub struct Service { l10n: Handler, + software: Handler, network: NetworkSystemClient, storage: Handler, issues: Handler, progress: Handler, questions: Handler, + products: ProductsRegistry, + licenses: LicensesRegistry, + product: Option>>, state: State, config: Config, + system: manager::SystemInfo, events: event::Sender, } @@ -71,6 +87,7 @@ impl Service { pub fn new( l10n: Handler, network: NetworkSystemClient, + software: Handler, storage: Handler, issues: Handler, progress: Handler, @@ -80,16 +97,49 @@ impl Service { Self { l10n, network, + software, storage, issues, progress, questions, - events, + products: ProductsRegistry::default(), + licenses: LicensesRegistry::default(), + // FIXME: state is already used for service state. state: State::Configuring, config: Config::default(), + system: manager::SystemInfo::default(), + product: None, + events, } } + /// Set up the service by reading the registries 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_registries().await?; + + if let Some(product) = self.products.default_product() { + let product = Arc::new(RwLock::new(product.clone())); + _ = self.software.cast(software::message::SetConfig::new( + Arc::clone(&product), + None, + )); + self.product = Some(product); + } + + self.update_issues(); + Ok(()) + } + + async fn read_registries(&mut self) -> Result<(), Error> { + self.licenses.read()?; + self.products.read()?; + self.system.licenses = self.licenses.licenses().into_iter().cloned().collect(); + self.system.products = self.products.products(); + Ok(()) + } + async fn configure_l10n(&self, config: api::l10n::SystemConfig) -> Result<(), Error> { self.l10n .call(l10n::message::SetSystem::new(config.clone())) @@ -129,6 +179,38 @@ impl Service { self.events.send(Event::StateChanged)?; Ok(()) } + + fn set_product_from_config(&mut self, config: &Config) { + 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 { + tracing::warn!("Unknown product '{id}'"); + } + } + } + + fn update_issues(&self) { + 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.", + IssueSeverity::Error, + ); + _ = self + .issues + .cast(issue::message::Set::new(Scope::Manager, vec![issue])); + } + } } impl Actor for Service { @@ -152,11 +234,12 @@ impl MessageHandler for Service { /// It returns the information of the underlying system. async fn handle(&mut self, _message: message::GetSystem) -> Result { 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?; - Ok(SystemInfo { l10n, + manager, network, storage, }) @@ -170,6 +253,7 @@ impl MessageHandler for Service { /// It includes user and default values. async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { 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?; @@ -178,6 +262,7 @@ impl MessageHandler for Service { l10n: Some(l10n), questions: questions, network: Some(network), + software: Some(software), storage, }) } @@ -195,18 +280,30 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { - /// Sets the config. + /// Sets the user configuration with the given values. async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - let config = message.config; + self.set_product_from_config(&message.config); - self.l10n - .call(l10n::message::SetConfig::new(config.l10n.clone())) - .await?; + self.config = message.config.clone(); + let config = message.config; self.questions .call(question::message::SetConfig::new(config.questions.clone())) .await?; + if let Some(product) = &self.product { + 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(config.storage.clone())) .await?; @@ -216,6 +313,7 @@ impl MessageHandler for Service { self.network.apply().await?; } + self.update_issues(); self.config = config; Ok(()) } @@ -243,6 +341,8 @@ impl MessageHandler for Service { let config = merge(&self.config, &message.config).map_err(|_| Error::MergeConfig)?; let config = merge_network(config, message.config); + self.set_product_from_config(&config); + if let Some(l10n) = &config.l10n { self.l10n .call(l10n::message::SetConfig::with(l10n.clone())) @@ -261,11 +361,23 @@ impl MessageHandler for Service { .await?; } + if let Some(product) = &self.product { + if let Some(software) = &config.software { + self.software + .call(software::message::SetConfig::with( + Arc::clone(&product), + software.clone(), + )) + .await?; + } + } + if let Some(network) = &config.network { self.network.update_config(network.clone()).await?; } self.config = config; + self.update_issues(); Ok(()) } } @@ -275,12 +387,14 @@ impl MessageHandler for Service { /// It returns the current proposal, if any. async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { 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 { l10n, network, + software, storage, })) } @@ -334,3 +448,13 @@ impl MessageHandler for Service { .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.software.call(message).await?; + Ok(()) + } +} diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 60537f10da..22723f504e 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.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 crate::{l10n, network, service::Service, storage}; +use crate::{l10n, network, service::Service, software, storage}; use agama_utils::{ actor::{self, Handler}, api::event, @@ -30,13 +30,17 @@ pub enum Error { #[error(transparent)] Progress(#[from] progress::start::Error), #[error(transparent)] - Issues(#[from] issue::start::Error), - #[error(transparent)] L10n(#[from] l10n::start::Error), #[error(transparent)] - Storage(#[from] storage::start::Error), + Manager(#[from] crate::service::Error), #[error(transparent)] Network(#[from] network::start::Error), + #[error(transparent)] + Software(#[from] software::start::Error), + #[error(transparent)] + Storage(#[from] storage::start::Error), + #[error(transparent)] + Issues(#[from] issue::start::Error), } /// Starts the manager service. @@ -52,9 +56,22 @@ pub async fn start( let issues = issue::start(events.clone(), dbus.clone()).await?; let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; - let storage = storage::start(progress.clone(), issues.clone(), events.clone(), dbus).await?; let network = network::start().await?; - let service = Service::new(l10n, network, storage, issues, progress, questions, events); + let software = software::start(issues.clone(), progress.clone(), events.clone()).await?; + let storage = storage::start(progress.clone(), issues.clone(), events.clone(), dbus).await?; + + let mut service = Service::new( + l10n, + network, + software, + storage, + issues, + progress, + questions, + events.clone(), + ); + service.setup().await?; + let handler = actor::spawn(service); Ok(handler) } @@ -67,6 +84,7 @@ mod test { api::{l10n, Config, Event}, question, test, }; + use std::path::PathBuf; use tokio::sync::broadcast; async fn start_service() -> Handler { @@ -88,6 +106,9 @@ mod test { #[tokio::test] #[cfg(not(ci))] async fn test_update_config() -> Result<(), Box> { + 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 handler = start_service().await; let input_config = Config { diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 42b3976db3..e92a997908 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -14,6 +14,7 @@ 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" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "2.0.12" @@ -57,8 +58,11 @@ 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" +serde_yaml = "0.9.34" strum = { version = "0.27.2", features = ["derive"] } [[bin]] @@ -71,3 +75,7 @@ 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/lib.rs b/rust/agama-server/src/lib.rs index 0339ee0d70..43f220d53b 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -29,7 +29,6 @@ pub mod manager; pub mod profile; pub mod scripts; pub mod security; -pub mod software; pub mod storage; pub mod users; pub mod web; diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 389b686cc7..ada38182cc 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -23,6 +23,7 @@ use crate::server::config_schema; use agama_lib::error::ServiceError; use agama_manager::{self as manager, message}; +use agama_software::Resolvable; use agama_utils::{ actor::Handler, api::{ @@ -33,9 +34,9 @@ use agama_utils::{ question, }; use axum::{ - extract::State, + extract::{Path, State}, response::{IntoResponse, Response}, - routing::{get, post}, + routing::{get, post, put}, Json, Router, }; use hyper::StatusCode; @@ -109,6 +110,7 @@ pub async fn server_service( "/private/storage_model", get(get_storage_model).put(set_storage_model), ) + .route("/private/resolvables/:id", put(set_resolvables)) .with_state(state)) } @@ -378,6 +380,29 @@ async fn set_storage_model( Ok(()) } +#[utoipa::path( + put, + path = "/resolvables/:id", + context_path = "/api/v2", + responses( + (status = 200, description = "The resolvables list was updated.") + ) +)] +async fn set_resolvables( + State(state): State, + Path(id): Path, + Json(resolvables): Json>, +) -> ServerResult<()> { + state + .manager + .call(agama_software::message::SetResolvables::new( + id, + resolvables, + )) + .await?; + Ok(()) +} + fn to_option_response(value: Option) -> Response { match value { Some(inner) => Json(inner).into_response(), diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs deleted file mode 100644 index a52e2947d9..0000000000 --- a/rust/agama-server/src/software/web.rs +++ /dev/null @@ -1,836 +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 software service. -//! -//! The module offers two public functions: -//! -//! * `software_service` which returns the Axum service. -//! * `software_stream` which offers an stream that emits the software events coming from D-Bus. - -use crate::{ - error::Error, - web::common::{service_status_router, EventStreams, ProgressClient, ProgressRouterBuilder}, -}; - -use agama_lib::{ - error::ServiceError, - event, - http::{self, EventPayload, OldEvent}, - product::{proxies::RegistrationProxy, Product, ProductClient}, - software::{ - model::{ - AddonParams, AddonProperties, Conflict, ConflictSolve, License, LicenseContent, - LicensesRepo, RegistrationError, RegistrationInfo, RegistrationParams, Repository, - ResolvableParams, SoftwareConfig, - }, - proxies::{Software1Proxy, SoftwareProductProxy}, - Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy, - }, -}; -use anyhow::Context; -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{get, post, put}, - Json, Router, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; -use tokio_stream::{Stream, StreamExt}; - -#[derive(Clone)] -struct SoftwareState<'a> { - product: ProductClient<'a>, - software: SoftwareClient<'a>, - licenses: LicensesRepo, - // cache the software values, during installation the software service is - // not responsive (blocked in a libzypp call) - products: Arc>>, - config: Arc>>, -} - -/// Returns an stream that emits software related events coming from D-Bus. -/// -/// It emits the Event::ProductChanged and Event::PatternsChanged events. -/// -/// * `connection`: D-Bus connection to listen for events. -pub async fn software_streams(dbus: zbus::Connection) -> Result { - let result: EventStreams = vec![ - ( - "patterns_changed", - Box::pin(patterns_changed_stream(dbus.clone()).await?), - ), - ( - "conflicts_changed", - Box::pin(conflicts_changed_stream(dbus.clone()).await?), - ), - ( - "product_changed", - Box::pin(product_changed_stream(dbus.clone()).await?), - ), - ( - "registration_code_changed", - Box::pin(registration_code_changed_stream(dbus.clone()).await?), - ), - ( - "registration_email_changed", - Box::pin(registration_email_changed_stream(dbus.clone()).await?), - ), - ]; - - Ok(result) -} - -async fn product_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = SoftwareProductProxy::new(&dbus).await?; - let stream = proxy - .receive_selected_product_changed() - .await - .then(|change| async move { - if let Ok(id) = change.get().await { - return Some(event!(ProductChanged { id })); - } - None - }) - .filter_map(|e| e); - Ok(stream) -} - -async fn patterns_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = Software1Proxy::new(&dbus).await?; - let stream = proxy - .receive_selected_patterns_changed() - .await - .then(|change| async move { - if let Ok(patterns) = change.get().await { - return match reason_to_selected_by(patterns) { - Ok(patterns) => Some(patterns), - Err(error) => { - tracing::warn!("Ignoring the list of changed patterns. Error: {}", error); - None - } - }; - } - None - }) - .filter_map(|e| e.map(|patterns| event!(SoftwareProposalChanged { patterns }))); - Ok(stream) -} - -async fn conflicts_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = Software1Proxy::new(&dbus).await?; - let stream = proxy - .receive_conflicts_changed() - .await - .then(|change| async move { - if let Ok(conflicts) = change.get().await { - return Some( - conflicts - .into_iter() - .map(|c| Conflict::from_dbus(c)) - .collect(), - ); - } - None - }) - .filter_map(|e| e.map(|conflicts| event!(ConflictsChanged { conflicts }))); - Ok(stream) -} - -async fn registration_email_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = RegistrationProxy::new(&dbus).await?; - let stream = proxy - .receive_email_changed() - .await - .then(|change| async move { - if let Ok(_id) = change.get().await { - // TODO: add to stream also proxy and return whole cached registration info - return Some(event!(RegistrationChanged)); - } - None - }) - .filter_map(|e| e); - Ok(stream) -} - -async fn registration_code_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = RegistrationProxy::new(&dbus).await?; - let stream = proxy - .receive_reg_code_changed() - .await - .then(|change| async move { - if let Ok(_id) = change.get().await { - return Some(event!(RegistrationChanged)); - } - None - }) - .filter_map(|e| e); - Ok(stream) -} - -// Returns a hash replacing the selection "reason" from D-Bus with a SelectedBy variant. -fn reason_to_selected_by( - patterns: HashMap, -) -> Result, UnknownSelectedBy> { - let mut selected: HashMap = HashMap::new(); - for (id, reason) in patterns { - match SelectedBy::try_from(reason) { - Ok(selected_by) => selected.insert(id, selected_by), - Err(e) => return Err(e), - }; - } - Ok(selected) -} - -/// Process incoming events. -/// -/// * `events`: channel to listen for events. -/// * `products`: list of products (shared behind a mutex). -pub async fn receive_events( - mut events: http::event::OldReceiver, - products: Arc>>, - config: Arc>>, - client: ProductClient<'_>, -) { - while let Ok(event) = events.recv().await { - match event.payload { - EventPayload::LocaleChanged { locale: _ } => { - let mut cached_products = products.write().await; - if let Ok(products) = client.products().await { - *cached_products = products; - } else { - tracing::error!("Could not update the products cached"); - } - } - - EventPayload::SoftwareProposalChanged { patterns } => { - let mut cached_config = config.write().await; - if let Some(config) = cached_config.as_mut() { - tracing::debug!( - "Updating the patterns list in the software configuration cache" - ); - let user_patterns: HashMap = patterns - .into_iter() - .filter_map(|(p, s)| { - if s == SelectedBy::User { - Some((p, true)) - } else { - None - } - }) - .collect(); - config.patterns = Some(user_patterns); - } - } - - _ => {} - } - } -} - -/// Sets up and returns the axum service for the software module. -pub async fn software_service( - dbus: zbus::Connection, - events: http::event::OldReceiver, - progress: ProgressClient, -) -> Result { - const DBUS_SERVICE: &str = "org.opensuse.Agama.Software1"; - const DBUS_PATH: &str = "/org/opensuse/Agama/Software1"; - - 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 mut licenses_repo = LicensesRepo::default(); - if let Err(error) = licenses_repo.read() { - tracing::error!("Could not read the licenses repository: {:?}", error); - } - - let product = ProductClient::new(dbus.clone()).await?; - let software = SoftwareClient::new(dbus).await?; - let all_products = product.products().await?; - - let state = SoftwareState { - product, - software, - licenses: licenses_repo, - products: Arc::new(RwLock::new(all_products)), - config: Arc::new(RwLock::new(None)), - }; - - let cached_products = Arc::clone(&state.products); - let cached_config = Arc::clone(&state.config); - let products_client = state.product.clone(); - tokio::spawn(async move { - receive_events(events, cached_products, cached_config, products_client).await - }); - - let router = Router::new() - .route("/patterns", get(patterns)) - .route("/conflicts", get(get_conflicts).patch(solve_conflicts)) - .route("/repositories", get(repositories)) - .route("/products", get(products)) - .route("/licenses", get(licenses)) - .route("/licenses/:id", get(license)) - .route( - "/registration", - get(get_registration).post(register).delete(deregister), - ) - .route("/registration/url", put(set_reg_url)) - .route("/registration/addons/register", post(register_addon)) - .route( - "/registration/addons/registered", - get(get_registered_addons), - ) - .route("/registration/addons/available", get(get_available_addons)) - .route("/proposal", get(proposal)) - .route("/config", put(set_config).get(get_config)) - .route("/probe", post(probe)) - .route("/resolvables/:id", put(set_resolvables)) - .merge(status_router) - .merge(progress_router) - .with_state(state); - Ok(router) -} - -/// Returns the list of available products. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/products", - context_path = "/api/software", - responses( - (status = 200, description = "List of known products", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn products(State(state): State>) -> Result>, Error> { - let products = state.products.read().await.clone(); - Ok(Json(products)) -} - -/// Returns the list of defined repositories. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/repositories", - context_path = "/api/software", - responses( - (status = 200, description = "List of known repositories", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn repositories( - State(state): State>, -) -> Result>, Error> { - let repositories = state.software.repositories().await?; - Ok(Json(repositories)) -} - -/// Returns the list of conflicts that proposal found. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/conflicts", - context_path = "/api/software", - responses( - (status = 200, description = "List of software conflicts", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_conflicts( - State(state): State>, -) -> Result>, Error> { - let conflicts = state.software.get_conflicts().await?; - Ok(Json(conflicts)) -} - -/// Solve conflicts. Not all conflicts needs to be solved at once. -/// -/// * `state`: service state. -#[utoipa::path( - patch, - path = "/conflicts", - context_path = "/api/software", - request_body = Vec, - responses( - (status = 200, description = "Operation success"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn solve_conflicts( - State(state): State>, - Json(solutions): Json>, -) -> Result<(), Error> { - let ret = state.software.solve_conflicts(solutions).await?; - - // refresh the config cache - let config = read_config(&state).await?; - tracing::info!("Caching product configuration: {:?}", &config); - let mut cached_config_write = state.config.write().await; - *cached_config_write = Some(config); - - Ok(ret) -} - -/// returns registration info -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/registration", - context_path = "/api/software", - responses( - (status = 200, description = "registration configuration", body = RegistrationInfo), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_registration( - State(state): State>, -) -> Result, Error> { - let result = RegistrationInfo { - registered: state.product.registered().await?, - key: state.product.registration_code().await?, - email: state.product.email().await?, - url: state.product.registration_url().await?, - }; - Ok(Json(result)) -} - -/// sets registration server url -/// -/// * `state`: service state. -#[utoipa::path( - put, - path = "/registration/url", - context_path = "/api/software", - responses( - (status = 200, description = "registration server set"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn set_reg_url( - State(state): State>, - Json(config): Json, -) -> Result<(), Error> { - state.product.set_registration_url(&config).await?; - Ok(()) -} - -/// Register product -/// -/// * `state`: service state. -#[utoipa::path( - post, - path = "/registration", - context_path = "/api/software", - responses( - (status = 204, description = "registration successful"), - (status = 422, description = "Registration failed. Details are in body", body = RegistrationError), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn register( - State(state): State>, - Json(config): Json, -) -> Result { - let (id, message) = state.product.register(&config.key, &config.email).await?; - if id == 0 { - Ok((StatusCode::NO_CONTENT, ().into_response())) - } else { - let details = RegistrationError { id, message }; - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Json(details).into_response(), - )) - } -} - -/// returns list of registered addons -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/registration/addons/registered", - context_path = "/api/software", - responses( - (status = 200, description = "List of registered addons", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_registered_addons( - State(state): State>, -) -> Result>, Error> { - let result = state.product.registered_addons().await?; - - Ok(Json(result)) -} - -/// returns list of available addons -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/registration/addons/available", - context_path = "/api/software", - responses( - (status = 200, description = "List of available addons", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_available_addons( - State(state): State>, -) -> Result>, Error> { - let result = state.product.available_addons().await?; - - Ok(Json(result)) -} - -/// Register an addon -/// -/// * `state`: service state. -#[utoipa::path( - post, - path = "/registration/addons/register", - context_path = "/api/software", - responses( - (status = 204, description = "registration successful"), - (status = 422, description = "Registration failed. Details are in the body", body = RegistrationError), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn register_addon( - State(state): State>, - Json(addon): Json, -) -> Result { - let (id, message) = state.product.register_addon(&addon).await?; - if id == 0 { - Ok((StatusCode::NO_CONTENT, ().into_response())) - } else { - let details = RegistrationError { id, message }; - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Json(details).into_response(), - )) - } -} - -/// Deregister product -/// -/// * `state`: service state. -#[utoipa::path( - delete, - path = "/registration", - context_path = "/api/software", - responses( - (status = 200, description = "deregistration successful"), - (status = 422, description = "De-registration failed. Details are in body", body = RegistrationError), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn deregister(State(state): State>) -> Result { - let (id, message) = state.product.deregister().await?; - let details = RegistrationError { id, message }; - if id == 0 { - Ok((StatusCode::NO_CONTENT, ().into_response())) - } else { - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Json(details).into_response(), - )) - } -} - -/// Returns the list of software patterns. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/patterns", - context_path = "/api/software", - responses( - (status = 200, description = "List of known software patterns", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn patterns(State(state): State>) -> Result>, Error> { - let patterns = state.software.patterns(true).await?; - Ok(Json(patterns)) -} - -/// Sets the software configuration. -/// -/// * `state`: service state. -/// * `config`: software configuration. -#[utoipa::path( - put, - path = "/config", - context_path = "/api/software", - operation_id = "set_software_config", - responses( - (status = 200, description = "Set the software 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> { - { - // first invalidate cache, so if it fails later, we know we need to re-read recent data - // use minimal context so it is released soon. - tracing::debug!("Invalidating product configuration cache"); - let mut cached_config_invalidate = state.config.write().await; - *cached_config_invalidate = None; - } - - // first set only require flag to ensure that it is used for later computing of solver - if let Some(only_required) = config.only_required { - state.software.set_only_required(only_required).await?; - } - - if let Some(product) = config.product { - state.product.select_product(&product).await?; - } - - if let Some(patterns) = config.patterns { - state.software.select_patterns(patterns).await?; - } - - if let Some(packages) = config.packages { - state.software.select_packages(packages).await?; - } - - if let Some(repositories) = config.extra_repositories { - state.software.set_user_repositories(repositories).await?; - } - - // load the config cache - let config = read_config(&state).await?; - tracing::debug!("Caching software configuration (set_config): {:?}", &config); - let mut cached_config_write = state.config.write().await; - *cached_config_write = Some(config); - - Ok(()) -} - -/// Returns the software configuration. -/// -/// * `state` : service state. -#[utoipa::path( - get, - path = "/config", - context_path = "/api/software", - operation_id = "get_software_config", - responses( - (status = 200, description = "Software configuration", body = SoftwareConfig), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] - -async fn get_config(State(state): State>) -> Result, Error> { - let cached_config = state.config.read().await.clone(); - - if let Some(config) = cached_config { - tracing::debug!("Returning cached software config: {:?}", &config); - return Ok(Json(config)); - } - - let config = read_config(&state).await?; - tracing::debug!("Caching software configuration (get_config): {:?}", &config); - let mut cached_config_write = state.config.write().await; - *cached_config_write = Some(config.clone()); - - Ok(Json(config)) -} - -/// Helper function -/// * `state` : software service state -async fn read_config(state: &SoftwareState<'_>) -> Result { - let product = state.product.product().await?; - let product = if product.is_empty() { - None - } else { - Some(product) - }; - let patterns = state - .software - .user_selected_patterns() - .await? - .into_iter() - .map(|p| (p, true)) - .collect(); - let packages = state.software.user_selected_packages().await?; - let repos = state.software.user_repositories().await?; - - Ok(SoftwareConfig { - patterns: Some(patterns), - packages: Some(packages), - product, - extra_repositories: if repos.is_empty() { None } else { Some(repos) }, - only_required: state.software.get_only_required().await?, - }) -} - -#[derive(Serialize, utoipa::ToSchema)] -/// Software proposal information. -pub struct SoftwareProposal { - /// Space required for installation. It is returned as a formatted string which includes - /// a number and a unit (e.g., "GiB"). - size: String, - /// Patterns selection. It is represented as a hash map where the key is the pattern's name - /// and the value why the pattern is selected. - patterns: HashMap, -} - -/// Returns the proposal information. -/// -/// At this point, only the required space is reported. -#[utoipa::path( - get, - path = "/proposal", - context_path = "/api/software", - responses( - (status = 200, description = "Software proposal", body = SoftwareProposal) - ) -)] -async fn proposal(State(state): State>) -> Result, Error> { - let size = state.software.used_disk_space().await?; - let patterns = state.software.selected_patterns().await?; - let proposal = SoftwareProposal { size, patterns }; - Ok(Json(proposal)) -} - -/// Returns the proposal information. -/// -/// At this point, only the required space is reported. -#[utoipa::path( - post, - path = "/probe", - context_path = "/api/software", - responses( - (status = 200, description = "Read repositories data"), - (status = 400, description = "The D-Bus service could not perform the action -") - ), - operation_id = "software_probe" -)] -async fn probe(State(state): State>) -> Result, Error> { - state.software.probe().await?; - Ok(Json(())) -} - -/// Updates the resolvables list with the given `id`. -#[utoipa::path( - put, - path = "/resolvables/:id", - context_path = "/api/software", - responses( - (status = 200, description = "Read repositories data"), - (status = 400, description = "The D-Bus service could not perform the action -") - ) -)] -async fn set_resolvables( - State(state): State>, - Path(id): Path, - Json(params): Json, -) -> Result, Error> { - let names: Vec<_> = params.names.iter().map(|n| n.as_str()).collect(); - state - .software - .set_resolvables(&id, params.r#type, &names, params.optional) - .await?; - Ok(Json(())) -} - -/// Returns the list of known licenses. -/// -/// It includes the license ID and the languages in which it is available. -#[utoipa::path( - get, - path = "/licenses", - context_path = "/api/software", - responses( - (status = 200, description = "List of known licenses", body = Vec) - ) -)] -async fn licenses(State(state): State>) -> Result>, Error> { - Ok(Json(state.licenses.licenses.clone())) -} - -#[derive(Deserialize, utoipa::IntoParams)] -struct LicenseQuery { - lang: Option, -} - -/// Returns the license content. -/// -/// Optionally it can receive a language tag (RFC 5646). Otherwise, it returns -/// the license in English. -#[utoipa::path( - get, - path = "/licenses/:id", - context_path = "/api/software", - params(LicenseQuery), - responses( - (status = 200, description = "License with the given ID", body = LicenseContent), - (status = 400, description = "The specified language tag is not valid"), - (status = 404, description = "There is not license with the given ID") - ) -)] -async fn license( - State(state): State>, - Path(id): Path, - Query(query): Query, -) -> Result { - let lang = query.lang.unwrap_or("en".to_string()); - - let Ok(lang) = lang.as_str().try_into() else { - return Ok(StatusCode::BAD_REQUEST.into_response()); - }; - - if let Some(license) = state.licenses.find(&id, &lang) { - Ok(Json(license).into_response()) - } else { - Ok(StatusCode::NOT_FOUND.into_response()) - } -} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 6641c41aba..aaf443a033 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -34,7 +34,6 @@ use crate::{ scripts::web::scripts_service, security::security_service, server::server_service, - software::web::{software_service, software_streams}, storage::web::{iscsi::iscsi_service, storage_service, storage_streams}, users::web::{users_service, users_streams}, web::common::{jobs_stream, service_status_stream}, @@ -85,10 +84,6 @@ where ) .add_service("/v2", server_service(events, dbus.clone()).await?) .add_service("/security", security_service(dbus.clone()).await?) - .add_service( - "/software", - software_service(dbus.clone(), old_events.subscribe(), progress.clone()).await?, - ) .add_service("/storage", storage_service(dbus.clone(), progress).await?) .add_service("/iscsi", iscsi_service(dbus.clone()).await?) .add_service("/bootloader", bootloader_service(dbus.clone()).await?) @@ -137,9 +132,6 @@ async fn run_events_monitor(dbus: zbus::Connection, events: OldSender) -> Result for (id, storage_stream) in storage_streams(dbus.clone()).await? { stream.insert(id, storage_stream); } - for (id, software_stream) in software_streams(dbus.clone()).await? { - stream.insert(id, software_stream); - } stream.insert( "storage-status", service_status_stream( @@ -159,15 +151,6 @@ async fn run_events_monitor(dbus: zbus::Connection, events: OldSender) -> Result ) .await?, ); - stream.insert( - "software-status", - service_status_stream( - dbus.clone(), - "org.opensuse.Agama.Software1", - "/org/opensuse/Agama/Software1", - ) - .await?, - ); tokio::pin!(stream); let e = events.clone(); diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 87fcffbc08..3b27aefc0f 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -28,8 +28,6 @@ mod storage; pub use storage::StorageApiDocBuilder; mod bootloader; pub use bootloader::BootloaderApiDocBuilder; -mod software; -pub use software::SoftwareApiDocBuilder; mod profile; pub use profile::ProfileApiDocBuilder; mod manager; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 1ae7c030db..1c0960f5a9 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -92,9 +92,8 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -105,20 +104,12 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -182,8 +173,8 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() .schema_from::() - .schema_from::() .build() } } diff --git a/rust/agama-server/src/web/docs/software.rs b/rust/agama-server/src/web/docs/software.rs deleted file mode 100644 index fedd4e77e3..0000000000 --- a/rust/agama-server/src/web/docs/software.rs +++ /dev/null @@ -1,74 +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 utoipa::openapi::{Components, ComponentsBuilder, OpenApi, Paths, PathsBuilder}; - -use super::{common::ServiceStatusApiDocBuilder, ApiDocBuilder}; - -pub struct SoftwareApiDocBuilder; - -impl ApiDocBuilder for SoftwareApiDocBuilder { - fn title(&self) -> String { - "Software HTTP API".to_string() - } - - fn paths(&self) -> Paths { - PathsBuilder::new() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .build() - } - - fn components(&self) -> Components { - ComponentsBuilder::new() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .build() - } - - fn nested(&self) -> Option { - let status = ServiceStatusApiDocBuilder::new("/api/storage/status").build(); - Some(status) - } -} diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index 2bb18516a1..2b91e84657 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -29,10 +29,14 @@ use axum::{ }; use common::body_to_string; use std::error::Error; +use std::path::PathBuf; use tokio::{sync::broadcast::channel, test}; use tower::ServiceExt; async fn build_server_service() -> Result { + 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 (tx, mut rx) = channel(16); let dbus = test::dbus::connection().await.unwrap(); @@ -176,16 +180,15 @@ async fn test_patch_config() -> Result<(), Box> { let response = server_service.clone().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let patch = api::config::Patch { - update: Some(api::Config { - l10n: Some(api::l10n::Config { - locale: None, - keymap: Some("en".to_string()), - timezone: None, - }), - ..Default::default() + let config = api::Config { + l10n: Some(api::l10n::Config { + locale: None, + keymap: Some("en".to_string()), + timezone: None, }), + ..Default::default() }; + let patch = agama_utils::api::Patch::with_update(&config).unwrap(); let request = Request::builder() .uri("/config") diff --git a/rust/agama-software/Cargo.toml b/rust/agama-software/Cargo.toml new file mode 100644 index 0000000000..b10801571e --- /dev/null +++ b/rust/agama-software/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agama-software" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-locale-data = { path = "../agama-locale-data" } +agama-utils = { path = "../agama-utils" } +async-trait = "0.1.89" +glob = "0.3.1" +regex = "1.11.0" +serde = { version = "1.0.210", features = ["derive"] } +serde_with = "3.10.0" +strum = { version = "0.27.2", features = ["derive"] } +thiserror = "2.0.12" +tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.16" +tracing = "0.1.41" +utoipa = { version = "5.2.0", features = ["axum_extras", "uuid"] } +zypp-agama = { path = "../zypp-agama" } + +[dev-dependencies] +serde_yaml = "0.9.34" diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs new file mode 100644 index 0000000000..808d465c07 --- /dev/null +++ b/rust/agama-software/src/lib.rs @@ -0,0 +1,48 @@ +// 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 software handling in Agama. +//! It takes care of setting the product, registration and software for the +//! target system. +//! +//! From a technical point of view, it includes: +//! +//! * The [Config] 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 available +//! stuff like products, repositories and others. +//! +//! The service can be started by calling the [start_service] function, which +//! returns a [agama_utils::actors::ActorHandler] to interact with the system +//! and also creates own separate thread for libzypp to satisfy its requirements. + +pub mod start; +pub use start::start; + +pub mod service; +pub use service::Service; + +mod model; +pub use model::{Model, ModelAdapter, Resolvable, ResolvableType}; + +pub mod message; +mod zypp_server; diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs new file mode 100644 index 0000000000..fbe68f6521 --- /dev/null +++ b/rust/agama-software/src/message.rs @@ -0,0 +1,118 @@ +// 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::software::{Config, Proposal, SystemInfo}, + products::ProductSpec, +}; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::Resolvable; + +#[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, + pub product: Arc>, +} + +impl Message for SetConfig { + type Reply = (); +} + +impl SetConfig { + pub fn new(product: Arc>, config: Option) -> Self { + Self { config, product } + } + + pub fn with(product: Arc>, config: T) -> Self { + Self { + config: Some(config), + product, + } + } +} + +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option; +} + +pub struct Install; + +impl Message for Install { + type Reply = bool; +} + +pub struct Refresh; + +impl Message for Refresh { + type Reply = (); +} + +pub struct Finish; + +impl Message for Finish { + type Reply = (); +} + +// Sets a resolvables list +pub struct SetResolvables { + pub id: String, + pub resolvables: Vec, +} + +impl SetResolvables { + pub fn new(id: String, resolvables: Vec) -> Self { + Self { id, resolvables } + } +} + +impl Message for SetResolvables { + type Reply = (); +} diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs new file mode 100644 index 0000000000..386a55bc04 --- /dev/null +++ b/rust/agama-software/src/model.rs @@ -0,0 +1,161 @@ +// 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_utils::{ + actor::Handler, + api::{ + software::{Pattern, SoftwareProposal}, + Issue, + }, + products::{ProductSpec, UserPattern}, + progress, +}; +use async_trait::async_trait; +use tokio::sync::{mpsc, oneshot}; + +use crate::{model::state::SoftwareState, service, zypp_server::SoftwareAction}; + +pub mod conflict; +pub mod packages; +pub mod registration; +pub mod software_selection; +pub mod state; + +pub use packages::{Resolvable, ResolvableType}; + +/// Abstract the software-related configuration from the underlying system. +/// +/// It offers an API to query and set different software and product elements of a +/// libzypp. This trait can be implemented to replace the real libzypp interaction during +/// tests. +#[async_trait] +pub trait ModelAdapter: Send + Sync + 'static { + /// List of available patterns. + async fn patterns(&self) -> Result, service::Error>; + + async fn compute_proposal(&self) -> Result; + + /// Refresh repositories information. + async fn refresh(&mut self) -> Result<(), service::Error>; + + /// install rpms to target system + async fn install(&self) -> Result; + + /// Finalizes system like disabling local repositories + async fn finish(&self) -> Result<(), service::Error>; + + fn set_product(&mut self, product_spec: ProductSpec); + + /// Applies the configuration to the system. + /// + /// It does not perform the installation, just update the repositories and + /// the software selection. + async fn write( + &mut self, + software: SoftwareState, + progress: Handler, + ) -> Result, service::Error>; +} + +/// [ModelAdapter] implementation for libzypp systems. +pub struct Model { + zypp_sender: mpsc::UnboundedSender, + // FIXME: what about having a SoftwareServiceState to keep business logic state? + selected_product: Option, +} + +impl Model { + /// Initializes the struct with the information from the underlying system. + pub fn new(zypp_sender: mpsc::UnboundedSender) -> Result { + Ok(Self { + zypp_sender, + selected_product: None, + }) + } +} + +#[async_trait] +impl ModelAdapter for Model { + fn set_product(&mut self, product_spec: ProductSpec) { + self.selected_product = Some(product_spec); + } + + async fn write( + &mut self, + software: SoftwareState, + progress: Handler, + ) -> Result, service::Error> { + let (tx, rx) = oneshot::channel(); + self.zypp_sender.send(SoftwareAction::Write { + state: software, + progress, + tx, + })?; + Ok(rx.await??) + } + + async fn patterns(&self) -> Result, service::Error> { + let Some(product) = &self.selected_product else { + return Err(service::Error::MissingProduct); + }; + + let names = product + .software + .user_patterns + .iter() + .map(|user_pattern| match user_pattern { + UserPattern::Plain(name) => name.clone(), + UserPattern::Preselected(preselected) => preselected.name.clone(), + }) + .collect(); + + let (tx, rx) = oneshot::channel(); + self.zypp_sender + .send(SoftwareAction::GetPatternsMetadata(names, tx))?; + Ok(rx.await??) + } + + async fn refresh(&mut self) -> Result<(), service::Error> { + unimplemented!() + } + + async fn finish(&self) -> Result<(), service::Error> { + let (tx, rx) = oneshot::channel(); + self.zypp_sender.send(SoftwareAction::Finish(tx))?; + Ok(rx.await??) + } + + async fn install(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.zypp_sender.send(SoftwareAction::Install(tx))?; + Ok(rx.await??) + } + + async fn compute_proposal(&self) -> Result { + let Some(product_spec) = self.selected_product.clone() else { + return Err(service::Error::MissingProduct); + }; + + let (tx, rx) = oneshot::channel(); + self.zypp_sender + .send(SoftwareAction::ComputeProposal(product_spec, tx))?; + Ok(rx.await??) + } +} diff --git a/rust/agama-lib/src/software/model/conflict.rs b/rust/agama-software/src/model/conflict.rs similarity index 68% rename from rust/agama-lib/src/software/model/conflict.rs rename to rust/agama-software/src/model/conflict.rs index 527fea41a7..05c3392051 100644 --- a/rust/agama-lib/src/software/model/conflict.rs +++ b/rust/agama-software/src/model/conflict.rs @@ -61,44 +61,3 @@ pub struct Conflict { /// list of possible solutions pub solutions: Vec, } - -impl Solution { - pub fn from_dbus(dbus_solution: (u32, String, String)) -> Self { - let details = dbus_solution.2; - let details = if details.is_empty() { - None - } else { - Some(details) - }; - - Self { - id: dbus_solution.0, - description: dbus_solution.1, - details, - } - } -} - -impl Conflict { - pub fn from_dbus(dbus_conflict: (u32, String, String, Vec<(u32, String, String)>)) -> Self { - let details = dbus_conflict.2; - let details = if details.is_empty() { - None - } else { - Some(details) - }; - - let solutions = dbus_conflict.3; - let solutions = solutions - .into_iter() - .map(|s| Solution::from_dbus(s)) - .collect(); - - Self { - id: dbus_conflict.0, - description: dbus_conflict.1, - details, - solutions, - } - } -} diff --git a/rust/agama-software/src/model/packages.rs b/rust/agama-software/src/model/packages.rs new file mode 100644 index 0000000000..d04ff4c640 --- /dev/null +++ b/rust/agama-software/src/model/packages.rs @@ -0,0 +1,60 @@ +// 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}; + +/// Represents a software resolvable. +#[derive(Clone, Debug, Deserialize, PartialEq, utoipa::ToSchema)] +pub struct Resolvable { + pub name: String, + #[serde(rename = "type")] + pub r#type: ResolvableType, +} + +impl Resolvable { + pub fn new(name: &str, r#type: ResolvableType) -> Self { + Self { + name: name.to_string(), + r#type, + } + } +} + +/// Software resolvable type (package or pattern). +#[derive( + Clone, Copy, Debug, Deserialize, Serialize, strum::Display, utoipa::ToSchema, PartialEq, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum ResolvableType { + Package = 0, + Pattern = 1, + Product = 2, +} + +impl From for zypp_agama::ResolvableKind { + fn from(value: ResolvableType) -> Self { + match value { + ResolvableType::Package => zypp_agama::ResolvableKind::Package, + ResolvableType::Product => zypp_agama::ResolvableKind::Product, + ResolvableType::Pattern => zypp_agama::ResolvableKind::Pattern, + } + } +} diff --git a/rust/agama-lib/src/software/model/registration.rs b/rust/agama-software/src/model/registration.rs similarity index 74% rename from rust/agama-lib/src/software/model/registration.rs rename to rust/agama-software/src/model/registration.rs index 29b05b62dd..acac8ff6ba 100644 --- a/rust/agama-lib/src/software/model/registration.rs +++ b/rust/agama-software/src/model/registration.rs @@ -41,30 +41,6 @@ pub struct AddonParams { 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")] diff --git a/rust/agama-software/src/model/software_selection.rs b/rust/agama-software/src/model/software_selection.rs new file mode 100644 index 0000000000..2371dbd678 --- /dev/null +++ b/rust/agama-software/src/model/software_selection.rs @@ -0,0 +1,84 @@ +// 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::collections::HashMap; + +use crate::Resolvable; + +/// A selection of resolvables to be installed. +/// +/// It holds a selection of patterns and packages to be installed and whether they are optional or +/// not. This class is similar to the `PackagesProposal` YaST module. +#[derive(Default)] +pub struct SoftwareSelection(HashMap>); + +impl SoftwareSelection { + /// Updates a set of resolvables. + /// + /// * `id` - The id of the set. + /// * `optional` - Whether the selection is optional or not. + /// * `resolvables` - The resolvables included in the set. + pub fn set(&mut self, id: &str, resolvables: Vec) { + self.0.insert(id.to_string(), resolvables); + } + + /// Remove the selection list with the given ID. + pub fn remove(&mut self, id: &str) { + self.0.remove(id); + } + + /// Returns all the resolvables. + pub fn resolvables<'a>(&'a self) -> impl Iterator + 'a { + self.0.values().flatten().cloned() + } +} + +#[cfg(test)] +mod tests { + use crate::ResolvableType; + + use super::{Resolvable, SoftwareSelection}; + + #[test] + fn test_set_selection() { + let mut selection = SoftwareSelection::default(); + let resolvable = Resolvable::new("agama-scripts", ResolvableType::Package); + selection.set("agama", vec![resolvable]); + let resolvable = Resolvable::new("btrfsprogs", ResolvableType::Pattern); + selection.set("software", vec![resolvable]); + + let all_resolvables: Vec<_> = selection.resolvables().collect(); + assert_eq!(all_resolvables.len(), 2); + } + + #[test] + fn test_remove_selection() { + let mut selection = SoftwareSelection::default(); + let resolvable = Resolvable::new("agama-scripts", ResolvableType::Package); + selection.set("agama", vec![resolvable]); + + let all_resolvables: Vec<_> = selection.resolvables().collect(); + assert_eq!(all_resolvables.len(), 1); + + selection.remove("agama"); + let all_resolvables: Vec<_> = selection.resolvables().collect(); + assert!(all_resolvables.is_empty()); + } +} diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs new file mode 100644 index 0000000000..f6a717661b --- /dev/null +++ b/rust/agama-software/src/model/state.rs @@ -0,0 +1,529 @@ +// 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 mechanism to build the wanted software +//! configuration and a mechanism to build it starting from the product +//! definition, the user configuration, etc. + +use agama_utils::{ + api::software::{Config, PatternsConfig, RepositoryConfig, SystemInfo}, + products::{ProductSpec, UserPattern}, +}; + +use crate::{model::software_selection::SoftwareSelection, Resolvable, ResolvableType}; + +/// Represents the wanted software configuration. +/// +/// It includes the list of repositories, selected resolvables, configuration +/// options, etc. This configuration is later applied by a model adapter. +/// +/// The SoftwareState is built by the [SoftwareStateBuilder] using different +/// sources (the product specification, the user configuration, etc.). +#[derive(Debug)] +pub struct SoftwareState { + pub product: String, + pub repositories: Vec, + pub resolvables: Vec, + pub options: SoftwareOptions, +} + +/// Builder to create a [SoftwareState] struct from different sources. +/// +/// At this point it uses the following sources: +/// +/// * [Product specification](ProductSpec). +/// * [Software user configuration](Config). +/// * [System information](SystemInfo). +/// * [Agama software selection](SoftwareSelection). +pub struct SoftwareStateBuilder<'a> { + /// Product specification. + product: &'a ProductSpec, + /// Configuration. + config: Option<&'a Config>, + /// Information from the underlying system. + system: Option<&'a SystemInfo>, + /// Agama's software selection. + selection: Option<&'a SoftwareSelection>, +} + +impl<'a> SoftwareStateBuilder<'a> { + /// Creates a builder for the given product specification. + pub fn for_product(product: &'a ProductSpec) -> Self { + Self { + product, + config: None, + system: None, + selection: None, + } + } + + /// Adds the user configuration to use. + pub fn with_config(mut self, config: &'a Config) -> Self { + self.config = Some(config); + self + } + + pub fn with_system(mut self, system: &'a SystemInfo) -> Self { + self.system = Some(system); + self + } + + pub fn with_selection(mut self, selection: &'a SoftwareSelection) -> Self { + self.selection = Some(selection); + self + } + + /// Builds the [SoftwareState] by merging the product specification and the + /// user configuration. + pub fn build(self) -> SoftwareState { + let mut state = self.from_product_spec(); + + if let Some(system) = self.system { + self.add_system_config(&mut state, system); + } + + if let Some(config) = self.config { + self.add_user_config(&mut state, config); + } + + if let Some(selection) = self.selection { + self.add_selection(&mut state, selection); + } + + state + } + + /// Adds the elements from the underlying system. + /// + /// It searches for repositories in the underlying system. The idea is to + /// use the repositories for off-line installation. + fn add_system_config(&self, state: &mut SoftwareState, system: &SystemInfo) { + let repositories = system + .repositories + .iter() + .filter(|r| r.mandatory) + .map(Repository::from); + state.repositories.extend(repositories); + } + + /// Adds the elements from the user configuration. + fn add_user_config(&self, state: &mut SoftwareState, config: &Config) { + let Some(software) = &config.software else { + return; + }; + + if let Some(repositories) = &software.extra_repositories { + let extra = repositories.iter().map(Repository::from); + state.repositories.extend(extra); + } + + if let Some(patterns) = &software.patterns { + match patterns { + PatternsConfig::PatternsList(list) => { + // Replaces the list, keeping only the non-optional elements. + state.resolvables.retain(|p| p.optional == false); + state.resolvables.extend( + list.iter() + .map(|n| ResolvableState::new(n, ResolvableType::Pattern, false)), + ); + } + PatternsConfig::PatternsMap(map) => { + // Adds or removes elements to the list + if let Some(add) = &map.add { + state.resolvables.extend( + add.iter() + .map(|n| ResolvableState::new(n, ResolvableType::Pattern, false)), + ); + } + + if let Some(remove) = &map.remove { + // NOTE: should we notify when a user wants to remove a + // pattern which is not optional? + state + .resolvables + .retain(|p| !(p.optional && remove.contains(&p.resolvable.name))); + } + } + } + } + + if let Some(only_required) = software.only_required { + state.options.only_required = only_required; + } + } + + /// It adds the software selection from Agama modules. + fn add_selection(&self, state: &mut SoftwareState, selection: &SoftwareSelection) { + let resolvables = selection + .resolvables() + .map(|r| ResolvableState::new_with_resolvable(&r, false)); + state.resolvables.extend(resolvables) + } + + fn from_product_spec(&self) -> SoftwareState { + let software = &self.product.software; + let repositories = software + .repositories() + .into_iter() + .enumerate() + .map(|(i, r)| { + let alias = format!("agama-{}", i); + Repository { + name: alias.clone(), + alias, + url: r.url.clone(), + enabled: true, + } + }) + .collect(); + + let mut resolvables: Vec = software + .mandatory_patterns + .iter() + .map(|p| ResolvableState::new(p, ResolvableType::Pattern, false)) + .collect(); + + resolvables.extend( + software + .optional_patterns + .iter() + .map(|p| ResolvableState::new(p, ResolvableType::Pattern, true)), + ); + + resolvables.extend(software.user_patterns.iter().filter_map(|p| match p { + UserPattern::Plain(_) => None, + UserPattern::Preselected(pattern) => { + if pattern.selected { + Some(ResolvableState::new( + &pattern.name, + ResolvableType::Pattern, + true, + )) + } else { + None + } + } + })); + + SoftwareState { + product: software.base_product.clone(), + repositories, + resolvables, + options: Default::default(), + } + } +} + +impl SoftwareState { + pub fn build_from( + product: &ProductSpec, + config: &Config, + system: &SystemInfo, + selection: &SoftwareSelection, + ) -> Self { + SoftwareStateBuilder::for_product(product) + .with_config(config) + .with_system(system) + .with_selection(selection) + .build() + } +} + +/// Defines a repository. +#[derive(Debug)] +pub struct Repository { + pub alias: String, + pub name: String, + pub url: String, + pub enabled: bool, +} + +impl From<&RepositoryConfig> for Repository { + fn from(value: &RepositoryConfig) -> Self { + Repository { + name: value.name.as_ref().unwrap_or(&value.alias).clone(), + alias: value.alias.clone(), + url: value.url.clone(), + enabled: value.enabled.unwrap_or(true), + } + } +} + +impl From<&agama_utils::api::software::Repository> for Repository { + fn from(value: &agama_utils::api::software::Repository) -> Self { + Repository { + name: value.name.clone(), + alias: value.alias.clone(), + url: value.url.clone(), + enabled: value.enabled, + } + } +} + +/// Defines a resolvable to be selected. +#[derive(Debug, PartialEq)] +pub struct ResolvableState { + /// Resolvable name. + pub resolvable: Resolvable, + /// Whether this resolvable is optional or not. + pub optional: bool, +} + +impl ResolvableState { + pub fn new(name: &str, r#type: ResolvableType, optional: bool) -> Self { + Self::new_with_resolvable(&Resolvable::new(name, r#type), optional) + } + + pub fn new_with_resolvable(resolvable: &Resolvable, optional: bool) -> Self { + Self { + resolvable: resolvable.clone(), + optional, + } + } +} + +/// Software system options. +#[derive(Default, Debug)] +pub struct SoftwareOptions { + /// Install only required packages (not recommended ones). + only_required: bool, +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use agama_utils::{ + api::software::{ + Config, PatternsConfig, PatternsMap, Repository, RepositoryConfig, SoftwareConfig, + SystemInfo, + }, + products::ProductSpec, + }; + + use crate::model::{ + packages::ResolvableType, + state::{ResolvableState, SoftwareStateBuilder}, + }; + + fn build_user_config(patterns: Option) -> Config { + let repo = RepositoryConfig { + alias: "user-repo-0".to_string(), + url: "http://example.net/repo".to_string(), + name: None, + product_dir: None, + enabled: Some(true), + priority: None, + allow_unsigned: None, + gpg_fingerprints: None, + }; + + let software = SoftwareConfig { + patterns, + extra_repositories: Some(vec![repo]), + ..Default::default() + }; + + Config { + software: Some(software), + ..Default::default() + } + } + + fn build_product_spec() -> ProductSpec { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../test/share/products.d/tumbleweed.yaml"); + let product = std::fs::read_to_string(&path).unwrap(); + serde_yaml::from_str(&product).unwrap() + } + + #[test] + fn test_build_state() { + let product = build_product_spec(); + let config = Config::default(); + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); + + assert_eq!(state.repositories.len(), 3); + let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); + let expected_aliases = vec![ + "agama-0".to_string(), + "agama-1".to_string(), + "agama-2".to_string(), + ]; + assert_eq!(expected_aliases, aliases); + + assert_eq!(state.product, "openSUSE".to_string()); + + assert_eq!( + state.resolvables, + vec![ + ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), + ResolvableState::new("selinux", ResolvableType::Pattern, true), + ] + ); + } + + #[test] + fn test_add_user_repositories() { + let product = build_product_spec(); + let config = build_user_config(None); + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); + + assert_eq!(state.repositories.len(), 4); + let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); + let expected_aliases = vec![ + "agama-0".to_string(), + "agama-1".to_string(), + "agama-2".to_string(), + "user-repo-0".to_string(), + ]; + assert_eq!(expected_aliases, aliases); + } + + #[test] + fn test_add_patterns() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsMap(PatternsMap { + add: Some(vec!["gnome".to_string()]), + remove: None, + }); + let config = build_user_config(Some(patterns)); + + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); + assert_eq!( + state.resolvables, + vec![ + ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), + ResolvableState::new("selinux", ResolvableType::Pattern, true), + ResolvableState::new("gnome", ResolvableType::Pattern, false) + ] + ); + } + + #[test] + fn test_remove_patterns() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsMap(PatternsMap { + add: None, + remove: Some(vec!["selinux".to_string()]), + }); + let config = build_user_config(Some(patterns)); + + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); + assert_eq!( + state.resolvables, + vec![ResolvableState::new( + "enhanced_base", + ResolvableType::Pattern, + false + ),] + ); + } + + #[test] + fn test_remove_mandatory_patterns() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsMap(PatternsMap { + add: None, + remove: Some(vec!["enhanced_base".to_string()]), + }); + let config = build_user_config(Some(patterns)); + + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); + assert_eq!( + state.resolvables, + vec![ + ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), + ResolvableState::new("selinux", ResolvableType::Pattern, true) + ] + ); + } + + #[test] + fn test_replace_patterns_list() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsList(vec!["gnome".to_string()]); + let config = build_user_config(Some(patterns)); + + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); + assert_eq!( + state.resolvables, + vec![ + ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), + ResolvableState::new("gnome", ResolvableType::Pattern, false) + ] + ); + } + + #[test] + fn test_use_base_repositories() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsList(vec!["gnome".to_string()]); + let config = build_user_config(Some(patterns)); + + let base_repo = Repository { + alias: "install".to_string(), + name: "install".to_string(), + url: "hd:/run/initramfs/install".to_string(), + enabled: false, + mandatory: true, + }; + + let another_repo = Repository { + alias: "another".to_string(), + name: "another".to_string(), + url: "https://example.lan/SLES/".to_string(), + enabled: false, + mandatory: false, + }; + + let system = SystemInfo { + repositories: vec![base_repo, another_repo], + ..Default::default() + }; + + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .with_system(&system) + .build(); + + let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); + let expected_aliases = vec![ + "agama-0".to_string(), + "agama-1".to_string(), + "agama-2".to_string(), + "install".to_string(), + "user-repo-0".to_string(), + ]; + assert_eq!(expected_aliases, aliases); + } +} diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs new file mode 100644 index 0000000000..2cedf3c880 --- /dev/null +++ b/rust/agama-software/src/service.rs @@ -0,0 +1,287 @@ +// 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::{process::Command, sync::Arc}; + +use crate::{ + message, + model::{software_selection::SoftwareSelection, state::SoftwareState, ModelAdapter}, + zypp_server::{self, SoftwareAction}, +}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{ + event::{self, Event}, + software::{Config, Proposal, Repository, SoftwareProposal, SystemInfo}, + Issue, IssueSeverity, Scope, + }, + issue, + products::ProductSpec, + progress, +}; +use async_trait::async_trait; +use tokio::sync::{broadcast, Mutex, RwLock}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("software service could not send the event")] + Event(#[from] broadcast::error::SendError), + #[error(transparent)] + Actor(#[from] actor::Error), + #[error("Failed to send message to libzypp thread: {0}")] + ZyppSender(#[from] tokio::sync::mpsc::error::SendError), + #[error("Failed to receive result from libzypp thread: {0}")] + ZyppReceiver(#[from] tokio::sync::oneshot::error::RecvError), + #[error(transparent)] + IO(#[from] std::io::Error), + #[error("There is no proposal for software")] + MissingProposal, + #[error("There is no product selected")] + MissingProduct, + #[error("There is no {0} product")] + WrongProduct(String), + #[error(transparent)] + ZyppServerError(#[from] zypp_server::ZyppServerError), + #[error(transparent)] + ZyppError(#[from] zypp_agama::errors::ZyppError), +} + +/// 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 { + model: Arc>, + issues: Handler, + progress: Handler, + events: event::Sender, + state: State, + selection: SoftwareSelection, +} + +#[derive(Default)] +struct State { + config: Config, + system: SystemInfo, + proposal: Arc>, +} + +impl Service { + pub fn new( + model: T, + issues: Handler, + progress: Handler, + events: event::Sender, + ) -> Service { + Self { + model: Arc::new(Mutex::new(model)), + issues, + progress, + events, + state: Default::default(), + selection: Default::default(), + } + } + + pub async fn setup(&mut self) -> Result<(), Error> { + if let Some(install_repo) = find_install_repository() { + tracing::info!("Found repository at {}", install_repo.url); + self.state.system.repositories.push(install_repo); + } + Ok(()) + } + + async fn update_system(&mut self) -> Result<(), Error> { + // TODO: add system information (repositories, patterns, etc.). + self.events.send(Event::SystemChanged { + scope: Scope::Software, + })?; + + Ok(()) + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetSystem) -> Result { + Ok(self.state.system.clone()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetConfig) -> Result { + Ok(self.state.config.clone()) + } +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + let product = message.product.read().await; + + self.state.config = message.config.clone().unwrap_or_default(); + self.events.send(Event::ConfigChanged { + scope: Scope::Software, + })?; + + let software = SoftwareState::build_from( + &product, + &self.state.config, + &self.state.system, + &self.selection, + ); + tracing::info!("Wanted software state: {software:?}"); + + let model = self.model.clone(); + let issues = self.issues.clone(); + let events = self.events.clone(); + let progress = self.progress.clone(); + let proposal = self.state.proposal.clone(); + let product_spec = product.clone(); + tokio::task::spawn(async move { + let (new_proposal, found_issues) = + match compute_proposal(model, product_spec, software, progress).await { + Ok((new_proposal, found_issues)) => (Some(new_proposal), found_issues), + Err(error) => { + let new_issue = Issue::new( + "software.proposal_failed", + "It was not possible to create a software proposal", + IssueSeverity::Error, + ) + .with_details(&error.to_string()); + (None, vec![new_issue]) + } + }; + proposal.write().await.software = new_proposal; + _ = issues.cast(issue::message::Set::new(Scope::Software, found_issues)); + _ = events.send(Event::ProposalChanged { + scope: Scope::Software, + }); + }); + + Ok(()) + } +} + +async fn compute_proposal( + model: Arc>, + product_spec: ProductSpec, + wanted: SoftwareState, + progress: Handler, +) -> Result<(SoftwareProposal, Vec), Error> { + let mut my_model = model.lock().await; + my_model.set_product(product_spec); + let issues = my_model.write(wanted, progress).await?; + let proposal = my_model.compute_proposal().await?; + Ok((proposal, issues)) +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + Ok(self.state.proposal.read().await.clone().into_option()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Refresh) -> Result<(), Error> { + self.model.lock().await.refresh().await?; + self.update_system().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Install) -> Result { + self.model.lock().await.install().await + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Finish) -> Result<(), Error> { + self.model.lock().await.finish().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetResolvables) -> Result<(), Error> { + self.selection.set(&message.id, message.resolvables); + Ok(()) + } +} + +const LIVE_REPO_DIR: &str = "/run/initramfs/live/install"; + +fn find_install_repository() -> Option { + if !std::fs::exists(LIVE_REPO_DIR).is_ok_and(|e| e) { + return None; + } + + normalize_repository_url(LIVE_REPO_DIR, "/install").map(|url| Repository { + alias: "install".to_string(), + name: "install".to_string(), + url, + enabled: true, + mandatory: true, + }) +} + +fn normalize_repository_url(mount_point: &str, path: &str) -> Option { + let live_device = Command::new("findmnt") + .args(["-o", "SOURCE", "--noheadings", "--target", mount_point]) + .output() + .ok()?; + let live_device = String::from_utf8(live_device.stdout) + .map(|d| d.trim().to_string()) + .ok()?; + + // check against /\A/dev/sr[0-9]+\z/ + if live_device.starts_with("/dev/sr") { + return Some(format!("dvd:{path}?devices={live_device}")); + } + + let by_id_devices = Command::new("find") + .args(["-L", "/dev/disk/by-id", "-samefile", &live_device]) + .output() + .ok()?; + let by_id_devices = String::from_utf8(by_id_devices.stdout).ok()?; + let mut by_id_devices = by_id_devices.trim().split("\n"); + + let device = by_id_devices.next().unwrap_or_default(); + if device.is_empty() { + Some(format!("hd:{mount_point}?device={live_device}")) + } else { + Some(format!("hd:{mount_point}?device={device}")) + } +} diff --git a/rust/agama-software/src/start.rs b/rust/agama-software/src/start.rs new file mode 100644 index 0000000000..4e802835dd --- /dev/null +++ b/rust/agama-software/src/start.rs @@ -0,0 +1,62 @@ +// 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::Model, + service::{self, Service}, + zypp_server::{ZyppServer, ZyppServerError}, +}; +use agama_utils::{ + actor::{self, Handler}, + api::event, + issue, progress, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Service(#[from] service::Error), + #[error(transparent)] + ZyppError(#[from] ZyppServerError), +} + +/// Starts the localization service. +/// +/// It starts two Tokio tasks: +/// +/// - The main service, which is reponsible for holding and applying the configuration. +/// - zypp thread for tasks which needs libzypp +/// - It depends on the issues service to keep the installation issues. +/// +/// * `events`: channel to emit the [localization-specific events](crate::Event). +/// * `issues`: handler to the issues service. +pub async fn start( + issues: Handler, + progress: Handler, + events: event::Sender, +) -> Result, Error> { + let zypp_sender = ZyppServer::start()?; + let model = Model::new(zypp_sender)?; + let mut service = Service::new(model, issues, progress, events); + // FIXME: this should happen after spawning the task. + service.setup().await?; + let handler = actor::spawn(service); + Ok(handler) +} diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs new file mode 100644 index 0000000000..63f7030828 --- /dev/null +++ b/rust/agama-software/src/zypp_server.rs @@ -0,0 +1,534 @@ +// 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::{ + software::{Pattern, SelectedBy, SoftwareProposal}, + Issue, IssueSeverity, Scope, + }, + products::ProductSpec, + progress, +}; +use std::path::Path; +use tokio::sync::{ + mpsc::{self, UnboundedSender}, + oneshot, +}; +use zypp_agama::ZyppError; + +use crate::model::state::{self, SoftwareState}; +const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; +const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; + +#[derive(thiserror::Error, Debug)] +pub enum ZyppDispatchError { + #[error("Failed to initialize libzypp: {0}")] + InitError(#[from] ZyppError), + #[error("Response channel closed")] + ResponseChannelClosed, + #[error("Target creation failed: {0}")] + TargetCreationFailed(#[source] std::io::Error), + #[error(transparent)] + Progress(#[from] progress::service::Error), +} + +#[derive(thiserror::Error, Debug)] +pub enum ZyppServerError { + #[error("Response channel closed")] + ResponseChannelClosed, + + #[error("Receiver error: {0}")] + RecvError(#[from] oneshot::error::RecvError), + + #[error("Sender error: {0}")] + SendError(#[from] mpsc::error::SendError), + + #[error("Unknown product: {0}")] + UnknownProduct(String), + + #[error("No selected product")] + NoSelectedProduct, + + #[error("Failed to initialize target directory: {0}")] + TargetInitFailed(#[source] ZyppError), + + #[error("Failed to add a repository: {0}")] + AddRepositoryFailed(#[source] ZyppError), + + #[error("Failed to load the repositories: {0}")] + LoadSourcesFailed(#[source] ZyppError), + + #[error("Listing patterns failed: {0}")] + ListPatternsFailed(#[source] ZyppError), + + #[error("Error from libzypp: {0}")] + ZyppError(#[from] zypp_agama::ZyppError), +} + +pub type ZyppServerResult = Result; + +pub enum SoftwareAction { + Install(oneshot::Sender>), + Finish(oneshot::Sender>), + GetPatternsMetadata(Vec, oneshot::Sender>>), + ComputeProposal( + ProductSpec, + oneshot::Sender>, + ), + Write { + state: SoftwareState, + progress: Handler, + tx: oneshot::Sender>>, + }, +} + +/// Software service server. +pub struct ZyppServer { + receiver: mpsc::UnboundedReceiver, +} + +impl ZyppServer { + /// Starts the software service loop and returns a client. + /// + /// The service runs on a separate thread and gets the client requests using a channel. + pub fn start() -> ZyppServerResult> { + let (sender, receiver) = mpsc::unbounded_channel(); + + let server = Self { receiver }; + + // see https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html#use-inside-tokiospawn for explain how to ensure that zypp + // runs locally on single thread + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + // drop the returned JoinHandle: the thread will be detached + // but that's OK for it to run until the process dies + std::thread::spawn(move || { + let local = tokio::task::LocalSet::new(); + + local.spawn_local(server.run()); + + // This will return once all senders are dropped and all + // spawned tasks have returned. + rt.block_on(local); + }); + Ok(sender) + } + + /// Runs the server dispatching the actions received through the input channel. + async fn run(mut self) -> Result<(), ZyppDispatchError> { + let zypp = self.initialize_target_dir()?; + + loop { + let action = self.receiver.recv().await; + let Some(action) = action else { + tracing::error!("Software action channel closed"); + break; + }; + + if let Err(error) = self.dispatch(action, &zypp).await { + tracing::error!("Software dispatch error: {:?}", error); + } + } + + Ok(()) + } + + /// Forwards the action to the appropriate handler. + async fn dispatch( + &mut self, + action: SoftwareAction, + zypp: &zypp_agama::Zypp, + ) -> Result<(), ZyppDispatchError> { + match action { + SoftwareAction::Write { + state, + progress, + tx, + } => { + self.write(state, progress, tx, zypp).await?; + } + SoftwareAction::GetPatternsMetadata(names, tx) => { + self.get_patterns(names, tx, zypp).await?; + } + SoftwareAction::Install(tx) => { + tx.send(self.install(zypp)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + } + SoftwareAction::Finish(tx) => { + self.finish(zypp, tx).await?; + } + SoftwareAction::ComputeProposal(product_spec, sender) => { + self.compute_proposal(product_spec, sender, zypp).await? + } + } + Ok(()) + } + + // Install rpms + fn install(&self, zypp: &zypp_agama::Zypp) -> ZyppServerResult { + let target = "/mnt"; + zypp.switch_target(target)?; + let result = zypp.commit()?; + tracing::info!("libzypp commit ends with {}", result); + Ok(result) + } + + fn read(&self, zypp: &zypp_agama::Zypp) -> Result { + let repositories = zypp + .list_repositories()? + .into_iter() + .map(|repo| state::Repository { + name: repo.user_name, + alias: repo.alias, + url: repo.url, + enabled: repo.enabled, + }) + .collect(); + + let state = SoftwareState { + // FIXME: read the real product. + product: "SLES".to_string(), + repositories, + resolvables: vec![], + options: Default::default(), + }; + Ok(state) + } + + async fn write( + &self, + state: SoftwareState, + progress: Handler, + tx: oneshot::Sender>>, + zypp: &zypp_agama::Zypp, + ) -> Result<(), ZyppDispatchError> { + let mut issues: Vec = vec![]; + // FIXME: + // 1. add and remove the repositories. + // 2. select the patterns. + // 3. select the packages. + // 4. return the proposal and the issues. + // self.add_repositories(state.repositories, tx, &zypp).await?; + + _ = progress.cast(progress::message::StartWithSteps::new( + Scope::Software, + &[ + "Updating the list of repositories", + "Refreshing metadata from the repositories", + "Calculating the software proposal", + ], + )); + let old_state = self.read(zypp).unwrap(); + let old_aliases: Vec<_> = old_state + .repositories + .iter() + .map(|r| r.alias.clone()) + .collect(); + let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); + + let to_add: Vec<_> = state + .repositories + .iter() + .filter(|r| !old_aliases.contains(&r.alias)) + .collect(); + + let to_remove: Vec<_> = state + .repositories + .iter() + .filter(|r| !aliases.contains(&r.alias)) + .collect(); + + for repo in &to_add { + let result = zypp.add_repository(&repo.alias, &repo.url, |percent, alias| { + tracing::info!("Adding repository {} ({}%)", alias, percent); + true + }); + + if let Err(error) = result { + let message = format!("Could not add the repository {}", repo.alias); + issues.push( + Issue::new("software.add_repo", &message, IssueSeverity::Error) + .with_details(&error.to_string()), + ); + } + // Add an issue if it was not possible to add the repository. + } + + for repo in &to_remove { + let result = zypp.remove_repository(&repo.alias, |percent, alias| { + tracing::info!("Removing repository {} ({}%)", alias, percent); + true + }); + + if let Err(error) = result { + let message = format!("Could not remove the repository {}", repo.alias); + issues.push( + Issue::new("software.remove_repo", &message, IssueSeverity::Error) + .with_details(&error.to_string()), + ); + } + } + + progress.cast(progress::message::Next::new(Scope::Software))?; + if to_add.is_empty() || to_remove.is_empty() { + let result = zypp.load_source(|percent, alias| { + tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); + true + }); + + if let Err(error) = result { + let message = format!("Could not read the repositories"); + issues.push( + Issue::new("software.load_source", &message, IssueSeverity::Error) + .with_details(&error.to_string()), + ); + } + } + + _ = progress.cast(progress::message::Next::new(Scope::Software)); + for resolvable_state in &state.resolvables { + let resolvable = &resolvable_state.resolvable; + // FIXME: we need to distinguish who is selecting the pattern. + // and register an issue if it is not found and it was not optional. + let result = zypp.select_resolvable( + &resolvable.name, + resolvable.r#type.into(), + zypp_agama::ResolvableSelected::Installation, + ); + + if let Err(error) = result { + let message = format!("Could not select pattern '{}'", &resolvable.name); + issues.push( + Issue::new("software.select_pattern", &message, IssueSeverity::Error) + .with_details(&error.to_string()), + ); + } + } + + _ = progress.cast(progress::message::Finish::new(Scope::Software)); + match zypp.run_solver() { + Ok(result) => println!("Solver result: {result}"), + Err(error) => println!("Solver failed: {error}"), + }; + + tx.send(Ok(issues)).unwrap(); + Ok(()) + } + + async fn finish( + &mut self, + zypp: &zypp_agama::Zypp, + tx: oneshot::Sender>, + ) -> Result<(), ZyppDispatchError> { + if let Err(error) = self.remove_dud_repo(zypp) { + tx.send(Err(error.into())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + } + if let Err(error) = self.disable_local_repos(zypp) { + tx.send(Err(error.into())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + } + self.registration_finish(); // TODO: move it outside of zypp server as it do not need zypp lock + self.modify_zypp_conf(); // TODO: move it outside of zypp server as it do not need zypp lock + + if let Err(error) = self.modify_full_repo(zypp) { + tx.send(Err(error.into())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + } + Ok(()) + } + + fn modify_full_repo(&self, zypp: &zypp_agama::Zypp) -> ZyppServerResult<()> { + let repos = zypp.list_repositories()?; + // if url is invalid, then do not disable it and do not touch it + let repos = repos + .iter() + .filter(|r| r.url.starts_with("dvd:/install?devices=")); + for r in repos { + zypp.set_repository_url(&r.alias, "dvd:/install")?; + } + Ok(()) + } + + fn remove_dud_repo(&self, zypp: &zypp_agama::Zypp) -> ZyppServerResult<()> { + const DUD_NAME: &str = "AgamaDriverUpdate"; + let repos = zypp.list_repositories()?; + let repo = repos.iter().find(|r| r.alias.as_str() == DUD_NAME); + if let Some(repo) = repo { + zypp.remove_repository(&repo.alias, |_, _| true)?; + } + Ok(()) + } + + fn disable_local_repos(&self, zypp: &zypp_agama::Zypp) -> ZyppServerResult<()> { + let repos = zypp.list_repositories()?; + // if url is invalid, then do not disable it and do not touch it + let repos = repos.iter().filter(|r| r.is_local().unwrap_or(false)); + for r in repos { + zypp.disable_repository(&r.alias)?; + } + Ok(()) + } + + fn registration_finish(&self) -> ZyppServerResult<()> { + // TODO: implement when registration is ready + Ok(()) + } + + fn modify_zypp_conf(&self) -> ZyppServerResult<()> { + // TODO: implement when requireOnly is implemented + Ok(()) + } + + async fn get_patterns( + &self, + names: Vec, + tx: oneshot::Sender>>, + zypp: &zypp_agama::Zypp, + ) -> Result<(), ZyppDispatchError> { + let pattern_names = names.iter().map(|n| n.as_str()).collect(); + let patterns = zypp + .patterns_info(pattern_names) + .map_err(ZyppServerError::ListPatternsFailed); + match patterns { + Err(error) => { + tx.send(Err(error)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + } + Ok(patterns_info) => { + let patterns = patterns_info + .into_iter() + .map(|info| Pattern { + name: info.name, + category: info.category, + description: info.description, + icon: info.icon, + summary: info.summary, + order: info.order, + }) + .collect(); + + tx.send(Ok(patterns)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + } + } + + Ok(()) + } + + fn initialize_target_dir(&self) -> Result { + let target_dir = Path::new(TARGET_DIR); + if target_dir.exists() { + _ = std::fs::remove_dir_all(target_dir); + } + + std::fs::create_dir_all(target_dir).map_err(ZyppDispatchError::TargetCreationFailed)?; + + let zypp = zypp_agama::Zypp::init_target(TARGET_DIR, |text, step, total| { + tracing::info!("Initializing target: {} ({}/{})", text, step, total); + })?; + + self.import_gpg_keys(&zypp); + Ok(zypp) + } + + fn import_gpg_keys(&self, zypp: &zypp_agama::Zypp) { + for file in glob::glob(GPG_KEYS).unwrap() { + match file { + Ok(file) => { + if let Err(e) = zypp.import_gpg_key(&file.to_string_lossy()) { + tracing::error!("Failed to import GPG key: {}", e); + } + } + Err(e) => { + tracing::error!("Could not read GPG key file: {}", e); + } + } + } + } + + async fn compute_proposal( + &self, + product_spec: ProductSpec, + sender: oneshot::Sender>, + zypp: &zypp_agama::Zypp, + ) -> Result<(), ZyppDispatchError> { + // TODO: for now it just compute total size, but it can get info about partitions from storage and pass it to libzypp + let mount_points = vec![zypp_agama::MountPoint { + directory: "/".to_string(), + filesystem: "btrfs".to_string(), + grow_only: false, // not sure if it has effect as we install everything fresh + used_size: 0, + }]; + let disk_usage = zypp.count_disk_usage(mount_points); + let Ok(computed_mount_points) = disk_usage else { + sender + .send(Err(disk_usage.unwrap_err().into())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + }; + let size = computed_mount_points.first().unwrap().used_size; + // TODO: format size + let size_str = format!("{size} KiB"); + + let selected_patterns: Result< + std::collections::HashMap, + ZyppServerError, + > = product_spec + .software + .user_patterns + .iter() + .map(|p| p.name()) + .map(|name| { + let selected = zypp.is_package_selected(name)?; + let tag = if selected { + SelectedBy::User + } else { + SelectedBy::None + }; + Ok((name.to_string(), tag)) + }) + .collect(); + let Ok(selected_patterns) = selected_patterns else { + sender + .send(Err(selected_patterns.unwrap_err())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + }; + + let proposal = SoftwareProposal { + size: size_str, + patterns: selected_patterns, + }; + + sender + .send(Ok(proposal)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + Ok(()) + } +} diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 961d044bde..f07725ce66 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -18,6 +18,9 @@ utoipa = "5.3.1" zbus = "5.7.1" zvariant = "5.5.2" gettext-rs = { version = "0.7.2", features = ["gettext-system"] } +regex = "1.12.2" +tracing = "0.1.41" +serde_yaml = "0.9.34" uuid = { version = "1.10.0", features = ["v4"] } cidr = { version = "0.3.1", features = ["serde"] } macaddr = { version = "1.0.1", features = ["serde_std"] } diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 9348189faf..6da4d7f5f3 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -52,6 +52,8 @@ mod action; pub use action::Action; pub mod l10n; +pub mod manager; pub mod network; pub mod question; +pub mod software; pub mod storage; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index 31608dc14b..18b6d7abc8 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.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 crate::api::{l10n, network, question, storage}; +use crate::api::{l10n, network, question, software, storage}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] @@ -27,6 +27,8 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] #[serde(alias = "localization")] pub l10n: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub software: Option, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/agama-utils/src/api/event.rs b/rust/agama-utils/src/api/event.rs index 31973df78d..3506038445 100644 --- a/rust/agama-utils/src/api/event.rs +++ b/rust/agama-utils/src/api/event.rs @@ -44,6 +44,11 @@ pub enum Event { SystemChanged { scope: Scope, }, + /// The configuration changed. + // TODO: do we need this event? + ConfigChanged { + scope: Scope, + }, /// Proposal changed. ProposalChanged { scope: Scope, diff --git a/rust/agama-utils/src/api/issue.rs b/rust/agama-utils/src/api/issue.rs index 6017be4bdd..a3036d14c4 100644 --- a/rust/agama-utils/src/api/issue.rs +++ b/rust/agama-utils/src/api/issue.rs @@ -47,6 +47,31 @@ pub struct Issue { pub class: String, } +impl Issue { + /// Creates a new issue. + pub fn new(class: &str, description: &str, severity: IssueSeverity) -> Self { + Self { + description: description.to_string(), + class: class.to_string(), + source: IssueSource::Config, + severity, + details: None, + } + } + + /// Sets the details for the issue. + pub fn with_details(mut self, details: &str) -> Self { + self.details = Some(details.to_string()); + self + } + + /// Sets the source for the issue. + pub fn with_source(mut self, source: IssueSource) -> Self { + self.source = source; + self + } +} + #[derive( Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, Eq, Hash, utoipa::ToSchema, )] @@ -75,14 +100,14 @@ impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { let value = value.downcast_ref::()?; let fields = value.fields(); - let Some([description, kind, details, source, severity]) = fields.get(0..5) else { + let Some([description, class, 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 class: String = class.try_into()?; let details: String = details.try_into()?; let source: u32 = source.try_into()?; let source = source as u8; @@ -95,7 +120,7 @@ impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { Ok(Issue { description, - class: kind, + class, details: if details.is_empty() { None } else { diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-utils/src/api/manager.rs similarity index 85% rename from rust/agama-lib/src/software/model.rs rename to rust/agama-utils/src/api/manager.rs index 4a9c14defa..51e060417d 100644 --- a/rust/agama-lib/src/software/model.rs +++ b/rust/agama-utils/src/api/manager.rs @@ -18,12 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -mod conflict; mod license; -mod packages; -mod registration; +pub use license::{InvalidLanguageCode, LanguageTag, License, LicenseContent}; -pub use conflict::*; -pub use license::*; -pub use packages::*; -pub use registration::*; +mod system_info; +pub use system_info::{Product, SystemInfo}; diff --git a/rust/agama-utils/src/api/manager/license.rs b/rust/agama-utils/src/api/manager/license.rs new file mode 100644 index 0000000000..ef188aa472 --- /dev/null +++ b/rust/agama-utils/src/api/manager/license.rs @@ -0,0 +1,109 @@ +// 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 support for reading software licenses. + +use std::fmt::Display; + +use regex::Regex; +use serde::Serialize; +use serde_with::{serde_as, DisplayFromStr}; +use thiserror::Error; + +/// Represents a product license. +/// +/// It contains the license ID and the list of languages that with a translation. +#[serde_as] +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct License { + /// License ID. + pub id: String, + /// Languages in which the license is translated. + #[serde_as(as = "Vec")] + pub languages: Vec, +} + +/// Represents a license content. +/// +/// It contains the license ID and the body. +/// +/// TODO: in the future it might contain a title, extracted from the text. +#[serde_as] +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct LicenseContent { + /// License ID. + pub id: String, + /// License text. + pub body: String, + /// License language. + #[serde_as(as = "DisplayFromStr")] + pub language: LanguageTag, +} + +/// Simplified representation of the RFC 5646 language code. +/// +/// It only considers xx and xx-XX formats. +#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema)] +pub struct LanguageTag { + // ISO-639 + pub language: String, + // ISO-3166 + pub territory: Option, +} + +impl Default for LanguageTag { + fn default() -> Self { + LanguageTag { + language: "en".to_string(), + territory: None, + } + } +} + +impl Display for LanguageTag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(territory) = &self.territory { + write!(f, "{}-{}", &self.language, territory) + } else { + write!(f, "{}", &self.language) + } + } +} + +#[derive(Error, Debug)] +#[error("Not a valid language code: {0}")] +pub struct InvalidLanguageCode(String); + +impl TryFrom<&str> for LanguageTag { + type Error = InvalidLanguageCode; + + fn try_from(value: &str) -> Result { + let language_regexp: Regex = Regex::new(r"^([[:alpha:]]+)(?:[_-]([A-Z]+))?").unwrap(); + + let captures = language_regexp + .captures(value) + .ok_or_else(|| InvalidLanguageCode(value.to_string()))?; + + Ok(Self { + language: captures.get(1).unwrap().as_str().to_string(), + territory: captures.get(2).map(|e| e.as_str().to_string()), + }) + } +} diff --git a/rust/agama-utils/src/api/manager/system_info.rs b/rust/agama-utils/src/api/manager/system_info.rs new file mode 100644 index 0000000000..bea28cf9ed --- /dev/null +++ b/rust/agama-utils/src/api/manager/system_info.rs @@ -0,0 +1,49 @@ +// 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::api::manager::License; +use serde::Serialize; + +/// Global information of the system where the installer is running. +#[derive(Clone, Debug, Default, Serialize, utoipa::ToSchema)] +pub struct SystemInfo { + /// List of known products. + pub products: Vec, + /// List of known licenses + pub licenses: Vec, +} + +/// 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, +} diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index 7a66f5d050..1dbb13eb88 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.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 crate::api::{l10n, network}; +use crate::api::{l10n, network, software}; use serde::Serialize; use serde_json::Value; @@ -29,5 +29,7 @@ pub struct Proposal { pub l10n: Option, pub network: network::Proposal, #[serde(skip_serializing_if = "Option::is_none")] + pub software: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub storage: Option, } diff --git a/rust/agama-server/src/software.rs b/rust/agama-utils/src/api/software.rs similarity index 81% rename from rust/agama-server/src/software.rs rename to rust/agama-utils/src/api/software.rs index b363de6ad1..58c7e44282 100644 --- a/rust/agama-server/src/software.rs +++ b/rust/agama-utils/src/api/software.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -17,6 +17,12 @@ // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +// +mod config; +pub use config::*; + +mod system_info; +pub use system_info::*; -pub mod web; -pub use web::{software_service, software_streams}; +mod proposal; +pub use proposal::{Proposal, SelectedBy, SoftwareProposal}; diff --git a/rust/agama-utils/src/api/software/config.rs b/rust/agama-utils/src/api/software/config.rs new file mode 100644 index 0000000000..0fcb36cee7 --- /dev/null +++ b/rust/agama-utils/src/api/software/config.rs @@ -0,0 +1,188 @@ +// 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. +//! Representation of the software settings + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// User configuration for the localization of the target system. +/// +/// This configuration is provided by the user, so all the values are optional. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[schema(as = software::UserConfig)] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// Product related configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub product: Option, + /// Software related configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub software: Option, +} + +/// Addon settings for registration +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[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(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProductConfig { + /// 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>, +} + +impl ProductConfig { + pub fn is_empty(&self) -> bool { + self.id.is_none() + && self.registration_code.is_none() + && self.registration_email.is_none() + && self.registration_url.is_none() + && self.addons.is_none() + } +} + +/// Software settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SoftwareConfig { + /// 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, utoipa::ToSchema)] +#[serde(untagged)] +pub enum PatternsConfig { + PatternsList(Vec), + PatternsMap(PatternsMap), +} + +impl Default for PatternsConfig { + fn default() -> Self { + PatternsConfig::PatternsMap(PatternsMap { + add: None, + remove: None, + }) + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +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 PatternsConfig { + fn from(list: Vec) -> Self { + Self::PatternsList(list) + } +} + +impl From>> for PatternsConfig { + 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 SoftwareConfig { + 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) + } + } +} + +/// Parameters for creating new a repository +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryConfig { + /// 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-utils/src/api/software/proposal.rs b/rust/agama-utils/src/api/software/proposal.rs new file mode 100644 index 0000000000..1dd60da8b6 --- /dev/null +++ b/rust/agama-utils/src/api/software/proposal.rs @@ -0,0 +1,66 @@ +// 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::collections::HashMap; + +use serde::Serialize; + +/// Represents the reason why a pattern is selected. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, utoipa::ToSchema)] +pub enum SelectedBy { + /// The pattern was selected by the user. + User, + /// The pattern was selected automatically. + Auto, + /// The pattern has not be selected. + None, +} + +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +/// Software proposal information. +pub struct SoftwareProposal { + /// Space required for installation. It is returned as a formatted string which includes + /// a number and a unit (e.g., "GiB"). + pub size: String, + /// Patterns selection. It is represented as a hash map where the key is the pattern's name + /// and the value why the pattern is selected. + pub patterns: HashMap, +} + +/// Describes what Agama proposes for the target system. +#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] +pub struct Proposal { + /// Software specific proposal + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub software: Option, + /// Registration proposal. Maybe same as config? + /// TODO: implement it + #[serde(skip_serializing_if = "Option::is_none")] + pub registration: Option<()>, +} + +impl Proposal { + pub fn into_option(self) -> Option { + if self.software.is_none() && self.registration.is_none() { + return None; + } + Some(self) + } +} diff --git a/rust/agama-utils/src/api/software/system_info.rs b/rust/agama-utils/src/api/software/system_info.rs new file mode 100644 index 0000000000..75c04dda2b --- /dev/null +++ b/rust/agama-utils/src/api/software/system_info.rs @@ -0,0 +1,89 @@ +// 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::Serialize; + +/// Localization-related information of the system where the installer +/// is running. +#[derive(Clone, Debug, Default, Serialize)] +pub struct SystemInfo { + /// List of known patterns. + pub patterns: Vec, + /// List of known repositories. + pub repositories: Vec, + /// List of available addons to register + pub addons: Vec, +} + +/// Repository specification. +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Repository { + /// Repository alias. It has to be unique. + pub alias: String, + /// Repository name + pub name: String, + /// Repository URL (raw format without expanded variables) + pub url: String, + /// Whether the repository is enabled + pub enabled: bool, + /// Whether the repository is mandatory (offline base repo, DUD repositories, etc.) + pub mandatory: bool, +} + +#[derive(Clone, 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, +} + +/// Addon registration +#[derive(Clone, Debug, Serialize, 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, +} diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index 7bc787077e..e864a531d2 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -18,14 +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 crate::api::l10n; -use crate::api::network; +use crate::api::{l10n, manager, network}; use serde::Serialize; use serde_json::Value; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct SystemInfo { + #[serde(flatten)] + pub manager: manager::SystemInfo, pub l10n: l10n::SystemInfo, #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/agama-utils/src/issue/message.rs b/rust/agama-utils/src/issue/message.rs index fcc69d89e5..2d6b2f5b96 100644 --- a/rust/agama-utils/src/issue/message.rs +++ b/rust/agama-utils/src/issue/message.rs @@ -54,3 +54,17 @@ impl Set { impl Message for Set { type Reply = (); } + +pub struct Clear { + pub scope: Scope, +} + +impl Clear { + pub fn new(scope: Scope) -> Self { + Self { scope } + } +} + +impl Message for Clear { + type Reply = (); +} diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index 91399ad502..da4414f790 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -91,3 +91,11 @@ impl MessageHandler for Service { Ok(()) } } + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Clear) -> Result<(), Error> { + _ = self.issues.remove(&message.scope); + Ok(()) + } +} diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 25fde03166..e03d98edea 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -25,7 +25,9 @@ pub mod actor; pub mod api; pub mod dbus; pub mod issue; +pub mod license; pub mod openapi; +pub mod products; pub mod progress; pub mod question; pub mod test; diff --git a/rust/agama-lib/src/software/model/license.rs b/rust/agama-utils/src/license.rs similarity index 78% rename from rust/agama-lib/src/software/model/license.rs rename to rust/agama-utils/src/license.rs index b0e9350954..5ac3a596d3 100644 --- a/rust/agama-lib/src/software/model/license.rs +++ b/rust/agama-utils/src/license.rs @@ -20,29 +20,23 @@ //! Implements support for reading software licenses. +use crate::api::manager::{InvalidLanguageCode, LanguageTag, License}; use agama_locale_data::get_territories; -use regex::Regex; use serde::Serialize; use serde_with::{serde_as, DisplayFromStr}; use std::{ collections::HashMap, - fmt::Display, fs::read_dir, path::{Path, PathBuf}, }; use thiserror::Error; -/// Represents a product license. -/// -/// It contains the license ID and the list of languages that with a translation. -#[serde_as] -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -pub struct License { - /// License ID. - pub id: String, - /// Languages in which the license is translated. - #[serde_as(as = "Vec")] - pub languages: Vec, +#[derive(Error, Debug)] +pub enum Error { + #[error("Not a valid language code: {0}")] + InvalidLanguageCode(#[from] InvalidLanguageCode), + #[error("I/O error")] + IO(#[from] std::io::Error), } /// Represents a license content. @@ -71,16 +65,16 @@ pub struct LicenseContent { /// The license diectory contains the default text (license.txt) and a set of translations (e.g., /// "license.es.txt", "license.zh_CH.txt", etc.). #[derive(Clone)] -pub struct LicensesRepo { +pub struct LicensesRegistry { /// Repository path. - pub path: std::path::PathBuf, + path: std::path::PathBuf, /// Licenses in the repository. - pub licenses: Vec, + licenses: Vec, /// Fallback languages per territory. fallback: HashMap, } -impl LicensesRepo { +impl LicensesRegistry { pub fn new>(path: P) -> Self { Self { path: path.as_ref().to_owned(), @@ -90,7 +84,7 @@ impl LicensesRepo { } /// Reads the licenses from the repository. - pub fn read(&mut self) -> Result<(), std::io::Error> { + pub fn read(&mut self) -> Result<(), Error> { let entries = read_dir(self.path.as_path())?; for entry in entries { @@ -162,7 +156,7 @@ impl LicensesRepo { /// The language is inferred from the file name (e.g., "es-ES" for license.es_ES.txt"). fn language_tag_from_file(name: &str) -> Option { if !name.starts_with("license") { - log::warn!("Unexpected file in the licenses directory: {}", &name); + tracing::warn!("Unexpected file in the licenses directory: {}", &name); return None; } let mut parts = name.split("."); @@ -220,92 +214,48 @@ impl LicensesRepo { .into_iter() .find(|c| license.languages.contains(&c)) } + + /// Returns a vector with the licenses from the repository. + pub fn licenses(&self) -> Vec<&License> { + self.licenses.iter().collect() + } } -impl Default for LicensesRepo { +impl Default for LicensesRegistry { fn default() -> Self { - let relative_path = Path::new("share/eula"); + let relative_path = PathBuf::from("share/eula"); let path = if relative_path.exists() { relative_path } else { - Path::new("/usr/share/agama/eula") + let share_dir = + std::env::var("AGAMA_SHARE_DIR").unwrap_or("/usr/share/agama".to_string()); + PathBuf::from(share_dir).join("eula") }; Self::new(path) } } -/// Simplified representation of the RFC 5646 language code. -/// -/// It only considers xx and xx-XX formats. -#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema)] -pub struct LanguageTag { - // ISO-639 - pub language: String, - // ISO-3166 - pub territory: Option, -} - -impl Default for LanguageTag { - fn default() -> Self { - LanguageTag { - language: "en".to_string(), - territory: None, - } - } -} - -impl Display for LanguageTag { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(territory) = &self.territory { - write!(f, "{}-{}", &self.language, territory) - } else { - write!(f, "{}", &self.language) - } - } -} - -#[derive(Error, Debug)] -#[error("Not a valid language code: {0}")] -pub struct InvalidLanguageCode(String); - -impl TryFrom<&str> for LanguageTag { - type Error = InvalidLanguageCode; - - fn try_from(value: &str) -> Result { - let language_regexp: Regex = Regex::new(r"^([[:alpha:]]+)(?:[_-]([A-Z]+))?").unwrap(); - - let captures = language_regexp - .captures(value) - .ok_or_else(|| InvalidLanguageCode(value.to_string()))?; - - Ok(Self { - language: captures.get(1).unwrap().as_str().to_string(), - territory: captures.get(2).map(|e| e.as_str().to_string()), - }) - } -} - #[cfg(test)] mod test { - use super::{LanguageTag, LicensesRepo}; + use super::{LanguageTag, LicensesRegistry}; use std::path::Path; - fn build_repo() -> LicensesRepo { - let mut repo = LicensesRepo::new(Path::new("../share/eula")); + fn build_registry() -> LicensesRegistry { + let mut repo = LicensesRegistry::new(Path::new("../share/eula")); repo.read().unwrap(); repo } #[test] fn test_read_licenses_repository() { - let repo = build_repo(); + let repo = build_registry(); let license = repo.licenses.first().unwrap(); assert_eq!(&license.id, "license.final"); } #[test] fn test_find_license() { - let repo = build_repo(); + let repo = build_registry(); let es_language: LanguageTag = "es".try_into().unwrap(); let license = repo.find("license.final", &es_language).unwrap(); assert!(license.body.starts_with("Acuerdo de licencia")); @@ -329,7 +279,7 @@ mod test { #[test] fn test_find_alternate_license() { - let repo = build_repo(); + let repo = build_registry(); // Tries to use the main language for the territory. let ca_language: LanguageTag = "ca-ES".try_into().unwrap(); diff --git a/rust/agama-utils/src/products.rs b/rust/agama-utils/src/products.rs new file mode 100644 index 0000000000..a00102cc10 --- /dev/null +++ b/rust/agama-utils/src/products.rs @@ -0,0 +1,284 @@ +// 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 products registry. +//! +//! The products registry contains the specification of every known product. +//! It reads the list of products from the `products.d` directory (usually, +//! `/usr/share/agama/products.d`). + +use crate::api::manager::Product; +use serde::{Deserialize, Deserializer}; +use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; +use std::path::{Path, PathBuf}; + +#[derive(thiserror::Error, Debug)] +pub enum ProductsRegistryError { + #[error("Could not read the products registry: {0}")] + IO(#[from] std::io::Error), + #[error("Could not deserialize a product specification: {0}")] + Deserialize(#[from] serde_yaml::Error), +} + +/// Products registry. +/// +/// It holds the products specifications. At runtime it is possible to change the `products.d` +/// location by setting the `AGAMA_SHARE_DIR` environment variable. This variable points to +/// the parent of `products.d`. +/// +/// Dynamic behavior, like filtering by architecture, is not supported yet. +#[derive(Clone, Debug, Deserialize)] +pub struct ProductsRegistry { + path: std::path::PathBuf, + products: Vec, +} + +impl ProductsRegistry { + pub fn new>(path: P) -> Self { + Self { + path: path.as_ref().to_owned(), + products: vec![], + } + } + + /// Creates a registry loading the products from its location. + pub fn read(&mut self) -> Result<(), ProductsRegistryError> { + let entries = std::fs::read_dir(&self.path)?; + self.products.clear(); + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + let Some(ext) = path.extension() else { + continue; + }; + + if path.is_file() && (ext == "yaml" || ext == "yml") { + let product = ProductSpec::load_from(path)?; + self.products.push(product); + } + } + + Ok(()) + } + + /// Returns the default product. + /// + /// If there is a single product, it is considered the "default product". + pub fn default_product(&self) -> Option<&ProductSpec> { + if self.products.len() == 1 { + self.products.first() + } else { + None + } + } + + /// Finds a product by its ID. + /// + /// * `id`: product ID. + pub fn find(&self, id: &str) -> Option<&ProductSpec> { + self.products.iter().find(|p| p.id == id) + } + + /// Returns a vector with the licenses from the repository. + pub fn products(&self) -> Vec { + self.products + .iter() + .map(|p| Product { + id: p.id.clone(), + name: p.name.clone(), + description: p.description.clone(), + icon: p.icon.clone(), + registration: p.registration, + license: None, + }) + .collect() + } +} + +impl Default for ProductsRegistry { + fn default() -> Self { + let share_dir = std::env::var("AGAMA_SHARE_DIR").unwrap_or("/usr/share/agama".to_string()); + let products_dir = PathBuf::from(share_dir).join("products.d"); + Self::new(products_dir) + } +} + +// TODO: ideally, part of this code could be auto-generated from a JSON schema definition. +/// Product specification (e.g., Tumbleweed). +#[derive(Clone, Debug, Deserialize)] +pub struct ProductSpec { + pub id: String, + pub name: String, + pub description: String, + pub icon: String, + #[serde(default)] + pub registration: bool, + pub version: Option, + pub software: SoftwareSpec, +} + +impl ProductSpec { + pub fn load_from>(path: P) -> Result { + let contents = std::fs::read_to_string(path)?; + let product: ProductSpec = serde_yaml::from_str(&contents)?; + Ok(product) + } +} + +fn parse_optional<'de, D>(d: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Deserialize::deserialize(d).map(|x: Option<_>| x.unwrap_or_default()) +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SoftwareSpec { + installation_repositories: Vec, + #[serde(default)] + pub installation_labels: Vec, + #[serde(default)] + pub user_patterns: Vec, + #[serde(default)] + pub mandatory_patterns: Vec, + #[serde(default)] + pub mandatory_packages: Vec, + #[serde(deserialize_with = "parse_optional")] + pub optional_patterns: Vec, + #[serde(deserialize_with = "parse_optional")] + pub optional_packages: Vec, + pub base_product: String, +} + +impl SoftwareSpec { + // NOTE: perhaps implementing our own iterator would be more efficient. + pub fn repositories(&self) -> Vec<&RepositorySpec> { + let arch = std::env::consts::ARCH.to_string(); + self.installation_repositories + .iter() + .filter(|r| r.archs.is_empty() || r.archs.contains(&arch)) + .collect() + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum UserPattern { + Plain(String), + Preselected(PreselectedPattern), +} + +impl UserPattern { + pub fn name(&self) -> &str { + match self { + UserPattern::Plain(name) => name, + UserPattern::Preselected(pattern) => &pattern.name, + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct PreselectedPattern { + pub name: String, + pub selected: bool, +} + +#[serde_as] +#[derive(Clone, Debug, Deserialize)] +pub struct RepositorySpec { + pub url: String, + #[serde(default)] + #[serde_as(as = "StringWithSeparator::")] + pub archs: Vec, +} + +#[serde_as] +#[derive(Clone, Debug, Deserialize)] +pub struct LabelSpec { + pub label: String, + #[serde(default)] + #[serde_as(as = "StringWithSeparator::")] + pub archs: Vec, +} + +#[cfg(test)] +mod test { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_load_registry() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); + let mut registry = ProductsRegistry::new(path.as_path()); + registry.read().unwrap(); + // ensuring that we can load all products from tests + assert_eq!(registry.products.len(), 8); + } + + #[test] + fn test_find_product() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); + let mut registry = ProductsRegistry::new(path.as_path()); + registry.read().unwrap(); + let tw = registry.find("Tumbleweed").unwrap(); + assert_eq!(tw.id, "Tumbleweed"); + assert_eq!(tw.name, "openSUSE Tumbleweed"); + assert_eq!(tw.icon, "Tumbleweed.svg"); + assert_eq!(tw.registration, false); + assert_eq!(tw.version, None); + let software = &tw.software; + assert_eq!(software.installation_repositories.len(), 12); + assert_eq!(software.installation_labels.len(), 4); + assert_eq!(software.base_product, "openSUSE"); + assert_eq!(software.user_patterns.len(), 11); + + let preselected = software + .user_patterns + .iter() + .find(|p| matches!(p, UserPattern::Preselected(_))); + let expected_pattern = PreselectedPattern { + name: "selinux".to_string(), + selected: true, + }; + assert_eq!( + preselected, + Some(&UserPattern::Preselected(expected_pattern)) + ); + + let missing = registry.find("Missing"); + assert!(missing.is_none()); + } + + #[test] + fn test_default_product() { + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d-single"); + let mut registry = ProductsRegistry::new(path.as_path()); + registry.read().unwrap(); + assert!(registry.default_product().is_some()); + + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); + let mut registry = ProductsRegistry::new(path.as_path()); + registry.read().unwrap(); + assert!(registry.default_product().is_none()); + } +} diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 51b7d043ba..7a6ce19574 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -40,6 +40,16 @@ Fri Oct 3 20:11:26 UTC 2025 - Imobach Gonzalez Sosa - Reimplement the localization service as part of the new API, following the new actors-based approach. +------------------------------------------------------------------- +Wed Oct 1 13:45:23 UTC 2025 - Ladislav Slezák + +- Autoinstallation schema improvements (gh#agama-project/agama#2773): + - Added product name examples + - Allow using "$schema" key for linking the schema definition, + some editors like VSCode uses that for validaing the file + automatically + - Use "agama-project" in the GitHub URLs + ------------------------------------------------------------------- Mon Sep 15 21:09:06 UTC 2025 - Imobach Gonzalez Sosa diff --git a/rust/package/agama.spec b/rust/package/agama.spec index cd62820c90..703d56ff25 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -27,6 +27,11 @@ Url: https://github.com/agama-project/agama Source0: agama.tar Source1: vendor.tar.zst +# zypp-c-api dependencies +BuildRequires: gcc +BuildRequires: gcc-c++ +BuildRequires: make +BuildRequires: libzypp-devel # defines the "limit_build" macro used in the "build" section below BuildRequires: memory-constraints BuildRequires: cargo-packaging diff --git a/rust/test/share/eula/license.final/license.es.txt b/rust/test/share/eula/license.final/license.es.txt new file mode 100644 index 0000000000..a00e57fe5c --- /dev/null +++ b/rust/test/share/eula/license.final/license.es.txt @@ -0,0 +1,295 @@ +Acuerdo de licencia de usuario final +del software de SUSE + + +Acuerdo de licencia de usuario final del software de SUSE +LEA ESTE ACUERDO ATENTAMENTE. AL DESCARGAR, INSTALAR O ADQUIRIR DE +CUALQUIER OTRO MODO EL SOFTWARE (COMO SE DEFINE MÁS ABAJO E +INCLUIDOS SUS COMPONENTES), ESTARÁ ACEPTANDO LOS TÉRMINOS DE ESTE +ACUERDO. SI NO ESTÁ CONFORME CON ESTOS TÉRMINOS, NO TENDRÁ +AUTORIZACIÓN PARA DESCARGAR, INSTALAR NI UTILIZAR EL SOFTWARE. SI +UNA PERSONA ACTÚA EN NOMBRE DE UNA ENTIDAD, SE DETERMINA QUE ESA +PERSONA TIENE LA AUTORIDAD PARA ACEPTAR ESTE ACUERDO EN NOMBRE DE +DICHA ENTIDAD. + +SUSE LLC (el "Licenciador" o "SUSE") pone a disposición del usuario +los productos de software, que son una recopilación: (i) programas +de software desarrollados por SUSE y sus afiliados; (ii) programas +de software desarrollados por terceros; (iii) marcas comerciales +propiedad de SUSE y/o sus filiales ("Marcas de SUSE"); y (iv) los +medios o reproducciones (físicos o virtuales) y la documentación +adjunta que acompañe a dichos programas de software (la recopilación +de programas, marcas comerciales y documentación se denomina +conjuntamente como el "Software"). + +El Software está protegido por las leyes y los tratados de derechos +de autor de Estados Unidos y por las leyes de derechos de autor de +otros países. Este Acuerdo de licencia de usuario final ("EULA") es +un acuerdo legal entre el Usuario (una entidad o una persona) y SUSE +que rige el uso del Software. Si las leyes de la ubicación principal +del negocio del Usuario requieren que los contratos estén en el +idioma local para ser ejecutables, dicha versión en el idioma local +se puede obtener del Licenciador previa solicitud por escrito y se +considerará que rige el uso que haga el Usuario del Software. +Cualquier complemento, extensión, actualización, aplicación móvil, +módulo, adaptador o versión de asistencia del Software que pueda +descargar o recibir el Usuario y no esté acompañado por un acuerdo +de licencia que reemplace expresamente al presente, se considerará +como Software y se regirá por este EULA. + +Términos de la licencia +Código abierto +El Software contiene muchos componentes individuales que son +software de código abierto, y la licencia de código abierto de cada +componente, que según el programa de software puede ser la Licencia +pública general de GNU versión 2 +(https://www.gnu.org/licenses/oldlicenses/gpl-2.0.en.html) o Apache +2.0 (https://www.apache.org/licenses/LICENSE-2.0) u otra licencia de +código abierto (cada una de estas licencias se denomina "Licencia de +código abierto"). Estas Licencias de código abierto se encuentran en +la documentación y/o en el código fuente del componente. + +Este EULA rige el uso del Software, incluidas las Marcas de SUSE, y +no limita, sustituye ni modifica los derechos del Usuario expresados +en la Licencia de código abierto aplicable al uso de cualquier +código de código abierto incluido en el Software sin las Marcas de +SUSE. + +El Software puede incluir o estar incluido en un paquete con otros +programas de software cuya licencia contenga términos distintos o +haya sido otorgada por otros fabricantes distintos al Licenciador. +El uso de cualquier programa de software acompañado de un acuerdo de +licencia independiente se regirá por dicho acuerdo de licencia. + +Licencia para utilizar el Software +Siempre que se cumplan los términos y condiciones de este EULA, el +Licenciador otorga al Usuario una licencia mundial perpetua, no +exclusiva, no transferible y revocable para reproducir y usar copias +del Software dentro de su Organización para uso interno en la +Organización del Usuario. "Organización" significa una entidad legal +y sus Afiliadas. "Afiliadas" hace referencia a las entidades que +controla el Usuario, las que tienen control sobre el Usuario y las +que están bajo control común del Usuario. La licencia anterior está +condicionada a que el Usuario sea responsable de cualquier +incumplimiento de las disposiciones de este EULA por parte de sus +Afiliadas. + +Este EULA no le permite distribuir el Software o sus componentes que +usen las marcas de SUSE aunque la copia haya sido modificada. El +Usuario puede realizar una redistribución fuera de su Organización: +(a) del Software, solo si se permite en virtud de un acuerdo por +escrito independiente con el Licenciador que autorice dicha +redistribución, o (b) de los componentes que constituyen el +Software, solo si el Usuario elimina y reemplaza todas las +apariciones de cualquier Marca de SUSE. + +Si el Usuario ha recibido de SUSE, ya sea directa o indirectamente, +hardware, software u otro dispositivo que utilice o integre el +Software, puede utilizar el Software únicamente con el fin de +ejecutar dicho hardware, software o dispositivo, y no de forma +independiente. + +Propiedad +No se le transfiere ningún título o propiedad del Software. El +Licenciador y sus licenciadores terceros mantienen íntegramente el +derecho, la titularidad y el interés sobre todos los derechos de +propiedad intelectual especificados en el Software, incluidas sus +copias o adaptaciones. El Software no se le vende al Usuario, el +Usuario adquiere únicamente una licencia condicional de uso del +Software. La titularidad, los derechos de propiedad y los derechos +de propiedad intelectual del contenido al que se accede a través del +Software son propiedad de los propietarios del contenido aplicable y +deben estar protegidos por derechos de autor u otras leyes +aplicables. Este EULA no da derecho alguno al Usuario sobre dicho +contenido. + +Marcas de SUSE +En virtud de este EULA no se otorga ningún derecho o licencia, ni +expreso ni implícito, para utilizar cualquier Marca de SUSE, nombre +comercial o marca de servicio del Licenciador o sus afiliados ni +licenciadores de otro modo que no sea necesario para utilizar el +Software según lo permitido por este EULA + +Servicios de suscripciones y Asistencia técnica +El Licenciador no tiene la obligación de proporcionar mantenimiento +o asistencia a menos que el Usuario adquiera una oferta de +suscripción, de conformidad con un contrato adicional con el +Licenciador o sus afiliados, que incluya expresamente dichos +servicios. + +Garantía y responsabilidad +Garantía limitada +El Licenciador garantiza que el medio en el que se entrega el +software está libre de defectos en materiales y manufacturado bajo +un uso normal para un periodo de sesenta (60) días desde la fecha de +entrega. LA ANTERIOR GARANTÍA ES LA ÚNICA Y EXCLUSIVA COMPENSACIÓN +DEL USUARIO Y SUSTITUYE A CUALQUIER OTRA GARANTÍA, YA SEA EXPLÍCITA +O IMPLÍCITA. SALVO POR LA PRESENTE GARANTÍA, EL SOFTWARE SE ENTREGA +"TAL CUAL", SIN GARANTÍA DE NINGÚN TIPO. +EL SOFTWARE NO ESTÁ DISEÑADO, FABRICADO NI PREVISTO PARA SU USO O +DISTRIBUCIÓN, Y NO SE DEBEN USAR, CON EQUIPOS DE CONTROL EN LÍNEA EN +ENTORNOS PELIGROSOS QUE REQUIERAN UN RENDIMIENTO A PRUEBA DE FALLOS, +COMO EL FUNCIONAMIENTO DE INSTALACIONES NUCLEARES, SISTEMAS DE +NAVEGACIÓN, COMUNICACIONES O CONTROL DE AVIONES, EQUIPOS DE SOPORTE +VITAL DIRECTO, SISTEMAS DE ARMAMENTO O CUALQUIER OTRO USO EN EL QUE +LOS FALLOS EN EL SOFTWARE PUEDAN PROVOCAR DIRECTAMENTE MUERTES, +DAÑOS PERSONALES O FÍSICOS O AL MEDIOAMBIENTE DE GRAVEDAD. +Productos que no sean del Licenciador +El Software puede incluir hardware u otros programas de software o +servicios, o bien formar parte de estos, que hayan sido vendidos o +cuya licencia haya sido otorgada por otra entidad distinta del +Licenciador. EL LICENCIADOR NO GARANTIZA LOS PRODUCTOS O SERVICIOS +NO PERTENECIENTES AL MISMO. ESTOS PRODUCTOS O SERVICIOS SE +DISTRIBUYEN "TAL CUAL". CUALQUIER SERVICIO DE GARANTÍA PARA LOS +PRODUCTOS NO PERTENECIENTES AL LICENCIADOR SERÁ PRESTADO POR EL +LICENCIADOR DEL PRODUCTO, DE CONFORMIDAD CON LO DISPUESTO EN LA +GARANTÍA DEL LICENCIADOR CORRESPONDIENTE. +CON LA EXCEPCIÓN DE LAS RESTRICCIONES LEGALES, EL LICENCIADOR +RECHAZA Y EXCLUYE TODAS LAS GARANTÍAS IMPLÍCITAS, INCLUIDAS LAS +GARANTÍAS DE COMERCIALIZACIÓN, IDONEIDAD PARA UN PROPÓSITO +PARTICULAR, TÍTULO O NO INFRACCIÓN; ASIMISMO TAMPOCO EXISTEN +GARANTÍAS CREADAS EN EL TRASCURSO DE LA NEGOCIACIÓN, EL RENDIMIENTO +O EL USO COMERCIAL. EL LICENCIADOR NO OFRECE NINGUNA GARANTÍA, +REPRESENTACIÓN NI PROMESA NO INCLUIDA DE FORMA EXPLÍCITA EN ESTA +GARANTÍA LIMITADA. EL LICENCIADOR NO GARANTIZA QUE EL SOFTWARE O LOS +SERVICIOS SATISFAGAN LAS NECESIDADES DEL USUARIO, SEAN COMPATIBLES +CON TODOS LOS SISTEMAS OPERATIVOS, O QUE EL FUNCIONAMIENTO DEL +SOFTWARE O LOS SERVICIOS SEA ININTERRUMPIDO O ESTÉ LIBRE DE ERRORES. +LAS EXCLUSIONES Y RENUNCIAS ANTERIORES SON UNA PARTE ESENCIAL DE +ESTE ACUERDO. Algunas jurisdicciones no permiten ciertas exclusiones +y limitaciones de garantías, por lo que algunas de las limitaciones +anteriores pueden no ser aplicables en el caso del Usuario. Esta +garantía limitada le otorga al Usuario derechos específicos. Además, +es posible que le asistan otros derechos, que pueden variar en +función del estado o la jurisdicción. +Limitación de responsabilidad +NI EL LICENCIADOR, NI SUS LICENCIADORES TERCEROS, SUBSIDIARIOS O +EMPLEADOS SERÁN RESPONSABLES EN FORMA ALGUNA DE NINGÚN DAÑO +CONSECUENTE O INDIRECTO, YA SE BASE EN UN CONTRATO, NEGLIGENCIA, +AGRAVIO U OTRA TEORÍA DE RESPONSABILIDAD, NI DE NINGUNA PÉRDIDA DE +BENEFICIOS, NEGOCIO O PÉRDIDA DE DATOS, INCLUSO AUNQUE SE LES +ADVIERTA DE LA POSIBILIDAD DE DICHOS DAÑOS. + +EN CASO DE QUE SE PRODUZCA, EN NINGÚN CASO LA RESPONSABILIDAD +CONJUNTA DEL LICENCIADOR EN RELACIÓN CON ESTE EULA (YA SEA EN UNA +INSTANCIA O EN UNA SERIE DE INSTANCIAS) EXCEDERÁ LA CANTIDAD PAGADA +POR EL USUARIO POR EL SOFTWARE (O 50 DÓLARES DE ESTADOS UNIDOS SI EL +USUARIO NO PAGÓ EL SOFTWARE), DURANTE LOS 12 MESES ANTERIORES A LA +PRIMERA RECLAMACIÓN AMPARADA POR ESTE EULA. +Las exclusiones y limitaciones anteriores no serán de aplicación a +las reclamaciones relacionadas con la muerte o daños personales +causados por la negligencia del Licenciador o de sus empleados, +agentes o contratistas. En las jurisdicciones donde no se permita +la exclusión o limitación de daños y perjuicios, incluyendo, sin +limitación, daños por incumplimiento de cualquiera de las +condiciones implícitas en cuanto al título o disfrute pacífico de +cualquier software obtenido de conformidad con el presente EULA o +por mala interpretación fraudulenta, la responsabilidad del +Licenciador se limitará o excluirá hasta el máximo permitido en +dichas jurisdicciones. + +Condiciones generales +Duración +Este EULA entrará en vigor en la fecha en que el Usuario descargue +el Software y finalizará automáticamente si el Usuario incumple +alguno de sus términos. +Transferencia +Este EULA no se puede transferir ni ceder sin el consentimiento +previo por escrito del Licenciador. Cualquier intento de cesión será +nulo y sin efecto alguno. +Legislación +Todas las cuestiones que surjan o estén relacionadas con el EULA se +regirán por las leyes de Estados Unidos y el estado de Nueva York, +excluyendo cualquier disposición de selección de fuero. Cualquier +pleito, acción o procedimiento que surja de este EULA o que esté +relacionado con él, solo podrá ser llevado ante un tribunal federal +de Estados Unidos o estatal de jurisdicción competente del estado de +Nueva York. Si una parte inicia procedimientos legales relacionados +con el EULA, la parte ganadora tendrá derecho a recuperar los +honorarios razonables de abogados. Sin embargo, si la ubicación +principal del negocio del Usuario se encuentra en un estado miembro +de la Unión Europea o de la Asociación Europea de Libre Comercio, +(1) los tribunales de Inglaterra y Gales tendrán jurisdicción +exclusiva sobre cualquier acción legal relacionada con este EULA y +(2) se aplicarán las leyes de Inglaterra excepto cuando sea +obligatorio que las leyes del país de dicha ubicación principal del +negocio se apliquen a cualquier acción legal, en cuyo caso se +aplicarán las leyes de ese país. Ni la Convención de las Naciones +Unidas sobre los Contratos para la Venta Internacional de +Mercaderías ni las reglas de conflicto de leyes de Nueva York o +Inglaterra y Gales se aplican a este EULA o su contenido. +Acuerdo completo +Este EULA, junto con cualquier otro documento de compra u otro +acuerdo entre el Usuario y el Licenciador o sus Afiliadas, +constituye la totalidad del entendimiento y acuerdo entre el Usuario +y el Licenciador y solo puede ser enmendado o modificado mediante un +acuerdo por escrito firmado por el Usuario y un representante +autorizado del Licenciador. NINGÚN LICENCIADOR EXTERNO, +DISTRIBUIDOR, PROVEEDOR, MINORISTA, REVENDEDOR, COMERCIAL NI +EMPLEADO ESTÁ AUTORIZADO A MODIFICAR ESTE ACUERDO NI A REALIZAR +NINGUNA DECLARACIÓN NI PROMESA QUE CONTRADIGA O AMPLÍE LOS TÉRMINOS +DE ESTE ACUERDO. +Renuncia +Ninguna renuncia voluntaria a los derechos otorgados en virtud de +este EULA será efectiva, a menos que se realice por escrito y esté +firmada por un representante debidamente autorizado de la parte +vinculada. Ninguna renuncia voluntaria a derechos presentes o +pasados obtenidos como consecuencia de infracciones o +incumplimientos se considerará una renuncia voluntaria de ningún +derecho futuro que pueda emanar de este EULA. +Omisión +Si cualquier disposición de este EULA no es válida o no es +aplicable, se interpretará, limitará, modificará o, si es necesario, +recortará en la medida en que sea necesario para eliminar su falta +de validez o imposibilidad de aplicación. El resto de disposiciones +del EULA no se verán afectadas. +Cumplimiento de normativas de exportación +El Usuario reconoce que los productos y/o la tecnología del +Licenciador pueden estar sujetos a las Regulaciones de la +Administración de Exportación de Estados Unidos ("EAR") y a las +leyes comerciales de otros países. El Usuario se compromete a +cumplir con las EAR y con las leyes y normativas locales que puedan +ser aplicables y afectar al derecho del Usuario a importar, exportar +o utilizar los productos y/o la tecnología del Licenciador. El +Usuario no exportará ni reexportará productos del Licenciador, +directa o indirectamente, a: (1) entidades incluidas en las listas +de exclusión a las exportaciones de Estados Unidos o que estén +sometidas a embargos, ni a países que apoyen el terrorismo según se +especifica en las EAR, (2) cualquier usuario final que el Usuario +sepa o tenga razones para saber que utilizará los productos del +Licenciador en el diseño, desarrollo o producción de sistemas de +armas nucleares, químicas o biológicas, sistemas de cohetes, +lanzadores espaciales y cohetes de sondeo o vehículos aéreos no +tripulados, salvo autorización de la agencia pública relevante por +normativas o licencias específicas, o (3) cualquier usuario final al +que se haya prohibido participar en las operaciones de exportación +de los Estados Unidos por cualquier agencia federal del Gobierno de +Estados Unidos. El Usuario no utilizará los productos y/o la +tecnología del Licenciador para fines prohibidos aplicados a +armamento nuclear, misilístico o biológico, tal como se especifica +en las EAR. Al descargar o utilizar el Software, el Usuario está de +acuerdo con lo anterior, y afirma y garantiza que no se encuentra +en, bajo el control de un nacional o residente de dichos países o en +ninguna de dichas listas. Además, el Usuario es responsable de +cumplir con las leyes locales en su jurisdicción que puedan afectar +a su derecho a la importación, exportación o uso de productos del +Licenciador. Consulte la página Web de la Oficina de Industria y +Seguridad de Estados Unidos https://www.bis.doc.gov antes de +exportar productos sujetos al as EAR. Para obtener más información +sobre la exportación del Software, incluyendo el Número de +Clasificación de Control de la Exportación (ECCN) aplicable y la +excepción de licencia asociada (según corresponda), consulte: +https://www.suse.com/company/legal/. Previa solicitud, el +Departamento de Servicios de Comercio Internacional del Licenciador +puede proporcionar información con respecto a las restricciones de +exportación aplicables a los productos del Licenciador. El +Licenciador no asume ninguna responsabilidad en el caso de que no +pueda obtener las aprobaciones de exportación necesarias. + +:versión:2024-02-01:001 +SUSE.com + +Copyright (c) SUSE 2024 + +SUSE Legal +Febrero de 2024 diff --git a/rust/test/share/eula/license.final/license.txt b/rust/test/share/eula/license.final/license.txt new file mode 100644 index 0000000000..5e9b4affe4 --- /dev/null +++ b/rust/test/share/eula/license.final/license.txt @@ -0,0 +1,263 @@ +End User License Agreement +for SUSE Software + + +End User License Agreement for SUSE Software +PLEASE READ THIS AGREEMENT CAREFULLY. BY PURCHASING, INSTALLING, +DOWNLOADING OR OTHERWISE USING THE SOFTWARE (AS DEFINED BELOW AND +INCLUDING ITS COMPONENTS), YOU AGREE TO THE TERMS OF THIS AGREEMENT. +IF YOU DO NOT AGREE WITH THESE TERMS, YOU ARE NOT PERMITTED TO +DOWNLOAD, INSTALL OR USE THE SOFTWARE. AN INDIVIDUAL ACTING ON +BEHALF OF AN ENTITY REPRESENTS THAT HE OR SHE HAS THE AUTHORITY TO +ENTER INTO THIS AGREEMENT ON BEHALF OF THAT ENTITY. + +SUSE LLC ("Licensor" or "SUSE") makes available software products, +being a compilation of: (i) software programs developed by SUSE and +is affiliates; (ii) software programs developed by third parties; +(iii) trade marks owned by SUSE and/or its affiliates ("SUSE +Marks"); and (iv) media or reproductions (physical or virtual) and +accompanying documentation accompanying such software programs (such +compilation of programs, trade marks and documentation being the +"Software"). + +The Software is protected by the copyright laws and treaties of the +United States and copyright laws in other countries worldwide. This +End User License Agreement ("EULA") is a legal agreement between You +(an entity or a person) and SUSE governing Your use of the Software. +If the laws of Your principal place of business require contracts to +be in the local language to be enforceable, such local language +version may be obtained from Licensor upon written request and shall +be deemed to govern Your use of the Software. Any add-on, extension, +update, mobile application, module, adapter or support release to +the Software that You may download or receive that is not +accompanied by a license agreement is Software and is governed by +this EULA. + +License Terms +Open Source +The Software contains many individual components that are open +source software and the open source license for each component, +which, depending on the software program, may be the GNU General +Public License v.2 +(https://www.gnu.org/licenses/oldlicenses/gpl-2.0.en.html) or Apache +2.0 (https://www.apache.org/licenses/LICENSE-2.0) or other open +source license (each such license being the "OSS License"), is +located in the licensing documentation and/or in the component's +source code. + +This EULA governs Your use of the Software, including SUSE Marks, +and does not limit, supersede or modify your rights under the OSS +License applicable to Your use of any open source code contained in +the Software without the SUSE Marks. + +The Software may include or be bundled with other software programs +licensed under different terms and/or licensed by a third party +other than Licensor. Use of any software programs accompanied by a +separate license agreement is governed by that separate license +agreement. + +License to use the Software +Subject to compliance with the terms and conditions of this EULA, +Licensor grants to You a perpetual, non- exclusive, +non-transferable, revocable, worldwide license to reproduce and use +copies of the Software within Your Organization for Your +Organization's internal use. "Organization" means a legal entity and +its Affiliates. "Affiliates" means entities that control, are +controlled by, or are under common control with You. The above +license is conditioned upon You being responsible and liable for any +breach of the provisions of this EULA by Your Affiliates. + +This EULA does not permit you to distribute the Software or its +components using the SUSE Marks regardless of whether the copy has +been modified. You may make a redistribution outside of Your +Organization: (a) of the Software, only if permitted under a +separate written agreement with Licensor authorizing such +redistribution, or (b) of the constituent components of the +Software, only if You remove and replace all occurrences of any SUSE +Mark. + +If You have received, whether directly or indirectly from SUSE, +hardware, software or other appliance that uses or embeds the +Software, You may use the Software solely for the purpose of running +that hardware, software or appliance and not on a stand-alone basis. + +Ownership +No title to or ownership of the Software is transferred to You. +Licensor and/or its third party licensors retain all right, title +and interest in and to all intellectual property rights in the +Software, including any adaptations or copies thereof. The Software +is not sold to You, You acquire only a conditional license to use +the Software. Title, ownership rights and intellectual property +rights in and to the content accessed through the Software are the +property of the applicable content owner and may be protected by +applicable copyright or other law. This EULA gives You no rights to +such content. + +SUSE Marks +No right or license, express or implied, is granted under this EULA +to use any SUSE Mark, trade name or service mark of Licensor or its +affiliates or licensors otherwise than is necessary to use the +Software as permitted by this EULA. + +Subscription Services and Support +Licensor has no obligation to provide maintenance or support unless +You purchase a subscription offering, pursuant to an additional +contract with Licensor or its affiliates, which expressly includes +such services. + +Warranty and Liability +Limited Warranty +Licensor warrants that the media that the Software is delivered on +will be free from defects in materials and manufacture under normal +use for a period of sixty (60) days from the date of delivery to +you. THE FOREGOING WARRANTY IS YOUR SOLE AND EXCLUSIVE REMEDY AND IS +IN LIEU OF ALL OTHER WARRANTIES, EXPRESS OR IMPLIED. SAVE FOR THE +FOREGOING WARRANTY, THE SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY +WARRANTIES OF ANY KIND. +THE SOFTWARE IS NOT DESIGNED, MANUFACTURED OR INTENDED FOR USE OR +DISTRIBUTION WITH, AND MUST NOT BE USED FOR, ON-LINE CONTROL +EQUIPMENT IN HAZARDOUS ENVIRONMENTS REQUIRING FAIL-SAFE PERFORMANCE, +SUCH AS IN THE OPERATION OF NUCLEAR FACILITIES, AIRCRAFT NAVIGATION, +COMMUNICATION, OR CONTROL SYSTEMS, DIRECT LIFE SUPPORT MACHINES, +WEAPONS SYSTEMS, OR OTHER USES IN WHICH FAILURE OF THE SOFTWARE +COULD LEAD DIRECTLY TO DEATH, PERSONAL INJURY, OR SEVERE PHYSICAL OR +ENVIRONMENTAL DAMAGE. +Non-Licensor Products +The Software may include or be bundled with hardware or other +software programs or services licensed or sold by an entity other +than Licensor. LICENSOR DOES NOT WARRANT NON-LICENSOR PRODUCTS OR +SERVICES. ANY SUCH PRODUCTS OR SERVICES ARE PROVIDED ON AN "AS IS" +BASIS. WARRANTY SERVICE IF ANY FOR NON-LICENSOR PRODUCTS IS PROVIDED +BY THE PRODUCT LICENSOR IN ACCORDANCE WITH THEIR APPLICABLE +WARRANTY. +EXCEPT AS OTHERWISE RESTRICTED BY LAW, LICENSOR DISCLAIMS AND +EXCLUDES ANY AND ALL IMPLIED WARRANTIES INCLUDING ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR +NON-INFRINGEMENT NOR ARE THERE ANY WARRANTIES CREATED BY COURSE OF +DEALING, COURSE OF PERFORMANCE OR TRADE USAGE. LICENSOR MAKES NO +WARRANTY, REPRESENTATION OR PROMISE NOT EXPRESSLY SET FORTH IN THIS +LIMITED WARRANTY. LICENSOR DOES NOT WARRANT THAT THE SOFTWARE OR +SERVICES WILL SATISFY YOUR REQUIREMENTS, BE COMPATIBLE WITH ALL +OPERATING SYSTEMS, OR THAT THE OPERATION OF THE SOFTWARE OR SERVICES +WILL BE UNINTERRUPTED OR ERROR-FREE. THE FOREGOING EXCLUSIONS AND +DISCLAIMERS ARE AN ESSENTIAL PART OF THIS AGREEMENT. Some +jurisdictions do not allow certain disclaimers and limitations of +warranties, so portions of the above limitations may not apply to +You. This limited warranty gives You specific rights and You may +also have other rights which vary by state or jurisdiction. +Limitation of Liability +NEITHER LICENSOR NOR ANY OF ITS THIRD PARTY LICENSORS, SUBSIDIARIES, +OR EMPLOYEES WILL IN ANY CASE BE LIABLE FOR ANY CONSEQUENTIAL OR +INDIRECT DAMAGES, WHETHER BASED ON CONTRACT, NEGLIGENCE, TORT OR +OTHER THEORY OF LIABILITY, OR FOR ANY LOSS OF PROFITS, BUSINESS OR +LOSS OR CORRUPTION OF DATA, IN EACH CASE, EVEN IF ADVISED OF THE +POSSIBILITY OF THOSE DAMAGES. + +IN NO EVENT WILL LICENSOR'S AGGREGATE LIABILITY UNDER OR IN +CONNECTION WITH THIS EULA (WHETHER IN ONE INSTANCE OR A SERIES OF +INSTANCES) EXCEED THE AMOUNT PAID BY YOU FOR THE SOFTWARE OUT OF +WHICH SUCH CLAIM AROSE (OR $50 (U.S.) IF YOU DID NOT PAY FOR THE +SOFTWARE), IN THE 12 MONTHS PRECEDING THE FIRST CLAIM UNDER THIS +EULA. +The above exclusions and limitations will not apply to claims +relating to death or personal injury caused by the negligence of +Licensor or its employees, agents or contractors. In those +jurisdictions that do not allow the exclusion or limitation of +damages, including, without limitation, damages for breach of any +implied terms as to title or quiet enjoyment of any Software +obtained pursuant to this EULA or for fraudulent misrepresentation, +Licensor's liability shall be limited or excluded to the maximum +extent allowed within those jurisdictions. + +General Terms +Term +This EULA becomes effective on the date You download the Software +and will automatically terminate if You breach any of its terms. +Transfer +This EULA may not be transferred or assigned without the prior +written approval of Licensor. Any such attempted transfer or +assignment shall be void and of no effect. +Law +All matters arising out of or relating to this EULA will be governed +by the substantive laws of the United States and the State ofNew +York without regard to its choice of law provisions. Any suit, +action or proceeding arising out of or relating to this EULA may +only be brought before a federal or state court of appropriate +jurisdiction in New York. If a party initiates EULA-related legal +proceedings, the prevailing party will be entitled to recover +reasonable attorneys' fees. If, however, Your principal place of +business is a member state of the European Union or the European +Free Trade Association, (1) the courts of England and Wales shall +have exclusive jurisdiction over any action of law relating to this +EULA; and (2) the laws of England shall apply except where the laws +of such country of Your principal place of business are required to +be applied to any such action of law, in which case the laws of that +country shall apply. Neither the United Nations Convention of +Contracts for the International Sale of Goods nor the New York or +England and Wales conflict of law rules apply to this EULA or its +subject matter. +Entire Agreement +This EULA, together with any other purchase documents or other +written agreement between You and Licensor or its Affiliates, sets +forth the entire understanding and agreement between You and +Licensor and may be amended or modified only by a written agreement +agreed to by You and an authorized representative of Licensor. NO +THIRD PARTY LICENSOR, DISTRIBUTOR, DEALER, RETAILER, RESELLER, SALES +PERSON, OR EMPLOYEE IS AUTHORIZED TO MODIFY THIS AGREEMENT OR TO +MAKE ANY REPRESENTATION OR PROMISE THAT IS DIFFERENT FROM, OR IN +ADDITION TO, THE TERMS OF THIS AGREEMENT. +Waiver +No waiver of any right under this EULA will be effective unless in +writing, signed by a duly authorized representative of the party to +be bound. No waiver of any past or present right arising from any +breach or failure to perform will be deemed to be a waiver of any +future right arising under this EULA. +Severability +If any provision in this EULA is invalid or unenforceable, that +provision will be construed, limited, modified or, if necessary, +severed, to the extent necessary, to eliminate its invalidity or +unenforceability, and the other provisions of this EULA will remain +unaffected. +Export Compliance +You acknowledge that Licensor's products and/or technology may be +subject to the U.S. Export Administration Regulations (the "EAR") +and the trade laws of other countries. You agree to comply with the +EAR and local laws and regulations which may be applicable to and +impact Your right to import, export or use Licensor's products +and/or technology. You will not export or re-export Licensor's +products, directly or indirectly, to (1) entities on the current +U.S. export exclusion lists or to any embargoed or terrorist +supporting countries as specified in the EAR; (2) any end user who +You know or have reason to know will utilize Licensor's products in +the design, development or production of nuclear, chemical or +biological weapons, or rocket systems, space launch vehicles, and +sounding rockets, or unmanned air vehicle systems, except as +authorized by the relevant government agency by regulation or +specific license; or (3) any end user who has been prohibited from +participating in the US export transactions by any federal agency of +the US government. You will not use Licensor's products and/or +technology for prohibited nuclear, missile, or chemical biological +weaponry end uses as specified in the EAR. By downloading or using +the Software, You are agreeing to the foregoing and You are +representing and warranting that You are not located in, under the +control of, or a national or resident of any such country or on any +such list. In addition, You are responsible for complying with any +local laws in Your jurisdiction which may impact Your right to +import, export or use Licensor's products. Please consult the Bureau +of Industry and Security web page https://www.bis.doc.gov before +exporting items subject to the EAR. For more information on +exporting Software, including the applicable Export Control +Classification Number (ECCN) and associated license exception (as +applicable), see https://www.suse.com/company/legal/. Upon request, +Licensor's International Trade Services Department can provide +information regarding applicable export restrictions for Licensor +products. Licensor assumes no responsibility for Your failure to +obtain any necessary export approvals. + +:version:2024-02-01:001 +SUSE.com + +Copyright (c) SUSE 2024 + +SUSE Legal +February 2024 diff --git a/rust/test/share/eula/license.final/license.zh_CN.txt b/rust/test/share/eula/license.final/license.zh_CN.txt new file mode 100644 index 0000000000..fd05df1624 --- /dev/null +++ b/rust/test/share/eula/license.final/license.zh_CN.txt @@ -0,0 +1,187 @@ +SUSE 软件 +最终用户许可协议 + + +SUSE 软件最终用户许可协议 +请仔细阅读本协议。一旦购买、安装、下载或以其他方式使用本软件(如下 +文定义,包括其组件),即表示您同意本协议的条款。如不同意以下条款, +您将不能下载、安装或使用本软件。代表某实体行事的个人表示其有权代表 +该实体签署本协议。 + +SUSE LLC(以下简称"许可证颁发者"或"SUSE")所提供的 +软件产品合集包含以下各项:(i) 由 SUSE 及其关联公司开发的 +软件程序;(ii) 第三方开发的软件程序;(iii) SUSE 及 +/或其关联公司拥有的商标(以下简称"SUSE 商标");以及 +(iv) 媒介或复制品(实体或虚拟格式)以及此类软件程序随附的相关 +文档(此类程序、商标和文档的合集统称为"软件")。 + +本软件受美国版权法和条约以及世界其他国家/地区版权法的保护。本最终 +用户许可协议(以下简称为"EULA")是您(作为个人或实体)与 +SUSE 之间就软件使用达成的法律协议。如果您所在的主要营业地的法 +律要求合同必须使用本地语言才能实施,则此类本地语言版本可按照书面请 +求从许可证颁发者处获得,并且应视为对您使用本软件的行为具有约束力。 +对于您可能下载或接收本软件的任何附加内容、扩展、更新、移动应用程序、 +模块、适配器或支持版本,如果没有随附许可协议,则均视为本软件并受 +本 EULA 的约束。 + +许可条款 +开放源代码 +本本软件包含许多独立组件,这些组件都是开源软件,每个组件的开源许可 +证(取决于软件程序)可能是 GNU 通用公共许可证 v.2 +(https://www.gnu.org/licenses/old +licenses/gpl-2.0.en.html) 或 +Apache 2.0 +(https://www.apache.org/licenses/ +LICENSE-2.0)或其他开放源代码许可(此类任一许可均 +为"OSS 许可"),位于许可文档和/或组件的开放源代码。 + +本 EULA 约束您使用本软件(包括 SUSE 商标)的权利,并不 +限制、替代或修改您根据 OSS 许可证对软件中的开放源代码(不包含 +SUSE 商标)的使用权利。 + +本软件可能包含或捆绑有其他软件程序,这些软件程序使用不同的条款许可, +并/或由许可证颁发者之外的第三方许可。使用任何附带有单独许可协议 +的软件程序的行为受该单独许可协议的约束。 + +本软件的使用许可证 +在遵守本 EULA 条款和条件的前提下,许可证颁发者授予您永久、非 +排他性、不可转让和可撤销的全球范围内的许可,允许在您组织内部复制和 +使用本软件的副本。"组织"指法律实体及其关联公司。"关联公司"指控 +制您、受您控制或受您共同控制的实体。上述许可的前提条件是,如果您的 +关联公司违反本 EULA 的任何条款,您将对此负有责任。 + +本 EULA 不允许您分发带有 SUSE 商标的软件或其组件,无论 +其副本有无改动。但在下列情况,您可以在您的组织范围外进行再分发: +(a) 只有在您与许可证颁发者签署的独立书面协议授权进行软件再分发 +的情况下,方可进行软件再分发;或 (b) 只有在您移除并替换所有 +SUSE 商标的情况下, 方可进行软件组件的再分发。 + +如果您直接或间接从 SUSE 收到使用或嵌入本软件的硬件、软件或其 +他设备,您只能将本软件用于运行该硬件、软件或设备,而不能单独使用本 +软件。 + +所有权 +本软件的所有权并未转让给您。许可证颁发者和/或其第三方许可证颁发者 +对本软件(包括软件的任何改编版本或副本)中的所有知识产权,保留全部 +权利、所有权和权益。本软件并非出售给您,您获得的只是使用本软件的有 +条件许可证。通过本软件访问的内容的相关权利、所有权和知识产权是相应 +内容所有者的财产,并可能受相应的版权法或其他法律的保护。本 +EULA 未授予您对此类内容的任何权利。 + +SUSE 商标 +除非根据本 EULA 的允许,必须使用本软件,否则本 EULA 并 +未以明示或暗示的方式,授予您使用许可证颁发者或其关联公司或其他许可 +证颁发者的任何 SUSE 商标、商号或服务商标的权利或许可。 + +订阅服务和支持 +除非您根据与许可证颁发者或其关联公司签署的附加合同购买的订阅产品中 +明确包含维护或支持服务,否则许可证颁发者无义务提供此类服务。 + +担保和责任 +有限担保 +自产品送达之日起六十 (60) 天内,许可证颁发者担保寄送软件所使 +用的任何介质在正常使用的情况下没有物理缺陷和制造缺陷。上述担保是您 +唯一的和独有的补救措施,它将取代所有其他明示或暗示的担保。除前述担 +保条款之外,本软件按"原样"提供,不提供任何形式的任何担保。 +本软件在设计、制造或使用目的方面,并非用于、分发于且不得用于在危险 +环境中使用的、需要故障自动防护性能的在线控制设备,例如核设备、飞机 +导航或通讯系统、空中交通控制、直接生命保障系统或武器系统。不适用的 +环境还包括由于本软件故障就会导致人员伤亡或严重的人身或环境损害的情 +况。 +非许可证颁发者产品 +本软件可能包含或捆绑着由许可证颁发者之外的实体许可或销售的硬件或其 +他软件程序或服务。对于非许可证颁发者的产品或服务,许可证颁发者不提 +供担保。任何此类产品和服务均按"原样"提供。对于非许可证颁发者的产 +品,如果有担保服务,则该担保服务由该产品的许可证颁发者依据其适用的 +担保提供。 +除非法律另行禁止,否则许可证颁发者不作任何暗示担保,包括对适销性、 +针对特定目的的适用性、所有权或不侵权的任何担保,交易过程、履约过程 +或贸易惯例也不会产生任何担保。除在本有限担保中所作的明示担保外,许 +可证颁发者不作任何担保、陈述或承诺。许可证颁发者不担保本软件或服务 +能满足您的要求并与所有操作系统兼容,也不担保本软件或服务的运行不会 +中断或没有错误。前述免除和免责声明构成本协议的核心部分。部分司法管 +辖区不允许特定免责声明和对担保的限制,因此,上述部分限制对您未必适 +用。本有限担保授予您特定的权利,您可能还拥有其他权利(因各州或司法 +辖区而异)。 +有限责任 +在任何情况下,无论是因合同、疏忽、侵权或其他责任原因,许可证颁发者 +或其任何第三方许可证颁发者、子公司或雇员均不对任何形式的间接或非直 +接损害承担责任,也不对任何利润损失、业务损失或数据丢失或损坏承担责 +任,即便已被告知可能发生此类损害。 + +在任何情况下,许可证颁发者在本 EULA 项下或与本 EULA 相 +关的累计责任(无论是单一事件还是系列事件)均不会超过您根据本 +EULA 在首次提出索赔前 12 个月内支付的与此类索赔有关的软件 +费用(如果您未支付任何软件费用,则或为 50 美元)。 +上述免除和限制不适用于与许可证颁发者或其雇员、代理或订约人所导致的 +死亡或人身伤害有关的索赔。对于不允许免除或限制损失(包括但不限于违 +反与所有权有关的任何隐含条款、安静享用依照本 EULA 获得的任何 +软件或欺诈性陈述所带来的损失)责任的司法管辖区,许可证颁发者的责任 +应在这些司法管辖区允许的最大范围内予以限制或免除。 + +通则 +术语 +本 EULA 自您下载本软件之日起生效,如果您违反了本协议的任何条 +款,本协议将自动终止。 +转移 +未经许可证颁发者的事先书面许可,不得转移或转让本 EULA。尝试进 +行任何此类转移或转让均属无用和无效。 +法律 +因本 EULA 产生或与本 EULA 相关的所有事宜均应受美国和纽 +约州实体法的约束,与所选的法律条款无关。因本 EULA 产生或与本 +EULA 相关的任何诉讼、行动或程序,只能呈交纽约州具有相应司法管 +辖权的联邦或州法庭裁决。如果某方提起与本 EULA 相关的法律诉讼, +则胜诉方有权获得合理的律师费。但是,如果您的主要营业地是欧盟或欧 +洲自由贸易联盟的成员国,则:(1) 英格兰和威尔士法庭将对与本 +EULA 相关的任何法律诉讼具有专属司法管辖权;以及 (2) 除非 +需要依据此类主要营业地所在的国家/地区的法律处理任何此类法律诉讼, +否则英格兰法律将适用。《联合国国际货物销售合同公约》、纽约或英格兰 +及威尔士的法律冲突规则对本 EULA 或其标的均不适用。 +完整协议 +本 EULA 连同其他任何购买单据或您与许可证颁发者或其关联公司之 +间签署的其他书面协议,构成您与许可证颁发者之间的完整理解与协议。未 +经您与许可证颁发者的授权代表的书面同意,不得修正或修改本协议。任何 +第三方许可证颁发者、分销商、经销商、零售商、转售商、销售人员或雇员, +均无权修改本协议,或做出与协议条款不一致或本协议条款之外的任何陈 +述或承诺。 +放弃 +对于本 EULA 中任何权利的放弃,必须以书面形式经受约束方正式授 +权代表签字,方可生效。对违约或未履约引发的任何过往、当前权利的弃权, +不得视为对未来依照本 EULA 而应具有的权利的弃权。 +可分割性 +如本 EULA 中的任何条款无效或不可执行,应在必要的范围内对该条 +款加以解释、限制、修改,如果必要的话,还可删除无效、不可执行的部分。 +本 EULA 的其他条款不受影响。 +符合出口法规 +您确认许可证颁发者的产品和/或技术可能受到《美国出口管理条例》(以 +下简称"EAR")及其他国家/地区贸易法的管辖。您同意遵循 EAR +及可能适用于您或影响您进口、出口或使用许可证颁发者产品和/或技术的 +当地法律和法规。您不得向以下国家/地区或用户直接或间接出口或再出口 +许可证颁发者的产品:(1) 列入美国出口排除名单的实体或 EAR +中规定的任何禁运或支持恐怖主义的其他国家/地区;(2) 任何您知晓 +或有理由知晓的将利用许可证颁发者的产品设计、开发或生产核武器、化学 +武器或生物武器、火箭系统、太空运载火箭和探测火箭或无人飞行器系统的 +最终用户,除非根据条例或特定许可证获得相关政府机构的授权;或者 +(3) 任何遭到美国政府的任何联邦机构禁止参与美国出口交易的最终用 +户。您还不得将许可证颁发者的产品和/或技术用于 EAR 所禁止的任 +何核武器、导弹或化学生物武器的最终用途。下载或使用本软件,即表示您 +同意上述条款并声明和保证,您不在上述任何国家/地区内,不受上述任何 +国家/地区的控制,不是上述任何国家/地区的公民或居民,也不在上述任 +何名单中。此外,您有义务遵守您所在司法管辖区内任何可能会影响您进口、 +出口或使用许可证颁发者产品的权利的当地法律。在依据 EAR 出口 +商品之前,请查阅美国商务部工业安全局网页 +https://www.bis.doc.gov。有关软件出口的更多 +信息,包括适用的出口管制分类号 (ECCN) 及相关的许可证异常 +(如果适用),请访问 +https://www.suse.com/company/lega +l/。如有必要,许可证颁发者的国际贸易服务部可以提供适用于许可证颁 +发者产品的出口限制方面的信息。如果您未能获得任何必要的出口许可,则 +许可证颁发者对此概不负责。 + +:版本:2024-02-01:001 +SUSE.com + +版权所有 (c) SUSE 2024 + +SUSE 法务 +2024 年 2 月 diff --git a/rust/test/share/products.d-single/tumbleweed.yaml b/rust/test/share/products.d-single/tumbleweed.yaml new file mode 100644 index 0000000000..561ff8aea7 --- /dev/null +++ b/rust/test/share/products.d-single/tumbleweed.yaml @@ -0,0 +1,224 @@ +id: Tumbleweed +name: openSUSE Tumbleweed +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'A pure rolling release version of openSUSE containing the latest + "stable" versions of all software instead of relying on rigid periodic release + cycles. The project does this for users that want the newest stable software.' +icon: Tumbleweed.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una versió de llançament continuada d'openSUSE que conté les darreres + versions estables de tot el programari en lloc de dependre de cicles de + llançament periòdics rígids. El projecte fa això per als usuaris que volen + el programari estable més nou. + cs: Čistě klouzavá verze openSUSE obsahující nejnovější "stabilní" verze + veškerého softwaru, která se nespoléhá na pevné periodické cykly vydávání. + Projekt to dělá pro uživatele, kteří chtějí nejnovější stabilní software. + de: Eine reine Rolling-Release-Version von openSUSE, die die neuesten „stabilen“ + Versionen der gesamten Software enthält, anstatt sich auf starre + periodische Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für + Benutzer, die die neueste, stabile Software wünschen. + es: Una versión de actualización continua pura de openSUSE que contiene las + últimas versiones "estables" de todo el software en lugar de depender de + rígidos ciclos de publicaciones periódicas. El proyecto hace esto para + usuarios que desean el software estable más novedoso. + fr: La distribution Tumbleweed est une pure "rolling release" (publication + continue) d'openSUSE contenant les dernières versions "stables" de tous + les logiciels au lieu de se baser sur des cycles de publication + périodiques et fixes. Le projet fait cela pour les utilisateurs qui + veulent les logiciels stables les plus récents. + id: Distribusi Tumbleweed merupakan versi rilis bergulir murni dari openSUSE + yang berisi versi "stabil" terbaru dari semua perangkat lunak dan tidak + bergantung pada siklus rilis berkala yang kaku. Proyek ini dibuat untuk + memenuhi kebutuhan pengguna yang menginginkan perangkat lunak stabil + terbaru. + ja: openSUSE の純粋なローリングリリース版で、特定のリリースサイクルによることなく全てのソフトウエアを最新の "安定" + バージョンに維持し続ける取り組みです。このプロジェクトは特に、最新の安定バージョンを使いたいユーザにお勧めです。 + nb_NO: Tumbleweed distribusjonen er en ren rullerende utgivelsesversjon av + openSUSE som inneholder de siste "stabile" versjonene av all programvare i + stedet for å stole på et rigid periodisk utgivelsessykluser. Prosjektet + gjør dette for brukere som vil ha de nyeste stabile programvarene. + pt_BR: Uma versão de lançamento puro e contínuo do openSUSE contendo as últimas + versões "estáveis" de todos os softwares em vez de depender de ciclos de + lançamento periódicos rígidos. O projeto faz isso para usuários que querem + o software estável mais novo. + ru: Дистрибутив Tumbleweed - это плавающий выпуск openSUSE, содержащий последние + "стабильные" версии всего программного обеспечения, вместо того чтобы + полагаться на жесткие периодические циклы выпуска. Проект делает его для + пользователей, которым нужно самое новое стабильное программное + обеспечение. + sv: En ren rullande släppversion av openSUSE som innehåller de senaste "stabila" + versionerna av all programvara istället för att förlita sig på stela + periodiska släppcykler. Projektet gör detta för användare som vill ha den + senaste stabila mjukvaran. + tr: Katı periyodik sürüm döngülerine güvenmek yerine tüm yazılımların en son + "kararlı" sürümlerini içeren openSUSE'nin saf bir yuvarlanan sürümü. Proje + bunu en yeni kararlı yazılımı isteyen kullanıcılar için yapar. + zh_Hans: Tumbleweed 发行版是 openSUSE + 的纯滚动发布版本,其并不依赖于严格的定时发布周期,而是持续包含所有最新“稳定”版本的软件。该项目为追求最新稳定软件的用户而生。 +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/non-oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ + archs: ppc + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + # device labels for offline installation media + installation_labels: + - label: openSUSE-Tumbleweed-DVD-x86_64 + archs: x86_64 + - label: openSUSE-Tumbleweed-DVD-aarch64 + archs: aarch64 + - label: openSUSE-Tumbleweed-DVD-s390x + archs: s390 + - label: openSUSE-Tumbleweed-DVD-ppc64le + archs: ppc + mandatory_patterns: + - enhanced_base # only pattern that is shared among all roles on TW + optional_patterns: null # no optional pattern shared + user_patterns: + - basic_desktop + - xfce + - kde + - gnome + - yast2_basis + - yast2_desktop + - yast2_server + - multimedia + - office + - name: selinux + selected: true + - apparmor + mandatory_packages: + - NetworkManager + - openSUSE-repos-Tumbleweed + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: openSUSE + +security: + lsm: selinux + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + boot_strategy: BLS + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 250% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/test/share/products.d/kalpa.yaml b/rust/test/share/products.d/kalpa.yaml new file mode 100644 index 0000000000..0298a97754 --- /dev/null +++ b/rust/test/share/products.d/kalpa.yaml @@ -0,0 +1,100 @@ +id: Kalpa +name: Kalpa Desktop +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: "A rolling release immutable desktop product, using the Plasma + Desktop, leveraging Flatpak for Application Delivery, a Read-Only base, and + automatic and atomic updates of your system" +icon: Kalpa.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + # device labels for offline installation media + installation_labels: + - label: Kalpa-desktop-DVD-x86_64 + archs: x86_64 + mandatory_patterns: + - microos_base + - microos_base_zypper + - microos_defaults + - microos_hardware + - microos_kde_desktop + - microos_selinux + optional_patterns: null + user_patterns: + - container_runtime + mandatory_packages: + - NetworkManager + - openSUSE-repos-MicroOS + optional_packages: null + base_product: Kalpa + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - microos_selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "/var" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: true + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + - path: boot/writable + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/x86_64-efi + archs: x86_64 + size: + auto: true + outline: + required: true + snapshots_configurable: false + filesystems: + - btrfs + auto_size: + base_min: 5 GiB + base_max: 25 GiB + max_fallback_for: + - "/var" + - mount_path: "/var" + filesystem: btrfs + mount_options: + - "x-initrd.mount" + - "nodatacow" + size: + auto: false + min: 5 GiB + outline: + required: false + filesystems: + - btrfs diff --git a/rust/test/share/products.d/leap_160.yaml b/rust/test/share/products.d/leap_160.yaml new file mode 100644 index 0000000000..2a627eb6b0 --- /dev/null +++ b/rust/test/share/products.d/leap_160.yaml @@ -0,0 +1,178 @@ +id: Leap_16.0 +name: Leap 16.0 +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'The latest version of a community distribution based on the latest + SUSE Linux Enterprise Server.' +# Do not manually change any translations! See README.md for more details. +icon: Leap16.svg +translations: + description: + ca: La darrera versió d'una distribució comunitària basada en l'últim SUSE Linux + Enterprise Server. + cs: Nejnovější verze komunitní distribuce založené na nejnovějším SUSE Linux + Enterprise Serveru. + de: Die neueste Version einer Community-Distribution, die auf dem aktuellen SUSE + Linux Enterprise Server basiert. + es: La última versión de una distribución comunitaria basada en el último SUSE + Linux Enterprise Server. + ja: 最新のSUSE Linux Enterprise Server をベースにした、コミュニティディストリビューションの最新版です。 + nb_NO: Leap 16.0 er den nyeste versjonen av den fellesskapte distribusjon basert + på den nyeste SUSE Linux Enterprise Server. + pt_BR: A versão mais recente de uma distribuição comunitária baseada no mais + recente SUSE Linux Enterprise Server. + ru: Leap 16.0 - это последняя версия дистрибутива от сообщества, основанного на + последней версии SUSE Linux Enterprise Server. + sv: Den senaste versionen av en gemenskapsdistribution baserad på den senaste + SUSE Linux Enterprise Server. + tr: En son SUSE Linux Enterprise Server'ı temel alan bir topluluk dağıtımının en + son sürümü. + zh_Hans: Leap 16.0 是基于 SUSE Linux Enterprise Server 构建的社区发行版的最新版本。 +software: + installation_repositories: + - url: https://download.opensuse.org/distribution/leap/16.0/repo/oss/$basearch + installation_labels: + - label: Leap-DVD-x86_64 + archs: x86_64 + - label: Leap-DVD-aarch64 + archs: aarch64 + - label: Leap-DVD-s390x + archs: s390 + - label: Leap-DVD-ppc64le + archs: ppc + mandatory_patterns: + - enhanced_base # only pattern that is shared among all roles on Leap + optional_patterns: null # no optional pattern shared + user_patterns: + - gnome + - kde + - xfce_wayland + - multimedia + - office + - cockpit + - fips + - name: selinux + selected: true + - documentation + - sw_management + - container_runtime_podman + - dhcp_dns_server + - directory_server + - file_server + - gateway_server + - kvm_server + - kvm_tools + - lamp_server + - mail_server + - printing + mandatory_packages: + - NetworkManager + - openSUSE-repos-Leap + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: Leap + +security: + lsm: selinux + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 150% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/test/share/products.d/leap_micro_62.yaml b/rust/test/share/products.d/leap_micro_62.yaml new file mode 100644 index 0000000000..e38012b577 --- /dev/null +++ b/rust/test/share/products.d/leap_micro_62.yaml @@ -0,0 +1,111 @@ +id: LeapMicro_6.2 +name: openSUSE Leap Micro 6.2 Beta +archs: x86_64,aarch64 +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'Leap Micro is an ultra-reliable, lightweight operating system + built for containerized and virtualized workloads.' +icon: LeapMicro.svg +software: + installation_repositories: + - url: https://download.opensuse.org/distribution/leap-micro/6.2/product/repo/openSUSE-Leap-Micro-6.2-x86_64 + archs: x86_64 + - url: https://download.opensuse.org/distribution/leap-micro/6.2/product/repo/openSUSE-Leap-Micro-6.2-aarch64 + archs: aarch64 + # device labels for offline installation media + installation_labels: + - label: openSUSE-Leap-Micro-DVD-x86_64 + archs: x86_64 + - label: openSUSE-Leap-Micro-DVD-aarch64 + archs: aarch64 + + mandatory_patterns: + - cockpit + - base + - transactional + - traditional + - hardware + - selinux + + optional_patterns: null + + user_patterns: + - cloud + - container_runtime + - fips + - ima_evm + - kvm_host + - ra_agent + - ra_verifier + - salt_minion + - sssd_ldap + + mandatory_packages: + - NetworkManager + - openSUSE-repos-LeapMicro + optional_packages: null + base_product: Leap-Micro + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "/var" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: true + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + - path: boot/writable + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/x86_64-efi + archs: x86_64 + size: + auto: true + outline: + required: true + snapshots_configurable: false + filesystems: + - btrfs + auto_size: + base_min: 5 GiB + base_max: 25 GiB + max_fallback_for: + - "/var" + - mount_path: "/var" + filesystem: btrfs + mount_options: + - "x-initrd.mount" + - "nodatacow" + size: + auto: false + min: 5 GiB + outline: + required: false + filesystems: + - btrfs diff --git a/rust/test/share/products.d/microos.yaml b/rust/test/share/products.d/microos.yaml new file mode 100644 index 0000000000..ac8bbc7c48 --- /dev/null +++ b/rust/test/share/products.d/microos.yaml @@ -0,0 +1,198 @@ +id: MicroOS +name: openSUSE MicroOS +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'A quick, small distribution designed to host container workloads + with automated administration & patching. openSUSE MicroOS provides + transactional (atomic) updates upon a read-only btrfs root file system. As + rolling release distribution the software is always up-to-date.' +icon: MicroOS.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una distribució ràpida i petita dissenyada per allotjar càrregues de treball + de contenidors amb administració i pedaços automatitzats. L'openSUSE + MicroSO proporciona actualitzacions transaccionals (atòmiques) en un + sistema de fitxers d'arrel btrfs només de lectura. Com a distribució + contínua, el programari està sempre actualitzat. + cs: Rychlá, malá distribuce určená pro úlohy hostitelského kontejneru s + automatizovanou správou a záplatováním. openSUSE MicroOS poskytuje + transakční (atomické) aktualizace na kořenovém souborovém systému btrfs + určeném pouze pro čtení. Jako distribuce s průběžným vydáváním je software + vždy aktuální. + de: Eine schnelle, kleine Distribution, die für den Betrieb von + Container-Arbeitslasten mit automatischer Verwaltung und automatisiertem + Patching entwickelt wurde. openSUSE MicroOS bietet transaktionale + (atomare) Aktualisierungen auf einem schreibgeschützten + btrfs-Wurzeldateisystem. Als Distribution mit rollierenden + Veröffentlichungen ist die Software immer auf dem neuesten Stand. + es: Una distribución pequeña y rápida diseñada para alojar cargas de trabajo de + contenedores con administración y parches automatizados. openSUSE MicroOS + proporciona actualizaciones transaccionales (atómicas) en un sistema de + archivos raíz btrfs de solo lectura. Como distribución de actualización + continua, el software siempre está actualizado. + fr: Une petite distribution rapide conçue pour héberger des charges de travail + de conteneurs avec une administration et des correctifs automatisés. + openSUSE MicroOS fournit des mises à jour transactionnelles (atomiques) + sur un système de fichiers racine btrfs en lecture seule. En tant que + distribution continue, le logiciel est toujours à jour. + id: Distribusi cepat dan ramping yang dirancang untuk menampung beban kerja + kontainer dengan administrasi & penambalan otomatis. openSUSE MicroOS + menyediakan pembaruan transaksional (atomik) pada sistem berkas root btrfs + yang hanya dapat dibaca. Sebagai distribusi rilis bergulir, perangkat + lunak didalamnya selalu diperbarui. + ja: 高速で小型のディストリビューションで、管理やパッチ適用の自動化のようなコンテナ処理を賄うのに最適な仕組みです。 openSUSE MicroOS + はトランザクション型の (不可分の) 更新機構が提供されており、 btrfs + のルートファイルシステムを読み込み専用にすることができます。こちらもローリングリリース型のディストリビューションであるため、常に最新を維持することができます。 + nb_NO: En rask, liten distribusjon laget for å være vert til container + arbeidsoppgaver med automatisk administrasjon & lapping. openSUSE MicroOS + gir transaksjonelle (atomisk) oppdateringer oppå en skrivebeskyttet btrfs + rotfilsystem. Som rullerende distribusjon er programvaren alltid + oppdatert. + pt_BR: Uma distribuição pequena e rápida projetada para hospedar cargas de + trabalho de contêiner com administração e aplicação de patches + automatizadas. O openSUSE MicroOS fornece atualizações transacionais + (atômicas) em um sistema de arquivos raiz btrfs somente leitura. Como + distribuição contínua, o software está sempre atualizado. + ru: Быстрый, минималистичный дистрибутив, предназначенный для размещения + контейнерных рабочих нагрузок с автоматизированным администрированием и + исправлениями. openSUSE MicroOS обеспечивает транзакционные (атомарные) + обновления на корневой файловой системе btrfs, доступной только для + чтения. Так как дистрибутив использует плавающий выпуск обновлений, + программное обеспечение всегда актуально. + sv: En snabb, liten distribution utformad för att vara värd för + arbetsbelastningar i behållare med automatiserad administration och + patchning. openSUSE MicroOS tillhandahåller transaktionella (atomära) + uppdateringar på ett skrivskyddat btrfs-rootfilsystem. Som rullande + releasedistribution är mjukvaran alltid uppdaterad. + tr: Otomatik yönetim ve yama uygulamayla konteyner iş yüklerini barındırmak için + tasarlanmış hızlı, küçük bir dağıtım. openSUSE MicroOS, salt okunur bir + btrfs kök dosya sistemi üzerinde işlemsel (atomik) güncellemeler sağlar. + Sürekli sürüm dağıtımı olarak yazılım her zaman günceldir. + zh_Hans: 一个快速、小型的发行版,旨在通过自动化管理和修补来托管容器工作负载。openSUSE MicroOS 提供基于只读 Btrfs + 根文件系统之上的事务性(原子)更新。作为滚动发行版,它的软件始终保持最新。 +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + # aarch64 does not have non-oss ports. Keep eye if it change + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ + archs: ppc + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + # device labels for offline installation media + installation_labels: + - label: openSUSE-MicroOS-DVD-x86_64 + archs: x86_64 + - label: openSUSE-MicroOS-DVD-aarch64 + archs: aarch64 + - label: openSUSE-MicroOS-DVD-s390x + archs: s390 + - label: openSUSE-MicroOS-DVD-ppc64le + archs: ppc + mandatory_patterns: + - microos_base + - microos_base_zypper + - microos_defaults + - microos_hardware + - microos_selinux + optional_patterns: null + user_patterns: + - container_runtime + - microos_ra_agent + - microos_ra_verifier + mandatory_packages: + - NetworkManager + - openSUSE-repos-MicroOS + optional_packages: null + base_product: MicroOS + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - microos_selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "/var" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: true + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + - path: boot/writable + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + snapshots_configurable: false + filesystems: + - btrfs + auto_size: + base_min: 5 GiB + base_max: 25 GiB + max_fallback_for: + - "/var" + - mount_path: "/var" + filesystem: btrfs + mount_options: + - "x-initrd.mount" + - "nodatacow" + size: + auto: false + min: 5 GiB + outline: + required: false + filesystems: + - btrfs diff --git a/rust/test/share/products.d/sles_160.yaml b/rust/test/share/products.d/sles_160.yaml new file mode 100644 index 0000000000..8e018535b7 --- /dev/null +++ b/rust/test/share/products.d/sles_160.yaml @@ -0,0 +1,200 @@ +id: SLES +name: SUSE Linux Enterprise Server 16.0 +registration: true +version: "16.0" +license: "license.final" +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: "An open, reliable, compliant, and future-proof Linux Server choice + that ensures the enterprise's business continuity. It is the secure and + adaptable OS for long-term supported, innovation-ready infrastructure running + business-critical workloads on-premises, in the cloud, and at the edge." +icon: SUSE.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una opció de servidor de Linux oberta, fiable, compatible i a prova del + futur que garanteix la continuïtat del negoci de l'empresa. És el sistema + operatiu segur i adaptable per a una infraestructura amb suport a llarg + termini i preparada per a la innovació que executa càrregues de treball + crítiques per a l'empresa a les instal·lacions, al núvol i a l'última. + cs: Otevřená, spolehlivá, kompatibilní a perspektivní volba linuxového serveru, + která zajišťuje kontinuitu podnikání podniku. Je to bezpečný a + přizpůsobivý operační systém pro dlouhodobě podporovanou infrastrukturu + připravenou na inovace, na které běží kritické podnikové úlohy v lokálním + prostředí, v cloudu i na okraji sítě. + de: Ein offener, zuverlässiger, kompatibler und zukunftssicherer Linux-Server, + der die Geschäftskontinuität des Unternehmens gewährleistet. Es ist das + sichere und anpassungsfähige Betriebssystem für eine langfristig + unterstützte, innovationsbereite Infrastruktur, auf der geschäftskritische + Arbeitslasten vor Ort, in der Cloud und am Netzwerkrand ausgeführt werden. + es: Una opción de servidor Linux abierta, confiable, compatible y preparada para + el futuro que garantiza la continuidad del negocio de la empresa. Es el + sistema operativo seguro y adaptable para una infraestructura lista para + la innovación y con soporte a largo plazo que ejecuta cargas de trabajo + críticas para el negocio en las instalaciones, en la nube y en el borde. + ja: オープンで信頼性が高く、各種の標準にも準拠し、将来性とビジネスの継続性を支援する Linux + サーバです。長期のサポートが提供されていることから、安全性と順応性に優れ、オンプレミスからクラウド、エッジ環境に至るまで、様々な場所で重要なビジネス処理をこなすことのできる革新性の高いインフラストラクチャです。 + pt_BR: Uma escolha de servidor Linux aberta, confiável, compatível e à prova do + futuro que garante a continuidade dos negócios da empresa. É o SO seguro e + adaptável para infraestrutura com suporte de longo prazo e pronta para + inovação, executando cargas de trabalho críticas para os negócios no + local, na nuvem e na borda. + sv: Ett öppet, pålitligt, kompatibelt och framtidssäkert Linux-serverval som + säkerställer företagets affärskontinuitet. Det är det säkra och + anpassningsbara operativsystemet för långsiktigt stödd, innovationsfärdig + infrastruktur som kör affärskritiska arbetsbelastningar på plats, i molnet + och vid kanten. + tr: İşletmenin iş sürekliliğini garanti eden açık, güvenilir, uyumlu ve geleceğe + dönük bir Linux Sunucu seçeneği. Uzun vadeli desteklenen, inovasyona hazır + altyapı için güvenli ve uyarlanabilir işletim sistemidir. Şirket içinde, + bulutta ve uçta iş açısından kritik iş yüklerini çalıştırır. +software: + installation_repositories: [] + installation_labels: + - label: SLES160-x86_64 + archs: x86_64 + - label: SLES160-arch64 + archs: aarch64 + - label: SLES160-s390x + archs: s390 + - label: SLES160-ppc64 + archs: ppc + + mandatory_patterns: + - enhanced_base + - bootloader + optional_patterns: null # no optional pattern shared + user_patterns: + - cockpit + - sles_sap_minimal_sap + - fips + - name: selinux + selected: true + - documentation + - sw_management + - container_runtime_docker + - container_runtime_podman + - dhcp_dns_server + - directory_server + - file_server + - gateway_server + - kvm_server + - kvm_tools + - lamp_server + - mail_server + - gnome + - gnome_internet + - devel_basis + - devel_kernel + - oracle_server + - print_server + mandatory_packages: + - NetworkManager + # bsc#1241224, bsc#1224868 avoid probe DHCP over all ethernet devices and ignore carrier + - NetworkManager-config-server + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: SLES + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 150% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/test/share/products.d/sles_sap_160.yaml b/rust/test/share/products.d/sles_sap_160.yaml new file mode 100644 index 0000000000..a11ff9bc9a --- /dev/null +++ b/rust/test/share/products.d/sles_sap_160.yaml @@ -0,0 +1,174 @@ +id: SLES_SAP +name: SUSE Linux Enterprise Server for SAP applications 16.0 +archs: x86_64,ppc +registration: true +version: "16.0" +license: "license.final" +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: "The leading OS for a secure and reliable SAP platform. + Endorsed for SAP deployments, SUSE Linux Enterprise Server for SAP applications + futureproofs the SAP project, offers uninterrupted business, and minimizes + operational risks and costs." +icon: SUSE.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: +software: + installation_repositories: [] + installation_labels: + - label: S4SAP160-x86_64 + archs: x86_64 + - label: S4SAP160-ppc64 + archs: ppc + + mandatory_patterns: + - base + - enhanced_base + - bootloader + - sles_sap_base_sap_server + optional_patterns: null # no optional pattern shared + user_patterns: + # First all patterns from file sles_160.yaml + - cockpit + - sles_sap_minimal_sap + - fips + - name: selinux + selected: true + - documentation + - sw_management + - container_runtime_docker + - container_runtime_podman + - dhcp_dns_server + - directory_server + - file_server + - gateway_server + - kvm_server + - kvm_tools + - lamp_server + - mail_server + - gnome + - gnome_internet + - devel_basis + - devel_kernel + - oracle_server + - print_server + # Second, all patterns for SAP only + - sles_sap_DB + - sles_sap_HADB + - sles_sap_APP + - sles_sap_HAAPP + - sles_sap_trento_server + - sles_sap_trento_agent + - sles_sap_automation + - sles_sap_monitoring + - sles_sap_gui + mandatory_packages: + - NetworkManager + # bsc#1241224, bsc#1224868 avoid probe DHCP over all ethernet devices and ignore carrier + - NetworkManager-config-server + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: SLES_SAP + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 150% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/test/share/products.d/slowroll.yaml b/rust/test/share/products.d/slowroll.yaml new file mode 100644 index 0000000000..9ff192fd4a --- /dev/null +++ b/rust/test/share/products.d/slowroll.yaml @@ -0,0 +1,169 @@ +id: Slowroll +name: Slowroll +archs: x86_64 +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'An experimental and slightly slower rolling release of openSUSE + designed to update less often than Tumbleweed but more often than Leap without + forcing users to choose between "stable" and newer packages.' +icon: Slowroll.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una versió experimental d'openSUSE però lleugerament més lenta quant a la + continuïtat, dissenyada per actualitzar-se amb menys freqüència que el + Tumbleweed però més sovint que el Leap, sense obligar els usuaris a triar + entre paquets estables i nous. + cs: Experimentální a mírně zpomalené rolující vydání openSUSE, které je navržené + tak, aby se aktualizovalo méně často než Tumbleweed. Zároveň se však + aktualizuje častěji než Leap, aby se uživatelé nemuseli rozhodovat mezi + "stabilními" a novějšími balíčky. + de: Ein experimentelles und etwas langsameres Rolling Release von openSUSE, das + darauf ausgelegt ist, weniger häufig als Tumbleweed, aber häufiger als + Leap zu aktualisieren, ohne die Benutzer zu zwingen, zwischen „stabilen“ + und neueren Paketen zu wählen. + es: Una versión experimental y de actualización contínua ligeramente más lenta + de openSUSE, diseñada para actualizarse con menos frecuencia que + Tumbleweed pero más a menudo que Leap, sin obligar a los usuarios a elegir + entre paquetes "estables" y más nuevos. + ja: 実験的なディストリビューションではありますが、 Tumbleweed よりは比較的ゆっくりした、かつ Leap よりは速いペースで公開される + openSUSE ローリングリリース型ディストリビューションです。 "安定性" と最新パッケージの中間を目指しています。 + pt_BR: Uma versão experimental e um pouco mais lenta do openSUSE, projetada para + atualizar com menos frequência que o Tumbleweed, mas com mais frequência + que o Leap, sem forçar os usuários a escolher entre pacotes "estáveis" e + mais novos. + sv: En experimentell och något långsammare rullande utgåva av openSUSE utformad + för att få nya paketuppdateringar mer sällan än Tumbleweed men oftare än + Leap utan att tvinga användarna att välja mellan "stabila" eller nyare + paket. +software: + installation_repositories: + - url: https://download.opensuse.org/slowroll/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/slowroll/repo/non-oss/ + archs: x86_64 + + mandatory_patterns: + - enhanced_base + optional_patterns: null + user_patterns: + - basic-desktop + - gnome + - kde + - yast2_basis + - yast2_desktop + - yast2_server + - multimedia + - office + mandatory_packages: + - NetworkManager + - openSUSE-repos-Slowroll + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: openSUSE + +security: + lsm: apparmor + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + boot_strategy: BLS + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "0" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 250% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/test/share/products.d/tumbleweed.yaml b/rust/test/share/products.d/tumbleweed.yaml new file mode 100644 index 0000000000..561ff8aea7 --- /dev/null +++ b/rust/test/share/products.d/tumbleweed.yaml @@ -0,0 +1,224 @@ +id: Tumbleweed +name: openSUSE Tumbleweed +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'A pure rolling release version of openSUSE containing the latest + "stable" versions of all software instead of relying on rigid periodic release + cycles. The project does this for users that want the newest stable software.' +icon: Tumbleweed.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una versió de llançament continuada d'openSUSE que conté les darreres + versions estables de tot el programari en lloc de dependre de cicles de + llançament periòdics rígids. El projecte fa això per als usuaris que volen + el programari estable més nou. + cs: Čistě klouzavá verze openSUSE obsahující nejnovější "stabilní" verze + veškerého softwaru, která se nespoléhá na pevné periodické cykly vydávání. + Projekt to dělá pro uživatele, kteří chtějí nejnovější stabilní software. + de: Eine reine Rolling-Release-Version von openSUSE, die die neuesten „stabilen“ + Versionen der gesamten Software enthält, anstatt sich auf starre + periodische Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für + Benutzer, die die neueste, stabile Software wünschen. + es: Una versión de actualización continua pura de openSUSE que contiene las + últimas versiones "estables" de todo el software en lugar de depender de + rígidos ciclos de publicaciones periódicas. El proyecto hace esto para + usuarios que desean el software estable más novedoso. + fr: La distribution Tumbleweed est une pure "rolling release" (publication + continue) d'openSUSE contenant les dernières versions "stables" de tous + les logiciels au lieu de se baser sur des cycles de publication + périodiques et fixes. Le projet fait cela pour les utilisateurs qui + veulent les logiciels stables les plus récents. + id: Distribusi Tumbleweed merupakan versi rilis bergulir murni dari openSUSE + yang berisi versi "stabil" terbaru dari semua perangkat lunak dan tidak + bergantung pada siklus rilis berkala yang kaku. Proyek ini dibuat untuk + memenuhi kebutuhan pengguna yang menginginkan perangkat lunak stabil + terbaru. + ja: openSUSE の純粋なローリングリリース版で、特定のリリースサイクルによることなく全てのソフトウエアを最新の "安定" + バージョンに維持し続ける取り組みです。このプロジェクトは特に、最新の安定バージョンを使いたいユーザにお勧めです。 + nb_NO: Tumbleweed distribusjonen er en ren rullerende utgivelsesversjon av + openSUSE som inneholder de siste "stabile" versjonene av all programvare i + stedet for å stole på et rigid periodisk utgivelsessykluser. Prosjektet + gjør dette for brukere som vil ha de nyeste stabile programvarene. + pt_BR: Uma versão de lançamento puro e contínuo do openSUSE contendo as últimas + versões "estáveis" de todos os softwares em vez de depender de ciclos de + lançamento periódicos rígidos. O projeto faz isso para usuários que querem + o software estável mais novo. + ru: Дистрибутив Tumbleweed - это плавающий выпуск openSUSE, содержащий последние + "стабильные" версии всего программного обеспечения, вместо того чтобы + полагаться на жесткие периодические циклы выпуска. Проект делает его для + пользователей, которым нужно самое новое стабильное программное + обеспечение. + sv: En ren rullande släppversion av openSUSE som innehåller de senaste "stabila" + versionerna av all programvara istället för att förlita sig på stela + periodiska släppcykler. Projektet gör detta för användare som vill ha den + senaste stabila mjukvaran. + tr: Katı periyodik sürüm döngülerine güvenmek yerine tüm yazılımların en son + "kararlı" sürümlerini içeren openSUSE'nin saf bir yuvarlanan sürümü. Proje + bunu en yeni kararlı yazılımı isteyen kullanıcılar için yapar. + zh_Hans: Tumbleweed 发行版是 openSUSE + 的纯滚动发布版本,其并不依赖于严格的定时发布周期,而是持续包含所有最新“稳定”版本的软件。该项目为追求最新稳定软件的用户而生。 +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/non-oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ + archs: ppc + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + # device labels for offline installation media + installation_labels: + - label: openSUSE-Tumbleweed-DVD-x86_64 + archs: x86_64 + - label: openSUSE-Tumbleweed-DVD-aarch64 + archs: aarch64 + - label: openSUSE-Tumbleweed-DVD-s390x + archs: s390 + - label: openSUSE-Tumbleweed-DVD-ppc64le + archs: ppc + mandatory_patterns: + - enhanced_base # only pattern that is shared among all roles on TW + optional_patterns: null # no optional pattern shared + user_patterns: + - basic_desktop + - xfce + - kde + - gnome + - yast2_basis + - yast2_desktop + - yast2_server + - multimedia + - office + - name: selinux + selected: true + - apparmor + mandatory_packages: + - NetworkManager + - openSUSE-repos-Tumbleweed + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: openSUSE + +security: + lsm: selinux + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + boot_strategy: BLS + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 250% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 0e31d8df2b..96f0581350 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -6,8 +6,8 @@ mod tasks { use agama_cli::Cli; use agama_server::web::docs::{ ApiDocBuilder, ConfigApiDocBuilder, HostnameApiDocBuilder, ManagerApiDocBuilder, - MiscApiDocBuilder, ProfileApiDocBuilder, ScriptsApiDocBuilder, SoftwareApiDocBuilder, - StorageApiDocBuilder, UsersApiDocBuilder, + MiscApiDocBuilder, ProfileApiDocBuilder, ScriptsApiDocBuilder, StorageApiDocBuilder, + UsersApiDocBuilder, }; use clap::CommandFactory; use clap_complete::aot; @@ -70,7 +70,6 @@ mod tasks { write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; write_openapi(ProfileApiDocBuilder {}, out_dir.join("profile.json"))?; write_openapi(ScriptsApiDocBuilder {}, out_dir.join("scripts.json"))?; - write_openapi(SoftwareApiDocBuilder {}, out_dir.join("software.json"))?; write_openapi(StorageApiDocBuilder {}, out_dir.join("storage.json"))?; write_openapi(UsersApiDocBuilder {}, out_dir.join("users.json"))?; println!( diff --git a/rust/zypp-agama/Cargo.toml b/rust/zypp-agama/Cargo.toml new file mode 100644 index 0000000000..a3df587f59 --- /dev/null +++ b/rust/zypp-agama/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "zypp-agama" +version = "0.1.0" +edition = "2021" + +[dependencies] +zypp-agama-sys = { path="./zypp-agama-sys" } +url = "2.5.7" diff --git a/rust/zypp-agama/fixtures/zypp_root/.gitignore b/rust/zypp-agama/fixtures/zypp_root/.gitignore new file mode 100644 index 0000000000..212aef5fdd --- /dev/null +++ b/rust/zypp-agama/fixtures/zypp_root/.gitignore @@ -0,0 +1,2 @@ +/usr/ +/var/ diff --git a/rust/zypp-agama/fixtures/zypp_root/etc/zypp/repos.d/repo-oss.repo b/rust/zypp-agama/fixtures/zypp_root/etc/zypp/repos.d/repo-oss.repo new file mode 100644 index 0000000000..d888eb42b4 --- /dev/null +++ b/rust/zypp-agama/fixtures/zypp_root/etc/zypp/repos.d/repo-oss.repo @@ -0,0 +1,8 @@ +[repo-oss] +name=Main repository +enabled=1 +autorefresh=1 +baseurl=http://download.opensuse.org/distribution/leap/15.6/repo/oss/ +path=/ +type=rpm-md +keeppackages=0 diff --git a/rust/zypp-agama/src/callbacks.rs b/rust/zypp-agama/src/callbacks.rs new file mode 100644 index 0000000000..9c611ce525 --- /dev/null +++ b/rust/zypp-agama/src/callbacks.rs @@ -0,0 +1,160 @@ +use std::os::raw::{c_char, c_int, c_void}; + +use zypp_agama_sys::{ + DownloadProgressCallbacks, ZyppDownloadFinishCallback, ZyppDownloadProblemCallback, + ZyppDownloadProgressCallback, ZyppDownloadStartCallback, PROBLEM_RESPONSE, + PROBLEM_RESPONSE_PROBLEM_ABORT, PROBLEM_RESPONSE_PROBLEM_IGNORE, + PROBLEM_RESPONSE_PROBLEM_RETRY, +}; + +use crate::helpers::string_from_ptr; + +// empty progress callback +pub fn empty_progress(_value: i64, _text: String) -> bool { + true +} + +pub enum ProblemResponse { + RETRY, + ABORT, + IGNORE, +} + +impl From for PROBLEM_RESPONSE { + fn from(response: ProblemResponse) -> Self { + match response { + ProblemResponse::ABORT => PROBLEM_RESPONSE_PROBLEM_ABORT, + ProblemResponse::IGNORE => PROBLEM_RESPONSE_PROBLEM_IGNORE, + ProblemResponse::RETRY => PROBLEM_RESPONSE_PROBLEM_RETRY, + } + } +} + +// generic trait to +pub trait DownloadProgress { + // callback when download start + fn start(&self, _url: &str, _localfile: &str) {} + // callback when download is in progress + fn progress(&self, _value: i32, _url: &str, _bps_avg: f64, _bps_current: f64) -> bool { + true + } + // callback when problem occurs + fn problem(&self, _url: &str, _error_id: i32, _description: &str) -> ProblemResponse { + ProblemResponse::ABORT + } + // callback when download finishes either successfully or with error + fn finish(&self, _url: &str, _error_id: i32, _reason: &str) {} +} + +// Default progress that do nothing +pub struct EmptyDownloadProgress; +impl DownloadProgress for EmptyDownloadProgress {} + +unsafe extern "C" fn download_progress_start( + url: *const c_char, + localfile: *const c_char, + user_data: *mut c_void, +) where + F: FnMut(String, String), +{ + let user_data = &mut *(user_data as *mut F); + user_data(string_from_ptr(url), string_from_ptr(localfile)); +} + +fn get_download_progress_start(_closure: &F) -> ZyppDownloadStartCallback +where + F: FnMut(String, String), +{ + Some(download_progress_start::) +} + +unsafe extern "C" fn download_progress_progress( + value: c_int, + url: *const c_char, + bps_avg: f64, + bps_current: f64, + user_data: *mut c_void, +) -> bool +where + F: FnMut(i32, String, f64, f64) -> bool, +{ + let user_data = &mut *(user_data as *mut F); + user_data(value, string_from_ptr(url), bps_avg, bps_current) +} + +fn get_download_progress_progress(_closure: &F) -> ZyppDownloadProgressCallback +where + F: FnMut(i32, String, f64, f64) -> bool, +{ + Some(download_progress_progress::) +} + +unsafe extern "C" fn download_progress_problem( + url: *const c_char, + error: c_int, + description: *const c_char, + user_data: *mut c_void, +) -> PROBLEM_RESPONSE +where + F: FnMut(String, c_int, String) -> ProblemResponse, +{ + let user_data = &mut *(user_data as *mut F); + let res = user_data(string_from_ptr(url), error, string_from_ptr(description)); + res.into() +} + +fn get_download_progress_problem(_closure: &F) -> ZyppDownloadProblemCallback +where + F: FnMut(String, c_int, String) -> ProblemResponse, +{ + Some(download_progress_problem::) +} + +unsafe extern "C" fn download_progress_finish( + url: *const c_char, + error: c_int, + reason: *const c_char, + user_data: *mut c_void, +) where + F: FnMut(String, c_int, String), +{ + let user_data = &mut *(user_data as *mut F); + user_data(string_from_ptr(url), error, string_from_ptr(reason)); +} + +fn get_download_progress_finish(_closure: &F) -> ZyppDownloadFinishCallback +where + F: FnMut(String, c_int, String), +{ + Some(download_progress_finish::) +} + +pub(crate) fn with_c_download_callbacks(callbacks: &impl DownloadProgress, block: &mut F) -> R +where + F: FnMut(DownloadProgressCallbacks) -> R, +{ + let mut start_call = |url: String, localfile: String| callbacks.start(&url, &localfile); + let cb_start = get_download_progress_start(&start_call); + let mut progress_call = |value, url: String, bps_avg, bps_current| { + callbacks.progress(value, &url, bps_avg, bps_current) + }; + let cb_progress = get_download_progress_progress(&progress_call); + let mut problem_call = + |url: String, error, description: String| callbacks.problem(&url, error, &description); + let cb_problem = get_download_progress_problem(&problem_call); + let mut finish_call = + |url: String, error, description: String| callbacks.finish(&url, error, &description); + let cb_finish = get_download_progress_finish(&finish_call); + + let callbacks = DownloadProgressCallbacks { + start: cb_start, + start_data: &mut start_call as *mut _ as *mut c_void, + progress: cb_progress, + progress_data: &mut progress_call as *mut _ as *mut c_void, + problem: cb_problem, + problem_data: &mut problem_call as *mut _ as *mut c_void, + finish: cb_finish, + finish_data: &mut finish_call as *mut _ as *mut c_void, + }; + block(callbacks) +} diff --git a/rust/zypp-agama/src/errors.rs b/rust/zypp-agama/src/errors.rs new file mode 100644 index 0000000000..ce6456466d --- /dev/null +++ b/rust/zypp-agama/src/errors.rs @@ -0,0 +1,28 @@ +use std::{error::Error, fmt}; + +pub type ZyppResult = Result; + +#[derive(Debug)] +pub struct ZyppError { + details: String, +} + +impl ZyppError { + pub fn new(msg: &str) -> ZyppError { + ZyppError { + details: msg.to_string(), + } + } +} + +impl fmt::Display for ZyppError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.details) + } +} + +impl Error for ZyppError { + fn description(&self) -> &str { + &self.details + } +} diff --git a/rust/zypp-agama/src/helpers.rs b/rust/zypp-agama/src/helpers.rs new file mode 100644 index 0000000000..fbb71da97e --- /dev/null +++ b/rust/zypp-agama/src/helpers.rs @@ -0,0 +1,29 @@ +// Safety requirements: inherited from https://doc.rust-lang.org/std/ffi/struct.CStr.html#method.from_ptr +pub(crate) unsafe fn string_from_ptr(c_ptr: *const i8) -> String { + String::from_utf8_lossy(std::ffi::CStr::from_ptr(c_ptr).to_bytes()).into_owned() +} + +// Safety requirements: ... +pub(crate) unsafe fn status_to_result( + mut status: zypp_agama_sys::Status, + result: R, +) -> Result { + let res = if status.state == zypp_agama_sys::Status_STATE_STATE_SUCCEED { + Ok(result) + } else { + Err(crate::ZyppError::new( + string_from_ptr(status.error).as_str(), + )) + }; + let status_ptr = &mut status; + zypp_agama_sys::free_status(status_ptr as *mut _); + + res +} + +// Safety requirements: ... +pub(crate) unsafe fn status_to_result_void( + status: zypp_agama_sys::Status, +) -> Result<(), crate::ZyppError> { + status_to_result(status, ()) +} diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs new file mode 100644 index 0000000000..dba7f8eebd --- /dev/null +++ b/rust/zypp-agama/src/lib.rs @@ -0,0 +1,678 @@ +use std::{ + ffi::CString, + os::raw::{c_char, c_uint, c_void}, + sync::Mutex, +}; + +pub use callbacks::DownloadProgress; +use errors::ZyppResult; +use zypp_agama_sys::{ + get_patterns_info, PatternNames, ProgressCallback, ProgressData, Status, ZyppProgressCallback, +}; + +pub mod errors; +pub use errors::ZyppError; + +mod helpers; +use helpers::{status_to_result, status_to_result_void, string_from_ptr}; + +pub mod callbacks; + +#[derive(Debug)] +pub struct Repository { + pub enabled: bool, + pub url: String, + pub alias: String, + pub user_name: String, +} + +impl Repository { + /// check if url points to local repository. + /// Can be Err if url is invalid + pub fn is_local(&self) -> Result { + unsafe { + let c_url = CString::new(self.url.as_str()).unwrap(); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let result = zypp_agama_sys::is_local_url(c_url.as_ptr(), status_ptr); + status_to_result(status, result) + } + } +} + +#[derive(Debug)] +pub struct MountPoint { + pub directory: String, + pub filesystem: String, + pub grow_only: bool, + pub used_size: i64, +} + +// TODO: should we add also e.g. serd serializers here? +#[derive(Debug)] +pub struct PatternInfo { + pub name: String, + pub category: String, + pub icon: String, + pub description: String, + pub summary: String, + pub order: String, + pub selected: ResolvableSelected, +} + +// TODO: is there better way how to use type from ProgressCallback binding type? +unsafe extern "C" fn zypp_progress_callback( + zypp_data: ProgressData, + user_data: *mut c_void, +) -> bool +where + F: FnMut(i64, String) -> bool, +{ + let user_data = &mut *(user_data as *mut F); + user_data(zypp_data.value, string_from_ptr(zypp_data.name)) +} + +fn get_zypp_progress_callback(_closure: &F) -> ZyppProgressCallback +where + F: FnMut(i64, String) -> bool, +{ + Some(zypp_progress_callback::) +} + +unsafe extern "C" fn progress_callback( + text: *const c_char, + stage: c_uint, + total: c_uint, + user_data: *mut c_void, +) where + F: FnMut(String, u32, u32), +{ + let user_data = &mut *(user_data as *mut F); + user_data(string_from_ptr(text), stage, total); +} + +fn get_progress_callback(_closure: &F) -> ProgressCallback +where + F: FnMut(String, u32, u32), +{ + Some(progress_callback::) +} + +/// protection ensure that there is just single zypp lock with single target living +static GLOBAL_LOCK: Mutex = Mutex::new(false); + +/// The only instance of Zypp on which all zypp calls should be invoked. +/// It is intentionally !Send and !Sync as libzypp gives no guarantees regarding +/// threads, so it should be run only in single thread and sequentially. +#[derive(Debug)] +pub struct Zypp { + ptr: *mut zypp_agama_sys::Zypp, +} + +impl Zypp { + pub fn init_target(root: &str, progress: F) -> ZyppResult + where + // cannot be FnOnce, the whole point of progress callbacks is + // to provide feedback multiple times + F: FnMut(String, u32, u32), + { + let mut locked = GLOBAL_LOCK + .lock() + .map_err(|_| ZyppError::new("thread with zypp lock panic"))?; + if *locked { + return Err(ZyppError::new("There is already initialized target")); + } + + unsafe { + let mut closure = progress; + let cb = get_progress_callback(&closure); + let c_root = CString::new(root).unwrap(); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let inner_zypp = zypp_agama_sys::init_target( + c_root.as_ptr(), + status_ptr, + cb, + &mut closure as *mut _ as *mut c_void, + ); + helpers::status_to_result_void(status)?; + // lock only after we successfully get pointer + *locked = true; + let res = Self { ptr: inner_zypp }; + Ok(res) + } + } + + pub fn switch_target(&self, root: &str) -> ZyppResult<()> { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_root = CString::new(root).unwrap(); + unsafe { + zypp_agama_sys::switch_target(self.ptr, c_root.as_ptr(), status_ptr); + helpers::status_to_result_void(status) + } + } + + pub fn commit(&self) -> ZyppResult { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + unsafe { + let res = zypp_agama_sys::commit(self.ptr, status_ptr); + helpers::status_to_result(status, res) + } + } + + pub fn count_disk_usage( + &self, + mut mount_points: Vec, + ) -> ZyppResult> { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + unsafe { + // we need to hold dirs and fss here to ensure that CString lives long enough + let dirs: Vec = mount_points + .iter() + .map(|mp| { + CString::new(mp.directory.as_str()) + .expect("CString must not contain internal NUL") + }) + .collect(); + let fss: Vec = mount_points + .iter() + .map(|mp| { + CString::new(mp.filesystem.as_str()) + .expect("CString must not contain internal NUL") + }) + .collect(); + let libzypp_mps: Vec<_> = mount_points + .iter() + .enumerate() + .map(|(i, mp)| zypp_agama_sys::MountPoint { + directory: dirs[i].as_ptr(), + filesystem: fss[i].as_ptr(), + grow_only: mp.grow_only, + used_size: 0, + }) + .collect(); + zypp_agama_sys::get_space_usage( + self.ptr, + status_ptr, + libzypp_mps.as_ptr() as *mut _, + libzypp_mps.len() as u32, + ); + helpers::status_to_result_void(status)?; + + libzypp_mps.iter().enumerate().for_each(|(i, mp)| { + mount_points[i].used_size = mp.used_size; + }); + + return Ok(mount_points); + } + } + + pub fn list_repositories(&self) -> ZyppResult> { + let mut repos_v = vec![]; + + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + + let mut repos = zypp_agama_sys::list_repositories(self.ptr, status_ptr); + // unwrap is ok as it will crash only on less then 32b archs,so safe for agama + let size_usize: usize = repos.size.try_into().unwrap(); + for i in 0..size_usize { + let c_repo = *(repos.repos.add(i)); + let r_repo = Repository { + enabled: c_repo.enabled, + url: string_from_ptr(c_repo.url), + alias: string_from_ptr(c_repo.alias), + user_name: string_from_ptr(c_repo.userName), + }; + repos_v.push(r_repo); + } + let repos_rawp = &mut repos; + zypp_agama_sys::free_repository_list(repos_rawp as *mut _); + + helpers::status_to_result(status, repos_v) + } + } + + pub fn patterns_info(&self, names: Vec<&str>) -> ZyppResult> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_names: Vec = names + .iter() + .map(|s| CString::new(*s).expect("CString must not contain internal NUL")) + .collect(); + let c_ptr_names: Vec<*const i8> = + c_names.iter().map(|c| c.as_c_str().as_ptr()).collect(); + let pattern_names = PatternNames { + size: names.len() as u32, + names: c_ptr_names.as_ptr(), + }; + let infos = get_patterns_info(self.ptr, pattern_names, status_ptr); + helpers::status_to_result_void(status)?; + + let mut r_infos = Vec::with_capacity(infos.size as usize); + for i in 0..infos.size as usize { + let c_info = *(infos.infos.add(i)); + let r_info = PatternInfo { + name: string_from_ptr(c_info.name), + category: string_from_ptr(c_info.category), + icon: string_from_ptr(c_info.icon), + description: string_from_ptr(c_info.description), + summary: string_from_ptr(c_info.summary), + order: string_from_ptr(c_info.order), + selected: c_info.selected.into(), + }; + r_infos.push(r_info); + } + zypp_agama_sys::free_pattern_infos(&infos); + Ok(r_infos) + } + } + + pub fn import_gpg_key(&self, file_path: &str) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_path = CString::new(file_path).expect("CString must not contain internal NUL"); + zypp_agama_sys::import_gpg_key(self.ptr, c_path.as_ptr(), status_ptr); + status_to_result_void(status) + } + } + + pub fn select_resolvable( + &self, + name: &str, + kind: ResolvableKind, + who: ResolvableSelected, + ) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_name = CString::new(name).unwrap(); + let c_kind = kind.into(); + zypp_agama_sys::resolvable_select( + self.ptr, + c_name.as_ptr(), + c_kind, + who.into(), + status_ptr, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn unselect_resolvable( + &self, + name: &str, + kind: ResolvableKind, + who: ResolvableSelected, + ) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_name = CString::new(name).unwrap(); + let c_kind = kind.into(); + zypp_agama_sys::resolvable_unselect( + self.ptr, + c_name.as_ptr(), + c_kind, + who.into(), + status_ptr, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn is_package_selected(&self, tag: &str) -> ZyppResult { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_tag = CString::new(tag).unwrap(); + let res = zypp_agama_sys::is_package_selected(self.ptr, c_tag.as_ptr(), status_ptr); + + helpers::status_to_result(status, res) + } + } + + pub fn is_package_available(&self, tag: &str) -> ZyppResult { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_tag = CString::new(tag).unwrap(); + let res = zypp_agama_sys::is_package_available(self.ptr, c_tag.as_ptr(), status_ptr); + + helpers::status_to_result(status, res) + } + } + + pub fn refresh_repository( + &self, + alias: &str, + progress: &impl DownloadProgress, + ) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + let mut refresh_fn = |mut callbacks| { + zypp_agama_sys::refresh_repository( + self.ptr, + c_alias.as_ptr(), + status_ptr, + &mut callbacks, + ) + }; + callbacks::with_c_download_callbacks(progress, &mut refresh_fn); + + helpers::status_to_result_void(status) + } + } + + pub fn add_repository(&self, alias: &str, url: &str, progress: F) -> ZyppResult<()> + where + F: FnMut(i64, String) -> bool, + { + unsafe { + let mut closure = progress; + let cb = get_zypp_progress_callback(&closure); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _ as *mut Status; + let c_alias = CString::new(alias).unwrap(); + let c_url = CString::new(url).unwrap(); + zypp_agama_sys::add_repository( + self.ptr, + c_alias.as_ptr(), + c_url.as_ptr(), + status_ptr, + cb, + &mut closure as *mut _ as *mut c_void, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn disable_repository(&self, alias: &str) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + zypp_agama_sys::disable_repository(self.ptr, c_alias.as_ptr(), status_ptr); + + helpers::status_to_result_void(status) + } + } + + pub fn set_repository_url(&self, alias: &str, url: &str) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + let c_url = CString::new(url).unwrap(); + zypp_agama_sys::set_repository_url( + self.ptr, + c_alias.as_ptr(), + c_url.as_ptr(), + status_ptr, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn remove_repository(&self, alias: &str, progress: F) -> ZyppResult<()> + where + F: FnMut(i64, String) -> bool, + { + unsafe { + let mut closure = progress; + let cb = get_zypp_progress_callback(&closure); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + zypp_agama_sys::remove_repository( + self.ptr, + c_alias.as_ptr(), + status_ptr, + cb, + &mut closure as *mut _ as *mut c_void, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn create_repo_cache(&self, alias: &str, progress: F) -> ZyppResult<()> + where + F: FnMut(i64, String) -> bool, + { + unsafe { + let mut closure = progress; + let cb = get_zypp_progress_callback(&closure); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + zypp_agama_sys::build_repository_cache( + self.ptr, + c_alias.as_ptr(), + status_ptr, + cb, + &mut closure as *mut _ as *mut c_void, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn load_repo_cache(&self, alias: &str) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + zypp_agama_sys::load_repository_cache(self.ptr, c_alias.as_ptr(), status_ptr); + + helpers::status_to_result_void(status) + } + } + + pub fn run_solver(&self) -> ZyppResult { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let r_res = zypp_agama_sys::run_solver(self.ptr, status_ptr); + helpers::status_to_result(status, r_res) + } + } + + // high level method to load source + pub fn load_source(&self, progress: F) -> ZyppResult<()> + where + F: Fn(i64, String) -> bool, + { + let repos = self.list_repositories()?; + let enabled_repos: Vec<&Repository> = repos.iter().filter(|r| r.enabled).collect(); + // TODO: this step logic for progress can be enclosed to own struct + let mut percent: f64 = 0.0; + let percent_step: f64 = 100.0 / (enabled_repos.len() as f64 * 3.0); // 3 substeps + let abort_err = Err(ZyppError::new("Operation aborted")); + let mut cont: bool; + for i in enabled_repos { + cont = progress( + percent.floor() as i64, + format!("Refreshing repository {}", &i.alias).to_string(), + ); + if !cont { + return abort_err; + } + self.refresh_repository(&i.alias, &callbacks::EmptyDownloadProgress)?; + percent += percent_step; + cont = progress( + percent.floor() as i64, + format!("Creating repository cache for {}", &i.alias).to_string(), + ); + if !cont { + return abort_err; + } + self.create_repo_cache(&i.alias, callbacks::empty_progress)?; + percent += percent_step; + cont = progress( + percent.floor() as i64, + format!("Loading repository cache for {}", &i.alias).to_string(), + ); + if !cont { + return abort_err; + } + self.load_repo_cache(&i.alias)?; + percent += percent_step; + } + progress(100, "Loading repositories finished".to_string()); + Ok(()) + } +} + +impl Drop for Zypp { + fn drop(&mut self) { + println!("dropping Zypp"); + unsafe { + zypp_agama_sys::free_zypp(self.ptr); + } + // allow to init it again. If it is poisened, we just get inner pointer, but + // it is already end of fun with libzypp. + let mut locked = GLOBAL_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + *locked = false; + } +} + +pub enum ResolvableKind { + Package, + Pattern, + SrcPackage, + Patch, + Product, +} + +impl From for zypp_agama_sys::RESOLVABLE_KIND { + fn from(resolvable_kind: ResolvableKind) -> Self { + match resolvable_kind { + ResolvableKind::Package => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_PACKAGE, + ResolvableKind::SrcPackage => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_SRCPACKAGE, + ResolvableKind::Patch => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_PATCH, + ResolvableKind::Product => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_PRODUCT, + ResolvableKind::Pattern => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_PATTERN, + } + } +} + +#[derive(Debug)] +pub enum ResolvableSelected { + Not, + User, + Installation, + Solver, +} + +impl From for ResolvableSelected { + fn from(value: zypp_agama_sys::RESOLVABLE_SELECTED) -> Self { + match value { + zypp_agama_sys::RESOLVABLE_SELECTED_NOT_SELECTED => Self::Not, + zypp_agama_sys::RESOLVABLE_SELECTED_USER_SELECTED => Self::User, + zypp_agama_sys::RESOLVABLE_SELECTED_APPLICATION_SELECTED => Self::Installation, + zypp_agama_sys::RESOLVABLE_SELECTED_SOLVER_SELECTED => Self::Solver, + _ => panic!("Unknown value for resolvable_selected {}", value), + } + } +} + +impl From for zypp_agama_sys::RESOLVABLE_SELECTED { + fn from(val: ResolvableSelected) -> Self { + match val { + ResolvableSelected::Not => zypp_agama_sys::RESOLVABLE_SELECTED_NOT_SELECTED, + ResolvableSelected::User => zypp_agama_sys::RESOLVABLE_SELECTED_USER_SELECTED, + ResolvableSelected::Installation => { + zypp_agama_sys::RESOLVABLE_SELECTED_APPLICATION_SELECTED + } + ResolvableSelected::Solver => zypp_agama_sys::RESOLVABLE_SELECTED_SOLVER_SELECTED, + } + } +} + +// NOTE: because some tests panic, it can happen that some Mutexes are poisoned. So always run tests sequentially with +// `cargo test -- --test-threads 1` otherwise random failures can happen with poisoned GLOBAL_LOCK +#[cfg(test)] +mod tests { + use super::*; + use std::error::Error; + use std::process::Command; + + fn setup() { + // empty now + } + + fn progress_cb(_text: String, _step: u32, _total: u32) { + // println!("Test initializing target: {}/{} - {}", _step, _total, _text) + } + + // Init a RPM database in *root*, or do nothing if it exists + fn init_rpmdb(root: &str) -> Result<(), Box> { + Command::new("rpmdb") + .args(["--root", root, "--initdb"]) + .status()?; + Ok(()) + } + + #[test] + fn init_target() -> Result<(), Box> { + // run just single test to avoid threads as it cause zypp to be locked to one of those threads + { + setup(); + let result = Zypp::init_target("/", progress_cb); + assert!(result.is_ok()); + } + { + setup(); + // a nonexistent relative root triggers a C++ exception + let result = Zypp::init_target("not_absolute", progress_cb); + assert!(result.is_err()); + } + { + setup(); + + // double init of target + let z1 = Zypp::init_target("/", progress_cb); + let z2 = Zypp::init_target("/mnt", progress_cb); + assert!(z2.is_err()); + + // z1 call after init target for z2 to ensure that it is not dropped too soon + assert!(z1.is_ok(), "z1 is not properly init {:?}.", z1); + } + { + // list repositories test + setup(); + let cwd = std::env::current_dir()?; + let root_buf = cwd.join("fixtures/zypp_root"); + root_buf + .try_exists() + .expect("run this from the dir that has fixtures/"); + let root = root_buf.to_str().expect("CWD is not UTF-8"); + + init_rpmdb(root)?; + let zypp = Zypp::init_target(root, progress_cb)?; + let repos = zypp.list_repositories()?; + assert!(repos.len() == 1); + } + { + setup(); + // when the target path is not a (potential) root diretory + // NOTE: run it as last test as it keeps ZyppLock in cached state, so next init root with correct path will fail. + let result = Zypp::init_target("/dev/full", progress_cb); + assert!(result.is_err()); + } + Ok(()) + } +} diff --git a/rust/zypp-agama/zypp-agama-sys/Cargo.toml b/rust/zypp-agama/zypp-agama-sys/Cargo.toml new file mode 100644 index 0000000000..5600e0469e --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "zypp-agama-sys" +version = "0.1.0" +edition.workspace = true + +[build-dependencies] +bindgen = { version= "0.72.1", features = ["runtime"] } diff --git a/rust/zypp-agama/zypp-agama-sys/README.md b/rust/zypp-agama/zypp-agama-sys/README.md new file mode 100644 index 0000000000..9c03476bb8 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/README.md @@ -0,0 +1,9 @@ +## Sys Crate for Agama Zypp + +Low level FFI bindings to agama-zypp c layer. + +How to regenerate bindings ( using bindgen-cli ): + +``` +bindgen --merge-extern-blocks headers.h -o src/bindings.rs -- -I../../c-layer/include +``` diff --git a/rust/zypp-agama/zypp-agama-sys/build.rs b/rust/zypp-agama/zypp-agama-sys/build.rs new file mode 100644 index 0000000000..7a025043e1 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/build.rs @@ -0,0 +1,63 @@ +use bindgen::builder; +use std::{env, fs, path::Path, process::Command}; + +// Write *contents* to *file_path* (panicking on problems) +// but do not update existing file if the exact contents is already there. +// Thus prevent needless rebuilds. +fn update_file(file_path: &str, contents: &str) { + let should_write = if Path::new(file_path).exists() { + match fs::read_to_string(file_path) { + Ok(existing_content) => existing_content != contents, + Err(_) => true, // File exists but can't read it, write anyway + } + } else { + true // File doesn't exist, write it + }; + + if should_write { + fs::write(file_path, contents).unwrap_or_else(|_| panic!("Couldn't write {}", file_path)); + } +} + +const WARNING_PREFIX: &str = "cargo::warning="; +// For each line in *stderr*, println! the line +// prefixed with WARNING_PREFIX +fn show_warnings(stderr: Vec) { + let stderr_str = String::from_utf8_lossy(&stderr); + for line in stderr_str.lines() { + println!("{}{}", WARNING_PREFIX, line); + } +} + +fn main() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let mut cmd = Command::new("make"); + cmd.arg("-C"); + cmd.arg(Path::new(&manifest_dir).join("c-layer").as_os_str()); + let output = cmd.output().expect("Failed to start make process"); + let result = output.status; + show_warnings(output.stderr); + if !result.success() { + panic!("Building C library failed.\n"); + } + + let bindings = builder() + .header("c-layer/include/headers.h") + .merge_extern_blocks(true) + .clang_arg("-I") + .clang_arg("../../c-layer/include") + .generate() + .expect("Unable to generate bindings"); + update_file("src/bindings.rs", &bindings.to_string()); + + println!( + "cargo::rustc-link-search=native={}", + Path::new(&manifest_dir).join("c-layer").display() + ); + println!("cargo::rustc-link-lib=static=agama-zypp"); + println!("cargo::rustc-link-lib=dylib=zypp"); + println!("cargo::rustc-link-lib=dylib=systemd"); + // NOTE: install the matching library for your compiler version, for example + // libstdc++6-devel-gcc13.rpm + println!("cargo::rustc-link-lib=dylib=stdc++"); +} diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile new file mode 100644 index 0000000000..f02476b35f --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile @@ -0,0 +1,24 @@ +AR=ar +CXX=g++ +CXXFLAGS=-Wall -I./include -I./internal -Izypp -Wall -std=c++14 -lzypp -lsystemd -fPIE +DEPS = include/lib.h include/callbacks.h internal/callbacks.hxx +OBJ = lib.o callbacks.o + +all: libagama-zypp.a + +clean: + rm -vf *.o *.a + +check: + git ls-files | grep -E '\.(c|h|cxx|hxx)$$' | \ + xargs --verbose clang-format --style=llvm --dry-run + +libagama-zypp.a: $(OBJ) + $(AR) -crs $@ $^ + +%.o: %.cxx $(DEPS) + $(CXX) -c -o $@ $< $(CXXFLAGS) + +format: + git ls-files | grep -E '\.(c|h|cxx|hxx)$$' | \ + xargs --verbose clang-format --style=llvm -i diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/README.md b/rust/zypp-agama/zypp-agama-sys/c-layer/README.md new file mode 100644 index 0000000000..78d4a15c26 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/README.md @@ -0,0 +1,27 @@ +## C-Layer on top of Libzypp + +Goal of this part is to provide C API on top of libzypp. Goal is to have it as thin layer +that allows to call easily libzypp functionality from languages that have issue to call C++ code (so almost all). + +### Directories + +- `/include` is official public C API +- `/internal` is internal only C++ headers when parts of code need to communicate + +### Reminders + +- if new header file is added to `/include` add it also to `../rust/zypp-agama-sys/headers.h` + +### Coding Conventions + +- All public methods are `noexcept`. Instead it should get `status` parameter that is properly filled in both case if exception happen and also if call succeed. +- If method progress can be observed, then use progress parameter. It can have two forms: + 1. just single method pointer and void* for data. + 2. one struct that contain multiple method pointers and for each pointer its void* data. + Selection of variant depends on what libzypp provides. If libzypp use global progress Receiver, then + it should be still parameter to method and it should be set at the beginning of method and unset at the end. +- if method provide any pointer, then memory is owned by caller who should deallocate it. +- if pointer provided by method is non-trivial ( usually struct ), then there have to be API call to free it. +- if method gets any pointer, it is still owned by caller who is responsible for its deallocation. +- if callback method receive any pointer, it is owned by library and library will deallocate it after callback finish. +- ideally C layer should only have runtime dependency on libzypp and libstdc++ diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx new file mode 100644 index 0000000000..f76be253ca --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx @@ -0,0 +1,156 @@ +#include +#include +#include + +#include "callbacks.h" + +struct ProgressReceive : zypp::callback::ReceiveReport { + ZyppProgressCallback callback; + void *user_data; + + ProgressReceive() {} + + void set_callback(ZyppProgressCallback callback_, void *user_data_) { + callback = callback_; + user_data = user_data_; + } + + // TODO: should we distinguish start/finish? and if so, is enum param to + // callback enough instead of having three callbacks? + virtual void start(const zypp::ProgressData &task) { + if (callback != NULL) { + ProgressData data = {task.reportValue(), task.name().c_str()}; + callback(data, user_data); + } + } + + bool progress(const zypp::ProgressData &task) { + if (callback != NULL) { + ProgressData data = {task.reportValue(), task.name().c_str()}; + return callback(data, user_data); + } else { + return zypp::ProgressReport::progress(task); + } + } + + virtual void finish(const zypp::ProgressData &task) { + if (callback != NULL) { + ProgressData data = {task.reportValue(), task.name().c_str()}; + callback(data, user_data); + } + } +}; + +static ProgressReceive progress_receive; + +struct DownloadProgressReceive : public zypp::callback::ReceiveReport< + zypp::media::DownloadProgressReport> { + int last_reported; + time_t last_reported_time; + struct DownloadProgressCallbacks *callbacks; + + DownloadProgressReceive() { callbacks = NULL; } + + void set_callbacks(DownloadProgressCallbacks *callbacks_) { + callbacks = callbacks_; + } + + virtual void start(const zypp::Url &file, zypp::Pathname localfile) { + last_reported = 0; + last_reported_time = time(NULL); + + if (callbacks != NULL && callbacks->start != NULL) { + callbacks->start(file.asString().c_str(), localfile.c_str(), + callbacks->start_data); + } + } + + virtual bool progress(int value, const zypp::Url &file, double bps_avg, + double bps_current) { + // call the callback function only if the difference since the last call is + // at least 5% or if 100% is reached or if at least 3 seconds have elapsed + time_t current_time = time(NULL); + const int timeout = 3; + if (callbacks != NULL && callbacks->progress != NULL && + (value - last_reported >= 5 || last_reported - value >= 5 || + value == 100 || current_time - last_reported_time >= timeout)) { + last_reported = value; + last_reported_time = current_time; + // report changed values + return callbacks->progress(value, file.asString().c_str(), bps_avg, + bps_current, callbacks->progress_data) != 0; + } + + return true; + } + + virtual Action problem(const zypp::Url &file, + zypp::media::DownloadProgressReport::Error error, + const std::string &description) { + if (callbacks != NULL && callbacks->problem != NULL) { + PROBLEM_RESPONSE response = + callbacks->problem(file.asString().c_str(), error, + description.c_str(), callbacks->problem_data); + + switch (response) { + case PROBLEM_RETRY: + return zypp::media::DownloadProgressReport::RETRY; + case PROBLEM_ABORT: + return zypp::media::DownloadProgressReport::ABORT; + case PROBLEM_IGNORE: + return zypp::media::DownloadProgressReport::IGNORE; + } + } + // otherwise return the default value from the parent class + return zypp::media::DownloadProgressReport::problem(file, error, + description); + } + + virtual void finish(const zypp::Url &file, + zypp::media::DownloadProgressReport::Error error, + const std::string &reason) { + if (callbacks != NULL && callbacks->finish != NULL) { + callbacks->finish(file.asString().c_str(), error, reason.c_str(), + callbacks->finish_data); + } + } +}; + +static DownloadProgressReceive download_progress_receive; + +extern "C" { +void set_zypp_progress_callback(ZyppProgressCallback progress, + void *user_data) { + progress_receive.set_callback(progress, user_data); + progress_receive.connect(); +} +} + +void set_zypp_download_callbacks(struct DownloadProgressCallbacks *callbacks) { + download_progress_receive.set_callbacks(callbacks); + download_progress_receive.connect(); +} + +void unset_zypp_download_callbacks() { + // NULL pointer to struct to be sure it is not called + download_progress_receive.set_callbacks(NULL); + download_progress_receive.disconnect(); +} + +#ifdef __cplusplus +bool dynamic_progress_callback(ZyppProgressCallback progress, void *user_data, + const zypp::ProgressData &task) { + if (progress != NULL) { + ProgressData data = {task.reportValue(), task.name().c_str()}; + return progress(data, user_data); + } else { + return true; + } +} + +zypp::ProgressData::ReceiverFnc +create_progress_callback(ZyppProgressCallback progress, void *user_data) { + return zypp::ProgressData::ReceiverFnc( + boost::bind(dynamic_progress_callback, progress, user_data, _1)); +} +#endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h new file mode 100644 index 0000000000..0413033e9a --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h @@ -0,0 +1,59 @@ +#ifndef C_CALLBACKS_H_ +#define C_CALLBACKS_H_ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct ProgressData { + // TODO: zypp also reports min/max so it can be either percent, min/max or + // just alive progress. Should we expose all of them? progress value is either + // percent or -1 which means just keep alive progress + long long value; + // pointer to progress name. Owned by zypp, so lives only as long as callback + const char *name; +}; + +// Progress reporting callback passed to libzypp. +// zypp_data is ProgressData get from zypp +// user_data is never touched by method and is used only to pass local data for +// callback +/// @return true to continue, false to abort. Can be ignored +typedef bool (*ZyppProgressCallback)(struct ProgressData zypp_data, + void *user_data); +void set_zypp_progress_callback(ZyppProgressCallback progress, void *user_data); + +enum PROBLEM_RESPONSE { PROBLEM_RETRY, PROBLEM_ABORT, PROBLEM_IGNORE }; +typedef void (*ZyppDownloadStartCallback)(const char *url, + const char *localfile, + void *user_data); +typedef bool (*ZyppDownloadProgressCallback)(int value, const char *url, + double bps_avg, double bps_current, + void *user_data); +typedef enum PROBLEM_RESPONSE (*ZyppDownloadProblemCallback)( + const char *url, int error, const char *description, void *user_data); +typedef void (*ZyppDownloadFinishCallback)(const char *url, int error, + const char *reason, void *user_data); + +// progress for downloading files. There are 4 callbacks: +// 1. start for start of download +// 2. progress to see how it goes +// 3. problem to react when something wrong happen and how to behave +// 4. finish when download finishes +// NOTE: user_data is separated for each call. +struct DownloadProgressCallbacks { + ZyppDownloadStartCallback start; + void *start_data; + ZyppDownloadProgressCallback progress; + void *progress_data; + ZyppDownloadProblemCallback problem; + void *problem_data; + ZyppDownloadFinishCallback finish; + void *finish_data; +}; +#ifdef __cplusplus +} +#endif +#endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/headers.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/headers.h new file mode 100644 index 0000000000..61c2db62d5 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/headers.h @@ -0,0 +1,3 @@ +#include "callbacks.h" +#include "lib.h" +#include "repository.h" diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h new file mode 100644 index 0000000000..d36dc5c85e --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h @@ -0,0 +1,210 @@ +#ifndef C_LIB_H_ +#define C_LIB_H_ + +#include "callbacks.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif +#ifndef __cplusplus +#define noexcept ; +#endif + +/// status struct to pass and obtain from calls that can fail. +/// After usage free with \ref free_status function. +/// +/// Most functions act as *constructors* for this, taking a pointer +/// to it as an output parameter, disregarding the struct current contents +/// and filling it in. Thus, if you reuse a `Status` without \ref free_status +/// in between, `error` will leak. +struct Status { + // lets use enum for future better distinguish + enum STATE { + STATE_SUCCEED, + STATE_FAILED, + } state; + /// detailed user error what happens. Only defined when not succeed + char *error; ///< owned +}; +void free_status(struct Status *s) noexcept; + +/// Opaque Zypp context +struct Zypp; + +/// Progress reporting callback used by methods that takes longer. +/// @param text text for user describing what is happening now +/// @param stage current stage number starting with 0 +/// @param total count of stages. It should not change during single call of +/// method. +/// @param user_data is never touched by method and is used only to pass local +/// data for callback +/// @todo Do we want to support response for callback that allows early exit of +/// execution? +typedef void (*ProgressCallback)(const char *text, unsigned stage, + unsigned total, void *user_data); +/// Initialize Zypp target (where to store zypp data). +/// The returned zypp context is not thread safe and should be protected by a +/// mutex in the calling layer. +/// @param root +/// @param[out] status +/// @param progress +/// @param user_data +/// @return zypp context +struct Zypp *init_target(const char *root, struct Status *status, + ProgressCallback progress, void *user_data) noexcept; + +/// Switch Zypp target (where to install packages to). +/// @param root +/// @param[out] status +void switch_target(struct Zypp *zypp, const char *root, + struct Status *status) noexcept; + +/// Commit zypp settings and install +/// TODO: callbacks +/// @param zypp +/// @param status +/// @return true if there is no error +bool commit(struct Zypp *zypp, struct Status *status) noexcept; + +/// Represents a single mount point and its space usage. +/// The string pointers are not owned by this struct. +struct MountPoint { + const char *directory; ///< The path where the filesystem is mounted. + const char *filesystem; ///< The filesystem type (e.g., "btrfs", "xfs"). + bool grow_only; + long long + used_size; ///< The used space in kilobytes. This is an output field. +}; + +/// Calculates the space usage for a given list of mount points. +/// This function populates the `used_size` field for each element in the +/// provided `mount_points` array. +/// +/// @param zypp The Zypp context. +/// @param[out] status Output status object. +/// @param[in,out] mount_points An array of mount points to be evaluated. +/// @param mount_points_size The number of elements in the `mount_points` array. +void get_space_usage(struct Zypp *zypp, struct Status *status, + struct MountPoint *mount_points, + unsigned mount_points_size) noexcept; + +enum RESOLVABLE_KIND { + RESOLVABLE_PRODUCT, + RESOLVABLE_PATCH, + RESOLVABLE_PACKAGE, + RESOLVABLE_SRCPACKAGE, + RESOLVABLE_PATTERN, +}; + +enum RESOLVABLE_SELECTED { + /// resolvable won't be installed + NOT_SELECTED, + /// dependency solver select resolvable + /// match TransactByValue::SOLVER + SOLVER_SELECTED, + /// installation proposal selects resolvable + /// match TransactByValue::APPL_{LOW,HIGH} we do not need both, so we use just + /// one value + APPLICATION_SELECTED, + /// user select resolvable for installation + /// match TransactByValue::USER + USER_SELECTED, +}; + +/// Marks resolvable for installation +/// @param zypp see \ref init_target +/// @param name resolvable name +/// @param kind kind of resolvable +/// @param who who do selection. If NOT_SELECTED is used, it will be empty +/// operation. +/// @param[out] status (will overwrite existing contents) +void resolvable_select(struct Zypp *zypp, const char *name, + enum RESOLVABLE_KIND kind, enum RESOLVABLE_SELECTED who, + struct Status *status) noexcept; + +/// Unselect resolvable for installation. It can still be installed as +/// dependency. +/// @param zypp see \ref init_target +/// @param name resolvable name +/// @param kind kind of resolvable +/// @param who who do unselection. Only unselect if it is higher or equal level +/// then who do the selection. +/// @param[out] status (will overwrite existing contents) +void resolvable_unselect(struct Zypp *zypp, const char *name, + enum RESOLVABLE_KIND kind, + enum RESOLVABLE_SELECTED who, + struct Status *status) noexcept; + +struct PatternNames { + /// names of patterns + const char *const *const names; + /// size of names array + unsigned size; +}; + +/// Info from zypp::Pattern. +/// https://doc.opensuse.org/projects/libzypp/HEAD/classzypp_1_1Pattern.html +struct PatternInfo { + char *name; ///< owned + char *category; ///< owned + char *icon; ///< owned + char *description; ///< owned + char *summary; ///< owned + char *order; ///< owned + enum RESOLVABLE_SELECTED selected; +}; + +struct PatternInfos { + struct PatternInfo *infos; ///< owned, *size* items + unsigned size; +}; + +/// Get Pattern details. +/// Unknown patterns are simply omitted from the result. Match by +/// PatternInfo.name, not by index. +struct PatternInfos get_patterns_info(struct Zypp *_zypp, + struct PatternNames names, + struct Status *status) noexcept; +void free_pattern_infos(const struct PatternInfos *infos) noexcept; + +void import_gpg_key(struct Zypp *zypp, const char *const pathname, + struct Status *status) noexcept; + +/// check if url has local schema +/// @param url url to check +/// @param[out] status (will overwrite existing contents) +/// @return true if url is local, for invalid url status is set to error +bool is_local_url(const char *url, struct Status *status) noexcept; + +/// check if package is available +/// @param zypp see \ref init_target +/// @param tag package name, provides or file path +/// @param[out] status (will overwrite existing contents) +/// @return true if package is available. In case of error it fills status and +/// return value is undefined +bool is_package_available(struct Zypp *zypp, const char *tag, + struct Status *status) noexcept; + +/// check if package is selected for installation +/// @param zypp see \ref init_target +/// @param tag package name, provides or file path +/// @param[out] status (will overwrite existing contents) +/// @return true if package is selected. In case of error it fills status and +/// return value is undefined +bool is_package_selected(struct Zypp *zypp, const char *tag, + struct Status *status) noexcept; + +/// Runs solver +/// @param zypp see \ref init_target +/// @param[out] status (will overwrite existing contents) +/// @return true if solver pass and false if it found some dependency issues +bool run_solver(struct Zypp *zypp, struct Status *status) noexcept; + +/// the last call that will free all pointers to zypp holded by agama +void free_zypp(struct Zypp *zypp) noexcept; + +#ifdef __cplusplus +} +#endif +#endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h new file mode 100644 index 0000000000..1c8eb55689 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h @@ -0,0 +1,88 @@ +#ifndef C_REPOSITORY_H_ +#define C_REPOSITORY_H_ + +#include "callbacks.h" +#include "lib.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct Repository { + bool enabled; ///< + char *url; ///< owned + char *alias; ///< owned + char *userName; ///< owned +}; + +struct RepositoryList { + const unsigned size; + /// dynamic array with given size + struct Repository *repos; ///< owned, *size* items +}; + +/// repository array in list. +/// when no longer needed, use \ref free_repository_list to release memory +/// @param zypp see \ref init_target +/// @param[out] status (will overwrite existing contents) +struct RepositoryList list_repositories(struct Zypp *zypp, + struct Status *status) noexcept; + +void free_repository_list(struct RepositoryList *repo_list) noexcept; + +/// Adds repository to repo manager +/// @param zypp see \ref init_target +/// @param alias have to be unique +/// @param url can contain repo variables +/// @param[out] status (will overwrite existing contents) +/// @param callback pointer to function with callback or NULL +/// @param user_data +void add_repository(struct Zypp *zypp, const char *alias, const char *url, + struct Status *status, ZyppProgressCallback callback, + void *user_data) noexcept; + +/// Disable repository in repo manager +/// @param zypp see \ref init_target +/// @param alias identifier of repository +void disable_repository(struct Zypp *zypp, const char *alias, + struct Status *status) noexcept; + +/// Changes url of given repository +/// @param zypp see \ref init_target +/// @param alias identifier of repository +/// @param alias have to be unique +void set_repository_url(struct Zypp *zypp, const char *alias, const char *url, + struct Status *status) noexcept; + +/// Removes repository from repo manager +/// @param zypp see \ref init_target +/// @param alias have to be unique +/// @param[out] status (will overwrite existing contents) +/// @param callback pointer to function with callback or NULL +/// @param user_data +void remove_repository(struct Zypp *zypp, const char *alias, + struct Status *status, ZyppProgressCallback callback, + void *user_data) noexcept; + +/// +/// @param zypp see \ref init_target +/// @param alias alias of repository to refresh +/// @param[out] status (will overwrite existing contents) +/// @param callbacks pointer to struct with callbacks or NULL if no progress is +/// needed +void refresh_repository(struct Zypp *zypp, const char *alias, + struct Status *status, + struct DownloadProgressCallbacks *callbacks) noexcept; + +void build_repository_cache(struct Zypp *zypp, const char *alias, + struct Status *status, + ZyppProgressCallback callback, + void *user_data) noexcept; +void load_repository_cache(struct Zypp *zypp, const char *alias, + struct Status *status) noexcept; + +#ifdef __cplusplus +} +#endif +#endif \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx new file mode 100644 index 0000000000..890dda262b --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx @@ -0,0 +1,16 @@ +#ifndef C_CALLBACKS_HXX_ +#define C_CALLBACKS_HXX_ + +#include "callbacks.h" +// C++ specific code call that cannot be used from C. Used to pass progress +// class between o files. +#include +zypp::ProgressData::ReceiverFnc +create_progress_callback(ZyppProgressCallback progress, void *user_data); + +// pair of set and unset calls. Struct for callbacks has to live as least as +// long as unset is call. idea is to wrap it around call that do some download +void set_zypp_download_callbacks(struct DownloadProgressCallbacks *callbacks); +void unset_zypp_download_callbacks(); + +#endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx new file mode 100644 index 0000000000..84be67d97d --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx @@ -0,0 +1,58 @@ +#ifndef C_HELPERS_HXX_ +#define C_HELPERS_HXX_ + +#include +#include + +// helper to get allocated formated string. Sadly C does not provide any +// portable way to do it. if we are ok with GNU or glib then it provides it +static char *format_alloc(const char *const format...) { + // `vsnprintf()` changes `va_list`'s state, so using it after that is UB. + // We need the args twice, so it is safer to just get two copies. + va_list args1; + va_list args2; + va_start(args1, format); + va_start(args2, format); + + // vsnprintf with len 0 just return needed size and add trailing zero. + size_t needed = 1 + vsnprintf(NULL, 0, format, args1); + + char *buffer = (char *)malloc(needed * sizeof(char)); + + vsnprintf(buffer, needed, format, args2); + + va_end(args1); + va_end(args2); + + return buffer; +} + +/// Macro in case of programmer error. We do not use exceptions do to usage of +/// noexpect in all places to avoid flowing exceptions to our pure C API. It +/// basically print message to stderr and abort +#define PANIC(...) \ + fprintf(stderr, __VA_ARGS__); \ + abort() + +/// Macro to define that status if OK +#define STATUS_OK(status) \ + ({ \ + status->state = status->STATE_SUCCEED; \ + status->error = NULL; \ + }) + +/// Macro to help report failure with zypp exception +#define STATUS_EXCEPT(status, excpt) \ + ({ \ + status->state = status->STATE_FAILED; \ + status->error = strdup(excpt.asUserString().c_str()); \ + }) + +/// Macro to help report failure with error string which is passed to format +#define STATUS_ERROR(status, ...) \ + ({ \ + status->state = status->STATE_FAILED; \ + status->error = format_alloc(__VA_ARGS__); \ + }) + +#endif \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx new file mode 100644 index 0000000000..4a36578c97 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -0,0 +1,670 @@ +#include "lib.h" +#include "callbacks.h" +#include "callbacks.hxx" +#include "helpers.hxx" +#include "repository.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +extern "C" { + +#include + +struct Zypp { + zypp::ZYpp::Ptr zypp_pointer; + zypp::RepoManager *repo_manager; +}; + +static struct Zypp the_zypp { + .zypp_pointer = NULL, .repo_manager = NULL, +}; + +// formatter which actually logs the messages to the systemd journal, +// that is a bit hacky but in the logger we receive an already formatted +// message as a single string and it would not be easy to get back the original +// components of the message +struct AgamaFormatter : public zypp::base::LogControl::LineFormater { + virtual std::string format(const std::string &zypp_group, + zypp::base::logger::LogLevel zypp_level, + const char *zypp_file, const char *zypp_func, + int zypp_line, const std::string &zypp_message) { + // the systemd/syslog compatible log level + int level; + + // convert the zypp log level to the systemd/syslog log level + switch (zypp_level) { + // for details about the systemd levels see + // https://www.freedesktop.org/software/systemd/man/latest/sd-daemon.html + case zypp::base::logger::E_DBG: + level = LOG_DEBUG; + break; + case zypp::base::logger::E_MIL: + level = LOG_INFO; + break; + case zypp::base::logger::E_WAR: + level = LOG_WARNING; + break; + case zypp::base::logger::E_ERR: + level = LOG_ERR; + break; + case zypp::base::logger::E_SEC: + // security error => critical + level = LOG_CRIT; + break; + case zypp::base::logger::E_INT: + // internal error => critical + level = LOG_CRIT; + break; + // libzypp specific level + case zypp::base::logger::E_USR: + level = LOG_INFO; + break; + // libzypp specific level + case zypp::base::logger::E_XXX: + level = LOG_CRIT; + break; + } + + // unlike the other values, the location needs to be sent in an already + // formatted strings + std::string file("CODE_FILE="); + file.append(zypp_file); + std::string line("CODE_LINE="); + line.append(std::to_string(zypp_line)); + + // this will log the message with libzypp location, not from *this* file, + // see "man sd_journal_send_with_location" + sd_journal_send_with_location( + file.c_str(), line.c_str(), zypp_func, "PRIORITY=%i", level, + "MESSAGE=[%s] %s", zypp_group.c_str(), zypp_message.c_str(), + // some custom data to allow easy filtering of the libzypp messages + "COMPONENT=libzypp", "ZYPP_GROUP=%s", zypp_group.c_str(), + "ZYPP_LEVEL=%i", zypp_level, NULL); + + // libzypp aborts when the returned message is empty, + // return some static fake data to make it happy + return "msg"; + } +}; + +// a dummy logger +struct AgamaLogger : public zypp::base::LogControl::LineWriter { + virtual void writeOut(const std::string &formatted) { + // do nothing, the message has been already logged by the formatter + } +}; + +void free_zypp(struct Zypp *zypp) noexcept { + // ensure that target is unloaded otherwise nasty things can happen if new + // zypp is created in different thread + zypp->zypp_pointer->getTarget()->unload(); + zypp->zypp_pointer = + NULL; // shared ptr assignment operator will free original pointer + delete (zypp->repo_manager); + zypp->repo_manager = NULL; +} + +static zypp::ZYpp::Ptr zypp_ptr() { + sd_journal_print(LOG_NOTICE, "Redirecting libzypp logs to systemd journal"); + + // log to systemd journal using our specific formatter + boost::shared_ptr formatter(new AgamaFormatter); + zypp::base::LogControl::instance().setLineFormater(formatter); + // use a dummy logger, using a NULL logger would skip the formatter completely + // so the messages would not be logged in the end + boost::shared_ptr logger(new AgamaLogger); + zypp::base::LogControl::instance().setLineWriter(logger); + + // do not do any magic waiting for lock as in agama context we work + // on our own root, so there should be no need to wait + return zypp::getZYpp(); +} + +void switch_target(struct Zypp *zypp, const char *root, + struct Status *status) noexcept { + const std::string root_str(root); + try { + zypp->zypp_pointer->initializeTarget(root_str, + false /* rebuild rpmdb: no */); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + return; + } + + STATUS_OK(status); +} + +bool commit(struct Zypp *zypp, struct Status *status) noexcept { + try { + zypp::ZYppCommitPolicy policy; + zypp::ZYppCommitResult result = zypp->zypp_pointer->commit(policy); + STATUS_OK(status); + return result.noError(); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + return false; + } +} + +// TODO: split init target into set of repo manager, initialize target and load +// target and merge it in rust +struct Zypp *init_target(const char *root, struct Status *status, + ProgressCallback progress, void *user_data) noexcept { + if (the_zypp.zypp_pointer != NULL) { + STATUS_ERROR(status, "Cannot have two init_target concurrently, " + "libzypp not ready for this. Call free_zypp first."); + return NULL; + } + + const std::string root_str(root); + + // create the libzypp lock also in the target directory + setenv("ZYPP_LOCKFILE_ROOT", root, 1 /* allow overwrite */); + + struct Zypp *zypp = NULL; + try { + zypp::RepoManagerOptions repo_manager_options(root); + // repository manager options cannot be replaced, a new repository manager + // is needed + zypp::RepoManager *new_repo_manager = + new zypp::RepoManager(repo_manager_options); + + // replace the old repository manager + if (the_zypp.repo_manager) + delete the_zypp.repo_manager; + the_zypp.repo_manager = new_repo_manager; + + // TODO: localization + if (progress != NULL) + progress("Initializing the Target System", 0, 2, user_data); + the_zypp.zypp_pointer = zypp_ptr(); + if (the_zypp.zypp_pointer == NULL) { + STATUS_ERROR(status, "Failed to obtain zypp pointer. " + "See journalctl for details."); + return NULL; + } + zypp = &the_zypp; + zypp->zypp_pointer->initializeTarget(root_str, false); + if (progress != NULL) + progress("Reading Installed Packages", 1, 2, user_data); + zypp->zypp_pointer->target()->load(); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + the_zypp.zypp_pointer = NULL; + return NULL; + } + + STATUS_OK(status); + return zypp; +} + +void free_repository(struct Repository *repo) { + free(repo->url); + free(repo->alias); + free(repo->userName); +} + +void free_repository_list(struct RepositoryList *list) noexcept { + for (unsigned i = 0; i < list->size; ++i) { + free_repository(list->repos + i); + } + free(list->repos); +} + +void free_status(struct Status *status) noexcept { + if (status->error != NULL) { + free(status->error); + status->error = NULL; + } +} + +static zypp::Resolvable::Kind kind_to_zypp_kind(RESOLVABLE_KIND kind) { + switch (kind) { + case RESOLVABLE_PACKAGE: + return zypp::Resolvable::Kind::package; + case RESOLVABLE_SRCPACKAGE: + return zypp::Resolvable::Kind::srcpackage; + case RESOLVABLE_PATTERN: + return zypp::Resolvable::Kind::pattern; + case RESOLVABLE_PRODUCT: + return zypp::Resolvable::Kind::product; + case RESOLVABLE_PATCH: + return zypp::Resolvable::Kind::patch; + } + PANIC("Unhandled case in resolvable kind switch %i", kind); +} + +static zypp::ResStatus::TransactByValue +transactby_from(enum RESOLVABLE_SELECTED who) { + switch (who) { + case RESOLVABLE_SELECTED::SOLVER_SELECTED: + return zypp::ResStatus::SOLVER; + case RESOLVABLE_SELECTED::APPLICATION_SELECTED: + return zypp::ResStatus::APPL_HIGH; + case RESOLVABLE_SELECTED::USER_SELECTED: + return zypp::ResStatus::USER; + case RESOLVABLE_SELECTED::NOT_SELECTED: { + PANIC("Unexpected value RESOLVABLE_SELECTED::NOT_SELECTED."); + } + } + + // should not happen + PANIC("Unexpected RESOLVABLE_SELECT value %i", who); +} + +void resolvable_select(struct Zypp *_zypp, const char *name, + enum RESOLVABLE_KIND kind, enum RESOLVABLE_SELECTED who, + struct Status *status) noexcept { + if (who == RESOLVABLE_SELECTED::NOT_SELECTED) { + STATUS_OK(status); + return; + } + + zypp::Resolvable::Kind z_kind = kind_to_zypp_kind(kind); + auto selectable = zypp::ui::Selectable::get(z_kind, name); + if (!selectable) { + STATUS_ERROR(status, "Failed to find %s with name '%s'", z_kind.c_str(), + name); + return; + } + + STATUS_OK(status); + auto value = transactby_from(who); + selectable->setToInstall(value); +} + +void resolvable_unselect(struct Zypp *_zypp, const char *name, + enum RESOLVABLE_KIND kind, + enum RESOLVABLE_SELECTED who, + struct Status *status) noexcept { + STATUS_OK(status); + if (who == RESOLVABLE_SELECTED::NOT_SELECTED) { + return; + } + + zypp::Resolvable::Kind z_kind = kind_to_zypp_kind(kind); + auto selectable = zypp::ui::Selectable::get(z_kind, name); + if (!selectable) { + STATUS_ERROR(status, "Failed to find %s with name '%s'", z_kind.c_str(), + name); + return; + } + + auto value = transactby_from(who); + selectable->unset(value); +} + +struct PatternInfos get_patterns_info(struct Zypp *_zypp, + struct PatternNames names, + struct Status *status) noexcept { + PatternInfos result = { + (struct PatternInfo *)malloc(names.size * sizeof(PatternInfo)), + 0 // initialize with zero and increase after each successfull add of + // pattern info + }; + + for (unsigned j = 0; j < names.size; ++j) { + zypp::ui::Selectable::constPtr selectable = + zypp::ui::Selectable::get(zypp::ResKind::pattern, names.names[j]); + // we do not find any pattern + if (!selectable.get()) + continue; + + // we know here that we get only patterns + zypp::Pattern::constPtr pattern = + zypp::asKind(selectable->theObj().resolvable()); + unsigned i = result.size; + result.infos[i].name = strdup(pattern->name().c_str()); + result.infos[i].category = strdup(pattern->category().c_str()); + result.infos[i].description = strdup(pattern->description().c_str()); + result.infos[i].icon = strdup(pattern->icon().c_str()); + result.infos[i].summary = strdup(pattern->summary().c_str()); + result.infos[i].order = strdup(pattern->order().c_str()); + auto &status = selectable->theObj().status(); + if (status.isToBeInstalled()) { + switch (status.getTransactByValue()) { + case zypp::ResStatus::TransactByValue::USER: + result.infos[i].selected = RESOLVABLE_SELECTED::USER_SELECTED; + break; + case zypp::ResStatus::TransactByValue::APPL_HIGH: + case zypp::ResStatus::TransactByValue::APPL_LOW: + result.infos[i].selected = RESOLVABLE_SELECTED::APPLICATION_SELECTED; + break; + case zypp::ResStatus::TransactByValue::SOLVER: + result.infos[i].selected = RESOLVABLE_SELECTED::SOLVER_SELECTED; + break; + } + } else { + result.infos[i].selected = RESOLVABLE_SELECTED::NOT_SELECTED; + } + result.size++; + }; + + STATUS_OK(status); + return result; +} + +void free_pattern_infos(const struct PatternInfos *infos) noexcept { + for (unsigned i = 0; i < infos->size; ++i) { + free(infos->infos[i].name); + free(infos->infos[i].category); + free(infos->infos[i].icon); + free(infos->infos[i].description); + free(infos->infos[i].summary); + free(infos->infos[i].order); + } + free(infos->infos); +} + +bool run_solver(struct Zypp *zypp, struct Status *status) noexcept { + try { + STATUS_OK(status); + return zypp->zypp_pointer->resolver()->resolvePool(); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + return false; // do not matter much as status indicate failure + } +} + +void refresh_repository(struct Zypp *zypp, const char *alias, + struct Status *status, + struct DownloadProgressCallbacks *callbacks) noexcept { + if (zypp->repo_manager == NULL) { + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); + return; + } + try { + zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); + if (zypp_repo == zypp::RepoInfo::noRepo) { + STATUS_ERROR(status, "Cannot refresh repo with alias %s. Repo not found.", + alias); + return; + } + + set_zypp_download_callbacks(callbacks); + zypp->repo_manager->refreshMetadata( + zypp_repo, + zypp::RepoManager::RawMetadataRefreshPolicy::RefreshIfNeeded); + STATUS_OK(status); + unset_zypp_download_callbacks(); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + unset_zypp_download_callbacks(); // TODO: we can add C++ final action helper + // if it is more common + } +} + +bool is_local_url(const char *url, struct Status *status) noexcept { + try { + zypp::Url z_url(url); + STATUS_OK(status); + return z_url.schemeIsLocal(); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + return false; + } +} + +static bool package_check(Zypp *zypp, const char *tag, bool selected, + Status *status) noexcept { + try { + std::string s_tag(tag); + if (s_tag.empty()) { + STATUS_ERROR(status, "Internal Error: Package tag is empty."); + return false; + } + + // look for packages + zypp::Capability cap(s_tag, zypp::ResKind::package); + zypp::sat::WhatProvides possibleProviders(cap); + + // if we check only for availability, then just check that quickly + if (!selected) + return !possibleProviders.empty(); + + for (auto iter = possibleProviders.begin(); iter != possibleProviders.end(); + ++iter) { + zypp::PoolItem provider = zypp::ResPool::instance().find(*iter); + // is it installed? if so return true, otherwise check next candidate + if (provider.status().isToBeInstalled()) + return true; + } + + return false; + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + return false; + } +} + +bool is_package_available(Zypp *zypp, const char *tag, + Status *status) noexcept { + return package_check(zypp, tag, false, status); +} + +bool is_package_selected(Zypp *zypp, const char *tag, Status *status) noexcept { + return package_check(zypp, tag, true, status); +} + +void add_repository(struct Zypp *zypp, const char *alias, const char *url, + struct Status *status, ZyppProgressCallback callback, + void *user_data) noexcept { + if (zypp->repo_manager == NULL) { + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); + return; + } + try { + auto zypp_callback = create_progress_callback(callback, user_data); + zypp::RepoInfo zypp_repo = zypp::RepoInfo(); + zypp_repo.setBaseUrl(zypp::Url(url)); + zypp_repo.setAlias(alias); + + zypp->repo_manager->addRepository(zypp_repo, zypp_callback); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + +void disable_repository(struct Zypp *zypp, const char *alias, + struct Status *status) noexcept { + if (zypp->repo_manager == NULL) { + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); + return; + } + try { + zypp::RepoInfo r_info = zypp->repo_manager->getRepo(alias); + r_info.setEnabled(false); + zypp->repo_manager->modifyRepository(r_info); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + +void set_repository_url(struct Zypp *zypp, const char *alias, const char *url, + struct Status *status) noexcept { + if (zypp->repo_manager == NULL) { + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); + return; + } + try { + zypp::RepoInfo r_info = zypp->repo_manager->getRepo(alias); + zypp::Url z_url(url); + r_info.setBaseUrl(z_url); + zypp->repo_manager->modifyRepository(r_info); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + +void remove_repository(struct Zypp *zypp, const char *alias, + struct Status *status, ZyppProgressCallback callback, + void *user_data) noexcept { + if (zypp->repo_manager == NULL) { + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); + return; + } + try { + auto zypp_callback = create_progress_callback(callback, user_data); + zypp::RepoInfo zypp_repo = zypp::RepoInfo(); + zypp_repo.setAlias(alias); // alias should be unique, so it should always + // match correct repo + + zypp->repo_manager->removeRepository(zypp_repo, zypp_callback); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + +struct RepositoryList list_repositories(struct Zypp *zypp, + struct Status *status) noexcept { + if (zypp->repo_manager == NULL) { + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); + return {0, NULL}; + } + + std::list zypp_repos = + zypp->repo_manager->knownRepositories(); + const std::list::size_type size = zypp_repos.size(); + struct Repository *repos = + (struct Repository *)malloc(size * sizeof(struct Repository)); + // TODO: error handling + unsigned res_i = 0; + for (auto iter = zypp_repos.begin(); iter != zypp_repos.end(); ++iter) { + struct Repository *new_repo = repos + res_i++; + new_repo->enabled = iter->enabled(); + new_repo->url = strdup(iter->url().asString().c_str()); + new_repo->alias = strdup(iter->alias().c_str()); + new_repo->userName = strdup(iter->asUserString().c_str()); + } + + struct RepositoryList result = {static_cast(size), repos}; + STATUS_OK(status); + return result; +} + +void load_repository_cache(struct Zypp *zypp, const char *alias, + struct Status *status) noexcept { + if (zypp->repo_manager == NULL) { + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); + } + try { + zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); + if (zypp_repo == zypp::RepoInfo::noRepo) { + STATUS_ERROR(status, "Cannot load repo with alias %s. Repo not found.", + alias); + return; + } + + // NOTE: loadFromCache has an optional `progress` parameter but it ignores + // it anyway + zypp->repo_manager->loadFromCache(zypp_repo); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + +void build_repository_cache(struct Zypp *zypp, const char *alias, + struct Status *status, + ZyppProgressCallback callback, + void *user_data) noexcept { + if (zypp->repo_manager == NULL) { + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); + return; + } + try { + zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); + if (zypp_repo == zypp::RepoInfo::noRepo) { + STATUS_ERROR(status, "Cannot load repo with alias %s. Repo not found.", + alias); + return; + } + + auto progress = create_progress_callback(callback, user_data); + zypp->repo_manager->buildCache( + zypp_repo, zypp::RepoManagerFlags::BuildIfNeeded, progress); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + +void import_gpg_key(struct Zypp *zypp, const char *const pathname, + struct Status *status) noexcept { + try { + zypp::filesystem::Pathname path(pathname); + zypp::PublicKey key(path); + // Keys that are unknown (not imported). + // or known-but-untrusted (weird in-between state, see KeyRing_test.cc) + // will trigger "Trust this?" callbacks. + bool trusted = true; + zypp->zypp_pointer->keyRing()->importKey(key, trusted); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + +void get_space_usage(struct Zypp *zypp, struct Status *status, + struct MountPoint *mount_points, + unsigned mount_points_size) noexcept { + try { + zypp::DiskUsageCounter::MountPointSet mount_points_set; + for (unsigned i = 0; i < mount_points_size; ++i) { + enum zypp::DiskUsageCounter::MountPoint::Hint hint = + mount_points[i].grow_only + ? zypp::DiskUsageCounter::MountPoint::Hint::Hint_growonly + : zypp::DiskUsageCounter::MountPoint::Hint::NoHint; + zypp::DiskUsageCounter::MountPoint mp(mount_points[i].directory, + mount_points[i].filesystem, 0, 0, 0, + 0, hint); + mount_points_set.insert(mp); + } + zypp->zypp_pointer->setPartitions(mount_points_set); + zypp::DiskUsageCounter::MountPointSet computed_set = + zypp->zypp_pointer->diskUsage(); + for (unsigned i = 0; i < mount_points_size; ++i) { + auto mp = + std::find_if(mount_points_set.begin(), mount_points_set.end(), + [mount_points, i](zypp::DiskUsageCounter::MountPoint m) { + return m.dir == mount_points[i].directory; + }); + if (mp == mount_points_set.end()) { + // mount point not found. Should not happen. + STATUS_ERROR(status, "Internal Error:Mount point not found."); + return; + } + mount_points[i].used_size = mp->pkg_size; + } + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + +} \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs new file mode 100644 index 0000000000..b80b0faa5d --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -0,0 +1,390 @@ +/* automatically generated by rust-bindgen 0.72.1 */ + +pub const __bool_true_false_are_defined: u32 = 1; +pub const true_: u32 = 1; +pub const false_: u32 = 0; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct ProgressData { + pub value: ::std::os::raw::c_longlong, + pub name: *const ::std::os::raw::c_char, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of ProgressData"][::std::mem::size_of::() - 16usize]; + ["Alignment of ProgressData"][::std::mem::align_of::() - 8usize]; + ["Offset of field: ProgressData::value"][::std::mem::offset_of!(ProgressData, value) - 0usize]; + ["Offset of field: ProgressData::name"][::std::mem::offset_of!(ProgressData, name) - 8usize]; +}; +#[doc = " @return true to continue, false to abort. Can be ignored"] +pub type ZyppProgressCallback = ::std::option::Option< + unsafe extern "C" fn(zypp_data: ProgressData, user_data: *mut ::std::os::raw::c_void) -> bool, +>; +pub const PROBLEM_RESPONSE_PROBLEM_RETRY: PROBLEM_RESPONSE = 0; +pub const PROBLEM_RESPONSE_PROBLEM_ABORT: PROBLEM_RESPONSE = 1; +pub const PROBLEM_RESPONSE_PROBLEM_IGNORE: PROBLEM_RESPONSE = 2; +pub type PROBLEM_RESPONSE = ::std::os::raw::c_uint; +pub type ZyppDownloadStartCallback = ::std::option::Option< + unsafe extern "C" fn( + url: *const ::std::os::raw::c_char, + localfile: *const ::std::os::raw::c_char, + user_data: *mut ::std::os::raw::c_void, + ), +>; +pub type ZyppDownloadProgressCallback = ::std::option::Option< + unsafe extern "C" fn( + value: ::std::os::raw::c_int, + url: *const ::std::os::raw::c_char, + bps_avg: f64, + bps_current: f64, + user_data: *mut ::std::os::raw::c_void, + ) -> bool, +>; +pub type ZyppDownloadProblemCallback = ::std::option::Option< + unsafe extern "C" fn( + url: *const ::std::os::raw::c_char, + error: ::std::os::raw::c_int, + description: *const ::std::os::raw::c_char, + user_data: *mut ::std::os::raw::c_void, + ) -> PROBLEM_RESPONSE, +>; +pub type ZyppDownloadFinishCallback = ::std::option::Option< + unsafe extern "C" fn( + url: *const ::std::os::raw::c_char, + error: ::std::os::raw::c_int, + reason: *const ::std::os::raw::c_char, + user_data: *mut ::std::os::raw::c_void, + ), +>; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct DownloadProgressCallbacks { + pub start: ZyppDownloadStartCallback, + pub start_data: *mut ::std::os::raw::c_void, + pub progress: ZyppDownloadProgressCallback, + pub progress_data: *mut ::std::os::raw::c_void, + pub problem: ZyppDownloadProblemCallback, + pub problem_data: *mut ::std::os::raw::c_void, + pub finish: ZyppDownloadFinishCallback, + pub finish_data: *mut ::std::os::raw::c_void, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of DownloadProgressCallbacks"] + [::std::mem::size_of::() - 64usize]; + ["Alignment of DownloadProgressCallbacks"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: DownloadProgressCallbacks::start"] + [::std::mem::offset_of!(DownloadProgressCallbacks, start) - 0usize]; + ["Offset of field: DownloadProgressCallbacks::start_data"] + [::std::mem::offset_of!(DownloadProgressCallbacks, start_data) - 8usize]; + ["Offset of field: DownloadProgressCallbacks::progress"] + [::std::mem::offset_of!(DownloadProgressCallbacks, progress) - 16usize]; + ["Offset of field: DownloadProgressCallbacks::progress_data"] + [::std::mem::offset_of!(DownloadProgressCallbacks, progress_data) - 24usize]; + ["Offset of field: DownloadProgressCallbacks::problem"] + [::std::mem::offset_of!(DownloadProgressCallbacks, problem) - 32usize]; + ["Offset of field: DownloadProgressCallbacks::problem_data"] + [::std::mem::offset_of!(DownloadProgressCallbacks, problem_data) - 40usize]; + ["Offset of field: DownloadProgressCallbacks::finish"] + [::std::mem::offset_of!(DownloadProgressCallbacks, finish) - 48usize]; + ["Offset of field: DownloadProgressCallbacks::finish_data"] + [::std::mem::offset_of!(DownloadProgressCallbacks, finish_data) - 56usize]; +}; +#[doc = " status struct to pass and obtain from calls that can fail.\n After usage free with \\ref free_status function.\n\n Most functions act as *constructors* for this, taking a pointer\n to it as an output parameter, disregarding the struct current contents\n and filling it in. Thus, if you reuse a `Status` without \\ref free_status\n in between, `error` will leak."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct Status { + pub state: Status_STATE, + #[doc = "< owned"] + pub error: *mut ::std::os::raw::c_char, +} +pub const Status_STATE_STATE_SUCCEED: Status_STATE = 0; +pub const Status_STATE_STATE_FAILED: Status_STATE = 1; +pub type Status_STATE = ::std::os::raw::c_uint; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of Status"][::std::mem::size_of::() - 16usize]; + ["Alignment of Status"][::std::mem::align_of::() - 8usize]; + ["Offset of field: Status::state"][::std::mem::offset_of!(Status, state) - 0usize]; + ["Offset of field: Status::error"][::std::mem::offset_of!(Status, error) - 8usize]; +}; +#[doc = " Opaque Zypp context"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct Zypp { + _unused: [u8; 0], +} +#[doc = " Progress reporting callback used by methods that takes longer.\n @param text text for user describing what is happening now\n @param stage current stage number starting with 0\n @param total count of stages. It should not change during single call of\n method.\n @param user_data is never touched by method and is used only to pass local\n data for callback\n @todo Do we want to support response for callback that allows early exit of\n execution?"] +pub type ProgressCallback = ::std::option::Option< + unsafe extern "C" fn( + text: *const ::std::os::raw::c_char, + stage: ::std::os::raw::c_uint, + total: ::std::os::raw::c_uint, + user_data: *mut ::std::os::raw::c_void, + ), +>; +#[doc = " Represents a single mount point and its space usage.\n The string pointers are not owned by this struct."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct MountPoint { + #[doc = "< The path where the filesystem is mounted."] + pub directory: *const ::std::os::raw::c_char, + #[doc = "< The filesystem type (e.g., \"btrfs\", \"xfs\")."] + pub filesystem: *const ::std::os::raw::c_char, + pub grow_only: bool, + #[doc = "< The used space in kilobytes. This is an output field."] + pub used_size: ::std::os::raw::c_longlong, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of MountPoint"][::std::mem::size_of::() - 32usize]; + ["Alignment of MountPoint"][::std::mem::align_of::() - 8usize]; + ["Offset of field: MountPoint::directory"] + [::std::mem::offset_of!(MountPoint, directory) - 0usize]; + ["Offset of field: MountPoint::filesystem"] + [::std::mem::offset_of!(MountPoint, filesystem) - 8usize]; + ["Offset of field: MountPoint::grow_only"] + [::std::mem::offset_of!(MountPoint, grow_only) - 16usize]; + ["Offset of field: MountPoint::used_size"] + [::std::mem::offset_of!(MountPoint, used_size) - 24usize]; +}; +pub const RESOLVABLE_KIND_RESOLVABLE_PRODUCT: RESOLVABLE_KIND = 0; +pub const RESOLVABLE_KIND_RESOLVABLE_PATCH: RESOLVABLE_KIND = 1; +pub const RESOLVABLE_KIND_RESOLVABLE_PACKAGE: RESOLVABLE_KIND = 2; +pub const RESOLVABLE_KIND_RESOLVABLE_SRCPACKAGE: RESOLVABLE_KIND = 3; +pub const RESOLVABLE_KIND_RESOLVABLE_PATTERN: RESOLVABLE_KIND = 4; +pub type RESOLVABLE_KIND = ::std::os::raw::c_uint; +#[doc = " resolvable won't be installed"] +pub const RESOLVABLE_SELECTED_NOT_SELECTED: RESOLVABLE_SELECTED = 0; +#[doc = " dependency solver select resolvable\n match TransactByValue::SOLVER"] +pub const RESOLVABLE_SELECTED_SOLVER_SELECTED: RESOLVABLE_SELECTED = 1; +#[doc = " installation proposal selects resolvable\n match TransactByValue::APPL_{LOW,HIGH} we do not need both, so we use just\n one value"] +pub const RESOLVABLE_SELECTED_APPLICATION_SELECTED: RESOLVABLE_SELECTED = 2; +#[doc = " user select resolvable for installation\n match TransactByValue::USER"] +pub const RESOLVABLE_SELECTED_USER_SELECTED: RESOLVABLE_SELECTED = 3; +pub type RESOLVABLE_SELECTED = ::std::os::raw::c_uint; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct PatternNames { + #[doc = " names of patterns"] + pub names: *const *const ::std::os::raw::c_char, + #[doc = " size of names array"] + pub size: ::std::os::raw::c_uint, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of PatternNames"][::std::mem::size_of::() - 16usize]; + ["Alignment of PatternNames"][::std::mem::align_of::() - 8usize]; + ["Offset of field: PatternNames::names"][::std::mem::offset_of!(PatternNames, names) - 0usize]; + ["Offset of field: PatternNames::size"][::std::mem::offset_of!(PatternNames, size) - 8usize]; +}; +#[doc = " Info from zypp::Pattern.\n https://doc.opensuse.org/projects/libzypp/HEAD/classzypp_1_1Pattern.html"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct PatternInfo { + #[doc = "< owned"] + pub name: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub category: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub icon: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub description: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub summary: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub order: *mut ::std::os::raw::c_char, + pub selected: RESOLVABLE_SELECTED, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of PatternInfo"][::std::mem::size_of::() - 56usize]; + ["Alignment of PatternInfo"][::std::mem::align_of::() - 8usize]; + ["Offset of field: PatternInfo::name"][::std::mem::offset_of!(PatternInfo, name) - 0usize]; + ["Offset of field: PatternInfo::category"] + [::std::mem::offset_of!(PatternInfo, category) - 8usize]; + ["Offset of field: PatternInfo::icon"][::std::mem::offset_of!(PatternInfo, icon) - 16usize]; + ["Offset of field: PatternInfo::description"] + [::std::mem::offset_of!(PatternInfo, description) - 24usize]; + ["Offset of field: PatternInfo::summary"] + [::std::mem::offset_of!(PatternInfo, summary) - 32usize]; + ["Offset of field: PatternInfo::order"][::std::mem::offset_of!(PatternInfo, order) - 40usize]; + ["Offset of field: PatternInfo::selected"] + [::std::mem::offset_of!(PatternInfo, selected) - 48usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct PatternInfos { + #[doc = "< owned, *size* items"] + pub infos: *mut PatternInfo, + pub size: ::std::os::raw::c_uint, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of PatternInfos"][::std::mem::size_of::() - 16usize]; + ["Alignment of PatternInfos"][::std::mem::align_of::() - 8usize]; + ["Offset of field: PatternInfos::infos"][::std::mem::offset_of!(PatternInfos, infos) - 0usize]; + ["Offset of field: PatternInfos::size"][::std::mem::offset_of!(PatternInfos, size) - 8usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct Repository { + #[doc = "<"] + pub enabled: bool, + #[doc = "< owned"] + pub url: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub alias: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub userName: *mut ::std::os::raw::c_char, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of Repository"][::std::mem::size_of::() - 32usize]; + ["Alignment of Repository"][::std::mem::align_of::() - 8usize]; + ["Offset of field: Repository::enabled"][::std::mem::offset_of!(Repository, enabled) - 0usize]; + ["Offset of field: Repository::url"][::std::mem::offset_of!(Repository, url) - 8usize]; + ["Offset of field: Repository::alias"][::std::mem::offset_of!(Repository, alias) - 16usize]; + ["Offset of field: Repository::userName"] + [::std::mem::offset_of!(Repository, userName) - 24usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct RepositoryList { + pub size: ::std::os::raw::c_uint, + #[doc = "< owned, *size* items"] + pub repos: *mut Repository, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of RepositoryList"][::std::mem::size_of::() - 16usize]; + ["Alignment of RepositoryList"][::std::mem::align_of::() - 8usize]; + ["Offset of field: RepositoryList::size"] + [::std::mem::offset_of!(RepositoryList, size) - 0usize]; + ["Offset of field: RepositoryList::repos"] + [::std::mem::offset_of!(RepositoryList, repos) - 8usize]; +}; +unsafe extern "C" { + pub fn set_zypp_progress_callback( + progress: ZyppProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ); + pub fn free_status(s: *mut Status); + #[doc = " Initialize Zypp target (where to store zypp data).\n The returned zypp context is not thread safe and should be protected by a\n mutex in the calling layer.\n @param root\n @param[out] status\n @param progress\n @param user_data\n @return zypp context"] + pub fn init_target( + root: *const ::std::os::raw::c_char, + status: *mut Status, + progress: ProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ) -> *mut Zypp; + #[doc = " Switch Zypp target (where to install packages to).\n @param root\n @param[out] status"] + pub fn switch_target(zypp: *mut Zypp, root: *const ::std::os::raw::c_char, status: *mut Status); + #[doc = " Commit zypp settings and install\n TODO: callbacks\n @param zypp\n @param status\n @return true if there is no error"] + pub fn commit(zypp: *mut Zypp, status: *mut Status) -> bool; + #[doc = " Calculates the space usage for a given list of mount points.\n This function populates the `used_size` field for each element in the\n provided `mount_points` array.\n\n @param zypp The Zypp context.\n @param[out] status Output status object.\n @param[in,out] mount_points An array of mount points to be evaluated.\n @param mount_points_size The number of elements in the `mount_points` array."] + pub fn get_space_usage( + zypp: *mut Zypp, + status: *mut Status, + mount_points: *mut MountPoint, + mount_points_size: ::std::os::raw::c_uint, + ); + #[doc = " Marks resolvable for installation\n @param zypp see \\ref init_target\n @param name resolvable name\n @param kind kind of resolvable\n @param who who do selection. If NOT_SELECTED is used, it will be empty\n operation.\n @param[out] status (will overwrite existing contents)"] + pub fn resolvable_select( + zypp: *mut Zypp, + name: *const ::std::os::raw::c_char, + kind: RESOLVABLE_KIND, + who: RESOLVABLE_SELECTED, + status: *mut Status, + ); + #[doc = " Unselect resolvable for installation. It can still be installed as\n dependency.\n @param zypp see \\ref init_target\n @param name resolvable name\n @param kind kind of resolvable\n @param who who do unselection. Only unselect if it is higher or equal level\n then who do the selection.\n @param[out] status (will overwrite existing contents)"] + pub fn resolvable_unselect( + zypp: *mut Zypp, + name: *const ::std::os::raw::c_char, + kind: RESOLVABLE_KIND, + who: RESOLVABLE_SELECTED, + status: *mut Status, + ); + #[doc = " Get Pattern details.\n Unknown patterns are simply omitted from the result. Match by\n PatternInfo.name, not by index."] + pub fn get_patterns_info( + _zypp: *mut Zypp, + names: PatternNames, + status: *mut Status, + ) -> PatternInfos; + pub fn free_pattern_infos(infos: *const PatternInfos); + pub fn import_gpg_key( + zypp: *mut Zypp, + pathname: *const ::std::os::raw::c_char, + status: *mut Status, + ); + #[doc = " check if url has local schema\n @param url url to check\n @param[out] status (will overwrite existing contents)\n @return true if url is local, for invalid url status is set to error"] + pub fn is_local_url(url: *const ::std::os::raw::c_char, status: *mut Status) -> bool; + #[doc = " check if package is available\n @param zypp see \\ref init_target\n @param tag package name, provides or file path\n @param[out] status (will overwrite existing contents)\n @return true if package is available. In case of error it fills status and\n return value is undefined"] + pub fn is_package_available( + zypp: *mut Zypp, + tag: *const ::std::os::raw::c_char, + status: *mut Status, + ) -> bool; + #[doc = " check if package is selected for installation\n @param zypp see \\ref init_target\n @param tag package name, provides or file path\n @param[out] status (will overwrite existing contents)\n @return true if package is selected. In case of error it fills status and\n return value is undefined"] + pub fn is_package_selected( + zypp: *mut Zypp, + tag: *const ::std::os::raw::c_char, + status: *mut Status, + ) -> bool; + #[doc = " Runs solver\n @param zypp see \\ref init_target\n @param[out] status (will overwrite existing contents)\n @return true if solver pass and false if it found some dependency issues"] + pub fn run_solver(zypp: *mut Zypp, status: *mut Status) -> bool; + #[doc = " the last call that will free all pointers to zypp holded by agama"] + pub fn free_zypp(zypp: *mut Zypp); + #[doc = " repository array in list.\n when no longer needed, use \\ref free_repository_list to release memory\n @param zypp see \\ref init_target\n @param[out] status (will overwrite existing contents)"] + pub fn list_repositories(zypp: *mut Zypp, status: *mut Status) -> RepositoryList; + pub fn free_repository_list(repo_list: *mut RepositoryList); + #[doc = " Adds repository to repo manager\n @param zypp see \\ref init_target\n @param alias have to be unique\n @param url can contain repo variables\n @param[out] status (will overwrite existing contents)\n @param callback pointer to function with callback or NULL\n @param user_data"] + pub fn add_repository( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + url: *const ::std::os::raw::c_char, + status: *mut Status, + callback: ZyppProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ); + #[doc = " Disable repository in repo manager\n @param zypp see \\ref init_target\n @param alias identifier of repository"] + pub fn disable_repository( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + ); + #[doc = " Changes url of given repository\n @param zypp see \\ref init_target\n @param alias identifier of repository\n @param alias have to be unique"] + pub fn set_repository_url( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + url: *const ::std::os::raw::c_char, + status: *mut Status, + ); + #[doc = " Removes repository from repo manager\n @param zypp see \\ref init_target\n @param alias have to be unique\n @param[out] status (will overwrite existing contents)\n @param callback pointer to function with callback or NULL\n @param user_data"] + pub fn remove_repository( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + callback: ZyppProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ); + #[doc = "\n @param zypp see \\ref init_target\n @param alias alias of repository to refresh\n @param[out] status (will overwrite existing contents)\n @param callbacks pointer to struct with callbacks or NULL if no progress is\n needed"] + pub fn refresh_repository( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + callbacks: *mut DownloadProgressCallbacks, + ); + pub fn build_repository_cache( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + callback: ZyppProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ); + pub fn load_repository_cache( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + ); +} diff --git a/rust/zypp-agama/zypp-agama-sys/src/lib.rs b/rust/zypp-agama/zypp-agama-sys/src/lib.rs new file mode 100644 index 0000000000..ee7e408421 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/src/lib.rs @@ -0,0 +1,14 @@ +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] + +include!("bindings.rs"); + +impl Default for Status { + fn default() -> Self { + Self { + state: Status_STATE_STATE_SUCCEED, + error: std::ptr::null_mut(), + } + } +} diff --git a/service/bin/agamactl b/service/bin/agamactl index 15fd581de9..a497f9027a 100755 --- a/service/bin/agamactl +++ b/service/bin/agamactl @@ -64,7 +64,7 @@ def start_service(name) service_runner.run end -ORDERED_SERVICES = [:software, :storage, :users, :manager].freeze +ORDERED_SERVICES = [:storage, :users, :manager].freeze dbus_server_manager = Agama::DBus::ServerManager.new diff --git a/service/lib/agama/config_reader.rb b/service/lib/agama/config_reader.rb index c67590d387..87f42d0297 100644 --- a/service/lib/agama/config_reader.rb +++ b/service/lib/agama/config_reader.rb @@ -137,7 +137,7 @@ def remote_config end def default_path - Dir.exist?(GIT_DIR) || File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH + File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH end def config_paths diff --git a/service/lib/agama/dbus/y2dir/manager/modules/Package.rb b/service/lib/agama/dbus/y2dir/manager/modules/Package.rb index 7237ce4353..4ec7600b70 100644 --- a/service/lib/agama/dbus/y2dir/manager/modules/Package.rb +++ b/service/lib/agama/dbus/y2dir/manager/modules/Package.rb @@ -18,7 +18,7 @@ # find current contact information at www.suse.com. require "yast" -require "agama/dbus/clients/software" +require "agama/http/clients/software" # :nodoc: module Yast @@ -26,7 +26,7 @@ module Yast class PackageClass < Module def main puts "Loading mocked module #{__FILE__}" - @client = Agama::DBus::Clients::Software.instance + @client = Agama::HTTP::Clients::Software.new(::Logger.new($stdout)) end # Determines whether a package is available. diff --git a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb index 53e17ac064..7d0ceeabce 100644 --- a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb +++ b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb @@ -18,7 +18,7 @@ # find current contact information at www.suse.com. require "yast" -require "agama/dbus/clients/software" +require "agama/http/clients/main" # :nodoc: module Yast @@ -26,29 +26,35 @@ module Yast class PackagesProposalClass < Module def main puts "Loading mocked module #{__FILE__}" - @client = Agama::DBus::Clients::Software.new + @client = Agama::HTTP::Clients::Main.new(::Logger.new($stdout)) end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L118 def AddResolvables(unique_id, type, resolvables, optional: false) - client.add_resolvables(unique_id, type, resolvables || [], optional: optional) + orig_resolvables = client.get_resolvables(unique_id, type, optional: optional) + orig_resolvables += resolvables + orig_resolvables.uniq! + SetResolvables(unique_id, type, orig_resolvables, optional: optional) true end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L145 def SetResolvables(unique_id, type, resolvables, optional: false) - client.set_resolvables(unique_id, type, resolvables || [], optional: optional) + client.set_resolvables(unique_id, type, resolvables || []) true end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L285 def GetResolvables(unique_id, type, optional: false) - client.get_resolvables(unique_id, type, optional: optional) + client.get_resolvables(unique_id, type, optional) end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L177 def RemoveResolvables(unique_id, type, resolvables, optional: false) - client.remove_resolvables(unique_id, type, resolvables || [], optional: optional) + orig_resolvables = client.get_resolvables(unique_id, type, optional: optional) + orig_resolvables -= resolvables + orig_resolvables.uniq! + SetResolvables(unique_id, type, orig_resolvables, optional: optional) true end diff --git a/service/lib/agama/dbus/y2dir/modules/Autologin.rb b/service/lib/agama/dbus/y2dir/modules/Autologin.rb index 7f470bdbeb..ed97f71f14 100644 --- a/service/lib/agama/dbus/y2dir/modules/Autologin.rb +++ b/service/lib/agama/dbus/y2dir/modules/Autologin.rb @@ -25,7 +25,7 @@ # # $Id$ require "yast" -require "agama/dbus/clients/software" +require "agama/http/clients/software" module Yast class AutologinClass < Module @@ -62,7 +62,7 @@ def main @pkg_initialized = false # Software service client - @dbus_client = nil + @software_client = nil end def available @@ -176,7 +176,7 @@ def AskForDisabling(new) # # @return Boolean def supported? - supported = dbus_client.provisions_selected?(DISPLAY_MANAGERS).any? + supported = software_client.provisions_selected?(DISPLAY_MANAGERS).any? if supported log.info("Autologin is supported") @@ -204,8 +204,8 @@ def supported? # Software service client # # @return [Agama::DBus::Clients::Software] Software service client - def dbus_client - @dbus_client ||= Agama::DBus::Clients::Software.new + def software_client + @software_client ||= Agama::HTTP::Clients::Software.new(::Logger.new($stdout)) end end diff --git a/service/lib/agama/http/clients/main.rb b/service/lib/agama/http/clients/main.rb index a6d70f5aec..600bef871c 100644 --- a/service/lib/agama/http/clients/main.rb +++ b/service/lib/agama/http/clients/main.rb @@ -29,6 +29,18 @@ class Main < Base def install post("v2/action", '"install"') end + + # Sets a list of resolvables for installation. + # + # @param unique_id [String] Unique ID to identify the list. + # @param type [String] Resolvable type (e.g., "package" or "pattern"). + # @param resolvables [Array] Resolvables names. + def set_resolvables(unique_id, type, resolvables) + data = resolvables.map do |name| + { "name" => name, "type" => type } + end + put("v2/private/resolvables/#{unique_id}", data) + end end end end diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 4f4a0be343..caa1ba7b18 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -34,8 +34,75 @@ def proposal JSON.parse(get("software/proposal")) end + def probe + post("software/probe", nil) + end + + def propose + # it is noop, probe already do proposal + # post("software/propose", nil) + end + + def install + http = Net::HTTP.new("localhost") + # FIXME: we need to improve it as it can e.g. wait for user interaction. + http.read_timeout = 3 * 60 * 60 # set timeout to three hours for rpm installation + response = http.post("/api/software/install", "", headers) + + return unless response.is_a?(Net::HTTPClientError) + + @logger.warn "server returned #{response.code} with body: #{response.body}" + end + + def finish + post("software/finish", nil) + end + + def locale=(value) + # TODO: implement it + post("software/locale", value) + end + def config - JSON.parse(get("software/config")) + JSON.parse(get("v2/config")) + end + + def selected_product + config.dig("product", "id") + end + + def errors? + # TODO: severity as integer is nasty for http API + JSON.parse(get("software/issues/software"))&.select { |i| i["severity"] == 1 }&.any? + end + + def get_resolvables(unique_id, type, optional) + JSON.parse(get("software/resolvables/#{unique_id}?type=#{type}&optional=#{optional}")) + end + + # (Yes, with a question mark. Bad naming.) + # @return [Array] Those names that are selected for installation + def provisions_selected?(provisions) + provisions.select do |prov| + package_installed?(prov) + end + end + + def package_available?(_name) + JSON.parse(get("software/available?tag=#{name}")) + end + + def package_installed?(name) + JSON.parse(get("software/selected?tag=#{name}")) + end + + def set_resolvables(unique_id, type, resolvables, optional) + data = { + "names" => resolvables, + "type" => type, + "optional" => optional + } + put("software/resolvables/#{unique_id}", data) end def add_patterns(patterns) @@ -53,6 +120,11 @@ def add_patterns(patterns) put("software/config", { "patterns" => config_patterns }) end + + def on_probe_finished(&block) + # TODO: it was agreed to change this storage observation to have the code + # in rust part and call via dbus ruby part + end end end end diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 71f7b9fe1e..9f69447aa0 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -30,7 +30,7 @@ require "agama/installation_phase" require "agama/service_status_recorder" require "agama/dbus/service_status" -require "agama/dbus/clients/software" +require "agama/http/clients/software" require "agama/dbus/clients/storage" require "agama/helpers" require "agama/http" @@ -44,7 +44,7 @@ module Agama # It is responsible for orchestrating the installation process. For module # specific stuff it delegates it to the corresponding module class (e.g., # {Agama::Network}, {Agama::Storage::Proposal}, etc.) or asks - # other services via D-Bus (e.g., `org.opensuse.Agama.Software1`). + # other services via HTTP (e.g., `/software`). class Manager include WithProgressManager include WithLocale @@ -83,7 +83,7 @@ def startup_phase installation_phase.startup # FIXME: hot-fix for decision taken at bsc#1224868 (RC1) network.startup - config_phase if software.selected_product + config_phase if software.config.dig("product", "id") logger.info("Startup phase done") service_status.idle @@ -169,11 +169,13 @@ def locale=(locale) # # @return [DBus::Clients::Software] def software - @software ||= DBus::Clients::Software.new.tap do |client| - client.on_service_status_change do |status| - service_status_recorder.save(client.service.name, status) - end - end + @software ||= HTTP::Clients::Software.new(logger) + # TODO: watch for http websocket events regarding software status + # software.tap do |client| + # client.on_service_status_change do |status| + # service_status_recorder.save(client.service.name, status) + # end + # end end # ProxySetup instance diff --git a/service/lib/agama/product_reader.rb b/service/lib/agama/product_reader.rb index 66ccdd779c..18e020e9d7 100644 --- a/service/lib/agama/product_reader.rb +++ b/service/lib/agama/product_reader.rb @@ -58,7 +58,7 @@ def load_products private def default_path - Dir.exist?(GIT_DIR) || File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH + File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH end end end diff --git a/service/po/ast.po b/service/po/ast.po index 8faa6c4527..28527f9e69 100644 --- a/service/po/ast.po +++ b/service/po/ast.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-21 02:56+0000\n" +"POT-Creation-Date: 2025-09-28 02:56+0000\n" "PO-Revision-Date: 2025-09-11 02:48+0000\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" diff --git a/service/po/ca.po b/service/po/ca.po index 12e864753d..73cee26c05 100644 --- a/service/po/ca.po +++ b/service/po/ca.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-21 02:56+0000\n" +"POT-Creation-Date: 2025-09-28 02:56+0000\n" "PO-Revision-Date: 2025-09-02 12:59+0000\n" "Last-Translator: David Medina \n" "Language-Team: Catalan \n" "Language-Team: Czech \n" "Language-Team: German \n" "Language-Team: Spanish \n" "Language-Team: Finnish \n" "Language-Team: French \n" "Language-Team: Indonesian \n" "Language-Team: Italian \n" "Language-Team: Japanese \n" "Language-Team: Georgian \n" "Language-Team: Kabyle \n" "Language-Team: Norwegian Bokmål \n" "Language-Team: Dutch \n" "Language-Team: Portuguese (Brazil) \n" "Language-Team: Russian , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-09-28 02:56+0000\n" +"PO-Revision-Date: 2025-09-23 02:46+0000\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: su\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. Reports the problems and decide whether to continue or not. +#. +#. @param elements [Array] List of unsupported elements. +#: service/lib/agama/autoyast/profile_reporter.rb:51 +#, perl-brace-format +msgid "Found unsupported elements in the AutoYaST profile: %{keys}." +msgstr "" + +#. Runs the config phase +#. +#. @param reprobe [Boolean] Whether a reprobe should be done instead of a probe. +#. @param data [Hash] Extra data provided to the D-Bus calls. +#: service/lib/agama/manager.rb:98 +msgid "Analyze disks" +msgstr "" + +#: service/lib/agama/manager.rb:98 +msgid "Configure software" +msgstr "" + +#. Runs the install phase +#. rubocop:disable Metrics/AbcSize, Metrics/MethodLength +#: service/lib/agama/manager.rb:117 +msgid "Prepare disks" +msgstr "" + +#: service/lib/agama/manager.rb:118 +msgid "Install software" +msgstr "" + +#: service/lib/agama/manager.rb:119 +msgid "Configure the system" +msgstr "" + +#. rubocop:enable Metrics/AbcSize, Metrics/MethodLength +#: service/lib/agama/manager.rb:160 +msgid "Load software translations" +msgstr "" + +#: service/lib/agama/manager.rb:161 +msgid "Load storage translations" +msgstr "" + +#. @param certificate [Agama::SSL::Certificate] +#. @return [Agama::Question] +#: service/lib/agama/registration.rb:424 +msgid "" +"Trying to import a self signed certificate. Do you want to trust it and " +"register the product?" +msgstr "" + +#. TRANSLATORS: button label, try downloading the failed package again +#: service/lib/agama/software/callbacks/base.rb:48 +msgid "Try again" +msgstr "" + +#. TRANSLATORS: button label, ignore the failed download, skip package installation +#: service/lib/agama/software/callbacks/base.rb:54 +msgid "Continue anyway" +msgstr "" + +#. TRANSLATORS: button label, abort the installation completely after an error +#: service/lib/agama/software/callbacks/base.rb:60 +msgid "Abort installation" +msgstr "" + +#. TRANSLATORS: button label, skip the error +#: service/lib/agama/software/callbacks/base.rb:66 +msgid "Skip" +msgstr "" + +#. TRANSLATORS: button label +#: service/lib/agama/software/callbacks/base.rb:72 +msgid "Yes" +msgstr "" + +#. TRANSLATORS: button label +#: service/lib/agama/software/callbacks/base.rb:78 +msgid "No" +msgstr "" + +#. Callback to accept a file without a checksum +#. +#. @param filename [String] File name +#. @return [Boolean] +#: service/lib/agama/software/callbacks/digest.rb:58 +#, perl-brace-format +msgid "" +"No checksum for the file %{file} was found in the repository. This means " +"that although the file is part of the signed repository, the list of " +"checksums does not mention this file. Use it anyway?" +msgstr "" + +#. Callback to accept an unknown digest +#. +#. @param filename [String] File name +#. @param digest [String] expected checksum +#. @return [Boolean] +#: service/lib/agama/software/callbacks/digest.rb:84 +#, perl-brace-format +msgid "" +"The checksum of the file %{file} is \"%{digest}\" but the expected checksum " +"is unknown. This means that the origin and integrity of the file cannot be " +"verified. Use it anyway?" +msgstr "" + +#. Callback to accept wrong digest +#. +#. @param filename [String] File name +#. @param expected_digest [String] expected checksum +#. @param found_digest [String] found checksum +#. @return [Boolean] +#: service/lib/agama/software/callbacks/digest.rb:111 +#, perl-brace-format +msgid "" +"The expected checksum of file %{file} is \"%{found}\" but it was expected to " +"be \"%{expected}\". The file has changed by accident or by an attacker since " +"the creater signed it. Use it anyway?" +msgstr "" + +#. DoneProvide callback +#. +#. @param description [String] Problem description +#. @return [String] "I" for ignore, "R" for retry and "C" for abort (not implemented) +#. @see https://github.com/yast/yast-yast2/blob/19180445ab935a25edd4ae0243aa7a3bcd09c9de/library/packages/src/modules/PackageCallbacks.rb#L620 +#: service/lib/agama/software/callbacks/script.rb:50 +msgid "There was a problem running a package script." +msgstr "" + +#. Callback to handle unsigned files +#. +#. @param filename [String] File name +#. @param repo_id [Integer] Repository ID. It might be -1 if there is not an associated repo. +#: service/lib/agama/software/callbacks/signature.rb:66 +#, perl-brace-format +msgid "" +"The file %{filename} from %{repo_url} is not digitally signed. The origin " +"and integrity of the file cannot be verified. Use it anyway?" +msgstr "" + +#: service/lib/agama/software/callbacks/signature.rb:72 +#, perl-brace-format +msgid "" +"The file %{filename} is not digitally signed. The origin and integrity of " +"the file cannot be verified. Use it anyway?" +msgstr "" + +#. Callback to handle signature verification failures +#. +#. @param key [Hash] GPG key data (id, name, fingerprint, etc.) +#. @param repo_id [Integer] Repository ID +#: service/lib/agama/software/callbacks/signature.rb:100 +#, perl-brace-format +msgid "" +"The key %{id} (%{name}) with fingerprint %{fingerprint} is unknown. Do you " +"want to trust this key?" +msgstr "" + +#. Callback to handle unknown GPG keys +#. +#. @param filename [String] Name of the file. +#. @param key_id [String] Key ID. +#. @param repo_id [String] Repository ID. +#: service/lib/agama/software/callbacks/signature.rb:131 +#, perl-brace-format +msgid "" +"The file %{filename} from %{repo_url} is digitally signed with the following " +"unknown GnuPG key: %{key_id}. Use it anyway?" +msgstr "" + +#: service/lib/agama/software/callbacks/signature.rb:137 +#, perl-brace-format +msgid "" +"The file %{filename} is digitally signed with the following unknown GnuPG " +"key: %{key_id}. Use it anyway?" +msgstr "" + +#. Callback to handle file verification failures +#. +#. @param filename [String] File name +#. @param key [Hash] GPG key data (id, name, fingerprint, etc.) +#. @param repo_id [Integer] Repository ID +#: service/lib/agama/software/callbacks/signature.rb:168 +#, perl-brace-format +msgid "" +"The file %{filename} from %{repo_url} is digitally signed with the following " +"GnuPG key, but the integrity check failed: %{key_id} (%{key_name}). Use it " +"anyway?" +msgstr "" + +#: service/lib/agama/software/callbacks/signature.rb:175 +#, perl-brace-format +msgid "" +"The file %{filename} is digitally signed with the following GnuPG key, but " +"the integrity check failed: %{key_id} (%{key_name}). Use it anyway?" +msgstr "" + +#. TRANSLATORS: button label, trust the GPG key or the signature +#: service/lib/agama/software/callbacks/signature.rb:199 +msgid "Trust" +msgstr "" + +#. Should an error be raised? +#: service/lib/agama/software/manager.rb:178 +msgid "Refreshing repositories metadata" +msgstr "" + +#: service/lib/agama/software/manager.rb:179 +msgid "Calculating the software proposal" +msgstr "" + +#: service/lib/agama/software/manager.rb:183 +msgid "Initializing sources" +msgstr "" + +#. error message +#: service/lib/agama/software/manager.rb:427 +#, c-format +msgid "Adding service '%s' failed." +msgstr "" + +#. error message +#: service/lib/agama/software/manager.rb:432 +#, c-format +msgid "Updating service '%s' failed." +msgstr "" + +#. error message +#: service/lib/agama/software/manager.rb:438 +#, c-format +msgid "Saving service '%s' failed." +msgstr "" + +#. error message +#: service/lib/agama/software/manager.rb:444 +#, c-format +msgid "Refreshing service '%s' failed." +msgstr "" + +#. rubocop:enable Metrics/AbcSize +#: service/lib/agama/software/manager.rb:453 +#, c-format +msgid "Removing service '%s' failed." +msgstr "" + +#. Issues related to the software proposal. +#. +#. Repositories that could not be probed are reported as errors. +#. +#. @return [Array] +#: service/lib/agama/software/manager.rb:710 +#, c-format +msgid "Could not read repository \"%s\"" +msgstr "" + +#. Issue when a product is missing +#. +#. @return [Agama::Issue] +#: service/lib/agama/software/manager.rb:720 +msgid "Product not selected yet" +msgstr "" + +#. Issue when a product requires registration but it is not registered yet. +#. +#. @return [Agama::Issue] +#: service/lib/agama/software/manager.rb:729 +msgid "Product must be registered" +msgstr "" + +#. Returns solver error messages from the last attempt +#. +#. @return [Array] Error messages +#: service/lib/agama/software/proposal.rb:276 +#, c-format +msgid "Found %s dependency issues." +msgstr "" + +#. TRANSLATORS: SSL certificate details +#: service/lib/agama/ssl/certificate_details.rb:31 +msgid "Certificate:" +msgstr "" + +#: service/lib/agama/ssl/certificate_details.rb:31 +msgid "Issued To" +msgstr "" + +#: service/lib/agama/ssl/certificate_details.rb:32 +msgid "Issued By" +msgstr "" + +#: service/lib/agama/ssl/certificate_details.rb:32 +msgid "SHA1 Fingerprint: " +msgstr "" + +#: service/lib/agama/ssl/certificate_details.rb:34 +msgid "SHA256 Fingerprint: " +msgstr "" + +#. label followed by the SSL certificate identification +#: service/lib/agama/ssl/certificate_details.rb:46 +msgid "Common Name (CN): " +msgstr "" + +#. label followed by the SSL certificate identification +#: service/lib/agama/ssl/certificate_details.rb:48 +msgid "Organization (O): " +msgstr "" + +#. label followed by the SSL certificate identification +#: service/lib/agama/ssl/certificate_details.rb:50 +msgid "Organization Unit (OU): " +msgstr "" + +#. Issue when the device has several users. +#. +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/alias.rb:74 +#, c-format +msgid "The device with alias '%s' is used by more than one device" +msgstr "" + +#. Issue when the device has both filesystem and a user. +#. +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/alias.rb:95 +#, c-format +msgid "" +"The device with alias '%s' cannot be formatted because it is used by other " +"device" +msgstr "" + +#. Issue when the device has both partitions and a user. +#. +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/alias.rb:118 +#, c-format +msgid "" +"The device with alias '%s' cannot be partitioned because it is used by other " +"device" +msgstr "" + +#. Error if a boot device is required and unknown. +#. +#. This happens when the config solver is not able to infer a boot device, see +#. {ConfigSolvers::Boot}. +#. +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/boot.rb:75 +msgid "The boot device cannot be automatically selected" +msgstr "" + +#. TRANSLATORS: %s is replaced by a device alias (e.g., "boot"). +#: service/lib/agama/storage/config_checkers/boot.rb:86 +#, c-format +msgid "There is no boot device with alias '%s'" +msgstr "" + +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device +#. (e.g., 'luks1', 'random_swap'). +#: service/lib/agama/storage/config_checkers/encryption.rb:78 +#, perl-brace-format +msgid "" +"No passphrase provided (required for using the method '%{crypt_method}')." +msgstr "" + +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device +#. (e.g., 'luks1', 'random_swap'). +#: service/lib/agama/storage/config_checkers/encryption.rb:93 +#, perl-brace-format +msgid "Encryption method '%{crypt_method}' is not available in this system." +msgstr "" + +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device +#. (e.g., 'luks1', 'random_swap'). +#: service/lib/agama/storage/config_checkers/encryption.rb:120 +#, perl-brace-format +msgid "'%{crypt_method}' is not a suitable method to encrypt the device." +msgstr "" + +#. TRANSLATORS: %s is replaced by a mount path (e.g., "/home"). +#: service/lib/agama/storage/config_checkers/filesystem.rb:79 +#, c-format +msgid "Missing file system type for '%s'" +msgstr "" + +#. TRANSLATORS: %{filesystem} is replaced by a file system type (e.g., "Btrfs") and +#. %{path} is replaced by a mount path (e.g., "/home"). +#: service/lib/agama/storage/config_checkers/filesystem.rb:100 +#, perl-brace-format +msgid "The file system type '%{filesystem}' is not suitable for '%{path}'" +msgstr "" + +#. TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). +#: service/lib/agama/storage/config_checkers/logical_volume.rb:82 +#, c-format +msgid "There is no LVM thin pool volume with alias '%s'" +msgstr "" + +#. TRANSLATORS: %s is the replaced by a device alias (e.g., "md1"). +#: service/lib/agama/storage/config_checkers/md_raid.rb:98 +#, c-format +msgid "There is no MD RAID member device with alias '%s'" +msgstr "" + +#. Issue if the MD RAID level is missing and the device is not reused. +#. +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/md_raid.rb:110 +msgid "There is a MD RAID without level" +msgstr "" + +#. Issue if the MD RAID does not contain enough member devices. +#. +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/md_raid.rb:121 +#, c-format +msgid "At least %s devices are required for %s" +msgstr "" + +#. Issue if the device member is deleted. +#. +#. @param member_config [#search] +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/md_raid.rb:168 +#, perl-brace-format +msgid "" +"The device '%{member}' cannot be deleted because it is part of the MD RAID %" +"{md_raid}" +msgstr "" + +#. Issue if the device member is resized. +#. +#. @param member_config [#search] +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/md_raid.rb:191 +#, perl-brace-format +msgid "" +"The device '%{member}' cannot be resized because it is part of the MD RAID %" +"{md_raid}" +msgstr "" + +#. Issue if the device member is formatted. +#. +#. @param member_config [#search] +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/md_raid.rb:214 +#, perl-brace-format +msgid "" +"The device '%{member}' cannot be formatted because it is part of the MD RAID " +"%{md_raid}" +msgstr "" + +#. Issue if the device member is partitioned. +#. +#. @param member_config [#search] +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/md_raid.rb:237 +#, perl-brace-format +msgid "" +"The device '%{member}' cannot be partitioned because it is part of the MD " +"RAID %{md_raid}" +msgstr "" + +#. Issue if the device member is used by other device (e.g., as target for physical volumes). +#. +#. @param member_config [#search] +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/md_raid.rb:259 +#, perl-brace-format +msgid "" +"The device '%{member}' cannot be used because it is part of the MD RAID %" +"{md_raid}" +msgstr "" + +#. Issue if the parent of the device member is formatted. +#. +#. @param device [Y2Storage::BlkDevice] +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/md_raid.rb:284 +#, perl-brace-format +msgid "" +"The device '%{device}' cannot be formatted because it is part of the MD RAID " +"%{md_raid}" +msgstr "" + +#. TRANSLATORS: 'method' is the identifier of the method to encrypt the device +#. (e.g., 'luks1'). +#: service/lib/agama/storage/config_checkers/physical_volumes_encryption.rb:61 +#, perl-brace-format +msgid "'%{method}' is not a suitable method to encrypt the physical volumes." +msgstr "" + +#. TRANSLATORS: %s is replaced by a device name (e.g., "/dev/vda"). +#: service/lib/agama/storage/config_checkers/search.rb:74 +#, c-format +msgid "Mandatory device %s not found" +msgstr "" + +#. TRANSLATORS: %s is replaced by a device type (e.g., "drive"). +#: service/lib/agama/storage/config_checkers/search.rb:77 +#, c-format +msgid "Mandatory %s not found" +msgstr "" + +#. @return [String] +#: service/lib/agama/storage/config_checkers/search.rb:85 +msgid "drive" +msgstr "" + +#: service/lib/agama/storage/config_checkers/search.rb:87 +msgid "MD RAID" +msgstr "" + +#: service/lib/agama/storage/config_checkers/search.rb:89 +msgid "partition" +msgstr "" + +#: service/lib/agama/storage/config_checkers/search.rb:91 +msgid "LVM logical volume" +msgstr "" + +#: service/lib/agama/storage/config_checkers/search.rb:93 +msgid "device" +msgstr "" + +#. Issue if the volume group name is missing. +#. +#. @return [Issue, nil] +#: service/lib/agama/storage/config_checkers/volume_group.rb:76 +msgid "There is a volume group without name" +msgstr "" + +#. TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). +#: service/lib/agama/storage/config_checkers/volume_group.rb:106 +#, c-format +msgid "There is no LVM physical volume with alias '%s'" +msgstr "" + +#. TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). +#: service/lib/agama/storage/config_checkers/volume_group.rb:133 +#, c-format +msgid "" +"The list of target devices for the volume group '%s' is mixing reused " +"devices and new devices" +msgstr "" + +#. TRANSLATORS: %s is the replaced by a device alias (e.g., "disk1"). +#: service/lib/agama/storage/config_checkers/volume_group.rb:153 +#, c-format +msgid "There is no target device for LVM physical volumes with alias '%s'" +msgstr "" + +#. TRANSLATORS: %s is the replaced by a device alias (e.g., "disk1"). +#: service/lib/agama/storage/config_checkers/volume_groups.rb:66 +#, c-format +msgid "" +"The device '%s' is used several times as target device for physical volumes" +msgstr "" + +#. Text of the reason preventing to shrink because there is no content. +#. +#. @return [String, nil] nil if there is content or there is any other reasons. +#: service/lib/agama/storage/device_shrinking.rb:151 +msgid "" +"Neither a file system nor a storage system was detected on the device. In " +"case the device does contain a file system or a storage system that is not " +"supported, resizing will most likely cause data loss." +msgstr "" + +#. Text of the reason preventing to shrink because there is no valid minimum size. +#. +#. @return [String, nil] nil if there is a minimum size or there is any other reasons. +#: service/lib/agama/storage/device_shrinking.rb:162 +msgid "Shrinking is not supported by this device" +msgstr "" + +#. Copy /etc/nvme/host* to keep NVMe working after installation, bsc#1238038 +#: service/lib/agama/storage/finisher.rb:145 +msgid "Copying important installation files to the target system" +msgstr "" + +#. Constructor +#: service/lib/agama/storage/finisher.rb:186 +msgid "Writing Linux Security Modules configuration" +msgstr "" + +#. Step to write the bootloader configuration +#: service/lib/agama/storage/finisher.rb:197 +msgid "Installing bootloader" +msgstr "" + +#. Step to finish the Y2Storage configuration +#: service/lib/agama/storage/finisher.rb:214 +msgid "Adjusting storage configuration" +msgstr "" + +#. Step to finish the iSCSI configuration +#: service/lib/agama/storage/finisher.rb:225 +msgid "Adjusting iSCSI configuration" +msgstr "" + +#. Step to configure the file-system snapshots +#: service/lib/agama/storage/finisher.rb:236 +msgid "Configuring file systems snapshots" +msgstr "" + +#. Step to copy the installation logs +#: service/lib/agama/storage/finisher.rb:254 +msgid "Copying logs" +msgstr "" + +#. Executes post-installation scripts +#: service/lib/agama/storage/finisher.rb:288 +msgid "Running user-defined scripts" +msgstr "" + +#. Executes post-installation scripts +#: service/lib/agama/storage/finisher.rb:327 +msgid "Deploying user-defined files" +msgstr "" + +#. Step to unmount the target file-systems +#: service/lib/agama/storage/finisher.rb:346 +msgid "Unmounting storage devices" +msgstr "" + +#. Applies the target configs. +#. +#. @param config [ISCSI::Config] +#: service/lib/agama/storage/iscsi/manager.rb:300 +msgid "Logout iSCSI targets" +msgstr "" + +#: service/lib/agama/storage/iscsi/manager.rb:301 +msgid "Discover iSCSI targets" +msgstr "" + +#: service/lib/agama/storage/iscsi/manager.rb:302 +msgid "Login iSCSI targets" +msgstr "" + +#. Login issue. +#. +#. @param target [ISCSI::Configs::Target] +#. @return [Issue] +#: service/lib/agama/storage/iscsi/manager.rb:366 +#, c-format +msgid "Cannot login to iSCSI target %s" +msgstr "" + +#. Probes storage devices and performs an initial proposal +#. +#. @param keep_config [Boolean] Whether to use the current storage config for calculating the +#. proposal. +#. @param keep_activation [Boolean] Whether to keep the current activation (e.g., provided LUKS +#. passwords). +#: service/lib/agama/storage/manager.rb:133 +msgid "Activating storage devices" +msgstr "" + +#: service/lib/agama/storage/manager.rb:134 +msgid "Probing storage devices" +msgstr "" + +#: service/lib/agama/storage/manager.rb:135 +msgid "Calculating the storage proposal" +msgstr "" + +#. Prepares the partitioning to install the system +#: service/lib/agama/storage/manager.rb:161 +msgid "Preparing bootloader proposal" +msgstr "" + +#. then also apply changes to that proposal +#: service/lib/agama/storage/manager.rb:168 +msgid "Adding storage-related packages" +msgstr "" + +#: service/lib/agama/storage/manager.rb:169 +msgid "Preparing the storage devices" +msgstr "" + +#: service/lib/agama/storage/manager.rb:170 +msgid "Writing bootloader sysconfig" +msgstr "" + +#. Issue representing the proposal is not valid. +#. +#. @return [Issue] +#: service/lib/agama/storage/proposal.rb:345 +msgid "Cannot calculate a valid storage setup with the current configuration" +msgstr "" + +#. Issue to communicate a generic Y2Storage error. +#. +#. @return [Issue] +#: service/lib/agama/storage/proposal.rb:356 +msgid "A problem ocurred while calculating the storage setup" +msgstr "" + +#. Returns an issue if there is no target device. +#. +#. @return [Issue, nil] +#: service/lib/agama/storage/proposal_strategies/guided.rb:135 +msgid "No device selected for installation" +msgstr "" + +#. Returns an issue if any of the devices required for the proposal is not found +#. +#. @return [Issue, nil] +#: service/lib/agama/storage/proposal_strategies/guided.rb:151 +#, perl-brace-format +msgid "The following selected device is not found in the system: %{devices}" +msgid_plural "" +"The following selected devices are not found in the system: %{devices}" +msgstr[0] "" +msgstr[1] "" + +#. Recalculates the list of issues +#: service/lib/agama/users.rb:154 +msgid "" +"Defining a user, setting the root password or a SSH public key is required" +msgstr "" diff --git a/service/po/sv.po b/service/po/sv.po index 0dc7cd22b2..6dde835f27 100644 --- a/service/po/sv.po +++ b/service/po/sv.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-21 02:56+0000\n" +"POT-Creation-Date: 2025-09-28 02:56+0000\n" "PO-Revision-Date: 2025-07-07 07:59+0000\n" "Last-Translator: Luna Jernberg \n" "Language-Team: Swedish \n" "Language-Team: Turkish \n" "Language-Team: Ukrainian \n" "Language-Team: Chinese (China) \n" "Language-Team: Chinese (Taiwan) "btrfsprogs", "type" => "package" }].to_json + expect(Net::HTTP).to receive(:put).with(url, data, { + "Content-Type": "application/json", + Authorization: "Bearer 123456" + }).and_return(response) + main.set_resolvables("storage", "package", ["btrfsprogs"]) + end + end +end diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index a4c26158a3..539ac8e2b9 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -43,9 +43,10 @@ let(:software) do instance_double( - Agama::DBus::Clients::Software, - probe: nil, install: nil, propose: nil, finish: nil, on_product_selected: nil, - on_service_status_change: nil, selected_product: product, errors?: false + Agama::HTTP::Clients::Software, + probe: nil, install: nil, propose: nil, finish: nil, + config: { "product" => { "id" => product } }, errors?: false, + selected_product: product ) end let(:users) do @@ -73,7 +74,7 @@ allow(Agama::Network).to receive(:new).and_return(network) allow(Agama::ProxySetup).to receive(:instance).and_return(proxy) allow(Agama::HTTP::Clients::Main).to receive(:new).and_return(http_client) - allow(Agama::DBus::Clients::Software).to receive(:new).and_return(software) + allow(Agama::HTTP::Clients::Software).to receive(:new).and_return(software) allow(Agama::DBus::Clients::Storage).to receive(:new).and_return(storage) allow(Agama::Users).to receive(:new).and_return(users) allow(Agama::HTTP::Clients::Scripts).to receive(:new) diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index af0bca2aba..871f8883c4 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -342,7 +342,8 @@ .with("agama", :pattern, [], { optional: true }) expect(proposal).to receive(:set_resolvables) .with("agama", :package, [ - "NetworkManager", "openSUSE-repos-Tumbleweed", "sudo-policy-wheel-auth-self" + "NetworkManager", "kernel-default", + "openSUSE-repos-Tumbleweed", "sudo-policy-wheel-auth-self" ]) expect(proposal).to receive(:set_resolvables) .with("agama", :package, [], { optional: true }) diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index 0f51f119e7..c497800c71 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -59,6 +59,7 @@ mock_storage(devicegraph: scenario) allow(Agama::Storage::Proposal).to receive(:new).and_return(proposal) allow(Agama::HTTP::Clients::Questions).to receive(:new).and_return(questions_client) + allow(Agama::HTTP::Clients::Software).to receive(:new).and_return(software) allow(Bootloader::FinishClient).to receive(:new).and_return(bootloader_finish) allow(Agama::Security).to receive(:new).and_return(security) # mock writting config as proposal call can do storage probing, which fails in CI @@ -78,6 +79,9 @@ let(:y2storage_manager) { Y2Storage::StorageManager.instance } let(:proposal) { Agama::Storage::Proposal.new(config, logger: logger) } let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:software) do + instance_double(Agama::HTTP::Clients::Software, config: { "product" => "ALP" }) + end let(:network) { instance_double(Agama::Network, link_resolv: nil, unlink_resolv: nil) } let(:bootloader_finish) { instance_double(Bootloader::FinishClient, write: nil) } let(:security) { instance_double(Agama::Security, write: nil) } diff --git a/setup-services.sh b/setup-services.sh index 447bf1519c..88ab14056e 100755 --- a/setup-services.sh +++ b/setup-services.sh @@ -139,6 +139,7 @@ $SUDO $ZYPPER install \ clang-devel \ gzip \ jsonnet \ + libzypp-devel \ lshw \ NetworkManager \ pam-devel \ diff --git a/setup.sh b/setup.sh index 687f012120..160377f85b 100755 --- a/setup.sh +++ b/setup.sh @@ -21,14 +21,6 @@ else SUDO="" fi -# Services setup -if ! $MYDIR/setup-services.sh; then - echo "Services setup failed." - echo "Agama services are NOT running." - - exit 2 -fi; - # Web setup if ! $MYDIR/setup-web.sh; then echo "Web client setup failed." @@ -37,6 +29,14 @@ if ! $MYDIR/setup-web.sh; then exit 3 fi; +# Services setup +if ! $MYDIR/setup-services.sh; then + echo "Services setup failed." + echo "Agama services are NOT running." + + exit 2 +fi; + # Start the installer. echo echo "The configured Agama services can be manually started with these commands:" diff --git a/web/src/components/software/SoftwarePage.tsx b/web/src/components/software/SoftwarePage.tsx index 707d51e996..c4b04929e5 100644 --- a/web/src/components/software/SoftwarePage.tsx +++ b/web/src/components/software/SoftwarePage.tsx @@ -41,7 +41,6 @@ import { usePatterns, useSoftwareProposal, useSoftwareProposalChanges, - useRepositories, useRepositoryMutation, } from "~/queries/software"; import { Pattern, SelectedBy } from "~/types/software"; @@ -136,7 +135,8 @@ function SoftwarePage(): React.ReactNode { const issues = useIssues("software"); const proposal = useSoftwareProposal(); const patterns = usePatterns(); - const repos = useRepositories(); + // FIXME: temporarily disabled, the API end point is not implemented yet + const repos = []; // useRepositories(); const [loading, setLoading] = useState(false); const { mutate: probe } = useRepositoryMutation(() => setLoading(false)); diff --git a/web/src/po/po.uk.js b/web/src/po/po.uk.js index f08ef2bf4a..2bf42d6030 100644 --- a/web/src/po/po.uk.js +++ b/web/src/po/po.uk.js @@ -76,6 +76,16 @@ export default { "A generic size range between %1$s and %2$s will be used for the new %3$s": [ "Для нового розділу %3$s буде використано типовий діапазон розмірів між %1$s і %2$s" ], + "A new partition will be created for %s": [ + "Для %s буде створено новий розділ", + "Для %s будуть створені нові розділи", + "Для %s будуть створені нові розділи" + ], + "A new volume will be created for %s": [ + "Для %s буде створено новий том", + "Для %s будуть створені нові томи", + "Для %s будуть створені нові томи" + ], "A partition may be deleted": [ "Розділ може бути видалений" ], @@ -184,6 +194,11 @@ export default { "Already using all available disks": [ "Вже використані всі наявні диски" ], + "An existing partition will be used for %s": [ + "Для %s буде використано наявний розділ", + "Для %s будуть використані наявні розділи", + "Для %s будуть використані наявні розділи" + ], "Any existing partition will be removed and all data in the disk will be lost.": [ "Всі розділи буде видалено, а всі дані на диску буде втрачено." ],