feat(sglang-deploy): replace nginx with caddy
Assisted-by: GLM 5.3 Flash
This commit is contained in:
+524
-141
@@ -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) {
|
||||
_status_done('config test failed');
|
||||
my $err = $test_result->{err};
|
||||
$err =~ s/\s+$//;
|
||||
_fail("nginx -t failed: $err");
|
||||
$results{nginx_reloaded} = 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 $reload_result = run(['systemctl', 'reload', 'nginx'], timeout => 30);
|
||||
if ($reload_result->{rc} == 0) {
|
||||
_status_done('reloaded');
|
||||
$results{nginx_reloaded} = 1;
|
||||
my $validate = run([$caddy, 'validate', '--config', caddy_main_config()],
|
||||
timeout => 60);
|
||||
if ($validate->{rc} != 0) {
|
||||
_status_done('config test failed');
|
||||
my $err = $validate->{err} . $validate->{out};
|
||||
$err =~ s/\s+$//;
|
||||
my $tail = length($err) > 500 ? substr($err, -500) : $err;
|
||||
_fail("caddy validate failed: $tail");
|
||||
$results{caddy_reloaded} = 0;
|
||||
}
|
||||
else {
|
||||
_status_done('failed');
|
||||
my $err = $reload_result->{err};
|
||||
$err =~ s/\s+$//;
|
||||
_fail("nginx reload failed: $err");
|
||||
$results{nginx_reloaded} = 0;
|
||||
my $reload_result = run(['systemctl', 'reload', 'caddy'], timeout => 30);
|
||||
if ($reload_result->{rc} == 0) {
|
||||
_status_done('reloaded');
|
||||
$results{caddy_reloaded} = 1;
|
||||
}
|
||||
else {
|
||||
_status_done('failed');
|
||||
my $err = $reload_result->{err};
|
||||
$err =~ s/\s+$//;
|
||||
_fail("caddy reload failed: $err");
|
||||
$results{caddy_reloaded} = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1885,31 +2216,69 @@ 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);
|
||||
my $reload_result = run(['systemctl', 'reload', 'nginx'], timeout => 30);
|
||||
if ($reload_result->{rc} != 0) {
|
||||
_status_done('removed (nginx reload failed)');
|
||||
my $err = $reload_result->{err};
|
||||
$err =~ s/\s+$//;
|
||||
_warn("nginx reload failed: $err");
|
||||
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 and nginx reloaded');
|
||||
_status_done('removed (caddy not running)');
|
||||
}
|
||||
$results{nginx_removed} = 1;
|
||||
$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)');
|
||||
my $err = $reload_result->{err};
|
||||
$err =~ s/\s+$//;
|
||||
_warn("nginx reload failed: $err");
|
||||
}
|
||||
else {
|
||||
_status_done('removed and nginx reloaded');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
else {
|
||||
_info('Legacy nginx configuration: would be removed');
|
||||
}
|
||||
}
|
||||
elsif ($ng->{nginx_configured} && $ng->{nginx_configured} eq 1) {
|
||||
_fail('nginx: config written but reload failed');
|
||||
if ($cd->{caddy_configured} && $cd->{caddy_configured} eq 1
|
||||
&& $cd->{caddy_reloaded} && $cd->{caddy_reloaded} eq 1) {
|
||||
_ok('Caddy: configured and reloaded');
|
||||
}
|
||||
elsif (($ng->{nginx_configured} // '') eq 'dry run') {
|
||||
_info('nginx: would write config and reload');
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user