Files
scripts/workstation-setup.pl
petrbalvin 788cf0571f
Deploy / deploy (push) Successful in 17s
Test / test (push) Successful in 53s
feat: initial release of the scripts collection
Assisted-by: GLM 5.3 Flash
2026-09-10 04:00:00 +00:00

2395 lines
77 KiB
Perl

#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Idempotent workstation setup for Fedora: development tools, editors
# and multimedia.
#
# Every operation checks the current state before acting, so running the script
# again produces the same result with no errors and no repeated work.
#
# Supply-chain posture:
# - Go: the latest stable tarball from go.dev, SHA-256 verified against the
# checksum published in go.dev's official download API.
# - JetBrains IDEs: the latest release tarballs from download.jetbrains.com,
# SHA-256 verified against the release's published checksum.
# - Brave: the repo signing key is fingerprint-verified against the official
# fingerprints from https://brave.com/signing-keys/ before rpm --import.
#
# Perl builtins only: no module has to be installed. Three pieces of work Perl
# does not carry as builtins are written out here:
#
# * the command runners, which fork and keep stdout and stderr apart in the
# scratch directory;
# * a JSON decoder, for the go.dev download API and the JetBrains releases API;
# * SHA-256, which has no builtin and is therefore read from sha256sum. The
# digest is the same either way; only the transport differs.
#
# External binaries used: dnf, rpm, curl, tar, sha256sum, gpg, flatpak, systemctl,
# getenforce, setenforce, sudo, sync, rm and uname.
#
# Usage:
# workstation-setup.pl # full setup
# workstation-setup.pl --dry-run # preview without changes
# workstation-setup.pl --skip-update # skip system update
# workstation-setup.pl --skip-rpm # skip RPM package installation
# workstation-setup.pl --skip-flatpak # skip Flatpak apps
# workstation-setup.pl --skip-repos # skip adding third-party repos
# workstation-setup.pl --skip-remove # skip removing pre-installed apps
# workstation-setup.pl --skip-go # skip Go toolchain
# workstation-setup.pl --skip-rust # skip Rust toolchain
# workstation-setup.pl --skip-jetbrains # skip JetBrains IDE installation
# workstation-setup.pl --skip-firewall # skip firewall setup
# workstation-setup.pl --skip-selinux # skip SELinux setup
# workstation-setup.pl --version
use strict;
use warnings;
my $VERSION = '2.0.0';
my $BOLD = "\033[1m";
my $RED = "\033[31m";
my $GREEN = "\033[32m";
my $YELLOW = "\033[33m";
my $DIM = "\033[2m";
my $RESET = "\033[0m";
# dnf transactions, and a fresh install downloading hundreds of packages, need far
# more than a generic 60-second timeout.
my $DNF_TIMEOUT = 1800;
my $DNF_UPDATE_TIMEOUT = 3600;
# Go comes from the official go.dev tarball, not the Fedora RPM, which lags behind.
# The download API returns the latest stable release with per-file SHA-256 sums.
my $GO_DL_API_URL = 'https://go.dev/dl/?mode=json';
my $GO_TARBALL_BASE_URL = 'https://go.dev/dl/';
my $GO_INSTALL_DIR = '/usr/local/go';
my @GO_BASHRC_LINES = ('export PATH="/usr/local/go/bin:$PATH"');
# JetBrains IDEs come from the latest release tarball, resolved through the official
# releases API, installed system-wide under /opt with a launcher and a menu entry.
my $JETBRAINS_RELEASES_URL = 'https://data.services.jetbrains.com/products/releases';
my $JETBRAINS_INSTALL_ROOT = '/opt';
my $JETBRAINS_BIN_DIR = '/usr/local/bin';
my $JETBRAINS_DESKTOP_DIR = '/usr/local/share/applications';
# The IDE set, in the order the sections report them.
my @JETBRAINS_ORDER = ('GO');
my %JETBRAINS_IDES = (
GO => 'GoLand',
);
my $BRAVE_REPO_URL = 'https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo';
my $BRAVE_KEY_URL = 'https://brave-browser-rpm-release.s3.brave.com/brave-core.asc';
my $BRAVE_REPO_FILE = '/etc/yum.repos.d/brave-browser.repo';
my $BRAVE_KEY_FILE = '/etc/pki/rpm-gpg/RPM-GPG-KEY-brave-browser';
# Primary-key fingerprints of the official Brave "Linux Package Repositories, Release
# Channel" keys, from https://brave.com/signing-keys/ (the 2023 key plus the 2025
# rotations). If Brave rotates again, verification fails loudly: update this set
# from the same page rather than weakening the check.
my %BRAVE_KEY_FINGERPRINTS = map { $_ => 1 } (
'DBF1A116C220B8C7164F98230686B78420038257', # Brave Linux Release (2023)
'47D32A74E9A9E013A4B4926C68D513D36A73CD96', # Brave Linux Release (2025, _mrk)
'B2A3DCA350E67256740DF904DE4EC67BE4B0DCA0', # Brave Linux Release (2025, _v2)
);
# Supported systems, in the order the messages list them. The workstation is a
# Fedora desktop; the server-facing scripts are the ones that carry the other
# systems.
my @SUPPORTED_ORDER = ('fedora');
my %SUPPORTED_OS = (
fedora => 'Fedora',
);
# Pre-installed packages to remove, skipped when already absent.
my @REMOVE_PACKAGES = qw(firefox gnome-system-monitor yelp mediawriter);
# RPM packages to install. Go is deliberately absent: the toolchain comes from the
# official go.dev tarball instead.
my @RPM_PACKAGES = qw(brave-browser git rustup just);
# Flatpak applications to install.
my @FLATPAK_APPS = qw(
org.remmina.Remmina org.telegram.desktop fr.handbrake.ghb com.rustdesk.RustDesk
org.gimp.GIMP net.mediaarea.MediaInfo net.nokyan.Resources app.drey.EarTag
org.bunkus.mkvtoolnix-gui org.fedoraproject.MediaWriter org.gnome.Firmware
);
# RPM counterparts of the Flatpak apps we install: when such an RPM is present it is
# removed first, so each application has a single source of truth. Apps without an
# official Fedora RPM counterpart (net.nokyan.Resources, app.drey.EarTag) are absent
# on purpose.
my @FLATPAK_RPM_ORDER = qw(
org.remmina.Remmina org.telegram.desktop fr.handbrake.ghb com.rustdesk.RustDesk
org.gimp.GIMP net.mediaarea.MediaInfo org.bunkus.mkvtoolnix-gui
org.fedoraproject.MediaWriter org.gnome.Firmware
);
my %FLATPAK_RPM_ALTERNATIVES = (
'org.remmina.Remmina' => 'remmina',
'org.telegram.desktop' => 'telegram-desktop',
'fr.handbrake.ghb' => 'HandBrake-gui',
'com.rustdesk.RustDesk' => 'rustdesk',
'org.gimp.GIMP' => 'gimp',
'net.mediaarea.MediaInfo' => 'mediainfo',
'org.bunkus.mkvtoolnix-gui' => 'mkvtoolnix-gui',
'org.fedoraproject.MediaWriter' => 'mediawriter',
'org.gnome.Firmware' => 'gnome-firmware',
);
# Flatpak counterparts of the RPM packages we install: uninstalled first.
my %RPM_FLATPAK_ALTERNATIVES = (
'brave-browser' => 'com.brave.Browser',
);
# The optional clock, loaded once and guarded where it is used.
my $HAVE_HIRES = eval { require Time::HiRes; 1 } ? 1 : 0;
my $TMP_DIR; # private scratch directory, created only when needed
my $PARENT_PID = $$; # a forked child must never clean up for the parent
my $RUN_SEQ = 0; # per-call suffix for the runner's files
# ---------------------------------------------------------------------------
# Progress, on stderr so stdout stays clean
# ---------------------------------------------------------------------------
sub _status {
my ($msg) = @_;
print STDERR " $msg...";
return;
}
sub _status_done {
my ($msg) = @_;
$msg = 'done' unless defined $msg;
print STDERR " $msg\n";
return;
}
sub _info {
my ($msg) = @_;
print STDERR " ${DIM}$msg$RESET\n";
return;
}
sub _warn {
my ($msg) = @_;
print STDERR " ${YELLOW}⚠ $msg$RESET\n";
return;
}
sub _ok {
my ($msg) = @_;
print STDERR " ${GREEN}✓ $msg$RESET\n";
return;
}
sub _fail {
my ($msg) = @_;
print STDERR " ${RED}✗ $msg$RESET\n";
return;
}
# ---------------------------------------------------------------------------
# Commands, files and small helpers
# ---------------------------------------------------------------------------
sub now {
return $HAVE_HIRES ? Time::HiRes::time() : time();
}
# A hand-rolled which(1), so that the lookup itself needs no external binary.
sub find_exe {
my ($name) = @_;
return undef unless defined $name && length $name;
if (index($name, '/') >= 0) {
return (-f $name && -x _) ? $name : undef;
}
for my $dir (split /:/, ($ENV{PATH} // '')) {
next unless length $dir;
my $path = "$dir/$name";
return $path if -f $path && -x _;
}
return undef;
}
sub scratch_dir {
return $TMP_DIR if defined $TMP_DIR;
my $base = $ENV{TMPDIR} // '/tmp';
for my $attempt (0 .. 9) {
my $dir = "$base/workstation-setup.$$" . ($attempt ? ".$attempt" : '');
if (mkdir($dir, 0700)) {
$TMP_DIR = $dir;
return $dir;
}
}
die "cannot create a scratch directory under $base\n";
}
sub remove_scratch {
return unless defined $TMP_DIR;
# Only the process that created the directory may remove it: a forked child
# inherits the END block.
return unless $$ == $PARENT_PID;
if (opendir(my $dh, $TMP_DIR)) {
for my $entry (readdir($dh)) {
next if $entry eq '.' || $entry eq '..';
unlink("$TMP_DIR/$entry");
}
closedir($dh);
}
rmdir($TMP_DIR);
undef $TMP_DIR;
return;
}
sub slurp {
my ($path) = @_;
open(my $fh, '<', $path) or return '';
my $text = do { local $/ = undef; <$fh> };
close($fh);
return defined $text ? $text : '';
}
# Run a command and return { rc, out, err }.
#
# The locale is forced to C for English output parsing. A timeout, a missing binary
# and a failed exec become rc 124, 127 and 126 rather than exceptions, so callers
# always have a result to inspect. With use_sudo the command is run through sudo.
sub run {
my ($cmd, %opt) = @_;
my $timeout = $opt{timeout} // 60;
my @full = $opt{use_sudo} ? ('sudo', @$cmd) : @$cmd;
my $exe = find_exe($full[0]);
return { rc => 127, out => '', err => "command not found: $full[0]" } unless defined $exe;
my $dir = scratch_dir();
$RUN_SEQ++;
my $out_file = "$dir/out.$$.$RUN_SEQ";
my $err_file = "$dir/err.$$.$RUN_SEQ";
my $pid = fork();
die "cannot fork: $!\n" unless defined $pid;
if ($pid == 0) {
if (open(STDOUT, '>', $out_file) && open(STDERR, '>', $err_file)) {
$ENV{LANG} = 'C';
$ENV{LC_ALL} = 'C';
exec { $exe } @full;
}
exit 126;
}
my $timed_out = 0;
eval {
local $SIG{ALRM} = sub { die "alarm\n" };
alarm($timeout);
waitpid($pid, 0);
alarm(0);
1;
} or do { $timed_out = 1; alarm(0) };
my $rc;
if ($timed_out) {
kill('TERM', $pid);
select(undef, undef, undef, 0.1);
kill('KILL', $pid);
waitpid($pid, 0);
$rc = 124;
}
else {
# A child killed by a signal must not look like success: $? >> 8 is 0
# for a signalled exit, so the signal becomes a shell-style 128+n code.
my $signal = $? & 127;
$rc = $signal ? 128 + $signal : ($? >> 8);
}
my $out = slurp($out_file);
my $err = slurp($err_file);
unlink($out_file, $err_file);
return {
rc => $rc,
out => $out,
err => $timed_out ? "timed out after ${timeout}s" : $err,
};
}
# The command list that reaches the target command as the real user: the sudo
# prefix with optional env(1) assignments, then the command itself as one flat
# list. Building the list separately is what keeps the command's arguments out
# of the runner's option hash.
sub user_command {
my ($cmd, $env) = @_;
my $sudo_user = $ENV{SUDO_USER} // '';
return [@$cmd] unless length $sudo_user;
my @full = ('sudo', '-u', $sudo_user);
if ($env && %$env) {
push @full, 'env', map { "$_=$env->{$_}" } sort keys %$env;
}
push @full, @$cmd;
return \@full;
}
# Run a command as the original user rather than as root, when the script was
# started through sudo. Extra environment variables go through env(1) so they
# survive sudo's environment reset.
sub run_as_user {
my ($cmd, %opt) = @_;
return run(user_command($cmd, $opt{env}), timeout => $opt{timeout} // 60);
}
# ---------------------------------------------------------------------------
# OS detection
# ---------------------------------------------------------------------------
sub parse_os_release {
my %release;
open(my $fh, '<', '/etc/os-release') or return \%release;
while (my $line = <$fh>) {
$line =~ s/^\s+//;
$line =~ s/\s+$//;
next unless length $line;
next if index($line, '#') == 0;
next unless index($line, '=') >= 0;
my ($key, $value) = split /=/, $line, 2;
$value = '' unless defined $value;
for my $quote ('"', "'") {
$value =~ s/^\Q$quote\E+//;
$value =~ s/\Q$quote\E+$//;
}
$release{$key} = $value;
}
close($fh);
return \%release;
}
sub detect_os {
my $release = parse_os_release();
my $os_id = lc($release->{ID} // '');
my $version_id = $release->{VERSION_ID} // 'unknown';
if (!exists $SUPPORTED_OS{$os_id}) {
print STDERR "${RED}${BOLD}Error:$RESET Unsupported operating system: "
. "'" . ($release->{ID} // 'unknown') . "' (detected from /etc/os-release).\n";
print STDERR " Supported systems: "
. join(', ', map { $SUPPORTED_OS{$_} } @SUPPORTED_ORDER) . "\n";
exit 1;
}
return ($os_id, $SUPPORTED_OS{$os_id}, $version_id);
}
# ---------------------------------------------------------------------------
# Section helpers
# ---------------------------------------------------------------------------
sub rpm_installed {
my ($pkg) = @_;
return run(['rpm', '-q', $pkg])->{rc} == 0;
}
sub have_flatpak {
my ($app) = @_;
return run(['flatpak', 'info', $app])->{rc} == 0;
}
sub flatpak_scopes {
my ($app) = @_;
my @found;
push @found, 'system' if run(['flatpak', 'info', $app])->{rc} == 0;
push @found, 'user' if run_as_user(['flatpak', 'info', '--user', $app])->{rc} == 0;
return @found;
}
sub remove_conflicting_rpm {
my ($app, $dry_run) = @_;
my $rpm_name = $FLATPAK_RPM_ALTERNATIVES{$app};
return undef unless defined $rpm_name;
return undef unless rpm_installed($rpm_name);
_warn("$app: also installed as RPM '$rpm_name', removing it to avoid a duplicate");
if ($dry_run) {
_info("Would remove RPM package: $rpm_name");
return $rpm_name;
}
my $result = run(['dnf', 'remove', '-y', $rpm_name], timeout => $DNF_TIMEOUT, use_sudo => 1);
if ($result->{rc} == 0) {
_ok("Removed RPM package: $rpm_name");
return $rpm_name;
}
_fail("Failed to remove RPM package $rpm_name");
return undef;
}
sub remove_conflicting_flatpak {
my ($pkg, $dry_run) = @_;
my $app_id = $RPM_FLATPAK_ALTERNATIVES{$pkg};
return undef unless defined $app_id;
my $removed = 0;
for my $scope (flatpak_scopes($app_id)) {
_warn("$pkg: also installed as Flatpak '$app_id' ($scope), removing it to avoid a duplicate");
if ($dry_run) {
_info("Would uninstall Flatpak app: $app_id ($scope)");
$removed = 1;
next;
}
my $result = $scope eq 'user'
? run_as_user(['flatpak', 'uninstall', '-y', '--user', $app_id], timeout => 300)
: run(['flatpak', 'uninstall', '-y', $app_id], timeout => 300, use_sudo => 1);
if ($result->{rc} == 0) {
_ok("Uninstalled Flatpak app: $app_id ($scope)");
$removed = 1;
}
else {
_fail("Failed to uninstall Flatpak app $app_id ($scope)");
}
}
return $removed ? $app_id : undef;
}
# The passwd entry of the real, non-root user: nothing to drop to means the script
# runs in a real root shell, or SUDO_USER does not resolve.
sub real_user {
my $sudo_user = $ENV{SUDO_USER} // '';
if (length $sudo_user) {
my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir) = getpwnam($sudo_user);
return undef unless defined $uid;
return { name => $name, uid => $uid, gid => $gid, dir => $dir };
}
return undef if $> == 0;
my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir) = getpwuid($>);
return undef unless defined $uid;
return { name => $name, uid => $uid, gid => $gid, dir => $dir };
}
# The errno, the reason and the path, so a failure message names all three.
sub os_error_text {
my ($path) = @_;
return "[Errno " . (0 + $!) . "] $!: '$path'";
}
sub fsync_path {
my ($path) = @_;
my $sync = find_exe('sync');
return unless defined $sync;
system { $sync } $sync, $path;
return;
}
# Replace a file with new content, preserving its mode and owner.
#
# /etc/selinux/config must never be left truncated by a crash mid-write: the content
# goes into a temporary file beside the target, which is then renamed over it. Perl's
# builtins expose no fsync, so that barrier is delegated to sync(1) where it exists.
sub atomic_write {
my ($path, $content) = @_;
my ($mode, $uid, $gid) = (0644, 0, 0);
my @st = stat($path);
if (@st) {
$mode = $st[2] & 07777;
$uid = $st[4];
$gid = $st[5];
}
my $tmp = "$path.$$.tmp";
my $ok = eval {
open(my $fh, '>', $tmp) or die os_error_text($tmp) . "\n";
print {$fh} $content or die os_error_text($tmp) . "\n";
close($fh) or die os_error_text($tmp) . "\n";
fsync_path($tmp);
chmod($mode, $tmp) or die os_error_text($tmp) . "\n";
chown($uid, $gid, $tmp) or die os_error_text($tmp) . "\n";
rename($tmp, $path) or die "[Errno " . (0 + $!) . "] $!: '$tmp' -> '$path'\n";
1;
};
if (!$ok) {
my $error = $@ || 'unknown error';
unlink($tmp);
die $error;
}
return;
}
sub real_home {
my $real = real_user();
return $real->{dir} if $real;
my $home = $ENV{HOME} // '';
return length $home ? $home : '/root';
}
sub chown_user {
my ($path) = @_;
my $real = real_user();
return unless $real;
chown($real->{uid}, $real->{gid}, $path);
return;
}
# Absolute path to a tool: PATH first, then the user's home locations.
sub tool_path {
my ($name, @home_rel) = @_;
my $found = find_exe($name);
return $found if defined $found;
my $home = real_home();
for my $rel (@home_rel) {
my $candidate = "$home/$rel";
return $candidate if -f $candidate && -x _;
}
return undef;
}
sub probe_version {
my ($cmd, %opt) = @_;
my $as_user = exists $opt{as_user} ? $opt{as_user} : 1;
my $result = $as_user ? run_as_user($cmd) : run($cmd);
return 'unknown' if $result->{rc} != 0;
my $out = $result->{out};
$out =~ s/^\s+//;
$out =~ s/\s+$//;
return 'unknown' unless length $out;
my ($first) = split /\n/, $out;
return $first;
}
sub download {
my ($url, $dest, %opt) = @_;
my $timeout = $opt{timeout} // 300;
return run(['curl', '-fsSL', '-o', $dest, $url], timeout => $timeout, use_sudo => 1)->{rc} == 0;
}
# The SHA-256 digest of a file. Builtins have none, so sha256sum is the transport and
# the digest is compared exactly as before.
sub sha256_file {
my ($path) = @_;
my $result = run(['sha256sum', '--', $path], timeout => 900);
return '' if $result->{rc} != 0;
my @fields = split ' ', $result->{out};
return @fields ? lc $fields[0] : '';
}
sub sha256_from_sums {
my ($sums_file, $filename) = @_;
open(my $fh, '<', $sums_file) or return undef;
while (my $line = <$fh>) {
my @parts = split ' ', $line;
next unless @parts == 2;
my $name = $parts[1];
$name =~ s/^\*//;
$name =~ s/\s+$//;
if ($name eq $filename) {
close($fh);
return lc $parts[0];
}
}
close($fh);
return undef;
}
# Fingerprints of the primary keys in an ASCII-armoured key file, through gpg. Only
# the fpr record that directly follows a pub record counts: subkey fingerprints are
# ignored, so the result compares against a set of primary keys.
sub asc_fingerprints {
my ($key_file) = @_;
return () unless defined find_exe('gpg');
my $result = run(['gpg', '--show-keys', '--with-colons', $key_file]);
return () unless $result->{rc} == 0;
my (@fingerprints, $last_record);
for my $line (split /\n/, $result->{out}) {
my @parts = split /:/, $line, -1;
next if @parts < 10;
if ($parts[0] eq 'pub' || $parts[0] eq 'sub') {
$last_record = $parts[0];
}
elsif ($parts[0] eq 'fpr' && ($last_record // '') eq 'pub' && length $parts[9]) {
push @fingerprints, uc $parts[9];
}
}
return @fingerprints;
}
# Copy a file with builtins, replacing the destination.
sub copy_file {
my ($from, $to) = @_;
my $content = slurp($from);
my $ok = eval {
open(my $fh, '>', $to) or die os_error_text($to) . "\n";
print {$fh} $content or die os_error_text($to) . "\n";
close($fh) or die os_error_text($to) . "\n";
1;
};
die $@ unless $ok;
return;
}
# A directory removed with everything under it.
sub remove_tree {
my ($path) = @_;
return unless -d $path;
if (opendir(my $dh, $path)) {
for my $entry (readdir($dh)) {
next if $entry eq '.' || $entry eq '..';
my $full = "$path/$entry";
if (-l $full) { unlink($full) }
elsif (-d $full) { remove_tree($full) }
else { unlink($full) }
}
closedir($dh);
}
rmdir($path);
return;
}
sub make_dirs {
my ($path, $mode) = @_;
$mode = 0755 unless defined $mode;
my @parts = split m{/}, $path;
my $current = '';
for my $part (@parts) {
next unless length $part;
$current .= "/$part";
next if -d $current;
mkdir($current, $mode) or return 0;
}
return 1;
}
# A temporary directory removed when the block ends, wherever it lives.
sub make_temp_dir {
my (%opt) = @_;
my $base = $opt{dir} // ($ENV{TMPDIR} // '/tmp');
my $prefix = $opt{prefix} // 'workstation-setup-';
for my $attempt (0 .. 9) {
my $dir = "$base/${prefix}$$.$attempt";
return $dir if mkdir($dir, 0700);
}
return undef;
}
# ---------------------------------------------------------------------------
# JSON, written by hand
# ---------------------------------------------------------------------------
#
# The go.dev download API and the JetBrains releases API both answer in JSON, and no
# JSON module may be assumed. The decoder covers the grammar the format allows.
sub json_decode {
my ($text) = @_;
$text = '' unless defined $text;
my $pos = 0;
my $value = json_parse_value($text, \$pos);
json_skip_space($text, \$pos);
die "trailing data at offset $pos\n" if $pos < length $text;
return $value;
}
sub json_skip_space {
my ($text, $pos_ref) = @_;
my $length = length $text;
while ($$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[ \t\r\n]/) {
$$pos_ref++;
}
return;
}
sub json_parse_value {
my ($text, $pos_ref) = @_;
json_skip_space($text, $pos_ref);
die "unexpected end of input at offset $$pos_ref\n" if $$pos_ref >= length $text;
my $char = substr($text, $$pos_ref, 1);
return json_parse_object($text, $pos_ref) if $char eq '{';
return json_parse_array($text, $pos_ref) if $char eq '[';
return json_parse_string($text, $pos_ref) if $char eq '"';
return json_parse_number($text, $pos_ref) if $char =~ /[-0-9]/;
for my $literal (['true', 1], ['false', 0], ['null', undef]) {
my ($word, $value) = @$literal;
if (substr($text, $$pos_ref, length $word) eq $word) {
$$pos_ref += length $word;
return $value;
}
}
die "unrecognised token at offset $$pos_ref\n";
}
sub json_parse_object {
my ($text, $pos_ref) = @_;
my %object;
$$pos_ref++;
json_skip_space($text, $pos_ref);
if (substr($text, $$pos_ref, 1) eq '}') {
$$pos_ref++;
return \%object;
}
while (1) {
json_skip_space($text, $pos_ref);
die "expected a key at offset $$pos_ref\n" unless substr($text, $$pos_ref, 1) eq '"';
my $key = json_parse_string($text, $pos_ref);
json_skip_space($text, $pos_ref);
die "expected a colon at offset $$pos_ref\n" unless substr($text, $$pos_ref, 1) eq ':';
$$pos_ref++;
$object{$key} = json_parse_value($text, $pos_ref);
json_skip_space($text, $pos_ref);
my $next = substr($text, $$pos_ref, 1);
if ($next eq ',') { $$pos_ref++; next }
if ($next eq '}') { $$pos_ref++; last }
die "expected a comma or a closing brace at offset $$pos_ref\n";
}
return \%object;
}
sub json_parse_array {
my ($text, $pos_ref) = @_;
my @items;
$$pos_ref++;
json_skip_space($text, $pos_ref);
if (substr($text, $$pos_ref, 1) eq ']') {
$$pos_ref++;
return \@items;
}
while (1) {
push @items, json_parse_value($text, $pos_ref);
json_skip_space($text, $pos_ref);
my $next = substr($text, $$pos_ref, 1);
if ($next eq ',') { $$pos_ref++; next }
if ($next eq ']') { $$pos_ref++; last }
die "expected a comma or a closing bracket at offset $$pos_ref\n";
}
return \@items;
}
sub json_parse_string {
my ($text, $pos_ref) = @_;
$$pos_ref++;
my $out = '';
my $length = length $text;
my %simple = ('"' => '"', '\\' => '\\', '/' => '/', 'b' => "\b", 'f' => "\f",
'n' => "\n", 'r' => "\r", 't' => "\t");
while ($$pos_ref < $length) {
my $char = substr($text, $$pos_ref, 1);
if ($char eq '"') {
$$pos_ref++;
return $out;
}
if ($char ne '\\') {
$out .= $char;
$$pos_ref++;
next;
}
$$pos_ref++;
my $escape = substr($text, $$pos_ref, 1);
if (exists $simple{$escape}) {
$out .= $simple{$escape};
$$pos_ref++;
next;
}
die "unrecognised escape at offset $$pos_ref\n" unless $escape eq 'u';
my $hex = substr($text, $$pos_ref + 1, 4);
die "malformed \\u escape at offset $$pos_ref\n" unless $hex =~ /^[0-9a-fA-F]{4}$/;
my $code = hex $hex;
$$pos_ref += 5;
if ($code >= 0xd800 && $code <= 0xdbff && substr($text, $$pos_ref, 2) eq '\\u') {
my $low_hex = substr($text, $$pos_ref + 2, 4);
if ($low_hex =~ /^[0-9a-fA-F]{4}$/) {
my $low = hex $low_hex;
if ($low >= 0xdc00 && $low <= 0xdfff) {
$code = 0x10000 + (($code - 0xd800) << 10) + ($low - 0xdc00);
$$pos_ref += 6;
}
}
}
my $bytes = pack('U', $code);
utf8::encode($bytes);
$out .= $bytes;
}
die "unterminated string\n";
}
sub json_parse_number {
my ($text, $pos_ref) = @_;
my $length = length $text;
my $start = $$pos_ref;
$$pos_ref++ if substr($text, $$pos_ref, 1) eq '-';
$$pos_ref++ while $$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[0-9]/;
if ($$pos_ref < $length && substr($text, $$pos_ref, 1) eq '.') {
$$pos_ref++;
$$pos_ref++ while $$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[0-9]/;
}
if ($$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[eE]/) {
$$pos_ref++;
$$pos_ref++ if substr($text, $$pos_ref, 1) =~ /[-+]/;
$$pos_ref++ while $$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[0-9]/;
}
my $literal = substr($text, $start, $$pos_ref - $start);
unless ($literal =~ /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/) {
die "malformed number at offset $start\n";
}
return $literal + 0;
}
# ---------------------------------------------------------------------------
# 1. System update
# ---------------------------------------------------------------------------
sub system_update {
my ($dry_run) = @_;
my %info = (updated => 0, skipped => 0, would_update => 0, error => 0);
_status('Checking for system updates');
my $check_result = run(['dnf', 'check-update'], timeout => 300, use_sudo => 1);
# dnf check-update: 0 means no updates, 100 means updates are available, anything
# else is an error.
if ($check_result->{rc} == 0) {
_status_done('already up to date');
$info{skipped} = 1;
return \%info;
}
if ($check_result->{rc} != 100) {
_status_done('check failed');
my $error = $check_result->{err};
$error =~ s/^\s+//;
$error =~ s/\s+$//;
$error = 'unknown error' unless length $error;
_fail("dnf check-update failed: $error");
$info{error} = 1;
return \%info;
}
_status_done('updates available');
if ($dry_run) {
_info('Would run: dnf update -y');
$info{would_update} = 1;
return \%info;
}
_status('Applying system updates');
my $update_result = run(['dnf', 'update', '-y'], timeout => $DNF_UPDATE_TIMEOUT, use_sudo => 1);
if ($update_result->{rc} == 0) {
_status_done();
$info{updated} = 1;
}
else {
_status_done('failed');
_fail('System update returned non-zero exit code');
$info{error} = 1;
}
return \%info;
}
# ---------------------------------------------------------------------------
# 2. Remove pre-installed apps
# ---------------------------------------------------------------------------
sub remove_packages {
my ($dry_run) = @_;
my (@removed, @skipped);
for my $pkg (@REMOVE_PACKAGES) {
_status("Checking $pkg");
if (!rpm_installed($pkg)) {
_status_done('not installed, skipping');
push @skipped, $pkg;
next;
}
if ($dry_run) {
_status_done('would remove');
push @removed, $pkg;
next;
}
my $result = run(['dnf', 'remove', '-y', $pkg], timeout => $DNF_TIMEOUT, use_sudo => 1);
if ($result->{rc} == 0) {
_status_done('removed');
push @removed, $pkg;
}
else {
_status_done('failed');
_fail("Failed to remove $pkg");
}
}
return { removed => \@removed, skipped => \@skipped };
}
# ---------------------------------------------------------------------------
# 3. Third-party RPM repos
# ---------------------------------------------------------------------------
# Download the Brave signing key, verify its fingerprints, import it, and keep the
# verified bytes beside the repo file.
sub do_import_brave_key {
if (!defined find_exe('gpg')) {
_fail('gpg not found, cannot verify the Brave signing key');
return 0;
}
my $tmp = make_temp_dir();
return 0 unless defined $tmp;
my $key_file = "$tmp/brave-core.asc";
my $failed = 0;
if (!download($BRAVE_KEY_URL, $key_file, timeout => 60)) {
_fail('Failed to download the Brave signing key');
$failed = 1;
}
my @fingerprints = $failed ? () : asc_fingerprints($key_file);
if (!$failed && !@fingerprints) {
_fail('No keys found in the downloaded Brave key file');
$failed = 1;
}
# Every fingerprint in the file has to be one of the published keys.
my @unknown = grep { !$BRAVE_KEY_FINGERPRINTS{$_} } @fingerprints;
if (!$failed && @unknown) {
my @expected = sort keys %BRAVE_KEY_FINGERPRINTS;
_fail('Brave signing key fingerprint mismatch, expected one of: '
. join(', ', @expected) . '; got: ' . join(', ', sort @fingerprints));
$failed = 1;
}
if (!$failed) {
_ok('Brave signing key fingerprint verified');
# Persist the verified bytes: the repo file's gpgkey= is pinned to this local
# copy, so dnf can only import exactly these keys.
my $key_dir = $BRAVE_KEY_FILE;
$key_dir =~ s{/[^/]+$}{};
if (make_dirs($key_dir)) {
my $copied = eval { copy_file($key_file, $BRAVE_KEY_FILE); 1 };
if ($copied) {
chmod(0644, $BRAVE_KEY_FILE);
}
else {
my $error = $@;
$error =~ s/\s+\z//;
# A half-written copy must not become the pinned gpgkey= target.
unlink($BRAVE_KEY_FILE);
_warn("Could not store the verified key at $BRAVE_KEY_FILE: $error");
}
}
my $import = run(['rpm', '--import', $key_file], use_sudo => 1);
if ($import->{rc} != 0) {
_fail('rpm --import failed for the Brave signing key');
$failed = 1;
}
}
remove_tree($tmp);
return $failed ? 0 : 1;
}
# Pin the repo file's gpgkey= line to the local copy of the verified key. The
# paths are parameters so the rewrite can be exercised away from /etc.
sub pin_brave_repo_gpgkey {
my ($repo_file, $key_file) = @_;
$repo_file //= $BRAVE_REPO_FILE;
$key_file //= $BRAVE_KEY_FILE;
my $read_ok = open(my $repo_fh, '<', $repo_file);
my $read_error = $read_ok ? '' : os_error_text($repo_file);
my $content;
if ($read_ok) {
$content = do { local $/ = undef; <$repo_fh> };
close($repo_fh);
}
if (!$read_ok || !defined $content) {
_warn("Cannot read $repo_file: $read_error");
return 0;
}
my $pinned = "gpgkey=file://$key_file";
return 1 if index($content, $pinned) >= 0;
if (!-f $key_file) {
_warn("$key_file not found, leaving the repo gpgkey= unpinned");
return 0;
}
my (@new_lines, $changed);
$changed = 0;
for my $line (split /\n/, $content, -1) {
if (!length $line) {
push @new_lines, $line;
next;
}
my $stripped = $line;
$stripped =~ s/^\s+//;
if (index($stripped, 'gpgkey=') == 0) {
$line = $pinned;
$changed = 1;
}
push @new_lines, $line;
}
if (!$changed) {
_warn("No gpgkey= line found in $repo_file");
return 0;
}
my $ok = eval { atomic_write($repo_file, join("\n", @new_lines) . "\n"); 1 };
if (!$ok) {
my $error = $@;
$error =~ s/\s+\z//;
_warn("Cannot update $repo_file: $error");
return 0;
}
return 1;
}
# The Brave repo, with the config-manager syntax the installed dnf understands.
sub add_brave_repo {
my @cmd = defined find_exe('dnf5')
? ('dnf', 'config-manager', 'addrepo', "--from-repofile=$BRAVE_REPO_URL")
: ('dnf', 'config-manager', '--add-repo', $BRAVE_REPO_URL);
my $result = run(\@cmd, timeout => 120, use_sudo => 1);
if ($result->{rc} != 0) {
_fail('Failed to add the Brave repo (on dnf4 systems this requires '
. 'the dnf-plugins-core package)');
return 0;
}
return 1;
}
sub setup_repos {
my ($dry_run) = @_;
my (@added, @skipped);
my $repo_name = 'brave-browser';
_status("Checking $repo_name repo");
if (-e $BRAVE_REPO_FILE) {
_status_done('already present');
if (!$dry_run) {
if (!-f $BRAVE_KEY_FILE) {
# The repo predates this script's key pinning: fetch, verify and
# import the key so gpgkey= can be pinned to a real file.
do_import_brave_key();
}
pin_brave_repo_gpgkey();
}
push @skipped, $repo_name;
}
elsif ($dry_run) {
_status_done('would verify + import GPG key, would add repo');
push @added, $repo_name;
}
else {
if (do_import_brave_key() && add_brave_repo()) {
pin_brave_repo_gpgkey();
_status_done('added');
push @added, $repo_name;
}
else {
_status_done('failed');
}
}
return { added => \@added, skipped => \@skipped };
}
# ---------------------------------------------------------------------------
# 4. RPM packages
# ---------------------------------------------------------------------------
sub install_rpm_packages {
my ($dry_run) = @_;
my (@installed, @skipped, @failed, @to_install);
_status('Checking RPM packages');
for my $pkg (@RPM_PACKAGES) {
if (rpm_installed($pkg)) { push @skipped, $pkg }
else { push @to_install, $pkg }
}
my $already = scalar @skipped;
my $missing = scalar @to_install;
my $total = scalar @RPM_PACKAGES;
_status_done("$already/$total already installed");
# Flatpak counterparts of the requested RPM packages go first, so the RPM becomes
# the single source of truth.
my @removed_flatpak;
for my $pkg (@RPM_PACKAGES) {
my $app_id = remove_conflicting_flatpak($pkg, $dry_run);
push @removed_flatpak, $app_id if defined $app_id;
}
if (!@to_install) {
_ok('All RPM packages already present');
return {
installed => \@installed,
skipped => \@skipped,
failed => \@failed,
removed_flatpak => \@removed_flatpak,
};
}
_info('Installing ' . $missing . ' package(s): ' . join(' ', @to_install));
if ($dry_run) {
for my $pkg (@to_install) {
_info(" Would install: $pkg");
push @installed, $pkg;
}
return {
installed => \@installed,
skipped => \@skipped,
failed => \@failed,
removed_flatpak => \@removed_flatpak,
};
}
_status("Installing $missing package(s)");
my $batch = run(['dnf', 'install', '-y', @to_install], timeout => $DNF_TIMEOUT, use_sudo => 1);
if ($batch->{rc} == 0) {
_status_done();
push @installed, @to_install;
return {
installed => \@installed,
skipped => \@skipped,
failed => \@failed,
removed_flatpak => \@removed_flatpak,
};
}
_status_done('batch failed, retrying one by one');
for my $pkg (@to_install) {
_status("Installing $pkg");
my $result = run(['dnf', 'install', '-y', $pkg], timeout => $DNF_TIMEOUT, use_sudo => 1);
if ($result->{rc} == 0) {
_status_done();
push @installed, $pkg;
}
else {
_status_done('failed');
_fail("Failed to install $pkg");
push @failed, $pkg;
}
}
return {
installed => \@installed,
skipped => \@skipped,
failed => \@failed,
removed_flatpak => \@removed_flatpak,
};
}
# ---------------------------------------------------------------------------
# 5. Shell PATH exports
# ---------------------------------------------------------------------------
# Append export lines to ~/.bashrc unless each is already present. Matching on the
# export line content rather than on the surrounding block means a block written by an
# upstream installer is recognised too. Returns true when a block was appended.
sub add_bashrc_lines {
my ($lines, $comment) = @_;
my $home = real_home();
my $bashrc = "$home/.bashrc";
if (!-e $bashrc) {
# A missing bashrc must not silently drop the PATH exports: create it owned by
# the real user, so the lines land where bash reads them. The handle is
# declared before the test, because one declared inside it is scoped to that
# block and invisible afterwards.
my $create;
if (!open($create, '>>', $bashrc)) {
_warn("Cannot create $bashrc, PATH setup has to be done manually");
return 0;
}
close($create);
chown_user($bashrc);
}
my $content = slurp($bashrc);
my $all_present = 1;
for my $line (@$lines) {
$all_present = 0 unless index($content, $line) >= 0;
}
return 0 if $all_present;
my $append;
if (!open($append, '>>', $bashrc)) {
_warn("Cannot append to $bashrc, PATH setup has to be done manually");
return 0;
}
print {$append} "\n# $comment\n" . join("\n", @$lines) . "\n";
close($append);
return 1;
}
# ---------------------------------------------------------------------------
# 6. Go toolchain
# ---------------------------------------------------------------------------
# The architecture in Go's naming, or undef when it is not supported.
sub go_arch {
my $machine = uname_m();
return 'amd64' if $machine eq 'x86_64';
return 'arm64' if $machine eq 'aarch64';
return undef;
}
my $UNAME_M;
sub uname_m {
return $UNAME_M if defined $UNAME_M;
my $result = run(['uname', '-m'], timeout => 5);
my $machine = $result->{out};
$machine =~ s/^\s+//;
$machine =~ s/\s+$//;
$UNAME_M = $machine;
return $UNAME_M;
}
# The latest stable release for this architecture: (version, filename, sha256).
sub latest_go {
my $arch = go_arch();
if (!defined $arch) {
_fail('Unsupported architecture for Go: ' . uname_m());
return undef;
}
my $result = run(['curl', '-fsSL', $GO_DL_API_URL], timeout => 120);
return undef if $result->{rc} != 0;
my $releases = eval { json_decode($result->{out}) };
return undef if $@ || ref $releases ne 'ARRAY';
for my $release (@$releases) {
next unless ref $release eq 'HASH' && $release->{stable};
my $files = $release->{files};
next unless ref $files eq 'ARRAY';
for my $artifact (@$files) {
next unless ref $artifact eq 'HASH';
next unless ($artifact->{os} // '') eq 'linux';
next unless ($artifact->{arch} // '') eq $arch;
next unless ($artifact->{kind} // '') eq 'archive';
return (
"$release->{version}",
"$artifact->{filename}",
lc "$artifact->{sha256}",
);
}
}
return undef;
}
# The installed Go version (for example go1.27.0), or undef when absent.
sub installed_go_version {
my $go = find_exe('go');
if (!defined $go && -f "$GO_INSTALL_DIR/bin/go") {
$go = "$GO_INSTALL_DIR/bin/go";
}
return undef unless defined $go;
my $result = run([$go, 'version']);
return undef if $result->{rc} != 0;
# The output looks like: "go version go1.27.0 linux/amd64"
for my $token (split ' ', $result->{out}) {
return $token if index($token, 'go1.') == 0;
}
return undef;
}
sub setup_go {
my ($dry_run) = @_;
my %info = (installed => 0, version => '', planned => 0);
_status('Checking Go');
my ($go_version, $filename, $sha256) = latest_go();
if (!defined $go_version) {
_status_done('failed');
_fail('Could not determine the latest Go release from go.dev');
return \%info;
}
my $installed = installed_go_version();
if (defined $installed && $installed eq $go_version) {
_status_done($installed);
$info{installed} = 1;
$info{version} = $installed;
if ($dry_run) {
_info('Would add the Go PATH to ~/.bashrc if missing');
}
elsif (add_bashrc_lines(\@GO_BASHRC_LINES, 'Go')) {
_info('Added Go PATH to ~/.bashrc');
}
return \%info;
}
_status_done(defined $installed
? "$installed installed (latest: $go_version)"
: 'not installed');
if ($dry_run) {
_info('Would remove RPM package: golang') if rpm_installed('golang');
_info("Would download $filename from go.dev, verify SHA-256, "
. "and install to $GO_INSTALL_DIR");
$info{planned} = 1;
return \%info;
}
if (rpm_installed('golang')) {
_warn('Removing the golang RPM: the official toolchain becomes the only Go');
my $removed = run(['dnf', 'remove', '-y', 'golang'], timeout => $DNF_TIMEOUT, use_sudo => 1);
if ($removed->{rc} != 0) {
_fail('Failed to remove the golang RPM');
return \%info;
}
}
_status("Installing $go_version");
my $tmp = make_temp_dir();
if (!defined $tmp) {
_status_done('failed');
_fail('Could not create a temporary directory');
return \%info;
}
my $tarball = "$tmp/$filename";
if (!download("$GO_TARBALL_BASE_URL$filename", $tarball, timeout => 900)) {
_status_done('download failed');
_fail("Failed to download $filename from go.dev");
remove_tree($tmp);
return \%info;
}
my $actual = sha256_file($tarball);
if ($actual ne $sha256) {
_status_done('failed');
_fail("SHA-256 mismatch for $filename: expected $sha256, got $actual");
remove_tree($tmp);
return \%info;
}
_info('SHA-256 checksum verified');
# The official install procedure replaces the whole directory: removing it first
# stops old binaries from shadowing the new tree.
my $wipe = run(['rm', '-rf', $GO_INSTALL_DIR], use_sudo => 1);
if ($wipe->{rc} != 0) {
_status_done('failed');
_fail("Failed to remove the existing $GO_INSTALL_DIR");
remove_tree($tmp);
return \%info;
}
my $extract = run(['tar', '-C', '/usr/local', '-xzf', $tarball], timeout => 600, use_sudo => 1);
remove_tree($tmp);
if ($extract->{rc} != 0) {
_status_done('failed');
_fail("Failed to extract $filename to /usr/local");
return \%info;
}
if (add_bashrc_lines(\@GO_BASHRC_LINES, 'Go')) {
_info('Added Go PATH to ~/.bashrc');
}
my $version = installed_go_version();
_status_done(defined $version ? $version : 'installed');
if (defined $version) {
$info{installed} = 1;
$info{version} = $version;
}
else {
_fail('Go binary not usable after installation');
}
return \%info;
}
# ---------------------------------------------------------------------------
# 7. Rust toolchain
# ---------------------------------------------------------------------------
sub setup_rust {
my ($dry_run) = @_;
my %info = (installed => 0, version => '', planned => 0);
_status('Checking Rust');
my $rustc = tool_path('rustc', '.cargo/bin/rustc');
if (defined $rustc) {
$info{version} = probe_version([$rustc, '--version']);
_status_done($info{version});
$info{installed} = 1;
return \%info;
}
_status_done('not installed');
if ($dry_run) {
_info('Would run: rustup-init -y');
$info{planned} = 1;
return \%info;
}
my $rustup_init = tool_path('rustup-init');
if (!defined $rustup_init) {
_fail("rustup-init not found, install the 'rustup' RPM first "
. '(or do not pass --skip-rpm)');
return \%info;
}
_status('Installing Rust toolchain');
my $result = run_as_user([$rustup_init, '-y'], timeout => 300);
if ($result->{rc} == 0) {
$info{version} = probe_version([real_home() . '/.cargo/bin/rustc', '--version']);
_status_done($info{version});
$info{installed} = 1;
}
else {
_status_done('failed');
_fail('Rust installation returned non-zero exit code');
}
return \%info;
}
# ---------------------------------------------------------------------------
# 8. JetBrains IDEs
# ---------------------------------------------------------------------------
# The downloads-API key for this machine's architecture.
sub jetbrains_download_key {
return uname_m() eq 'aarch64' ? 'linuxARM64' : 'linux';
}
# The latest release entry for a product code, from the releases API.
sub latest_jetbrains {
my ($code) = @_;
my $url = "$JETBRAINS_RELEASES_URL?code=$code&latest=true&type=release";
my $result = run(['curl', '-fsSL', $url], timeout => 120);
return undef if $result->{rc} != 0;
my $releases = eval { json_decode($result->{out}) };
return undef if $@ || ref $releases ne 'HASH';
my $entries = $releases->{$code};
return undef unless ref $entries eq 'ARRAY' && @$entries;
return $entries->[0];
}
# Existing installation directories for an IDE under /opt.
sub jetbrains_install_dirs {
my ($name) = @_;
return () unless -d $JETBRAINS_INSTALL_ROOT;
my @entries;
if (opendir(my $dh, $JETBRAINS_INSTALL_ROOT)) {
@entries = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh);
closedir($dh);
}
my @dirs;
for my $entry (@entries) {
next unless index($entry, "$name-") == 0;
push @dirs, "$JETBRAINS_INSTALL_ROOT/$entry" if -d "$JETBRAINS_INSTALL_ROOT/$entry";
}
return @dirs;
}
# The icon file shipped in the IDE's bin/ directory, if there is one.
sub jetbrains_icon {
my ($install_dir, $name) = @_;
my $bin_dir = "$install_dir/bin";
my $lower = lc $name;
for my $candidate ("$lower.svg", "$lower.png") {
my $path = "$bin_dir/$candidate";
return $path if -f $path;
}
if (opendir(my $dh, $bin_dir)) {
my @entries = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh);
closedir($dh);
for my $entry (@entries) {
return "$bin_dir/$entry" if $entry =~ /\.svg$/;
}
}
return undef;
}
# A menu entry pointing at the verified launcher path.
sub write_jetbrains_desktop_entry {
my ($name, $install_dir, $launcher, $icon, $desktop_dir) = @_;
$desktop_dir //= $JETBRAINS_DESKTOP_DIR;
my $lower = lc $name;
my $path = "$desktop_dir/jetbrains-$lower.desktop";
my @lines = (
'[Desktop Entry]',
'Version=1.0',
'Type=Application',
"Name=$name",
"Exec=\"$launcher\" %f",
);
push @lines, "Icon=$icon" if defined $icon;
push @lines,
"Comment=$name by JetBrains",
'Categories=Development;IDE;',
'Terminal=false',
"StartupWMClass=jetbrains-$lower",
'StartupNotify=true';
if (!make_dirs($desktop_dir)) {
_warn("Could not write $path");
return 0;
}
my $ok = eval { atomic_write($path, join("\n", @lines) . "\n"); 1 };
if (!$ok) {
my $error = $@;
$error =~ s/\s+\z//;
_warn("Could not write $path: $error");
return 0;
}
chmod(0644, $path);
return 1;
}
# The command-line symlink and the menu entry, ensured on every run: an earlier
# interrupted run between the directory move and the links is healed by the next
# one. The directories are parameters so the links can be exercised away from
# /usr/local.
sub jetbrains_links {
my ($name, $install_dir, $bin_dir, $desktop_dir) = @_;
$bin_dir //= $JETBRAINS_BIN_DIR;
$desktop_dir //= $JETBRAINS_DESKTOP_DIR;
my $lower = lc $name;
my $launcher = "$install_dir/bin/$lower.sh";
if (!-f $launcher) {
_fail("Launcher not found: $launcher");
return 0;
}
my $bin_link = "$bin_dir/$lower";
if (-l $bin_link) {
my $target = readlink($bin_link) // '';
if ($target ne $launcher) {
unlink($bin_link)
and symlink($launcher, $bin_link)
or _warn("Could not repoint the $bin_link symlink: $!");
}
}
elsif (!-e $bin_link) {
symlink($launcher, $bin_link)
or _warn("Could not create the $bin_link symlink: $!");
}
else {
_warn("$bin_link already exists and is not a symlink, left in place");
}
write_jetbrains_desktop_entry($name, $install_dir, $launcher,
jetbrains_icon($install_dir, $name), $desktop_dir);
return 1;
}
# Install the latest release of one IDE system-wide.
sub setup_jetbrains_ide {
my ($code, $name, $dry_run) = @_;
my %info = (installed => 0, version => '', planned => 0);
_status("Checking $name");
my $release = latest_jetbrains($code);
if (!defined $release) {
_status_done('failed');
_fail("Could not determine the latest $name release from JetBrains");
return \%info;
}
my $version = "$release->{version}";
my $downloads = ref $release->{downloads} eq 'HASH' ? $release->{downloads} : {};
my $key = jetbrains_download_key();
my $download = ref $downloads->{$key} eq 'HASH' ? $downloads->{$key} : {};
my $link = "$download->{link}";
my $checksum_link = "$download->{checksumLink}";
if (!length($release->{version} // '') || !length($download->{link} // '')
|| !length($download->{checksumLink} // '')) {
_status_done('failed');
_fail("Incomplete $name release metadata from JetBrains");
return \%info;
}
my $install_dir = "$JETBRAINS_INSTALL_ROOT/$name-$version";
my @existing = jetbrains_install_dirs($name);
if (grep { $_ eq $install_dir } @existing) {
# Already at the latest release: the links are re-checked so an earlier
# interrupted run does not leave the IDE without a launcher.
if (jetbrains_links($name, $install_dir)) {
_status_done($version);
$info{installed} = 1;
$info{version} = $version;
}
else {
_status_done('broken install, the launcher is missing');
}
return \%info;
}
_status_done(@existing ? "older build present (latest: $version)" : 'not installed');
if ($dry_run) {
my $tar_name = $link;
$tar_name =~ s{.*/}{};
_info("Would download $tar_name, verify SHA-256, and install to $install_dir");
$info{planned} = 1;
return \%info;
}
_status("Installing $name $version");
my $tar_name = $link;
$tar_name =~ s{.*/}{};
my $tmp = make_temp_dir();
if (!defined $tmp) {
_status_done('failed');
_fail('Could not create a temporary directory');
return \%info;
}
my $tarball = "$tmp/$tar_name";
if (!download($link, $tarball, timeout => 1800)) {
_status_done('download failed');
_fail("Failed to download $tar_name");
remove_tree($tmp);
return \%info;
}
my $sums_path = "$tmp/checksums.sha256";
if (!download($checksum_link, $sums_path, timeout => 60)) {
_status_done('failed');
_fail("Failed to download the $name SHA-256 checksum");
remove_tree($tmp);
return \%info;
}
my $expected = sha256_from_sums($sums_path, $tar_name);
if (!defined $expected) {
_status_done('failed');
_fail("No checksum for $tar_name in the published SHA-256 file");
remove_tree($tmp);
return \%info;
}
my $actual = sha256_file($tarball);
if ($actual ne $expected) {
_status_done('failed');
_fail("SHA-256 mismatch for $tar_name: expected $expected, got $actual");
remove_tree($tmp);
return \%info;
}
_info('SHA-256 checksum verified');
# Extract into a staging directory first: a failure then never leaves a
# half-installed IDE in /opt, and the top-level directory is moved to its
# canonical name afterwards.
make_dirs($JETBRAINS_INSTALL_ROOT);
my $staging = make_temp_dir(dir => $JETBRAINS_INSTALL_ROOT, prefix => ".$name-stage-");
if (!defined $staging) {
_status_done('failed');
_fail("Could not create a staging directory in $JETBRAINS_INSTALL_ROOT");
remove_tree($tmp);
return \%info;
}
my $extract = run(['tar', '-C', $staging, '-xzf', $tarball], timeout => 1200, use_sudo => 1);
remove_tree($tmp);
if ($extract->{rc} != 0) {
_status_done('failed');
_fail("Failed to extract $tar_name");
remove_tree($staging);
return \%info;
}
my @entries;
if (opendir(my $dh, $staging)) {
@entries = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh);
closedir($dh);
}
if (@entries != 1 || !-d "$staging/$entries[0]") {
_status_done('failed');
_fail("Unexpected archive layout in $tar_name: " . join(', ', @entries));
remove_tree($staging);
return \%info;
}
remove_tree($install_dir) if -e $install_dir;
if (!rename("$staging/$entries[0]", $install_dir)) {
_status_done('failed');
_fail("Could not move the extracted $name into place: $!");
remove_tree($staging);
return \%info;
}
remove_tree($staging);
# Older versions go, so the latest release is the only one in /opt.
for my $old_dir (@existing) {
next if $old_dir eq $install_dir;
next unless -d $old_dir;
_warn("Removing previous $name install: $old_dir");
remove_tree($old_dir);
}
if (!jetbrains_links($name, $install_dir)) {
return \%info;
}
_status_done();
$info{installed} = 1;
$info{version} = $version;
return \%info;
}
sub setup_jetbrains {
my ($dry_run) = @_;
my %results;
for my $code (@JETBRAINS_ORDER) {
$results{$code} = setup_jetbrains_ide($code, $JETBRAINS_IDES{$code}, $dry_run);
}
return \%results;
}
# ---------------------------------------------------------------------------
# 9. Flatpak apps
# ---------------------------------------------------------------------------
# Names of the configured system-wide remotes, matched exactly rather than by
# substring.
sub flatpak_remote_names {
my $result = run(['flatpak', 'remotes']);
my %names;
for my $line (split /\n/, $result->{out}) {
my @tokens = split ' ', $line;
next unless @tokens;
next if $tokens[0] eq 'Name'; # the table header
$names{ $tokens[0] } = 1;
}
return %names;
}
sub setup_flatpak {
my ($dry_run) = @_;
my (@installed, @skipped, @failed, @removed_rpm);
my $remote_added = 0;
_status('Checking Flathub remote');
my %remotes = flatpak_remote_names();
if ($remotes{flathub}) {
_status_done('already present');
}
elsif ($dry_run) {
_status_done('would add');
$remote_added = 1;
}
else {
my $result = run(
['flatpak', 'remote-add', '--if-not-exists', 'flathub',
'https://flathub.org/repo/flathub.flatpakrepo'],
timeout => 120, use_sudo => 1,
);
if ($result->{rc} == 0) {
_status_done('added');
$remote_added = 1;
}
else {
_status_done('failed');
_fail('Failed to add Flathub remote');
}
}
for my $app (@FLATPAK_APPS) {
my $rpm_name = remove_conflicting_rpm($app, $dry_run);
push @removed_rpm, $rpm_name if defined $rpm_name;
_status("Checking $app");
if (have_flatpak($app)) {
_status_done('already installed');
push @skipped, $app;
next;
}
if ($dry_run) {
_status_done('would install');
push @installed, $app;
next;
}
my $result = run(['flatpak', 'install', '-y', 'flathub', $app],
timeout => 300, use_sudo => 1);
if ($result->{rc} == 0) {
_status_done('installed');
push @installed, $app;
}
else {
_status_done('failed');
_fail("Failed to install $app");
push @failed, $app;
}
}
return {
remote_added => $remote_added,
installed => \@installed,
skipped => \@skipped,
failed => \@failed,
removed_rpm => \@removed_rpm,
};
}
# ---------------------------------------------------------------------------
# 10. Firewall
# ---------------------------------------------------------------------------
# Ensure firewalld is installed, enabled and running. Unlike the server setup this
# does NOT change the default zone: the workstation default is left as it is.
sub setup_firewall {
my ($dry_run) = @_;
my %info = (installed => 0, enabled => 0, running => 0);
_status('Checking firewalld');
if (!rpm_installed('firewalld')) {
_status_done('not installed');
if ($dry_run) {
_info('Would install: firewalld');
}
else {
my $result = run(['dnf', 'install', '-y', 'firewalld'],
timeout => $DNF_TIMEOUT, use_sudo => 1);
if ($result->{rc} == 0) {
_ok('Installed firewalld');
$info{installed} = 1;
}
else {
_fail('Failed to install firewalld');
return \%info;
}
}
}
else {
_status_done('installed');
$info{installed} = 1;
}
_status('Enabling firewalld service');
my $enabled = run(['systemctl', 'is-enabled', 'firewalld.service']);
if ($enabled->{rc} != 0) {
if ($dry_run) {
_status_done('would enable');
}
else {
my $result = run(['systemctl', 'enable', 'firewalld.service'], use_sudo => 1);
if ($result->{rc} == 0) {
_status_done('enabled');
}
else {
_status_done('failed');
my $error = $result->{err};
$error =~ s/^\s+//;
$error =~ s/\s+$//;
_fail("Failed to enable firewalld: $error");
}
}
}
else {
_status_done('already enabled');
}
_status('Starting firewalld service');
my $active = run(['systemctl', 'is-active', 'firewalld.service']);
if ($active->{rc} != 0) {
if ($dry_run) {
_status_done('would start');
}
else {
my $result = run(['systemctl', 'start', 'firewalld.service'], use_sudo => 1);
if ($result->{rc} == 0) {
_status_done('started');
}
else {
_status_done('failed');
my $error = $result->{err};
$error =~ s/^\s+//;
$error =~ s/\s+$//;
_fail("Failed to start firewalld: $error");
}
}
}
else {
_status_done('already running');
}
# Reflect the real probed state, never an assumed success.
if (!$dry_run) {
$info{enabled} = run(['systemctl', 'is-enabled', 'firewalld.service'])->{rc} == 0 ? 1 : 0;
$info{running} = run(['systemctl', 'is-active', 'firewalld.service'])->{rc} == 0 ? 1 : 0;
}
return \%info;
}
# ---------------------------------------------------------------------------
# 11. SELinux
# ---------------------------------------------------------------------------
# Ensure SELinux is enforcing. A disabled SELinux cannot be switched at runtime, so
# only the configuration is fixed and a reboot warning is printed.
sub setup_selinux {
my ($dry_run) = @_;
my %info = (mode_changed => 0, config_changed => 0, current_mode => 'unknown',
reboot_required => 0);
_status('Checking SELinux mode');
my $mode_result = run(['getenforce']);
my $current_mode = $mode_result->{out};
$current_mode =~ s/^\s+//;
$current_mode =~ s/\s+$//;
$current_mode = 'unknown' unless length $current_mode;
$info{current_mode} = $current_mode;
_status_done($current_mode);
if ($current_mode eq 'Permissive') {
if ($dry_run) {
_info('Would set SELinux to enforcing mode');
}
else {
_status('Setting SELinux to enforcing');
my $result = run(['setenforce', '1'], use_sudo => 1);
if ($result->{rc} == 0) {
_status_done();
$info{mode_changed} = 1;
}
else {
_status_done('failed');
my $error = $result->{err};
$error =~ s/^\s+//;
$error =~ s/\s+$//;
_fail("setenforce 1 failed: $error");
}
}
}
elsif ($current_mode eq 'Disabled') {
_warn('SELinux is disabled, it cannot be enabled at runtime. '
. 'Setting SELINUX=enforcing in the config; a REBOOT is required.');
$info{reboot_required} = 1;
}
_status('Checking /etc/selinux/config');
my $read_ok = open(my $config_fh, '<', '/etc/selinux/config');
my $read_error = $read_ok ? '' : os_error_text('/etc/selinux/config');
my $content;
if ($read_ok) {
$content = do { local $/ = undef; <$config_fh> };
close($config_fh);
}
if (!$read_ok || !defined $content) {
_status_done('error');
_warn("Cannot read/write /etc/selinux/config: $read_error");
return \%info;
}
my @lines = split /\n/, $content, -1;
pop @lines if @lines && $lines[-1] eq '';
my $has_enforcing = 0;
for my $line (@lines) {
my $stripped = $line;
$stripped =~ s/^\s+//;
$stripped =~ s/\s+$//;
if (index($stripped, 'SELINUX=') == 0 && index($stripped, '#') != 0) {
my (undef, $value) = split /=/, $stripped, 2;
$value = '' unless defined $value;
$value =~ s/^\s+//;
$value =~ s/\s+$//;
$has_enforcing = 1 if lc($value) eq 'enforcing';
last;
}
}
if (!$has_enforcing) {
if ($dry_run) {
_status_done('would update to SELINUX=enforcing');
}
else {
my (@new_lines, $found);
$found = 0;
for my $line (@lines) {
my $stripped = $line;
$stripped =~ s/^\s+//;
$stripped =~ s/\s+$//;
if (index($stripped, 'SELINUX=') == 0 && index($stripped, '#') != 0) {
if (!$found) {
push @new_lines, 'SELINUX=enforcing';
$found = 1;
}
# Any duplicate active SELINUX= line is dropped.
next;
}
push @new_lines, $line;
}
push @new_lines, 'SELINUX=enforcing' unless $found;
my $ok = eval { atomic_write('/etc/selinux/config', join("\n", @new_lines) . "\n"); 1 };
if (!$ok) {
my $error = $@;
$error =~ s/\s+\z//;
_status_done('error');
_warn("Cannot read/write /etc/selinux/config: $error");
return \%info;
}
_status_done('updated to enforcing');
$info{config_changed} = 1;
}
}
else {
_status_done('already enforcing');
}
return \%info;
}
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
# In dry-run mode nothing is phrased as accomplished: only would-be actions.
sub print_summary {
my ($os_display, $version_id, $results, $warnings, $elapsed, $dry_run) = @_;
my $bar = "═" x 60;
print STDERR "\n${BOLD}══ Setup Summary ══$RESET\n";
print STDERR " OS: $os_display $version_id\n";
my $update = $results->{update} // {};
if ($update->{error}) {
_fail('System update failed');
}
elsif ($update->{would_update}) {
_info('Would apply system updates');
}
elsif ($update->{updated}) {
_ok('System updated');
}
elsif ($update->{skipped}) {
_info('System already up to date');
}
elsif (%$update) {
_info('System update skipped');
}
my $remove = $results->{remove} // {};
if (%$remove) {
my $removed = scalar @{ $remove->{removed} // [] };
my $skipped = scalar @{ $remove->{skipped} // [] };
if ($dry_run) {
_info("Remove: would remove $removed, $skipped already absent");
}
elsif ($removed) {
_ok("Removed: $removed package(s), $skipped already absent");
}
else {
_info("Remove: $skipped package(s) already absent");
}
}
my $repos = $results->{repos} // {};
if (%$repos) {
my $added = scalar @{ $repos->{added} // [] };
my $skipped = scalar @{ $repos->{skipped} // [] };
if ($dry_run) {
_info("Repos: would add $added, $skipped already present");
}
else {
_ok("Repos: $added added, $skipped already present");
}
}
my $rpm = $results->{rpm} // {};
if (%$rpm) {
my $installed = scalar @{ $rpm->{installed} // [] };
my $skipped = scalar @{ $rpm->{skipped} // [] };
my $failed = scalar @{ $rpm->{failed} // [] };
if ($dry_run) {
_info("RPM packages: would install $installed, $skipped already present");
}
else {
_ok("RPM packages: $skipped already present, $installed installed");
}
_fail(" $failed package(s) failed to install") if $failed;
my $removed = scalar @{ $rpm->{removed_flatpak} // [] };
if ($removed) {
if ($dry_run) {
_info(" Would uninstall $removed duplicate Flatpak app(s)");
}
else {
_ok(" Uninstalled $removed duplicate Flatpak app(s)");
}
}
}
# Tools fetched by curl or packaged
for my $item (['go', 'Go'], ['rust', 'Rust']) {
my ($section, $label) = @$item;
my $tool = $results->{$section} // {};
next unless %$tool;
if ($tool->{version}) {
_ok("$label: $tool->{version}");
}
elsif ($tool->{planned}) {
_info("$label: would be installed");
}
elsif ($tool->{installed}) {
_ok("$label: installed");
}
else {
_info("$label: not installed");
}
}
my $jetbrains = $results->{jetbrains} // {};
for my $code (@JETBRAINS_ORDER) {
my $tool = $jetbrains->{$code};
next unless defined $tool;
my $name = $JETBRAINS_IDES{$code};
if ($tool->{version}) {
_ok("$name: $tool->{version}");
}
elsif ($tool->{planned}) {
_info("$name: would be installed");
}
else {
_fail("$name: not installed");
}
}
my $flatpak = $results->{flatpak} // {};
if (%$flatpak) {
my $installed = scalar @{ $flatpak->{installed} // [] };
my $skipped = scalar @{ $flatpak->{skipped} // [] };
my $failed = scalar @{ $flatpak->{failed} // [] };
if ($dry_run) {
_info("Flatpak: would install $installed, $skipped already present");
}
else {
_ok("Flatpak: $skipped already present, $installed installed");
}
_fail(" $failed app(s) failed to install") if $failed;
my $removed = scalar @{ $flatpak->{removed_rpm} // [] };
if ($removed) {
if ($dry_run) {
_info(" Would remove $removed duplicate RPM package(s)");
}
else {
_ok(" Removed $removed duplicate RPM package(s)");
}
}
}
my $firewall = $results->{firewall} // {};
if (%$firewall) {
if ($dry_run) {
_info('Firewall: would ensure firewalld is installed, enabled and running');
}
elsif ($firewall->{running}) {
_ok('Firewall: installed & running');
}
else {
_fail('Firewall: not running');
}
}
my $selinux = $results->{selinux} // {};
if (%$selinux) {
my $mode = $selinux->{current_mode} // '?';
if ($selinux->{reboot_required}) {
_warn("SELinux: mode=$mode, config set to enforcing, REBOOT required");
}
else {
_ok("SELinux: mode=$mode");
}
}
if (@$warnings) {
print STDERR "\n";
_warn($_) for @$warnings;
}
print STDERR "\n${BOLD}${bar}$RESET\n";
printf STDERR " %sTotal time: %.1fs%s\n", $BOLD, $elapsed, $RESET;
print STDERR "${BOLD}${bar}$RESET\n\n";
return;
}
# ---------------------------------------------------------------------------
# Command line
# ---------------------------------------------------------------------------
sub usage {
my $name = $0;
$name =~ s{.*/}{};
my $systems = join(' and ', map { $SUPPORTED_OS{$_} } @SUPPORTED_ORDER);
return <<"USAGE";
Usage: $name [options]
Idempotent workstation setup for $systems
Options:
--dry-run Print what would be done without making changes
--skip-update Skip system update
--skip-rpm Skip RPM package installation
--skip-flatpak Skip Flatpak apps
--skip-repos Skip adding third-party repos
--skip-remove Skip removing pre-installed apps
--skip-go Skip Go toolchain installation
--skip-rust Skip Rust toolchain installation
--skip-jetbrains Skip JetBrains IDE installation
--skip-firewall Skip firewall setup
--skip-selinux Skip SELinux setup
--version Show the version and exit
-h, --help Show this help and exit
USAGE
}
sub parse_args {
my %opt = (
dry_run => 0,
skip_update => 0,
skip_rpm => 0,
skip_flatpak => 0,
skip_repos => 0,
skip_remove => 0,
skip_go => 0,
skip_rust => 0,
skip_jetbrains => 0,
skip_firewall => 0,
skip_selinux => 0,
);
my %flag_for = (
'--dry-run' => 'dry_run',
'--skip-update' => 'skip_update',
'--skip-rpm' => 'skip_rpm',
'--skip-flatpak' => 'skip_flatpak',
'--skip-repos' => 'skip_repos',
'--skip-remove' => 'skip_remove',
'--skip-go' => 'skip_go',
'--skip-rust' => 'skip_rust',
'--skip-jetbrains' => 'skip_jetbrains',
'--skip-firewall' => 'skip_firewall',
'--skip-selinux' => 'skip_selinux',
);
my @argv = @ARGV;
while (defined(my $arg = shift @argv)) {
if (exists $flag_for{$arg}) { $opt{ $flag_for{$arg} } = 1; next }
if ($arg eq '--help' || $arg eq '-h') { print usage(); exit 0 }
if ($arg eq '--version') {
my $name = $0;
$name =~ s{.*/}{};
print "$name $VERSION\n";
exit 0;
}
print STDERR "unrecognised argument: $arg\n";
print STDERR usage();
exit 2;
}
return %opt;
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
sub main {
my $start_time = now();
my @warnings;
my %results;
# Arguments first, so --help and --version work anywhere.
my %opt = parse_args();
my ($os_id, $os_display, $version_id) = detect_os();
my $bar = "═" x 60;
print STDERR "\n${BOLD}${bar}$RESET\n";
print STDERR "${BOLD} Workstation Setup v$VERSION ($os_display $version_id)$RESET\n";
print STDERR "${BOLD}${bar}$RESET\n";
if ($opt{dry_run}) {
print STDERR "\n ${YELLOW}${BOLD}DRY RUN: no changes will be made$RESET\n";
}
print STDERR "\n";
# 1. System update
print STDERR "\n${BOLD}── System Update ──$RESET\n";
if (!$opt{skip_update}) {
$results{update} = system_update($opt{dry_run});
}
else {
_info('System update: skipped (--skip-update)');
}
# 2. Remove pre-installed apps
print STDERR "\n${BOLD}── Remove Pre-installed Apps ──$RESET\n";
if (!$opt{skip_remove}) {
$results{remove} = remove_packages($opt{dry_run});
}
else {
_info('Remove apps: skipped (--skip-remove)');
}
# 3. Third-party repos
print STDERR "\n${BOLD}── Repositories ──$RESET\n";
if (!$opt{skip_repos}) {
$results{repos} = setup_repos($opt{dry_run});
}
else {
_info('Repos: skipped (--skip-repos)');
}
# 4. RPM packages
print STDERR "\n${BOLD}── RPM Packages ──$RESET\n";
if (!$opt{skip_rpm}) {
$results{rpm} = install_rpm_packages($opt{dry_run});
if (@{ $results{rpm}{failed} }) {
push @warnings, 'Some RPM packages failed to install: '
. join(', ', @{ $results{rpm}{failed} });
}
}
else {
_info('RPM packages: skipped (--skip-rpm)');
}
# 5. Go toolchain
print STDERR "\n${BOLD}── Go Toolchain ──$RESET\n";
if (!$opt{skip_go}) {
$results{go} = setup_go($opt{dry_run});
}
else {
_info('Go: skipped (--skip-go)');
}
# 6. Rust toolchain
print STDERR "\n${BOLD}── Rust Toolchain ──$RESET\n";
if (!$opt{skip_rust}) {
$results{rust} = setup_rust($opt{dry_run});
}
else {
_info('Rust: skipped (--skip-rust)');
}
# 7. JetBrains IDEs
print STDERR "\n${BOLD}── JetBrains IDEs ──$RESET\n";
if (!$opt{skip_jetbrains}) {
$results{jetbrains} = setup_jetbrains($opt{dry_run});
my @failed_ides;
for my $code (@JETBRAINS_ORDER) {
my $tool = $results{jetbrains}{$code};
next unless defined $tool;
push @failed_ides, $JETBRAINS_IDES{$code}
unless $tool->{installed} || $tool->{planned};
}
push @warnings, 'Some JetBrains IDEs failed to install: ' . join(', ', @failed_ides)
if @failed_ides;
}
else {
_info('JetBrains IDEs: skipped (--skip-jetbrains)');
}
# 8. Flatpak apps
print STDERR "\n${BOLD}── Flatpak Apps ──$RESET\n";
if (!$opt{skip_flatpak}) {
$results{flatpak} = setup_flatpak($opt{dry_run});
if (@{ $results{flatpak}{failed} }) {
push @warnings, 'Some Flatpak apps failed to install: '
. join(', ', @{ $results{flatpak}{failed} });
}
}
else {
_info('Flatpak: skipped (--skip-flatpak)');
}
# 9. Firewall
print STDERR "\n${BOLD}── Firewall ──$RESET\n";
if (!$opt{skip_firewall}) {
$results{firewall} = setup_firewall($opt{dry_run});
}
else {
_info('Firewall: skipped (--skip-firewall)');
}
# 10. SELinux
print STDERR "\n${BOLD}── SELinux ──$RESET\n";
if (!$opt{skip_selinux}) {
$results{selinux} = setup_selinux($opt{dry_run});
}
else {
_info('SELinux: skipped (--skip-selinux)');
}
my $elapsed = now() - $start_time;
print_summary($os_display, $version_id, \%results, \@warnings, $elapsed, $opt{dry_run});
return 0;
}
$SIG{INT} = sub {
print STDERR "\nInterrupted.\n";
remove_scratch();
exit 130;
};
$SIG{TERM} = sub {
remove_scratch();
exit 143;
};
END {
remove_scratch();
}
# Only when this file is the program: a test harness may require it and call the
# pure functions directly.
exit(main()) unless caller;