feat: dnf module (#377)

Co-authored-by: xyny <60004820+xynydev@users.noreply.github.com>
Co-authored-by: Gerald Pinder <gmpinder@gmail.com>
Co-authored-by: certifiedfoolio <156134535+cherry-os@users.noreply.github.com>
Co-authored-by: xyny <git@xyny.anonaddy.me>
Co-authored-by: somebody once told me <156134535+certifiedfoolio@users.noreply.github.com>
Co-authored-by: franute <franute@gmail.com>
This commit is contained in:
fiftydinar 2025-04-27 16:49:39 +02:00 committed by GitHub
parent d12d657371
commit fef0f17870
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1712 additions and 4 deletions

View file

@ -7,7 +7,7 @@ let images = ls modules | each { |moduleDir|
cd $moduleDir.name
# module is unversioned
if ($"($moduleDir.name | path basename).sh" | path exists) {
if (glob $"($moduleDir.name | path basename).{sh,nu}" | any { path exists }) {
print $"(ansi cyan)Found(ansi reset) (ansi cyan_bold)unversioned(ansi reset) (ansi cyan)module:(ansi reset) ($moduleDir.name | path basename)"

View file

@ -9,7 +9,7 @@ mkdir ./modules-latest
ls modules | each { |moduleDir|
# module is unversioned
if ($"($moduleDir.name)/($moduleDir.name | path basename).sh" | path exists) {
if (glob $"($moduleDir.name)/($moduleDir.name | path basename).{sh,nu}" | any { path exists }) {
print $"(ansi cyan)Found(ansi reset) (ansi cyan_bold)unversioned(ansi reset) (ansi cyan)module:(ansi reset) ($moduleDir.name | path basename)"

View file

@ -10,6 +10,7 @@
"https://raw.githubusercontent.com/blue-build/modules/main/modules/gschema-overrides/module.yml",
"https://raw.githubusercontent.com/blue-build/modules/main/modules/justfiles/module.yml",
"https://raw.githubusercontent.com/blue-build/modules/main/modules/rpm-ostree/module.yml",
"https://raw.githubusercontent.com/blue-build/modules/main/modules/dnf/module.yml",
"https://raw.githubusercontent.com/blue-build/modules/main/modules/kargs/module.yml",
"https://raw.githubusercontent.com/blue-build/modules/main/modules/initramfs/module.yml",
"https://raw.githubusercontent.com/blue-build/modules/main/modules/script/module.yml",

265
modules/dnf/README.md Normal file
View file

@ -0,0 +1,265 @@
# **`dnf` Module**
The `dnf` module offers pseudo-declarative package and repository management using [`dnf5`](https://github.com/rpm-software-management/dnf).
## Features
This module is capable of:
- Repository Management
- Enabling/disabling COPR repos
- Adding repo files via url or local files
- Removing repos by specifying the repo name
- Automatically cleaning up any repos added in the module
- Adding keys for repos via url or local files
- Adding non-free repos like `rpmfusion` and `negativo17`
- Package Management
- Installing packages from RPM urls, local RPM files, or package repositories
- Installing packages from a specific repository
- Removing packages
- Replacing installed packages with versions from another repository
- Optfix
- Setup symlinks to `/opt/` to allow certain packages to install
## Repository Management
### Add Repository Files
- Add repos from
- any `https://` or `http://` URL
- any `.repo` files located in `./files/dnf/` of your image repo
- If the OS version is included in the file name or URL, you can substitute it with the `%OS_VERSION%` magic string
- The version is gathered from the `VERSION_ID` field of `/usr/lib/os-release`
```yaml
type: dnf
repos:
files:
- https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo
- custom-file.repo # file path for /files/dnf/custom-file.repo
```
### Add COPR Repositories
- [COPR](https://copr.fedorainfracloud.org/) contains software repositories maintained by fellow Fedora users
```yaml
type: dnf
repos:
copr:
- atim/starship
- trixieua/mutter-patched
```
### Disable/Enable Repositories
```yaml
type: dnf
repos:
files:
add:
- repo1
- repo2
remove:
- repo3
copr:
enable:
- ryanabx/cosmic-epoch
disable:
- kylegospo/oversteer
```
### Add Repository Keys
```yaml
type: dnf
repos:
keys:
- https://example.com/repo-1.asc
- key2.asc
```
### Add Non-free Repositories
This allows you to add a commonly used non-free repository.
You can choose between [negativo17](https://negativo17.org/) and [rpmfusion](https://rpmfusion.org/).
Your choice will also disable the opposite repository if it was already enabled.
```yaml
type: dnf
repos:
nonfree: negativo17
```
### Options
There is currently only one option that can be specified in the repository management section.
- `cleanup` automatically cleans up repositories added in this section
- Disabled by default
```yaml
type: dnf
repos:
cleanup: true
```
## Package Management
### Installing
#### Packages from Any Repository
```yaml
type: dnf
install:
packages:
- package-1
- package-2
```
#### Packages from URL or File
- If the OS version is included in the file name or URL, you can substitute it with the `%OS_VERSION%` magic string
- The version is gathered from the `VERSION_ID` field of `/usr/lib/os-release`
```yaml
type: dnf
install:
packages:
- https://example.com/package-%OS_VERSION%.rpm
- custom-file.rpm # install files/dnf/custom-file.rpm from the image repository
```
#### Packages from Specific Repositories
- Set `repo` to the name of the RPM repository, not the name or URL of the repo file
```yaml
type: dnf
install:
packages:
- repo: copr:copr.fedorainfracloud.org:custom-user:custom-repo
packages:
- package-1
```
#### Package Groups
- See list of all package groups by running `dnf5 group list --hidden` on a live system
- Set the option `with-optional` to `true` to enable installation of optional packages in package groups
```yaml
type: dnf
group-install:
with-optional: true
packages:
- de-package-1
- wm-package-2
```
#### Replace Packages
- You can specify one or more packages that will be swapped from another repo
- This process uses `distro-sync` to perform this operation
- All packages not specifying `old:` and `new:` will be swapped in a single transaction
```yaml
type: dnf
replace:
- from-repo: copr:copr.fedorainfracloud.org:custom-user:custom-repo
packages:
- package-1
```
- If a package has a different name in another repo, you can use the `old:` and `new:` properties
- This process uses `swap` to perform this operation for each set
- This process is ran before `distro-sync`
```yaml
type: dnf
replace:
- from-repo: repo-1
packages:
- old: old-package-2
new: new-package-2
```
#### Options
The following options can specified in the package installation, group installation, and package replacement sections.
- `install-weak-deps` enables installation of the weak dependencies of RPMs
- Enabled by default
- Corresponds to the [`--setopt=install_weak_deps=True` / `--setopt=install_weak_deps=False`](https://dnf5.readthedocs.io/en/latest/dnf5.conf.5.html#install-weak-deps-options-label) flag
- `skip-unavailable` enables skipping packages unavailable in repositories without erroring out
- Disabled by default
- Corresponds to the [`--skip-unavailable`](https://dnf5.readthedocs.io/en/latest/commands/install.8.html#options) flag
- `skip-broken` enables skipping broken packages without erroring out
- Disabled by default
- Corresponds to the [`--skip-broken`](https://dnf5.readthedocs.io/en/latest/commands/install.8.html#options) flag
- `allow-erasing` allows removing packages in case of dependency problems during package installation
- Disabled by default
- Corresponds to the [`--allowerasing`](https://dnf5.readthedocs.io/en/latest/commands/install.8.html#options) flag
```yaml
type: dnf
install:
skip-unavailable: true
packages:
...
group-install:
skip-broken: true
packages:
...
replace:
- from-repo: repo-1
allow-erasing: true
packages:
...
```
### Removing
#### Packages
- You can set the `auto-remove` option to `false` to only remove the specific package and leave unused dependencies
```yaml
type: dnf
remove:
auto-remove: false
packages:
- package-1
- package-2
```
#### Package Groups
```yaml
type: dnf
group-remove:
packages:
- de-package-2
```
## Optfix
- Optfix is a script used to work around problems with certain packages that install into `/opt/`
- These issues are caused by Fedora Atomic storing `/opt/` at the location `/var/opt/` by default, while `/var/` is only writeable on a live system
- The script works around these issues by moving the folder to `/usr/lib/opt/` and creating the proper symlinks at runtime
- Specify a list of folders inside `/opt/`
```yaml
type: dnf
optfix:
- brave.com
- foldername
```
## Known issues
Replacing the kernel with the `dnf` module is not done cleanly at the moment & some remaints of old kernel will be present.
Please use the `rpm-ostree` module for this purpose until this `dnf` behavior is fixed.
## Note
This documentation page uses the installation of the Brave Browser as an example of a package that required a custom repository, with a custom key, and an optfix configuration to install properly. This is not an official endorsement of the Brave Browser by the BlueBuild project.

View file

@ -0,0 +1,11 @@
[Unit]
Description=Create symbolic links for directories in /usr/lib/opt/ to /var/opt/
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/usr/libexec/bluebuild/optfix.sh
RemainAfterExit=no
[Install]
WantedBy=default.target

84
modules/dnf/dnf-repoinfo Executable file
View file

@ -0,0 +1,84 @@
#!/bin/bash
# convert the output of dnf repoinfo into json
repo_id="$1"
repo_info=$(dnf repoinfo -q "$repo_id")
echo "["
echo " {"
repo_id_val=$(echo "$repo_info" | grep -oP "^Repo-id *: *\K.*")
if [ -n "$repo_id_val" ]; then
echo " \"id\":\"$repo_id_val\","
fi
repo_name=$(echo "$repo_info" | grep -oP "^Repo-name *: *\K.*")
if [ -n "$repo_name" ]; then
echo " \"name\":\"$repo_name\","
fi
repo_status=$(echo "$repo_info" | grep -oP "^Repo-status *: *\K.*")
if [ -n "$repo_status" ]; then
if [[ "$repo_status" == "enabled" ]]; then
echo " \"is_enabled\":true,"
else
echo " \"is_enabled\":false,"
fi
fi
repo_revision=$(echo "$repo_info" | grep -oP "^Repo-revision *: *\K.*")
if [ -n "$repo_revision" ]; then
echo " \"revision\":\"$repo_revision\","
fi
repo_updated=$(echo "$repo_info" | grep -oP "^Repo-updated *: *\K.*")
if [ -n "$repo_updated" ]; then
echo " \"updated\":\"$repo_updated\","
fi
repo_available_pkgs=$(echo "$repo_info" | grep -oP "^Repo-available-pkgs *: *\K.*")
if [ -n "$repo_available_pkgs" ]; then
echo " \"available-pkgs\":$repo_available_pkgs,"
fi
repo_pkgs=$(echo "$repo_info" | grep -oP "^Repo-pkgs *: *\K.*")
if [ -n "$repo_pkgs" ]; then
echo " \"pkgs\":$repo_pkgs,"
fi
repo_size=$(echo "$repo_info" | grep -oP "^Repo-size *: *\K.*")
if [ -n "$repo_size" ]; then
echo " \"size\":\"$repo_size\","
fi
repo_metalink=$(echo "$repo_info" | grep -oP "^Repo-metalink *: *\K.*")
if [ -n "$repo_metalink" ]; then
echo " \"metalink\":\"$repo_metalink\","
fi
updated=$(echo "$repo_info" | grep -oP "^Updated *: *\K.*")
if [ -n "$updated" ]; then
echo " \"updated\":\"$updated\","
fi
repo_baseurl=$(echo "$repo_info" | grep -oP "^Repo-baseurl *: *\K.*")
if [ -n "$repo_baseurl" ]; then
echo " \"baseurl\":\"$repo_baseurl\","
fi
repo_expire=$(echo "$repo_info" | grep -oP "^Repo-expire *: *\K.*")
if [ -n "$repo_expire" ]; then
echo " \"expire\":\"$repo_expire\","
fi
repo_filename=$(echo "$repo_info" | grep -oP "^Repo-filename *: *\K.*")
if [ -n "$repo_filename" ]; then
echo " \"repo_file_path\":\"$repo_filename\""
fi
if [[ "$(tail -c 2 <<< "$(echo "$repo_info")" | head -c 1)" == "," ]]; then
sed -i '$ s/,$//'
fi
echo " }"
echo "]"

29
modules/dnf/dnf-repolist Executable file
View file

@ -0,0 +1,29 @@
#!/bin/bash
# convert the output of dnf repolist into json
output=$(dnf repolist -q --all 2>/dev/null)
lines=$(echo "$output" | tail -n +3)
echo "["
echo "$lines" | while read -r line; do
repo_id=$(echo "$line" | awk '{print $1}')
status=$(echo "$line" | awk '{print $NF}')
repo_name=$(echo "$line" | awk '{$1=""; $NF=""; print $0}' | sed -e 's/^ *//g' -e 's/ *$//g')
if [ "$status" = "enabled" ]; then
status=true
else
status=false
fi
cat <<EOF
{
"id":"$repo_id",
"name":"$repo_name",
"is_enabled":$status
},
EOF
done | sed '$s/},/}/'
echo "]"

690
modules/dnf/dnf.nu Normal file
View file

@ -0,0 +1,690 @@
#!/usr/bin/env nu
use dnf_interface.nu *
const NEGATIVO = 'negativo17'
const NEGATIVO_URL = 'https://negativo17.org/repos/fedora-negativo17.repo'
const RPMFUSION = 'rpmfusion'
# Handle adding/removing repo files and COPR repos.
#
# This command returns an object containing the repos
# that were added to allow for cleaning up afterwards.
def repos [$repos: record]: nothing -> record {
let repos = $repos
| default [] keys
let cleanup_repos = match $repos.files? {
# Add repos if it's a list
[..$files] => {
add_repos ($files | default [])
}
# Add and remove repos
{
add: [..$add]
remove: [..$remove]
} => {
let repos = add_repos ($add | default [])
remove_repos ($remove | default [])
$repos
}
# Add repos
{ add: [..$add] } => {
add_repos ($add | default [])
}
# Remove repos
{ remove: [..$remove] } => {
remove_repos ($remove | default [])
[]
}
_ => []
}
let cleanup_coprs = match $repos.copr? {
# Enable repos if it's a list
[..$coprs] => {
add_coprs ($coprs | default [])
}
# Enable and disable repos
{
enable: [..$enable]
disable: [..$disable]
} => {
let coprs = add_coprs ($enable | default [])
disable_coprs ($disable | default [])
$coprs
}
# Enable repos
{ enable: [..$enable] } => {
add_coprs ($enable | default [])
}
# Disable repos
{ disable: [..$disable] } => {
disable_coprs ($disable | default [])
[]
}
_ => []
}
nonfree_repos $repos.nonfree?
add_keys $repos.keys
{
copr: $cleanup_coprs
files: $cleanup_repos
}
}
# Setup nonfree repos for rpmfusion or negativo17-multimedia.
def nonfree_repos [repo_type?: string]: nothing -> list<string> {
match $repo_type {
$repo if $repo == $RPMFUSION => {
disable_negativo
enable_rpmfusion
}
$repo if $repo == $NEGATIVO => {
disable_rpmfusion
enable_negativo
}
null => [],
_ => {
error make {
msg: $"The only valid values are '($NEGATIVO)' and '($RPMFUSION)'"
label: {
text: 'Passed in value'
span: (metadata $repo_type).span
}
}
}
}
}
# Enable rpmfusion repos
#
# See https://rpmfusion.org/Configuration
def enable_rpmfusion []: nothing -> nothing {
const CISCO_REPO = 'fedora-cisco-openh264'
print $'(ansi green)Enabling rpmfusion repos(ansi reset)'
mut repos = []
if (^rpm -q rpmfusion-free-release | complete).exit_code != 0 {
$repos = $repos | append $'https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-($env.OS_VERSION).noarch.rpm'
}
if (^rpm -q rpmfusion-nonfree-release | complete).exit_code != 0 {
$repos = $repos | append $'https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-($env.OS_VERSION).noarch.rpm'
}
install_pkgs { packages: $repos }
print $"(ansi green)Enabling '(ansi cyan)($CISCO_REPO)(ansi green)' repo for RPMFusion compatibility(ansi reset)"
dnf config-manager setopt [$'($CISCO_REPO).enabled=1']
}
# Disable rpmfusion repos
def disable_rpmfusion []: nothing -> nothing {
print $'(ansi green)Removing rpmfusion repos(ansi reset)'
mut repos = []
if (^rpm -q rpmfusion-free-release | complete).exit_code == 0 {
$repos = $repos | append 'rpmfusion-free-release'
}
if (^rpm -q rpmfusion-nonfree-release | complete).exit_code == 0 {
$repos = $repos | append 'rpmfusion-nonfree-release'
}
remove_pkgs { packages: $repos }
}
def negativo_repo_list []: nothing -> list<path> {
dnf repo list
| find negativo17
| get id
| ansi strip
| par-each {|repo|
dnf repo info $repo --all
}
| flatten
| get id
| uniq
}
# Enable negativo17-multimedia repos
def enable_negativo []: nothing -> nothing {
print $'(ansi green)Enabling negativo17 repos(ansi reset)'
let current_repo_list = negativo_repo_list
if ($current_repo_list | is-not-empty) {
print $'(ansi green)Cleaning up existing negativo17 repos(ansi reset)'
remove_repos $current_repo_list
}
add_repos [$NEGATIVO_URL]
dnf repo list
| find negativo17
| get id
| ansi strip
| each {|id|
[$'($id).enabled=1' $'($id).priority=90']
}
| flatten
| dnf config-manager setopt $in
}
# Disable negativo17-multimedia repos
def disable_negativo []: nothing -> nothing {
print $'(ansi green)Disabling negativo17 repos(ansi reset)'
remove_repos (negativo_repo_list)
}
# Adds a list of repo files for `dnf` to use
# for installing packages.
#
# Returns a list of IDs of the repos added
def add_repos [$repos: list]: nothing -> list<string> {
if ($repos | is-not-empty) {
print $'(ansi green)Adding repositories:(ansi reset)'
# Substitute %OS_VERSION% & remove newlines/whitespaces from all repo entries
let repos = $repos
| each {
str replace --all '%OS_VERSION%' $env.OS_VERSION
| str trim
}
$repos
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
for $repo in $repos {
let repo_path = [$env.CONFIG_DIRECTORY dnf $repo] | path join
let repo = if ($repo | str starts-with 'https://') or ($repo | str starts-with 'http://') {
print $"Adding repository URL: (ansi cyan)'($repo)'(ansi reset)"
$repo
} else if ($repo | str ends-with '.repo') and ($repo_path | path exists) {
print $"Adding repository file: (ansi cyan)'($repo_path)'(ansi reset)"
$repo_path
} else {
return (error make {
msg: $"(ansi red)Unrecognized repo (ansi cyan)'($repo)'(ansi reset)"
label: {
span: (metadata $repo).span
text: 'Found in config'
}
})
}
dnf config-manager addrepo --from-repofile $repo
}
}
# Get a list of paths of all new repo files added
let repo_files = $repos
| each {|repo|
[/ etc yum.repos.d ($repo | path basename)] | path join
}
# Get a list of info for every repo installed
let repo_info = dnf repo list
| get id
| par-each {|repo|
dnf repo info $repo
}
| flatten
# Return the IDs of all repos that were added
let repo_ids = $repo_info
| filter {|repo|
$repo.repo_file_path in $repo_files
}
| get id
$repo_ids
| each {
print $'Enabling repo (ansi cyan)($in)(ansi reset)'
$'($in).enabled=1'
}
| dnf config-manager setopt $in
$repo_ids
}
# Remove a list of repos. The list must be the IDs of the repos.
def remove_repos [$repos: list]: nothing -> nothing {
if ($repos | is-not-empty) {
print $'(ansi green)Removing repositories:(ansi reset)'
let repos = $repos | str trim
$repos
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
$repos
| par-each {|repo|
dnf repo info $repo --all
}
| flatten
| get repo_file_path
| uniq
| each {|file|
print $"Removing repo file '(ansi cyan)($file)(ansi reset)'"
rm -f $file
}
}
}
# Enable a list of COPR repos. The COPR repo ID has a '/' in the name.
#
# This will error if a COPR repo ID is invalid.
def add_coprs [$copr_repos: list]: nothing -> list<string> {
if ($copr_repos | is-not-empty) {
print $'(ansi green)Adding COPR repositories:(ansi reset)'
$copr_repos
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
for $copr in $copr_repos {
print $"Adding COPR repository: (ansi cyan)'($copr)'(ansi reset)"
dnf copr enable $copr
}
}
$copr_repos
}
# Disable a list of COPR repos. The COPR repo ID has a '/' in the name.
#
# This will error if a COPR repo ID is invalid.
def disable_coprs [$copr_repos: list]: nothing -> nothing {
if ($copr_repos | is-not-empty) {
print $'(ansi green)Disabling COPR repositories:(ansi reset)'
$copr_repos
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
for $copr in $copr_repos {
print $"Disabling COPR repository: (ansi cyan)'($copr)'(ansi reset)"
dnf copr disable $copr
}
}
}
# Add a list of keys for integrity checking repos.
def add_keys [$keys: list]: nothing -> nothing {
if ($keys | is-not-empty) {
print $'(ansi green)Adding keys:(ansi reset)'
let keys = $keys
| str replace --all '%OS_VERSION%' $env.OS_VERSION
| str trim
| each {|key|
let key = if ($key | str starts-with 'https://') or ($key | str starts-with 'http://') {
$key
} else {
[$env.CONFIG_DIRECTORY dnf $key] | path join
}
print $'- (ansi cyan)($key)(ansi reset)'
$key
}
for $key in $keys {
let key = $key
| str replace --all '%OS_VERSION%' $env.OS_VERSION
| str trim
try {
^rpm --import $key
} catch {
exit 1
}
}
}
}
# Setup /opt directory symlinks to allow certain packages to install.
#
# Each entry must be the directory name that the application expects
# to install into /opt. A systemd unit will be installed to setup
# symlinks on boot of the OS.
def run_optfix [$optfix_pkgs: list]: nothing -> nothing {
const LIB_EXEC_DIR = '/usr/libexec/bluebuild'
const SYSTEMD_DIR = '/etc/systemd/system'
const MODULE_DIR = '/tmp/modules/dnf'
const LIB_OPT_DIR = '/usr/lib/opt'
const VAR_OPT_DIR = '/var/opt'
const OPTFIX_SCRIPT = 'optfix.sh'
const SERV_UNIT = 'bluebuild-optfix.service'
if ($optfix_pkgs | is-not-empty) {
if not ($LIB_EXEC_DIR | path join $OPTFIX_SCRIPT | path exists) {
mkdir $LIB_EXEC_DIR
cp ($MODULE_DIR | path join $OPTFIX_SCRIPT) $'($LIB_EXEC_DIR)/'
try {
^chmod +x $'($LIB_EXEC_DIR | path join $OPTFIX_SCRIPT)'
} catch {
exit 1
}
}
if not ($SYSTEMD_DIR | path join $SERV_UNIT | path exists) {
cp ($MODULE_DIR | path join $SERV_UNIT) $'($SYSTEMD_DIR)/'
try {
^systemctl enable $SERV_UNIT
} catch {
exit 1
}
}
print $"(ansi green)Creating symlinks to fix packages that install to /opt:(ansi reset)"
$optfix_pkgs
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
mkdir $VAR_OPT_DIR
try {
^ln -snf $VAR_OPT_DIR /opt
} catch {
exit 1
}
for $opt in $optfix_pkgs {
let lib_dir = [$LIB_OPT_DIR $opt] | path join
let var_opt_dir = [$VAR_OPT_DIR $opt] | path join
mkdir $lib_dir
try {
^ln -sf $lib_dir $var_opt_dir
} catch {
exit 1
}
print $"Created symlinks for '(ansi cyan)($opt)(ansi reset)'"
}
}
}
# Remove group packages.
def group_remove [remove: record]: nothing -> nothing {
let remove_list = $remove
| default [] packages
| get packages
if ($remove_list | is-not-empty) {
print $'(ansi green)Removing group packages:(ansi reset)'
$remove_list
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
try {
dnf group remove $remove_list
} catch {
exit 1
}
}
}
# Install group packages.
def group_install [install: record]: nothing -> nothing {
let install = $install
| default false with-optional
| default [] packages
let install_list = $install
| get packages
| each { str trim }
if ($install_list | is-not-empty) {
print $'(ansi green)Installing group packages:(ansi reset)'
$install_list
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
(dnf
group
install
--opts $install
$install_list)
}
}
# Remove packages.
def remove_pkgs [remove: record]: nothing -> nothing {
let remove = $remove
| default [] packages
| default true auto-remove
if ($remove.packages | is-not-empty) {
print $'(ansi green)Removing packages:(ansi reset)'
$remove.packages
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
dnf remove --opts $remove $remove.packages
}
}
# Install packages.
#
# You can specify a list of packages to install, and you can
# specify a list of packages for a specific repo to install.
def install_pkgs [install: record]: nothing -> nothing {
let install = $install
| default [] packages
# Gather lists of the various ways a package is installed
# to report back to the user.
let install_list = $install.packages
| filter {|pkg|
($pkg | describe) == 'string'
}
| str replace --all '%OS_VERSION%' $env.OS_VERSION
| str trim
let http_list = $install_list
| filter {|pkg|
($pkg | str starts-with 'https://') or ($pkg | str starts-with 'http://')
}
let local_list = $install_list
| each {|pkg|
[$env.CONFIG_DIRECTORY dnf $pkg] | path join
}
| filter {|pkg|
($pkg | path exists)
}
let normal_list = $install_list
| filter {|pkg|
not (
($pkg | str starts-with 'https://') or ($pkg | str starts-with 'http://')
) and not (
[$env.CONFIG_DIRECTORY dnf $pkg]
| path join
| path exists
)
}
if ($install_list | is-not-empty) {
if ($http_list | is-not-empty) {
print $'(ansi green)Installing packages directly from URL:(ansi reset)'
$http_list
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
}
if ($local_list | is-not-empty) {
print $'(ansi green)Installing local packages:(ansi reset)'
$local_list
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
}
if ($normal_list | is-not-empty) {
print $'(ansi green)Installing packages:(ansi reset)'
$normal_list
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
}
(dnf
install
--opts $install
([
$http_list
$local_list
$normal_list
] | flatten))
}
# Get all the entries that have a repo specified.
let repo_install_list = $install.packages
| filter {|pkg|
'repo' in $pkg and 'packages' in $pkg
}
for $repo_install in $repo_install_list {
let repo = $repo_install.repo
let packages = $repo_install.packages
print $'(ansi green)Installing packages from repo (ansi cyan)($repo)(ansi green):(ansi reset)'
$packages
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
(dnf
install
--repoid
$repo
--opts $repo_install
--global-opts $install
$packages)
}
}
# Perform a replace operation for a list of packages that
# you want to replace from a specific repo.
def replace_pkgs [replace_list: list]: nothing -> nothing {
let check = {|item|
'old' in $item and 'new' in $item
}
if ($replace_list | is-not-empty) {
for $replacement in $replace_list {
let replacement = $replacement
| default [] packages
if ($replacement.packages | is-not-empty) {
let has_from_repo = 'from-repo' in $replacement
if not $has_from_repo {
return (error make {
msg: $"(ansi red)A value is expected in key 'from-repo'(ansi reset)"
label: {
span: (metadata $replacement).span
text: "Checks for 'from-repo' property"
}
})
}
let from_repo = $replacement
| get from-repo
let swap_packages = $replacement.packages
| filter $check
let sync_packages = $replacement.packages
| filter {
not (do $check $in)
}
if ($swap_packages | is-not-empty) {
print $"(ansi green)Swapping packages from '(ansi cyan)($from_repo)(ansi green)':(ansi reset)"
$swap_packages
| each {
print $'- (ansi cyan)($in.old)(ansi green) -> (ansi cyan)($in.new)(ansi reset)'
}
for $pkg_pair in $swap_packages {
(dnf
swap
--opts $pkg_pair
--global-opts $replacement
$pkg_pair.old
$pkg_pair.new)
}
}
if ($sync_packages | is-not-empty) {
print $"(ansi green)Replacing packages from '(ansi cyan)($from_repo)(ansi green)':(ansi reset)"
$sync_packages
| each {
print $'- (ansi cyan)($in)(ansi reset)'
}
(dnf
distro-sync
--opts $replacement
--repo $from_repo
$sync_packages)
}
}
}
}
}
def main [config: string]: nothing -> nothing {
let config = $config
| from json
| default {} repos
| default {} group-remove
| default {} group-install
| default {} remove
| default {} install
| default [] optfix
| default [] replace
let should_cleanup = $config.repos
| default false cleanup
| get cleanup
dnf version
let cleanup_repos = repos $config.repos
dnf makecache
run_optfix $config.optfix
group_remove $config.group-remove
group_install $config.group-install
remove_pkgs $config.remove
install_pkgs $config.install
replace_pkgs $config.replace
if $should_cleanup {
print $'(ansi green)Cleaning up added repos(ansi reset)'
remove_repos $cleanup_repos.files
disable_coprs $cleanup_repos.copr
match $config.repos.nonfree? {
$repo if $repo == $RPMFUSION => {
disable_rpmfusion
}
$repo if $repo == $NEGATIVO => {
disable_negativo
}
_ => {},
}
print $'(ansi green)Finished cleaning up repos(ansi reset)'
}
}

159
modules/dnf/dnf.tsp Normal file
View file

@ -0,0 +1,159 @@
import "@typespec/json-schema";
using TypeSpec.JsonSchema;
@jsonSchema("/modules/dnf-latest.json")
model DnfModuleLatest {
...DnfModuleV1;
}
@jsonSchema("/modules/dnf-v1.json")
model DnfModuleV1 {
/**
* The dnf module offers pseudo-declarative package and repository management using dnf.
* https://blue-build.org/reference/modules/dnf/
*/
type: "dnf" | "dnf@v1" | "dnf@latest";
/** List of links to .repo files to download into /etc/yum.repos.d/. */
repos?: Repo;
/** List of folder names under /opt/ to enable for installing into. */
optfix?: Array<string>;
/** Configuration of RPM groups removal. */
`group-remove`?: GroupRemove;
/** Configuration of RPM groups install. */
`group-install`?: GroupInstall;
/** Configuration of RPM packages removal. */
remove?: Remove;
/** Configuration of RPM packages install. */
install?: Install;
/** List of configurations for replacing packages from another repo. */
replace?: Array<Replace>;
}
model Repo {
/** Cleans up the repos added in the same step after packages are installed. */
cleanup?: boolean = false;
/** List of paths or URLs to .repo files to import */
files?: Array<string> | RepoFiles;
/**
* List of COPR project repos to add.
* You can also specify 2 lists
* instead to 'enable' or 'disable' COPR repos.
*/
copr?: Array<string> | RepoCopr;
/** List of links to key files to import for installing from custom repositories. */
keys?: Array<string>;
/**
* Enable one of the nonfree repos.
*
* This allows you to enable one of the nonfree repos.
* However, only one can be enabled at a time so if one
* is enabled, the other will be disabled if it is already enabled.
*/
nonfree?: "negativo17" | "rpmfusion";
}
model RepoFiles {
/** List of repo files/URLs to add. */
add?: Array<string>;
/**
* List of repos to disable.
* This must be the ID of the repo
* as seen in `dnf5 repolist`.
*/
remove?: Array<string>;
}
model RepoCopr {
/** List of COPR repos to enable */
enable?: Array<string>;
/** List of COPR repos to disable */
disable?: Array<string>;
}
model Install {
/** List of RPM packages to install. */
packages: Array<string | InstallRepo>;
...InstallCommon;
}
model InstallRepo {
/** The repo to use when installing packages */
repo: string;
/** List of RPM packages to install. */
packages: Array<string>;
...InstallCommon;
}
model Remove {
/** List of RPM packages to remove. */
packages: Array<string>;
/** Whether to remove unused dependencies during removal operation. */
`auto-remove`?: boolean = true;
}
model Replace {
/** URL to the source COPR repo for the new packages. */
`from-repo`: string;
/** List of packages to replace using packages from the defined repo. */
packages: Array<string | Swap>;
...InstallCommon;
}
model Swap {
/** The package to be replaced. */
old: string;
/** The package to replace with. */
new: string;
/** Whether to allow erasing (removal) of packages in case of dependency problems. */
`allow-erasing`?: boolean = false;
}
model GroupInstall {
/** List of RPM groups to install. */
packages: Array<string>;
/** Include optional packages from group. */
`with-optional`?: boolean = false;
...InstallCommon;
}
model GroupRemove {
/** List of RPM groups to remove. */
packages: Array<string>;
}
model InstallCommon {
/** Whether to install weak dependencies. */
`install-weak-deps`?: boolean = true;
/** Whether to continue with the install if there are no packages available in the repository. */
`skip-unavailable`?: boolean = false;
/** Whether to continue with the install if there are broken packages. */
`skip-broken`?: boolean = false;
/** Whether to allow erasing (removal) of packages in case of dependency problems. */
`allow-erasing`?: boolean = false;
}

View file

@ -0,0 +1,394 @@
export def "dnf install" [
--opts: record
--global-opts: record
--repoid: string
packages: list
]: nothing -> nothing {
let dnf = dnf version
try {
(^$dnf.path
-y
($opts | weak_arg --global-config $global_opts)
install
...(if $repoid != null {
[--repoid $repoid]
} else {
[]
})
...($opts | install_args --global-config $global_opts)
...$packages)
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf remove" [
--opts: record
packages: list
]: nothing -> nothing {
let dnf = dnf version
mut args = []
if not $opts.auto-remove {
$args = $args | append '--no-autoremove'
}
try {
(^$dnf.path
-y
remove
...($args)
...($packages))
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf config-manager addrepo" [
--from-repofile: string
]: nothing -> nothing {
check_dnf_plugins
let dnf = dnf version
try {
match $dnf.command {
"dnf4" => {
^dnf4 -v -y config-manager --add-repo $from_repofile
}
"dnf5" => {
(^dnf5
-y
config-manager
addrepo
--create-missing-dir
--overwrite
--from-repofile $from_repofile)
}
}
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf config-manager setopt" [
opts: list
]: nothing -> nothing {
check_dnf_plugins
let dnf = dnf version
try {
match $dnf.command {
"dnf4" => {
(^dnf4
-y
config-manager
--save
...($opts
| each {|opt|
[--setopt $opt]
}
| flatten))
}
"dnf5" => {
^dnf5 -y config-manager setopt ...($opts)
}
}
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf copr enable" [copr: string]: nothing -> nothing {
check_dnf_plugins
let dnf = dnf version
try {
^$dnf.path -y copr enable ($copr | check_copr)
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf copr disable" [copr: string]: nothing -> nothing {
check_dnf_plugins
let dnf = dnf version
try {
^$dnf.path -y copr disable ($copr | check_copr)
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf swap" [
--opts: record
--global-opts: record
old: string
new: string
]: nothing -> nothing {
let dnf = dnf version
try {
(^$dnf.path
-y
swap
...($opts | install_args --global-config $global_opts 'allow-erasing')
$old
$new)
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf distro-sync" [
--opts: record
--repo: string
packages: list
]: nothing -> nothing {
let dnf = dnf version
try {
(^$dnf.path
-y
($opts | weak_arg)
distro-sync
...($opts | install_args)
--repo $repo
...($packages))
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf group install" [
--opts: record
packages: list
]: nothing -> nothing {
let dnf = dnf version
mut args = $opts | install_args
if $opts.with-optional {
$args = $args | append '--with-optional'
}
try {
(^$dnf.path
-y
($opts | weak_arg)
group
install
...($args)
...($packages))
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf group remove" [
packages: list
]: nothing -> nothing {
let dnf = dnf version
try {
(^$dnf.path -y group remove ...($packages))
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf repo list" []: nothing -> list {
let dnf = dnf version
try {
match $dnf.command {
"dnf4" => {
^/tmp/modules/dnf/dnf-repolist | from json
}
"dnf5" => {
^dnf5 repo list --all --json | from json
}
}
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf repo info" [
repo: string
--all
]: nothing -> record {
let dnf = dnf version
try {
match $dnf.command {
"dnf4" => {
^/tmp/modules/dnf/dnf-repoinfo $repo | from json
}
"dnf5" => {
(^dnf5
-y
repo
info
$repo
...(if $all {
[--all]
} else {
[]
})
--json)
| from json
}
}
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf makecache" []: nothing -> nothing {
let dnf = dnf version
try {
^$dnf.path makecache --refresh
} catch {|e|
print $'($e.msg)'
exit 1
}
}
export def "dnf version" []: nothing -> record {
let dnf = which dnf4 dnf5
if ("dnf5" in ($dnf | get command)) {
$dnf | filter { $in.command == "dnf5" } | first
} else if ("dnf4" in ($dnf | get command)) {
$dnf | filter { $in.command == "dnf4" } | first
} else {
return (error make {
msg: $"(ansi red)ERROR: Main dependency '(ansi cyan)dnf5/dnf4(ansi red)' is not installed. Install '(ansi cyan)dnf5/dnf4(ansi red)' before using this module to solve this error.(ansi reset)"
label: {
span: (metadata $dnf).span
text: 'Checks for dnf5/dnf4'
}
})
}
}
# Build up args to use on `dnf`
def install_args [
--global-config: record
...filter: string
]: record -> list<string> {
let opts = $in | default {}
let global_config = $global_config | default {}
let install = $opts
| default (
$global_config.skip-unavailable?
| default false
) skip-unavailable
| default (
$global_config.skip-broken?
| default false
) skip-broken
| default (
$global_config.allow-erasing?
| default false
) allow-erasing
mut args = []
let check_filter = {|arg|
let arg_exists = ($arg in $install)
if ($filter | is-empty) {
$arg_exists and ($install | get $arg)
} else {
$arg_exists and ($arg in $filter) and ($install | get $arg)
}
}
if (do $check_filter 'skip-unavailable') {
$args = $args | append '--skip-unavailable'
}
if (do $check_filter 'skip-broken') {
$args = $args | append '--skip-broken'
}
if (do $check_filter 'allow-erasing') {
$args = $args | append '--allowerasing'
}
$args
}
# Generate a weak deps argument
def weak_arg [
--global-config: record
]: record -> string {
let opts = $in | default {}
let global_config = $global_config | default {}
let install = $opts
| default (
$global_config.install-weak-deps?
| default true
) install-weak-deps
if $install.install-weak-deps {
'--setopt=install_weak_deps=True'
} else {
'--setopt=install_weak_deps=False'
}
}
# Handles installing necessary plugins for repo management.
def check_dnf_plugins []: nothing -> nothing {
let dnf = dnf version
match $dnf.command {
"dnf4" => {
if (^rpm -q dnf-plugins-core | complete).exit_code != 0 {
print $'(ansi yellow1)Required dnf4 plugins are not installed. Installing plugins(ansi reset)'
dnf install [dnf-plugins-core]
}
}
"dnf5" => {
if (^rpm -q dnf5-plugins | complete).exit_code != 0 {
print $'(ansi yellow1)Required dnf5 plugins are not installed. Installing plugins(ansi reset)'
dnf install [dnf5-plugins]
}
}
}
}
# Checks to see if the string passed in is
# a COPR repo string. Will error if it isn't
def check_copr []: string -> string {
let is_copr = ($in | split row / | length) == 2
if not $is_copr {
return (error make {
msg: $"(ansi red)The string '(ansi cyan)($in)(ansi red)' is not recognized as a COPR repo(ansi reset)"
label: {
span: (metadata $is_copr).span
text: 'Checks if string is a COPR repo'
}
})
}
$in
}

47
modules/dnf/module.yml Normal file
View file

@ -0,0 +1,47 @@
name: dnf
shortdesc: The dnf module offers pseudo-declarative package and repository management using dnf.
example: |
type: dnf
repos:
cleanup: true # clean up added repos after module is done
files:
- https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo
- fury.repo
copr:
- atim/starship
- trixieua/mutter-patched
keys:
- https://brave-browser-rpm-release.s3.brave.com/brave-core.asc
nonfree: rpmfusion
optfix: # performs symlinking for `/opt/` to allow certain packages to install
- Tabby # needed because tabby installs into `/opt/Tabby/`
- brave.com
install:
skip-unavailable: true # skip unavailable packages
packages:
- repo: brave-browser
packages:
- brave-browser
- starship
- https://github.com/Eugeny/tabby/releases/download/v1.0.209/tabby-1.0.209-linux-x64.rpm
- kubectl.rpm
remove:
packages:
- firefox
- firefox-langpacks
replace:
- from-repo: copr:copr.fedorainfracloud.org:trixieua:mutter-patched
skip-unavailable: true # skip unavailable packages
packages:
- mutter
- mutter-common
- gdm
group-install:
with-optional: true # install optional packages from group
packages:
- cosmic-desktop
- cosmic-desktop-apps
- window-managers
group-remove:
packages:
- development-tools

27
modules/dnf/optfix.sh Normal file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
SOURCE_DIR="/usr/lib/opt"
TARGET_DIR="/var/opt"
# Ensure the target directory exists
mkdir -p "$TARGET_DIR"
# Loop through directories in the source directory
for dir in "$SOURCE_DIR/"*/; do
if [ -d "$dir" ]; then
# Get the base name of the directory
dir_name=$(basename "$dir")
# Check if the symlink already exists in the target directory
if [ -L "$TARGET_DIR/$dir_name" ]; then
echo "Symlink already exists for $dir_name, skipping."
continue
fi
# Create the symlink
ln -s "$dir" "$TARGET_DIR/$dir_name"
echo "Created symlink for $dir_name"
fi
done

View file

@ -62,6 +62,7 @@ if [[ ${#OPTFIX[@]} -gt 0 ]]; then
mkdir -p "/var/opt"
ln -fs "/var/opt" "/opt"
# Create symlinks for each directory specified in recipe.yml
for OPTPKG in "${OPTFIX[@]}"; do
OPTPKG="${OPTPKG%\"}"