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.0zypperen_USus
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 @@
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 (
-
-
-