diff --git a/.github/workflows/ci-devel.yml b/.github/workflows/ci-devel.yml new file mode 100644 index 0000000000..34c88a2ec4 --- /dev/null +++ b/.github/workflows/ci-devel.yml @@ -0,0 +1,44 @@ +name: CI - Devel scripts + +on: + push: + paths: + # NOTE: GitHub Actions do not allow using YAML references, the same path + # list is used below for the pull request event. Keep both lists in sync!! + + # this file as well + - .github/workflows/ci-devel.yml + # any change in the devel subfolder + - devel/** + # except the Markdown documentation + - "!devel/**.md" + pull_request: + paths: + # NOTE: GitHub Actions do not allow using YAML references, the same path + # list is used above for the push event. Keep both lists in sync!! + + # this file as well + - .github/workflows/ci-devel.yml + # any change in the devel subfolder + - devel/** + # except the Markdown documentation + - "!devel/**.md" + + # allow running manually + workflow_dispatch: + +jobs: + ci_devel: + runs-on: ubuntu-latest + + steps: + + - name: Git Checkout + uses: actions/checkout@v4 + with: + # checkout only the "devel" subdirectory + sparse-checkout: | + devel + + - name: Run the tests + run: find devel -type f -exec grep -l -E "^#! *(/usr/|)/bin/(ba|)sh" \{\} \; | xargs -I% bash -c "echo 'Checking %...' && shellcheck %" diff --git a/.github/workflows/obs-release.yml b/.github/workflows/obs-release.yml deleted file mode 100644 index 9928560fa6..0000000000 --- a/.github/workflows/obs-release.yml +++ /dev/null @@ -1,48 +0,0 @@ -# Publish a new version -# - Submit the packages to the OBS project defined in OBS_PROJECT_RELEASE variable -# at GitHub (in the original repository it is set to systemsmanagement:Agama:Release, -# see https://github.com/agama-project/agama/settings/variables/actions, -# you might change that in forks) -# - Send submit requests - -name: Release - -on: - # runs when creating a release tag - push: - tags: - - v[0-9]* - -jobs: - # Note: the Live ISO is currently not submitted - - update_rust: - uses: ./.github/workflows/obs-staging-shared.yml - # pass all secrets - secrets: inherit - with: - install_packages: obs-service-cargo_audit obs-service-cargo_vendor - package_name: agama - service_file: rust/package/_service - - update_web: - uses: ./.github/workflows/obs-staging-shared.yml - # pass all secrets - secrets: inherit - with: - install_packages: obs-service-node_modules - package_name: agama-web-ui - service_file: web/package/_service - - update_service: - uses: ./.github/workflows/obs-service-shared.yml - # pass all secrets - secrets: inherit - - update_products: - uses: ./.github/workflows/obs-staging-shared.yml - # pass all secrets - secrets: inherit - with: - package_name: agama-products - service_file: products.d/_service diff --git a/.github/workflows/obs-service-shared.yml b/.github/workflows/obs-service-shared.yml index 4c4a33693f..5c0a3ccd1e 100644 --- a/.github/workflows/obs-service-shared.yml +++ b/.github/workflows/obs-service-shared.yml @@ -5,21 +5,24 @@ name: Update OBS Service Package on: workflow_call: secrets: - OBS_USER: - required: true OBS_PASSWORD: required: true jobs: update_service: - # do not run in forks which do not set the OBS_PROJECT variable - if: vars.OBS_PROJECT != '' + # do not run in forks which do not set the OBS_PROJECTS and OBS_USER variables, + # or the mapping for the current branch is missing + if: vars.OBS_PROJECTS && fromJson(vars.OBS_PROJECTS)[github.ref_name] && vars.OBS_USER runs-on: ubuntu-latest container: image: registry.opensuse.org/opensuse/tumbleweed:latest + env: + # to avoid Ruby UTF-8 errors (the default is "POSIX") + LC_ALL: en_US.UTF-8 + steps: - name: Configure and refresh repositories # disable unused repositories to have a faster refresh @@ -43,33 +46,27 @@ jobs: # fetch all history, we need to find the latest tag and offset for the version number fetch-depth: 0 - - name: Git Checkout (release tag only) - if: ${{ github.ref_type == 'tag' }} - uses: actions/checkout@v4 - - name: Configure git run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Git Checkout + uses: actions/checkout@v4 + with: + # fetch all history with tags, we need to find the latest version tag + fetch-depth: 0 + fetch-tags: true + - name: Configure osc run: .github/workflows/configure_osc.sh env: - OBS_USER: ${{ secrets.OBS_USER }} + OBS_USER: ${{ vars.OBS_USER }} OBS_PASSWORD: ${{ secrets.OBS_PASSWORD }} - - name: Commit the rubygem-agama-yast package to ${{ vars.OBS_PROJECT }} + - name: Commit the rubygem-agama-yast package to ${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }} run: rake osc:commit working-directory: ./service env: # do not build the package with "osc", it takes long time # and does not provide much value SKIP_OSC_BUILD: 1 - OBS_PROJECT: ${{ vars.OBS_PROJECT }} - - - name: Submit the rubygem-agama-yast package - # only when a tag has been pushed - if: ${{ github.ref_type == 'tag' }} - # the package has been comitted in the previous step, just submit it - run: rake osc:sr:force - working-directory: ./service - env: - OBS_PROJECT: ${{ vars.OBS_PROJECT }} + OBS_PROJECT: ${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }} diff --git a/.github/workflows/obs-staging-autoinstallation.yml b/.github/workflows/obs-staging-autoinstallation.yml index 6888946a16..d9c8f15f3f 100644 --- a/.github/workflows/obs-staging-autoinstallation.yml +++ b/.github/workflows/obs-staging-autoinstallation.yml @@ -3,9 +3,6 @@ name: Submit agama-auto on: # runs on pushes targeting the default branch push: - branches: - - master - - release paths: # run only when an autoinstallation source is changed - autoinstallation/** diff --git a/.github/workflows/obs-staging-live.yml b/.github/workflows/obs-staging-live.yml index 43d9feb8a8..e0eb7ed708 100644 --- a/.github/workflows/obs-staging-live.yml +++ b/.github/workflows/obs-staging-live.yml @@ -1,11 +1,7 @@ name: Submit agama-installer on: - # runs on pushes targeting the default branch push: - branches: - - master - - release paths: # run only when a live ISO source is changed - live/** @@ -14,9 +10,10 @@ on: workflow_dispatch: jobs: - update_staging_package: - # do not run in forks which do not set the OBS_PROJECT variable - if: vars.OBS_PROJECT != '' + update_obs_package: + # do not run in forks which do not set the OBS_PROJECTS variable, + # or the mapping for the current branch is missing + if: vars.OBS_PROJECTS && fromJson(vars.OBS_PROJECTS)[github.ref_name] && vars.OBS_USER runs-on: ubuntu-latest @@ -41,11 +38,11 @@ jobs: - name: Configure osc run: .github/workflows/configure_osc.sh env: - OBS_USER: ${{ secrets.OBS_USER }} + OBS_USER: ${{ vars.OBS_USER }} OBS_PASSWORD: ${{ secrets.OBS_PASSWORD }} - - name: Checkout ${{ vars.OBS_PROJECT }} agama-installer - run: osc co -o dist ${{ vars.OBS_PROJECT }} agama-installer + - name: Checkout ${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }} agama-installer + run: osc co -o dist ${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }} agama-installer working-directory: ./live - name: Build sources @@ -60,6 +57,6 @@ jobs: run: osc diff && osc status working-directory: ./live/dist - - name: Commit agama-installer to ${{ vars.OBS_PROJECT }} + - name: Commit agama-installer to ${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }} run: osc commit -m "Updated to Agama $GITHUB_SHA" working-directory: ./live/dist diff --git a/.github/workflows/obs-staging-products.yml b/.github/workflows/obs-staging-products.yml index ea54b77394..95743e56b9 100644 --- a/.github/workflows/obs-staging-products.yml +++ b/.github/workflows/obs-staging-products.yml @@ -1,13 +1,9 @@ name: Submit agama-products on: - # runs on pushes targeting the default branch push: - branches: - - master - - release paths: - # run only when a Rust source is changed + # run only when a product source is changed - products.d/** # allow running manually diff --git a/.github/workflows/obs-staging-rust.yml b/.github/workflows/obs-staging-rust.yml index d1c7a80f61..7f8fd25f2c 100644 --- a/.github/workflows/obs-staging-rust.yml +++ b/.github/workflows/obs-staging-rust.yml @@ -1,11 +1,7 @@ name: Submit agama on: - # runs on pushes targeting the default branch push: - branches: - - master - - release paths: # run only when a Rust source is changed - rust/** diff --git a/.github/workflows/obs-staging-service.yml b/.github/workflows/obs-staging-service.yml index e17e560061..9679811807 100644 --- a/.github/workflows/obs-staging-service.yml +++ b/.github/workflows/obs-staging-service.yml @@ -1,11 +1,7 @@ name: Submit rubygem-agama-yast on: - # runs on pushes targeting the default branch push: - branches: - - master - - release paths: # run only when a service source is changed - service/** diff --git a/.github/workflows/obs-staging-shared.yml b/.github/workflows/obs-staging-shared.yml index 2e4a968983..e3cfade8aa 100644 --- a/.github/workflows/obs-staging-shared.yml +++ b/.github/workflows/obs-staging-shared.yml @@ -5,8 +5,6 @@ name: Update OBS Packages on: workflow_call: secrets: - OBS_USER: - required: true OBS_PASSWORD: required: true @@ -27,10 +25,10 @@ on: type: string jobs: - update_staging_package: - # do not run in forks which do not set the OBS_PROJECT variable, - # for the "release" branch or a git tag use the OBS_PROJECT_RELEASE variable - if: vars.OBS_PROJECT != '' || ((github.ref_name == 'release' || github.ref_type == 'tag') && vars.OBS_PROJECT_RELEASE != '') + update_obs_package: + # do not run in forks which do not set the OBS_PROJECTS and OBS_USER variables, + # or the mapping for the current branch is missing + if: vars.OBS_PROJECTS && fromJson(vars.OBS_PROJECTS)[github.ref_name] && vars.OBS_USER runs-on: ubuntu-latest @@ -38,17 +36,6 @@ jobs: image: registry.opensuse.org/opensuse/tumbleweed:latest steps: - - name: Select OBS project - id: obs_project - run: |- - if [ "${{ github.ref_name }}" = "release" -o "${{ github.ref_type }}" = "tag" ]; then - echo "OBS_PROJECT=${{ vars.OBS_PROJECT_RELEASE }}" >> "$GITHUB_OUTPUT" - echo "OBS project: ${{ vars.OBS_PROJECT_RELEASE }}" - else - echo "OBS_PROJECT=${{ vars.OBS_PROJECT }}" >> "$GITHUB_OUTPUT" - echo "OBS project: ${{ vars.OBS_PROJECT }}" - fi - - name: Configure and refresh repositories # disable unused repositories to have a faster refresh run: zypper modifyrepo -d repo-non-oss repo-openh264 repo-update && zypper ref @@ -73,22 +60,21 @@ jobs: - name: Configure osc run: .github/workflows/configure_osc.sh env: - OBS_USER: ${{ secrets.OBS_USER }} + OBS_USER: ${{ vars.OBS_USER }} OBS_PASSWORD: ${{ secrets.OBS_PASSWORD }} - - name: Checkout ${{ steps.obs_project.outputs.OBS_PROJECT }} ${{ inputs.package_name }} - run: osc co ${{ steps.obs_project.outputs.OBS_PROJECT }} ${{ inputs.package_name }} + - name: Checkout ${{ inputs.package_name }} from ${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }} + run: osc co ${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }} ${{ inputs.package_name }} - name: Configure git run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Update service revision # only when a tag has been pushed, or "release" branch updated - if: github.ref_type == 'tag' || github.ref_name == 'release' + if: inputs.service_file != '' run: |- echo "Updating revision to \"${{ github.ref_name }}\"" - sed -i -e 's#.*#${{ github.ref_name }}#' _service - working-directory: ./${{ steps.obs_project.outputs.OBS_PROJECT }}/${{ inputs.package_name }} + sed -i -e 's#.*#${{ github.ref_name }}#' ${{ inputs.service_file }} - name: Copy optional service file # patch the URL in the file so it works also from forks, forks also by @@ -96,31 +82,23 @@ jobs: # no tag is present if: inputs.service_file != '' run: | - sed -e 's#.*#https://github.com/${{ github.repository }}.git#' ${{ inputs.service_file }} > ./${{ steps.obs_project.outputs.OBS_PROJECT }}/${{ inputs.package_name }}/_service - if [ -z "$(git tag -l)" ]; then sed -i -e 's#.*##' ./${{ steps.obs_project.outputs.OBS_PROJECT }}/${{ inputs.package_name }}/_service; fi + sed -e 's#.*#https://github.com/${{ github.repository }}.git#' ${{ inputs.service_file }} > ./${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }}/${{ inputs.package_name }}/_service + if [ -z "$(git tag -l)" ]; then sed -i -e 's#.*##' ./${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }}/${{ inputs.package_name }}/_service; fi - name: Run services - run: osc service manualrun - working-directory: ./${{ steps.obs_project.outputs.OBS_PROJECT }}/${{ inputs.package_name }} - - - name: Cleanup - # sometimes the "osc service" run does not cleanup properly all - # downloaded NPM package tarballs and they are accidentally added to the - # OBS package, so delete any TGZ files present - run: rm -vf *.tgz - working-directory: ./${{ steps.obs_project.outputs.OBS_PROJECT }}/${{ inputs.package_name }} + run: | + osc service manualrun + # sometimes the "osc service" run does not cleanup properly all + # downloaded NPM package tarballs and they are accidentally added to the + # OBS package, so delete any TGZ files present + rm -vf *.tgz + working-directory: ./${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }}/${{ inputs.package_name }} - name: Check status run: osc addremove && osc diff && osc status - working-directory: ./${{ steps.obs_project.outputs.OBS_PROJECT }}/${{ inputs.package_name }} + working-directory: ./${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }}/${{ inputs.package_name }} - - name: Commit ${{ inputs.package_name }} to ${{ steps.obs_project.outputs.OBS_PROJECT }} + - name: Commit ${{ inputs.package_name }} to ${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }} run: |- osc commit -m "Updated to $(sed -e '/^version:/!d' -e 's/version: *\(.*\)/\1/' agama.obsinfo) ($(sed -e '/^commit:/!d' -e 's/commit: *\(.*\)/\1/' agama.obsinfo))" - working-directory: ./${{ steps.obs_project.outputs.OBS_PROJECT }}/${{ inputs.package_name }} - - - name: Submit the package - # only when a tag has been pushed - if: github.ref_type == 'tag' - run: osc sr --yes -m "Releasing version ${{ github.ref_name }}" - working-directory: ./${{ steps.obs_project.outputs.OBS_PROJECT }}/${{ inputs.package_name }} + working-directory: ./${{ fromJson(vars.OBS_PROJECTS)[github.ref_name] }}/${{ inputs.package_name }} diff --git a/.github/workflows/obs-staging-web.yml b/.github/workflows/obs-staging-web.yml index 6480cfda4a..b457caca32 100644 --- a/.github/workflows/obs-staging-web.yml +++ b/.github/workflows/obs-staging-web.yml @@ -1,11 +1,7 @@ name: Submit agama-web-ui on: - # runs on pushes targeting the default branch push: - branches: - - master - - release paths: # run only when a web frontend source is changed - web/** diff --git a/devel/README.md b/devel/README.md new file mode 100644 index 0000000000..ecd799a9a3 --- /dev/null +++ b/devel/README.md @@ -0,0 +1,117 @@ +# Development script + +This directory contains scripts useful for Agama development. + +## Git autosubmission to OBS + +The [branch2obs.sh](./branch2obs.sh) script creates an OBS project and configures the GitHub Actions +for automatic submission from the specified Git branch or the current branch. Each branch can be +submitted to a different OBS project. + +There are several use cases for this script. You can use it for [building testing +images](#testing-builds) with patched Agama so the testers can easily test your fixes. Another use +case is using it for a long running feature or refactoring, especially when more people work on +that. + +And last but not least, it can be used for preparing a new [version for release](#release-builds). +The release branch can be configured to be submitted to a special OBS release project while the +master continues in accepting new features for the future release and is submitted to the usual +development project. + +### Used tools + +The script requires the `git`, `gh`, `jq` and `osc` command line tools to be installed. The `osc` +and `gh` tools need to be configured/authenticated against OBS respective GitHub. Do not worry the +script checks for that. + +### GitHub configuration + +If you run the script in your GitHub fork then you need to configure the OBS credentials. When +running in the original repository it will use an already pre-configured OBS user, you do not need +to change anything. + +You need to create the `OBS_USER` action variable containing your OBS login name. You can do that +from command line running command + + gh -R /agama variable set OBS_USER --body + +where `gh_user` is your GitHub login name and `obs_user` your OBS login name. + +Alternatively you can create the variable manually by visiting URL + + https://github.com//agama/settings/variables/actions/new + +where `gh_user` is your GitHub login name. On that page create variable `OBS_USER` with your OBS +login name. + +Similarly we need to enter the OBS password, but as this is sensitive private value we use GitHub +secret for that. From command line run this command + + gh -R /agama secret set OBS_PASSWORD + +where `gh_user` is your GitHub login name. The command will interactively ask for the password. + +Or you can create the secret in browser going to this page + + https://github.com//agama/settings/secrets/actions/new` + +where `gh_user` is your GitHub login name. Create a new secret with name `OBS_PASSWORD` and +enter your OBS password as the value. + +### Testing builds + +This works in both original repository and in a fork. + +- Create a new branch in git and push it to GitHub +- Run the `branch2obs.sh` script +- The Git branch name is by default used in the OBS project name + - `systemsmanagement:Agama:branches:` for the original repository + - `home::Agama:branches:` for forks + +### Release builds + +This works only in the original repository because is uses the +[systemsmanagement:Agama:Release](https://build.opensuse.org/project/show/systemsmanagement:Agama:Release) +project for submitting. + +- Create a new release branch in git and push it to GitHub + - `git checkout -b beta2` + - `git push origin beta2` +- Configure submission, use the systemsmanagement:Agama:Release project as the target + - `branch2obs.sh -p systemsmanagement:Agama:Release` +- Bump the version in master branch for the next release + - `git checkout master` + - Update the ISO version in `live/src/agama-installer.kiwi`, use the `pre` suffix to distinguish + between a development version and the final version. I.e. for Beta3 change the version from `12` + to `13pre`. + - Push the changes + - `git commit -a` + - `git push` + - Configure that the version tag is submitted to the Devel project as well: + - `branch2obs.sh -b v13.pre -p systemsmanagement:Agama:Devel` + - Important: The version tag needs to contain the dot separator between the version and "pre" + suffix! It is used in the Agama Ruby gem version and Ruby Gemspec is quite picky about the + version format. + - Create the version tag and push it to GitHub + - `git tag -s -m "Version v13.pre" v13.pre` + - `git push origin v13.pre` + - Important: The new version tag must not be reachable from the release branch otherwise it + would use this version as well. That special version bump commit created before ensures that. +- Now the new features can be committed to the `master` branch without breaking the release code. +- Do not forget to merge the fixes from the release branch also to `master`. +- When the development for the next release is open in `master` remove the `pre` suffix from the + version (use the same process as described above without the `pre` suffix). +- The "pre" tag can be removed from Git, it is not used anymore. +- You might remove the mapping for the previous release branch from the + [OBS_PROJECTS](https://github.com/agama-project/agama/settings/variables/actions/OBS_PROJECTS) + GitHub variable. Just to avoid accidentally updating the packages with the old + code when a commit is added to the old branch. + +## Implementation details + +The mapping between the Git branch and the target OBS project is stored in the +[OBS_PROJECTS](https://github.com/agama-project/agama/settings/variables/actions/OBS_PROJECTS) +GitHub variable. It is in JSON format and maps the Git branch name to the OBS project name. + +The GitHub submission actions check the mapping value for the current branch/tag and if no mapping +is found the submission is skipped. diff --git a/devel/branch2obs.sh b/devel/branch2obs.sh new file mode 100755 index 0000000000..450f7a36b8 --- /dev/null +++ b/devel/branch2obs.sh @@ -0,0 +1,191 @@ +#! /usr/bin/bash + +# This script configures autosubmission from a GitHub branch to an OBS project. +# Works with the original project and with forks as well. + +usage () { + echo "Usage: $0 [options]" + echo + echo "Options:" + echo " -a - keep all original build archs (default: build only x86_64)" + echo " -b - source git branch or tag (default: current git branch)" + echo " -p - target OBS project (based on the git branch)" + echo " -t - keep all original build targets (default: disable Leap 16.0)" + echo " -h - print this help" +} + +# process command line arguments +while getopts ":ab:hp:t" opt; do + case ${opt} in + a) + ALL_ARCHS=true + ;; + b) + branch="${OPTARG}" + ;; + p) + PROJECT="${OPTARG}" + ;; + t) + ALL_TARGETS=true + ;; + h) + usage + exit 0 + ;; + :) + echo "ERROR: Missing argument for option -${OPTARG}" + echo + usage + exit 1 + ;; + ?) + echo "ERROR: Invalid option -${OPTARG}" + echo + usage + exit 1 + ;; + esac +done + +# check if all needed tools are installed +tools=(git gh jq osc) +for tool in "${tools[@]}"; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "Tool \"$tool\" is not installed, please run \"sudo zypper install $tool\"" + exit 1 + fi +done + +# check if osc is authenticated +osc_user=$(osc user | sed "s/^\([^:]*\):.*$/\\1/") +if [ -z "$osc_user" ]; then + echo "ERROR: Cannot read the osc user, please configure osc" + exit 1 +fi + +# check if gh is authenticated +if ! gh auth status --active > /dev/null 2>&1; then + echo "ERROR: Not logged into a GitHub account" + echo "Run \"gh auth login\" to log into your GitHub account" + exit 1 +fi + +# git branch from the command line or the current git branch +BRANCH=${branch-$(git rev-parse --abbrev-ref HEAD)} +echo "Git branch: $BRANCH" + +repo_slug=$(gh repo view --json nameWithOwner -q ".nameWithOwner") + +# is this repository a GitHub fork? +if [ "$repo_slug" = "agama-project/agama" ]; then + if [ -z "$PROJECT" ]; then + if [ "$BRANCH" = "master" ]; then + PROJECT="systemsmanagement:Agama:Devel" + else + PROJECT="systemsmanagement:Agama:branches:${BRANCH}" + fi + fi +else + echo "GitHub fork detected" + + # check if OBS_USER and OBS_PASSWORD are defined in a fork + gh_obs_user=$(gh -R "$repo_slug" variable get OBS_USER 2> /dev/null) + if [ -z "$gh_obs_user" ]; then + echo "ERROR: OBS_USER variable is not defined in the GitHub configuration" + echo "Run this command to configure your OBS user name:" + echo " gh -R \"$repo_slug\" variable set OBS_USER --body \"$osc_user\"" + exit 1 + fi + + if ! gh -R "$repo_slug" secret list 2> /dev/null | grep -q OBS_PASSWORD; then + echo "ERROR: OBS password is not defined in the GitHub configuration" + echo "Run this command to configure your OBS password:" + echo " gh -R \"$repo_slug\" secret set OBS_PASSWORD" + exit 1 + fi + + if [ -z "$PROJECT" ]; then + PROJECT="home:${osc_user}:Agama:branches:${BRANCH}" + fi +fi + +echo "OBS project: $PROJECT" +echo + +# check if the project already exists +if osc ls "$PROJECT" > /dev/null 2>&1; then + echo "Project $PROJECT already exists, not branching" +else + echo "Creating project $PROJECT..." + # packages to branch + packages=(agama agama-installer agama-auto agama-products agama-web-ui rubygem-agama-yast) + for pkg in "${packages[@]}"; do + echo "Branching package $pkg" + # branch the package + osc branch systemsmanagement:Agama:Devel "$pkg" "$PROJECT" > /dev/null + # detach branch so the package is not updated when the original package changes, + # this also avoids possible conflicts + osc detachbranch "$PROJECT" "$pkg" + done + + # disable building on aarch64, ppc64le, i586 and s390x, usually not needed + if [ "$ALL_ARCHS" != true ]; then + echo "Disabling build on aarch64, i586, ppc64le and s390x" + osc meta prj "$PROJECT" | \ + sed "/aarch64<\/arch>/d;/i586<\/arch>/d;/ppc64le<\/arch>/d;/s390x<\/arch>/d;" | \ + osc meta prj -F - "$PROJECT" + fi + + # disable Leap 16.0 target + if [ "$ALL_TARGETS" != true ]; then + echo "Disabling openSUSE Leap 16.0 build target" + osc meta prj "$PROJECT" | \ + sed 's####' | \ + osc meta prj -F - "$PROJECT" + fi + + # enable publishing of the built packages and images (delete the disabled publish section) + echo "Enable publishing of the build results" + osc meta prj "$PROJECT" | sed "/^\s*\s*$/,/^\s*<\/publish>\s*$/d" | \ + osc meta prj -F - "$PROJECT" + + echo "Set project description" + url=$(gh repo view --json url --jq .url) + osc meta prj "$PROJECT" | + sed -e "s#.*#$url/tree/$BRANCH#" \ + -e "s#.*#Agama from Git#" \ + -e "s#.*#This project contains the latest packages built from repository $repo_slug, branch \"$BRANCH\".#" | \ + osc meta prj -F - "$PROJECT" +fi + +# configure OBS_PROJECTS GitHub variable +projects=$(gh -R "$repo_slug" variable get OBS_PROJECTS 2> /dev/null) + +if [ -z "$projects" ]; then + # fallback to empty JSON if not defined yet + projects="{}" +fi + +# insert the mapping for the new branch +echo "$projects" | jq ". += { \"$BRANCH\" : \"$PROJECT\" } " | gh -R "$repo_slug" variable set OBS_PROJECTS + +# to really synchronize the GitHub content with OBS trigger the autosubmission jobs if the remote +# brach already exists or print the instructions for later +workflows=(obs-staging-autoinstallation.yml obs-staging-live.yml obs-staging-products.yml obs-staging-rust.yml obs-staging-service.yml obs-staging-web.yml) +if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null; then + for workflow in "${workflows[@]}"; do + echo "Starting GitHub Action $workflow..." + gh workflow run "$workflow" --ref "$BRANCH" + done +else + echo "After creating the remote branch trigger the submission actions on the web" + echo "or run these commands:" + echo + for workflow in "${workflows[@]}"; do + echo " gh workflow run \"$workflow\" --ref \"$BRANCH\"" + done +fi + +echo +echo "Git branch \"$BRANCH\" is now automatically submitted to OBS project \"$PROJECT\"" diff --git a/live/root/etc/systemd/system/agama-self-update.service b/live/root/etc/systemd/system/agama-self-update.service index 820cf16f10..8bea8f5ae9 100644 --- a/live/root/etc/systemd/system/agama-self-update.service +++ b/live/root/etc/systemd/system/agama-self-update.service @@ -23,7 +23,7 @@ ExecStartPost=dmesg --console-on TTYReset=yes TTYVHangup=yes StandardInput=tty -TimeoutSec=0 +TimeoutStartSec=infinity [Install] WantedBy=default.target diff --git a/live/root/etc/systemd/system/checkmedia.service b/live/root/etc/systemd/system/checkmedia.service index 25bb802e59..eb9dcf3d92 100644 --- a/live/root/etc/systemd/system/checkmedia.service +++ b/live/root/etc/systemd/system/checkmedia.service @@ -40,7 +40,7 @@ ExecStartPost=kill -SIGRTMIN+20 1 StandardInput=tty RemainAfterExit=true -TimeoutSec=0 +TimeoutStartSec=infinity [Install] WantedBy=default.target diff --git a/live/root/etc/systemd/system/live-password.service b/live/root/etc/systemd/system/live-password.service index 5b4280f65a..0f91391f68 100644 --- a/live/root/etc/systemd/system/live-password.service +++ b/live/root/etc/systemd/system/live-password.service @@ -42,7 +42,7 @@ ExecStartPost=kill -SIGRTMIN+20 1 StandardOutput=tty RemainAfterExit=true -TimeoutSec=0 +TimeoutStartSec=infinity [Install] WantedBy=default.target diff --git a/live/root/usr/bin/agama-issue-generator b/live/root/usr/bin/agama-issue-generator index f21adbc4ce..e536befca7 100755 --- a/live/root/usr/bin/agama-issue-generator +++ b/live/root/usr/bin/agama-issue-generator @@ -24,8 +24,9 @@ CERT_ISSUE=/run/issue.d/50-agama-ssl-certificate.issue # a helper function which generates the Agama welcome message displayed at the # console generate_welcome() { - # get the latest version of any Agama package - AGAMA_VERSION=$(rpm -qa | grep agama | xargs rpm -q --queryformat \ + # get the latest version of any Agama package (except the integration tests, it lives in a + # separate git repository and has different number of commits than the rest) + AGAMA_VERSION=$(rpm -qa | grep agama | grep -v agama-integration-tests | xargs rpm -q --queryformat \ "%{VERSION}\n" | sed -e "s/\\.devel/+/" -e 's/+0$//' | sort -V | tail -n 1) ISSUE=/run/issue.d/10-agama-welcome.issue diff --git a/live/root/usr/lib/dracut/modules.d/99agama-cmdline/agama-network-compat.sh b/live/root/usr/lib/dracut/modules.d/99agama-cmdline/agama-network-compat.sh index 8a54c182c4..88870c06e1 100755 --- a/live/root/usr/lib/dracut/modules.d/99agama-cmdline/agama-network-compat.sh +++ b/live/root/usr/lib/dracut/modules.d/99agama-cmdline/agama-network-compat.sh @@ -7,14 +7,18 @@ ifcfg_to_ip() { local ip - local v="${2}", - local interface="$1" local conf_path="/etc/cmdline.d/40-agama-network.conf" - set -- - while [ -n "$v" ]; do - set -- "$@" "${v%%,*}" - v=${v#*,} - done + if [ -n "$2" ]; then + local v="${2}", + local interface="$1" + set -- + while [ -n "$v" ]; do + set -- "$@" "${v%%,*}" + v=${v#*,} + done + else + local interface="*" + fi ### See https://en.opensuse.org/SDB:Linuxrc#Network_Config # ifcfg==[try,]dhcp*,[rfc2132,]OPTION1=value1,OPTION2=value2... @@ -83,6 +87,24 @@ ifcfg_to_ip() { return 0 } +parse_hostname() { + local hostname + + hostname=$(getarg hostname=) + + if [[ -n $hostname ]]; then + echo "${hostname}" >/etc/hostname + fi + + if ! getargbool 1 SetHostname=; then + mkdir -p /run/NetworkManager/conf.d + echo '[main]' >/run/NetworkManager/conf.d/10-agama-hostname.conf + echo 'hostname-mode=none' >>/run/NetworkManager/conf.d/10-agama-hostname.conf + fi + + return 0 +} + translate_ifcfg() { local i local vlan @@ -121,3 +143,4 @@ translate_ifcfg() { } translate_ifcfg +parse_hostname diff --git a/live/root/usr/lib/dracut/modules.d/99agama-cmdline/module-setup.sh b/live/root/usr/lib/dracut/modules.d/99agama-cmdline/module-setup.sh index 34671a3994..a25645c0f0 100755 --- a/live/root/usr/lib/dracut/modules.d/99agama-cmdline/module-setup.sh +++ b/live/root/usr/lib/dracut/modules.d/99agama-cmdline/module-setup.sh @@ -18,4 +18,5 @@ installkernel() { install() { inst_hook cmdline 99 "$moddir/agama-cmdline-conf.sh" inst_hook cmdline 99 "$moddir/agama-network-compat.sh" + inst_hook pre-pivot 99 "$moddir/save-agama-conf.sh" } diff --git a/live/root/usr/lib/dracut/modules.d/99agama-cmdline/save-agama-conf.sh b/live/root/usr/lib/dracut/modules.d/99agama-cmdline/save-agama-conf.sh new file mode 100644 index 0000000000..3e33f3b419 --- /dev/null +++ b/live/root/usr/lib/dracut/modules.d/99agama-cmdline/save-agama-conf.sh @@ -0,0 +1,9 @@ +#! /bin/sh + +[ -e /dracut-state.sh ] && . /dracut-state.sh + +. /lib/dracut-lib.sh + +if [ -e /etc/hostname ]; then + cp /etc/hostname "$NEWROOT/etc/hostname" +fi diff --git a/live/src/agama-installer.changes b/live/src/agama-installer.changes index fb77504a6b..dbda1eb122 100644 --- a/live/src/agama-installer.changes +++ b/live/src/agama-installer.changes @@ -1,3 +1,25 @@ +------------------------------------------------------------------- +Wed Mar 12 17:17:08 UTC 2025 - Knut Anderssen + +- (gh#agama-project/agama#2142) + - Allow to set the static hostname using the hostname kernel + cmdline argument. + - Allow to disable the set of the hostname via DHCP using the + SetHostname kernel cmdline argument. + +------------------------------------------------------------------- +Tue Mar 11 16:29:40 UTC 2025 - Ladislav Slezák + +- Bump the version to 13pre + +------------------------------------------------------------------- +Wed Mar 5 14:54:48 UTC 2025 - Ladislav Slezák + +- Decrease the libzypp timeout from the default 60 seconds to + 20 seconds (Agama now does automatic retry) + (gh#agama-project/agama#2117) +- Display proper Agama version in the console + ------------------------------------------------------------------- Wed Feb 26 11:50:17 UTC 2025 - Ladislav Slezák diff --git a/live/src/agama-installer.kiwi b/live/src/agama-installer.kiwi index b622607b9f..d660b8ae59 100644 --- a/live/src/agama-installer.kiwi +++ b/live/src/agama-installer.kiwi @@ -20,7 +20,7 @@ - 12.0.0 + 13pre.0.0 zypper en_US us diff --git a/live/src/config.sh b/live/src/config.sh index e19dce561d..cd795f244b 100644 --- a/live/src/config.sh +++ b/live/src/config.sh @@ -32,6 +32,9 @@ if stat -t /usr/lib/rpm/gnupg/keys/*.asc 2>/dev/null 1>/dev/null; then rpm --import /usr/lib/rpm/gnupg/keys/*.asc fi +# decrease the libzypp timeout to 20 seconds (the default is 60 seconds) +sed -i -e "s/^\s*#\s*download.connect_timeout\s*=\s*.*$/download.connect_timeout = 20/" /etc/zypp/zypp.conf + # activate services systemctl enable sshd.service systemctl enable NetworkManager.service diff --git a/products.d/slowroll.yaml b/products.d/slowroll.yaml index 421a8e271d..0b949469ec 100644 --- a/products.d/slowroll.yaml +++ b/products.d/slowroll.yaml @@ -79,6 +79,7 @@ security: patterns: null storage: + boot_strategy: BLS space_policy: delete volumes: - "/" diff --git a/products.d/tumbleweed.yaml b/products.d/tumbleweed.yaml index 13c76d47d3..9d3fdef59c 100644 --- a/products.d/tumbleweed.yaml +++ b/products.d/tumbleweed.yaml @@ -130,6 +130,7 @@ security: patterns: null storage: + boot_strategy: BLS space_policy: delete volumes: - "/" diff --git a/rust/agama-cli/src/commands.rs b/rust/agama-cli/src/commands.rs index 175fe63962..630767cbad 100644 --- a/rust/agama-cli/src/commands.rs +++ b/rust/agama-cli/src/commands.rs @@ -18,6 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use std::path::PathBuf; + use crate::auth::AuthCommands; use crate::config::ConfigCommands; use crate::logs::LogsCommands; @@ -105,6 +107,8 @@ pub enum Commands { Download { /// URL pointing to file for download url: String, + /// File name + destination: PathBuf, }, /// Finish the installation rebooting the system by default. /// diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs index dd5a43fddc..f25562c5db 100644 --- a/rust/agama-cli/src/lib.rs +++ b/rust/agama-cli/src/lib.rs @@ -19,6 +19,7 @@ // find current contact information at www.suse.com. use agama_lib::manager::FinishMethod; +use anyhow::Context; use clap::{Args, Parser}; mod auth; @@ -43,6 +44,9 @@ use logs::run as run_logs_cmd; use profile::run as run_profile_cmd; use progress::InstallerProgress; use questions::run as run_questions_cmd; +use std::fs; +use std::os::unix::fs::OpenOptionsExt; +use std::path::PathBuf; use std::{ collections::HashMap, process::{ExitCode, Termination}, @@ -198,6 +202,21 @@ async fn allowed_insecure_api(use_insecure: bool, api_url: String) -> Result Result<(), ServiceError> { + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .mode(0o400) + .open(path) + .context(format!("Cannot write the file '{}'", path.display()))?; + + match Transfer::get(&url, &mut file) { + Ok(()) => println!("File saved to {}", path.display()), + Err(e) => eprintln!("Could not retrieve the file: {e}"), + } + Ok(()) +} + pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { // somehow check whether we need to ask user for self-signed certificate acceptance let api_url = cli.opts.api.trim_end_matches('/').to_string(); @@ -238,7 +257,7 @@ pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { } Commands::Questions(subcommand) => run_questions_cmd(client, subcommand).await?, Commands::Logs(subcommand) => run_logs_cmd(client, subcommand).await?, - Commands::Download { url } => Transfer::get(&url, std::io::stdout())?, + Commands::Download { url, destination } => download_file(&url, &destination)?, Commands::Auth(subcommand) => { run_auth_cmd(client, subcommand).await?; } diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index c76be3147c..150f20324e 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -146,8 +146,8 @@ async fn import(url_string: String, dir: Option) -> anyhow::Result<()> fn pre_process_profile>(url_string: &str, path: P) -> anyhow::Result<()> { let work_dir = path.as_ref().parent().unwrap(); let tmp_profile_path = work_dir.join("profile.temp"); - let tmp_file = File::create(&tmp_profile_path)?; - Transfer::get(url_string, tmp_file)?; + let mut tmp_file = File::create(&tmp_profile_path)?; + Transfer::get(url_string, &mut tmp_file)?; match FileFormat::from_file(&tmp_profile_path)? { FileFormat::Jsonnet => { diff --git a/rust/agama-lib/share/examples/profile.jsonnet b/rust/agama-lib/share/examples/profile.jsonnet index 88b3624885..2834f6e520 100644 --- a/rust/agama-lib/share/examples/profile.jsonnet +++ b/rust/agama-lib/share/examples/profile.jsonnet @@ -45,7 +45,19 @@ local memory = agama.findByID(agama.lshw, 'memory').size; keyboard: 'us', }, storage: { - bootDevice: findBiggestDisk(agama.selectByClass(agama.lshw, 'disk')), + boot: { + configure: true, + device: "boot" + }, + drives: [ + { + search: findBiggestDisk(agama.selectByClass(agama.lshw, 'disk')), + alias: "boot" + } + ] + }, + hostname: { + static: 'agama', }, network: { connections: [ diff --git a/rust/agama-lib/share/examples/profile_tw.json b/rust/agama-lib/share/examples/profile_tw.json index e410ced3a1..47670afe7c 100644 --- a/rust/agama-lib/share/examples/profile_tw.json +++ b/rust/agama-lib/share/examples/profile_tw.json @@ -12,6 +12,11 @@ "id": "Tumbleweed" }, "storage": { + "drives": [ + { + "partitions": [{ "generate": "default" }] + } + ] }, "user": { "fullName": "Jane Doe", diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 82c62a4f2b..b0c74b610c 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -56,6 +56,20 @@ } } }, + "hostname": { + "title": "Hostname settings", + "type": "object", + "properties": { + "static": { + "title": "System static hostname.", + "type": "string" + }, + "transient": { + "title": "System transient hostname.", + "type": "string" + } + } + }, "software": { "title": "Software settings", "type": "object", @@ -67,6 +81,14 @@ "type": "string", "examples": ["minimal_base"] } + }, + "packages": { + "title": "List of packages to install", + "type": "array", + "items": { + "type": "string", + "examples": ["vim"] + } } } }, @@ -334,13 +356,30 @@ "items": { "title": "List of EAP methods used", "type": "string", - "enum": ["leap", "md5", "tls", "peap", "ttls", "pwd", "fast"] + "enum": [ + "leap", + "md5", + "tls", + "peap", + "ttls", + "pwd", + "fast" + ] } }, "phase2Auth": { "title": "Phase 2 inner auth method", "type": "string", - "enum": ["pap", "chap", "mschap", "mschapv2", "gtc", "otp", "md5", "tls"] + "enum": [ + "pap", + "chap", + "mschap", + "mschapv2", + "gtc", + "otp", + "md5", + "tls" + ] }, "identity": { "title": "Identity string, often for example the user's login name", diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index 6a6bfb6b8f..e1fcf25fda 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -100,7 +100,8 @@ "reuse": { "type": "boolean" }, "default": { "type": "boolean" }, "type": { "$ref": "#/$defs/filesystemType" }, - "snapshots": { "type": "boolean" } + "snapshots": { "type": "boolean" }, + "label": { "type": "string" } } }, "filesystemType": { diff --git a/rust/agama-lib/src/hostname.rs b/rust/agama-lib/src/hostname.rs new file mode 100644 index 0000000000..a749e2cea2 --- /dev/null +++ b/rust/agama-lib/src/hostname.rs @@ -0,0 +1,26 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements support for handling the hostname settings + +pub mod client; +pub mod http_client; +pub mod model; +pub mod store; diff --git a/rust/agama-lib/src/hostname/client.rs b/rust/agama-lib/src/hostname/client.rs new file mode 100644 index 0000000000..d2d5a2b23d --- /dev/null +++ b/rust/agama-lib/src/hostname/client.rs @@ -0,0 +1,75 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a client to access Hostnamed D-Bus API related to hostname management. + +use crate::{error::ServiceError, hostname::model::HostnameSettings, proxies::Hostname1Proxy}; + +/// Client to connect to org.freedesktop.hostname1 DBUS API on the system bus for Hostname management. +#[derive(Clone)] +pub struct HostnameClient<'a> { + hostname_proxy: Hostname1Proxy<'a>, +} + +impl<'a> HostnameClient<'a> { + pub async fn new() -> Result, ServiceError> { + let connection = zbus::Connection::system().await?; + let hostname_proxy = Hostname1Proxy::new(&connection).await?; + + Ok(Self { hostname_proxy }) + } + + pub async fn get_config(&self) -> Result { + let hostname = self.hostname_proxy.hostname().await?; + let static_hostname = self.hostname_proxy.static_hostname().await?; + + let settings = HostnameSettings { + hostname: Some(hostname), + static_hostname: Some(static_hostname), + ..Default::default() + }; + + Ok(settings) + } + + pub async fn set_config(&self, config: &HostnameSettings) -> Result<(), ServiceError> { + let settings = self.get_config().await?; + + // order is important as otherwise the transient hostname could not be set in case the + // static one is not empty + if let Some(config_static_hostname) = &config.static_hostname { + if settings.static_hostname != config.static_hostname { + self.hostname_proxy + .set_static_hostname(config_static_hostname.as_str(), false) + .await?; + } + } + + if let Some(config_hostname) = &config.hostname { + if settings.hostname != config.hostname { + self.hostname_proxy + .set_hostname(config_hostname.as_str(), false) + .await?; + } + } + + Ok(()) + } +} diff --git a/rust/agama-lib/src/hostname/http_client.rs b/rust/agama-lib/src/hostname/http_client.rs new file mode 100644 index 0000000000..ad0acfd39e --- /dev/null +++ b/rust/agama-lib/src/hostname/http_client.rs @@ -0,0 +1,43 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a client to access Agama's HTTP API related to Hostname management. + +use crate::base_http_client::BaseHTTPClient; +use crate::hostname::model::HostnameSettings; +use crate::ServiceError; + +pub struct HostnameHTTPClient { + client: BaseHTTPClient, +} + +impl HostnameHTTPClient { + pub fn new(base: BaseHTTPClient) -> Self { + Self { client: base } + } + + pub async fn get_config(&self) -> Result { + self.client.get("/hostname/config").await + } + + pub async fn set_config(&self, config: &HostnameSettings) -> Result<(), ServiceError> { + self.client.put_void("/hostname/config", config).await + } +} diff --git a/rust/agama-lib/src/hostname/model.rs b/rust/agama-lib/src/hostname/model.rs new file mode 100644 index 0000000000..01e2a6716f --- /dev/null +++ b/rust/agama-lib/src/hostname/model.rs @@ -0,0 +1,34 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a data model for Hostname configuration. + +use serde::{Deserialize, Serialize}; + +/// Represents a Hostname +#[derive(Clone, Debug, Serialize, Deserialize, Default, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct HostnameSettings { + #[serde(rename = "transient")] + pub hostname: Option, + // empty string means removing the static hostname + #[serde(rename = "static")] + pub static_hostname: Option, +} diff --git a/rust/agama-lib/src/hostname/store.rs b/rust/agama-lib/src/hostname/store.rs new file mode 100644 index 0000000000..93f18f4ca3 --- /dev/null +++ b/rust/agama-lib/src/hostname/store.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. + +//! Implements the store for the hostname settings. + +use crate::base_http_client::BaseHTTPClient; +use crate::error::ServiceError; + +use super::http_client::HostnameHTTPClient; +use super::model::HostnameSettings; + +/// Loads and stores the hostname settings from/to the HTTP service. +pub struct HostnameStore { + hostname_client: HostnameHTTPClient, +} + +impl HostnameStore { + pub fn new(client: BaseHTTPClient) -> Result { + Ok(Self { + hostname_client: HostnameHTTPClient::new(client), + }) + } + + pub async fn load(&self) -> Result { + self.hostname_client.get_config().await + } + + pub async fn store(&self, settings: &HostnameSettings) -> Result<(), ServiceError> { + self.hostname_client.set_config(settings).await + } +} diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index 77ebdf9a59..9595865331 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -22,6 +22,7 @@ //! //! This module implements the mechanisms to load and store the installation settings. use crate::bootloader::model::BootloaderSettings; +use crate::hostname::model::HostnameSettings; use crate::{ localization::LocalizationSettings, network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, software::SoftwareSettings, users::UserSettings, @@ -42,6 +43,8 @@ use std::path::Path; pub struct InstallSettings { #[serde(default)] pub bootloader: Option, + #[serde(default)] + pub hostname: Option, #[serde(default, flatten)] pub user: Option, #[serde(default)] diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 23c27abc0d..7600b0a9e6 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -47,6 +47,7 @@ pub mod auth; pub mod base_http_client; pub mod bootloader; pub mod error; +pub mod hostname; pub mod install_settings; pub mod jobs; pub mod localization; diff --git a/rust/agama-lib/src/product/http_client.rs b/rust/agama-lib/src/product/http_client.rs index 29a36f3b3a..6802b05c2c 100644 --- a/rust/agama-lib/src/product/http_client.rs +++ b/rust/agama-lib/src/product/http_client.rs @@ -56,6 +56,7 @@ impl ProductHTTPClient { let config = SoftwareConfig { product: Some(product_id.to_owned()), patterns: None, + packages: None, }; self.set_software(&config).await } diff --git a/rust/agama-lib/src/product/store.rs b/rust/agama-lib/src/product/store.rs index e077ced587..21b90d8d14 100644 --- a/rust/agama-lib/src/product/store.rs +++ b/rust/agama-lib/src/product/store.rs @@ -155,6 +155,7 @@ mod test { .body( r#"{ "patterns": {}, + "packages": [], "product": "" }"#, ); @@ -163,7 +164,7 @@ mod test { when.method(PUT) .path("/api/software/config") .header("content-type", "application/json") - .body(r#"{"patterns":null,"product":"Tumbleweed"}"#); + .body(r#"{"patterns":null,"packages":null,"product":"Tumbleweed"}"#); then.status(200); }); let manager_mock = server.mock(|when, then| { diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index 8b7452ca1e..8b6d049408 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -36,3 +36,6 @@ mod locale; pub use locale::LocaleMixinProxy; pub mod jobs; + +mod hostname1; +pub use hostname1::Hostname1Proxy; diff --git a/rust/agama-lib/src/proxies/hostname1.rs b/rust/agama-lib/src/proxies/hostname1.rs new file mode 100644 index 0000000000..5c4630d61f --- /dev/null +++ b/rust/agama-lib/src/proxies/hostname1.rs @@ -0,0 +1,155 @@ +//! # D-Bus interface proxy for: `org.freedesktop.hostname1` +//! +//! This code was generated by `zbus-xmlgen` `4.1.0` from D-Bus introspection data. +//! Source: `Interface '/org/freedesktop/hostname1' from service 'org.freedesktop.hostname1' on system bus`. +//! +//! You may prefer to adapt it, instead of using it verbatim. +//! +//! More information can be found in the [Writing a client proxy] section of the zbus +//! documentation. +//! +//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the +//! following zbus API can be used: +//! +//! * [`zbus::fdo::PeerProxy`] +//! * [`zbus::fdo::IntrospectableProxy`] +//! * [`zbus::fdo::PropertiesProxy`] +//! +//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. +//! +//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html +//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, +use zbus::proxy; +#[proxy( + interface = "org.freedesktop.hostname1", + default_service = "org.freedesktop.hostname1", + default_path = "/org/freedesktop/hostname1" +)] +pub trait Hostname1 { + /// Describe method + fn describe(&self) -> zbus::Result; + + /// GetHardwareSerial method + fn get_hardware_serial(&self) -> zbus::Result; + + /// GetProductUUID method + #[zbus(name = "GetProductUUID")] + fn get_product_uuid(&self, interactive: bool) -> zbus::Result>; + + /// SetChassis method + fn set_chassis(&self, chassis: &str, interactive: bool) -> zbus::Result<()>; + + /// SetDeployment method + fn set_deployment(&self, deployment: &str, interactive: bool) -> zbus::Result<()>; + + /// SetHostname method + fn set_hostname(&self, hostname: &str, interactive: bool) -> zbus::Result<()>; + + /// SetIconName method + fn set_icon_name(&self, icon: &str, interactive: bool) -> zbus::Result<()>; + + /// SetLocation method + fn set_location(&self, location: &str, interactive: bool) -> zbus::Result<()>; + + /// SetPrettyHostname method + fn set_pretty_hostname(&self, hostname: &str, interactive: bool) -> zbus::Result<()>; + + /// SetStaticHostname method + fn set_static_hostname(&self, hostname: &str, interactive: bool) -> zbus::Result<()>; + + /// BootID property + #[zbus(property, name = "BootID")] + fn boot_id(&self) -> zbus::Result>; + + /// Chassis property + #[zbus(property)] + fn chassis(&self) -> zbus::Result; + + /// DefaultHostname property + #[zbus(property)] + fn default_hostname(&self) -> zbus::Result; + + /// Deployment property + #[zbus(property)] + fn deployment(&self) -> zbus::Result; + + /// FirmwareDate property + #[zbus(property)] + fn firmware_date(&self) -> zbus::Result; + + /// FirmwareVendor property + #[zbus(property)] + fn firmware_vendor(&self) -> zbus::Result; + + /// FirmwareVersion property + #[zbus(property)] + fn firmware_version(&self) -> zbus::Result; + + /// HardwareModel property + #[zbus(property)] + fn hardware_model(&self) -> zbus::Result; + + /// HardwareVendor property + #[zbus(property)] + fn hardware_vendor(&self) -> zbus::Result; + + /// HomeURL property + #[zbus(property, name = "HomeURL")] + fn home_url(&self) -> zbus::Result; + + /// Hostname property + #[zbus(property)] + fn hostname(&self) -> zbus::Result; + + /// HostnameSource property + #[zbus(property)] + fn hostname_source(&self) -> zbus::Result; + + /// IconName property + #[zbus(property)] + fn icon_name(&self) -> zbus::Result; + + /// KernelName property + #[zbus(property)] + fn kernel_name(&self) -> zbus::Result; + + /// KernelRelease property + #[zbus(property)] + fn kernel_release(&self) -> zbus::Result; + + /// KernelVersion property + #[zbus(property)] + fn kernel_version(&self) -> zbus::Result; + + /// Location property + #[zbus(property)] + fn location(&self) -> zbus::Result; + + /// MachineID property + #[zbus(property, name = "MachineID")] + fn machine_id(&self) -> zbus::Result>; + + /// OperatingSystemCPEName property + #[zbus(property, name = "OperatingSystemCPEName")] + fn operating_system_cpename(&self) -> zbus::Result; + + /// OperatingSystemPrettyName property + #[zbus(property)] + fn operating_system_pretty_name(&self) -> zbus::Result; + + /// OperatingSystemSupportEnd property + #[zbus(property)] + fn operating_system_support_end(&self) -> zbus::Result; + + /// PrettyHostname property + #[zbus(property)] + fn pretty_hostname(&self) -> zbus::Result; + + /// StaticHostname property + #[zbus(property)] + fn static_hostname(&self) -> zbus::Result; + + /// VSockCID property + #[zbus(property, name = "VSockCID")] + fn vsock_cid(&self) -> zbus::Result; +} diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index 80bc2d2569..7efb1bf987 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -65,7 +65,7 @@ impl BaseScript { match &self.source { ScriptSource::Text { body } => write!(file, "{}", &body)?, - ScriptSource::Remote { url } => Transfer::get(url, file)?, + ScriptSource::Remote { url } => Transfer::get(url, &mut file)?, }; Ok(()) diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index f868235a93..9ad2d0dc79 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -28,6 +28,8 @@ use serde_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)] @@ -189,6 +191,28 @@ impl<'a> SoftwareClient<'a> { } } + /// 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. @@ -219,4 +243,22 @@ impl<'a> SoftwareClient<'a> { .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) + } } diff --git a/rust/agama-lib/src/software/http_client.rs b/rust/agama-lib/src/software/http_client.rs index 21877e9b84..de366e618f 100644 --- a/rust/agama-lib/src/software/http_client.rs +++ b/rust/agama-lib/src/software/http_client.rs @@ -73,6 +73,7 @@ impl SoftwareHTTPClient { product: None, // TODO: SoftwareStore only passes true bools, false branch is untested patterns: Some(patterns), + packages: None, }; self.set_config(&config).await } diff --git a/rust/agama-lib/src/software/model/packages.rs b/rust/agama-lib/src/software/model/packages.rs index f402c292a2..ea1bc007da 100644 --- a/rust/agama-lib/src/software/model/packages.rs +++ b/rust/agama-lib/src/software/model/packages.rs @@ -26,6 +26,8 @@ use std::collections::HashMap; 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, } diff --git a/rust/agama-lib/src/software/settings.rs b/rust/agama-lib/src/software/settings.rs index d872f65e87..c73a0da667 100644 --- a/rust/agama-lib/src/software/settings.rs +++ b/rust/agama-lib/src/software/settings.rs @@ -26,6 +26,10 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SoftwareSettings { - /// List of patterns to install. If empty use default. - pub patterns: Vec, + /// 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>, } diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs index dbe4c6f3fa..e139677437 100644 --- a/rust/agama-lib/src/software/store.rs +++ b/rust/agama-lib/src/software/store.rs @@ -22,6 +22,7 @@ use std::collections::HashMap; +use super::model::SoftwareConfig; use super::{SoftwareHTTPClient, SoftwareSettings}; use crate::base_http_client::BaseHTTPClient; use crate::error::ServiceError; @@ -40,16 +41,31 @@ impl SoftwareStore { pub async fn load(&self) -> Result { let patterns = self.software_client.user_selected_patterns().await?; - Ok(SoftwareSettings { patterns }) + // 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(patterns) + }, + packages: config.packages, + }) } pub async fn store(&self, settings: &SoftwareSettings) -> Result<(), ServiceError> { - let patterns: HashMap = settings + let patterns: Option> = settings .patterns - .iter() - .map(|name| (name.to_owned(), true)) - .collect(); - self.software_client.select_patterns(patterns).await?; + .clone() + .map(|pat| pat.iter().map(|n| (n.to_owned(), true)).collect()); + + let config = SoftwareConfig { + // do not change the product + product: None, + patterns, + packages: settings.packages.clone(), + }; + self.software_client.set_config(&config).await?; Ok(()) } @@ -82,6 +98,7 @@ mod test { .body( r#"{ "patterns": {"xfce":true}, + "packages": ["vim"], "product": "Tumbleweed" }"#, ); @@ -92,13 +109,15 @@ mod test { let settings = store.load().await?; let expected = SoftwareSettings { - patterns: vec!["xfce".to_owned()], + patterns: Some(vec!["xfce".to_owned()]), + packages: Some(vec!["vim".to_owned()]), }; // 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(); + software_mock.assert_hits(2); Ok(()) } @@ -109,14 +128,15 @@ mod test { when.method(PUT) .path("/api/software/config") .header("content-type", "application/json") - .body(r#"{"patterns":{"xfce":true},"product":null}"#); + .body(r#"{"patterns":{"xfce":true},"packages":["vim"],"product":null}"#); then.status(200); }); let url = server.url("/api"); let store = software_store(url); let settings = SoftwareSettings { - patterns: vec!["xfce".to_owned()], + patterns: Some(vec!["xfce".to_owned()]), + packages: Some(vec!["vim".to_owned()]), }; let result = store.store(&settings).await; @@ -136,7 +156,7 @@ mod test { when.method(PUT) .path("/api/software/config") .header("content-type", "application/json") - .body(r#"{"patterns":{"no_such_pattern":true},"product":null}"#); + .body(r#"{"patterns":{"no_such_pattern":true},"packages":["vim"],"product":null}"#); then.status(400) .body(r#"'{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}"#); }); @@ -144,7 +164,8 @@ mod test { let store = software_store(url); let settings = SoftwareSettings { - patterns: vec!["no_such_pattern".to_owned()], + patterns: Some(vec!["no_such_pattern".to_owned()]), + packages: Some(vec!["vim".to_owned()]), }; let result = store.store(&settings).await; diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 345664bdb8..a2a33f9357 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -24,6 +24,7 @@ use crate::base_http_client::BaseHTTPClient; use crate::bootloader::store::BootloaderStore; use crate::error::ServiceError; +use crate::hostname::store::HostnameStore; use crate::install_settings::InstallSettings; use crate::manager::{InstallationPhase, ManagerHTTPClient}; use crate::scripts::{ScriptsClient, ScriptsGroup}; @@ -40,6 +41,7 @@ use crate::{ /// This struct uses the default connection built by [connection function](super::connection). pub struct Store { bootloader: BootloaderStore, + hostname: HostnameStore, users: UsersStore, network: NetworkStore, product: ProductStore, @@ -55,6 +57,7 @@ impl Store { pub async fn new(http_client: BaseHTTPClient) -> Result { Ok(Self { bootloader: BootloaderStore::new(http_client.clone())?, + hostname: HostnameStore::new(http_client.clone())?, localization: LocalizationStore::new(http_client.clone())?, users: UsersStore::new(http_client.clone())?, network: NetworkStore::new(http_client.clone()).await?, @@ -71,6 +74,7 @@ impl Store { pub async fn load(&self) -> Result { let mut settings = InstallSettings { bootloader: Some(self.bootloader.load().await?), + hostname: Some(self.hostname.load().await?), network: Some(self.network.load().await?), software: Some(self.software.load().await?), user: Some(self.users.load().await?), @@ -131,6 +135,9 @@ impl Store { if let Some(bootloader) = &settings.bootloader { self.bootloader.store(bootloader).await?; } + if let Some(hostname) = &settings.hostname { + self.hostname.store(hostname).await?; + } Ok(()) } diff --git a/rust/agama-lib/src/utils/transfer.rs b/rust/agama-lib/src/utils/transfer.rs index a9c9475387..b60d4db2bb 100644 --- a/rust/agama-lib/src/utils/transfer.rs +++ b/rust/agama-lib/src/utils/transfer.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -20,21 +20,61 @@ //! File transfer API for Agama. //! -//! Implement a file transfer API which, in the future, will support Agama specific URLs. Check the +//! Implement a file transfer API which, at this point, partially supports Agama specific URLs. Check the //! YaST document about [URL handling in the //! installer](https://github.com/yast/yast-installation/blob/master/doc/url.md) for further //! information. //! -//! At this point, it only supports those schemes supported by CURL. +//! This API supports the following URLs from YaST: `device:`, `usb:`, `label:`, ! `hd:`, `dvd:` and +//! `cd:`. The support for well-known URLs (e.g., `file:`, `http:`, `https:`, ! `ftp:`, `nfs:`, +//! etc.) is implemented using CURL. +//! +//! Support for `relurl:` and `repo:` are still missing. +//! +//! ## SSL +//! +//! YaST support for HTTPS used a custom certificate which was located in +//! `/etc/sssl/clientcerts/client-cert.pem`. Agama does not use such a certificate and it only +//! relies on those that are installed in the installation media. +//! +//! ## Examples +//! Requires working localectl. +//! +//! ```no_run +//! use agama_lib::utils::Transfer; +//! Transfer::get("label://OEMDRV/autoinst.xml", &mut std::io::stdout()).unwrap(); +//! ```` use std::io::Write; -use curl::easy::Easy; use thiserror::Error; +use url::Url; + +mod file_finder; +mod file_systems; +mod handlers; + +use handlers::{DeviceHandler, GenericHandler, HdHandler, LabelHandler}; #[derive(Error, Debug)] -#[error(transparent)] -pub struct TransferError(#[from] curl::Error); +pub enum TransferError { + #[error("Could not retrieve the file: {0}")] + CurlError(#[from] curl::Error), + #[error("Could not parse the URL: {0}")] + ParseError(#[from] url::ParseError), + #[error("File not found: {0}")] + FileNotFound(String), + #[error("IO error: {0}")] + IO(#[from] std::io::Error), + #[error("Could not mount the file system {0}")] + FileSystemMount(String), + #[error("Missing file path: {0}")] + MissingPath(Url), + #[error("Missing device: {0}")] + MissingDevice(Url), + #[error("Missing file system label: {0}")] + MissingLabel(Url), +} pub type TransferResult = Result; /// File transfer API @@ -45,15 +85,13 @@ impl Transfer { /// /// * `url`: URL to get the data from. /// * `out_fd`: where to write the data. - pub fn get(url: &str, mut out_fd: impl Write) -> TransferResult<()> { - let mut handle = Easy::new(); - handle.follow_location(true)?; - handle.fail_on_error(true)?; - handle.url(url)?; - - let mut transfer = handle.transfer(); - transfer.write_function(|buf| Ok(out_fd.write(buf).unwrap()))?; - transfer.perform()?; - Ok(()) + pub fn get(url: &str, out_fd: &mut impl Write) -> TransferResult<()> { + let url = Url::parse(url)?; + match url.scheme() { + "device" | "usb" => DeviceHandler::default().get(url, out_fd), + "label" => LabelHandler::default().get(url, out_fd), + "cd" | "dvd" | "hd" => HdHandler::default().get(url, out_fd), + _ => GenericHandler::default().get(url, out_fd), + } } } diff --git a/rust/agama-lib/src/utils/transfer/file_finder.rs b/rust/agama-lib/src/utils/transfer/file_finder.rs new file mode 100644 index 0000000000..081266cb30 --- /dev/null +++ b/rust/agama-lib/src/utils/transfer/file_finder.rs @@ -0,0 +1,81 @@ +// 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::{ + io::Write, + path::{Path, PathBuf}, +}; + +use super::{ + file_systems::{FileSystem, FileSystemsList}, + TransferError, TransferResult, +}; + +/// Finds a file in a set of file systems and copies its content. +#[derive(Default)] +pub struct FileFinder {} + +impl FileFinder { + /// Searchs for a file in the given file systems and copies its content to the given writer. + /// + /// * `file_systems`: file systems to search in. + /// * `file_name`: file name. + /// * `writer`: where to write the contents. + pub fn copy_from_file_systems( + &self, + file_systems: &FileSystemsList, + file_name: &str, + writer: &mut impl Write, + ) -> TransferResult<()> { + for fs in file_systems.to_vec().iter() { + if self.copy_from_file_system(fs, &file_name, writer).is_ok() { + return Ok(()); + } + } + Err(TransferError::FileNotFound(file_name.to_string())) + } + + /// Copies the file from the file system to the given writer. + /// + /// * `file_systems`: file systems to search in. + /// * `file_name`: file name. + /// * `writer`: where to write the contents. + pub fn copy_from_file_system( + &self, + file_system: &FileSystem, + file_name: &str, + writer: &mut impl Write, + ) -> TransferResult<()> { + println!("Searching {} in {}", &file_name, &file_system.block_device); + + file_system.ensure_mounted(|mount_point: &PathBuf| { + let file_name = file_name.strip_prefix("/").unwrap_or(file_name); + let source = mount_point.join(&file_name); + Self::copy_file(source, writer) + }) + } + + /// Reads and write the file content to the given writer. + fn copy_file>(source: P, out_fd: &mut impl Write) -> TransferResult<()> { + let mut reader = std::fs::File::open(source)?; + std::io::copy(&mut reader, out_fd)?; + Ok(()) + } +} diff --git a/rust/agama-lib/src/utils/transfer/file_systems.rs b/rust/agama-lib/src/utils/transfer/file_systems.rs new file mode 100644 index 0000000000..6c70771132 --- /dev/null +++ b/rust/agama-lib/src/utils/transfer/file_systems.rs @@ -0,0 +1,352 @@ +// 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. + +/// Module to search for file systems. +use std::{path::PathBuf, process::Command}; + +use regex::Regex; + +use super::{TransferError, TransferResult}; + +/// Represents a file system from the underlying system. +/// +/// It only includes the elements that are relevant for the transfer API. +#[derive(Clone, Debug, Default)] +pub struct FileSystem { + pub block_device: String, + pub fstype: Option, + pub mount_point: Option, + pub transport: Option, + pub label: Option, +} + +impl FileSystem { + /// Whether the file system was mounted. + pub fn is_mounted(&self) -> bool { + self.mount_point.is_some() + } + + /// Kernel name of the block device containing the file system. + pub fn device(&self) -> String { + format!("/dev/{}", &self.block_device) + } + + /// Mounts the file system and runs the given function. + /// + /// It does not try to mount the file system if it is already mounted. + /// + /// * `func`: function to run. It receives the mount point. + /// + /// TODO: TransferResult and TransferError should not be visible from this + /// struct. + pub fn ensure_mounted(&self, func: F) -> TransferResult<()> + where + F: FnOnce(&PathBuf) -> TransferResult<()>, + { + const DEFAULT_MOUNT_PATH: &str = "/run/agama/mount"; + let default_mount_point = PathBuf::from(DEFAULT_MOUNT_PATH); + let mount_point = self + .mount_point + .clone() + .unwrap_or_else(|| default_mount_point); + + if !self.is_mounted() { + self.mount(&mount_point).unwrap(); + } + let result = func(&mount_point); + if !self.is_mounted() { + self.umount(&mount_point).unwrap(); + } + + result + } + + /// Whether the file system can be mounted. + /// + /// File systems that cannot be mounted are ignored. + fn can_be_mounted(&self) -> bool { + let Some(fstype) = &self.fstype else { + return false; + }; + + match fstype.as_str() { + "" | "crypto_LUKS" | "swap" => false, + _ => true, + } + } + + /// Mounts file system from the given mount point. + fn mount(&self, mount_point: &PathBuf) -> TransferResult<()> { + std::fs::create_dir_all(mount_point)?; + let output = Command::new("mount") + .args([ + "-o", + "ro", + &self.device(), + &mount_point.display().to_string(), + ]) + .output()?; + if !output.status.success() { + return Err(TransferError::FileSystemMount(self.device())); + } + Ok(()) + } + + /// Umounts file system from the given mount point. + fn umount(&self, mount_point: &PathBuf) -> TransferResult<()> { + Command::new("umount") + .arg(mount_point.display().to_string()) + .output()?; + Ok(()) + } +} + +/// Holds a list of file systems. +/// +/// It offers a set of convenience method to search within the list. +#[derive(Debug, Default)] +pub struct FileSystemsList { + file_systems: Vec, +} + +impl FileSystemsList { + /// Creates a list of file systems. + pub fn new(file_systems: Vec) -> Self { + Self { file_systems } + } + + /// Creates a list for the file systems in the underlying system. + pub fn from_system() -> Self { + let file_systems = FileSystemsReader::read_from_system(); + Self::new(file_systems) + } + + pub fn to_vec(&self) -> Vec { + self.file_systems.clone() + } + + /// Returns the file system with the given block device name. + /// + /// * `name`: block device name. + pub fn find_by_name(&self, name: &str) -> Option<&FileSystem> { + self.file_systems.iter().find(|fs| name == &fs.block_device) + } + + /// Returns the file systems with the given label name. + /// + /// * `label`: device label. + pub fn with_label(&mut self, label: &str) -> Self { + let label = Some(label.to_string()); + let file_systems = self + .file_systems + .iter() + .filter(|fs| fs.label == label) + .cloned() + .collect(); + + FileSystemsList { file_systems } + } + + /// Returns the file systems using the given transport. + /// + /// * `transport`: transport of the device (e.g., "usb"). + pub fn with_transport(&mut self, transport: &str) -> Self { + let transport = Some(transport.to_string()); + let file_systems = self + .file_systems + .iter() + .filter(|fs| fs.transport == transport) + .cloned() + .collect(); + + FileSystemsList { file_systems } + } +} + +/// Implements the logic to read the file systems from the underlying system. +/// +/// This struct relies on lsblk to find the file systems. It is extracted to +/// a separate struct to make testing easier. +struct FileSystemsReader {} + +impl FileSystemsReader { + /// Returns the file systems from the underlying system. + pub fn read_from_system() -> Vec { + let lsblk = Command::new("lsblk") + .args([ + "--output", + "KNAME,FSTYPE,MOUNTPOINTS,TRAN,LABEL", + "--pairs", + "--path", + ]) + .output() + .unwrap(); + let output = String::from_utf8_lossy(&lsblk.stdout); + Self::read_from_string(&output) + } + + /// Turns the output of lsblk into a list of file systems. + pub fn read_from_string(lsblk_string: &str) -> Vec { + let mut file_systems = vec![]; + let mut parent_transport: Option = None; + let re = + Regex::new(r#"KNAME="(.+)" FSTYPE="(.*)" MOUNTPOINTS="(.*)" TRAN="(.*)" LABEL="(.*)""#) + .unwrap(); + + for (_, [block_device, fstype, mount_points, transport, label]) in + re.captures_iter(lsblk_string).map(|c| c.extract()) + { + // Use the shorter path as the canonical mount point. + let mount_point = if mount_points.is_empty() { + None + } else { + let mut mounts = mount_points.split("\\x0a").collect::>(); + mounts.sort_by(|a, b| a.len().cmp(&b.len())); + mounts.first().map(|m| PathBuf::from(m)) + }; + + let mut file_system = FileSystem { + block_device: block_device + .strip_prefix("/dev/") + .unwrap_or(block_device) + .to_string(), + fstype: if fstype.is_empty() { + None + } else { + Some(fstype.to_string()) + }, + mount_point, + transport: if transport.is_empty() { + None + } else { + Some(transport.to_string()) + }, + label: if label.is_empty() { + None + } else { + Some(label.to_string()) + }, + }; + if file_system.transport.is_none() { + file_system.transport = parent_transport.clone(); + } else { + parent_transport = file_system.transport.clone(); + } + if file_system.can_be_mounted() { + file_systems.push(file_system); + } + } + + file_systems + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{FileSystem, FileSystemsList, FileSystemsReader}; + + fn build_file_systems() -> Vec { + let vda1 = FileSystem { + block_device: "vda1".to_string(), + fstype: Some("ext4".to_string()), + mount_point: Some(PathBuf::from("/")), + ..Default::default() + }; + let vdb1 = FileSystem { + block_device: "vdb1".to_string(), + fstype: Some("xfs".to_string()), + mount_point: Some(PathBuf::from("/home")), + ..Default::default() + }; + let usb = FileSystem { + block_device: "sr0".to_string(), + fstype: Some("vfat".to_string()), + mount_point: None, + transport: Some("usb".to_string()), + label: Some("OEMDRV".to_string()), + ..Default::default() + }; + vec![vda1, vdb1, usb] + } + + #[test] + fn test_find_file_system_by_name() { + let file_systems = build_file_systems(); + let list = FileSystemsList::new(file_systems); + let vdb1 = list.find_by_name("vdb1").unwrap(); + assert_eq!(&vdb1.block_device, "vdb1"); + } + + #[test] + fn test_find_file_system_by_label() { + let file_systems = build_file_systems(); + let mut list = FileSystemsList::new(file_systems); + let found = list.with_label("OEMDRV").to_vec(); + let usb = found.first().unwrap(); + assert_eq!(&usb.block_device, "sr0"); + } + + #[test] + fn test_find_file_system_by_transport() { + let file_systems = build_file_systems(); + let mut list = FileSystemsList::new(file_systems); + let found = list.with_transport("usb").to_vec(); + let usb = found.first().unwrap(); + assert_eq!(&usb.block_device, "sr0"); + } + + #[test] + fn test_find_all() { + let file_systems = build_file_systems(); + let finder = FileSystemsList::new(file_systems); + let all = finder.to_vec(); + assert_eq!(all.len(), 3); + } + + #[test] + fn test_parse_file_systems() { + let lsblk = r#"KNAME="sda" FSTYPE="" MOUNTPOINT="" TRAN="usb" LABEL="" +KNAME="/dev/sda1" FSTYPE="iso9660" MOUNTPOINTS="/run/media/user/agama-installer" TRAN="" LABEL="agama-installer" +KNAME="/dev/sda2" FSTYPE="vfat" MOUNTPOINTS="" TRAN="" LABEL="BOOT" +KNAME="/dev/nvme0n1" FSTYPE="" MOUNTPOINTS="" TRAN="nvme" LABEL="" +KNAME="/dev/nvme0n1p1" FSTYPE="vfat" MOUNTPOINTS="/boot/efi" TRAN="nvme" LABEL="" +KNAME="/dev/nvme0n1p2" FSTYPE="crypto_LUKS" MOUNTPOINT="" TRAN="nvme" LABEL="" +KNAME="/dev/dm-0" FSTYPE="btrfs" MOUNTPOINTS="/home\x0a/\x0a/var" TRAN="" LABEL="" +KNAME="/dev/nvme0n1p3" FSTYPE="crypto_LUKS" MOUNTPOINTS="" TRAN="nvme" LABEL="" +KNAME="/dev/dm-1" FSTYPE="swap" MOUNTPOINTS="[SWAP]" TRAN="" LABEL="" +"#; + let file_systems = FileSystemsReader::read_from_string(&lsblk); + assert_eq!(file_systems.len(), 4); + + let dm0 = file_systems + .iter() + .find(|fs| &fs.block_device == "dm-0") + .unwrap(); + assert_eq!(dm0.mount_point.as_ref().unwrap(), &PathBuf::from("/")); + + let sda2 = file_systems + .iter() + .find(|fs| &fs.block_device == "sda2") + .unwrap(); + assert_eq!(sda2.mount_point, None); + } +} diff --git a/rust/agama-lib/src/utils/transfer/handlers.rs b/rust/agama-lib/src/utils/transfer/handlers.rs new file mode 100644 index 0000000000..2c62af111c --- /dev/null +++ b/rust/agama-lib/src/utils/transfer/handlers.rs @@ -0,0 +1,25 @@ +// 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. + +mod generic; +mod yast; + +pub use generic::GenericHandler; +pub use yast::{DeviceHandler, HdHandler, LabelHandler}; diff --git a/rust/agama-lib/src/utils/transfer/handlers/generic.rs b/rust/agama-lib/src/utils/transfer/handlers/generic.rs new file mode 100644 index 0000000000..b44f503e7b --- /dev/null +++ b/rust/agama-lib/src/utils/transfer/handlers/generic.rs @@ -0,0 +1,46 @@ +// 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::io::Write; + +use curl::easy::Easy; +use url::Url; + +use crate::utils::TransferResult; + +/// Generic handler to retrieve any URL. +/// +/// It uses curl under the hood. +#[derive(Default)] +pub struct GenericHandler {} + +impl GenericHandler { + pub fn get(&self, url: Url, out_fd: &mut impl Write) -> TransferResult<()> { + let mut handle = Easy::new(); + handle.follow_location(true)?; + handle.fail_on_error(true)?; + handle.url(&url.to_string())?; + + let mut transfer = handle.transfer(); + transfer.write_function(|buf| Ok(out_fd.write(buf).unwrap()))?; + transfer.perform()?; + Ok(()) + } +} diff --git a/rust/agama-lib/src/utils/transfer/handlers/yast.rs b/rust/agama-lib/src/utils/transfer/handlers/yast.rs new file mode 100644 index 0000000000..f74b5b28b7 --- /dev/null +++ b/rust/agama-lib/src/utils/transfer/handlers/yast.rs @@ -0,0 +1,155 @@ +// 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::io::Write; + +use url::Url; + +use crate::utils::{ + transfer::{file_finder::FileFinder, file_systems::FileSystemsList}, + TransferError, TransferResult, +}; + +/// Handler for the cd:, dvd: and hd: schemes +/// +/// It converts those schemes to a regular DeviceHandler. +#[derive(Default)] +pub struct HdHandler {} + +impl HdHandler { + pub fn get(&self, url: Url, out_fd: &mut impl Write) -> TransferResult<()> { + let device = url.query_pairs().find(|(key, _value)| key == "devices"); + + let Some((_, device_name)) = device else { + return Err(TransferError::MissingDevice(url)); + }; + let device_name = device_name.strip_prefix("/dev/").unwrap_or(&device_name); + let device_url = format!("device://{}{}", &device_name, url.path()); + let device_url = Url::parse(&device_url)?; + + DeviceHandler::default().get(device_url, out_fd) + } +} + +/// Handler for the label: scheme +#[derive(Default)] +pub struct LabelHandler {} + +impl LabelHandler { + pub fn get(&self, url: Url, out_fd: &mut impl Write) -> TransferResult<()> { + let file_name = url.path(); + if file_name.is_empty() { + return Err(TransferError::MissingPath(url)); + } + + let Some(label) = url.host_str() else { + return Err(TransferError::MissingLabel(url)); + }; + + let file_systems = FileSystemsList::from_system().with_label(label); + FileFinder::default().copy_from_file_systems(&file_systems, &file_name, out_fd) + } +} + +/// Handler to process AutoYaST-like URLs of type "device" and "usb". +/// +/// * If the URL contains a "host", it is used as the device name. +/// * If the URL does not contain a "host", it searches in all +/// known file systems. +#[derive(Default)] +pub struct DeviceHandler {} + +impl DeviceHandler { + pub fn get(&self, url: Url, out_fd: &mut impl Write) -> TransferResult<()> { + if url.path().is_empty() { + return Err(TransferError::MissingPath(url)); + } + + let mut file_systems = FileSystemsList::from_system(); + + if url.scheme() == "usb" { + file_systems = file_systems.with_transport("usb"); + } + + if let Some(host) = url.host_str() { + self.get_by_partial_names( + &mut file_systems, + &format!("{}{}", host, url.path()), + out_fd, + ) + } else { + self.get_from_any_device(&mut file_systems, url.path(), out_fd) + } + } + + /// Gets a file trying to guess the name from the device and the file itself. + /// + /// Given a URL like `device://a/b/c/d.json`, it will try: + /// + /// * device `a` and file `b/c/d.json`, + /// * device `a/b` and file `c/d.json` + /// * and device `a/b/c` and file `d.json`. + /// + /// See https://github.com/yast/yast-installation/blob/master/src/lib/transfer/file_from_url.rb#L483 + /// + /// * `file_systems`: list of file systems to search. + /// * `full_path`: full path to decompose and search for. + /// * `out_fd`: file to write to + fn get_by_partial_names( + &self, + file_systems: &mut FileSystemsList, + full_path: &str, + out_fd: &mut impl Write, + ) -> TransferResult<()> { + let mut path = full_path.to_string(); + let mut dev = "".to_string(); + let finder = FileFinder::default(); + + while let Some((device_name, file_name)) = path.split_once('/') { + dev = format!("{}/{}", dev, device_name) + .trim_start_matches('/') + .to_string(); + if let Some(file_system) = file_systems.find_by_name(&dev) { + if finder + .copy_from_file_system(&file_system, file_name, out_fd) + .is_ok() + { + return Ok(()); + } + } + path = file_name.to_string(); + } + Err(TransferError::FileNotFound(full_path.to_string())) + } + + /// Try to search in all devices. + /// + /// * `file_systems`: list of file systems to search. + /// * `file_name`: full path to decompose and search for. + /// * `out_fd`: file to write to + fn get_from_any_device( + &self, + file_systems: &mut FileSystemsList, + file_name: &str, + out_fd: &mut impl Write, + ) -> TransferResult<()> { + FileFinder::default().copy_from_file_systems(&file_systems, &file_name, out_fd) + } +} diff --git a/rust/agama-server/src/bootloader/web.rs b/rust/agama-server/src/bootloader/web.rs index bcce7944f6..eb86437ad5 100644 --- a/rust/agama-server/src/bootloader/web.rs +++ b/rust/agama-server/src/bootloader/web.rs @@ -18,11 +18,11 @@ // 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 storage service. +//! This module implements the web API for the bootloader service. //! //! The module offers one public function: //! -//! * `storage_service` which returns the Axum service. +//! * `bootloader_service` which returns the Axum service. //! //! stream is not needed, as we do not need to emit signals (for NOW). @@ -39,7 +39,7 @@ struct BootloaderState<'a> { client: BootloaderClient<'a>, } -/// Sets up and returns the axum service for the storage module. +/// Sets up and returns the axum service for the bootloader module. pub async fn bootloader_service(dbus: zbus::Connection) -> Result { let client = BootloaderClient::new(dbus).await?; let state = BootloaderState { client }; @@ -65,7 +65,7 @@ pub async fn bootloader_service(dbus: zbus::Connection) -> Result>, ) -> Result, error::Error> { - // StorageSettings is just a wrapper over serde_json::value::RawValue + // BootloaderSettings is just a wrapper over serde_json::value::RawValue let settings = state.client.get_config().await?; Ok(Json(settings)) } diff --git a/rust/agama-server/src/hostname.rs b/rust/agama-server/src/hostname.rs new file mode 100644 index 0000000000..1202c7853c --- /dev/null +++ b/rust/agama-server/src/hostname.rs @@ -0,0 +1,21 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub mod web; diff --git a/rust/agama-server/src/hostname/web.rs b/rust/agama-server/src/hostname/web.rs new file mode 100644 index 0000000000..94b7e0c526 --- /dev/null +++ b/rust/agama-server/src/hostname/web.rs @@ -0,0 +1,93 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements the web API for the hostname service. +//! +//! The module offers one public function: +//! +//! * `hostname_service` which returns the Axum service. +//! +//! stream is not needed, as we do not need to emit signals (for NOW). + +use agama_lib::{ + error::ServiceError, + hostname::{client::HostnameClient, model::HostnameSettings}, +}; +use axum::{extract::State, routing::put, Json, Router}; + +use crate::error; + +#[derive(Clone)] +struct HostnameState<'a> { + client: HostnameClient<'a>, +} + +/// Sets up and returns the axum service for the hostname module. +pub async fn hostname_service() -> Result { + let client = HostnameClient::new().await?; + let state = HostnameState { client }; + let router = Router::new() + .route("/config", put(set_config).get(get_config)) + .with_state(state); + Ok(router) +} + +/// Returns the hostname configuration. +/// +/// * `state` : service state. +#[utoipa::path( + get, + path = "/config", + context_path = "/api/hostname", + operation_id = "get_hostname_config", + responses( + (status = 200, description = "hostname configuration", body = HostnameSettings), + (status = 400, description = "The D-Bus service could not perform the action") + ) +)] +async fn get_config( + State(state): State>, +) -> Result, error::Error> { + // HostnameSettings is just a wrapper over serde_json::value::RawValue + let settings = state.client.get_config().await?; + Ok(Json(settings)) +} + +/// Sets the hostname configuration. +/// +/// * `state`: service state. +/// * `config`: hostname configuration. +#[utoipa::path( + put, + path = "/config", + context_path = "/api/hostname", + operation_id = "set_hostname_config", + responses( + (status = 200, description = "Set the hostname configuration"), + (status = 400, description = "The D-Bus service could not perform the action") + ) +)] +async fn set_config( + State(state): State>, + Json(settings): Json, +) -> Result, error::Error> { + state.client.set_config(&settings).await?; + Ok(Json(())) +} diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 8ad2e6cb35..318e1ae0ac 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -22,6 +22,7 @@ pub mod bootloader; pub mod cert; pub mod dbus; pub mod error; +pub mod hostname; pub mod l10n; pub mod logs; pub mod manager; diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index 364307358b..da2199efca 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -251,10 +251,15 @@ async fn download_logs() -> impl IntoResponse { header::CONTENT_TYPE, HeaderValue::from_static("application/x-compressed-tar"), ); - headers.insert( - header::CONTENT_DISPOSITION, - HeaderValue::from_static("attachment; filename=\"agama-logs\""), - ); + if let Some(file_name) = path.file_name() { + let disposition = + format!("attachment; filename=\"{}\"", &file_name.to_string_lossy()); + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&disposition) + .unwrap_or_else(|_| HeaderValue::from_static("attachment")), + ); + } headers.insert( header::CONTENT_ENCODING, HeaderValue::from_static(logs::DEFAULT_COMPRESSION.1), diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 92d94e4bf2..7440ba0da9 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -381,6 +381,10 @@ async fn set_config( state.software.select_patterns(patterns).await?; } + if let Some(packages) = config.packages { + state.software.select_packages(packages).await?; + } + Ok(()) } @@ -411,8 +415,10 @@ async fn get_config(State(state): State>) -> Result String { + "Hostname HTTP API".to_string() + } + + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> Components { + ComponentsBuilder::new() + .schema_from::() + .build() + } +} diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 8ba93c327f..8de95ce77a 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,8 +1,44 @@ +------------------------------------------------------------------- +Fri Mar 14 12:32:42 UTC 2025 - Imobach Gonzalez Sosa + +- Allow selecting individual packages through a configuration file + (gh#agama-project/agama#2153). + +------------------------------------------------------------------- +Wed Mar 12 17:12:10 UTC 2025 - Knut Alejandro Anderssen González + +- Introduced the hostname model in order to start managing it + (gh#agama-project/agama#2118). + +------------------------------------------------------------------- +Wed Mar 12 13:09:52 UTC 2025 - Imobach Gonzalez Sosa + +- Set the extension in the disposition "filename" so Chrome uses + the correct name (gh#agama-project/agama#2141). + +------------------------------------------------------------------- +Mon Mar 10 12:13:19 UTC 2025 - José Iván López González + +- Package and install the storage model schema + (gh#agama-project/agama#2135). + +------------------------------------------------------------------- +Thu Mar 6 12:51:42 UTC 2025 - Imobach Gonzalez Sosa + +- Extend agama download to support most of YaST-like URLs + (device:, usb:, label:, cd:, dvd: and hd:) (gh#agama-project/agama#2118). + ------------------------------------------------------------------- Tue Mar 4 13:34:13 UTC 2025 - Martin Vidner - install and package also storage.schema.json (bsc#1238367) +------------------------------------------------------------------- +Wed Feb 26 06:52:52 UTC 2025 - José Iván López González + +- Extend storage model schema to support file system label (needed + for jsc#AGM-122 and bsc#1237165). + ------------------------------------------------------------------- Wed Feb 26 06:51:37 UTC 2025 - Imobach Gonzalez Sosa diff --git a/rust/package/agama.spec b/rust/package/agama.spec index 5aa84ef008..a749bb9fc1 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -150,6 +150,7 @@ install -D -p -m 644 %{_builddir}/agama/share/agama.pam $RPM_BUILD_ROOT%{_pam_ve install -D -d -m 0755 %{buildroot}%{_datadir}/agama-cli install -m 0644 %{_builddir}/agama/agama-lib/share/profile.schema.json %{buildroot}%{_datadir}/agama-cli install -m 0644 %{_builddir}/agama/agama-lib/share/storage.schema.json %{buildroot}%{_datadir}/agama-cli +install -m 0644 %{_builddir}/agama/agama-lib/share/storage.model.schema.json %{buildroot}%{_datadir}/agama-cli install -m 0644 %{_builddir}/agama/share/agama.libsonnet %{buildroot}%{_datadir}/agama-cli install --directory %{buildroot}%{_datadir}/dbus-1/agama-services install -m 0644 --target-directory=%{buildroot}%{_datadir}/dbus-1/agama-services %{_builddir}/agama/share/org.opensuse.Agama1.service @@ -219,6 +220,7 @@ echo $PATH %{_datadir}/agama-cli/agama.libsonnet %{_datadir}/agama-cli/profile.schema.json %{_datadir}/agama-cli/storage.schema.json +%{_datadir}/agama-cli/storage.model.schema.json %{_mandir}/man1/agama*1%{?ext_man} %files -n agama-cli-bash-completion diff --git a/rust/share/agama-scripts.service b/rust/share/agama-scripts.service index dcee0f99d9..8ab70a6fff 100644 --- a/rust/share/agama-scripts.service +++ b/rust/share/agama-scripts.service @@ -12,7 +12,7 @@ Environment=TERM=linux ExecStartPre=-/usr/bin/plymouth --hide-splash ExecStart=/usr/libexec/agama-scripts.sh RemainAfterExit=yes -TimeoutSec=0 +TimeoutStartSec=infinity [Install] WantedBy=default.target diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 9526a49409..8d96938768 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -5,9 +5,9 @@ mod tasks { use agama_cli::Cli; use agama_server::web::docs::{ - ApiDocBuilder, L10nApiDocBuilder, ManagerApiDocBuilder, MiscApiDocBuilder, - NetworkApiDocBuilder, QuestionsApiDocBuilder, ScriptsApiDocBuilder, SoftwareApiDocBuilder, - StorageApiDocBuilder, UsersApiDocBuilder, + ApiDocBuilder, HostnameApiDocBuilder, L10nApiDocBuilder, ManagerApiDocBuilder, + MiscApiDocBuilder, NetworkApiDocBuilder, QuestionsApiDocBuilder, ScriptsApiDocBuilder, + SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, }; use clap::CommandFactory; use clap_complete::aot; @@ -60,6 +60,7 @@ mod tasks { pub fn generate_openapi() -> std::io::Result<()> { let out_dir = create_output_dir("openapi")?; + write_openapi(HostnameApiDocBuilder {}, out_dir.join("hostname.json"))?; write_openapi(L10nApiDocBuilder {}, out_dir.join("l10n.json"))?; write_openapi(ManagerApiDocBuilder {}, out_dir.join("manager.json"))?; write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; diff --git a/service/lib/agama/autoyast/software_reader.rb b/service/lib/agama/autoyast/software_reader.rb index 213c7b4c48..c0699614f9 100755 --- a/service/lib/agama/autoyast/software_reader.rb +++ b/service/lib/agama/autoyast/software_reader.rb @@ -39,10 +39,13 @@ def initialize(profile) def read return {} if profile["software"].nil? - patterns = profile["software"].fetch_as_array("patterns") - return {} if patterns.empty? + software = {} - { "software" => { "patterns" => patterns } } + software["patterns"] = profile["software"].fetch_as_array("patterns") + software["packages"] = profile["software"].fetch_as_array("packages") + return {} if software.empty? + + { "software" => software } end private diff --git a/service/lib/agama/config.rb b/service/lib/agama/config.rb index 8c51602c4f..b36ec4c524 100644 --- a/service/lib/agama/config.rb +++ b/service/lib/agama/config.rb @@ -217,6 +217,13 @@ def lvm? data.dig("storage", "lvm") || false end + # Boot strategy for the product + # + # @return [String, nil] + def boot_strategy + data.dig("storage", "boot_strategy") + end + private def mandatory_path?(path) diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index d7128904be..a9b966b665 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -258,10 +258,6 @@ def collect_logs(path: nil) # @param method [HALT, POWEROFF, STOP, REBOOT] # @return [Boolean] def finish_installation(method) - logs = collect_logs(path: "/tmp/var/logs/") - - logger.info("Installation logs stored in #{logs}") - unless installation_phase.finish? logger.error "The installer has not finished correctly. Please check logs" return false diff --git a/service/lib/agama/software/callbacks/media.rb b/service/lib/agama/software/callbacks/media.rb index 8b847700a6..6c34e3a00b 100644 --- a/service/lib/agama/software/callbacks/media.rb +++ b/service/lib/agama/software/callbacks/media.rb @@ -23,6 +23,7 @@ require "yast" require "agama/question" require "agama/software/callbacks/base" +require "agama/software/repository" Yast.import "Pkg" Yast.import "URL" @@ -32,6 +33,12 @@ module Software module Callbacks # Callbacks related to media handling class Media < Base + def initialize(questions_client, logger) + super + # retry counter + self.attempt = 0 + end + # Register the callbacks def setup Yast::Pkg.CallbackMediaChange( @@ -41,13 +48,25 @@ def setup "boolean, list , integer)" ) ) + Yast::Pkg.CallbackStartProvide( + Yast::FunRef.new(method(:start_provide), "void (string, integer, boolean)") + ) + end + + # @param name [String] name of the package to download + # @param size [Integer] download size + # @param _remote [Boolean] true if the package is downloaded from a remote repository, + # false for local packages + def start_provide(name, size, _remote) + self.attempt = 1 + logger.info("Downloading #{name}, size: #{size}") end # Media change callback # # @return [String] # @see https://github.com/yast/yast-yast2/blob/19180445ab935a25edd4ae0243aa7a3bcd09c9de/library/packages/src/modules/PackageCallbacks.rb#L620 - # rubocop:disable Metrics/ParameterLists + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength def media_change(error_code, error, url, product, current, current_label, wanted, wanted_label, double_sided, devices, current_device) logger.debug( @@ -67,6 +86,18 @@ def media_change(error_code, error, url, product, current, current_label, wanted current_device) ) + # "IO" = IO error (scratched DVD or HW failure) + # "IO_SOFT" = network timeout + # in other cases automatic retry usually does not make much sense + if ["IO", "IO_SOFT"].include?(error_code) && attempt <= Repository::RETRY_COUNT + self.attempt += 1 + logger.info("Retry in #{Repository::RETRY_DELAY} seconds, attempt #{attempt}...") + sleep(Repository::RETRY_DELAY) + + # retry + return "" + end + question = Agama::Question.new( qclass: "software.package_error.medium_error", text: error, @@ -75,10 +106,19 @@ def media_change(error_code, error, url, product, current, current_label, wanted data: { "url" => url } ) questions_client.ask(question) do |question_client| - (question_client.answer == retry_label.to_sym) ? "" : "S" + if question_client.answer == retry_label.to_sym + self.attempt += 1 + "" + else + "S" + end end end - # rubocop:enable Metrics/ParameterLists + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength + + private + + attr_accessor :attempt end end end diff --git a/service/lib/agama/software/repository.rb b/service/lib/agama/software/repository.rb index e3ba791cfe..9c010ec6e1 100644 --- a/service/lib/agama/software/repository.rb +++ b/service/lib/agama/software/repository.rb @@ -31,11 +31,28 @@ module Software # # @see RepositoriesManager class Repository < Y2Packager::Repository + # delay before retrying (in seconds) + RETRY_DELAY = 5 + # number of automatic retries + RETRY_COUNT = 1 + # Probes a repository # # @return [Boolean] true if the repository can be read; false otherwise def probe - type = Yast::Pkg.RepositoryProbe(url.to_s, product_dir) + attempt = 1 + type = nil + + loop do + # on a timeout error the result is nil, retry automatically in that case, + # note: callbacks are disabled during repo probing call + type = Yast::Pkg.RepositoryProbe(url.to_s, product_dir) + break if !type.nil? || attempt > RETRY_COUNT + + sleep(RETRY_DELAY) + attempt += 1 + end + !!type && type != "NONE" end @@ -44,7 +61,17 @@ def loaded? end def refresh - @loaded = !!super + attempt = 1 + + loop do + @loaded = !!super + break if @loaded || attempt > RETRY_COUNT + + sleep(RETRY_DELAY) + attempt += 1 + end + + @loaded end end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb index 1f7f44636a..d20c533485 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -43,7 +43,8 @@ def conversions { reuse: model_json.dig(:filesystem, :reuse), path: model_json[:mountPath], - type: convert_type + type: convert_type, + label: model_json.dig(:filesystem, :label) } end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb index f0d0c6cab4..2652d885b1 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -41,7 +41,8 @@ def conversions reuse: config.reuse?, default: convert_default, type: convert_type, - snapshots: convert_snapshots + snapshots: convert_snapshots, + label: config.label } end diff --git a/service/lib/agama/storage/finisher.rb b/service/lib/agama/storage/finisher.rb index b651a035d8..e04e42fc95 100644 --- a/service/lib/agama/storage/finisher.rb +++ b/service/lib/agama/storage/finisher.rb @@ -28,8 +28,10 @@ require "agama/helpers" require "agama/http" require "abstract_method" +require "fileutils" Yast.import "Arch" +Yast.import "Installation" module Agama module Storage @@ -217,21 +219,31 @@ def label end def run - wfm_write("copy_logs_finish") - copy_agama_scripts + FileUtils.mkdir_p(logs_dir, mode: 0o700) + collect_logs + copy_scripts end private - def copy_agama_scripts + def copy_scripts return unless Dir.exist?(SCRIPTS_DIR) - Yast.import "Installation" - require "fileutils" - logs_dir = File.join(Yast::Installation.destdir, "var", "log", "agama-installation") - FileUtils.mkdir_p(logs_dir) FileUtils.cp_r(SCRIPTS_DIR, logs_dir) end + + def collect_logs + path = File.join(logs_dir, "logs") + Yast::Execute.locally( + "agama", "logs", "store", "--destination", path + ) + end + + def logs_dir + @logs_dir ||= File.join( + Yast::Installation.destdir, "var", "log", "agama-installation" + ) + end end # Executes post-installation scripts diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index cee30a8bef..6109bb19e2 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -22,6 +22,7 @@ require "yast" require "bootloader/proposal_client" require "y2storage/storage_manager" +require "y2storage/storage_env" require "y2storage/clients/inst_prepdisk" require "agama/storage/actions_generator" require "agama/storage/bootloader" @@ -119,6 +120,12 @@ def on_probe(&block) def probe(keep_config: false) start_progress_with_size(4) product_config.pick_product(software.selected_product) + + # Underlying yast-storage-ng has own mechanism for proposing boot strategies. + # However, we don't always want to use BLS when it proposes so. Currently + # we want to use BLS only for Tumbleweed / Slowroll + prohibit_bls_boot if !product_config.boot_strategy&.casecmp("BLS") + check_multipath progress.step(_("Activating storage devices")) { activate_devices } progress.step(_("Probing storage devices")) { probe_devices } @@ -214,6 +221,12 @@ def locale=(locale) # @return [Logger] attr_reader :logger + def prohibit_bls_boot + ENV["YAST_NO_BLS_BOOT"] = "1" + # avoiding problems with cached values + Y2Storage::StorageEnv.instance.reset_cache + end + # Issues are updated when the proposal is calculated def register_proposal_callbacks proposal.on_calculate { update_issues } diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index afacd75d60..1232f89b92 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,22 @@ +------------------------------------------------------------------- +Fri Mar 14 12:34:03 UTC 2025 - Imobach Gonzalez Sosa + +- Import the packages list from an AutoYaST profile + (gh#agama-project/agama#2153). + +------------------------------------------------------------------- +Wed Mar 12 00:44:33 UTC 2025 - Imobach Gonzalez Sosa + +- Copy Agama logs to the installed system (gh#agama/agama-project#2148). +- Set /var/log/agama-installation permissions to 0700 + (gh#agama/agama-project#2140). + +------------------------------------------------------------------- +Wed Mar 5 14:50:04 UTC 2025 - Ladislav Slezák + +- Automatically retry package download or repository refresh + before reporting an error (gh#agama-project/agama#2117) + ------------------------------------------------------------------- Wed Mar 5 11:33:39 UTC 2025 - Imobach Gonzalez Sosa @@ -10,12 +29,25 @@ Wed Mar 5 09:21:03 UTC 2025 - Imobach Gonzalez Sosa - Enable again the signature checking for dir:/// repositories (gh#agama-project/agama#2092). +------------------------------------------------------------------- +Wed Mar 5 08:09:28 UTC 2025 - Michal Filka + +- introduced boot_strategy into storage section of product + definition yaml file. It allows to control what boot strategy + will be proposed by storage. Currently works only for BLS. + ------------------------------------------------------------------- Fri Feb 28 13:03:11 UTC 2025 - Imobach Gonzalez Sosa - Temporarily disable signature checking for dir:// repositories (gh#agama-project/agama#2092). +------------------------------------------------------------------- +Wed Feb 26 06:52:45 UTC 2025 - José Iván López González + +- Add file system label to the config model (needed for jsc#AGM-122 + and bsc#1237165). + ------------------------------------------------------------------- Wed Feb 26 06:51:36 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/share/autoyast-compat.json b/service/share/autoyast-compat.json index 0e8ef83e7d..30ce0ffba9 100644 --- a/service/share/autoyast-compat.json +++ b/service/share/autoyast-compat.json @@ -397,7 +397,7 @@ { "key": "install_recommended", "support": "no" }, { "key": "instsource", "support": "no" }, { "key": "kernel", "support": "no" }, - { "key": "packages[]", "support": "planned" }, + { "key": "packages[]", "support": "yes", "agama": "software.packages[]" }, { "key": "post-packages[]", "support": "no" }, { "key": "patterns[]", "support": "yes", "agama": "software.patterns[]" }, { "key": "products[]", "support": "yes", "agama": "software.id" }, diff --git a/service/test/agama/autoyast/software_reader_test.rb b/service/test/agama/autoyast/software_reader_test.rb index e66397ac90..fec65a5d48 100644 --- a/service/test/agama/autoyast/software_reader_test.rb +++ b/service/test/agama/autoyast/software_reader_test.rb @@ -30,7 +30,8 @@ { "software" => { "products" => ["SLE"], - "patterns" => ["base", "gnome"] + "patterns" => ["base", "gnome"], + "packages" => ["vim"] } } end @@ -54,5 +55,12 @@ expect(patterns).to eq(["base", "gnome"]) end end + + context "when a list of packages is included" do + it "includes the list of patterns under 'software.packages'" do + patterns = subject.read.dig("software", "packages") + expect(patterns).to eq(["vim"]) + end + end end end diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index c119d72432..a8e621baf8 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -228,17 +228,11 @@ let(:method) { "reboot" } before do - allow(subject).to receive(:collect_logs) allow(subject).to receive(:iguana?).and_return(iguana) allow(subject.installation_phase).to receive(:finish?).and_return(finished) allow(logger).to receive(:error) end - it "collects the logs" do - expect(subject).to receive(:collect_logs) - subject.finish_installation(method) - end - context "when it is not in finish the phase" do it "logs the error and returns false" do expect(logger).to receive(:error).with(/not finished/) diff --git a/service/test/agama/software/callbacks/media_test.rb b/service/test/agama/software/callbacks/media_test.rb index fdeb4b68f6..23ede77b76 100644 --- a/service/test/agama/software/callbacks/media_test.rb +++ b/service/test/agama/software/callbacks/media_test.rb @@ -35,6 +35,9 @@ before do allow(questions_client).to receive(:ask).and_yield(question_client) allow(question_client).to receive(:answer).and_return(answer) + + # mock sleep() to speed up test + allow(subject).to receive(:sleep) end let(:question_client) { instance_double(Agama::DBus::Clients::Question) } @@ -60,5 +63,18 @@ expect(ret).to eq("S") end end + + context "when a timeout error occurs" do + # actually not used, just required by the global "before" + let(:answer) { nil } + + it "returns '' without asking" do + expect(questions_client).to_not receive(:ask) + ret = subject.media_change( + "IO_SOFT", "Timeout", "", "", 0, "", 0, "", true, [], 0 + ) + expect(ret).to eq("") + end + end end end diff --git a/service/test/agama/software/repository_test.rb b/service/test/agama/software/repository_test.rb index bf73df76bc..e2c105cb26 100644 --- a/service/test/agama/software/repository_test.rb +++ b/service/test/agama/software/repository_test.rb @@ -35,6 +35,9 @@ before do allow(Yast::Pkg).to receive(:RepositoryProbe).with(/example.net/, "/") .and_return(repo_type) + + # do not call real sleep to make the test faster + allow_any_instance_of(Agama::Software::Repository).to receive(:sleep) end context "if the repository can be read" do @@ -59,6 +62,41 @@ it "returns false" do expect(subject.probe).to eq(false) end + + it "retries probing automatically" do + expect(Yast::Pkg).to receive(:RepositoryProbe).at_least(2).times.and_return(nil) + subject.probe + end + end + end + + describe "#refresh" do + before do + allow(Yast::Pkg).to receive(:SourceRefreshNow).and_return(refresh_result) + + # do not call real sleep to make the test faster + allow(subject).to receive(:sleep) + end + + context "if the repository can be refreshed" do + let(:refresh_result) { true } + + it "returns true" do + expect(subject.refresh).to eq(true) + end + end + + context "if the repository cannot be refreshed" do + let(:refresh_result) { nil } + + it "returns false" do + expect(subject.refresh).to eq(false) + end + + it "retries refresh automatically" do + expect(Yast::Pkg).to receive(:SourceRefreshNow).at_least(2).times + subject.refresh + end end end end diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb index d6a0cfe58d..2810bb72f7 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -172,14 +172,23 @@ end shared_examples "with filesystem" do |config_proc| + let(:filesystem) do + { + reuse: reuse, + default: default, + type: type, + snapshots: true, + label: label + } + end + + let(:reuse) { false } + let(:default) { false } + let(:type) { nil } + let(:label) { "test" } + context "if the filesystem is default" do - let(:filesystem) do - { - default: true, - type: type, - snapshots: true - } - end + let(:default) { true } context "and the type is 'btrfs'" do let(:type) { "btrfs" } @@ -193,7 +202,7 @@ expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) expect(filesystem.type.btrfs.snapshots?).to eq(true) - expect(filesystem.label).to be_nil + expect(filesystem.label).to eq("test") expect(filesystem.path).to be_nil expect(filesystem.mount_by).to be_nil expect(filesystem.mkfs_options).to be_empty @@ -212,7 +221,7 @@ expect(filesystem.type.default?).to eq(true) expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) expect(filesystem.type.btrfs).to be_nil - expect(filesystem.label).to be_nil + expect(filesystem.label).to eq("test") expect(filesystem.path).to be_nil expect(filesystem.mount_by).to be_nil expect(filesystem.mkfs_options).to be_empty @@ -222,13 +231,7 @@ end context "if the filesystem is not default" do - let(:filesystem) do - { - default: false, - type: type, - snapshots: true - } - end + let(:default) { false } context "and the type is 'btrfs'" do let(:type) { "btrfs" } @@ -242,7 +245,7 @@ expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) expect(filesystem.type.btrfs.snapshots?).to eq(true) - expect(filesystem.label).to be_nil + expect(filesystem.label).to eq("test") expect(filesystem.path).to be_nil expect(filesystem.mount_by).to be_nil expect(filesystem.mkfs_options).to be_empty @@ -261,7 +264,7 @@ expect(filesystem.type.default?).to eq(false) expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) expect(filesystem.type.btrfs).to be_nil - expect(filesystem.label).to be_nil + expect(filesystem.label).to eq("test") expect(filesystem.path).to be_nil expect(filesystem.mount_by).to be_nil expect(filesystem.mkfs_options).to be_empty @@ -270,8 +273,27 @@ end end + context "if the filesystem specifies 'reuse'" do + let(:reuse) { true } + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(true) + expect(filesystem.type.default?).to eq(false) + expect(filesystem.type.fs_type).to be_nil + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to eq("test") + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end + context "if the filesystem does not specify 'type'" do - let(:filesystem) { { default: false } } + let(:type) { nil } it "sets #filesystem to the expected value" do config = config_proc.call(subject.convert) @@ -281,7 +303,7 @@ expect(filesystem.type.default?).to eq(false) expect(filesystem.type.fs_type).to be_nil expect(filesystem.type.btrfs).to be_nil - expect(filesystem.label).to be_nil + expect(filesystem.label).to eq("test") expect(filesystem.path).to be_nil expect(filesystem.mount_by).to be_nil expect(filesystem.mkfs_options).to eq([]) @@ -289,22 +311,22 @@ end end - context "if the filesystem specifies 'reuse'" do - let(:filesystem) { { reuse: true } } + context "if the filesystem does not specify 'label'" do + let(:label) { nil } it "sets #filesystem to the expected value" do config = config_proc.call(subject.convert) filesystem = config.filesystem expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) - expect(filesystem.reuse?).to eq(true) - expect(filesystem.type.default?).to eq(true) + expect(filesystem.reuse?).to eq(false) + expect(filesystem.type.default?).to eq(false) expect(filesystem.type.fs_type).to be_nil expect(filesystem.type.btrfs).to be_nil expect(filesystem.label).to be_nil expect(filesystem.path).to be_nil expect(filesystem.mount_by).to be_nil - expect(filesystem.mkfs_options).to be_empty - expect(filesystem.mount_options).to be_empty + expect(filesystem.mkfs_options).to eq([]) + expect(filesystem.mount_options).to eq([]) end end end @@ -316,7 +338,8 @@ { default: false, type: "btrfs", - snapshots: true + snapshots: true, + label: "test" } end @@ -329,7 +352,7 @@ expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) expect(filesystem.type.btrfs.snapshots?).to eq(true) - expect(filesystem.label).to be_nil + expect(filesystem.label).to eq("test") expect(filesystem.path).to eq("/test") expect(filesystem.mount_by).to be_nil expect(filesystem.mkfs_options).to be_empty diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index adf2b45e9a..927f60dbc0 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -103,7 +103,8 @@ { reuse: true, default: false, - type: "xfs" + type: "xfs", + label: "test" } ) end diff --git a/service/test/agama/storage/finisher_test.rb b/service/test/agama/storage/finisher_test.rb index 17b57a7f58..dcf14db305 100644 --- a/service/test/agama/storage/finisher_test.rb +++ b/service/test/agama/storage/finisher_test.rb @@ -141,3 +141,35 @@ include_examples "progress" end + +describe Agama::Storage::Finisher::CopyLogsStep do + let(:logger) { Logger.new($stdout, level: :warn) } + let(:scripts_dir) { File.join(tmp_dir, "run", "agama", "scripts") } + let(:tmp_dir) { Dir.mktmpdir } + + subject { Agama::Storage::Finisher::CopyLogsStep.new(logger) } + + before do + allow(Yast::Installation).to receive(:destdir).and_return(File.join(tmp_dir, "mnt")) + allow(Yast::Execute).to receive(:locally) + stub_const("Agama::Storage::Finisher::CopyLogsStep::SCRIPTS_DIR", + File.join(tmp_dir, "run", "agama", "scripts")) + end + + after do + FileUtils.remove_entry(tmp_dir) + end + + context "when scripts artifacts exist" do + before do + FileUtils.mkdir_p(scripts_dir) + FileUtils.touch(File.join(scripts_dir, "test.sh")) + end + + it "copies the artifacts to the installed system" do + subject.run + expect(File).to exist(File.join(tmp_dir, "mnt", "var", "log", "agama-installation", + "scripts")) + end + end +end diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index 9d0faf2b05..a278f6a314 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -218,6 +218,14 @@ let(:callback) { proc {} } + it "sets env YAST_NO_BLS_BOOT to yes if product doesn't requires bls boot explicitly" do + expect(config).to receive(:pick_product) + expect(config).to receive(:boot_strategy).and_return(nil) + expect(ENV).to receive(:[]=).with("YAST_NO_BLS_BOOT", "1") + + storage.probe + end + it "probes the storage devices and calculates a proposal" do expect(config).to receive(:pick_product).with("ALP") expect(iscsi).to receive(:activate) @@ -361,6 +369,7 @@ allow(File).to receive(:directory?).with("/iguana").and_return iguana allow(copy_files_class).to receive(:new).and_return(copy_files) allow(Yast::Execute).to receive(:on_target!) + allow(Yast::Execute).to receive(:local) end let(:copy_files_class) { Agama::Storage::Finisher::CopyFilesStep } let(:copy_files) { instance_double(copy_files_class, run?: true, run: true, label: "Copy") } @@ -378,8 +387,10 @@ expect(scripts_client).to receive(:run).with("post") expect(Yast::Execute).to receive(:on_target!) .with("systemctl", "enable", "agama-scripts", allowed_exitstatus: [0, 1]) - expect(Yast::WFM).to receive(:CallFunction).with("copy_logs_finish", ["Write"]) expect(Yast::WFM).to receive(:CallFunction).with("umount_finish", ["Write"]) + expect(Yast::Execute).to receive(:locally).with( + "agama", "logs", "store", "--destination", /\/var\/log\/agama-installation\/logs/ + ) storage.finish end diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 3458ad9460..8c1d32cd12 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,21 @@ +------------------------------------------------------------------- +Thu Mar 6 08:06:17 UTC 2025 - David Diaz + +- Fix a language selector overflow issue in the product license dialog + (gh#agama-project/agama#2105) + +------------------------------------------------------------------- +Thu Feb 27 16:10:51 UTC 2025 - Vaishnavi Nawghare + +- Add alpha-label-less leap16.svg + (gh#agama-project/agama#2091). + +------------------------------------------------------------------- +Thu Feb 27 11:21:45 UTC 2025 - José Iván López González + +- Allow setting the file system label (related to jsc#AGM-122 + and bsc#1237165). + ------------------------------------------------------------------- Thu Feb 27 10:21:45 UTC 2025 - José Iván López González diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/config-model.ts index fff51257ca..8a109fb952 100644 --- a/web/src/api/storage/types/config-model.ts +++ b/web/src/api/storage/types/config-model.ts @@ -64,6 +64,7 @@ export interface Filesystem { default: boolean; type?: FilesystemType; snapshots?: boolean; + label?: string; } export interface Partition { name?: string; diff --git a/web/src/assets/products/Leap16.svg b/web/src/assets/products/Leap16.svg index 2ee4a146bd..bb23cb86d0 100644 --- a/web/src/assets/products/Leap16.svg +++ b/web/src/assets/products/Leap16.svg @@ -1,17 +1,17 @@ + sodipodi:docname="Leap16.svg" + inkscape:version="1.4 (86a8ad7, 2024-10-11)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> @@ -35,16 +35,19 @@ inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1920" - inkscape:window-height="1011" + inkscape:window-height="991" id="namedview897" showgrid="false" inkscape:zoom="2.8710938" - inkscape:cx="128" + inkscape:cx="127.82585" inkscape:cy="128" - inkscape:window-x="0" - inkscape:window-y="32" + inkscape:window-x="-9" + inkscape:window-y="-9" inkscape:window-maximized="1" - inkscape:current-layer="svg895" /> + inkscape:current-layer="svg895" + inkscape:showpageshadow="2" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" /> - - Alpha diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 176648a1a8..c271ff2fb2 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -356,6 +356,11 @@ label.pf-m-disabled + .pf-v6-c-check__description { --pf-v6-c-form__group--m-action--MarginBlockStart: var(--pf-t--global--spacer--md); } +.pf-v6-c-list.pf-m-inline { + --pf-v6-c-list--m-inline--RowGap: var(--pf-t--global--spacer--sm); + --pf-v6-c-list--m-inline--ColumnGap: var(--pf-t--global--spacer--md); +} + // Some utilities not found at PF .w-14ch { inline-size: 14ch; diff --git a/web/src/components/core/Popup.test.tsx b/web/src/components/core/Popup.test.tsx index 8582066fce..36e063b38f 100644 --- a/web/src/components/core/Popup.test.tsx +++ b/web/src/components/core/Popup.test.tsx @@ -41,7 +41,7 @@ const TestingPopup = (props: PopupProps) => { return ( { describe("when it is not open", () => { beforeEach(() => { isOpen = false; - isLoading = false; }); it("renders nothing", async () => { @@ -72,56 +71,84 @@ describe("Popup", () => { }); }); - describe("when it is open and not loading", () => { + describe("when it is open", () => { beforeEach(() => { isOpen = true; - isLoading = false; }); - it("renders the popup content inside a PF/Modal", async () => { - installerRender(Testing); + it("renders given title and titleAddon inside PF/ModalHeader", async () => { + installerRender( + With action at title}> + Testing + , + ); const dialog = await screen.findByRole("dialog"); - expect(dialog.classList.contains("pf-v6-c-modal-box")).toBe(true); + const header = within(dialog).getByRole("banner"); + within(header).getByRole("heading", { name: "Awesome Popup" }); + within(header).getByRole("button", { name: "With action at title" }); + }); + + it("does not render header when none, title nor titleAddon, are giving", async () => { + installerRender( + + Testing + , + ); - within(dialog).getByText("The Popup Content"); + await screen.findByRole("dialog"); + expect(screen.queryByRole("banner")).toBeNull(); }); - it("does not display a progress message", async () => { - installerRender(Testing); + describe("and not loading", () => { + beforeEach(() => { + isLoading = false; + }); - const dialog = await screen.findByRole("dialog"); + it("renders the popup content inside a PF/Modal", async () => { + installerRender(Testing); - expect(within(dialog).queryByText(loadingText)).toBeNull(); - }); + const dialog = await screen.findByRole("dialog"); + expect(dialog.classList.contains("pf-v6-c-modal-box")).toBe(true); - it("renders the popup actions inside a PF/Modal footer", async () => { - installerRender(Testing); + within(dialog).getByText("The Popup Content"); + }); - const dialog = await screen.findByRole("dialog"); - // NOTE: Sadly, PF Modal/ModalFooter does not have a footer or navigation role. - // So, using https://developer.mozilla.org/es/docs/Web/API/Document/querySelector - // for getting the footer. See https://github.com/testing-library/react-testing-library/issues/417 too. - const footer = dialog.querySelector("footer"); + it("does not display a progress message", async () => { + installerRender(Testing); - within(footer).getByText("Confirm"); - within(footer).getByText("Cancel"); - }); - }); + const dialog = await screen.findByRole("dialog"); - describe("when it is open and loading", () => { - beforeEach(() => { - isOpen = true; - isLoading = true; + expect(within(dialog).queryByText(loadingText)).toBeNull(); + }); + + it("renders the popup actions inside a PF/Modal footer", async () => { + installerRender(Testing); + + const dialog = await screen.findByRole("dialog"); + // NOTE: Sadly, PF Modal/ModalFooter does not have a footer or navigation role. + // So, using https://developer.mozilla.org/es/docs/Web/API/Document/querySelector + // for getting the footer. See https://github.com/testing-library/react-testing-library/issues/417 too. + const footer = dialog.querySelector("footer"); + + within(footer).getByText("Confirm"); + within(footer).getByText("Cancel"); + }); }); - it("displays progress message instead of the content", async () => { - installerRender(Testing); + describe("and loading", () => { + beforeEach(() => { + isLoading = true; + }); - const dialog = await screen.findByRole("dialog"); + it("displays progress message instead of the content", async () => { + installerRender(Testing); + + const dialog = await screen.findByRole("dialog"); - expect(within(dialog).queryByText("The Popup Content")).toBeNull(); - within(dialog).getByText(loadingText); + expect(within(dialog).queryByText("The Popup Content")).toBeNull(); + within(dialog).getByText(loadingText); + }); }); }); }); diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx index fbeadd3044..242defe744 100644 --- a/web/src/components/core/Popup.tsx +++ b/web/src/components/core/Popup.tsx @@ -38,8 +38,10 @@ import { partition } from "~/utils"; type ButtonWithoutVariantProps = Omit; type PredefinedAction = React.PropsWithChildren; export type PopupProps = { - /** The dialog header */ + /** The dialog title */ title?: ModalHeaderProps["title"]; + /** Extra content to be placed in the header after the title */ + titleAddon?: React.ReactNode; /** The block/height size for the dialog. Default is "auto". */ blockSize?: "auto" | "small" | "medium" | "large"; /** The inline/width size for the dialog. Default is "medium". */ @@ -204,6 +206,7 @@ const AncillaryAction = ({ children, ...actionsProps }: PredefinedAction) => ( */ const Popup = ({ title, + titleAddon, titleIconVariant, description, isOpen = false, @@ -240,6 +243,7 @@ const Popup = ({ title={title} description={description} titleIconVariant={titleIconVariant} + help={titleAddon} /> )} {isLoading ? : content} diff --git a/web/src/components/product/LicenseDialog.test.tsx b/web/src/components/product/LicenseDialog.test.tsx index e23275efcd..85f78f9531 100644 --- a/web/src/components/product/LicenseDialog.test.tsx +++ b/web/src/components/product/LicenseDialog.test.tsx @@ -66,6 +66,14 @@ describe("LicenseDialog", () => { }); }); + it("renders change language button in the header but not as part of the h1 heading", async () => { + installerRender(); + const header = await screen.findByRole("banner"); + const heading = await within(header).findByRole("heading", { level: 1 }); + expect(heading).toHaveTextContent(sle.name); + within(header).getByRole("button", { name: "License language" }); + }); + it("requests license in the language selected by user", async () => { const { user } = installerRender(, { withL10n: true, @@ -75,7 +83,7 @@ describe("LicenseDialog", () => { await user.click(languageButton); expect(languageButton).toHaveAttribute("aria-expanded", "true"); // FIXME: the selector should not be hidden for the Accessiblity API - const languageFrenchOption = screen.getByRole("option", { name: "Français", hidden: true }); + const languageFrenchOption = screen.getByRole("menuitem", { name: "Français", hidden: true }); await user.click(languageFrenchOption); expect(mockFetchLicense).toHaveBeenCalledWith(sle.license, "fr-FR"); within(languageButton).getByText("Français"); diff --git a/web/src/components/product/LicenseDialog.tsx b/web/src/components/product/LicenseDialog.tsx index 49211d536d..d5855e2920 100644 --- a/web/src/components/product/LicenseDialog.tsx +++ b/web/src/components/product/LicenseDialog.tsx @@ -21,21 +21,20 @@ */ import React, { useEffect, useState } from "react"; -import { Popup } from "~/components/core"; -import { _ } from "~/i18n"; import { + Dropdown, + DropdownItem, + DropdownList, MenuToggle, ModalProps, - Select, - SelectOption, - Split, - SplitItem, Stack, } from "@patternfly/react-core"; +import { Popup } from "~/components/core"; import { Product } from "~/types/software"; import { fetchLicense } from "~/api/software"; import { useInstallerL10n } from "~/context/installerL10n"; import supportedLanguages from "~/languages.json"; +import { _ } from "~/i18n"; function LicenseDialog({ onClose, product }: { onClose: ModalProps["onClose"]; product: Product }) { const { language: uiLanguage } = useInstallerL10n(); @@ -64,30 +63,28 @@ function LicenseDialog({ onClose, product }: { onClose: ModalProps["onClose"]; p return ( - - -

