diff --git a/build-individual.nu b/build-individual.nu index 1d20a74..3d53f3f 100644 --- a/build-individual.nu +++ b/build-individual.nu @@ -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)" diff --git a/build-unified.nu b/build-unified.nu index baccabe..3faddd0 100644 --- a/build-unified.nu +++ b/build-unified.nu @@ -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)" @@ -56,4 +56,4 @@ let digest = ( print $"(ansi cyan)Signing image:(ansi reset) ($env.REGISTRY)/modules@($digest)" cosign sign -y --key env://COSIGN_PRIVATE_KEY $"($env.REGISTRY)/modules@($digest)" -print $"(ansi green_bold)DONE!(ansi reset)" \ No newline at end of file +print $"(ansi green_bold)DONE!(ansi reset)" diff --git a/modules.json b/modules.json index 66afaa8..b597ad2 100644 --- a/modules.json +++ b/modules.json @@ -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/initramfs/module.yml", "https://raw.githubusercontent.com/blue-build/modules/main/modules/script/module.yml", "https://raw.githubusercontent.com/blue-build/modules/main/modules/signing/module.yml", diff --git a/modules/dnf/README.md b/modules/dnf/README.md new file mode 100644 index 0000000..90f6b5b --- /dev/null +++ b/modules/dnf/README.md @@ -0,0 +1,233 @@ +# **`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 +``` + +## Package Management + +### 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 +``` + +### Install 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 +``` + +### Remove Packages + +```yaml +type: dnf +remove: + packages: + - package-1 + - package-2 +``` + +### Install 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 +``` + +### Remove Package Groups +```yaml +type: dnf +group-remove: + packages: + - de-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 +``` + +### Installation 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-unavailable: true + packages: + ... +replace: + - from-repo: repo-1 + allow-erasing: true + packages: + ... +``` + +## 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. diff --git a/modules/dnf/bluebuild-optfix.service b/modules/dnf/bluebuild-optfix.service new file mode 100644 index 0000000..6470559 --- /dev/null +++ b/modules/dnf/bluebuild-optfix.service @@ -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 diff --git a/modules/dnf/dnf.nu b/modules/dnf/dnf.nu new file mode 100644 index 0000000..0037375 --- /dev/null +++ b/modules/dnf/dnf.nu @@ -0,0 +1,887 @@ +#!/usr/bin/env nu + +const NEGATIVO = 'negativo17' +const NEGATIVO_URL = 'https://negativo17.org/repos/fedora-negativo17.repo' +const RPMFUSION = 'rpmfusion' + +# Handles installing necessary plugins for repo management. +def check_dnf5_plugins []: nothing -> nothing { + if (^rpm -q dnf5-plugins | complete).exit_code != 0 { + print $'(ansi yellow1)Required dnf5 plugins are not installed. Installing plugins(ansi reset)' + + install_pkgs { packages: [dnf5-plugins] } + } +} + +# 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> { + check_dnf5_plugins + + 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)" + try { + ^dnf5 config-manager setopt $'($CISCO_REPO).enabled=1' + } catch { + exit 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> { + try { + ^dnf5 -y repo list --all --json | from json + } catch { + exit 1 + } + | find negativo17 + | get id + | ansi strip + | par-each {|repo| + try { + ^dnf5 -y repo info $repo --all --json | from json + } catch { + exit 1 + } + } + | 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] + + try { + ^dnf5 repo list --all --json + } catch { + exit 1 + } + | from json + | find negativo17 + | get id + | ansi strip + | each {|id| + [$'($id).enabled=1' $'($id).priority=90'] + } + | flatten + | try { + ^dnf5 -y config-manager setopt ...($in) + } catch { + exit 1 + } +} + +# 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> { + check_dnf5_plugins + + 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)'(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' + } + }) + } + + try { + ^dnf5 -y config-manager addrepo --overwrite --from-repofile $repo + } catch { + exit 1 + } + } + } + + # 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 = try { + ^dnf5 repo list --all --json + } catch { + exit 1 + } + | from json + | get id + | par-each {|repo| + try { + ^dnf5 repo info --json $repo + } catch { + exit 1 + } + | from json + } + | 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 { + $'($in).enabled=1' + } + | try { + ^dnf5 -y config-manager setopt ...($in) + } catch { + exit 1 + } + + $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| + try { + ^dnf5 -y repo info $repo --all --json | from json + } catch { + exit 1 + } + } + | flatten + | get repo_file_path + | uniq + | each {|file| + print $"Removing repo file '(ansi cyan)($file)(ansi reset)'" + rm -f $file + } + } +} + +# 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 +} + +# 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> { + check_dnf5_plugins + + 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)" + try { + ^dnf5 -y copr enable ($copr | check_copr) + } catch { + exit 1 + } + } + } + $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 { + check_dnf5_plugins + + 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 $"Disabling COPR repository: (ansi cyan)'($copr)'(ansi reset)" + try { + ^dnf5 -y copr disable ($copr| check_copr) + } catch { + exit 1 + } + } + } +} + +# 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 { + ^dnf5 -y 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)' + } + + mut args = $install | install_args + + if $install.with-optional { + $args = $args | appent '--with-optional' + } + + try { + (^dnf5 + -y + ($install | weak_arg) + group + install + ...($args) + ...($install_list)) + } catch { + exit 1 + } + } +} + +# 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)' + } + + mut args = [] + + if not $remove.auto-remove { + $args = $args | append '--no-autoremove' + } + + try { + ^dnf5 -y remove ...($args) ...($remove.packages) + } catch { + exit 1 + } + } +} + +# Build up args to use on `dnf` +def install_args [ + --global-config: record + ...filter: string +]: record -> list<string> { + let install = $in + | 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 install = + | 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' + } +} + +# 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)' + } + } + + try { + (^dnf5 + -y + ($install | weak_arg) + install + ...($install | install_args) + ...($http_list) + ...($local_list) + ...($normal_list)) + } catch { + exit 1 + } + } + + # 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)' + } + + try { + (^dnf5 + -y + ($repo_install | weak_arg --global-config $install) + install + --repoid + $repo + ...($repo_install | install_args --global-config $install) + ...($packages)) + } catch { + exit 1 + } + } +} + +# 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 { + try { + (^dnf5 + -y + swap + ...($replacement | install_args allow-erasing) + $pkg_pair.old + $pkg_pair.new) + } catch { + exit 1 + } + } + } + + 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)' + } + + try { + (^dnf5 + -y + ($replacement | weak_arg) + distro-sync + ...($replacement | install_args) + --repo $from_repo + ...($sync_packages)) + } catch { + exit 1 + } + } + } + } + } +} + +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 has_dnf5 = ^rpm -q dnf5 | complete + let should_cleanup = $config.repos + | default false cleanup + | get cleanup + + if $has_dnf5.exit_code != 0 { + return (error make { + msg: $"(ansi red)ERROR: Main dependency '(ansi cyan)dnf5(ansi red)' is not installed. Install '(ansi cyan)dnf5(ansi red)' before using this module to solve this error.(ansi reset)" + label: { + span: (metadata $has_dnf5).span + text: 'Checks for dnf5' + } + }) + } + + let cleanup_repos = repos $config.repos + + try { + ^dnf5 makecache --refresh + } catch { + exit 1 + } + + 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)' + } +} diff --git a/modules/dnf/dnf.tsp b/modules/dnf/dnf.tsp new file mode 100644 index 0000000..fb98271 --- /dev/null +++ b/modules/dnf/dnf.tsp @@ -0,0 +1,156 @@ +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`. + */ + disable?: 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; +} + +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 during the RPM group install or not. */ + `install-weak-deps`?: boolean = true; + + /** Whether to continue with the RPM group install if there are no packages available in the repository. */ + `skip-unavailable`?: boolean = false; + + /** Whether to continue with the RPM group install if there are broken packages. */ + `skip-broken`?: boolean = false; + + /** Whether to allow erasing (removal) of packages in case of dependency problems during the RPM group install. */ + `allow-erasing`?: boolean = false; +} diff --git a/modules/dnf/module.yml b/modules/dnf/module.yml new file mode 100644 index 0000000..295e5b1 --- /dev/null +++ b/modules/dnf/module.yml @@ -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 diff --git a/modules/dnf/optfix.sh b/modules/dnf/optfix.sh new file mode 100644 index 0000000..07d12fb --- /dev/null +++ b/modules/dnf/optfix.sh @@ -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 diff --git a/modules/rpm-ostree/rpm-ostree.sh b/modules/rpm-ostree/rpm-ostree.sh index f5f1564..80e5552 100644 --- a/modules/rpm-ostree/rpm-ostree.sh +++ b/modules/rpm-ostree/rpm-ostree.sh @@ -60,7 +60,8 @@ if [[ ${#OPTFIX[@]} -gt 0 ]]; then echo "Creating symlinks to fix packages that install to /opt" # Create symlink for /opt to /var/opt since it is not created in the image yet mkdir -p "/var/opt" - ln -fs "/var/opt" "/opt" + ln -fs "/var/opt" "/opt" + # Create symlinks for each directory specified in recipe.yml for OPTPKG in "${OPTFIX[@]}"; do