feat: initial release of the scripts collection
Deploy / deploy (push) Successful in 17s
Test / test (push) Successful in 53s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-10 04:00:00 +00:00
commit 788cf0571f
27 changed files with 19257 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
# Deploy: the scripts over rsync. Runs on every push to main and on manual dispatch,
# because scripts are not versioned and go live immediately.
#
# Requires secrets: DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, DEPLOY_PASSWORD,
# DEPLOY_KNOWN_HOSTS (the host key, from ssh-keyscan -t ed25519 HOST).
#
# Every step is one command, and the scripted steps are Perl rather than shell, so
# nothing has to be trusted to a shell option and no shell ever parses an argument.
# The Perl uses builtins only. The host key is pinned from the secret before the
# first connection: pointing UserKnownHostsFile at /dev/null would disable
# verification and turn every deploy into a blind trust on first use.
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: alpine
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Install Perl
# The alpine base image carries no Perl.
run: apk add --no-cache perl
- name: Generate SHA256SUMS
run: |
perl -e '
my @files = sort glob q{*.pl};
@files or die qq{ERROR: no scripts found\n};
open(my $out, q{>}, q{SHA256SUMS}) or die qq{SHA256SUMS: $!\n};
for my $file (@files) {
open(my $sums, q{-|}, q{sha256sum}, $file) or die qq{sha256sum: $!\n};
my $line = <$sums>;
# close waits for the child and answers false when it failed.
close($sums) or die qq{ERROR: sha256sum failed for $file\n};
defined $line or die qq{ERROR: sha256sum produced nothing for $file\n};
print $out $line;
print $line;
}
close($out) or die qq{SHA256SUMS: $!\n};
'
- name: Pin the host key
env:
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
# The runner has no persistent known_hosts, so the key comes from a secret
# and is written before the first connection.
perl -e '
my $key = $ENV{DEPLOY_KNOWN_HOSTS} // q{};
$key =~ m{\S} or die qq{ERROR: DEPLOY_KNOWN_HOSTS is empty\n};
my $dir = ($ENV{HOME} // q{.}) . q{/.ssh};
mkdir($dir, 0700) unless -d $dir;
open(my $out, q{>}, qq{$dir/known_hosts}) or die qq{known_hosts: $!};
print $out $key;
$key =~ m{\n\z} or print $out qq{\n};
close($out);
chmod(0600, qq{$dir/known_hosts}) or die qq{chmod: $!};
print qq{host key pinned in $dir/known_hosts\n};
'
- name: Deploy via rsync
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
run: |
# rsync is driven from Perl through system() with a list, so no shell ever
# parses the password, the remote path or the ssh options. The published
# set is staged into one directory and mirrored whole, because --delete
# only prunes when rsync walks a directory: a file list transfer would
# leave a script removed from the repository live on the host.
perl -e '
my $host = $ENV{DEPLOY_HOST} // die qq{ERROR: DEPLOY_HOST is unset\n};
my $user = $ENV{DEPLOY_USER} // die qq{ERROR: DEPLOY_USER is unset\n};
my $path = $ENV{DEPLOY_PATH} // die qq{ERROR: DEPLOY_PATH is unset\n};
my $pass = $ENV{DEPLOY_PASSWORD} // die qq{ERROR: DEPLOY_PASSWORD is unset\n};
my @files = sort glob q{*.pl};
@files or die qq{ERROR: no scripts found\n};
my $stage = ($ENV{HOME} // q{.}) . q{/.deploy-stage.} . $$;
mkdir($stage, 0700) or die qq{ERROR: cannot create the staging directory: $!\n};
for my $file (@files, q{SHA256SUMS}) {
system(q{cp}, q{-p}, $file, $stage) == 0
or die qq{ERROR: cannot stage $file\n};
}
# The staged files are public on the host, so the directory must stay
# traversable by nginx: rsync -a would carry 0700 over and every URL
# behind it would answer 403.
chmod(0755, $stage) or die qq{ERROR: cannot chmod the staging directory: $!\n};
print qq{Deploying to $user\@$host:$path\n};
my @cmd = (q{sshpass}, q{-p}, $pass, q{rsync}, q{-avz}, q{--delete},
q{-e}, q{ssh -o StrictHostKeyChecking=yes}, qq{$stage/},
qq{$user\@$host:$path});
system(@cmd) == 0 or die qq{ERROR: rsync failed\n};
system(q{rm}, q{-rf}, $stage) == 0
or die qq{ERROR: cannot remove the staging directory\n};
print qq{Deploy complete.\n};
'
+107
View File
@@ -0,0 +1,107 @@
# Test: the push-path gates for the scripts collection. Runs on every push and pull
# request against development, never on main, which is release-only.
#
# The gates are the same set the repository documents in docs/DEVELOPMENT.md: every
# script compiles, carries its licence header, uses no dash as punctuation, and passes
# its own checks under tests/. They are the affordable ones: the container rigs that
# verify a script against a real system need podman and a privileged container, which
# belong to the machine at the keyboard, not to a shared runner.
#
# Every step is one command, and the scripted steps are Perl rather than shell, so no
# shell option has to be trusted for the run to stop. The Perl uses builtins only:
# the runner packages the modules separately.
name: Test
on:
push:
branches: [development]
pull_request:
branches: [development]
jobs:
test:
runs-on: fedora
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Install Perl
run: dnf install -y perl
- name: Every script compiles
run: |
perl -e '
my @files = sort glob q{*.pl};
@files or die qq{ERROR: no scripts found\n};
for my $file (@files) {
system(q{perl}, q{-c}, $file) == 0 or die qq{ERROR: $file does not compile\n};
}
print qq{compiled @files[0..$#files]: }, scalar(@files), qq{ scripts\n};
'
- name: Every script carries the licence header
run: |
perl -e '
my @files = sort glob q{*.pl};
@files or die qq{ERROR: no scripts found\n};
for my $file (@files) {
open(my $fh, q{<}, $file) or die qq{ERROR: $file: $!\n};
my @head = map { my $line = <$fh> // q{}; chomp $line; $line } 1 .. 3;
close($fh);
$head[0] =~ m{^#!/usr/bin/env perl\z}
or die qq{ERROR: $file has no Perl shebang in the form this collection uses\n};
# The name carries a non-ASCII letter and the files are byte strings,
# so its UTF-8 bytes are matched rather than a wildcard width.
$head[1] =~ m{^# Copyright \(c\) \d{4} Petr Balv\xc3\xadn <opensource\@petrbalvin\.org> \(https://petrbalvin\.org\)\z}
or die qq{ERROR: $file has no copyright line\n};
$head[2] =~ m{^# SPDX-License-Identifier: MIT\z}
or die qq{ERROR: $file has no SPDX line, or names another licence\n};
}
print qq{licence header present in }, scalar(@files), qq{ scripts\n};
'
- name: No dash as punctuation
run: |
perl -e '
my @files = (sort(glob q{*.pl}), sort(glob q{tests/*.pl}), sort(glob q{*.md}),
sort(glob q{.gitea/workflows/*.yml}));
@files or die qq{ERROR: no files found\n};
my $found = 0;
for my $file (@files) {
open(my $fh, q{<}, $file) or die qq{ERROR: $file: $!\n};
my $number = 0;
while (my $line = <$fh>) {
$number++;
# The bytes matter: the files are byte strings, so the em dash and
# the en dash are matched as their UTF-8 sequences.
if ($line =~ /\xe2\x80\x94|\xe2\x80\x93/) {
print qq{ERROR: $file:$number uses a dash as punctuation\n};
$found++;
}
}
close($fh);
}
die qq{ERROR: $found line(s) use a dash as punctuation\n} if $found;
print qq{no dash used as punctuation in }, scalar(@files), qq{ files\n};
'
- name: network-diag.pl
run: perl tests/network-diag.pl
- name: security-audit.pl
run: perl tests/security-audit.pl
- name: server-setup.pl
run: perl tests/server-setup.pl
- name: sglang-deploy.pl
run: perl tests/sglang-deploy.pl
- name: system-diag.pl
run: perl tests/system-diag.pl
- name: system-optimise.pl
run: perl tests/system-optimise.pl
- name: workstation-setup.pl
run: perl tests/workstation-setup.pl
+5
View File
@@ -0,0 +1,5 @@
.idea/
.zcode/
# Generated by CI, deployed alongside the scripts
SHA256SUMS
+114
View File
@@ -0,0 +1,114 @@
# Contributing
Thanks for contributing to **scripts**.
## Development setup
Requirements: Perl 5.38 or newer, which is what the oldest supported system ships
(CentOS Stream 10 carries 5.40, openEuler 24.03 LTS carries 5.38). No module beyond the
interpreter is needed, by design: the scripts use builtins only. Podman is needed only
for the container rigs described in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
```sh
git clone https://sourcedock.dev/petrbalvin/scripts.git
cd scripts
perl -c network-diag.pl
perl tests/network-diag.pl
```
## Workflow
1. Branch from `development`. Never commit directly to `main`, which is release-only.
2. Commit in [Conventional Commits](https://www.conventionalcommits.org/) form:
`type(scope): description`, subject line only, imperative mood, lowercase after the
colon, no trailing full stop. Allowed types: `feat`, `fix`, `docs`, `style`,
`refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`.
3. One logical change per commit. A refactor, a behaviour change and a formatting pass
are three commits, never one.
4. Add or extend the checks in `tests/` for the code you touched. A new script arrives
with its own `tests/<script>.pl`.
5. Update the documentation when the flags, the behaviour or the target systems change.
6. Open a pull request against `development`.
`main` is not a release branch in the usual sense: the deploy pipeline publishes the
scripts on every push to it, so a merge to `main` is a release. The scripts carry their
own versions, and the commit history is the record of what changed.
## Code style
There is no formatter to run, and the style is the one the other scripts already share.
Read the script nearest to your change and follow it.
- **Builtins only.** No `use` beyond `strict` and `warnings`. A job Perl has no builtin
for is either written out in the script (the command runner, version comparison, the
JSON codecs, IPv4 and IPv6 arithmetic) or delegated to a system binary through the
script's `run` helper, which is how `dnf`, `rpm`, `curl`, `openssl`, `podman`,
`systemctl` and the rest are driven.
- **Every external command is one argument list.** `run([...])` never builds a string,
so no shell parses a path, a key or a URL.
- **Idempotence is the contract.** Every operation checks the current state first and
reports what it found; running a script twice must change nothing the second time.
- **`--dry-run` changes nothing**, and the summary of a dry run reads as a preview, not
as an accomplishment.
- **Messages are in British English, on stderr**, and none of them uses a dash as
punctuation.
- `perl -c <script>` must be clean, and so must `perl tests/<script>.pl`.
- New files open with the shebang `#!/usr/bin/env perl`, then the two-line licence
header whose SPDX identifier matches [LICENSE](LICENSE).
The gates are listed in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md), and the test
pipeline runs the same set.
## AI contribution policy
AI tools are welcome as productivity aids and are a normal part of modern software
development. What matters is that the contribution stays understandable, reviewable and
genuinely useful.
- **Disclose the assistance.** If AI helped draft any part of a commit, issue, pull
request or review, say so.
- **Commit messages carry exactly one trailer**, on the line after the subject:
```
Assisted-by: MODEL
```
Name the model that did the work, spelled the way its maker spells it, for example
`GLM 5.3`, `DeepSeek V4.1 Flash` or `Qwen 3.8 Flash`. No `Co-Authored-By`, no
`Signed-off-by`, no other trailers, and no prose: the trailer is the disclosure.
- **Issues and pull requests** attribute the assistance in a comment, for example
`_Assisted-by: GLM 5.3_`. It does not belong in the pull request description.
- **Take responsibility.** You are accountable for the accuracy, completeness and
intent of everything you submit, whether or not AI produced it.
- **Review before marking ready.** Read the diff carefully, run it locally, and add the
tests it needs. Do not mark a pull request ready until you can defend every change in
it.
- **Quality over quantity.** Contributions that look like un-reviewed output, or whose
author cannot engage substantively during review, may be closed.
- **Preferred models.** Prefer open-weight models with transparent training data and
minimal output filtering.
AI assists. It does not replace judgement.
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners:
| Workflow | Trigger | What it does |
|---|---|---|
| Test | push or pull request to `development` | every script compiles, carries its licence header and uses no dash as punctuation, then the six check files under `tests/` |
| Deploy | push to `main` | writes `SHA256SUMS` over every script and publishes them over rsync |
The container rigs are not in the pipeline: they need Podman and a privileged
container, which belong to the machine at the keyboard rather than to a shared runner.
A change to a script that touches a system is expected to be verified that way locally,
and the pull request says what was run.
## Reporting bugs
Open an issue at `https://sourcedock.dev/petrbalvin/scripts/issues` with the script and
its version (the script's `--version`), the operating system and version, the exact
command, the full output, and the expected against the actual behaviour.
**Security issues do not go in the issue tracker.** Report them as
[SECURITY.md](SECURITY.md) describes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+126
View File
@@ -0,0 +1,126 @@
# Scripts: DevOps Toolbox
A personal collection of standalone scripts for Linux server and desktop management:
diagnostics, first-time server and workstation setup, routine cleanup, and an
inference server deployment. Every script is Perl, every one runs on the interpreter's
own builtins, and none of them needs a module installed beside it.
## Features
- **Diagnostics**: `network-diag.pl` measures latency, packet loss, DNS resolution,
MTU and dual-stack reachability and grades the connection; `system-diag.pl` reads
the whole machine (CPU, memory, disk, network, GPU, services, security, performance)
and grades its health.
- **Security audit**: `security-audit.pl` grades the machine's defences A to F from the
state it is actually in: the effective sshd configuration, the running firewalld
checked against the sockets that really listen, SELinux now and at the next boot,
pending security updates, accounts and sudo, kernel hardening, and an opt-in deep
scan of the file system. A critical finding is a non-zero exit, so a cron job fails
loudly.
- **Setup**: `server-setup.pl` takes a fresh server to a working state (packages,
firewall with the SSH rule verified before the service is enabled, SELinux, Podman,
automatic updates); `workstation-setup.pl` sets up a Fedora desktop (Brave, the
official Go toolchain, Rust, GoLand, Flatpak applications, firewall, SELinux).
- **Deployment**: `sglang-deploy.pl` puts an SGLang inference server on an AMD GPU
behind nginx with HTTPS and an API key, runs the engine from the project's ROCm
container image, and downloads the model weights from ModelScope.
- **Maintenance**: `system-optimise.pl` removes old kernels (the running one and one
fallback always stay), vacuums journals, clears temporary files and core dumps, and
audits the installed packages against their repositories.
- **Idempotent by construction**: every operation checks the current state before
acting, so a second run reports what is already in place and changes nothing.
## Install
The scripts are single files. Take the repository, or take one script with `curl`:
```sh
git clone https://sourcedock.dev/petrbalvin/scripts.git
cd scripts
```
Requirements: Perl, which every supported system ships. Nothing else: the scripts
drive the system's own tools (`dnf`, `systemctl`, `podman`, `openssl`, `curl`) rather
than carrying a library layer, and where a language has no builtin for a job the
script does the work itself.
## Quick start
```sh
# Network diagnostics, no root needed
perl network-diag.pl
# System health, no root needed
perl system-diag.pl
# Security audit, deeper answers as root
sudo perl security-audit.pl --deep
# Server setup, as root
sudo perl server-setup.pl --dry-run
# SGLang on an AMD GPU, as root
sudo perl sglang-deploy.pl --model ZhipuAI/GLM-5.3
```
Any script can also be fetched and run in one step:
```sh
curl -sSf https://petrbalvin.org/scripts/system-diag.pl | perl
```
#### Integrity verification
A single `SHA256SUMS` file covers every published script. Verify before running:
```sh
curl -sSfO https://petrbalvin.org/scripts/server-setup.pl
curl -sSfO https://petrbalvin.org/scripts/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
sudo perl server-setup.pl --dry-run
```
## Usage
Each script documents its own flags; `--help` prints them and `--dry-run` shows what
a change would do without doing it.
| Script | Version | Purpose |
|---|---|---|
| `network-diag.pl` | 2.0.0 | Network latency, DNS, MTU, packet loss and dual-stack on Linux and FreeBSD |
| `system-diag.pl` | 2.0.0 | System health with a grade from A to F. Linux only |
| `security-audit.pl` | 2.0.0 | Security posture with a grade from A to F and an exit code for cron. Linux only |
| `server-setup.pl` | 2.0.0 | Server initial setup for Fedora, CentOS Stream and openEuler |
| `workstation-setup.pl` | 2.0.0 | Fedora desktop setup |
| `sglang-deploy.pl` | 2.0.0 | SGLang inference server on an AMD GPU, behind nginx with HTTPS |
| `system-optimise.pl` | 2.0.0 | System cleanup; refuses rpm-ostree systems |
The full flag reference is in [docs/CLI.md](docs/CLI.md).
## Development
```sh
perl -c network-diag.pl # every script compiles
perl tests/network-diag.pl # that script's checks
perl tests/system-diag.pl # and so on for each script
```
The local gates are the syntax check, the seven test files, the licence header and the
punctuation rule; the container rigs that verify a script against a real system are
described in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). See
[CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute.
## Documentation
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): the shared shape of the scripts and
what each one owns
- [docs/CLI.md](docs/CLI.md): every flag of every script
- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md): how the scripts are published, and what
they deploy on a host
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md): prerequisites, commands and verification
## Licence
MIT. See [LICENSE](LICENSE).
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
+69
View File
@@ -0,0 +1,69 @@
# Security policy
## Supported versions
The published scripts are not versioned artefacts: `https://petrbalvin.org/scripts/` is
updated on every push to `main`, and each script carries its own version string. A
security fix goes to the newest published copy and to the `development` branch of the
repository. Older copies that were downloaded earlier do not receive fixes; take the
current file again.
`SHA256SUMS` is published beside the scripts and covers every one of them, so a
downloaded copy can be checked before it runs:
```sh
curl -sSfO https://petrbalvin.org/scripts/server-setup.pl
curl -sSfO https://petrbalvin.org/scripts/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
```
## Reporting a vulnerability
**Do not open a public issue for a security problem.** A public report tells everyone
about the flaw before there is a fix. Report it privately to
**opensource@petrbalvin.org**.
Include:
- the script and its version (`--version`), and the platform
- what the problem is, and what an attacker gains from it
- the smallest reproducer you have, ideally a single command
- a suggested fix, if you have one
## What to expect
- A human reads the report, and you get an acknowledgement.
- You are kept informed while the fix is being made, and told when it ships.
- The fix is published before the details are, and the timing is agreed with you.
- You are credited in the commit message of the fix, by name or by handle, as you
prefer.
## What the scripts already do
These are the properties to check first, because a finding that contradicts one of them
is a defect worth reporting:
- `server-setup.pl` writes the SSH rule into the permanent firewalld configuration and
verifies it through the offline client **before** firewalld is enabled, so enabling
the firewall cannot lock the operator out.
- `sglang-deploy.pl` protects the endpoint with an API key stored in a root-only
`EnvironmentFile` (`/etc/sysconfig/sglang`, mode 0600), serves it over TLS only, and
binds the engine to the loopback address.
- `workstation-setup.pl` verifies what it downloads: the Go tarball against the SHA-256
in go.dev's download API, the GoLand tarball against JetBrains' published checksum,
and Brave's repository key against the fingerprints published on brave.com.
- Downloads are never piped into a shell: a fetched installer lands in a file first and
is executed only after the transfer completed.
## Out of scope
- Findings that require the attacker to already have root, or to already run code as the
user who runs the script.
- Missing hardening with no demonstrated impact, such as a script not setting a stricter
umask than the system's.
- Flaws in a third-party tool the scripts drive (`dnf`, `podman`, `openssl`, `nginx` and
the rest): report those to that project, and here only when this repository's use of
the tool makes the flaw reachable in a way the tool's own documentation does not
anticipate.
- The published copies being fetched over HTTPS from a domain the operator does not
control, which is the deployment model rather than a defect in the scripts.
+128
View File
@@ -0,0 +1,128 @@
# Architecture
How the scripts collection is put together. Every script, helper and arrow below
exists in the repository; nothing is aspirational.
## Overview
There is no shared library. Each script is one file that can be fetched and run on its
own, and the six of them share a skeleton rather than a module: the same command
runner, the same progress reporting, the same state checks before every action, and the
same shape of a run.
```mermaid
flowchart TD
Start[invocation] --> Args[parse_args reads ARGV]
Args --> Version{version or help?}
Version -->|yes| Print[print and exit]
Version -->|no| Root[check_root, detect_os]
Root --> Sections[sections run in order]
Sections --> Each[each section checks state first]
Each --> Done{changed?}
Done -->|no| Report[report what is already in place]
Done -->|yes| Act[act, then verify what was done]
Act --> Report
Report --> Summary[print_summary on stderr]
```
A run is idempotent because of that loop: a section reads the state of the system,
compares it with the desired one, and acts only on the difference, so a second run
reports everything as already in place and changes nothing.
## The seven scripts
| Script | Owns | Deliberately does not |
|---|---|---|
| `network-diag.pl` | Latency, loss, DNS timing, MTU discovery, dual-stack reachability, listening ports, and the grade of the connection | Touch any configuration; it only measures |
| `system-diag.pl` | Reading CPU, memory, disk, network, GPU, services, security and performance, and grading the result | Change anything on the host; it is read-only |
| `server-setup.pl` | Base packages, firewall (with the SSH rule verified before the service starts), SELinux, Podman, automatic updates | Install the services themselves; that is the operator's, and the deploy scripts' |
| `workstation-setup.pl` | A Fedora desktop: Brave, the official Go toolchain, Rust, GoLand, Flatpak applications, firewall, SELinux | Anything on a server distribution; it targets Fedora Workstation |
| `sglang-deploy.pl` | The SGLang deployment: the container engine, the systemd unit, nginx with TLS, the API key, the firewall rule and the SELinux boolean | The host's own setup, which `server-setup.pl` does first |
| `system-optimise.pl` | Old kernels (the running one and one fallback always stay), journals, temporary files, core dumps, and the package audit | Touch an rpm-ostree system, which it refuses |
## Data flow: a forked section collection
`system-diag.pl` is the one script whose shape is not linear. Its sections are
independent and dominated by waiting, so each runs in its own child process, writes its
result into a private scratch directory as a Perl literal, and the parent reads the
results back after reaping. The runner keeps stdout and stderr apart in that same
directory, which is why the file names carry the process id.
```mermaid
sequenceDiagram
participant Parent
participant Child as Section child
participant Disk as Scratch directory
Parent->>Child: fork, one per requested section
Child->>Child: collect one section
Child->>Disk: write the result as a Perl literal
Parent->>Parent: waitpid, report the section as it lands
Parent->>Disk: read_literal for each child
Parent->>Parent: compute the grade and render the report
Parent->>Disk: remove the scratch directory
```
Only the process that created the scratch directory removes it: a forked child inherits
the `END` block, so the cleanup is guarded by a parent pid comparison. A child that
cannot write its result is reported as a missing section, and the grade drops that
component rather than inventing a value for it.
## Conventions every script follows
- **Builtins only.** No module beyond the interpreter is loaded, because the systems
this targets package modules separately. What Perl has no builtin for is written out:
the command runner, RPM's version comparison, the JSON decoders and the encoder, the
IPv4 and IPv6 arithmetic, `sockaddr` packing, a `which`, and argument parsing.
- **External binaries as transport.** `dnf`, `rpm`, `curl`, `openssl`, `podman`,
`systemctl`, `journalctl`, `find`, `stat`, `sha256sum`, `tar`, `gpg`, `lspci` and the
rest are driven as argument lists through `run`, which keeps stdout and stderr apart,
forces `LANG=C` and `LC_ALL=C` for parseable output, and turns a timeout, a missing
binary and a failed exec into exit codes 124, 127 and 126 rather than exceptions.
- **One optional module.** `Time::HiRes` is loaded inside an `eval` in the scripts that
time something. Where it is absent the affected figures are reported as not measured
and the grade drops that component, rather than the script failing.
- **Atomic writes for system configuration.** A file that a boot depends on is written
to a temporary name, flushed, given its mode and owner, and renamed over the target.
Perl has no `fsync`, so that barrier is delegated to `sync` where the binary exists.
- **Errors carry what the tool said.** A failure reproduces the errno, the reason and
the path, so a message can be searched for as it stands.
## The SGLang deployment
`sglang-deploy.pl` is the only script that deploys a service, and its engine runs as a
container. That is a consequence of the material rather than a preference: SGLang
publishes no ROCm wheel, and its AMD install paths are the project's container images
or a source build against a full ROCm toolchain that Fedora carries only partly and
that CentOS Stream and openEuler cannot carry at all.
```mermaid
flowchart LR
Client[client] -->|443| Nginx[nginx on the host, TLS]
Nginx -->|loopback, plain HTTP| Engine[SGLang in a Podman container]
Engine -->|device nodes| GPU[/dev/kfd, /dev/dri]
Engine -->|bind mount| Cache[state directory, model cache]
Unit[systemd unit] -->|podman run| Engine
EnvFile[EnvironmentFile, mode 0600] -->|API key| Unit
```
The host therefore needs the amdgpu kernel driver and its device nodes, never a ROCm
userland. The image tag is resolved from the card's family (mi30x for an MI300 or
MI325, mi35x for an MI350 or MI355) and from the host's ROCm, because the container's
ROCm userland must not be newer than the host's kernel driver. A Radeon 8060S or 8050S
(gfx1151) has no stable tag anywhere; AMD's dated development builds are resolved
instead, and `--image` pins one. Radeon cards need `SGLANG_USE_AITER=false` and
`SGLANG_ROCM_FUSED_DECODE_MLA=false` in the unit, which the script writes for them and
never for an Instinct host.
## Dependencies
Nothing outside the interpreter, and nothing that has to be installed beyond the tools
each script's own dependency section installs. The non-obvious ones and their reasons:
- `podman` for `sglang-deploy.pl`, because the engine is a container.
- `lspci` for the GPU family, and `rocm-smi` in `system-diag.pl` for AMD memory and
utilisation figures.
- `sha256sum` in `workstation-setup.pl`, because Perl's builtins have no hash.
- `sync` for the write barrier after an atomic write.
- `getent` or `host` in `network-diag.pl` as the resolver transport, since `getent` on
FreeBSD implements neither of the `ahosts` databases.
+197
View File
@@ -0,0 +1,197 @@
# Command line
Every script is its own program, and there is no global command. The reference below is
taken from each script's own `--help`; if the two disagree, the program is right and
this file is a defect.
Common to all six: `--version` prints the version and exits, `-h` / `--help` prints the
usage and exits, options are given as `--name value` or `--name=value`, and an unknown
option is refused with the usage and exit code 2.
## network-diag.pl
```
Usage: network-diag.pl [options]
Network diagnostics: latency, DNS, MTU, dual-stack, ports
Options:
--target HOST Target host for tests (default: cloudflare.com)
--protocol FAMILY Address family: auto (dual-stack), v4 (IPv4 only),
v6 (IPv6 only) (default: auto)
--version Show the version and exit
-h, --help Show this help and exit
```
| Flag | Default | Effect |
|---|---|---|
| `--target HOST` | `cloudflare.com` | The host pinged, resolved and probed for MTU |
| `--protocol FAMILY` | `auto` | `auto` measures both families, `v4` and `v6` restrict the run |
Runs without root. Ports belonging to other users are reported as `(no permission)`
rather than silently dropped.
## system-diag.pl
```
Usage: system-diag.pl [options]
System diagnostics: CPU, memory, disk, network, GPU, services, security, performance
Options:
--section LIST Comma-separated sections to run. Available: overview, cpu, memory, disk, network, gpu, services, security, performance, issues [default: all]
--external-ip Also detect external IP addresses via icanhazip.com (sends a request to a third-party service)
--json Output machine-readable JSON to stdout
--version Show the version and exit
-h, --help Show this help and exit
```
| Flag | Default | Effect |
|---|---|---|
| `--section LIST` | all | Runs only the named sections |
| `--external-ip` | off | Sends a request to `icanhazip.com` to report the public address |
| `--json` | off | Writes the JSON document to stdout instead of the report |
Runs without root; some figures need it and are reported as unavailable instead. Colour
is used only on a terminal and is suppressed by `NO_COLOR`. The report goes to stderr,
so `--json` keeps stdout clean.
## server-setup.pl
```
Usage: server-setup.pl [options]
Idempotent server setup for Fedora Server, CentOS Stream and openEuler
Options:
--dry-run Print what would be done without making changes
--skip-update Skip system update
--skip-packages Skip base package installation
--skip-epel Skip EPEL repository setup on CentOS
--skip-firewall Skip firewall setup
--skip-selinux Skip SELinux configuration
--skip-podman Skip Podman installation
--skip-auto-updates Skip automatic updates configuration
--version Show the version and exit
-h, --help Show this help and exit
```
Needs root. The `--skip-*` flags exist per section so a section can be left to another
tool; the sections are ordered update, EPEL, packages, firewall, SELinux, Podman,
automatic updates.
## workstation-setup.pl
```
Usage: workstation-setup.pl [options]
Idempotent workstation setup for Fedora
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
```
Needs root, and targets Fedora only. The Go toolchain and GoLand are downloaded and
verified by checksum; Brave's repository key is verified by fingerprint before import.
## sglang-deploy.pl
```
Usage: sglang-deploy.pl [options]
--model ID Hugging Face model ID (menu when omitted)
--port N internal engine port (default: 8000, not 443)
--tensor-parallel N GPUs for tensor parallelism (default: 1, written as
the engine's --tp-size)
--max-model-len N context length (default: 4096, written as the
engine's --context-length)
--gpu-memory-utilization F static memory fraction (default: 0.90, written as
the engine's --mem-fraction-static)
--state-dir PATH state and model cache directory (default: /opt/sglang)
--service-name NAME systemd service name (default: sglang)
--cert-dir PATH TLS certificate directory (default: /etc/ssl/sglang)
--image TAG engine image (default: resolved from the GPU and
the newest SGLang release; a Radeon card resolves
AMD's newest dated gfx1151 build)
--rocm-flavour NAME ROCm flavour of the image (default: from the host
ROCm; rocm10, rocm724, rocm720 or rocm700)
--api-key KEY API key for the endpoint (default: generate and
store in /etc/sysconfig)
--dry-run preview without making changes
--uninstall tear down the service, container, nginx config
and certificates
--help show this help
--version show the version
```
Needs root. Without `--model` an interactive menu offers GLM 5.3, GLM 5.3 Flash,
DeepSeek V4 Pro, DeepSeek V4 Flash, MiMo V2.5 Pro and MiMo V2.5, plus a free-form
entry. `--tensor-parallel`, `--max-model-len` and `--gpu-memory-utilization` are
written to the unit as the engine's own `--tp-size`, `--context-length` and
`--mem-fraction-static`. The API key is shown once when it is generated; a key given
with `--api-key` is never echoed. `--uninstall` stops and disables the service, removes
the unit, the container, the nginx configuration and the certificates, and keeps the
image and the model cache.
## system-optimise.pl
```
Usage: system-optimise.pl [options]
Idempotent system cleanup and optimisation for Fedora, CentOS Stream and openEuler
Options:
--dry-run Preview without making changes
--skip-dnf Skip DNF cleanup (autoremove, old kernels, cache)
--skip-journal Skip journal vacuum
--skip-tmp Skip temp file cleanup
--skip-cores Skip core dump cleanup
--version Show the version and exit
-h, --help Show this help and exit
```
Needs root. The running kernel and one fallback are always kept. Both dnf generations
are driven: Fedora ships dnf 5, CentOS Stream 10 and openEuler ship dnf 4.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | The run completed, and every step it attempted succeeded |
| `1` | A step failed, a required tool is missing, the hardware does not qualify, or the system is unsupported |
| `2` | The arguments were wrong, or interactive input was needed and stdin is not a terminal |
A run that finishes with failed steps prints them at the end of the summary and exits 1,
so a pipeline notices even when the failure was not fatal.
## Examples
```sh
# Is the connection healthy, and is IPv6 working?
perl network-diag.pl --target example.org --protocol auto
# The same machine's health as JSON, for a monitoring poll
perl system-diag.pl --json --section cpu,memory,disk
# A new server, seen before it is changed
sudo perl server-setup.pl --dry-run
# Cleanup, leaving journals and core dumps alone
sudo perl system-optimise.pl --skip-journal --skip-cores
# Deploy a model and then take it down again
sudo perl sglang-deploy.pl --model deepseek-ai/DeepSeek-V4-Flash-0731
sudo perl sglang-deploy.pl --uninstall
```
+105
View File
@@ -0,0 +1,105 @@
# Deployment
The collection has two senses of deployment: how the scripts themselves are published,
and what they deploy when they run on a host. Both are below.
## Topology: publishing
The scripts are served from `https://petrbalvin.org/scripts/`, which is a directory on a
host reached over SSH. The Gitea instance at `sourcedock.dev` holds the repository and
runs the pipeline; there is no package, no tag and no artefact store, because a script
that is a single file is published by copying it.
```mermaid
flowchart LR
Dev[development branch] -->|merge| Main[main]
Main --> Runner[Gitea runner, alpine]
Runner -->|rsync over ssh| Host[petrbalvin.org]
Host -->|https| Client[client]
Runner -->|SHA256SUMS| Host
```
## Requirements
- The runner image is `alpine`, which carries `ssh`, `sshpass`, `rsync` and no Perl: the
pipeline installs Perl in its first step.
- Five repository secrets: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_PATH`,
`DEPLOY_PASSWORD`, and `DEPLOY_KNOWN_HOSTS`.
- `DEPLOY_KNOWN_HOSTS` holds the host key, from
`ssh-keyscan -t ed25519 <host>`. The pipeline writes it into `~/.ssh/known_hosts` and
connects with `StrictHostKeyChecking=yes`; without the secret the deploy stops before
it starts, deliberately, because the alternative is trusting whatever answers on the
first connection.
## Build
There is nothing to build. The pipeline writes `SHA256SUMS`, one line per script, hashed
with `sha256sum` in sorted filename order so the file is stable between runs, and then
sends the scripts and that file.
## Run
The pipeline runs itself: merging into `main` publishes, and a manual dispatch republishes
the current tree.
```mermaid
sequenceDiagram
participant Dev
participant CI as Gitea runner
participant Host as petrbalvin.org
Dev->>CI: push to main
CI->>CI: write SHA256SUMS over every script
CI->>CI: pin the host key from DEPLOY_KNOWN_HOSTS
CI->>Host: rsync -avz --delete, the scripts and SHA256SUMS
Host-->>CI: exit status of rsync
CI->>CI: stop the run unless it was zero
```
`--delete` is deliberate: the host directory mirrors the repository, so a script removed
here disappears there. Nothing else lives in that directory.
## Upgrade and rollback
A publish is atomic per file as rsync writes it, and the previous copy is overwritten in
place. Rollback is a revert commit on `main`: the pipeline publishes the reverted tree on
the next push, and the host is back to the earlier scripts within the minute the run
takes. There is no rehearsal of that procedure on a staging host; it is the same
pipeline with the same single target.
## Monitoring
Nothing polls the host. Two things are worth watching by hand:
- The pipeline's own run history in Gitea, which fails loudly on a non-zero `rsync` or a
missing secret.
- `https://petrbalvin.org/scripts/SHA256SUMS`, which a client can compare against what it
downloaded. A file that is on the host but missing from `SHA256SUMS` means a publish was
interrupted.
## What the scripts deploy
Two of the six change a host's role, and a third prepares a desktop. Their output is the
deployment an operator cares about, so the pipeline that publishes them never runs them.
| Script | What it leaves behind | Where it is documented |
|---|---|---|
| `server-setup.pl` | Packages, firewalld with the SSH rule already allowed, SELinux enforcing, Podman, unattended updates | [docs/CLI.md](CLI.md) |
| `sglang-deploy.pl` | A systemd unit running the engine in a Podman container, nginx with TLS in front, an API key file, the firewall rule and the SELinux boolean | [docs/ARCHITECTURE.md](ARCHITECTURE.md) |
| `workstation-setup.pl` | A Fedora desktop with the toolchains and applications installed | [docs/CLI.md](CLI.md) |
The SGLang deployment is the only service in the collection, and the host needs no ROCm
installation for it: the engine runs from the project's ROCm image and reaches the GPU
through `/dev/kfd` and `/dev/dri`, which the amdgpu kernel driver provides. The image tag
follows the card and the host's ROCm, which is why the script resolves it rather than
carrying a constant.
## Production configuration
Nothing in this repository holds a secret. The values that differ per host come from
where the script reads them:
- The published copies are unversioned: whatever is on `main` is what is served.
- `sglang-deploy.pl` keeps the engine's API key in `/etc/sysconfig/sglang`, mode 0600,
written by the script and never committed. A Hugging Face token for a gated model goes
into that same file as `HF_TOKEN`, which the unit forwards to the container.
- The deploy secrets live in the Gitea repository settings, under Actions, Secrets.
+146
View File
@@ -0,0 +1,146 @@
# Development
How to work on the scripts collection.
## Prerequisites
- Perl 5.38 or newer, which is what the oldest supported system ships. Measured on the
three test platforms: Fedora 44 carries 5.42.3, CentOS Stream 10 carries 5.40.2, and
openEuler 24.03 LTS carries 5.38.0.
- Podman, for the container rigs that verify a script against a real system.
- Nothing else. There is no build step, no dependency to install and no module to fetch:
the scripts use the interpreter's builtins, and the checks use nothing beyond them
either.
## Setup
```sh
git clone https://sourcedock.dev/petrbalvin/scripts.git
cd scripts
perl -c network-diag.pl
```
## Commands
There is no recipe file: the gates are the commands below, and the test pipeline runs
the same set.
| Command | What it does |
|---|---|
| `perl -c <script>` | Compiles one script. All six in a loop is what the pipeline's first gate does |
| `perl tests/<script>.pl` | One script's checks, each printing `ok` or `FAIL` and exiting non-zero on a failure |
| `perl tests/container/rig.pl` | The container rig, which must be run through Podman as root: see below |
The whole local gate, one line per script:
```sh
for f in *.pl; do perl -c "$f" || exit 1; done
for t in tests/*.pl; do perl "$t" || exit 1; done
```
Two more gates have no command of their own because the pipeline carries them: every
script opens with the shebang `#!/usr/bin/env perl` and the two-line licence header,
and no file in the repository uses a dash as punctuation. Both are checked in
`.gitea/workflows/test.yml`.
## The checks under tests/
One file per script, named after it, run as `perl tests/<script>.pl`. They cover the
pure functions, which is where the arithmetic and the parsing live: RPM's version
comparison (checked against `rpm`'s own implementation, which is the definition of the
ordering), the two JSON decoders and the encoder, IPv4 and IPv6 parsing with the
`sockaddr` packing and the address classification, the ping transcript parser, the
grading, the dnf-automatic renderer, the checksum reader, the writers, and the argument
parsing of every script.
They need no root, no network and no Podman, except where a case is skipped for that
reason and says so: `atomic_write` settles the owner of the file it writes, so it can
only be exercised as root.
## The container rig
`tests/container/rig.pl` runs the real `sglang-deploy.pl` as root inside a container
against a stub `PATH`: every command the script drives (`dnf`, `rpm`, `podman`,
`systemctl`, `curl`, `openssl`, `nginx`, `firewall-cmd`, `getsebool`, `lspci`) is a
stub that answers from a fixture, so a run is deterministic and needs no network and no
GPU. It covers the deploy end to end: the resolved image tag, the unit file, the nginx
configuration, the TLS certificate and its modes, the API key file, the SELinux
boolean, the firewall rule, the idempotent second run, the dry run, the uninstall, the
Radeon and MI300 paths, the offline and unpublished-tag failures, the argument
validation and the non-root refusal.
Prepare the platform image once, since the base images carry no Perl:
```sh
podman run --name sglang-prep registry.fedoraproject.org/fedora:44 dnf install -y perl
podman commit sglang-prep localhost/sglang-rig:fedora
podman rm sglang-prep
```
Then run the scenarios. The repository is mounted read-only at its own path and the
work directory is a scratch directory outside it:
```sh
podman run --rm --privileged \
-v "$PWD:$PWD:ro,z" \
-v /tmp/sglang-rig:/tmp/sglang-rig:Z \
localhost/sglang-rig:fedora perl "$PWD/tests/container/rig.pl"
```
The device nodes the script requires are faked by the rig itself, which is why the
container needs `--privileged`. The one scenario that checks the *missing* driver needs
a run without it:
```sh
podman run --rm \
-v "$PWD:$PWD:ro,z" \
-v /tmp/sglang-rig:/tmp/sglang-rig:Z \
localhost/sglang-rig:fedora perl "$PWD/tests/container/rig.pl" no_driver
```
The same rig was run on all three platforms, which is how the script's behaviour
against their package managers and their `os-release` was confirmed; only the image tag
in the commands changes:
| Platform | Image | Perl |
|---|---|---|
| Fedora 44 | `registry.fedoraproject.org/fedora:44` | 5.42.3 |
| CentOS Stream 10 | `quay.io/centos/centos:stream10` | 5.40.2 |
| openEuler 24.03 LTS | `docker.io/openeuler/openeuler:24.03-lts` | 5.38.0 |
A run leaves its log in `/tmp/sglang-rig/log/`, including `run.out`, the script's own
output, and `stubs.log`, every command it issued with its arguments. That log is the
quickest way to see what a run actually did.
## Running one script for real
The diagnostic scripts need no root and can simply be run:
```sh
perl network-diag.pl
perl system-diag.pl --json
```
The four that change a system need root and, except for
`workstation-setup.pl`, run in a disposable container the same way the rig does. Start
from the platform image, and read the summary before anything else: every one of them
supports `--dry-run`, which changes nothing and prints what it would do.
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners:
| Workflow | Trigger | Steps |
|---|---|---|
| Test | push or pull request to `development` | install Perl, then the syntax gate, the licence-header gate, the punctuation gate, and the six check files |
| Deploy | push to `main`, or dispatched by hand | write `SHA256SUMS`, pin the host key from `DEPLOY_KNOWN_HOSTS`, publish over rsync |
The container rig is deliberately not in the pipeline: it needs `--privileged`, and the
runner is a small box shared with the forge. A change that touches a system is expected
to be verified with the rig locally, and the pull request says which platform was used.
## Releases
There are none. Each script carries its own version string, and the deploy pipeline
publishes the scripts on every push to `main`, so a merge is a release and the commit
history is the record of what changed.
+1584
View File
File diff suppressed because it is too large Load Diff
+2350
View File
File diff suppressed because it is too large Load Diff
+1861
View File
File diff suppressed because it is too large Load Diff
+2604
View File
File diff suppressed because it is too large Load Diff
+2325
View File
File diff suppressed because it is too large Load Diff
+1727
View File
File diff suppressed because it is too large Load Diff
+732
View File
@@ -0,0 +1,732 @@
#!/usr/bin/perl
# Rig for sglang-deploy.pl: runs the real script as root inside a container against
# stub commands, and checks the state, the written files and the exit codes it leaves.
use strict;
use warnings;
# The rig file lives beside this repository's other checks, everything it writes lives
# in a work directory that is bind mounted into the container, and the script under test
# is the one in the repository root.
my $RIG = $0 =~ m{^(.*)/[^/]+$} ? $1 : '.';
my $WORK = $ENV{SGLANG_RIG_WORK} // '/tmp/sglang-rig';
my $BIN = "$WORK/bin";
my $FIX = "$WORK/fixture";
my $ST = "$WORK/state";
my $LOG = "$WORK/log/stubs.log";
my $SCRIPT = $ENV{SGLANG_RIG_SCRIPT} // "$RIG/../../sglang-deploy.pl";
my $UNIT = '/etc/systemd/system/sglang.service';
my $ENVFILE = '/etc/sysconfig/sglang';
my $NGINX = '/etc/nginx/conf.d/sglang.conf';
my $CERTDIR = '/etc/ssl/sglang';
my $STATEDIR = '/opt/sglang';
my ($passed, $failed) = (0, 0);
my @failures;
sub check {
my ($cond, $label) = @_;
if (!defined $label || $label eq '') {
my @stack;
for my $i (0 .. 3) {
my @c = caller($i);
push @stack, join(':', $c[1] // '?', $c[2] // '?');
}
$label = 'no label, stack ' . join(' <- ', @stack);
}
if ($cond) { $passed++; print "ok $label\n"; return 1 }
$failed++;
push @failures, $label;
print "FAIL $label\n";
return 0;
}
# The verdict is forced into a boolean: a bare match in a sub argument list is a list
# context, where a failed match yields the empty list and would shift the label into
# the condition slot, turning a failure into a silent pass.
sub check_like {
my ($text, $re, $label) = @_;
$label = 'check_like without a label' unless defined $label;
my $matched = (defined $text && $text =~ $re) ? 1 : 0;
return check($matched, $label);
}
sub check_unlike {
my ($text, $re, $label) = @_;
$label = 'check_unlike without a label' unless defined $label;
my $matched = (defined $text && $text !~ $re) ? 1 : 0;
return check($matched, $label);
}
# Directories are made and removed with the language's own calls, so no module has to
# be installed on the machine running the rig.
sub make_dirs {
for my $path (@_) {
next if -d $path;
my @parts = split m{/}, $path;
my $built = '';
for my $part (@parts) {
next unless length $part;
$built .= "/$part";
mkdir($built, 0755) unless -d $built;
}
}
return;
}
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 $child = "$path/$entry";
remove_tree($child) if -d $child;
unlink($child);
}
closedir($dh);
}
rmdir($path);
return;
}
sub slurp_file {
my ($path) = @_;
return '' unless -f $path;
open(my $fh, '<', $path) or return '';
local $/ = undef;
my $text = <$fh>;
close($fh);
return defined $text ? $text : '';
}
sub mode_of {
my ($path) = @_;
return sprintf('%04o', (stat($path))[2] & 07777);
}
sub reset_fixture {
for my $path ($UNIT, $ENVFILE, $NGINX) {
unlink($path);
}
remove_tree($CERTDIR);
remove_tree($STATEDIR);
remove_tree($FIX);
remove_tree($ST);
# Directories a host that ran server-setup.pl has: nginx's configuration drop-in
# and systemd's unit directory.
make_dirs($FIX, $ST, "$WORK/log", '/etc/nginx/conf.d', '/etc/systemd/system');
my $fh;
open($fh, q{>}, $LOG) and close($fh);
# Packages a real host or a previous run has already installed.
write_fixture('rpm-installed', "nginx\n");
write_fixture('fw-services', "\n");
# firewalld is running: server-setup.pl ensures it, and the firewall step needs it.
my $fw;
open($fw, '>', "$ST/active.firewalld") and close($fw);
return;
}
sub write_fixture {
my ($name, $content) = @_;
open(my $fh, '>', "$FIX/$name") or die "cannot write $FIX/$name: $!\n";
print {$fh} $content;
close($fh);
return;
}
sub fixture_exists {
my ($name) = @_;
return -f "$FIX/$name" ? 1 : 0;
}
# Run the script with the stub PATH and return (rc, merged output).
sub run_script {
my (@args) = @_;
my $out_file = "$WORK/log/run.out";
my $pid = fork();
die "cannot fork: $!\n" unless defined $pid;
if ($pid == 0) {
open(STDOUT, '>', $out_file);
open(STDERR, '>&', \*STDOUT);
$ENV{PATH} = "$BIN:/usr/bin:/bin";
# The stubs read the work directory from the environment, since they are
# reached through links and cannot tell where the rig file lives.
$ENV{STUB_WORK} = $WORK;
exec '/usr/bin/perl', $SCRIPT, @args;
exit 126;
}
waitpid($pid, 0);
my $rc = $? >> 8;
return ($rc, slurp_file($out_file));
}
sub stub_log {
return slurp_file($LOG);
}
sub count_in_log {
my ($pattern) = @_;
my @lines = grep { /$pattern/ } split /\n/, stub_log();
return scalar @lines;
}
sub reset_log {
my $fh;
open($fh, q{>}, $LOG) and close($fh);
return;
}
# ---------------------------------------------------------------------------
# Scenarios
# ---------------------------------------------------------------------------
# The stubs are one dispatcher file reached through links named after each command,
# so the script's own PATH lookup finds them and nothing of the real system is used.
sub prepare_stubs {
make_dirs($BIN, $FIX, $ST, "$WORK/log");
for my $name (qw(lspci rpm dnf podman systemctl curl openssl nginx
firewall-cmd getenforce getsebool setsebool)) {
my $link = "$BIN/$name";
# A link left over from a work directory that moved reads as broken to
# -e, and its stale target would leave the stubs unreachable: it is
# replaced rather than kept or died on.
if (-l $link && !-e $link) { unlink($link) }
next if -e $link;
symlink("$RIG/stub.pl", $link) or die "cannot link $link: $!\n";
}
return;
}
sub prepare_devices {
# The driver creates these on a real host; the rig fakes them (needs --privileged).
return if -e q{/dev/kfd};
system(qw(mknod /dev/kfd c 237 0));
mkdir(q{/dev/dri}, 0755) unless -d q{/dev/dri};
return;
}
sub scenario_fresh {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1] (rev 01)\n");
write_fixture('rocm.txt', "7.2.4\n");
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 0, 'fresh: exit 0');
check(-f $UNIT, 'fresh: unit written');
check(-f $ENVFILE, 'fresh: environment file written');
check(-f $NGINX, 'fresh: nginx configuration written');
check(-f "$CERTDIR/sglang.crt" && -f "$CERTDIR/sglang.key", 'fresh: certificate written');
check(-d "$STATEDIR/modelscope", 'fresh: model cache directory created');
check(mode_of($ENVFILE) eq '0600', 'fresh: environment file is 0600 (' . mode_of($ENVFILE) . ')');
check(mode_of("$CERTDIR/sglang.key") eq '0600', 'fresh: key is 0600');
check(mode_of("$CERTDIR/sglang.crt") eq '0644', 'fresh: certificate is 0644');
my $env = slurp_file($ENVFILE);
check_like($env, qr/^SGLANG_API_KEY=([A-Za-z0-9_-]{43})\n$/, 'fresh: generated key, url-safe, 43 chars');
my $unit = slurp_file($UNIT);
check_like($unit, qr|docker\.io/lmsysorg/sglang:v0\.5\.19-rocm724-mi30x|, 'fresh: image resolved from ROCm 7.2.4 and MI300');
check_like($unit, qr/--tp-size 1/, 'fresh: tensor parallel default');
check_like($unit, qr/--context-length 4096/, 'fresh: context length default');
check_like($unit, qr/--mem-fraction-static 0\.9/, 'fresh: memory fraction default');
check_unlike($unit, qr/SGLANG_USE_AITER/, 'fresh: no Radeon variables on an Instinct host');
my $nginx = slurp_file($NGINX);
check_like($nginx, qr|proxy_pass http://\[::1\]:8000;|, 'fresh: nginx proxies to the loopback engine');
check_like($nginx, qr|ssl_certificate /etc/ssl/sglang/sglang\.crt;|, 'fresh: nginx uses the sglang certificate');
check(count_in_log(qr/^podman pull /) == 1, 'fresh: exactly one image pull');
check_like(stub_log(), qr/^podman pull docker\.io\/lmsysorg\/sglang:v0\.5\.19-rocm724-mi30x$/m,
'fresh: the pulled image is the resolved tag');
check(count_in_log(qr/^systemctl enable sglang$/) == 1, 'fresh: service enabled');
check(count_in_log(qr/^systemctl start sglang$/) == 1, 'fresh: service started');
check_like(stub_log(), qr/^firewall-cmd --permanent --add-service=https$/m, 'fresh: HTTPS opened');
check_like($out, qr/Engine image: docker\.io\/lmsysorg\/sglang:v0\.5\.19-rocm724-mi30x/,
'fresh: summary names the image');
check_like($out, qr/systemd: sglang running on \[::1\]:8000/, 'fresh: summary names the endpoint');
check_like($out, qr/API key \(shown once, store it securely\)/, 'fresh: the generated key is shown once');
check_unlike($out, qr/✗/, 'fresh: no failed step');
return;
}
sub scenario_rerun {
scenario_fresh();
reset_log();
my $key_before = slurp_file($ENVFILE);
my $unit_before = slurp_file($UNIT);
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 0, 'rerun: exit 0');
check(count_in_log(qr/^podman pull /) == 0, 'rerun: no second pull');
check_like(stub_log(), qr/^podman image exists /m, 'rerun: the image is checked instead');
check(count_in_log(qr/^systemctl enable sglang$/) == 0, 'rerun: no second enable');
check(count_in_log(qr/^systemctl start sglang$/) == 0, 'rerun: no second start');
check(count_in_log(qr/try-restart/) == 0, 'rerun: no restart, the unit did not change');
check(count_in_log(qr/^dnf install /) == 0, 'rerun: no second package transaction');
check(slurp_file($ENVFILE) eq $key_before, 'rerun: the API key is reused, not regenerated');
check(slurp_file($UNIT) eq $unit_before, 'rerun: the unit file is byte-identical');
check_like($out, qr/API key: existing key reused/, 'rerun: the key is reported as reused');
check_like($out, qr/Engine image: already present/, 'rerun: the image is reported present');
check_like($out, qr/systemd: sglang running/, 'rerun: the service is reported running');
check_unlike($out, qr/would /, 'rerun: nothing is phrased as would');
check_unlike($out, qr/✗/, 'rerun: no failed step');
return;
}
sub scenario_dry_run {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1] (rev 01)\n");
write_fixture('rocm.txt', "7.2.4\n");
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--dry-run');
check($rc == 0, 'dry run: exit 0');
check(!-f $UNIT, 'dry run: no unit written');
check(!-f $ENVFILE, 'dry run: no environment file written');
check(!-f $NGINX, 'dry run: no nginx configuration written');
check(!-d $CERTDIR, 'dry run: no certificate directory');
check(count_in_log(qr/^podman pull /) == 0, 'dry run: no pull');
check(count_in_log(qr/^systemctl (enable|start) /) == 0, 'dry run: no service change');
check(count_in_log(qr/^dnf install /) == 0, 'dry run: no package transaction');
check_like($out, qr/DRY RUN: no changes will be made/, 'dry run: banner');
check_like($out, qr/would be pulled|would be installed/, 'dry run: phrased as would');
check_like($out, qr|Engine image: docker\.io/lmsysorg/sglang:v0\.5\.19-rocm724-mi30x|,
'dry run: the resolved image is reported');
return;
}
sub scenario_uninstall {
scenario_fresh();
reset_log();
my ($rc, $out) = run_script('--uninstall');
check($rc == 0, 'uninstall: exit 0');
check(!-f $UNIT, 'uninstall: unit removed');
check(!-f $ENVFILE, 'uninstall: environment file removed');
check(!-f $NGINX, 'uninstall: nginx configuration removed');
check(!-d $CERTDIR, 'uninstall: certificate directory removed');
check(-d $STATEDIR, 'uninstall: model cache kept');
check(-e "$ST/image.docker.io_lmsysorg_sglang_v0.5.19-rocm724-mi30x",
'uninstall: the image is kept in podman');
check_like(stub_log(), qr/^systemctl stop sglang$/m, 'uninstall: service stopped');
check_like(stub_log(), qr/^systemctl disable sglang$/m, 'uninstall: service disabled');
check_like($out, qr/SGLang service stopped/, 'uninstall: summary reports the stop');
check_like($out, qr/Kept on the system/, 'uninstall: the kept state is listed');
check_like($out, qr/ModelScope cache/, 'uninstall: the cache is named as kept');
return;
}
sub scenario_uninstall_twice {
scenario_uninstall();
reset_log();
my ($rc, $out) = run_script('--uninstall');
check($rc == 0, 'uninstall twice: exit 0');
check_like($out, qr/already absent/, 'uninstall twice: idempotent');
check(count_in_log(qr/^systemctl stop /) == 0, 'uninstall twice: nothing to stop');
return;
}
sub scenario_radeon {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Navi 31 [Radeon RX 7900 XTX] [1002:744c] (rev c8)\n");
write_fixture('rocm.txt', "7.2.4\n");
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 1, 'radeon: exit 1');
check_like($out, qr/Radeon card detected/, 'radeon: names the cause');
check_like($out, qr/rocm\.Dockerfile/, 'radeon: names the build recipe');
check_like($out, qr/--image sglang-rocm:latest/, 'radeon: names the way forward');
check(!-f $UNIT, 'radeon: nothing deployed');
return;
}
sub scenario_radeon_dev {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon Graphics / Radeon 8050S Graphics / Radeon 8060S Graphics] [1002:1586] (rev d1)\n");
write_fixture('rocm.txt', "7.2.4\n");
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 0, 'radeon dev: exit 0');
check_like(stub_log(), qr{^curl .*rocm/sgl-dev/tags\?.*$}m,
'radeon dev: the AMD tag index is asked for the newest build');
my $unit = slurp_file($UNIT);
check_like($unit, qr|docker\.io/rocm/sgl-dev:v0\.5\.19-rocm724-gfx1151-20260916|,
'radeon dev: the newest dated build is deployed');
check_like($unit, qr/Environment=SGLANG_USE_AITER=false/, 'radeon dev: AITER off');
check_like($unit, qr/Environment=SGLANG_ROCM_FUSED_DECODE_MLA=false/,
'radeon dev: fused decode MLA off');
check_like($out, qr/AMD publishes this build daily/, 'radeon dev: the moving tag is stated');
check_like($out, qr/the only flavour published for gfx1151/, 'radeon dev: the flavour is explained');
reset_log();
my ($rc2, $out2) = run_script('--model', 'ZhipuAI/GLM-5.3', '--image', 'localhost/pinned:1');
check_like(slurp_file($UNIT), qr|localhost/pinned:1|, 'radeon dev: --image pins a build');
check(count_in_log(qr{^curl .*rocm/sgl-dev/tags\?.*$}) == 0,
'radeon dev: no tag resolution when --image is given');
return;
}
sub scenario_radeon_with_image {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon 8060S] [1002:150e]\n");
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--image', 'localhost/sglang-rocm:gfx1151');
check($rc == 0, 'radeon with image: exit 0');
my $unit = slurp_file($UNIT);
check_like($unit, qr/Environment=SGLANG_USE_AITER=false/, 'radeon with image: AITER off');
check_like($unit, qr/Environment=SGLANG_ROCM_FUSED_DECODE_MLA=false/,
'radeon with image: fused decode MLA off');
check_like($unit, qr|localhost/sglang-rocm:gfx1151|, 'radeon with image: the requested image');
my ($rc2, $out2) = run_script('--model', 'ZhipuAI/GLM-5.3', '--image', 'localhost/sglang-rocm:gfx1151');
check($rc2 == 0, 'radeon with image: rerun exit 0');
check(slurp_file($UNIT) eq $unit, 'radeon with image: the unit is unchanged on a rerun');
return;
}
sub scenario_rocm_flavours {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.3\n");
my ($rc, $out) = run_script('--dry-run', '--model', 'ZhipuAI/GLM-5.3');
check_like($out, qr/rocm720-mi30x/, 'flavour: ROCm 7.2.3 takes rocm720');
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "10.0.0\n");
($rc, $out) = run_script('--dry-run', '--model', 'ZhipuAI/GLM-5.3');
check_like($out, qr/rocm10-mi30x/, 'flavour: ROCm 10.0.0 takes rocm10');
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI350X [1002:75a0]\n");
write_fixture('rocm.txt', "7.2.4\n");
($rc, $out) = run_script('--dry-run', '--model', 'ZhipuAI/GLM-5.3');
check_like($out, qr/rocm724-mi35x/, 'flavour: MI350X takes mi35x');
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "6.4.0\n");
($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 1, 'flavour: an old host ROCm is refused');
check_like($out, qr/No published image targets ROCm 6\.4\.0/, 'flavour: the refusal names the version');
check_like($out, qr/--image/, 'flavour: the refusal names the way forward');
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
($rc, $out) = run_script('--dry-run', '--model', 'ZhipuAI/GLM-5.3');
check_like($out, qr/rocm10-mi30x/, 'flavour: no host ROCm takes the newest');
check_like($out, qr/No ROCm userland found on the host/, 'flavour: the assumption is stated out loud');
return;
}
sub scenario_modelscope {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
write_fixture('ms.code', "200\n");
write_fixture('ms.json', qq({"Data":{"Name":"GLM-5.3"}}\n));
my ($rc, $out) = run_script('--dry-run', '--model', 'ZhipuAI/GLM-5.3');
check_like($out, qr/Verifying GLM 5\.3 on ModelScope\.\.\. found/, 'modelscope: found');
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
write_fixture('ms.code', "404\n");
($rc, $out) = run_script('--model', 'nope/does-not-exist');
check($rc == 1, 'missing model: exit 1');
check_like($out, qr/does not exist on ModelScope/, 'missing model: named as missing');
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
# A 200 without a Name carries no usable answer: the deployment proceeds with
# the probe marked unverified rather than blocked.
write_fixture('ms.code', "200\n");
write_fixture('ms.json', qq({}\n));
($rc, $out) = run_script('--dry-run', '--model', 'ZhipuAI/GLM-5.3');
check($rc == 0, 'modelscope: an unnamed answer does not block');
check_like($out, qr/could not verify/, 'modelscope: the unnamed answer reads as unverified');
return;
}
sub scenario_hardware {
reset_fixture();
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 1, 'no GPU: exit 1');
check_like($out, qr/No AMD GPU detected/, 'no GPU: named as missing');
# The missing driver is checked outside the rig: podman bind-mounts /dev/kfd into a
# privileged container, so it cannot be removed from inside one.
return;
}
# Run without a GPU device node (a container without --privileged, or a host whose
# driver is not loaded): the script must refuse and say what is missing.
sub scenario_no_driver {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
if (-e '/dev/kfd') {
print "skip no driver: this run has the device node (needs a run without --privileged)\n";
return;
}
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 1, 'no driver: exit 1');
check_like($out, qr/amdgpu kernel driver is not ready/, 'no driver: named as missing');
check_like($out, qr{/dev/kfd}, 'no driver: names the device node');
check(!-f $UNIT, 'no driver: nothing deployed');
return;
}
sub scenario_bad_os {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('os-release', "ID=ubuntu\nVERSION_ID=\"24.04\"\n");
my $real = slurp_file('/etc/os-release');
open(my $fh, '>', '/etc/os-release') or die "cannot write /etc/os-release: $!\n";
print {$fh} slurp_file("$FIX/os-release");
close($fh);
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--dry-run');
open(my $back, '>', '/etc/os-release') or die "cannot restore /etc/os-release: $!\n";
print {$back} $real;
close($back);
check($rc == 1, 'bad OS: exit 1');
check_like($out, qr/Unsupported operating system: 'ubuntu'/, 'bad OS: names the system');
check_like($out, qr/Supported systems: Fedora, CentOS Stream, openEuler/, 'bad OS: lists the supported ones');
return;
}
sub scenario_validation {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--port', '443', '--dry-run');
check($rc == 1, 'validation: port 443 refused');
check_like($out, qr/must be 1-65535 and not 443/, 'validation: port message');
($rc, $out) = run_script('--model', 'not-a-model-id', '--dry-run');
check($rc == 1, 'validation: bad model ID refused');
check_like($out, qr/Invalid model ID/, 'validation: model message');
($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--gpu-memory-utilization', '1.5', '--dry-run');
check($rc == 1, 'validation: memory fraction above 1 refused');
check_like($out, qr/must be in \(0, 1\]/, 'validation: memory fraction message');
($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--api-key', 'has space', '--dry-run');
check($rc == 1, 'validation: whitespace API key refused');
($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--rocm-flavour', 'rocm999', '--dry-run');
check($rc == 1, 'validation: unknown flavour refused');
check_like($out, qr/Invalid --rocm-flavour/, 'validation: flavour message');
($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--image', 'bad image%name', '--dry-run');
check($rc == 1, 'validation: image with whitespace or % refused');
check_like($out, qr/Invalid --image/, 'validation: image message');
return;
}
sub scenario_api_key_argument {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--api-key', 'chosen-key-1234');
check($rc == 0, 'api key argument: exit 0');
check_like(slurp_file($ENVFILE), qr/^SGLANG_API_KEY=chosen-key-1234$/, 'api key argument: the key is stored');
check_unlike($out, qr/chosen-key-1234/, 'api key argument: a given key is never echoed');
my $unit = slurp_file($UNIT);
check_unlike($unit, qr/chosen-key-1234/, 'api key argument: the key is not in the unit');
return;
}
sub scenario_non_root {
reset_fixture();
my $out_file = "$WORK/log/run.out";
my $pid = fork();
die "cannot fork: $!\n" unless defined $pid;
if ($pid == 0) {
open(STDOUT, '>', $out_file);
open(STDERR, '>&', \*STDOUT);
$ENV{PATH} = "$BIN:/usr/bin:/bin";
$ENV{STUB_WORK} = $WORK;
$) = 1000;
$> = 1000;
exec '/usr/bin/perl', $SCRIPT, '--version';
exit 126;
}
waitpid($pid, 0);
my $rc = $? >> 8;
my $out = slurp_file($out_file);
check($rc == 0, 'non-root: --version works without root');
check_like($out, qr/^sglang-deploy\.pl 2\.0\.0$/, 'non-root: the version is printed');
my $pid3 = fork();
die "cannot fork: $!\n" unless defined $pid3;
if ($pid3 == 0) {
open(STDOUT, '>', $out_file);
open(STDERR, '>&', \*STDOUT);
$ENV{PATH} = "$BIN:/usr/bin:/bin";
$ENV{STUB_WORK} = $WORK;
$) = 1000;
$> = 1000;
exec '/usr/bin/perl', $SCRIPT, '--model', 'ZhipuAI/GLM-5.3';
exit 126;
}
waitpid($pid3, 0);
my $rc3 = $? >> 8;
my $out3 = slurp_file($out_file);
check($rc3 == 1, 'non-root: exit 1');
check_like($out3, qr/must be run as root/, 'non-root: the message says so');
return;
}
sub scenario_offline {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
write_fixture('image-missing', "1\n");
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 1, 'unpublished tag: exit 1');
check_like($out, qr/resolved image tag does not exist/, 'unpublished tag: named as missing');
check_like($out, qr/--image/, 'unpublished tag: names the way forward');
return;
}
sub scenario_dependency_section {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
write_fixture('rpm-installed', "pciutils\ncurl\nopenssl\npodman\nnginx\n");
reset_log();
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3', '--dry-run');
check($rc == 0, 'deps: exit 0');
check(count_in_log(qr/^dnf install /) == 0, 'deps: no transaction when all packages are present');
check_like($out, qr/All 4 packages already installed/, 'deps: reported as present');
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
write_fixture('rpm-installed', "\n");
reset_log();
($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check_like(stub_log(), qr/^dnf install -y pciutils curl openssl podman$/m,
'deps: the four packages are installed in one transaction');
check_like($out, qr/nginx \(reverse proxy\) is not installed: run server-setup.pl first/,
'deps: the missing reverse proxy is warned about');
return;
}
sub scenario_custom_layout {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
my ($rc, $out) = run_script(
'--model', 'ZhipuAI/GLM-5.3',
'--service-name', 'llm',
'--state-dir', '/srv/llm',
'--cert-dir', '/etc/ssl/llm',
'--port', '9000',
'--tensor-parallel', '8',
'--max-model-len', '131072',
'--gpu-memory-utilization', '0.85',
);
check($rc == 0, 'custom layout: exit 0');
check(-f '/etc/systemd/system/llm.service', 'custom layout: the named unit is written');
check(-f '/etc/nginx/conf.d/llm.conf', 'custom layout: the named nginx file is written');
# The certificate file names follow the program, as they did before, not the
# service name; the directory follows --cert-dir.
check(-f '/etc/ssl/llm/sglang.crt', 'custom layout: certificates follow the directory');
check(-d '/srv/llm/modelscope', 'custom layout: the state directory follows');
my $unit = slurp_file('/etc/systemd/system/llm.service');
check_like($unit, qr/--port 9000/, 'custom layout: port in the unit');
check_like($unit, qr/--tp-size 8/, 'custom layout: tensor parallel in the unit');
check_like($unit, qr/--context-length 131072/, 'custom layout: context length in the unit');
check_like($unit, qr/--mem-fraction-static 0\.85/, 'custom layout: memory fraction in the unit');
check_like($unit, qr|--volume /srv/llm/modelscope:/root/\.cache/modelscope:Z|,
'custom layout: the cache volume follows the state directory');
unlink('/etc/systemd/system/llm.service');
unlink('/etc/nginx/conf.d/llm.conf');
remove_tree('/etc/ssl/llm');
remove_tree('/srv/llm');
return;
}
sub scenario_tls_existing {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
run_script('--model', 'ZhipuAI/GLM-5.3');
my $crt = slurp_file("$CERTDIR/sglang.crt");
reset_log();
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check(slurp_file("$CERTDIR/sglang.crt") eq $crt, 'tls: an existing certificate is kept');
check(count_in_log(qr/^openssl /) == 0, 'tls: no second certificate');
check_like($out, qr/TLS certificate: configured/, 'tls: reported as configured');
return;
}
sub scenario_stale_container {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
my $fh;
open($fh, q{>}, "$ST/container.sglang") and close($fh);
my ($rc, $out) = run_script('--uninstall');
check($rc == 0, 'stale container: uninstall exit 0');
check_like(stub_log(), qr/^podman rm -f sglang$/m, 'stale container: removed');
check(!-e "$ST/container.sglang", 'stale container: gone from podman');
return;
}
sub scenario_selinux {
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
$ENV{STUB_SELINUX} = 'Permissive';
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check(count_in_log(qr/^setsebool /) == 0, 'selinux: nothing set while permissive');
check_like($out, qr/SELinux: permissive \(skipped\)/, 'selinux: reported as skipped');
reset_fixture();
write_fixture('gpu.txt', "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1]\n");
write_fixture('rocm.txt', "7.2.4\n");
$ENV{STUB_SELINUX} = 'Enforcing';
reset_log();
($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check_like(stub_log(), qr/^setsebool -P httpd_can_network_connect=1$/m, 'selinux: the boolean is set');
check_like($out, qr/SELinux: httpd_can_network_connect on/, 'selinux: reported as on');
reset_log();
my ($rc2, $out2) = run_script('--model', 'ZhipuAI/GLM-5.3');
check(count_in_log(qr/^setsebool /) == 0, 'selinux: nothing set when already on');
delete $ENV{STUB_SELINUX};
return;
}
my %scenarios = (
fresh => \&scenario_fresh,
rerun => \&scenario_rerun,
dry_run => \&scenario_dry_run,
uninstall => \&scenario_uninstall,
uninstall_twice => \&scenario_uninstall_twice,
radeon => \&scenario_radeon,
radeon_dev => \&scenario_radeon_dev,
radeon_with_image => \&scenario_radeon_with_image,
rocm_flavours => \&scenario_rocm_flavours,
modelscope => \&scenario_modelscope,
hardware => \&scenario_hardware,
bad_os => \&scenario_bad_os,
no_driver => \&scenario_no_driver,
validation => \&scenario_validation,
api_key_argument => \&scenario_api_key_argument,
non_root => \&scenario_non_root,
offline => \&scenario_offline,
dependencies => \&scenario_dependency_section,
custom_layout => \&scenario_custom_layout,
tls_existing => \&scenario_tls_existing,
stale_container => \&scenario_stale_container,
selinux => \&scenario_selinux,
);
prepare_stubs();
prepare_devices();
my @wanted = @ARGV ? @ARGV : sort keys %scenarios;
for my $name (@wanted) {
if (!exists $scenarios{$name}) {
print "FAIL unknown scenario $name\n";
$failed++;
next;
}
print "\n== $name ==\n";
$scenarios{$name}->();
}
print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all " . $passed . " checks passed") . "\n";
exit($failed ? 1 : 0);
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/perl
# Stub commands for the sglang-deploy.pl rig. One file, dispatched by $0's basename,
# so every stubbed tool answers from the fixture under /rig/fixture and /rig/state.
use strict;
use warnings;
# Everything this stub reads and writes lives in the work directory the rig exports to
# the script under test, which is why it is read from the environment: the stub is
# reached through a link named after the command it stands in for.
my $RIG = $ENV{STUB_WORK} // '/tmp/sglang-rig';
my $FIX = "$RIG/fixture";
my $ST = "$RIG/state";
my $LOG = "$RIG/log/stubs.log";
my ($name) = $0 =~ m{([^/]+)$};
my @args = @ARGV;
sub log_call {
if (open(my $fh, '>>', $LOG)) {
print {$fh} "$name @args\n";
close($fh);
}
return;
}
sub fixture_text {
my ($file, $default) = @_;
my $path = "$FIX/$file";
return $default unless -f $path;
open(my $fh, '<', $path) or return $default;
local $/ = undef;
my $text = <$fh>;
close($fh);
return defined $text ? $text : $default;
}
# Image names carry slashes and colons, which are not file names; the marker files keep
# the image identity without them.
sub state_name { my ($name) = @_; $name =~ s{[/:]}{_}g; return $name }
sub state_exists { return -e "$ST/" . state_name($_[0]) ? 1 : 0 }
sub touch_state {
my $fh;
open($fh, '>', "$ST/" . state_name($_[0])) and close($fh);
return;
}
sub drop_state { unlink("$ST/" . state_name($_[0])); return }
log_call();
if ($name eq 'lspci') {
print fixture_text('gpu.txt', '');
exit 0;
}
if ($name eq 'rpm') {
my $joined = join(' ', @args);
if ($joined =~ /%\{VERSION\}/ && $joined =~ /rocm-runtime/) {
my $version = fixture_text('rocm.txt', '');
$version =~ s/\s+$//;
exit 1 unless length $version;
print "$version\n";
exit 0;
}
my $pkg = $args[-1];
my $installed = fixture_text('rpm-installed', '');
if ($installed =~ /^\Q$pkg\E$/m) {
print "$pkg-1.0-1.x86_64\n";
exit 0;
}
print STDERR "package $pkg is not installed\n";
exit 1;
}
if ($name eq 'dnf') {
my @pkgs = grep { !/^-/ && $_ ne 'install' } @args;
my $installed = fixture_text('rpm-installed', '');
for my $pkg (@pkgs) {
$installed .= "$pkg\n" unless $installed =~ /^\Q$pkg\E$/m;
}
if (open(my $fh, '>', "$FIX/rpm-installed")) {
print {$fh} $installed;
close($fh);
}
print "Installing: @pkgs\n";
exit 0;
}
if ($name eq 'podman') {
my $sub = $args[0] // '';
if ($sub eq '--version') { print "podman version 5.8.4\n"; exit 0 }
if ($sub eq 'pull') {
my $image = $args[-1];
touch_state("image.$image");
print "Pulled $image\n";
exit 0;
}
if ($sub eq 'image' && ($args[1] // '') eq 'exists') {
exit(state_exists("image.$args[2]") ? 0 : 1);
}
if ($sub eq 'container' && ($args[1] // '') eq 'exists') {
exit(state_exists("container.$args[2]") ? 0 : 1);
}
if ($sub eq 'rm') {
my $target = $args[-1];
drop_state("container.$target");
print "$target\n";
exit 0;
}
exit 0;
}
if ($name eq 'systemctl') {
my @rest = grep { $_ !~ /^--/ } @args;
my $verb = shift @rest // '';
my $svc = $rest[0] // '';
$svc =~ s/\.service$//;
if ($verb eq 'is-active') { exit(state_exists("active.$svc") ? 0 : 1) }
if ($verb eq 'is-enabled') { exit(state_exists("enabled.$svc") ? 0 : 1) }
if ($verb eq 'start') { touch_state("active.$svc"); exit 0 }
if ($verb eq 'stop') { drop_state("active.$svc"); exit 0 }
if ($verb eq 'enable') { touch_state("enabled.$svc"); touch_state("active.$svc") if grep { $_ eq q{--now} } @args; exit 0 }
if ($verb eq 'disable') { drop_state("enabled.$svc"); exit 0 }
if ($verb eq 'try-restart') { exit(state_exists("active.$svc") ? 0 : 1) }
if ($verb eq 'reload' || $verb eq 'daemon-reload') { exit 0 }
exit 0;
}
if ($name eq 'curl') {
my ($url) = grep { /^https?:\/\// } @args;
my $joined = join(' ', @args);
$url //= '';
if ($url =~ m{modelscope\.cn/api/v1/models/}) {
my $code = fixture_text('ms.code', '200');
$code =~ s/\s+$//;
my $body = $joined =~ /__HTTP__/ ? fixture_text('ms.json', '{"Data":{"Name":"GLM-5.3"}}') : '';
print $body;
print "\n__HTTP__$code\n" if $joined =~ /__HTTP__/;
exit 0;
}
if ($url =~ m{api\.github\.com}) {
print fixture_text('releases.json', qq({"tag_name":"v0.5.19"}\n));
exit 0;
}
if ($url =~ m{hub\.docker\.com}) {
if (-f "$FIX/image-missing") {
print STDERR "curl: (22) The requested URL returned error: 404\n";
exit 22;
}
if ($url =~ /tags\?/) {
# A tag listing, newest first: the rig's AMD development build.
print qq({"results":[{"name":"v0.5.19-rocm724-gfx1151-20260916"}]}\n);
exit 0;
}
print qq({"name":"ok"}\n);
exit 0;
}
exit 0;
}
if ($name eq 'openssl') {
my %want;
for my $i (0 .. $#args) {
if ($args[$i] eq '-keyout') { $want{key} = $args[$i + 1] }
if ($args[$i] eq '-out') { $want{crt} = $args[$i + 1] }
if ($args[$i] eq '-subj') { $want{subj} = $args[$i + 1] }
if ($args[$i] eq '-addext') { $want{san} = $args[$i + 1] }
}
for my $kind (qw(key crt)) {
my $path = $want{$kind};
next unless defined $path;
if (open(my $fh, '>', $path)) {
print {$fh} "stub $kind $want{subj} $want{san}\n";
close($fh);
}
else {
print STDERR "can't open $path\n";
exit 1;
}
}
exit 0;
}
if ($name eq 'nginx') {
print "nginx: configuration file /etc/nginx/nginx.conf test is successful\n";
exit 0;
}
if ($name eq 'firewall-cmd') {
my $joined = join(' ', @args);
if ($joined =~ /--list-services/) {
print fixture_text('fw-services', "\n");
exit 0;
}
if ($joined =~ /--add-service=https/) {
my $fh;
open($fh, q{>>}, "$FIX/fw-services") and print {$fh} "https\n" and close($fh);
exit 0;
}
exit 0;
}
if ($name eq 'getenforce') {
my $mode = $ENV{STUB_SELINUX} // 'Enforcing';
print "$mode\n";
exit 0;
}
if ($name eq 'getsebool') {
my $value = state_exists('sebool') ? 'on' : 'off';
print "httpd_can_network_connect --> $value\n";
exit 0;
}
if ($name eq 'setsebool') {
touch_state('sebool');
exit 0;
}
exit 0;
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Checks for network-diag.pl: the address arithmetic, the address classification, the
# ping parsing, the MTU family fallback and the small helpers.
#
# Run from anywhere: perl tests/network-diag.pl
use strict;
use warnings;
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
$root = "./$root" if $root !~ m{^/};
require "$root/network-diag.pl";
my ($passed, $failed) = (0, 0);
sub is {
my ($got, $want, $label) = @_;
$got = 'undef' unless defined $got;
$want = 'undef' unless defined $want;
if ($got eq $want) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: $got\n want: $want\n";
return 0;
}
sub check {
my ($cond, $label) = @_;
return is($cond ? 1 : 0, 1, $label);
}
sub hex_of {
my ($bytes) = @_;
return join(' ', map { sprintf('%02x', $_) } unpack('C*', $bytes));
}
# ---------------------------------------------------------------------------
# IPv4 and IPv6 parsing
# ---------------------------------------------------------------------------
is(hex_of(ip4_bytes('127.0.0.1')), '7f 00 00 01', 'ip4: 127.0.0.1');
is(hex_of(ip4_bytes('0.0.0.0')), '00 00 00 00', 'ip4: the unspecified address');
is(hex_of(ip4_bytes('255.255.255.255')), 'ff ff ff ff', 'ip4: the broadcast address');
is(ip4_bytes('256.0.0.1'), undef, 'ip4: an octet above 255 is refused');
is(ip4_bytes('1.2.3'), undef, 'ip4: three octets are refused');
is(ip4_bytes('1.2.3.4.5'), undef, 'ip4: five octets are refused');
is(ip4_bytes('::1'), undef, 'ip4: an IPv6 address is refused');
is(hex_of(ip6_bytes('::')), '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
'ip6: the unspecified address');
is(hex_of(ip6_bytes('::1')), '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01',
'ip6: loopback in shorthand');
is(hex_of(ip6_bytes('2001:db8::1')), '20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01',
'ip6: shorthand in the middle');
is(hex_of(ip6_bytes('::ffff:127.0.0.1')), '00 00 00 00 00 00 00 00 00 00 ff ff 7f 00 00 01',
'ip6: an embedded IPv4 address');
is(hex_of(ip6_bytes('fe80::1%eth0')), 'fe 80 00 00 00 00 00 00 00 00 00 00 00 00 00 01',
'ip6: a scope suffix is dropped');
is(hex_of(ip6_bytes('2001:0db8:0000:0000:0000:0000:0000:0001')),
'20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01', 'ip6: the full form');
is(ip6_bytes('127.0.0.1'), undef, 'ip6: an IPv4 address is refused');
is(ip6_bytes('2001:db8:::1'), undef, 'ip6: a malformed address is refused');
is(ip6_bytes('2001:db8::1::2'), undef, 'ip6: two shorthands are refused');
is(is_mapped6('::ffff:10.0.0.1'), 1, 'mapped: an IPv4-mapped address');
is(is_mapped6('::1'), 0, 'mapped: loopback is not mapped');
is(is_mapped6('2001:db8::1'), 0, 'mapped: a global address is not mapped');
# ---------------------------------------------------------------------------
# sockaddr packing
# ---------------------------------------------------------------------------
my $sa = sa_in('127.0.0.1', 80);
is(length($sa), 16, 'sa_in: a sockaddr_in is 16 bytes');
is(unpack('S', substr($sa, 0, 2)), 2, 'sa_in: the family is AF_INET');
is(unpack('n', substr($sa, 2, 2)), 80, 'sa_in: the port is network order');
is(hex_of(substr($sa, 4, 4)), '7f 00 00 01', 'sa_in: the address follows');
is(hex_of(substr($sa, 8)), '00 00 00 00 00 00 00 00', 'sa_in: the padding is zero');
is(sa_in('::1', 80), undef, 'sa_in: an IPv6 address is refused');
my $sa6 = sa_in6('::1', 443);
is(length($sa6), 28, 'sa_in6: a sockaddr_in6 is 28 bytes');
is(unpack('n', substr($sa6, 2, 2)), 443, 'sa_in6: the port is network order');
is(hex_of(substr($sa6, 8, 16)), '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01',
'sa_in6: the address follows the flow information');
is(sa_in6('127.0.0.1', 443), undef, 'sa_in6: an IPv4 address is refused');
# ---------------------------------------------------------------------------
# Address classification
# ---------------------------------------------------------------------------
is(class4(ip4_bytes('127.0.0.1')), 'loopback', 'class4: loopback');
is(class4(ip4_bytes('169.254.1.1')), 'linklocal', 'class4: link-local');
is(class4(ip4_bytes('0.0.0.0')), 'unspecified', 'class4: unspecified');
is(class4(ip4_bytes('224.0.0.1')), 'multicast', 'class4: multicast');
is(class4(ip4_bytes('10.0.0.1')), 'private', 'class4: the ten network');
is(class4(ip4_bytes('192.168.1.1')), 'private', 'class4: the 192.168 network');
is(class4(ip4_bytes('172.16.0.1')), 'private', 'class4: the 172.16 network');
is(class4(ip4_bytes('100.64.0.1')), 'private', 'class4: carrier-grade NAT');
is(class4(ip4_bytes('8.8.8.8')), 'global', 'class4: a global address');
is(class6(ip6_bytes('::')), 'unspecified', 'class6: unspecified');
is(class6(ip6_bytes('::1')), 'loopback', 'class6: loopback');
is(class6(ip6_bytes('::ffff:1.2.3.4')), 'mapped', 'class6: IPv4-mapped');
is(class6(ip6_bytes('fe80::1')), 'linklocal', 'class6: link-local');
is(class6(ip6_bytes('ff02::1')), 'multicast', 'class6: multicast');
is(class6(ip6_bytes('fd00::1')), 'private', 'class6: unique local');
is(class6(ip6_bytes('2002::1')), 'private', 'class6: 6to4');
is(class6(ip6_bytes('2001:db8::1')), 'global', 'class6: a documentation prefix');
is(address_kind('v4', '127.0.0.1'), 'loopback', 'address_kind: an IPv4 class');
is(address_kind('v6', '::1'), 'loopback', 'address_kind: an IPv6 class');
is(address_kind('v4', 'not-an-address'), '', 'address_kind: unparsable input is empty');
# ---------------------------------------------------------------------------
# Ping output
# ---------------------------------------------------------------------------
my $iputils = <<'PING';
PING cloudflare.com (104.16.132.229) 56(84) bytes of data.
64 bytes from 104.16.132.229: icmp_seq=1 ttl=57 time=11.2 ms
64 bytes from 104.16.132.229: icmp_seq=2 ttl=57 time=12.4 ms
64 bytes from 104.16.132.229: icmp_seq=3 ttl=57 time=10.1 ms
--- cloudflare.com ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 10.109/11.233/12.401/0.938 ms
PING
my ($latencies, $received) = parse_ping_output($iputils);
is(scalar(@$latencies), 3, 'ping: three replies are parsed');
is($latencies->[0], '11.2', 'ping: the first latency');
is($latencies->[2], '10.1', 'ping: the last latency');
is($received, 3, 'ping: the received counter');
my $lossy = <<'PING';
2 packets transmitted, 1 received, 50% packet loss, time 1001ms
PING
my ($none, $one) = parse_ping_output($lossy);
is(scalar(@$none), 0, 'ping: a transcript without replies has no latencies');
is($one, 1, 'ping: a partial loss is counted');
my ($min, $max, $sum) = min_max_sum([3, 1, 2]);
is($min, 1, 'latency: the minimum');
is($max, 3, 'latency: the maximum');
is($sum, 6, 'latency: the sum');
# ---------------------------------------------------------------------------
# Listening sockets
# ---------------------------------------------------------------------------
my @ss_ports = parse_ss_output(
"State Recv-Q Send-Q Local Address:Port Peer Address:Port Process\n" .
"LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd daemon\",pid=977,fd=3))\n" .
"LISTEN 0 128 [::]:22 [::]:*\n"
);
is($ss_ports[0]{process}, 'users:(("sshd daemon",pid=977,fd=3))',
'ss: a process name with spaces is kept whole');
is($ss_ports[0]{bucket}, 'tcp4', 'ss: a plain local address is IPv4');
is($ss_ports[1]{process}, '', 'ss: a socket without permission has no process');
is($ss_ports[1]{bucket}, 'tcp6', 'ss: a bracketed local address is IPv6');
# ---------------------------------------------------------------------------
# Grading
# ---------------------------------------------------------------------------
is(grade_letter(100), 'A', 'grade: a perfect score');
is(grade_letter(90), 'A', 'grade: the A boundary');
is(grade_letter(89.9), 'B', 'grade: just under the A boundary');
is(grade_letter(80), 'B', 'grade: the B boundary');
is(grade_letter(70), 'C', 'grade: the C boundary');
is(grade_letter(60), 'D', 'grade: the D boundary');
is(grade_letter(59.9), 'F', 'grade: just under the D boundary');
is(grade_letter(0), 'F', 'grade: zero');
my $graded = compute_grade({});
is(ref($graded), 'HASH', 'grade: the verdict is a hash');
is($graded->{score}, 0, 'grade: no data scores zero');
is($graded->{grade}, 'F', 'grade: no data fails');
check($graded->{max_score} > 0, 'grade: the maximum is reported');
check(ref($graded->{breakdown}) eq 'HASH', 'grade: the breakdown is a hash');
my $full = compute_grade({
ping => { loss_pct => 0, avg_ms => 10 },
dns => { v4 => { 'example.org' => [{ ms => 5 }] } },
});
check($full->{score} >= 0 && $full->{score} <= $full->{max_score},
'grade: a measured run stays within the maximum');
is(ms_colour(undef), "\033[2m", 'colour: an unmeasured latency is dim');
is(ms_colour(0), "\033[2m", 'colour: a zero latency is dim');
check(ms_colour(5) ne ms_colour(5000), 'colour: the bands differ');
# ---------------------------------------------------------------------------
# Serialisation and arguments
# ---------------------------------------------------------------------------
is(perl_literal(5), '5', 'literal: an integer stays bare');
is(perl_literal(1.5), '1.5', 'literal: a float stays bare');
is(perl_literal('abc'), "'abc'", 'literal: a word is quoted');
is(perl_literal('42x'), "'42x'", 'literal: a number-like word is quoted');
is(perl_literal(undef), 'undef', 'literal: undef');
is(perl_literal("it's"), "'it\\'s'", 'literal: a quote is escaped');
is(perl_literal([1, 2]), '[1, 2]', 'literal: an array');
is(perl_literal({ b => 2, a => 1 }), "{ 'a' => 1, 'b' => 2 }", 'literal: a hash in key order');
eval { write_literal('/nonexistent/network-diag-check', 1) };
check(scalar($@ =~ /cannot write /), 'result write: a failed write names the operation');
{
local $ENV{TMPDIR} = '';
my $scratch = eval { scratch_dir() };
check(defined($scratch) && index($scratch // '', '/tmp/') == 0,
'scratch: an empty TMPDIR falls back to /tmp');
remove_scratch() if defined $scratch;
}
my $report_out = '';
open(my $report_cap, '>', \$report_out) or die "cannot capture the report: $!\n";
{
local *STDOUT = $report_cap;
print_report({ hostname => 'testhost', os => 'linux', dns => { error => 'probe boom' } });
}
close($report_cap);
check(index($report_out, 'unavailable: probe boom') >= 0,
'report: a failed DNS check names its error');
my @saved = @ARGV;
@ARGV = ('--target', 'example.org', '--protocol', 'v6');
my %args = parse_args();
is($args{target}, 'example.org', 'args: --target takes a value');
is($args{protocol}, 'v6', 'args: --protocol takes a value');
@ARGV = @saved;
@ARGV = ();
my %defaults = parse_args();
is($defaults{protocol}, 'auto', 'args: the family defaults to auto');
is($defaults{target}, 'cloudflare.com', 'args: the target has a default');
@ARGV = @saved;
# ---------------------------------------------------------------------------
# The MTU probe, through fixture binaries on a private PATH
# ---------------------------------------------------------------------------
# A fake getent answers the resolver probe and one fixed name, and a fake ping
# models a host whose IPv6 path is dead while IPv4 passes a 1500 B DF probe.
# Together they exercise the family fallback of check_mtu end to end without
# touching the network.
sub install_fake_bin {
my ($dir, $name, $body) = @_;
open(my $fh, '>', "$dir/$name") or die "cannot write $dir/$name: $!\n";
print {$fh} "#!/usr/bin/env perl\n", $body;
close($fh);
chmod(0755, "$dir/$name") or die "cannot chmod $dir/$name: $!\n";
return;
}
my $bindir = "/tmp/network-diag-test-bin.$$";
mkdir($bindir, 0700) or die "cannot create $bindir: $!\n";
{
local $ENV{PATH} = "$bindir:$ENV{PATH}";
install_fake_bin($bindir, 'getent', <<'FIXTURE');
my ($verb, $host) = @ARGV;
if ($verb eq 'ahostsv4' && $host eq 'localhost') {
print "127.0.0.1 STREAM\n";
print "# the localhost resolver probe\n";
exit 0;
}
if ($host eq 'mtu-fallback.test') {
print $verb eq 'ahostsv6' ? "2001:db8::1 STREAM\n" : "192.0.2.10 STREAM\n";
exit 0;
}
exit 2;
FIXTURE
install_fake_bin($bindir, 'ping', <<'FIXTURE');
my ($family, $payload) = ('', 0);
for (my $i = 0; $i < @ARGV; $i++) {
$family = $ARGV[$i] if $ARGV[$i] eq '-4' || $ARGV[$i] eq '-6';
$payload = $ARGV[$i + 1] if $ARGV[$i] eq '-s';
}
if ($family eq '-6' || $payload > 1472) {
print "1 packets transmitted, 0 received, 100% packet loss, time 0ms\n";
exit 1;
}
print "1 packets transmitted, 1 received, 0% packet loss, time 0ms\n";
exit 0;
FIXTURE
my $auto = check_mtu('mtu-fallback.test', 'auto', 'linux');
is($auto->{path_mtu}, 1500, 'mtu: a dead preferred family falls back to the other');
check(index($auto->{method} // '', 'IPv4 fallback') >= 0,
'mtu: the fallback names the family that answered');
my $v6 = check_mtu('mtu-fallback.test', 'v6', 'linux');
is($v6->{path_mtu}, 0, 'mtu: an explicit protocol choice does not fall back');
is($v6->{method}, 'ping DF probe (no probe size succeeded)',
'mtu: an explicit protocol choice reports the failure');
my $v4 = check_mtu('mtu-fallback.test', 'v4', 'linux');
is($v4->{path_mtu}, 1500, 'mtu: the preferred family answering needs no fallback');
is($v4->{method}, 'ping DF probe (1500B OK)', 'mtu: a direct hit carries no fallback note');
}
unlink(glob("$bindir/*"));
rmdir($bindir) or die "cannot remove $bindir: $!\n";
print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all $passed checks passed") . "\n";
exit($failed ? 1 : 0);
+706
View File
@@ -0,0 +1,706 @@
#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Checks for security-audit.pl: the SSH configuration readers and their
# evaluator, the socket and firewall parsers with their coverage and exposure
# rules, SELinux, updates, accounts, the kernel hardening verdicts, the deep
# scan evaluator, the grade, the findings order and the JSON encoder.
#
# Run from anywhere: perl tests/security-audit.pl
use strict;
use warnings;
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
$root = "./$root" if $root !~ m{^/};
require "$root/security-audit.pl";
my ($passed, $failed) = (0, 0);
sub is {
my ($got, $want, $label) = @_;
$got = 'undef' unless defined $got;
$want = 'undef' unless defined $want;
if ($got eq $want) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: $got\n want: $want\n";
return 0;
}
sub check {
my ($cond, $label) = @_;
# A match in an argument list yields the empty list on failure, which would
# shift the label into the condition slot, so the condition is forced first.
return is($cond ? 1 : 0, 1, $label);
}
sub like {
my ($got, $re, $label) = @_;
my $matched = (defined $got && $got =~ $re) ? 1 : 0;
if ($matched) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: " . (defined $got ? $got : 'undef') . "\n want: $re\n";
return 0;
}
# The severity a check id carries in a list of checks, and the message with it.
sub severity_of {
my ($checks, $id) = @_;
for my $check (@$checks) {
return ($check->{severity}, $check->{message}) if $check->{id} eq $id;
}
return ('missing', '');
}
# ---------------------------------------------------------------------------
# RPM version comparison
# ---------------------------------------------------------------------------
is(rpmvercmp('1.0', '1.0'), 0, 'rpmvercmp: equal');
is(rpmvercmp('1.0', '1.0.1'), -1, 'rpmvercmp: an extra segment is newer');
is(rpmvercmp('2.0', '1.9.9'), 1, 'rpmvercmp: the major decides');
is(rpmvercmp('1.10', '1.9'), 1, 'rpmvercmp: numeric segments compare by value');
is(rpmvercmp('1.007', '1.7'), 0, 'rpmvercmp: leading zeros are stripped');
is(rpmvercmp('1.0~rc1', '1.0'), -1, 'rpmvercmp: tilde sorts before the release');
is(rpmvercmp('1.0~rc1', '1.0~rc2'), -1, 'rpmvercmp: two pre releases');
is(rpmvercmp('1.0.fc41', '1.0.fc41'), 0, 'rpmvercmp: identical dist tags');
is(rpmvercmp('6.12.4-200.fc41', '6.12.4-100.fc41'), 1, 'rpmvercmp: a kernel release');
is(rpmvercmp('1.0b', '1.0'), 1, 'rpmvercmp: an alpha suffix is newer than none');
is(rpmvercmp('1.0', '1.0b'), -1, 'rpmvercmp: and the mirror side');
is(rpmvercmp('2016a', '2015616'), -1, 'rpmvercmp: a numeric segment beats an alpha one');
# ---------------------------------------------------------------------------
# SSH configuration
# ---------------------------------------------------------------------------
my ($values, $ports) = parse_sshd_effective(<<'SSHD');
port 22
port 2222
addressfamily any
listenaddress 0.0.0.0:22
permitrootlogin prohibit-password
passwordauthentication no
permitemptypasswords no
pubkeyauthentication yes
x11forwarding no
maxauthtries 3
SSHD
is($values->{permitrootlogin}, 'prohibit-password', 'sshd -T: a keyword reads back');
is($values->{passwordauthentication}, 'no', 'sshd -T: the key is lowercased');
is(scalar @$ports, 2, 'sshd -T: both port lines are kept');
is($ports->[1], 2222, 'sshd -T: the second port in order');
my $tmp_dir = "/tmp/security-audit-test.$$";
mkdir($tmp_dir, 0700) or die "cannot mkdir $tmp_dir: $!\n";
open(my $main_fh, '>', "$tmp_dir/sshd_config") or die "cannot write: $!\n";
print {$main_fh} <<'CONF';
# a comment
Port 22
PasswordAuthentication yes
Match User backup
PasswordAuthentication no
CONF
close($main_fh);
open(my $drop_fh, '>', "$tmp_dir/50-audit.conf") or die "cannot write: $!\n";
print {$drop_fh} "PasswordAuthentication no\nMaxAuthTries 2\n";
close($drop_fh);
my $conf_state = { values => {}, seen => {}, accum => {} };
read_sshd_config("$tmp_dir/sshd_config", $conf_state, 0);
is($conf_state->{values}{passwordauthentication}, 'yes', 'config: the first value wins');
is($conf_state->{values}{permitrootlogin}, undef, 'config: an absent key stays absent');
my $port_count = grep { $_ eq '22' } @{ $conf_state->{accum}{port} // [] };
is($port_count, 1, 'config: the port accumulates');
# Include expansion, with a path that is not under /etc/ssh.
open($main_fh, '>', "$tmp_dir/sshd_config") or die "cannot write: $!\n";
print {$main_fh} "Include $tmp_dir/*.conf\nPasswordAuthentication yes\n";
close($main_fh);
$conf_state = { values => {}, seen => {}, accum => {} };
read_sshd_config("$tmp_dir/sshd_config", $conf_state, 0);
is($conf_state->{values}{passwordauthentication}, 'no',
'config: an include is read before the lines after it');
is($conf_state->{values}{maxauthtries}, '2', 'config: the drop-in carries its own key');
# The Match block of the first fixture must not be picked up either: the first
# value is the one a global reading of the file yields, which is what the
# fallback promises.
# A Match block alone sets nothing: everything after the Match line is
# conditional on a pattern the fallback cannot evaluate.
open($main_fh, '>', "$tmp_dir/sshd_config") or die "cannot write: $!\n";
print {$main_fh} "Match User admin\n PermitRootLogin yes\n";
close($main_fh);
$conf_state = { values => {}, seen => {}, accum => {} };
read_sshd_config("$tmp_dir/sshd_config", $conf_state, 0);
is($conf_state->{values}{permitrootlogin}, undef, 'config: a Match block alone sets nothing');
sub ssh_values {
my %pair = @_;
my %values;
$values{$_} = $pair{$_} for keys %pair;
return \%values;
}
my @checks = evaluate_ssh({
available => 1, source => 'effective',
values => ssh_values(
permitrootlogin => 'yes', passwordauthentication => 'no',
permitemptypasswords => 'no', pubkeyauthentication => 'yes',
x11forwarding => 'no', maxauthtries => '3',
),
ports => [22],
});
is((severity_of(\@checks, 'ssh.permit_root_login'))[0], 'critical',
'ssh: root login yes is critical');
@checks = evaluate_ssh({
available => 1, source => 'config',
values => ssh_values(
permitrootlogin => 'prohibit-password', passwordauthentication => 'yes',
permitemptypasswords => 'yes',
),
ports => [22],
});
is((severity_of(\@checks, 'ssh.permit_root_login'))[0], 'pass',
'ssh: prohibit-password passes');
is((severity_of(\@checks, 'ssh.password_authentication'))[0], 'warn',
'ssh: password authentication on is a warning');
is((severity_of(\@checks, 'ssh.permit_empty_passwords'))[0], 'critical',
'ssh: empty passwords permitted is critical');
is((severity_of(\@checks, 'ssh.max_auth_tries'))[0], 'pass',
'ssh: an absent MaxAuthTries falls back to OpenSSH default and passes');
is((severity_of(\@checks, 'ssh.max_auth_tries'))[1], 'MaxAuthTries 6',
'ssh: the OpenSSH default for MaxAuthTries is 6');
# sshd accepts and lowercases the Yes and No values, so an uppercase value in
# the configuration file is graded by its meaning and not by its case.
@checks = evaluate_ssh({
available => 1, source => 'config',
values => ssh_values(permitrootlogin => 'YES', passwordauthentication => 'No'),
ports => [22],
});
is((severity_of(\@checks, 'ssh.permit_root_login'))[0], 'critical',
'ssh: an uppercase Yes is still root login yes');
is((severity_of(\@checks, 'ssh.password_authentication'))[0], 'pass',
'ssh: an uppercase No still turns password auth off');
@checks = evaluate_ssh({ available => 0, source => '', values => {}, ports => [] });
is(scalar @checks, 0, 'ssh: an unavailable source yields no checks');
# ---------------------------------------------------------------------------
# Sockets
# ---------------------------------------------------------------------------
my @listeners = @{
parse_ss_listeners(<<'SS')
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1180,fd=3))
tcp LISTEN 0 5 192.168.1.7:5432 0.0.0.0:* users:(("postgres",pid=900,fd=5))
tcp LISTEN 0 128 127.0.0.1:6379 0.0.0.0:*
tcp LISTEN 0 128 [::1]:6379 [::]:*
tcp LISTEN 0 128 [::]:80 [::]:* users:(("nginx",pid=800,fd=6))
udp UNCONN 0 0 0.0.0.0:5353 0.0.0.0:*
udp UNCONN 0 0 [fe80::a7f4:eb68:1d07:aac3]%wlp1s0:3702 [::]:* users:(("wsdd",pid=7,fd=13))
tcp ESTAB 0 0 192.168.1.7:22 192.168.1.2:51000
SS
};
is(scalar @listeners, 7, 'ss: only LISTEN and UNCONN lines are sockets');
is($listeners[0]{proto}, 'tcp', 'ss: the protocol');
is($listeners[0]{port}, 22, 'ss: the port');
is($listeners[0]{loopback}, 0, 'ss: a wildcard v4 socket is not loopback');
is($listeners[0]{process}, 'sshd', 'ss: the process name');
is($listeners[1]{loopback}, 0, 'ss: a specific address is not loopback');
is($listeners[2]{loopback}, 1, 'ss: 127.0.0.1 is loopback');
is($listeners[3]{loopback}, 1, 'ss: [::1] is loopback');
is($listeners[3]{address}, '::1', 'ss: the brackets go');
is($listeners[4]{address}, '::', 'ss: a v6 wildcard');
is($listeners[5]{proto}, 'udp', 'ss: a udp socket is kept');
is($listeners[6]{address}, 'fe80::a7f4:eb68:1d07:aac3',
'ss: the scope suffix and the brackets both go');
is((split_listener_address('127.0.0.53%lo:53'))[0], '127.0.0.53', 'address: a v4 scope suffix');
is((split_listener_address('garbage'))[0], undef, 'address: no port means no socket');
is(is_loopback_address('127.255.0.1'), 1, 'address: the whole 127/8 is loopback');
is(is_loopback_address('0.0.0.0'), 0, 'address: the wildcard is not loopback');
is(is_wildcard_address('*'), 1, 'address: ss writes * for a v6 wildcard');
is(is_wildcard_address('192.168.1.7'), 0, 'address: a specific address');
is(parse_process_field('users:(("sshd",pid=1180,fd=3))'), 'sshd', 'process: the name');
is(parse_process_field(''), '', 'process: nothing to read');
# ---------------------------------------------------------------------------
# firewalld
# ---------------------------------------------------------------------------
my @xml_ports = parse_service_xml(<<'XML');
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>SSH</short>
<description>Secure Shell</description>
<port port="22" protocol="tcp"/>
</service>
XML
is(join(',', @xml_ports), '22/tcp', 'service xml: port first');
@xml_ports = parse_service_xml('<service><port protocol="udp" port="5353"/><port port="6000-6009" protocol="tcp"/></service>');
is(join(',', @xml_ports), '5353/udp,6000-6009/tcp', 'service xml: attribute order varies and ranges pass');
@xml_ports = parse_service_xml('<service><description>no ports</description></service>');
is(scalar @xml_ports, 0, 'service xml: a service without ports');
is(join('|', parse_active_zones("FedoraWorkstation (default)\n interfaces: wlp1s0\n")),
'FedoraWorkstation', 'zones: the decoration is not part of the name');
is(join('|', parse_active_zones("public\n interfaces: eth0\nwork\n interfaces: eth1\n")),
'public|work', 'zones: two zones, the second undecorated');
my $info = parse_info_zone(<<'ZONE');
FedoraWorkstation (default, active)
target: default
ingress-priority: 0
interfaces: wlp1s0
sources:
services: dhcpv6-client samba-client ssh
ports: 1025-65535/udp 1025-65535/tcp
rich rules:
ZONE
is($info->{name}, 'FedoraWorkstation (default, active)', 'info zone: the name line');
is($info->{target}, 'default', 'info zone: the target');
is(join('|', @{ $info->{services} }), 'dhcpv6-client|samba-client|ssh', 'info zone: the services');
is(join('|', @{ $info->{ports} }), '1025-65535/udp|1025-65535/tcp', 'info zone: the ports');
my $allowed = build_allowed([
{
ports => ['8080/tcp', '53/udp'],
services => ['ssh'],
service_ports => ['22/tcp'],
target => 'default',
},
]);
check(port_allowed($allowed, 'tcp', 22), 'coverage: a service port is allowed');
check(port_allowed($allowed, 'tcp', 8080), 'coverage: a listed port is allowed');
check(port_allowed($allowed, 'udp', 53), 'coverage: the protocol matters');
check(!port_allowed($allowed, 'tcp', 8081), 'coverage: the next port is not');
check(!port_allowed($allowed, 'tcp', 53), 'coverage: udp 53 is not tcp 53');
# --state answers on stdout only for a live daemon: a stopped firewalld is
# installed but inactive, not absent.
is(join(',', classify_firewalld(1, "running\n")), '1,1',
'firewalld: a running daemon');
is(join(',', classify_firewalld(1, '')), '1,0',
'firewalld: installed but stopped is available and inactive');
is(join(',', classify_firewalld(0, '')), '0,0',
'firewalld: no binary is unavailable');
$allowed = build_allowed([ { ports => ['6000-6009/tcp'], services => [], service_ports => [] } ]);
check(port_allowed($allowed, 'tcp', 6000), 'coverage: the start of a range');
check(port_allowed($allowed, 'tcp', 6009), 'coverage: the end of a range');
check(!port_allowed($allowed, 'tcp', 6010), 'coverage: past the end');
# The exposure model: behind a filtering zone only allowed ports are exposed;
# an ACCEPT target exposes everything; without a firewall the socket is exposed
# unless it sits on loopback.
sub firewall_fixture {
my (%override) = @_;
my %fw = (
available => 1, active => 1, default_zone => 'public',
zones => [ {
name => 'public', target => 'default',
ports => ['8080/tcp'], services => ['ssh'], service_ports => ['22/tcp'],
} ],
listeners => [
{ proto => 'tcp', address => '0.0.0.0', port => 22, loopback => 0, process => 'sshd' },
{ proto => 'tcp', address => '0.0.0.0', port => 9999, loopback => 0, process => '' },
{ proto => 'tcp', address => '127.0.0.1', port => 6379, loopback => 1, process => 'redis' },
],
);
$fw{$_} = $override{$_} for keys %override;
return \%fw;
}
my @fw_checks = evaluate_firewall(firewall_fixture());
my $listeners_check = (grep { $_->{id} eq 'firewall.listeners' } @fw_checks)[0];
like($listeners_check->{message}, qr/1 exposed/, 'firewall: one socket exposed behind a filtering zone');
is($listeners_check->{detail}[0], 'tcp 0.0.0.0:22 exposed (sshd)', 'firewall: the exposed socket is the allowed one');
is($listeners_check->{detail}[2], 'tcp 127.0.0.1:6379 local (redis)',
'firewall: a loopback listener is local, not filtered');
is((severity_of(\@fw_checks, 'firewall.active'))[0], 'pass', 'firewall: running firewalld passes');
is((severity_of(\@fw_checks, 'firewall.zone_target'))[0], 'pass', 'firewall: a filtering target passes');
is((severity_of(\@fw_checks, 'firewall.allowed_no_listener'))[0], 'warn',
'firewall: 8080 allowed with nothing listening is stale');
@fw_checks = evaluate_firewall(firewall_fixture(
zones => [ { name => 'home', target => 'accept', ports => [], services => ['ssh'], service_ports => ['22/tcp'] } ],
));
is((severity_of(\@fw_checks, 'firewall.zone_target'))[0], 'warn',
'firewall: an ACCEPT target zone is a warning');
like((severity_of(\@fw_checks, 'firewall.listeners'))[0], qr/info/,
'firewall: an ACCEPT target exposes the unallowed socket without failing');
is((grep { $_->{id} eq 'firewall.listeners' } @fw_checks)[0]{message} =~ /2 exposed/ ? 'two' : 'other',
'two', 'firewall: two sockets exposed under ACCEPT');
@fw_checks = evaluate_firewall(firewall_fixture(
available => 0, active => 0, zones => [],
));
is((severity_of(\@fw_checks, 'firewall.active'))[0], 'critical',
'firewall: no firewall and exposed sockets is critical');
@fw_checks = evaluate_firewall(firewall_fixture(
available => 0, active => 0, zones => [],
listeners => [ { proto => 'tcp', address => '127.0.0.1', port => 6379, loopback => 1, process => '' } ],
));
is((severity_of(\@fw_checks, 'firewall.active'))[0], 'warn',
'firewall: no firewall with loopback-only sockets is a warning');
@fw_checks = evaluate_firewall(firewall_fixture(
available => 1, active => 0, zones => [],
listeners => undef,
));
is((severity_of(\@fw_checks, 'firewall.active'))[0], 'unknown',
'firewall: an inactive firewall and no inventory is unknown');
is((severity_of(\@fw_checks, 'firewall.listeners'))[0], 'unknown',
'firewall: no ss means no inventory');
@fw_checks = evaluate_firewall(firewall_fixture(zones => []));
is((severity_of(\@fw_checks, 'firewall.zone_target'))[0], 'unknown',
'firewall: firewalld running without an active zone says so');
# ---------------------------------------------------------------------------
# SELinux
# ---------------------------------------------------------------------------
is(parse_selinux_config("# comment\nSELINUX=enforcing\n"), 'enforcing', 'selinux config: the value');
is(parse_selinux_config("SELINUXTYPE=targeted\nSELINUX = permissive\n"), 'permissive',
'selinux config: spaces around the assignment');
is(parse_selinux_config("SELINUXTYPE=targeted\n"), '', 'selinux config: no SELINUX key');
@checks = evaluate_selinux({ available => 1, runtime => 'Enforcing', configured => 'enforcing' });
is((severity_of(\@checks, 'selinux.runtime'))[0], 'pass', 'selinux: enforcing passes');
is((severity_of(\@checks, 'selinux.config'))[0], 'pass', 'selinux: matching config passes');
@checks = evaluate_selinux({ available => 1, runtime => 'Permissive', configured => 'enforcing' });
is((severity_of(\@checks, 'selinux.runtime'))[0], 'warn', 'selinux: permissive is a warning');
is((severity_of(\@checks, 'selinux.config'))[0], 'info', 'selinux: a future enforcement is noted');
@checks = evaluate_selinux({ available => 1, runtime => 'Enforcing', configured => 'disabled' });
is((severity_of(\@checks, 'selinux.config'))[0], 'warn',
'selinux: an enforcing runtime with a disabled config downgrades at boot');
@checks = evaluate_selinux({ available => 1, runtime => 'Disabled', configured => 'enforcing' });
is((severity_of(\@checks, 'selinux.config'))[0], 'warn',
'selinux: a disabled runtime with an enforcing config is a contradiction, not a promise');
@checks = evaluate_selinux({ available => 1, runtime => 'Enforcing', configured => '' });
is((severity_of(\@checks, 'selinux.config'))[0], 'unknown',
'selinux: no configuration file is unknown');
@checks = evaluate_selinux({ available => 0, runtime => '', configured => '' });
is(scalar @checks, 0, 'selinux: no tooling, no checks');
# ---------------------------------------------------------------------------
# Updates
# ---------------------------------------------------------------------------
is(count_security_updates(<<'DNF4'), 2, 'updateinfo: two dnf 4 advisory lines');
FEDORA-2026-aaaaaa security kernel-6.12.4-200.fc41.noarch
FEDORA-2026-bbbbbb security/important openssl-3.2.0-1.fc41.x86_64
DNF4
is(count_security_updates("FEDORA-2026-cccccc bugfix bash-5.2.0-1.fc41.x86_64\n"), 0,
'updateinfo: a bugfix line is not security');
is(count_security_updates("Last metadata expiration check: 2:04:11 ago on Fri.\n"), 0,
'updateinfo: a progress line is not an advisory');
is(parse_automatic_conf("[commands]\napply_updates = yes\n"), 'yes', 'automatic.conf: yes');
is(parse_automatic_conf("[commands]\n# apply_updates = yes\napply_updates = no\n"), 'no',
'automatic.conf: the last assignment, comments skipped');
is(parse_automatic_conf("[commands]\nupgrade_type = security\n"), '', 'automatic.conf: the key is absent');
# On a dnf5 host dnf-automatic.timer is an alias whose target carries the
# enablement, and "alias" itself names no state.
is(join('|', pick_timer_unit({
'dnf5-automatic.timer' => 'enabled',
'dnf-automatic-install.timer' => 'not-found',
'dnf-automatic.timer' => 'alias',
})), 'dnf5-automatic.timer|enabled',
'timers: an alias defers to the dnf5 unit name');
is(join('|', pick_timer_unit({
'dnf5-automatic.timer' => 'not-found',
'dnf-automatic-install.timer' => 'disabled',
'dnf-automatic.timer' => 'disabled',
})), 'dnf-automatic-install.timer|disabled',
'timers: the dnf4 install timer is still found');
is(join('|', pick_timer_unit({
'dnf5-automatic.timer' => 'not-found',
'dnf-automatic-install.timer' => 'not-found',
'dnf-automatic.timer' => 'alias',
})), '|', 'timers: alias and not-found are no answer');
# An absent apply_updates is the documented default: no, download only.
@checks = evaluate_updates({
security_pending => 0, timer => 'enabled', timer_name => 'dnf5-automatic.timer',
apply_updates => 'no', apply_updates_defaulted => 1,
running_kernel => '6.12.4-200.fc44.x86_64', newest_kernel => '', reboot_required => 0,
});
is((severity_of(\@checks, 'updates.apply_updates'))[0], 'warn',
'updates: the default downloads without installing');
like((severity_of(\@checks, 'updates.apply_updates'))[1], qr/no \(the default\)/,
'updates: a defaulted value says so');
@checks = evaluate_updates({
timer => 'unknown', timer_name => '', apply_updates => undef,
security_pending => undef, pending_error => '', running_kernel => '',
newest_kernel => '', reboot_required => undef,
});
is((severity_of(\@checks, 'updates.apply_updates'))[0], 'unknown',
'updates: no readable configuration is unknown');
# ---------------------------------------------------------------------------
# Accounts
# ---------------------------------------------------------------------------
my $users = parse_passwd([
'root:x:0:0:root:/root:/bin/bash',
'petrbalvin:x:1000:1000:Petr:/home/petrbalvin:/bin/zsh',
'daemon:x:2:2:daemon:/sbin:/sbin/nologin',
'brokenline',
]);
is(scalar @$users, 3, 'passwd: a malformed line is skipped');
is($users->[0]{uid}, 0, 'passwd: the uid');
is(join(',', @{ empty_password_users(['root:', 'daemon:*', 'svc:!!']) }), 'root',
'shadow: an empty hash, a locked one and a bang one');
# A real shadow line carries the ageing fields behind the hash, so the hash is
# the second field and not everything after the name.
is(join(',', @{ empty_password_users([
'svc::19997:0:99999:7:::',
'daemon:*:19997:0:99999:7:::',
'alice:$6$rounds=656000$ salted:19997:0:99999:7:::',
'root:',
]) }), 'svc,root', 'shadow: the empty hash is the second field, ageing fields aside');
my @principals = parse_sudoers_text(<<'SUDO');
Defaults env_reset
%wheel ALL=(ALL) ALL
petrbalvin ALL=(ALL) NOPASSWD: ALL
# %commented ALL=(ALL) NOPASSWD: ALL
SUDO
is(join('|', @principals), 'petrbalvin', 'sudoers: the NOPASSWD principal, comments skipped');
@checks = evaluate_accounts({
uid_zero => ['root'], empty_passwords => undef, nopasswd => undef,
human_users => ['petrbalvin (1000)'], root_authorized_keys => undef,
});
is((severity_of(\@checks, 'accounts.uid_zero'))[0], 'pass', 'accounts: only root at uid 0');
is((severity_of(\@checks, 'accounts.empty_passwords'))[0], 'unknown',
'accounts: shadow unreadable is unknown, not a pass');
@checks = evaluate_accounts({
uid_zero => ['root', 'toor'], empty_passwords => ['svc'], nopasswd => ['%wheel'],
human_users => [], root_authorized_keys => 2,
});
is((severity_of(\@checks, 'accounts.uid_zero'))[0], 'critical', 'accounts: a second uid 0 is critical');
is((severity_of(\@checks, 'accounts.empty_passwords'))[0], 'critical', 'accounts: an empty password is critical');
is((severity_of(\@checks, 'accounts.nopasswd_sudo'))[0], 'warn', 'accounts: NOPASSWD is a warning');
# ---------------------------------------------------------------------------
# Services and the kernel sysctls
# ---------------------------------------------------------------------------
is((severity_of([evaluate_services([])], 'services.failed_units'))[0], 'pass', 'services: none failed');
is((severity_of([evaluate_services([{ unit => 'sshd.service', state => 'failed' }])],
'services.failed_units'))[0], 'warn', 'services: a failed unit is a warning');
is((severity_of([evaluate_services(undef)], 'services.failed_units'))[0], 'unknown',
'services: no systemctl is unknown');
# The columns of `systemctl --failed --no-legend --plain` are unit, load,
# active, sub, description: the report names the active state.
my $failed_units = parse_failed_units(
"sshd.service loaded failed failed OpenSSH server daemon\n" .
"dnf5-automatic.service not-found failed failed (bad unit)\n");
is(scalar @$failed_units, 2, 'services: two failed lines parse');
is($failed_units->[0]{state}, 'failed', 'services: the state is the active column, not the load');
is($failed_units->[1]{unit}, 'dnf5-automatic.service', 'services: a not-found load state still parses');
my %sysctl_ok = (
'/proc/sys/kernel/kptr_restrict' => '1',
'/proc/sys/kernel/dmesg_restrict' => '1',
'/proc/sys/kernel/unprivileged_bpf_disabled' => '2',
'/proc/sys/kernel/yama/ptrace_scope' => '1',
'/proc/sys/fs/protected_symlinks' => '1',
'/proc/sys/fs/protected_hardlinks' => '1',
'/proc/sys/fs/protected_fifos' => '1',
'/proc/sys/fs/protected_regular' => '2',
'/proc/sys/net/ipv4/tcp_syncookies' => '1',
'/proc/sys/kernel/randomize_va_space' => '2',
);
@checks = evaluate_kernel({ %sysctl_ok });
my $pass_count = grep { $_->{severity} eq 'pass' } @checks;
is($pass_count, 7, 'kernel: a hardened machine passes every check');
my %sysctl_weak = (%sysctl_ok,
'/proc/sys/kernel/kptr_restrict' => '0',
'/proc/sys/kernel/randomize_va_space' => '1',
);
delete $sysctl_weak{'/proc/sys/kernel/yama/ptrace_scope'};
@checks = evaluate_kernel({ %sysctl_weak });
is((severity_of(\@checks, 'kernel.kptr_restrict'))[0], 'warn', 'kernel: a visible kptr is a warning');
is((severity_of(\@checks, 'kernel.randomize_va_space'))[0], 'warn', 'kernel: reduced ASLR is a warning');
is((severity_of(\@checks, 'kernel.yama_ptrace_scope'))[0], 'unknown', 'kernel: an absent sysctl is unknown');
# ---------------------------------------------------------------------------
# The deep scan evaluator
# ---------------------------------------------------------------------------
@checks = evaluate_files({
suid => ['/usr/bin/sudo', '/tmp/backdoor'],
world_writable_files => ['/var/tmp/junk'],
world_writable_dirs => [],
unowned => [],
});
is((severity_of(\@checks, 'files.suid_staging'))[0], 'critical',
'files: a SUID binary under /tmp is critical');
like((severity_of(\@checks, 'files.world_writable'))[1], qr/1 world writable file/,
'files: the world writable count');
@checks = evaluate_files({
suid => ['/usr/bin/sudo'],
world_writable_files => [], world_writable_dirs => [], unowned => [],
});
is((severity_of(\@checks, 'files.suid_staging'))[0], 'pass', 'files: a clean staging check');
is((severity_of(\@checks, 'files.unowned'))[0], 'pass', 'files: nothing unowned');
@checks = evaluate_files({
suid => [], world_writable_files => [], world_writable_dirs => ['/tmp'], unowned => ['x'],
});
is((severity_of(\@checks, 'files.world_writable'))[0], 'warn',
'files: a stickyless world writable directory is a warning');
is((severity_of(\@checks, 'files.unowned'))[0], 'warn', 'files: an unowned file is a warning');
# A scan that did not complete is unknown, never a clean pass: an interrupted
# find leaves an undef slot behind.
@checks = evaluate_files({
suid => undef, world_writable_files => [], world_writable_dirs => [], unowned => [],
});
is((severity_of(\@checks, 'files.suid_staging'))[0], 'unknown',
'files: an interrupted SUID scan is unknown');
@checks = evaluate_files({
suid => [], world_writable_files => undef, world_writable_dirs => [], unowned => [],
});
is((severity_of(\@checks, 'files.world_writable'))[0], 'unknown',
'files: an interrupted world writable scan is unknown');
@checks = evaluate_files({
suid => [], world_writable_files => [], world_writable_dirs => [], unowned => undef,
});
is((severity_of(\@checks, 'files.unowned'))[0], 'unknown',
'files: an interrupted unowned scan is unknown');
# ---------------------------------------------------------------------------
# Grade and findings
# ---------------------------------------------------------------------------
my $grade = compute_grade({});
is($grade->{grade}, 'N/A', 'grade: nothing assessable is not graded');
is($grade->{score}, undef, 'grade: no score');
my %by_id;
$by_id{"s.$_"} = {
id => "s.$_",
section => 's',
severity => $_ eq 'a' ? 'pass' : $_ eq 'b' ? 'warn' : $_ eq 'c' ? 'critical' : 'unknown',
weight => $_ eq 'd' ? 0 : 8,
} for qw(a b c d e);
$grade = compute_grade({ %by_id });
# a: 8, b: 4, c: 0, d: weight 0 so not counted, e: unknown so out of the pool.
is($grade->{maxima}{d}, undef, 'grade: a weightless check is not in the maxima');
is($grade->{score}, 50, 'grade: 12 of the 24 remaining points');
is($grade->{grade}, 'F', 'grade: the letter follows the thresholds');
is($grade->{sections}{s}{possible}, 24, 'grade: the unknown check leaves the pool');
is($grade->{skipped}{'s.e'}, 1, 'grade: the unknown check is named as skipped');
%by_id = map {
("sec.$_" => { id => "sec.$_", section => 'sec', severity => 'pass', weight => 10 });
} qw(one two);
$grade = compute_grade({ %by_id });
is($grade->{score}, 100, 'grade: full marks');
my $findings = findings_of(\%by_id);
is(scalar @$findings, 0, 'findings: a clean set is empty');
%by_id = (
'a.w' => { id => 'a.w', section => 'a', severity => 'warn', weight => 4, message => 'w' },
'a.c' => { id => 'a.c', section => 'a', severity => 'critical', weight => 4, message => 'c' },
'b.w' => { id => 'b.w', section => 'b', severity => 'warn', weight => 4, message => 'w2' },
'a.p' => { id => 'a.p', section => 'a', severity => 'pass', weight => 4, message => 'p' },
);
$findings = findings_of(\%by_id);
is(join(',', map { $_->{id} } @$findings), 'a.c,a.w,b.w',
'findings: criticals first, then warnings by id, passes left out');
# ---------------------------------------------------------------------------
# JSON
# ---------------------------------------------------------------------------
like(json_encode({ root => 1, deep => 0 }), qr/"root": true/, 'json: a listed boolean path');
like(json_encode({ sections => { firewall => { listeners => [ { loopback => 1, exposed => 0 } ] } } }),
qr/"loopback": true/, 'json: a listener boolean by shape');
like(json_encode({ sections => { ssh => { available => 1 }, selinux => { available => 0 } } }),
qr/"available": true/, 'json: the section available flags are booleans');
like(json_encode({ sections => { firewall => { listeners => [ { allowed => 1 } ] } } }),
qr/"allowed": true/, 'json: a listener allowed flag is a boolean');
like(json_encode({ a => 'text', b => 3 }), qr/"b": 3/, 'json: a number stays a number');
like(json_encode({ s => "quote\"end\ning" }), qr/"quote\\"end\\ning"/, 'json: the escapes');
like(json_encode({}), qr/\{\}/, 'json: an empty object');
like(json_encode([]), qr/\[\]/, 'json: an empty array');
# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
my @saved = @ARGV;
@ARGV = ('--json', '--strict', '--deep', '--section', 'ssh,firewall');
my %args = parse_args();
is($args{json}, 1, 'args: --json');
is($args{strict}, 1, 'args: --strict');
is($args{deep}, 1, 'args: --deep');
is($args{section}, 'ssh,firewall', 'args: --section takes a list');
@ARGV = ('--section=ssh');
%args = parse_args();
is($args{section}, 'ssh', 'args: --section=VALUE');
@ARGV = ();
%args = parse_args();
is($args{deep}, 0, 'args: deep is off by default');
@ARGV = @saved;
# ---------------------------------------------------------------------------
# Section selection and the exit status of the program
# ---------------------------------------------------------------------------
my ($resolved, $section_error) = resolve_sections('ssh,firewall', 0);
is(join(',', @$resolved), 'ssh,firewall', 'sections: a plain list resolves');
is($section_error, undef, 'sections: no error for a plain list');
($resolved, $section_error) = resolve_sections('ssh,ssh', 0);
is(join(',', @$resolved), 'ssh', 'sections: a repeated name runs once');
($resolved, $section_error) = resolve_sections('ssh', 1);
is(join(',', @$resolved), 'ssh,files', 'sections: --deep adds the file scan');
($resolved, $section_error) = resolve_sections('', 0);
is(scalar @$resolved, 7, 'sections: the default run is every section but files');
($resolved, $section_error) = resolve_sections('nope', 0);
is($resolved, undef, 'sections: an unknown name yields no list');
like($section_error, qr/unknown section/, 'sections: an unknown name is reported');
($resolved, $section_error) = resolve_sections('files', 0);
like($section_error, qr/requires --deep/, 'sections: files without --deep is refused');
# The validation runs before any collection, and a usage error exits 2 as the
# usage text promises, not 1.
my $exit_code = system($^X, "$root/security-audit.pl", '--section', 'nope') >> 8;
is($exit_code, 2, 'cli: an unknown section is a usage error, exit 2');
$exit_code = system($^X, "$root/security-audit.pl", '--section', 'files') >> 8;
is($exit_code, 2, 'cli: files without --deep is a usage error, exit 2');
# ---------------------------------------------------------------------------
unlink("$tmp_dir/sshd_config", "$tmp_dir/50-audit.conf");
rmdir($tmp_dir);
print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all $passed checks passed") . "\n";
exit($failed ? 1 : 0);
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Checks for server-setup.pl: the dnf-automatic configuration renderer, the os-release
# reader, the error text, the argument parsing, the command runner's wait-status
# conversion and the EPEL enable path.
#
# Run from anywhere: perl tests/server-setup.pl
use strict;
use warnings;
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
$root = "./$root" if $root !~ m{^/};
require "$root/server-setup.pl";
my ($passed, $failed) = (0, 0);
sub is {
my ($got, $want, $label) = @_;
$got = 'undef' unless defined $got;
$want = 'undef' unless defined $want;
if ($got eq $want) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: $got\n want: $want\n";
return 0;
}
sub check {
my ($cond, $label) = @_;
return is($cond ? 1 : 0, 1, $label);
}
sub like {
my ($got, $re, $label) = @_;
my $matched = (defined $got && $got =~ $re) ? 1 : 0;
if ($matched) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: " . (defined $got ? $got : 'undef') . "\n want: $re\n";
return 0;
}
# ---------------------------------------------------------------------------
# dnf-automatic configuration
# ---------------------------------------------------------------------------
# The renderer returns the lines and whether it changed anything, which is how its
# caller decides whether to write.
my @order = qw(upgrade_type apply_updates);
my ($rendered, $changed) = render_dnf_automatic_conf(
["[commands]\n", "# a comment\n", "upgrade_type = security\n"],
\@order,
{ upgrade_type => 'default', apply_updates => 'yes' },
);
my $text = join('', @$rendered);
is($changed, 1, 'dnf-automatic: a change is reported');
like($text, qr/^\[commands\]$/m, 'dnf-automatic: the section survives');
like($text, qr/^upgrade_type = default$/m, 'dnf-automatic: an existing key is updated');
like($text, qr/^apply_updates = yes$/m, 'dnf-automatic: a missing key is added');
like($text, qr/^# a comment$/m, 'dnf-automatic: a comment survives');
is(($text =~ /upgrade_type/g) + 0, 1, 'dnf-automatic: the key is written once');
my ($again, $unchanged) = render_dnf_automatic_conf($rendered, \@order,
{ upgrade_type => 'default', apply_updates => 'yes' });
is($unchanged, 0, 'dnf-automatic: an already correct file is reported unchanged');
is(join('', @$again), $text, 'dnf-automatic: an already correct file is reproduced exactly');
my ($fresh, $fresh_changed) = render_dnf_automatic_conf([], \@order, { upgrade_type => 'default' });
check(join('', @$fresh) !~ /^apply_updates = *$/m,
'dnf-automatic: a key with no desired value is not written empty');
my $fresh_text = join('', @$fresh);
is($fresh_changed, 1, 'dnf-automatic: a missing section is a change');
like($fresh_text, qr/^\[commands\]$/m, 'dnf-automatic: a missing section is created');
like($fresh_text, qr/^upgrade_type = default$/m, 'dnf-automatic: the key lands in it');
my ($unterminated, $unterminated_changed) = render_dnf_automatic_conf(
["[commands]\n", 'upgrade_type = security'],
\@order,
{ apply_updates => 'yes' },
);
my $unterminated_text = join('', @$unterminated);
is($unterminated_changed, 1, 'dnf-automatic: an appended key is a change');
like($unterminated_text, qr/^apply_updates = yes$/m,
'dnf-automatic: an unterminated last line does not swallow the new key');
check($unterminated_text !~ /securityapply_updates/,
'dnf-automatic: no key is glued onto another');
# ---------------------------------------------------------------------------
# os-release and error text
# ---------------------------------------------------------------------------
my $release = parse_os_release();
is(ref($release), 'HASH', 'os-release: a hash reference');
check(defined $release->{ID} && length $release->{ID}, 'os-release: the identifier is read');
check(defined $release->{VERSION_ID}, 'os-release: the version is read');
my $handle;
open($handle, '<', '/nonexistent/for-the-test');
my $error = os_error_text('/nonexistent/for-the-test');
like($error, qr/^\[Errno \d+\] .+: '\/nonexistent\/for-the-test'$/,
'error text: the errno, the reason and the path');
# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
my @saved = @ARGV;
@ARGV = ('--dry-run', '--skip-firewall');
my %args = parse_args();
is($args{dry_run}, 1, 'args: --dry-run');
is($args{skip_firewall}, 1, 'args: --skip-firewall');
is($args{skip_packages}, 0, 'args: an unset skip stays off');
@ARGV = ();
my %defaults = parse_args();
is($defaults{dry_run}, 0, 'args: dry run is off by default');
@ARGV = @saved;
# ---------------------------------------------------------------------------
# The command runner
# ---------------------------------------------------------------------------
is(wait_status_rc(0), 0, 'runner: a clean exit is rc 0');
is(wait_status_rc(2 << 8), 2, 'runner: the exit status is taken from bits 8-15');
is(wait_status_rc(9), 137, 'runner: a child killed by SIGKILL is not read as rc 0');
is(wait_status_rc(11), 139, 'runner: a child killed by a signal is never rc 0');
my $killed = run(['sh', '-c', 'kill -9 $$']);
is($killed->{rc}, 137, 'runner: run reports 128+signal for a killed child');
my $exited = run(['sh', '-c', 'exit 3']);
is($exited->{rc}, 3, 'runner: run reports the exit code of a child');
# ---------------------------------------------------------------------------
# EPEL: a present package with a disabled repository must be enabled, and a
# silent no-op must not be reported as success
# ---------------------------------------------------------------------------
{
no warnings 'redefine';
my ($epel_enabled, $enable_works, @seen);
my $fake_run = sub {
my ($cmd) = @_;
my $line = join ' ', @$cmd;
push @seen, $line;
if ($line eq 'dnf repolist --enabled') {
my $out = "repo id\trepo name\n";
$out .= "epel\tExtra Packages for Enterprise Linux\n" if $epel_enabled;
return { rc => 0, out => $out, err => '' };
}
return { rc => 0, out => "epel-release-10-0.noarch\n", err => '' }
if $line eq 'rpm -q epel-release';
return { rc => 0, out => '', err => '' }
if $line eq 'dnf config-manager --set-enabled crb';
if ($line eq 'dnf config-manager --set-enabled epel') {
if ($enable_works) {
$epel_enabled = 1;
return { rc => 0, out => '', err => '' };
}
return { rc => 1, out => '', err => 'failed' };
}
# Installing an already-present package is a successful no-op that
# leaves the repository switched off, which made the old path a false
# success.
return { rc => 0, out => '', err => '' }
if $line eq 'dnf install -y epel-release';
return { rc => 1, out => '', err => "unexpected command: $line" };
};
local *run = $fake_run;
($epel_enabled, $enable_works, @seen) = (0, 1);
my $ok_info = ensure_epel(0);
check(!$ok_info->{failed} && $epel_enabled,
'EPEL: a disabled repository is enabled when config-manager works');
check($ok_info->{installed}, 'EPEL: success is reported once the repository is enabled');
check((grep { /install / } @seen) == 0,
'EPEL: an installed package is enabled, not installed again');
($epel_enabled, $enable_works, @seen) = (0, 0);
my $fail_info = ensure_epel(0);
check($fail_info->{failed} && !$epel_enabled,
'EPEL: a repository that stays disabled is a failure, not a success');
($epel_enabled, $enable_works, @seen) = (0, 1);
ensure_epel(1);
check((grep { /set-enabled|install / } @seen) == 0,
'EPEL: a dry run issues no mutating command');
}
print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all $passed checks passed") . "\n";
exit($failed ? 1 : 0);
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/perl
# Unit checks for sglang-deploy.pl: the pure helpers, against what they must produce.
use strict;
use warnings;
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
$root = "./$root" if $root !~ m{^/};
require "$root/sglang-deploy.pl";
my ($passed, $failed) = (0, 0);
sub is {
my ($got, $want, $label) = @_;
$got = 'undef' unless defined $got;
$want = 'undef' unless defined $want;
if ($got eq $want) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: $got\n want: $want\n";
return 0;
}
sub check {
my ($cond, $label) = @_;
return is($cond ? 1 : 0, 1, $label);
}
# The verdict of a match is forced into a boolean: in a sub argument list a failed match
# yields the empty list, which shifts the label into the condition slot and turns the
# failure into a silent pass.
sub like {
my ($got, $re, $label) = @_;
$label = 'like without a label' unless defined $label;
my $matched = (defined $got && $got =~ $re) ? 1 : 0;
return is($matched, 1, $label);
}
sub check_unlike {
my ($got, $re, $label) = @_;
$label = 'check_unlike without a label' unless defined $label;
my $matched = (defined $got && $got !~ $re) ? 1 : 0;
return is($matched, 1, $label);
}
# ---- version_cmp ----------------------------------------------------------
is(version_cmp('7.2.4', '7.2.0'), 1, 'version_cmp 7.2.4 > 7.2.0');
is(version_cmp('7.2.0', '7.2.4'), -1, 'version_cmp 7.2.0 < 7.2.4');
is(version_cmp('7.2', '7.2.0'), 0, 'version_cmp 7.2 == 7.2.0');
is(version_cmp('10.0.0', '7.2.4'), 1, 'version_cmp 10.0.0 > 7.2.4');
is(version_cmp('6.4.3', '7.0.0'), -1, 'version_cmp 6.4.3 < 7.0.0');
is(version_cmp('7.10.0', '7.9.0'), 1, 'version_cmp 7.10.0 > 7.9.0 (numeric, not lexical)');
# ---- select_rocm_flavour --------------------------------------------------
is(select_rocm_flavour('7.2.4'), 'rocm724', 'flavour: host 7.2.4');
is(select_rocm_flavour('7.2.5'), 'rocm724', 'flavour: host 7.2.5');
is(select_rocm_flavour('7.2.3'), 'rocm720', 'flavour: host 7.2.3');
is(select_rocm_flavour('7.2.0'), 'rocm720', 'flavour: host 7.2.0');
is(select_rocm_flavour('7.1.0'), 'rocm700', 'flavour: host 7.1.0 falls back to rocm700');
is(select_rocm_flavour('7.0.0'), 'rocm700', 'flavour: host 7.0.0');
is(select_rocm_flavour('10.0.0'), 'rocm10', 'flavour: host 10.0.0');
is(select_rocm_flavour('10.2.0'), 'rocm10', 'flavour: host 10.2.0');
is(select_rocm_flavour('6.4.0'), undef, 'flavour: host 6.4.0 has no image');
# ---- arch_variant / radeon_present ---------------------------------------
my @mi300 = ('Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI300X OAM [1002:74a1] (rev 01)');
my @mi325 = ('Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI325X [1002:74a1]');
my @mi350 = ('Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI350X [1002:75a0]');
my @mi355 = ('Advanced Micro Devices, Inc. [AMD/ATI] Instinct MI355X [1002:75a3]');
my @radeon = ('Advanced Micro Devices, Inc. [AMD/ATI] Navi 31 [Radeon RX 7900 XTX] [1002:744c]');
my @strix = ('Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon 8060S] [1002:150e]');
is(arch_variant(\@mi300), 'mi30x', 'arch: MI300X -> mi30x');
is(arch_variant(\@mi325), 'mi30x', 'arch: MI325X -> mi30x');
is(arch_variant(\@mi350), 'mi35x', 'arch: MI350X -> mi35x');
is(arch_variant(\@mi355), 'mi35x', 'arch: MI355X -> mi35x');
is(arch_variant(\@radeon), undef, 'arch: Radeon has no published image');
is(arch_variant(\@strix), 'gfx1151', 'arch: Strix Halo maps to gfx1151');
is(radeon_present(\@radeon), 1, 'radeon: Navi 31 detected');
is(radeon_present(\@strix), 0, 'radeon: Strix Halo is served by AMD builds');
is(radeon_present(\@mi300), 0, 'radeon: MI300X is not a Radeon card');
# ---- resolve_image / model_display ---------------------------------------
is(resolve_image('docker.io/lmsysorg/sglang', 'v0.5.19', 'mi30x', 'rocm10'),
'docker.io/lmsysorg/sglang:v0.5.19-rocm10-mi30x', 'image: resolved tag');
is(resolve_image('docker.io/rocm/sgl-dev', 'v0.5.19', 'gfx1151', 'rocm724'),
'docker.io/rocm/sgl-dev:v0.5.19-rocm724-gfx1151', 'image: AMD dev repository for gfx1151');
is(model_display('ZhipuAI/GLM-5.3'), 'GLM 5.3', 'display: known model');
is(model_display('acme/unknown'), 'acme/unknown', 'display: unknown model passes through');
# ---- the preset catalogue ------------------------------------------------
# The menu is the owner's list in the owner's order, so the names are asserted
# one by one, and each display name must resolve to the exact ModelScope
# repository the engine will pull weights from.
is(join('|', default_model_order()),
'GLM 5.3|GLM 5.3 Flash|Qwen 3.8 Max|Qwen 3.8 Flash|DeepSeek V4.1 Flash',
'presets: the menu order');
is(model_display('ZhipuAI/GLM-5.3'), 'GLM 5.3', 'presets: GLM 5.3');
is(default_model_repo('GLM 5.3'), 'ZhipuAI/GLM-5.3', 'presets: GLM 5.3 on ModelScope');
is(default_model_repo('GLM 5.3 Flash'), 'ZhipuAI/GLM-5.3-Flash',
'presets: GLM 5.3 Flash on ModelScope, under ZhipuAI');
is(default_model_repo('Qwen 3.8 Max'), 'Qwen/Qwen3.8-2.4T-A95B',
'presets: Qwen 3.8 Max under its weight name');
is(default_model_repo('Qwen 3.8 Flash'), 'Qwen/Qwen3.8-Flash-Next',
'presets: Qwen 3.8 Flash under its weight name');
is(default_model_repo('DeepSeek V4.1 Flash'), 'deepseek-ai/DeepSeek-V4.1-Flash',
'presets: DeepSeek V4.1 Flash');
is(model_display('Qwen/Qwen3.8-2.4T-A95B'), 'Qwen 3.8 Max',
'display: the reverse lookup through a weight name');
for my $id (map { default_model_repo($_) } default_model_order()) {
like($id, qr{^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$}, "presets: $id fits the model ID pattern");
}
# ---- base64url and key shape ---------------------------------------------
is(encode_base64url('Man'), 'TWFu', 'base64url: Man -> TWFu');
is(encode_base64url("\xfb\xff"), '-_8', 'base64url: FB FF -> -_8 (url alphabet)');
is(encode_base64url('M'), 'TQ', 'base64url: one byte, no padding');
my $key = generate_api_key();
like($key, qr/^[A-Za-z0-9_-]{43}$/, 'key: 32 bytes -> 43 url-safe characters');
my $key2 = generate_api_key();
is($key eq $key2 ? 'same' : 'different', 'different', 'key: two calls differ');
# ---- is_number ------------------------------------------------------------
is((is_number('8000') ? 1 : 0), 1, 'is_number: 8000');
is((is_number('0.90') ? 1 : 0), 1, 'is_number: 0.90');
is((is_number('abc') ? 1 : 0), 0, 'is_number: abc');
is((is_number('') ? 1 : 0), 0, 'is_number: empty');
# ---- is_positive_int / valid_dir_path -------------------------------------
is((is_positive_int('4') ? 1 : 0), 1, 'is_positive_int: 4');
is((is_positive_int('0') ? 1 : 0), 0, 'is_positive_int: 0 is not positive');
is((is_positive_int('8000.5') ? 1 : 0), 0, 'is_positive_int: a fractional port is refused');
is((is_positive_int('1e2') ? 1 : 0), 0, 'is_positive_int: an exponent is refused');
is((valid_dir_path('/opt/sglang') ? 1 : 0), 1, 'valid_dir_path: an absolute path passes');
is((valid_dir_path('sglang') ? 1 : 0), 0, 'valid_dir_path: a relative path is refused');
is((valid_dir_path('/opt/sg lang') ? 1 : 0), 0, 'valid_dir_path: whitespace is refused');
is((valid_dir_path('/opt/%h/sglang') ? 1 : 0), 0,
'valid_dir_path: a systemd specifier is refused');
is((valid_dir_path('/opt/x;/sglang') ? 1 : 0), 0,
'valid_dir_path: an nginx directive end is refused');
# ---- run(): exit status, signals and timeout ------------------------------
my $killed = run([$^X, '-e', 'kill 9, $$']);
is($killed->{rc}, 137, 'run: a child killed by a signal reports 128+signal, not 0');
my $exited = run([$^X, '-e', 'exit 3']);
is($exited->{rc}, 3, 'run: the child exit code passes through');
my $timedout = run([$^X, '-e', 'sleep 5'], timeout => 1);
is($timedout->{rc}, 124, 'run: an expired timeout reports rc 124');
# ---- family_is_fatal: --image overrides the GPU family --------------------
is(family_is_fatal(undef, 0), 1, 'family: an unmatched card without --image is fatal');
is(family_is_fatal(undef, 1), 0, 'family: --image overrides an unmatched card');
is(family_is_fatal('mi30x', 0), 0, 'family: a published family is never fatal');
# ---- radeon_env_for: the family decides, not any Radeon -------------------
my @mixed = ('Instinct MI300X [1002:74a1]', 'Navi 31 [Radeon RX 7900 XTX] [1002:744c]');
is(arch_variant(\@mixed), 'mi30x', 'env: a mixed host resolves the Instinct family');
is(radeon_present(\@mixed), 1, 'env: a mixed host does carry a Radeon');
is(scalar @{ radeon_env_for(arch_variant(\@mixed), radeon_present(\@mixed)) }, 0,
'env: a display Radeon does not switch an Instinct deployment to the Radeon defaults');
is(scalar @{ radeon_env_for('gfx1151', 0) }, 2, 'env: gfx1151 carries the Radeon defaults');
is(scalar @{ radeon_env_for(undef, 1) }, 2,
'env: a custom image on a Radeon-only host carries the Radeon defaults');
is(scalar @{ radeon_env_for('mi30x', 0) }, 0, 'env: an Instinct host carries none');
# ---- nginx config --------------------------------------------------------
my $conf = nginx_conf_content(8000, '[::1]', '/etc/ssl/sglang');
like($conf, qr/listen \[::\]:443 ssl;/, 'nginx: IPv6 listener on a dual-stack kernel');
like($conf, qr/listen 443 ssl;/, 'nginx: IPv4 listener');
like($conf, qr|ssl_certificate /etc/ssl/sglang/sglang\.crt;|, 'nginx: certificate path');
like($conf, qr/ssl_certificate_key \/etc\/ssl\/sglang\/sglang\.key;/, 'nginx: key path');
like($conf, qr|proxy_pass http://\[::1\]:8000;|, 'nginx: loopback upstream with the port');
like($conf, qr/proxy_http_version 1\.1;/, 'nginx: HTTP/1.1 for streaming');
like($conf, qr/proxy_buffering off;/, 'nginx: buffering off for streaming');
like($conf, qr/proxy_set_header Host \$host;/, 'nginx: $host survives the heredoc');
like($conf, qr/proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;/,
'nginx: $proxy_add_x_forwarded_for survives the heredoc');
my $conf4 = nginx_conf_content(8000, '127.0.0.1', '/etc/ssl/sglang');
is($conf4 =~ /\[::\]/ ? 'yes' : 'no',
(-e '/proc/net/if_inet6' ? 'yes' : 'no'), 'nginx: IPv6 listener follows the kernel');
# ---- systemd unit --------------------------------------------------------
my $unit = systemd_content({
model => 'ZhipuAI/GLM-5.3',
host => '::1',
port => 8000,
tensor_parallel => 4,
max_model_len => 32768,
gpu_memory_utilization => '0.9',
state_dir => '/opt/sglang',
image => 'docker.io/lmsysorg/sglang:v0.5.19-rocm10-mi30x',
env_file => '/etc/sysconfig/sglang',
radeon_env => [],
});
like($unit, qr/Description=SGLang Inference Server \(ZhipuAI\/GLM-5\.3\)/, 'unit: description');
like($unit, qr/EnvironmentFile=\/etc\/sysconfig\/sglang/, 'unit: EnvironmentFile');
like($unit, qr/Environment=SGLANG_USE_MODELSCOPE=true/, 'unit: weights come from ModelScope');
like($unit, qr/--network=host/, 'unit: host networking');
like($unit, qr/--device=\/dev\/kfd --device=\/dev\/dri/, 'unit: GPU device nodes');
like($unit, qr/--group-add video/, 'unit: video group');
like($unit, qr/--ipc=host/, 'unit: IPC namespace');
like($unit, qr/--cap-add=SYS_PTRACE/, 'unit: ptrace capability');
like($unit, qr/--security-opt seccomp=unconfined/, 'unit: seccomp');
like($unit, qr|--volume /opt/sglang/modelscope:/root/\.cache/modelscope:Z|,
'unit: ModelScope cache volume with SELinux relabel');
like($unit, qr/--env MODELSCOPE_TOKEN/, 'unit: ModelScope token forwarded when set');
like($unit, qr/--rm --replace --name sglang/, 'unit: container name and cleanup');
like($unit, qr/--pull=missing/, 'unit: pull policy');
like($unit, qr/docker\.io\/lmsysorg\/sglang:v0\.5\.19-rocm10-mi30x/, 'unit: image');
like($unit, qr/python3 -m sglang\.launch_server/, 'unit: engine entry point');
like($unit, qr/--model-path ZhipuAI\/GLM-5\.3/, 'unit: model path');
like($unit, qr/--host ::1/, 'unit: loopback bind');
like($unit, qr/--port 8000/, 'unit: port');
like($unit, qr/--api-key \$\{SGLANG_API_KEY\}/, 'unit: API key from the environment file');
is($unit =~ /sk-[A-Za-z0-9]/ ? 'yes' : 'no', 'no', 'unit: no literal API key');
like($unit, qr/--tp-size 4/, 'unit: tensor parallel as --tp-size');
like($unit, qr/--context-length 32768/, 'unit: context length');
like($unit, qr/--mem-fraction-static 0\.9/, 'unit: static memory fraction');
is($unit =~ /SGLANG_USE_AITER/ ? 'yes' : 'no', 'no',
'unit: no Radeon variables on a non-Radeon host');
like($unit, qr/WantedBy=multi-user\.target/, 'unit: install section');
like($unit, qr/Restart=on-failure/, 'unit: restart policy');
my $unit_radeon = systemd_content({
model => 'ZhipuAI/GLM-5.3',
host => '::1',
port => 8000,
tensor_parallel => 1,
max_model_len => 4096,
gpu_memory_utilization => '0.9',
state_dir => '/opt/sglang',
image => 'local/sglang-rocm:latest',
env_file => '/etc/sysconfig/sglang',
radeon_env => ['SGLANG_USE_AITER=false', 'SGLANG_ROCM_FUSED_DECODE_MLA=false'],
});
like($unit_radeon, qr/Environment=SGLANG_USE_AITER=false/, 'unit: AITER off on Radeon');
like($unit_radeon, qr/Environment=SGLANG_ROCM_FUSED_DECODE_MLA=false/,
'unit: fused decode MLA off on Radeon');
# ---- parse_os_release reads the host (smoke test) ------------------------
my %release = parse_os_release();
is(exists $release{ID} ? 'present' : 'absent', 'present', 'os-release: ID is parsed');
# ---- arguments -----------------------------------------------------------
my @ARGV_COPY = @ARGV;
local @ARGV = ('--model', 'ZhipuAI/GLM-5.3', '--port=9000', '--tensor-parallel', '2');
my %args = parse_args();
is($args{model}, 'ZhipuAI/GLM-5.3', 'args: --model value');
is($args{port}, '9000', 'args: --port=9000 inline form');
is($args{tensor_parallel}, '2', 'args: --tensor-parallel value');
is($args{service_name}, 'sglang', 'args: service name default');
is($args{state_dir}, '/opt/sglang', 'args: state dir default');
is($args{cert_dir}, '/etc/ssl/sglang', 'args: cert dir default');
@ARGV = ('--dry-run', '--uninstall', '--rocm-flavour', 'rocm720');
my %args2 = parse_args();
is($args2{dry_run}, 1, 'args: --dry-run');
is($args2{uninstall}, 1, 'args: --uninstall');
is($args2{rocm_flavour}, 'rocm720', 'args: --rocm-flavour');
@ARGV = @ARGV_COPY;
print "\n" . ($failed
? "$failed FAILED of " . ($passed + $failed)
: "all $passed checks passed") . "\n";
exit($failed ? 1 : 0);
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Checks for system-diag.pl: the JSON encoder, the calendar and formatting helpers,
# the address validation and the section result reader.
#
# Run from anywhere: perl tests/system-diag.pl
use strict;
use warnings;
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
$root = "./$root" if $root !~ m{^/};
require "$root/system-diag.pl";
my ($passed, $failed) = (0, 0);
sub is {
my ($got, $want, $label) = @_;
$got = 'undef' unless defined $got;
$want = 'undef' unless defined $want;
if ($got eq $want) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: $got\n want: $want\n";
return 0;
}
sub check {
my ($cond, $label) = @_;
return is($cond ? 1 : 0, 1, $label);
}
sub like {
my ($got, $re, $label) = @_;
my $matched = (defined $got && $got =~ $re) ? 1 : 0;
if ($matched) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: " . (defined $got ? $got : 'undef') . "\n want: $re\n";
return 0;
}
# ---------------------------------------------------------------------------
# JSON
# ---------------------------------------------------------------------------
is(json_quote('plain'), '"plain"', 'json_quote: a plain word');
is(json_quote("a\"b"), '"a\\"b"', 'json_quote: a double quote is escaped');
is(json_quote("a\nb"), '"a\\nb"', 'json_quote: a newline is escaped');
is(json_quote("a\tb"), '"a\\tb"', 'json_quote: a tab is escaped');
is(json_quote('back\\slash'), '"back\\\\slash"', 'json_quote: a backslash is escaped');
my $encoded = json_encode({ a => 1, b => 'x', c => [1, 2], d => undef, e => 1.5 });
like($encoded, qr/"a": 1/, 'json_encode: an integer');
like($encoded, qr/"b": "x"/, 'json_encode: a string');
like($encoded, qr/"d": null/, 'json_encode: an undefined value');
like($encoded, qr/"e": 1\.5/, 'json_encode: a float');
like($encoded, qr/"c": \[\s*1,\s*2\s*\]/s, 'json_encode: an array');
like($encoded, qr/\A\{\n/, 'json_encode: an object opens on its own line');
like($encoded, qr/\}\n\z/, 'json_encode: the document ends with a newline');
like(json_encode([1, 2, 3]), qr/\A\[\n/, 'json_encode: a top-level array');
# ---------------------------------------------------------------------------
# Calendar and time
# ---------------------------------------------------------------------------
is(_days_from_civil(1970, 1, 1), 0, 'calendar: the epoch is day zero');
is(_days_from_civil(1970, 1, 2), 1, 'calendar: the next day');
is(_days_from_civil(1969, 12, 31), -1, 'calendar: the day before the epoch');
is(_days_from_civil(2000, 3, 1), 11017, 'calendar: the day after the leap day of 2000');
# Cross-checked with the system's own calendar: timegm for that date over 86400.
is(_days_from_civil(2026, 9, 17), 20713, 'calendar: a current date');
local $ENV{TZ} = 'UTC';
is(timestamp_fields(0), '1970-01-01T00:00:00+0000', 'timestamp: the epoch in UTC');
is(timestamp_fields(86400), '1970-01-02T00:00:00+0000', 'timestamp: one day on');
like(timestamp_fields(1787961600), qr/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}\z/,
'timestamp: the shape of a timestamp');
like(utc_offset(time()), qr/\A[+-]\d{4}\z/, 'timestamp: the offset has a sign and four digits');
like(uptime_human(90061), qr/1/, 'uptime: a day is reported');
like(uptime_human(0), qr/\d/, 'uptime: zero still reads as a duration');
# ---------------------------------------------------------------------------
# Bars and percentages
# ---------------------------------------------------------------------------
# The bar carries colour codes around its cells and the percentage after them, so the
# assertions strip the codes first.
sub bar_plain {
my ($text) = @_;
$text =~ s/\e\[[0-9;]*m//g;
return $text;
}
is(bar_plain(_bar(50, 100, 10)), "\xe2\x96\x88" x 5 . "\xe2\x96\x91" x 5 . ' 50%',
'bar: half of ten cells');
is(bar_plain(_bar(100, 100, 10)), "\xe2\x96\x88" x 10 . ' 100%', 'bar: a full bar');
is(bar_plain(_bar(0, 100, 10)), "\xe2\x96\x91" x 10 . ' 0%', 'bar: an empty bar');
is(bar_plain(_bar(0, 0, 10)), "\xe2\x96\x91" x 10 . ' 0%', 'bar: a zero maximum does not divide');
# Colour is off when stdout is not a terminal, which is how the tests run, so the bar
# is asserted plain here.
# Deltas: 1000 jiffies in total, 800 of them idle and iowait, so 200 are busy.
is(usage_percent([100, 0, 100, 800, 100], [200, 0, 200, 1600, 100]), '20.0',
'usage: a fifth of the delta is busy');
is(usage_percent([0, 0, 0, 100, 0], [0, 0, 0, 200, 0]), '0.0', 'usage: an idle machine');
is(usage_percent([0, 0, 0], [1, 1, 1]), '0.0', 'usage: a short sample is zero');
# ---------------------------------------------------------------------------
# Addresses and file systems
# ---------------------------------------------------------------------------
is(_valid_ipv6('::1'), 1, 'ipv6: loopback');
is(_valid_ipv6('2001:db8::1'), 1, 'ipv6: a documentation prefix');
is(_valid_ipv6('127.0.0.1'), 0, 'ipv6: an IPv4 address is not IPv6');
is(_valid_ipv6('2001:db8::1::2'), 0, 'ipv6: two shorthands are invalid');
is(_valid_ipv6('fe80::1%eth0'), 1, 'ipv6: a scope suffix is accepted');
is(is_address('192.168.1.1'), 1, 'address: IPv4');
is(is_address('::1'), 1, 'address: IPv6');
is(is_address('not-an-address'), 0, 'address: a word is not an address');
is(is_address(''), 0, 'address: an empty string is not an address');
is(skip_filesystem('tmpfs', '/run'), 1, 'filesystem: tmpfs is skipped');
is(skip_filesystem('overlay', '/'), 1, 'filesystem: overlay is skipped');
is(skip_filesystem('ext4', '/dev/sda1'), 0, 'filesystem: ext4 is measured');
is(skip_filesystem('xfs', '/dev/mapper/root'), 0, 'filesystem: xfs is measured');
is(unescape_mount_point('/mnt/\\040data'), '/mnt/ data', 'mounts: an escaped space decodes');
is(unescape_mount_point('/mnt/\\011tab'), "/mnt/\ttab", 'mounts: an escaped tab decodes');
is(unescape_mount_point('/mnt/\\134back'), '/mnt/\\back', 'mounts: an escaped backslash decodes');
is(unescape_mount_point('/plain/path'), '/plain/path', 'mounts: a plain path is untouched');
# ---------------------------------------------------------------------------
# Results read back from a section worker
# ---------------------------------------------------------------------------
is(perl_literal('42'), '42', 'literal: a whole number stays a number');
is(perl_literal('08'), "'08'", 'literal: a leading zero is quoted, not octalised');
is(perl_literal('010'), "'010'", 'literal: a leading zero with digits is quoted too');
my $tmp = "/tmp/system-diag-test.$$";
open(my $fh, '>', $tmp) or die "cannot write $tmp: $!\n";
print {$fh} perl_literal({ cpu => { usage_pct => 12.5 }, host => 'node1', key => '18.0', zeroed => '08' });
close($fh);
my ($value, $error) = read_literal($tmp);
is($error, '', 'literal: a written result reads back without error');
is($value->{host}, 'node1', 'literal: a string survives the round trip');
is($value->{cpu}{usage_pct}, 12.5, 'literal: a float survives the round trip');
is($value->{key}, '18.0', 'literal: a numeric-looking string stays a string');
is($value->{zeroed}, '08', 'literal: a leading-zero string survives the round trip');
unlink($tmp);
my ($missing, $missing_error) = read_literal("$tmp.does-not-exist");
is($missing, undef, 'literal: a missing result file yields nothing');
like($missing_error, qr/was not written/, 'literal: a missing result file says so');
# ---------------------------------------------------------------------------
# External tools, through fixture binaries on a private PATH
# ---------------------------------------------------------------------------
# A collector is exercised end to end by replacing its external tool with a Perl
# fixture that prints a known answer, so the parsing is checked against the shape
# of the real tool's output without needing the tool installed.
sub install_fake_bin {
my ($dir, $name, $body) = @_;
open(my $fh, '>', "$dir/$name") or die "cannot write $dir/$name: $!\n";
print {$fh} "#!/usr/bin/env perl\n", $body;
close($fh);
chmod(0755, "$dir/$name") or die "cannot chmod $dir/$name: $!\n";
return;
}
my $bindir = "/tmp/system-diag-test-bin.$$";
mkdir($bindir, 0700) or die "cannot create $bindir: $!\n";
# The fixture PATH is scoped, so the rest of the checks see the real one.
{
local $ENV{PATH} = "$bindir:$ENV{PATH}";
install_fake_bin($bindir, 'lspci', <<'FIXTURE');
exit 0 if @ARGV && $ARGV[0] eq '-d';
print "0000:01:00.0 VGA compatible controller: Intel Corporation UHD Graphics 730 (rev 01)\n";
print "0000:02:00.0 3D controller: Huawei Technologies Co., Ltd. Ascend 910A\n";
FIXTURE
my $gpu = lspci_gpu();
is($gpu->{cards}[0]{model}, 'Intel Corporation UHD Graphics 730 (rev 01)',
'lspci: the model is the description, not the whole line');
is($gpu->{cards}[0]{vendor}, 'Intel', 'lspci: Corporation does not read as AMD');
is($gpu->{cards}[1]{vendor}, 'Huawei', 'lspci: an Ascend card has its vendor');
install_fake_bin($bindir, 'systemctl', <<'FIXTURE');
if (@ARGV && $ARGV[0] eq '--failed') {
print "nginx.service loaded failed failed A high performance web server\n";
}
FIXTURE
my $failed = failed_units();
is($failed->[0]{unit}, 'nginx.service', 'services: a failed unit is listed');
is($failed->[0]{state}, 'failed', 'services: the state is the unit state, not the load state');
install_fake_bin($bindir, 'iostat', <<'FIXTURE');
print "Device r/s rkB/s w/s wkB/s\n";
print "sda 4.00 2048.00 10.00 512.00\n\n";
print "Device r/s rkB/s w/s wkB/s\n";
print "sda 8.00 4096.00 20.00 1024.00\n";
FIXTURE
my $io = disk_io_stats();
is($io->{devices}[0]{tps}, '28.00', 'iostat: the second report is the one measured');
is($io->{devices}[0]{read_mb_s}, '4.00', 'iostat: a kB/s column converts to MB/s');
is($io->{devices}[0]{write_mb_s}, '1.00', 'iostat: kB/s writes convert to MB/s');
# Older sysstat printed MB/s columns; dividing those again would report a value
# 1024 times too small.
install_fake_bin($bindir, 'iostat', <<'FIXTURE');
print "Device: rrqm/s wrqm/s r/s w/s rMB/s wMB/s\n";
print "sda 0.00 0.00 4.00 10.00 16.00 8.00\n";
FIXTURE
$io = disk_io_stats();
is($io->{devices}[0]{read_mb_s}, '16.00', 'iostat: an MB/s column is not divided again');
is($io->{devices}[0]{write_mb_s}, '8.00', 'iostat: MB/s writes stay in MB/s');
# A machine with lspci and no GPU at all: collect_gpu answers undef, which is a
# result and must not reach the report as a collection failure.
install_fake_bin($bindir, 'lspci', <<'FIXTURE');
exit 0 if @ARGV && $ARGV[0] eq '-d';
print "00:1f.2 SATA controller: Intel Corporation 82801IR SATA [AHCI mode]\n";
FIXTURE
my $collected = collect_all(['gpu'], 0);
check(!(ref($collected->{gpu}) eq 'HASH' && $collected->{gpu}{error}),
'collect: no GPU is a result, not a collection failure');
}
unlink(grep { -f } glob("$bindir/*"));
rmdir($bindir);
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
my $health = compute_health({});
is(ref($health), 'HASH', 'health: the verdict is a hash');
is($health->{score}, undef, 'health: no data has no score');
is($health->{grade}, 'N/A', 'health: no data is not graded');
is($health->{max_score}, 100, 'health: the maximum is one hundred');
# An ungraded run is the only verdict this test can construct, so the letter is not
# asserted here: it depends on a populated collection, which the script's own run
# exercises. A check whose condition is a match must be forced into a boolean, since
# in an argument list a failed match yields the empty list and shifts the label into
# the condition slot.
check(ref($health->{breakdown}) eq 'HASH', 'health: the breakdown is a hash');
check(ref($health->{factors_skipped}) eq 'ARRAY', 'health: the skipped factors are listed');
# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
my @saved = @ARGV;
@ARGV = ('--json', '--section', 'cpu,memory');
my %args = parse_args();
is($args{json}, 1, 'args: --json');
is($args{section}, 'cpu,memory', 'args: --section takes a list');
@ARGV = ();
my %defaults = parse_args();
is($defaults{json}, 0, 'args: json is off by default');
is($defaults{section}, '', 'args: every section by default');
@ARGV = @saved;
print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all $passed checks passed") . "\n";
exit($failed ? 1 : 0);
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Checks for system-optimise.pl: the version comparison against rpm's own
# implementation, the pure helpers and the JSON decoder.
#
# Run from anywhere: perl tests/system-optimise.pl
use strict;
use warnings;
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
$root = "./$root" if $root !~ m{^/};
require "$root/system-optimise.pl";
my ($passed, $failed) = (0, 0);
sub is {
my ($got, $want, $label) = @_;
$got = 'undef' unless defined $got;
$want = 'undef' unless defined $want;
if ($got eq $want) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: $got\n want: $want\n";
return 0;
}
sub check {
my ($cond, $label) = @_;
return is($cond ? 1 : 0, 1, $label);
}
sub like {
my ($got, $re, $label) = @_;
my $matched = (defined $got && $got =~ $re) ? 1 : 0;
if ($matched) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: " . (defined $got ? $got : 'undef') . "\n want: $re\n";
return 0;
}
# ---------------------------------------------------------------------------
# Version comparison, against rpm itself
# ---------------------------------------------------------------------------
#
# rpm carries the definition of its own ordering, so the comparison is checked
# against rpm's implementation rather than against an expectation written here
# by hand. The corpus is fixed: the same pairs every run. No pair may carry
# a dash or a colon: the oracle below parses its arguments as
# [epoch:]version[-release] and would split on them, so those shapes belong to
# the EVR corpus further down.
my @VERSION_PAIRS = (
['1.0', '1.1'],
['1.1', '1.0'],
['1.0', '1.0'],
['1.09', '1.9'],
['1.0', '1.0.0'],
['1.0a', '1.0'],
['1.0a', '1.0b'],
['1.0', '1.0~rc1'],
['1.0~rc1', '1.0~rc2'],
['1.0~rc1', '1.0~rc1'],
['1.0^post1', '1.0'],
['1.0^post1', '1.0^post2'],
['2.0', '10.0'],
['1.2.3', '1.2.3'],
['1.2.3.4', '1.2.3'],
['5.14.0', '5.14.0.1'],
['0.9', '1.0'],
['1.0', '1.0b'],
['2024.1', '2024.10'],
['1.0.rc1', '1.0'],
['1.0^post1', '1.0b'],
['1.0', '1.0^'],
['1.0.~rc1', '1.0~rc1'],
['1.0^x', '1.0~x'],
['1.0.1', '1.0^post1'],
);
sub rpm_vercmp_oracle {
my ($pairs) = @_;
# rpm's macro parser counts braces, so the lua body below carries none: the corpus
# travels as one flat string, and the pattern splits it back into pairs.
my $spec = join(';', map { "$_->[0],$_->[1]" } @$pairs) . ';';
# a comma or a semicolon would silently shift the pairing, so both travel
# with the characters that break the lua literal
return undef if grep { m{["\\{};,]} } map { @$_ } @$pairs;
my $lua = '%{lua: local s="' . $spec . '";'
. q{ for l,r in s:gmatch("([^,]+),([^;]+);") do io.write(rpm.vercmp(l,r), "\n") end};
$lua .= '}';
my $fh;
open($fh, '-|', 'rpm', '--eval', $lua) or return undef;
my @out = <$fh>;
close($fh);
return undef unless $? == 0;
chomp @out;
return \@out;
}
my $oracle = rpm_vercmp_oracle(\@VERSION_PAIRS);
if (!defined $oracle) {
print "skip version comparison: rpm is not installed (the oracle)\n";
}
else {
my $i = 0;
for my $pair (@VERSION_PAIRS) {
$i++;
my ($left, $right) = @$pair;
my $mine = rpmvercmp($left, $right);
my $sign = $mine <=> 0;
is($sign, $oracle->[$i - 1], "rpmvercmp($left, $right) matches rpm");
}
}
is(rpmvercmp('1.0', '1.0'), 0, 'rpmvercmp: equal versions');
check(rpmvercmp('1.0~rc1', '1.0') < 0, 'rpmvercmp: a tilde sorts before the release');
check(rpmvercmp('1.0^post1', '1.0') > 0, 'rpmvercmp: a caret sorts after the release');
# ---------------------------------------------------------------------------
# EVR comparison
# ---------------------------------------------------------------------------
is(rpm_evr_cmp('1:1.0-1', '1:1.0-2'), -1, 'evr: release decides');
is(rpm_evr_cmp('1:1.0-1', '2:1.0-1'), -1, 'evr: epoch decides');
is(rpm_evr_cmp('1:1.0-1', '1:1.0-1'), 0, 'evr: identical');
check(rpm_evr_cmp('2:1.0-1', '1:9.9-9') > 0, 'evr: an epoch outranks any version');
is(rpm_evr_cmp('1.0-1', '1:1.0-1'), -1, 'evr: a missing epoch is zero');
# The same oracle drives the EVR comparison, now with dashes and colons:
# rpm.vercmp parses its arguments as [epoch:]version[-release] and compares
# field by field, which is exactly the contract of rpm_evr_cmp. The pairs
# below are the shapes the kernel query produces, plus the cases where
# comparing version and release as one string would order differently.
my @EVR_PAIRS = (
['1.0-2', '1.0.1-1'],
['1.0', '1.0-1'],
['1.0-1-2', '1.0-1'],
['2:1.0-1', '1.0-1'],
['0:1.0', '1.0'],
['1.0~rc1-1', '1.0-1'],
['0:6.9.5-100.fc40.x86_64', '0:6.9.6-50.fc40.x86_64'],
['0:6.9.5-100.fc40.x86_64', '0:6.9.5-200.fc40.x86_64'],
);
my $evr_oracle = rpm_vercmp_oracle(\@EVR_PAIRS);
if (!defined $evr_oracle) {
print "skip EVR comparison: rpm is not installed (the oracle)\n";
}
else {
my $j = 0;
for my $pair (@EVR_PAIRS) {
$j++;
my ($left, $right) = @$pair;
my $mine = rpm_evr_cmp($left, $right);
my $sign = $mine <=> 0;
is($sign, $evr_oracle->[$j - 1], "rpm_evr_cmp($left, $right) matches rpm");
}
}
# ---------------------------------------------------------------------------
# Formatting and small helpers
# ---------------------------------------------------------------------------
is(format_size(0), '0 B', 'format_size: zero');
is(format_size(512), '512 B', 'format_size: under a kilobyte');
is(format_size(1024), '1.0 KB', 'format_size: one kilobyte');
is(format_size(1536), '1.5 KB', 'format_size: fraction of a kilobyte');
is(format_size(1048576), '1.0 MB', 'format_size: one megabyte');
is(format_size(1073741824), '1.00 GB', 'format_size: one gigabyte');
is(format_size(-1), '0 B', 'format_size: negative is clamped');
is(kernel_pkg_name('fedora'), 'kernel-core', 'kernel_pkg_name: Fedora');
is(kernel_pkg_name('openeuler'), 'kernel', 'kernel_pkg_name: openEuler');
is(kernel_pkg_name('centos'), 'kernel-core', 'kernel_pkg_name: CentOS Stream');
is(last_lines("a\nb\nc\nd\n", 2), 'c | d', 'last_lines: the last two lines, joined');
is(last_lines("1\n2\n3\n4\n", 10), '1 | 2 | 3 | 4', 'last_lines: a count above the line number');
is(last_lines("\n\n", 3), 'no error output', 'last_lines: blank input is named as no output');
my ($gone, $left) = split_by_presence(['a', 'b', 'c'], ['b', 'd']);
is(join(',', @$gone), 'a,c', 'split_by_presence: gone holds what vanished');
is(join(',', @$left), 'b', 'split_by_presence: left holds the survivors');
# ---------------------------------------------------------------------------
# JSON decoder
# ---------------------------------------------------------------------------
my $decoded = json_decode('{"a":1,"b":[true,false,null],"c":"x\\ny","d":{"e":2.5}}');
is(ref($decoded), 'HASH', 'json: an object decodes to a hash');
is($decoded->{a}, 1, 'json: an integer');
is(ref($decoded->{b}), 'ARRAY', 'json: an array decodes to an array');
is($decoded->{b}[0], 1, 'json: true is truthy');
is($decoded->{b}[1], 0, 'json: false is falsey');
is($decoded->{b}[2], undef, 'json: null is undef');
is($decoded->{c}, "x\ny", 'json: an escaped newline');
is($decoded->{d}{e}, 2.5, 'json: a nested float');
my $escaped = json_decode('{"s":"a\"b\\\\c\/d\te"}');
is($escaped->{s}, "a\"b\\c/d\te", 'json: quotes, backslashes, a solidus and a tab');
my $unicode = json_decode('{"s":"\u0041\u00e9"}');
is($unicode->{s}, "A\xc3\xa9", 'json: a unicode escape becomes UTF-8 bytes');
my $empty = json_decode('[]');
is(ref($empty), 'ARRAY', 'json: an empty array');
is(scalar(@$empty), 0, 'json: an empty array has no elements');
# The decoder dies on malformed input rather than returning a sentinel, so a caller
# that cannot trust its input wraps the call; the test asserts that contract.
my $died = eval { json_decode('not json'); 1 } ? 0 : 1;
is($died, 1, 'json: malformed input dies rather than returning a value');
like($@, qr/unrecognised token/, 'json: the death names the reason');
# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
# parse_args reads the program's arguments, so a test sets them the way a caller does.
my @saved = @ARGV;
@ARGV = ('--dry-run', '--skip-journal');
my %args = parse_args();
is($args{dry_run}, 1, 'args: --dry-run sets the flag');
is($args{skip_journal}, 1, 'args: --skip-journal sets the flag');
is($args{skip_tmp}, 0, 'args: an unset skip stays off');
@ARGV = ();
my %defaults = parse_args();
is($defaults{dry_run}, 0, 'args: dry run defaults to off');
@ARGV = @saved;
# ---------------------------------------------------------------------------
# Temp cleanup, end to end
# ---------------------------------------------------------------------------
#
# clean_temp_dir drives the real find(1), so the age filter, the deletion and
# the verification of what went away are exercised together here, against a
# directory this test owns.
if (!defined find_exe('find')) {
print "skip temp cleanup: find is not installed\n";
}
else {
my $dir = "/tmp/system-optimise-test.$$";
mkdir($dir, 0700) or die "cannot create $dir: $!\n";
my $aged = "$dir/aged.txt";
my $fresh = "$dir/fresh.txt";
for my $file ($aged, $fresh) {
open(my $fh, '>', $file) or die "cannot create $file: $!\n";
print {$fh} "test\n";
close($fh);
}
my $old_time = time() - 30 * 86400;
utime($old_time, $old_time, $aged);
my @warnings;
my @errors;
my ($count, $bytes) = clean_temp_dir($dir, 10,
dry_run => 1, warnings => \@warnings, errors => \@errors);
is($count, 1, 'temp: dry run counts the one aged file');
check(-e $aged, 'temp: dry run leaves the aged file in place');
is(scalar(@errors), 0, 'temp: dry run reports no errors');
($count, $bytes) = clean_temp_dir($dir, 10,
dry_run => 0, warnings => \@warnings, errors => \@errors);
is($count, 1, 'temp: the real run reports the aged file as removed');
check(!-e $aged, 'temp: the aged file is gone');
check(-e $fresh, 'temp: the fresh file survives');
check($bytes > 0, 'temp: the freed size is above zero');
is(scalar(@errors), 0, 'temp: the real run reports no errors');
my ($again) = clean_temp_dir($dir, 10,
dry_run => 0, warnings => \@warnings, errors => \@errors);
is($again, 0, 'temp: a second run finds nothing to remove');
unlink($fresh);
rmdir($dir);
}
# ---------------------------------------------------------------------------
# The command runner and the scratch directory
# ---------------------------------------------------------------------------
# A file that cannot be exec'd: the runner must report 126 and name the
# binary, rather than swallowing the reason the exec failed.
my $bad_exe = "/tmp/system-optimise-test-noexec.$$";
if (open(my $fh, '>', $bad_exe)) {
print {$fh} "#!/nonexistent/interpreter\n";
close($fh);
chmod(0755, $bad_exe);
my $result = run([$bad_exe]);
is($result->{rc}, 126, 'run: a failed exec reports 126');
like($result->{err}, qr/cannot execute/, 'run: the failure names the binary');
unlink($bad_exe);
}
else {
print "skip runner: cannot write $bad_exe\n";
}
# An empty TMPDIR must fall back to /tmp rather than placing the scratch
# directory at the filesystem root.
my $empty_tmpdir = eval {
remove_scratch(); # the cached directory would bypass the fallback
local $ENV{TMPDIR} = '';
my $scratch = scratch_dir();
remove_scratch();
return $scratch;
};
if (defined $empty_tmpdir) {
like($empty_tmpdir, qr{^/tmp/system-optimise\.},
'scratch_dir: an empty TMPDIR falls back to /tmp');
}
else {
print "skip scratch_dir: cannot create a scratch directory\n";
}
print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all $passed checks passed") . "\n";
exit($failed ? 1 : 0);
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Checks for workstation-setup.pl: the JSON decoder, the architecture mapping, the
# checksum file reader, the atomic writer, the runner and user command helpers,
# the Brave repo pin, the IDE launcher links and the supported system set.
#
# Run from anywhere: perl tests/workstation-setup.pl
use strict;
use warnings;
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
$root = "./$root" if $root !~ m{^/};
require "$root/workstation-setup.pl";
my ($passed, $failed) = (0, 0);
sub is {
my ($got, $want, $label) = @_;
$got = 'undef' unless defined $got;
$want = 'undef' unless defined $want;
if ($got eq $want) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: $got\n want: $want\n";
return 0;
}
sub check {
my ($cond, $label) = @_;
return is($cond ? 1 : 0, 1, $label);
}
# ---------------------------------------------------------------------------
# JSON
# ---------------------------------------------------------------------------
my $decoded = json_decode('{"a":1,"b":[true,false,null],"c":"x\\ny","d":{"e":2.5}}');
is(ref($decoded), 'HASH', 'json: an object decodes to a hash');
is($decoded->{a}, 1, 'json: an integer');
is($decoded->{b}[0], 1, 'json: true is truthy');
is($decoded->{b}[1], 0, 'json: false is falsey');
is($decoded->{b}[2], undef, 'json: null is undef');
is($decoded->{c}, "x\ny", 'json: an escaped newline');
is($decoded->{d}{e}, 2.5, 'json: a nested float');
my $strings = json_decode('{"s":"a\"b\\\\c\/d\te","u":"\u0041\u00e9"}');
is($strings->{s}, "a\"b\\c/d\te", 'json: quotes, backslashes, a solidus and a tab');
is($strings->{u}, "A\xc3\xa9", 'json: a unicode escape becomes UTF-8 bytes');
my $numbers = json_decode('{"i":-12,"f":1e3,"z":0,"n":-0.5}');
is($numbers->{i}, -12, 'json: a negative integer');
is($numbers->{f}, 1000, 'json: an exponent');
is($numbers->{z}, 0, 'json: zero');
is($numbers->{n}, -0.5, 'json: a negative float');
my $empty = json_decode('[]');
is(ref($empty), 'ARRAY', 'json: an empty array');
is(scalar(@$empty), 0, 'json: an empty array has no elements');
my $whitespace = json_decode(" {\n \"a\" : 1\n} ");
is($whitespace->{a}, 1, 'json: whitespace around and inside is skipped');
# Malformed input dies with the offset, so a caller that cannot trust its input wraps
# the call; the test asserts that contract rather than a sentinel value.
for my $bad ('', '{', '[1,]', 'nope', '{"a":}') {
my $died = eval { json_decode($bad); 1 } ? 0 : 1;
is($died, 1, "json: malformed input dies (" . ($bad eq '' ? 'empty' : $bad) . ")");
}
my $reason = eval { json_decode('nope'); '' } || $@;
check($reason =~ /offset 0/, 'json: the death names the offset');
# ---------------------------------------------------------------------------
# Architecture
# ---------------------------------------------------------------------------
my $arch = go_arch();
check(!defined $arch || $arch =~ /^(amd64|arm64)$/,
'arch: the machine maps to a Go architecture or to nothing');
my $tmp = "/tmp/workstation-test.$$";
my $sums = "$tmp.sums";
open(my $fh, '>', $sums) or die "cannot write $sums: $!\n";
print {$fh} 'a' x 64 . " ./tool.tar.gz\n";
print {$fh} 'b' x 64 . " other.tar.gz\n";
close($fh);
is(sha256_from_sums($sums, 'other.tar.gz'), 'b' x 64, 'sums: a name is found in the file');
is(sha256_from_sums($sums, 'missing.tar.gz'), undef, 'sums: a name that is absent yields nothing');
is(sha256_from_sums($sums, './tool.tar.gz'), 'a' x 64, 'sums: the name as listed is matched');
is(sha256_from_sums($sums, 'tool.tar.gz'), undef, 'sums: a name that is listed differently is not matched');
unlink($sums);
is(sha256_from_sums("$tmp.nothing", 'x'), undef, 'sums: a missing file yields nothing');
# ---------------------------------------------------------------------------
# Atomic writes
# ---------------------------------------------------------------------------
# atomic_write settles the owner, which for a new file is root, so it needs root to
# run; as an ordinary user the write is refused and the check is skipped rather than
# pretended. The container runs exercise the real path.
my $target = "$tmp.target";
# The contract is a death on failure and no value on success, which is how every
# caller uses it: eval { atomic_write(...); 1 }.
if ($> == 0) {
my $written = eval { atomic_write($target, "one\n"); 1 } ? 1 : 0;
is($written, 1, 'atomic_write: a new file is written');
is(slurp($target), "one\n", 'atomic_write: the content is there');
$written = eval { atomic_write($target, "two\n"); 1 } ? 1 : 0;
is($written, 1, 'atomic_write: an existing file is replaced');
is(slurp($target), "two\n", 'atomic_write: the new content is there');
check(!-e "$target.$$" . '.tmp', 'atomic_write: no temporary file is left behind');
unlink($target);
}
else {
my $died = eval { atomic_write($target, "one\n"); 1 } ? 0 : 1;
is($died, 1, 'atomic_write: dies when it cannot own the target, as an ordinary user');
check(!-e "$target.$$" . '.tmp', 'atomic_write: the temporary file is cleaned up');
}
# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
my @saved = @ARGV;
@ARGV = ('--dry-run', '--skip-rpm', '--skip-go');
my %args = parse_args();
is($args{dry_run}, 1, 'args: --dry-run');
is($args{skip_rpm}, 1, 'args: --skip-rpm');
is($args{skip_go}, 1, 'args: --skip-go');
is($args{skip_update}, 0, 'args: an unset skip stays off');
@ARGV = ();
my %defaults = parse_args();
is($defaults{dry_run}, 0, 'args: dry run is off by default');
@ARGV = @saved;
# ---------------------------------------------------------------------------
# Command composition for the real user
# ---------------------------------------------------------------------------
{
local $ENV{SUDO_USER};
delete $ENV{SUDO_USER};
is(join(' ', @{ user_command(['flatpak', 'info', '--user', 'org.example.App']) }),
'flatpak info --user org.example.App',
'user_command: without SUDO_USER the command itself is the whole list');
my $direct = run_as_user(['/bin/echo', 'as-user']);
is($direct->{out}, "as-user\n",
'run_as_user: without SUDO_USER the command runs directly');
$ENV{SUDO_USER} = 'someuser';
is(join(' ', @{ user_command(['flatpak', 'uninstall', '-y', 'org.example.App']) }),
'sudo -u someuser flatpak uninstall -y org.example.App',
'user_command: SUDO_USER becomes a sudo -u prefix before the command');
is(join(' ', @{ user_command(['rustup-init', '-y'],
{ RUSTUP_HOME => '/h', CARGO_HOME => '/c' }) }),
'sudo -u someuser env CARGO_HOME=/c RUSTUP_HOME=/h rustup-init -y',
'user_command: env assignments come sorted between env(1) and the command');
}
# ---------------------------------------------------------------------------
# Runner exit codes
# ---------------------------------------------------------------------------
is(run(['perl', '-e', 'exit 7'])->{rc}, 7, 'run: an exit code passes through');
is(run(['perl', '-e', q{kill 9, $$}])->{rc}, 137,
'run: a child killed by a signal reports 128+signal, not success');
# ---------------------------------------------------------------------------
# Repo gpgkey pinning
# ---------------------------------------------------------------------------
my $repo_file = "$tmp.repo";
my $key_file = "$tmp.key";
open(my $key_out, '>', $key_file) or die "cannot write $key_file: $!\n";
print {$key_out} "verified key material\n";
close($key_out);
open(my $repo_out, '>', $repo_file) or die "cannot write $repo_file: $!\n";
print {$repo_out} "[brave-browser]\nbaseurl=https://example.com/brave/x86_64/\n"
. "\ngpgkey=https://example.com/brave-core.asc\nenabled=1\n";
close($repo_out);
is(pin_brave_repo_gpgkey($repo_file, $key_file), 1,
'pin: a URL gpgkey is pinned to the local key file');
my $pinned_content = slurp($repo_file);
check(index($pinned_content, "gpgkey=file://$key_file") >= 0,
'pin: the gpgkey line points at the key file');
check(index($pinned_content, 'baseurl=https://example.com/brave/x86_64/') >= 0,
'pin: the untouched lines survive the rewrite');
check($pinned_content =~ /\n\n/, 'pin: a blank line survives the rewrite');
is(pin_brave_repo_gpgkey($repo_file, $key_file), 1,
'pin: an already pinned file is left as it is');
unlink($repo_file);
is(pin_brave_repo_gpgkey($repo_file, $key_file), 0,
'pin: a missing repo file fails without dying');
unlink($key_file);
# ---------------------------------------------------------------------------
# JetBrains launcher links
# ---------------------------------------------------------------------------
my $ide_home = "$tmp.ide";
my $ide_bin = "$ide_home/bin";
my $ide_desktop = "$ide_home/applications";
make_dirs($ide_bin) or die "cannot create $ide_bin\n";
for my $dir ("$ide_home/GoLand-2026.1", "$ide_home/GoLand-2026.2") {
make_dirs("$dir/bin") or die "cannot create $dir/bin\n";
my $launcher = "$dir/bin/goland.sh";
open(my $launch_out, '>', $launcher) or die "cannot write $launcher: $!\n";
close($launch_out);
chmod(0755, $launcher);
}
is(jetbrains_links('GoLand', "$ide_home/GoLand-2026.1", $ide_bin, $ide_desktop), 1,
'ide links: an install with a launcher gets its links');
is(readlink("$ide_bin/goland") // '', "$ide_home/GoLand-2026.1/bin/goland.sh",
'ide links: the symlink points at the launcher');
is(jetbrains_links('GoLand', "$ide_home/GoLand-2026.2", $ide_bin, $ide_desktop), 1,
'ide links: a newer install repoints the symlink');
is(readlink("$ide_bin/goland") // '', "$ide_home/GoLand-2026.2/bin/goland.sh",
'ide links: the symlink follows the newer launcher');
is(jetbrains_links('GoLand', "$ide_home/GoLand-0.0.0", $ide_bin, $ide_desktop), 0,
'ide links: a missing launcher fails the links');
if ($> == 0) {
my $entry = slurp("$ide_desktop/jetbrains-goland.desktop");
check(index($entry, "Exec=\"$ide_home/GoLand-2026.2/bin/goland.sh\"") >= 0,
'ide links: the menu entry names the launcher');
}
remove_tree($ide_home);
# ---------------------------------------------------------------------------
# Supported systems
# ---------------------------------------------------------------------------
# The supported set is a lexical in the script, so its contract is checked
# through the usage text the set builds: Fedora advertised, nothing else.
check(index(usage(), 'workstation setup for Fedora') >= 0 && index(usage(), 'openEuler') < 0,
'usage: only Fedora is advertised');
print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all $passed checks passed") . "\n";
exit($failed ? 1 : 0);
+2394
View File
File diff suppressed because it is too large Load Diff