feat(sglang-deploy): replace nginx with caddy

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-29 00:18:09 +02:00
parent bd291890db
commit 9455c65f10
4 changed files with 820 additions and 186 deletions
+509 -126
View File
@@ -4,12 +4,14 @@
# Idempotent SGLang deployment for AMD ROCm GPUs.
#
# SGLang behind nginx with self-signed TLS on Fedora, CentOS Stream or openEuler.
# SGLang behind Caddy with self-signed TLS on Fedora, CentOS Stream or openEuler.
# The engine binds to ::1 (IPv4 loopback fallback) on port 8000, internal only;
# nginx proxies :443 to the loopback upstream with streaming (SSE) support. The
# Caddy proxies :443 to the loopback upstream and streams (SSE) unbuffered. The
# endpoint requires an API key, delivered to the service through a 0600
# EnvironmentFile; nginx to engine proxying is allowed through SELinux on enforcing
# systems.
# EnvironmentFile. Caddy's service runs as the caddy user, so the private key is
# made group-readable for the caddy group, and on SELinux-enforcing hosts the
# distribution's caddy runs unconfined, which needs no boolean; where a confined
# caddy policy is loaded anyway the script sets httpd_can_network_connect.
#
# Why the engine runs in a container rather than straight on the host:
#
@@ -19,8 +21,8 @@
# Rust), which Fedora carries only partly and which CentOS Stream and openEuler, where
# ROCm itself is unsupported by AMD, cannot carry at all. Both AMD and SGLang document
# the container as the way to run SGLang on ROCm, so the container is what this script
# deploys: podman runs the official image, and the host keeps nginx, TLS, the API key,
# the firewall and the SELinux boolean. The host needs no ROCm userland, only the
# deploys: podman runs the official image, and the host keeps Caddy, TLS, the API key,
# the firewall and the SELinux story. The host needs no ROCm userland, only the
# amdgpu kernel driver and its device nodes, /dev/kfd and /dev/dri.
#
# Radeon cards: the project publishes no stable image for gfx1151 (Strix Halo, the
@@ -42,7 +44,9 @@
# keeps stdout and stderr apart in the scratch directory.
#
# External binaries used: dnf, rpm, curl, podman, lspci, openssl, systemctl,
# getenforce, getsebool, setsebool, firewall-cmd and nginx.
# getenforce, getsebool, setsebool, semodule, firewall-cmd, caddy, tar, install and
# useradd (the last four only on a host where no repository carries the caddy
# package and the release binary is installed instead).
#
# Usage:
# sglang-deploy.pl # interactive model selection
@@ -55,7 +59,7 @@
use strict;
use warnings;
my $VERSION = '2.0.0';
my $VERSION = '2.1.0';
my $BOLD = "\033[1m";
my $RED = "\033[31m";
@@ -78,11 +82,11 @@ my %SUPPORTED_OS = (
);
# Tools this script itself needs. podman is the engine's runtime: SGLang ships no
# ROCm wheel, so the server runs from the project's own ROCm image.
my @DNF_PACKAGES = qw(pciutils curl openssl podman);
# Packages the engine expects from server-setup.pl (warning only, not installed here).
my @REQUIRED_SERVER_PACKAGES = (['nginx', 'reverse proxy']);
# ROCm wheel, so the server runs from the project's own ROCm image. caddy is
# installed in its own step rather than in this transaction: a name the
# repositories do not carry aborts the whole dnf run, and openEuler ships no
# caddy at all (there the release binary takes its place).
my @DNF_PACKAGES = qw(pciutils curl openssl podman tar);
# Default models for interactive selection when --model is omitted, keyed by
# display name. Every ID is a ModelScope repository, which is where the engine
@@ -176,6 +180,19 @@ my $DEFAULT_CERT_DIR = '/etc/ssl/sglang';
my $DEFAULT_SERVICE = 'sglang';
my $INTERNAL_PORT = 8000;
# Caddy, the endpoint's TLS proxy. Fedora carries the package, CentOS Stream
# gets it from EPEL, and openEuler ships none, so where no package can be
# installed the official release binary takes its place, with the unit file the
# package would have carried. The distribution's default Caddyfile imports the
# Caddyfile.d directory, which is the drop-in this script writes; a main file
# without the import line gets it appended.
my $CADDY_RELEASES_API = 'https://api.github.com/repos/caddyserver/caddy/releases/latest';
my $CADDY_FALLBACK_VERSION = '2.10.2';
my $CADDY_BINARY_PATH = '/usr/local/bin/caddy';
my $CADDY_CONFIG_DIR = '/etc/caddy';
my $CADDY_IMPORT_LINE = 'import Caddyfile.d/*.caddyfile';
my $LEGACY_NGINX_DIR = '/etc/nginx/conf.d';
# ModelScope model IDs look like "org/name", the same shape Hugging Face uses.
# The strict pattern also keeps systemd specifier characters (%) and whitespace
# out of unit files.
@@ -287,7 +304,7 @@ sub write_file {
my ($path, $content) = @_;
open(my $fh, '>', $path) or return 0;
# The flush of a buffered handle surfaces at close, so close is checked too:
# a full disk must not report a truncated unit file or nginx configuration
# a full disk must not report a truncated unit file or Caddyfile drop-in
# as written.
my $ok = print {$fh} $content;
$ok = 0 unless close($fh);
@@ -691,7 +708,7 @@ sub ms_model_status {
return 'unknown';
}
# Return (bind_host, nginx_upstream_host): IPv6 ::1 first.
# Return (bind_host, caddy_upstream_host): IPv6 ::1 first.
#
# Falls back to 127.0.0.1 on kernels with IPv6 disabled
# (net.ipv6.conf.all.disable_ipv6=1), where binding ::1 would fail.
@@ -760,7 +777,7 @@ sub cert_san {
# ---------------------------------------------------------------------------
sub install_system_deps {
my ($dry_run) = @_;
my ($os_id, $dry_run) = @_;
my %results = (installed => [], skipped => [], failed => []);
my @missing;
@@ -820,17 +837,199 @@ sub install_system_deps {
_fail('podman is not installed: the engine runs as a container and cannot start');
}
# Packages expected from server-setup.pl (warning only, not installed here).
for my $entry (@REQUIRED_SERVER_PACKAGES) {
my ($pkg, $purpose) = @$entry;
if (!rpm_installed($pkg)) {
_warn("$pkg ($purpose) is not installed: run server-setup.pl first");
# Caddy proxies the endpoint on 443. Fedora carries the package; CentOS
# Stream carries it in EPEL, whose repository file installs first; where no
# repository carries it at all (openEuler ships none), the official release
# binary takes its place, unit file included. The step is separate from the
# transaction above, because one unresolvable name aborts a whole dnf run.
my $caddy = caddy_binary();
if (defined $caddy) {
_status('Checking caddy');
my $result = run([$caddy, 'version'], timeout => 30);
my $version = $result->{out};
$version =~ s/^\s+//;
$version =~ s/\s+$//;
$version = (split /\s+/, $version)[0] // '';
_status_done($version ne '' ? $version : 'installed');
$results{caddy} = $version ne '' ? $version : 'installed';
}
elsif ($dry_run) {
_status('Checking caddy');
_status_done('would install');
$results{caddy} = 'dry run';
}
else {
# CentOS Stream carries caddy in EPEL, and the repository file ships in
# its extras repository: installing it first is what makes caddy
# resolvable in the transaction below.
if ($os_id eq 'centos' && !rpm_installed('epel-release')) {
_status('Enabling EPEL (caddy is packaged there)');
my $epel = run(['dnf', 'install', '-y', 'epel-release'], timeout => $DNF_TIMEOUT);
if ($epel->{rc} == 0) {
_status_done('ok');
}
else {
_status_done('failed');
my $err = $epel->{err};
$err =~ s/\s+$//;
_info("dnf stderr: $err") if length $err;
}
}
_status('Installing caddy');
my $package = run(['dnf', 'install', '-y', 'caddy'], timeout => $DNF_TIMEOUT);
if ($package->{rc} == 0) {
_status_done('package');
$results{caddy} = 'package';
}
elsif (install_caddy_binary()) {
_status_done('release binary');
$results{caddy} = 'release binary';
}
else {
_status_done('failed');
$results{caddy} = undef;
_fail('caddy is not available: the endpoint cannot be served on 443');
}
}
return \%results;
}
# The caddy binary the script drives, package or release binary. An absolute
# fallback is needed because a systemd unit and a fresh install reach the
# binary before any PATH that carries /usr/local/bin.
sub caddy_binary {
my $exe = find_exe('caddy');
return $exe if defined $exe;
return (-f $CADDY_BINARY_PATH && -x _) ? $CADDY_BINARY_PATH : undef;
}
# The newest caddy release version ('2.10.2'), from the GitHub API with a
# constant as the fallback. The charset bound keeps whatever the API answers
# out of the download URL.
sub resolve_caddy_version {
my $listing = fetch_text($CADDY_RELEASES_API);
if ($listing =~ /"tag_name"\s*:\s*"v(\d+\.\d+\.\d+)"/) {
return $1;
}
return $CADDY_FALLBACK_VERSION;
}
# caddy publishes release assets as caddy_VERSION_linux_ARCH.tar.gz.
sub uname_to_arch {
my ($machine) = @_;
return 'amd64' if $machine eq 'x86_64';
return 'arm64' if $machine eq 'aarch64';
return undef;
}
sub caddy_asset_url {
my ($version, $arch) = @_;
return "https://github.com/caddyserver/caddy/releases/download"
. "/v$version/caddy_${version}_linux_${arch}.tar.gz";
}
# Install caddy from the official release binary, for the hosts no repository
# carries the package for (openEuler ships none). Everything the package would
# have provided is provided here: the binary, the directories, the service user
# and the unit file, copied from the distribution's own unit. The caller owns
# the progress line; this reports only failures.
sub install_caddy_binary {
my $version = resolve_caddy_version();
my $machine = run(['uname', '-m'], timeout => 15)->{out};
$machine =~ s/^\s+//;
$machine =~ s/\s+$//;
my $arch = uname_to_arch($machine);
if (!defined $arch) {
_fail("caddy publishes no release binary for $machine");
return 0;
}
my $curl = find_exe('curl');
my $tar = find_exe('tar');
my $install = find_exe('install');
if (!defined $curl || !defined $tar || !defined $install) {
_fail('curl, tar or install is missing: cannot install the caddy release binary');
return 0;
}
my $dir = scratch_dir();
my $tarball = "$dir/caddy.tar.gz";
my $download = run([$curl, '-fsSL', '-o', $tarball, caddy_asset_url($version, $arch)],
timeout => 300);
if ($download->{rc} != 0) {
my $err = $download->{err};
$err =~ s/\s+$//;
_fail("The caddy release download failed: $err");
return 0;
}
my $extract = "$dir/caddy-extract";
mkdir($extract, 0700);
my $unpacked = run([$tar, '-xzf', $tarball, '-C', $extract], timeout => 60);
if ($unpacked->{rc} != 0 || !-f "$extract/caddy") {
_fail('The caddy release archive is not readable');
return 0;
}
for my $path ($CADDY_CONFIG_DIR, "$CADDY_CONFIG_DIR/Caddyfile.d", '/var/lib/caddy') {
mkdir($path, 0755) unless -d $path;
}
my $placed = run([$install, '-m', '0755', "$extract/caddy", $CADDY_BINARY_PATH],
timeout => 30);
if ($placed->{rc} != 0) {
my $err = $placed->{err};
$err =~ s/\s+$//;
_fail("Could not install $CADDY_BINARY_PATH: $err");
return 0;
}
if (!getpwnam('caddy')) {
my $user = run(['useradd', '--system', '--home-dir', '/var/lib/caddy',
'--create-home', '--shell', '/sbin/nologin', 'caddy'], timeout => 30);
if ($user->{rc} != 0) {
_warn('The caddy user could not be created: create it before starting caddy');
}
}
my $unit_path = '/etc/systemd/system/caddy.service';
if (!write_file($unit_path, caddy_unit_content())) {
_fail("Could not write $unit_path: " . os_error_text($unit_path));
return 0;
}
run(['systemctl', 'daemon-reload'], timeout => 30);
return 1;
}
# The unit file for a release-binary install: the distribution's own unit, with
# the binary path adjusted. validate in ExecStartPre is what keeps a broken
# Caddyfile from taking the service down at boot.
sub caddy_unit_content {
return <<"UNIT";
[Unit]
Description=Caddy web server
Documentation=https://caddyserver.com/docs/
After=network.target
[Service]
User=caddy
Group=caddy
ExecStartPre=$CADDY_BINARY_PATH validate --config $CADDY_CONFIG_DIR/Caddyfile
ExecStart=$CADDY_BINARY_PATH run --environ --config $CADDY_CONFIG_DIR/Caddyfile
ExecReload=$CADDY_BINARY_PATH reload --config $CADDY_CONFIG_DIR/Caddyfile
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target
UNIT
}
# ---------------------------------------------------------------------------
# 2. Pre-flight checks
# ---------------------------------------------------------------------------
@@ -1119,6 +1318,21 @@ sub fetch_engine_image {
# 5. TLS certificate (self-signed)
# ---------------------------------------------------------------------------
# The caddy service runs as the caddy user (the package creates it), so the
# private key has to cross the group line: root keeps the ownership and the
# caddy group gets read. Without the group (the package is missing) the key
# stays root-only and the caddy step reports what is missing.
sub ensure_caddy_key_readable {
my ($key_path) = @_;
my ($group, undef, $gid) = getgrnam('caddy');
if (!defined $group) {
return 0;
}
chown(0, $gid, $key_path);
chmod(0640, $key_path);
return 1;
}
sub setup_tls {
my ($cert_dir, $dry_run) = @_;
my %results;
@@ -1127,6 +1341,7 @@ sub setup_tls {
_status('Checking TLS certificate');
if (-f $crt_path && -f $key_path) {
$results{key_readable} = ensure_caddy_key_readable($key_path);
_status_done('already exists');
$results{cert_exists} = 1;
return \%results;
@@ -1172,6 +1387,7 @@ sub setup_tls {
if ($result->{rc} == 0) {
chmod 0600, $key_path;
chmod 0644, $crt_path;
$results{key_readable} = ensure_caddy_key_readable($key_path);
_status_done('generated');
$results{cert_created} = 1;
}
@@ -1288,10 +1504,13 @@ sub encode_base64url {
# 7. SELinux
# ---------------------------------------------------------------------------
# Allow nginx to reach the engine's port on SELinux-enforcing systems.
# Allow Caddy to reach the engine's port on SELinux-enforcing systems.
#
# http_port_t covers 80/81/443/488/8008/8009/8443/9000 but not the engine's port, so
# on enforcing systems nginx needs httpd_can_network_connect.
# The distributions package caddy without an SELinux policy module, so its
# service runs unconfined and needs no boolean at all. Where a confined caddy
# policy is loaded anyway (a local module), proxying to the engine's port needs
# httpd_can_network_connect: http_port_t covers 80/81/443/488/8008/8009/8443/9000
# but not the engine's port.
sub setup_selinux {
my ($dry_run) = @_;
my %results;
@@ -1315,12 +1534,28 @@ sub setup_selinux {
}
_status_done('enforcing');
_status('Checking for a confined caddy policy');
my $semodule = find_exe('semodule');
my $confined = 0;
if (defined $semodule) {
my $list = run([$semodule, '-l'], timeout => 30);
# "semodule -l" lines read "100 caddy(pp)" or the plain "caddy 1.0"
# of older releases; the priority column is optional in the match.
$confined = 1 if $list->{rc} == 0 && $list->{out} =~ /^\s*(?:\d+\s+)?\S*caddy\b/m;
}
if (!$confined) {
_status_done('none (caddy runs unconfined)');
$results{selinux} = 'unconfined';
return \%results;
}
_status_done('confined policy loaded');
_status('Checking httpd_can_network_connect boolean');
my $getsebool = find_exe('getsebool');
my $setsebool = find_exe('setsebool');
if (!defined $getsebool || !defined $setsebool) {
_status_done('tools missing');
_warn('getsebool/setsebool not found: nginx proxying may be blocked (502)');
_warn('getsebool/setsebool not found: caddy proxying may be blocked (502)');
$results{selinux} = 'failed';
return \%results;
}
@@ -1356,59 +1591,143 @@ sub setup_selinux {
}
# ---------------------------------------------------------------------------
# 8. nginx configuration
# 8. Caddy configuration
# ---------------------------------------------------------------------------
# Return the nginx server block content.
# Return the Caddyfile drop-in for the endpoint.
#
# HTTP/1.1 with an empty Connection header and proxy_buffering off are required for
# the engine's SSE streaming: nginx's defaults (HTTP/1.0, buffering on) would hold a
# whole streamed completion until generation finishes. The IPv6 listener is emitted
# only when the kernel actually has IPv6 enabled (the same check as detect_loopback);
# socket() on [::]:443 would otherwise fail with EAFNOSUPPORT and take nginx down.
sub nginx_conf_content {
# Caddy streams proxied responses immediately when flush_interval is negative,
# which the engine's SSE completions need; the nginx equivalent was HTTP/1.1
# with proxy_buffering off. Caddy sets X-Forwarded-For and X-Forwarded-Proto
# itself and passes the Host header through, so only X-Real-IP is written.
# There is no read timeout: a completion that generates for minutes must not be
# cut at a fixed limit, and the response ends when the engine ends it. No bind
# directive is written: Caddy listens on both loopback families on kernels with
# IPv6 and falls back to IPv4 alone where the kernel has none. The nesting is
# tab-indented, which is how the Caddyfile is formatted.
sub caddyfile_content {
my ($port, $upstream_host, $cert_dir) = @_;
my $ipv6_listen = -e '/proc/net/if_inet6' ? "listen [::]:443 ssl;\n " : '';
return <<"CONF";
server {
${ipv6_listen}listen 443 ssl;
server_name _;
ssl_certificate $cert_dir/$CONTAINER_NAME.crt;
ssl_certificate_key $cert_dir/$CONTAINER_NAME.key;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 50m;
location / {
proxy_pass http://$upstream_host:$port;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
:443 {
tls $cert_dir/$CONTAINER_NAME.crt $cert_dir/$CONTAINER_NAME.key
request_body {
max_size 50MB
}
reverse_proxy $upstream_host:$port {
flush_interval -1
header_up X-Real-IP {remote_host}
}
}
CONF
}
sub nginx_conf_path {
sub caddyfile_path {
my ($service_name) = @_;
return "/etc/nginx/conf.d/$service_name.conf";
return "$CADDY_CONFIG_DIR/Caddyfile.d/$service_name.caddyfile";
}
sub setup_nginx {
sub caddy_main_config {
return "$CADDY_CONFIG_DIR/Caddyfile";
}
# The main Caddyfile must import the drop-in directory. The distributions'
# default file carries the import; a host without the file gets a minimal one,
# and one that does not import gets the line appended, which is inert while the
# directory holds nothing else.
sub ensure_caddy_import {
my ($dry_run) = @_;
my %results;
my $main = caddy_main_config();
my $current = -f $main ? slurp($main) : '';
_status('Checking the Caddyfile import');
# Any import of the drop-in directory counts, not only this script's exact
# line: appending a second one would make Caddy read every drop-in twice.
if ($current =~ /^\s*import\s+Caddyfile\.d\//m) {
_status_done('present');
$results{caddy_import} = 'present';
return \%results;
}
if ($dry_run) {
_status_done('would add');
$results{caddy_import} = 'dry run';
return \%results;
}
for my $dir ($CADDY_CONFIG_DIR, "$CADDY_CONFIG_DIR/Caddyfile.d") {
mkdir($dir, 0755) unless -d $dir;
}
my $desired = length($current)
? $current . (substr($current, -1) eq "\n" ? '' : "\n") . "$CADDY_IMPORT_LINE\n"
: "$CADDY_IMPORT_LINE\n";
if (write_file($main, $desired)) {
chmod 0644, $main;
_status_done(length $current ? 'added' : 'created');
$results{caddy_import} = length $current ? 'added' : 'created';
}
else {
_status_done('failed');
_fail("Could not write $main: " . os_error_text($main));
$results{caddy_import} = 0;
}
return \%results;
}
# A deployment made by the nginx release leaves its drop-in behind. Remove it,
# so exactly one proxy owns :443; nginx itself stays, for whatever else it serves.
sub remove_legacy_nginx {
my ($service_name, $dry_run) = @_;
my %results;
my $legacy = "$LEGACY_NGINX_DIR/$service_name.conf";
return \%results unless -f $legacy;
_status('Removing the legacy nginx configuration');
if ($dry_run) {
_status_done('dry run');
$results{legacy_nginx_removed} = 'dry run';
return \%results;
}
unlink($legacy);
if (systemctl_is_active('nginx')) {
my $reload = run(['systemctl', 'reload', 'nginx'], timeout => 30);
if ($reload->{rc} == 0) {
_status_done('removed and nginx reloaded');
}
else {
_status_done('removed (nginx reload failed)');
my $err = $reload->{err};
$err =~ s/\s+$//;
_warn("nginx reload failed: $err");
}
}
else {
_status_done('removed (nginx not running)');
}
$results{legacy_nginx_removed} = 1;
return \%results;
}
sub setup_caddy {
my ($port, $upstream_host, $cert_dir, $service_name, $dry_run) = @_;
my %results;
my $conf_path = nginx_conf_path($service_name);
my $desired_content = nginx_conf_content($port, $upstream_host, $cert_dir);
# Write the nginx config if it is missing or different.
_status('Checking nginx configuration');
my $import = ensure_caddy_import($dry_run);
$results{caddy_import} = $import->{caddy_import};
my $legacy = remove_legacy_nginx($service_name, $dry_run);
$results{legacy_nginx_removed} = $legacy->{legacy_nginx_removed}
if defined $legacy->{legacy_nginx_removed};
my $conf_path = caddyfile_path($service_name);
my $desired_content = caddyfile_content($port, $upstream_host, $cert_dir);
if (!$dry_run) {
for my $dir ($CADDY_CONFIG_DIR, "$CADDY_CONFIG_DIR/Caddyfile.d") {
mkdir($dir, 0755) unless -d $dir;
}
}
# Write the drop-in if it is missing or different.
_status('Checking the caddy configuration');
if (-f $conf_path) {
my $current = slurp($conf_path);
$current =~ s/^\s+//;
@@ -1418,95 +1737,107 @@ sub setup_nginx {
$desired =~ s/\s+$//;
if ($current eq $desired) {
_status_done('already configured');
$results{nginx_configured} = 1;
$results{caddy_configured} = 1;
}
elsif ($dry_run) {
_status_done('would update');
$results{nginx_configured} = 'dry run';
$results{caddy_configured} = 'dry run';
}
elsif (write_file($conf_path, $desired_content)) {
chmod 0644, $conf_path;
_status_done('updated');
$results{nginx_configured} = 1;
$results{caddy_configured} = 1;
}
else {
_status_done('failed');
_fail("Could not write $conf_path: " . os_error_text($conf_path));
$results{nginx_configured} = 0;
$results{caddy_configured} = 0;
}
}
elsif ($dry_run) {
_status_done('would create');
$results{nginx_configured} = 'dry run';
$results{caddy_configured} = 'dry run';
}
elsif (write_file($conf_path, $desired_content)) {
chmod 0644, $conf_path;
_status_done('created');
$results{nginx_configured} = 1;
$results{caddy_configured} = 1;
}
else {
_status_done('failed');
_fail("Could not write $conf_path: " . os_error_text($conf_path));
$results{nginx_configured} = 0;
$results{caddy_configured} = 0;
}
# Ensure nginx is enabled and running (handles the enabled-but-stopped case).
_status('Ensuring nginx service is enabled and running');
my $nginx_running = systemctl_is_active('nginx');
if (systemctl_is_enabled('nginx') && $nginx_running) {
# Ensure caddy is enabled and running (handles the enabled-but-stopped case).
_status('Ensuring caddy service is enabled and running');
my $caddy_running = systemctl_is_active('caddy');
if (systemctl_is_enabled('caddy') && $caddy_running) {
_status_done('running');
$results{nginx_running} = 1;
$results{caddy_running} = 1;
}
elsif ($dry_run) {
_status_done('dry run');
$results{nginx_running} = 'dry run';
$results{caddy_running} = 'dry run';
}
else {
my $start_result = systemctl_is_enabled('nginx')
? run(['systemctl', 'start', 'nginx'], timeout => 30)
: run(['systemctl', 'enable', '--now', 'nginx'], timeout => 30);
my $start_result = systemctl_is_enabled('caddy')
? run(['systemctl', 'start', 'caddy'], timeout => 30)
: run(['systemctl', 'enable', '--now', 'caddy'], timeout => 30);
if ($start_result->{rc} == 0) {
_status_done('enabled and started');
$results{nginx_running} = 1;
$results{caddy_running} = 1;
}
else {
_status_done('failed');
my $err = $start_result->{err};
$err =~ s/\s+$//;
_warn("Could not start nginx: $err");
$results{nginx_running} = 0;
_warn("Could not start caddy: $err");
$results{caddy_running} = 0;
}
}
# Test and reload the configuration (only possible when nginx is running).
_status('Reloading nginx configuration');
# Validate and reload the configuration (only possible when caddy is running).
_status('Reloading caddy configuration');
if ($dry_run) {
_status_done('dry run');
$results{nginx_reloaded} = 'dry run';
$results{caddy_reloaded} = 'dry run';
}
elsif (!$results{nginx_running}) {
_status_done('skipped (nginx not running)');
$results{nginx_reloaded} = 0;
elsif (!$results{caddy_running}) {
_status_done('skipped (caddy not running)');
$results{caddy_reloaded} = 0;
}
else {
my $test_result = run(['nginx', '-t'], timeout => 30);
if ($test_result->{rc} != 0) {
my $caddy = caddy_binary();
if (!defined $caddy) {
_status_done('caddy not found');
_fail('caddy is not installed: cannot validate the configuration');
$results{caddy_reloaded} = 0;
}
else {
my $validate = run([$caddy, 'validate', '--config', caddy_main_config()],
timeout => 60);
if ($validate->{rc} != 0) {
_status_done('config test failed');
my $err = $test_result->{err};
my $err = $validate->{err} . $validate->{out};
$err =~ s/\s+$//;
_fail("nginx -t failed: $err");
$results{nginx_reloaded} = 0;
my $tail = length($err) > 500 ? substr($err, -500) : $err;
_fail("caddy validate failed: $tail");
$results{caddy_reloaded} = 0;
}
else {
my $reload_result = run(['systemctl', 'reload', 'nginx'], timeout => 30);
my $reload_result = run(['systemctl', 'reload', 'caddy'], timeout => 30);
if ($reload_result->{rc} == 0) {
_status_done('reloaded');
$results{nginx_reloaded} = 1;
$results{caddy_reloaded} = 1;
}
else {
_status_done('failed');
my $err = $reload_result->{err};
$err =~ s/\s+$//;
_fail("nginx reload failed: $err");
$results{nginx_reloaded} = 0;
_fail("caddy reload failed: $err");
$results{caddy_reloaded} = 0;
}
}
}
}
@@ -1885,15 +2216,50 @@ sub uninstall {
$results{env_not_found} = 1;
}
# Remove the nginx config.
my $nginx_conf = nginx_conf_path($service_name);
_status("Removing nginx $service_name configuration");
if (-f $nginx_conf) {
# Remove the caddy drop-in. The main Caddyfile stays: it may carry sites
# this deployment knows nothing about, and the import line is inert once
# the drop-in is gone.
my $caddy_conf = caddyfile_path($service_name);
_status("Removing the caddy $service_name drop-in");
if (-f $caddy_conf) {
if ($dry_run) {
_status_done('dry run');
}
else {
unlink($nginx_conf);
unlink($caddy_conf);
if (systemctl_is_active('caddy')) {
my $reload_result = run(['systemctl', 'reload', 'caddy'], timeout => 30);
if ($reload_result->{rc} != 0) {
_status_done('removed (caddy reload failed)');
my $err = $reload_result->{err};
$err =~ s/\s+$//;
_warn("caddy reload failed: $err");
}
else {
_status_done('removed and caddy reloaded');
}
}
else {
_status_done('removed (caddy not running)');
}
$results{caddy_removed} = 1;
}
}
else {
_status_done('not present');
$results{caddy_not_found} = 1;
}
# Remove the drop-in the nginx release wrote, when one is left over.
my $legacy_conf = "$LEGACY_NGINX_DIR/$service_name.conf";
_status('Removing the legacy nginx configuration');
if (-f $legacy_conf) {
if ($dry_run) {
_status_done('dry run');
}
else {
unlink($legacy_conf);
if (systemctl_is_active('nginx')) {
my $reload_result = run(['systemctl', 'reload', 'nginx'], timeout => 30);
if ($reload_result->{rc} != 0) {
_status_done('removed (nginx reload failed)');
@@ -1904,12 +2270,15 @@ sub uninstall {
else {
_status_done('removed and nginx reloaded');
}
$results{nginx_removed} = 1;
}
else {
_status_done('removed (nginx not running)');
}
$results{legacy_nginx_removed} = 1;
}
}
else {
_status_done('not present');
$results{nginx_not_found} = 1;
}
# Remove the TLS certificates.
@@ -1939,7 +2308,9 @@ sub uninstall {
_info('Kept on the system (remove manually if unwanted):');
_info(" the engine image (podman rmi <image>)");
_info(" $state_dir (ModelScope cache with downloaded model weights)");
_info(" firewalld 'https' rule and the SELinux httpd_can_network_connect boolean");
_info(' the caddy service and /etc/caddy');
_info(" the firewalld 'https' rule (and the SELinux boolean, where a confined "
. 'caddy policy needed it)');
return \%results;
}
@@ -1966,7 +2337,8 @@ sub print_summary {
['unit_removed', 'systemd unit removed'],
['container_removed', 'engine container removed'],
['env_removed', 'API key environment file removed'],
['nginx_removed', 'nginx config removed'],
['caddy_removed', 'caddy drop-in removed'],
['legacy_nginx_removed', 'legacy nginx configuration removed'],
['certs_removed', 'TLS certificates removed'],
) {
my ($key, $label) = @$pair;
@@ -1981,7 +2353,7 @@ sub print_summary {
['unit_not_found', 'systemd unit: already absent'],
['container_not_found', 'engine container: already absent'],
['env_not_found', 'API key environment file: already absent'],
['nginx_not_found', 'nginx config: already absent'],
['caddy_not_found', 'caddy drop-in: already absent'],
['certs_not_found', 'TLS certs: already absent'],
) {
my ($key, $label) = @$pair;
@@ -2096,17 +2468,28 @@ sub print_summary {
elsif ($se && $se eq 'absent') {
_info('SELinux: not installed (skipped)');
}
elsif ($se && $se eq 'unconfined') {
_info('SELinux: caddy runs unconfined (nothing to do)');
}
my $ng = $results->{nginx} // {};
if ($ng->{nginx_configured} && $ng->{nginx_configured} eq 1
&& $ng->{nginx_reloaded} && $ng->{nginx_reloaded} eq 1) {
_ok('nginx: configured and reloaded');
my $cd = $results->{caddy} // {};
if (defined $cd->{legacy_nginx_removed}) {
if ($cd->{legacy_nginx_removed} eq 1) {
_ok('Legacy nginx configuration: removed');
}
elsif ($ng->{nginx_configured} && $ng->{nginx_configured} eq 1) {
_fail('nginx: config written but reload failed');
else {
_info('Legacy nginx configuration: would be removed');
}
elsif (($ng->{nginx_configured} // '') eq 'dry run') {
_info('nginx: would write config and reload');
}
if ($cd->{caddy_configured} && $cd->{caddy_configured} eq 1
&& $cd->{caddy_reloaded} && $cd->{caddy_reloaded} eq 1) {
_ok('Caddy: configured and reloaded');
}
elsif ($cd->{caddy_configured} && $cd->{caddy_configured} eq 1) {
_fail('Caddy: drop-in written but reload failed');
}
elsif (($cd->{caddy_configured} // '') eq 'dry run') {
_info('Caddy: would write the drop-in and reload');
}
my $svc = $results->{systemd} // {};
@@ -2178,7 +2561,7 @@ Usage: sglang-deploy.pl [options]
--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
--uninstall tear down the service, container, caddy drop-in
and certificates
--help show this help
--version show the version
@@ -2340,9 +2723,9 @@ sub is_positive_int {
return defined $value && $value =~ /^\d+$/ && $value + 0 > 0;
}
# A directory the generated nginx configuration and the unit file carry
# verbatim: absolute, and free of the whitespace that splits arguments, of the
# % systemd expands as a specifier and of the ; that ends an nginx directive.
# A directory the generated Caddyfile drop-in and the unit file carry verbatim:
# absolute, and free of the whitespace that splits arguments and of the %
# systemd expands as a specifier.
sub valid_dir_path {
my ($path) = @_;
return 0 unless defined $path && length $path;
@@ -2387,7 +2770,7 @@ sub validate_args {
if (!is_positive_int($args->{port}) || $args->{port} + 0 > 65535
|| $args->{port} + 0 == 443) {
_fail("Invalid --port $args->{port}: must be an integer 1-65535 and not 443 "
. "(nginx)");
. '(Caddy serves 443)');
exit 1;
}
if (!is_number($args->{gpu_memory_utilization})
@@ -2469,7 +2852,7 @@ sub main {
# ── Deploy path ──
# 1. System dependencies (before preflight: provides lspci, curl and podman).
print STDERR "\n${BOLD}── System Dependencies ──${RESET}\n";
$results{system_deps} = install_system_deps($args->{dry_run});
$results{system_deps} = install_system_deps($os_id, $args->{dry_run});
my @failed_pkgs = @{ $results{system_deps}{failed} // [] };
push @failures, 'failed to install packages: ' . join(', ', @failed_pkgs) if @failed_pkgs;
@@ -2501,7 +2884,7 @@ sub main {
exit 1;
}
# 5. TLS certificate (fatal, nginx cannot start without it).
# 5. TLS certificate (fatal, caddy cannot start without it).
print STDERR "\n${BOLD}── TLS Certificate ──${RESET}\n";
$results{tls} = setup_tls($args->{cert_dir}, $args->{dry_run});
if (defined $results{tls}{cert_created} && $results{tls}{cert_created} eq 0) {
@@ -2524,13 +2907,13 @@ sub main {
push @failures, 'SELinux boolean httpd_can_network_connect not set';
}
# 8. nginx.
print STDERR "\n${BOLD}── nginx ──${RESET}\n";
$results{nginx} = setup_nginx(
# 8. Caddy.
print STDERR "\n${BOLD}── Caddy ──${RESET}\n";
$results{caddy} = setup_caddy(
$args->{port}, $upstream_host, $args->{cert_dir}, $args->{service_name}, $args->{dry_run},
);
if (defined $results{nginx}{nginx_reloaded} && $results{nginx}{nginx_reloaded} eq 0) {
push @failures, 'nginx configuration reload failed';
if (defined $results{caddy}{caddy_reloaded} && $results{caddy}{caddy_reloaded} eq 0) {
push @failures, 'caddy configuration reload failed';
}
# 9. systemd service (the model is probed before the unit is written).
+188 -24
View File
@@ -16,7 +16,11 @@ 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 $CADDY = '/etc/caddy/Caddyfile.d/sglang.caddyfile';
my $CADDY_MAIN = '/etc/caddy/Caddyfile';
my $CADDY_UNIT = '/etc/systemd/system/caddy.service';
my $CADDY_BIN = '/usr/local/bin/caddy';
my $NGINX_LEGACY = '/etc/nginx/conf.d/sglang.conf';
my $CERTDIR = '/etc/ssl/sglang';
my $STATEDIR = '/opt/sglang';
@@ -105,22 +109,27 @@ sub mode_of {
}
sub reset_fixture {
for my $path ($UNIT, $ENVFILE, $NGINX) {
for my $path ($UNIT, $ENVFILE, $CADDY, $CADDY_MAIN, $CADDY_UNIT, $CADDY_BIN,
$NGINX_LEGACY) {
unlink($path);
}
remove_tree('/etc/caddy');
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.
# Directories a host that ran server-setup.pl has: the legacy nginx drop-in
# directory an earlier release wrote into, 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.
# Packages a real host or a previous run has already installed. nginx stands
# for the drop-in the previous release left behind.
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.
# The handle is declared first: a my inside the open's argument list does not
# reach the right-hand operand of the and on this interpreter.
my $fw;
open($fw, '>', "$ST/active.firewalld") and close($fw);
return;
@@ -184,8 +193,8 @@ sub reset_log {
# 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)) {
for my $name (qw(lspci rpm dnf podman systemctl curl openssl caddy tar useradd
semodule 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
@@ -197,6 +206,21 @@ sub prepare_stubs {
return;
}
# The caddy package creates its group; the rig creates it the same way, so the
# key permission the package makes possible is exercised for real. The group
# file is edited directly: the minimal openEuler image carries no groupadd to
# call, and a group entry is all the getgrnam in the script needs.
sub prepare_group {
return if defined getgrnam('caddy');
my $existing = slurp_file('/etc/group');
my $gid = 995;
$gid++ while $existing =~ /^[^:]+:[^:]*:\Q$gid\E:/m;
open(my $fh, '>>', '/etc/group') or die "cannot append to /etc/group: $!\n";
print {$fh} "caddy:x:$gid:\n";
close($fh);
return;
}
sub prepare_devices {
# The driver creates these on a real host; the rig fakes them (needs --privileged).
return if -e q{/dev/kfd};
@@ -214,12 +238,15 @@ sub scenario_fresh {
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 $CADDY, 'fresh: caddy drop-in written');
check(-f $CADDY_MAIN, 'fresh: main Caddyfile 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.key") eq '0640', 'fresh: key is 0640 for the caddy group (' . mode_of("$CERTDIR/sglang.key") . ')');
check(mode_of("$CERTDIR/sglang.crt") eq '0644', 'fresh: certificate is 0644');
check((stat("$CERTDIR/sglang.key"))[5] == getgrnam('caddy'),
'fresh: the key belongs to the caddy group');
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');
@@ -231,19 +258,29 @@ sub scenario_fresh {
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');
my $caddy = slurp_file($CADDY);
check_like($caddy, qr/reverse_proxy \[::1\]:8000 \{/, 'fresh: caddy proxies to the loopback engine');
check_like($caddy, qr|tls /etc/ssl/sglang/sglang\.crt /etc/ssl/sglang/sglang\.key|,
'fresh: caddy uses the sglang certificate pair');
check_like($caddy, qr/flush_interval -1/, 'fresh: streaming is unbuffered');
my $main = slurp_file($CADDY_MAIN);
check_like($main, qr/^import Caddyfile\.d\/\*\.caddyfile$/m, 'fresh: the main Caddyfile imports the drop-ins');
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_like(stub_log(), qr/^caddy version$/m, 'fresh: caddy is reported from the binary');
check_like(stub_log(), qr/^caddy validate --config \/etc\/caddy\/Caddyfile$/m,
'fresh: the configuration is validated before the reload');
check_like(stub_log(), qr/^systemctl enable --now caddy$/m, 'fresh: caddy enabled and started');
check_like(stub_log(), qr/^systemctl reload caddy$/m, 'fresh: caddy reloaded');
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/Caddy: configured and reloaded/, 'fresh: summary reports caddy');
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;
@@ -261,6 +298,7 @@ sub scenario_rerun {
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/^systemctl enable --now caddy$/) == 0, 'rerun: no second caddy enable');
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');
@@ -282,7 +320,8 @@ sub scenario_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(!-f $CADDY, 'dry run: no caddy drop-in written');
check(!-e '/etc/caddy', 'dry run: no caddy directory created');
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');
@@ -302,14 +341,17 @@ sub scenario_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(!-f $CADDY, 'uninstall: caddy drop-in removed');
check(-f $CADDY_MAIN, 'uninstall: the main Caddyfile is kept');
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(stub_log(), qr/^systemctl reload caddy$/m, 'uninstall: caddy reloaded after the drop-in');
check_like($out, qr/SGLang service stopped/, 'uninstall: summary reports the stop');
check_like($out, qr/caddy drop-in removed/, 'uninstall: summary reports the drop-in');
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;
@@ -322,6 +364,78 @@ sub scenario_uninstall_twice {
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');
check(count_in_log(qr/^systemctl reload caddy$/) == 0,
'uninstall twice: no reload without a drop-in');
return;
}
# A host deployed by the nginx release carries its drop-in. Both a deploy and
# an uninstall remove it, so exactly one proxy owns :443 afterwards.
sub scenario_legacy_nginx {
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");
open(my $fh, '>', $NGINX_LEGACY) or die "cannot write $NGINX_LEGACY: $!\n";
print {$fh} "server {\n listen 443 ssl;\n}\n";
close($fh);
# The stub state: nginx is running on this host, so the removal reloads it.
my $st;
open($st, '>', "$ST/active.nginx") and close($st);
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 0, 'legacy nginx: exit 0');
check(!-f $NGINX_LEGACY, 'legacy nginx: the old drop-in is gone on deploy');
check_like(stub_log(), qr/^systemctl reload nginx$/m,
'legacy nginx: the running nginx is reloaded');
check_like($out, qr/Legacy nginx configuration: removed/, 'legacy nginx: the summary names it');
# An uninstall on a host the new release never deployed cleans it too.
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");
open($fh, '>', $NGINX_LEGACY) or die "cannot write $NGINX_LEGACY: $!\n";
print {$fh} "server {\n listen 443 ssl;\n}\n";
close($fh);
reset_log();
($rc, $out) = run_script('--uninstall');
check($rc == 0, 'legacy nginx: uninstall exit 0');
check(!-f $NGINX_LEGACY, 'legacy nginx: the old drop-in is gone on uninstall');
check_like($out, qr/legacy nginx configuration removed/, 'legacy nginx: the uninstall summary names it');
return;
}
# Where no repository carries the caddy package, the official release binary
# takes its place: download, extract, install, the service user, and the unit
# file the package would have carried.
sub scenario_caddy_binary {
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('caddy-no-package', "1\n");
unlink('/usr/bin/caddy'); # order independence: no package binary, no stub
unlink("$BIN/caddy") or die "cannot remove the caddy stub: $!\n";
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check($rc == 0, 'caddy binary: exit 0');
check_like(stub_log(), qr/^dnf install -y caddy$/m, 'caddy binary: the package install was attempted');
check_like(stub_log(), qr{^curl -fsSL -o \S+ https://github\.com/caddyserver/caddy/releases/download/v2\.10\.2/caddy_2\.10\.2_linux_amd64\.tar\.gz$}m,
'caddy binary: the release asset is downloaded');
check_like(stub_log(), qr/^tar -xzf \S+ -C \S+$/m, 'caddy binary: the archive is extracted');
check(-x $CADDY_BIN, 'caddy binary: the binary is installed executable');
check_like(stub_log(), qr/^useradd --system --home-dir \/var\/lib\/caddy --create-home --shell \/sbin\/nologin caddy$/m,
'caddy binary: the service user is created');
check(-f $CADDY_UNIT, 'caddy binary: the unit file is written');
my $unit = slurp_file($CADDY_UNIT);
check_like($unit, qr|ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile|,
'caddy binary: the unit runs the release binary');
check_like(stub_log(), qr/^systemctl daemon-reload$/m, 'caddy binary: systemd reloaded');
check_like(stub_log(), qr{^caddy validate --config /etc/caddy/Caddyfile$}m,
'caddy binary: the installed binary validates');
check_like($out, qr/Caddy: configured and reloaded/, 'caddy binary: the deployment completes');
unlink($CADDY_BIN);
unlink($CADDY_UNIT);
unlink("$FIX/caddy-no-package");
symlink("$RIG/stub.pl", "$BIN/caddy") or die "cannot restore the caddy stub: $!\n";
return;
}
@@ -495,7 +609,7 @@ sub scenario_validation {
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');
check_like($out, qr/must be an integer 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');
@@ -545,7 +659,7 @@ sub scenario_non_root {
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');
check_like($out, qr/^sglang-deploy\.pl 2\.1\.0$/, 'non-root: the version is printed');
my $pid3 = fork();
die "cannot fork: $!\n" unless defined $pid3;
@@ -583,12 +697,12 @@ 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");
write_fixture('rpm-installed', "pciutils\ncurl\nopenssl\npodman\ntar\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');
check_like($out, qr/All 5 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");
@@ -596,10 +710,41 @@ sub scenario_dependency_section {
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');
check_like(stub_log(), qr/^dnf install -y pciutils curl openssl podman tar$/m,
'deps: the five packages are installed in one transaction');
check_like(stub_log(), qr/^caddy version$/m, 'deps: caddy is checked after the transaction');
check_unlike($out, qr/run server-setup\.pl first/,
'deps: no warning points at server-setup.pl, caddy is installed here');
return;
}
# CentOS Stream carries caddy in EPEL, and the repository file installs first,
# which is what makes the package transaction below it resolvable.
sub scenario_epel {
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 $real = slurp_file('/etc/os-release');
open(my $fh, '>', '/etc/os-release') or die "cannot write /etc/os-release: $!\n";
print {$fh} "ID=centos\nVERSION_ID=\"10\"\n";
close($fh);
unlink('/usr/bin/caddy'); # order independence: no binary, no stub
unlink("$BIN/caddy");
reset_log();
my ($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
open(my $back, '>', '/etc/os-release') or die "cannot restore /etc/os-release: $!\n";
print {$back} $real;
close($back);
check($rc == 0, 'epel: exit 0');
check_like(stub_log(), qr/^dnf install -y epel-release$/m, 'epel: the repository file installs first');
check_like(stub_log(), qr/^dnf install -y caddy$/m, 'epel: the package installs after it');
check_like($out, qr/Installing caddy\.\.\. package/, 'epel: the package path is taken');
check_like($out, qr/Caddy: configured and reloaded/, 'epel: the deployment completes');
unlink('/usr/bin/caddy');
unlink("$FIX/caddy-no-package");
symlink("$RIG/stub.pl", "$BIN/caddy") or die "cannot restore the caddy stub: $!\n";
return;
}
@@ -619,7 +764,7 @@ sub scenario_custom_layout {
);
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');
check(-f '/etc/caddy/Caddyfile.d/llm.caddyfile', 'custom layout: the named caddy drop-in 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');
@@ -632,7 +777,7 @@ sub scenario_custom_layout {
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');
unlink('/etc/caddy/Caddyfile.d/llm.caddyfile');
remove_tree('/etc/ssl/llm');
remove_tree('/srv/llm');
return;
@@ -674,17 +819,32 @@ sub scenario_selinux {
check(count_in_log(qr/^setsebool /) == 0, 'selinux: nothing set while permissive');
check_like($out, qr/SELinux: permissive \(skipped\)/, 'selinux: reported as skipped');
# Enforcing with no confined caddy policy: the distributions run caddy
# unconfined, so no boolean is touched.
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(count_in_log(qr/^setsebool /) == 0, 'selinux: no boolean without a confined policy');
check_like($out, qr/SELinux: caddy runs unconfined \(nothing to do\)/,
'selinux: the unconfined case is stated');
# Enforcing with a confined caddy policy loaded: the boolean is set.
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('semodule-caddy', "1\n");
reset_log();
($rc, $out) = run_script('--model', 'ZhipuAI/GLM-5.3');
check_like(stub_log(), qr/^semodule -l$/m, 'selinux: the loaded modules are asked for');
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');
unlink("$FIX/semodule-caddy");
delete $ENV{STUB_SELINUX};
return;
}
@@ -695,6 +855,9 @@ my %scenarios = (
dry_run => \&scenario_dry_run,
uninstall => \&scenario_uninstall,
uninstall_twice => \&scenario_uninstall_twice,
legacy_nginx => \&scenario_legacy_nginx,
caddy_binary => \&scenario_caddy_binary,
epel => \&scenario_epel,
radeon => \&scenario_radeon,
radeon_dev => \&scenario_radeon_dev,
radeon_with_image => \&scenario_radeon_with_image,
@@ -715,6 +878,7 @@ my %scenarios = (
);
prepare_stubs();
prepare_group();
prepare_devices();
my @wanted = @ARGV ? @ARGV : sort keys %scenarios;
Regular → Executable
+71 -5
View File
@@ -74,6 +74,12 @@ if ($name eq 'rpm') {
if ($name eq 'dnf') {
my @pkgs = grep { !/^-/ && $_ ne 'install' } @args;
# Parenthesised on purpose: a named list operator swallows a trailing &&,
# and the unparenthesised form asks the grep about a boolean, not the list.
if ((grep { $_ eq 'caddy' } @pkgs) && -f "$FIX/caddy-no-package") {
print STDERR "Error: Unable to find a match: caddy\n";
exit 1;
}
my $installed = fixture_text('rpm-installed', '');
for my $pkg (@pkgs) {
$installed .= "$pkg\n" unless $installed =~ /^\Q$pkg\E$/m;
@@ -82,6 +88,12 @@ if ($name eq 'dnf') {
print {$fh} $installed;
close($fh);
}
# A package transaction that installs caddy leaves the binary where PATH
# finds it, the way the real package does. The grep is parenthesised as
# above: a named list operator swallows a trailing &&.
if ((grep { $_ eq 'caddy' } @pkgs) && !-e '/usr/bin/caddy') {
symlink($0, '/usr/bin/caddy');
}
print "Installing: @pkgs\n";
exit 0;
}
@@ -138,14 +150,32 @@ if ($name eq 'curl') {
print "\n__HTTP__$code\n" if $joined =~ /__HTTP__/;
exit 0;
}
if ($url =~ m{api\.github\.com/repos/caddyserver/caddy}) {
print fixture_text('caddy-release.json', qq({"tag_name":"v2.10.2"}\n));
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{github\.com/caddyserver/caddy/releases/download/}) {
my $dest;
for my $i (0 .. $#args) {
$dest = $args[$i + 1] if $args[$i] eq '-o';
}
if (defined $dest) {
open(my $fh, '>', $dest) or exit 1;
print {$fh} "stub caddy release archive\n";
close($fh);
}
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;
# A definitive 404 on a tag lookup is what the script reads as
# unpublished; a curl-level failure would read as cannot-tell.
if ($url =~ m{/tags/[A-Za-z0-9._-]+$} && -f "$FIX/image-missing") {
print "404";
exit 0;
}
if ($url =~ /tags\?/) {
# A tag listing, newest first: the rig's AMD development build.
@@ -181,8 +211,44 @@ if ($name eq 'openssl') {
exit 0;
}
if ($name eq 'nginx') {
print "nginx: configuration file /etc/nginx/nginx.conf test is successful\n";
if ($name eq 'caddy') {
my $joined = join(' ', @args);
if ($joined =~ /version/) {
print "v2.10.2 h1:stub\n";
exit 0;
}
if ($joined =~ /validate/) {
print "Valid configuration\n";
exit 0;
}
exit 0;
}
if ($name eq 'tar') {
# The release-binary install extracts the archive and installs the binary it
# names; the stub lays down a link to this dispatcher, so the installed
# stand-in answers and logs like every other stubbed command.
my $dest_dir;
for my $i (0 .. $#args) {
$dest_dir = $args[$i + 1] if $args[$i] eq '-C';
}
if (defined $dest_dir) {
unlink("$dest_dir/caddy");
symlink($0, "$dest_dir/caddy");
}
exit 0;
}
if ($name eq 'useradd') {
exit 0;
}
if ($name eq 'semodule') {
# A loaded module line looks like "100 caddy\tpp"; the fixture decides
# whether this host carries a confined caddy policy.
if (-f "$FIX/semodule-caddy") {
print "100 caddy\tpp\n";
}
exit 0;
}
+37 -16
View File
@@ -138,7 +138,7 @@ is((valid_dir_path('/opt/sg lang') ? 1 : 0), 0, 'valid_dir_path: whitespace is r
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');
'valid_dir_path: a path with a semicolon is refused');
# ---- run(): exit status, signals and timeout ------------------------------
my $killed = run([$^X, '-e', 'kill 9, $$']);
@@ -164,22 +164,43 @@ 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');
# ---- caddy drop-in --------------------------------------------------------
my $conf = caddyfile_content(8000, '[::1]', '/etc/ssl/sglang');
like($conf, qr/^:443 \{$/m, 'caddy: the endpoint site on 443');
like($conf, qr|tls /etc/ssl/sglang/sglang\.crt /etc/ssl/sglang/sglang\.key|,
'caddy: certificate pair paths');
like($conf, qr/reverse_proxy \[::1\]:8000 \{/, 'caddy: loopback upstream with the port');
like($conf, qr/flush_interval -1/, 'caddy: unbuffered streaming');
like($conf, qr/header_up X-Real-IP \{remote_host\}/, 'caddy: X-Real-IP survives the heredoc');
like($conf, qr/max_size 50MB/, 'caddy: the request body limit');
like($conf, qr/\treverse_proxy /, 'caddy: tab-indented as the Caddyfile is formatted');
check_unlike($conf, qr/\bbind\b/, 'caddy: no bind directive, the kernel decides the families');
check_unlike($conf, qr/acme|on_demand|http:\/\/\{/i, 'caddy: no ACME, the self-signed pair is used');
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');
my $conf4 = caddyfile_content(9000, '127.0.0.1', '/etc/ssl/llm');
like($conf4, qr/reverse_proxy 127\.0\.0\.1:9000 \{/, 'caddy: an IPv4 upstream carries the port');
like($conf4, qr|tls /etc/ssl/llm/sglang\.crt|, 'caddy: the certificate directory follows --cert-dir');
is(caddyfile_path('sglang'), '/etc/caddy/Caddyfile.d/sglang.caddyfile',
'caddy: the drop-in path follows the service name');
is(caddy_main_config(), '/etc/caddy/Caddyfile', 'caddy: the main Caddyfile path');
# ---- caddy release binary -------------------------------------------------
is(uname_to_arch('x86_64'), 'amd64', 'caddy binary: x86_64 maps to amd64');
is(uname_to_arch('aarch64'), 'arm64', 'caddy binary: aarch64 maps to arm64');
is(uname_to_arch('ppc64le'), undef, 'caddy binary: an unmapped machine is refused');
is(caddy_asset_url('2.10.2', 'amd64'),
'https://github.com/caddyserver/caddy/releases/download/v2.10.2/caddy_2.10.2_linux_amd64.tar.gz',
'caddy binary: the release asset URL');
my $caddy_unit = caddy_unit_content();
like($caddy_unit, qr|ExecStartPre=/usr/local/bin/caddy validate --config /etc/caddy/Caddyfile|,
'caddy binary: the unit validates before it starts');
like($caddy_unit, qr|ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile|,
'caddy binary: the unit runs the release binary');
like($caddy_unit, qr|ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile|,
'caddy binary: the unit reloads through the admin endpoint');
like($caddy_unit, qr/^User=caddy$/m, 'caddy binary: the unit runs as the caddy user');
like($caddy_unit, qr/AmbientCapabilities=CAP_NET_BIND_SERVICE/, 'caddy binary: the port capability');
# ---- systemd unit --------------------------------------------------------
my $unit = systemd_content({