{product.name}

-
- -
- + title={product.name} + titleAddon={ + setLanguageSelectorOpen(!isOpen)} + toggle={localesToggler} + isScrollable + popperProps={{ position: "right" }} + > + + {Object.entries(supportedLanguages).map(([id, name]) => ( + + {name} + + ))} + + } + width="auto" >
{license}
diff --git a/web/src/components/questions/UnsupportedAutoYaST.tsx b/web/src/components/questions/UnsupportedAutoYaST.tsx index b162137f26..6136b27d64 100644 --- a/web/src/components/questions/UnsupportedAutoYaST.tsx +++ b/web/src/components/questions/UnsupportedAutoYaST.tsx @@ -21,12 +21,20 @@ */ import React from "react"; -import { Flex, Grid, GridItem, Content } from "@patternfly/react-core"; +import { + Content, + Grid, + GridItem, + List, + ListItem, + ListVariant, + Stack, +} from "@patternfly/react-core"; import { AnswerCallback, Question } from "~/types/questions"; import { Page, Popup } from "~/components/core"; -import { _ } from "~/i18n"; import QuestionActions from "~/components/questions/QuestionActions"; import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; const UnsupportedElements = ({ elements, @@ -43,14 +51,12 @@ const UnsupportedElements = ({ return ( - - + + {elements.map((e: string, i: number) => ( - - {e} - + {e} ))} - + ); @@ -73,30 +79,32 @@ export default function UnsupportedAutoYaST({ return ( - {_("Some of the elements in your AutoYaST profile are not supported.")} - - - - - - {_( - 'If you want to disable this check, please specify "agama.ay_check=0" at kernel\'s command-line', - )} - + + {_("Some of the elements in your AutoYaST profile are not supported.")} + + + + + + {_( + 'If you want to disable this check, please specify "agama.ay_check=0" at kernel\'s command-line', + )} + + { const size = screen.getByRole("button", { name: "Size" }); // File system and size fields disabled until valid mount point selected expect(filesystem).toBeDisabled(); + expect(screen.queryByRole("textbox", { name: "File system label" })).not.toBeInTheDocument(); expect(size).toBeDisabled(); + await user.click(mountPoint); const mountPointOptions = screen.getByRole("listbox", { name: "Suggested mount points" }); const homeMountPoint = within(mountPointOptions).getByRole("option", { name: "/home" }); await user.click(homeMountPoint); // Valid mount point selected, enable file system and size fields expect(filesystem).toBeEnabled(); + expect(screen.queryByRole("textbox", { name: "File system label" })).toBeInTheDocument(); expect(size).toBeEnabled(); // Display mount point options await user.click(mountPointMode); @@ -206,6 +209,7 @@ describe("PartitionPage", () => { await user.click(homeMountPoint); expect(mountPoint).toHaveValue("/home"); expect(filesystem).toBeEnabled(); + expect(screen.queryByRole("textbox", { name: "File system label" })).toBeInTheDocument(); expect(size).toBeEnabled(); const clearMountPointButton = screen.getByRole("button", { name: "Clear selected mount point", @@ -214,6 +218,7 @@ describe("PartitionPage", () => { expect(mountPoint).toHaveValue(""); // File system and size fields disabled until valid mount point selected expect(filesystem).toBeDisabled(); + expect(screen.queryByRole("textbox", { name: "File system label" })).not.toBeInTheDocument(); expect(size).toBeDisabled(); }); @@ -227,7 +232,11 @@ describe("PartitionPage", () => { min: gib(5), max: gib(15), }, - filesystem: { default: false, type: "xfs" }, + filesystem: { + default: false, + type: "xfs", + label: "HOME", + }, }); }); @@ -239,6 +248,8 @@ describe("PartitionPage", () => { within(targetButton).getByText(/As a new partition/); const filesystemButton = screen.getByRole("button", { name: "File system" }); within(filesystemButton).getByText("XFS"); + const label = screen.getByRole("textbox", { name: "File system label" }); + expect(label).toHaveValue("HOME"); const sizeOptionButton = screen.getByRole("button", { name: "Size" }); within(sizeOptionButton).getByText("Custom"); const minSizeInput = screen.getByRole("textbox", { name: "Minimum size value" }); diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index f61018be93..135bfe2705 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -80,6 +80,7 @@ type FormValue = { mountPoint: string; target: string; filesystem: string; + filesystemLabel: string; sizeOption: SizeOptionValue; minSize: string; maxSize: string; @@ -131,6 +132,7 @@ function toPartitionConfig(value: FormValue): configModel.Partition { default: false, type, snapshots: value.filesystem === BTRFS_SNAPSHOTS, + label: value.filesystemLabel, }; }; @@ -167,6 +169,8 @@ function toFormValue(partitionConfig: configModel.Partition): FormValue { return fsConfig.type; }; + const filesystemLabel = (): string => partitionConfig.filesystem?.label || NO_VALUE; + const sizeOption = (): SizeOptionValue => { const reusePartition = partitionConfig.name !== undefined; const sizeConfig = partitionConfig.size; @@ -182,6 +186,7 @@ function toFormValue(partitionConfig: configModel.Partition): FormValue { mountPoint: mountPoint(), target: target(), filesystem: filesystem(), + filesystemLabel: filesystemLabel(), sizeOption: sizeOption(), minSize: size(partitionConfig.size?.min), maxSize: size(partitionConfig.size?.max), @@ -385,6 +390,7 @@ function useSolvedModel(value: FormValue): configModel.Config | null { const initialPartitionConfig = useInitialPartitionConfig(); const partitionConfig = toPartitionConfig(value); partitionConfig.size = undefined; + if (partitionConfig.filesystem) partitionConfig.filesystem.label = undefined; let sparseModel: configModel.Config | undefined; @@ -552,7 +558,8 @@ function FilesystemOptionLabel({ value, target }: FilesystemOptionLabelProps): R const filesystem = partition?.filesystem?.type; if (value === NO_VALUE) return _("Waiting for a mount point"); // TRANSLATORS: %s is a filesystem type, like Btrfs - if (value === REUSE_FILESYSTEM) return sprintf(_("Current %s"), filesystem); + if (value === REUSE_FILESYSTEM && filesystem) + return sprintf(_("Current %s"), filesystemLabel(filesystem)); if (value === BTRFS_SNAPSHOTS) return _("Btrfs with snapshots"); return filesystemLabel(value); @@ -641,6 +648,25 @@ function FilesystemSelect({ ); } +type FilesystemLabelProps = { + id?: string; + value: string; + onChange: (v: string) => void; +}; + +function FilesystemLabel({ id, value, onChange }: FilesystemLabelProps): React.ReactNode { + const isValid = (v: string) => /^[\w-_.]*$/.test(v); + + return ( + isValid(v) && onChange(v)} + /> + ); +} + type SizeOptionLabelProps = { value: SizeOptionValue; mountPoint: string; @@ -1116,6 +1142,7 @@ export default function PartitionPage() { const [mountPoint, setMountPoint] = React.useState(NO_VALUE); const [target, setTarget] = React.useState(NEW_PARTITION); const [filesystem, setFilesystem] = React.useState(NO_VALUE); + const [filesystemLabel, setFilesystemLabel] = React.useState(NO_VALUE); const [sizeOption, setSizeOption] = React.useState(NO_VALUE); const [minSize, setMinSize] = React.useState(NO_VALUE); const [maxSize, setMaxSize] = React.useState(NO_VALUE); @@ -1125,7 +1152,7 @@ export default function PartitionPage() { const [autoRefreshSize, setAutoRefreshSize] = React.useState(false); const initialValue = useInitialFormValue(); - const value = { mountPoint, target, filesystem, sizeOption, minSize, maxSize }; + const value = { mountPoint, target, filesystem, filesystemLabel, sizeOption, minSize, maxSize }; const { errors, getVisibleError } = useErrors(value); const device = useDevice(); @@ -1138,6 +1165,7 @@ export default function PartitionPage() { setMountPoint(initialValue.mountPoint); setTarget(initialValue.target); setFilesystem(initialValue.filesystem); + setFilesystemLabel(initialValue.filesystemLabel); setSizeOption(initialValue.sizeOption); setMinSize(initialValue.minSize); setMaxSize(initialValue.maxSize); @@ -1147,6 +1175,7 @@ export default function PartitionPage() { setMountPoint, setTarget, setFilesystem, + setFilesystemLabel, setSizeOption, setMinSize, setMaxSize, @@ -1209,6 +1238,7 @@ export default function PartitionPage() { const isFormValid = errors.length === 0; const mountPointError = getVisibleError("mountPoint"); const usedMountPt = mountPointError ? NO_VALUE : mountPoint; + const showLabel = filesystem !== NO_VALUE && filesystem !== REUSE_FILESYSTEM; return ( @@ -1256,14 +1286,31 @@ export default function PartitionPage() { - - + + + + + + + + {showLabel && ( + + + + + + )} +