# coding:utf-8
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2026 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT
"""Root-side orchestration of the install-time delivery.
Design: docs/design/nodejs-bun-delivery.md.
Same shape as the pnpm delivery hook: `cloudlinux-selector install-modules
--user ` is invoked by the panel as root and would normally re-exec the
whole selector inside the account's CageFS. The delivery replaces that
single descent with three steps driven from the root side that is already
there:
1. cagefs_enter_user -> bun_user_phase resolve (user, no package code)
2. root -> cloudlinux-bun-delivery (stage as root; rebuild
through CageFS as user;
commit or roll back)
There is no user->root bridge: inside a cage there is not even a sudo
binary. A qualifying enabled request reports a failed delivery if any phase
fails; it does not silently proceed with the ordinary npm path.
"""
import os
import pwd
import re
import subprocess
import errno
import fcntl
import stat
from contextlib import contextmanager
from clselect.utils import _terminate_process_group, run_process_in_cagefs
from clselect import clselectctlnodejsuser as nodejs_user
from . import node_modules_storage
CONFIG_PATH = node_modules_storage.CONFIG_PATH
BUN_BIN = '/opt/cl-bun/bun'
USER_PHASE_BIN = '/usr/share/l.v.e-manager/utils/bun_user_phase'
DELIVERY_CLI = '/usr/share/l.v.e-manager/utils/cloudlinux-bun-delivery.py'
SELECTOR_BIN = '/usr/sbin/cloudlinux-selector'
RESTART_TIMEOUT = 120
USER_PHASE_TIMEOUT = 900
_ASCII_DIGITS = re.compile(r'^[0-9]{1,3}$')
# One exit code for every input this engine cannot handle, all of which
# leave the account on classic npm. The sentinel, not the code, says which.
SHRINKWRAP_UNSUPPORTED_RC = 3
SHRINKWRAP_UNSUPPORTED_SENTINEL = 'cl-bun:unsupported-npm-shrinkwrap'
UNSUPPORTED_MANIFEST_RC = 3
UNSUPPORTED_MANIFEST_SENTINEL = 'cl-bun:unsupported-manifest'
# The fields the account phase and the root validator both refuse. The name
# comes back on the sentinel line, and only these are repeated to the panel.
UNSUPPORTED_MANIFEST_FIELDS = ('patchedDependencies', 'workspaces', 'symlink')
LEGACY_PACKAGE_LOCK_RC = 3
LEGACY_PACKAGE_LOCK_SENTINEL = 'cl-bun:legacy-package-lock-v1'
# Bun writes no lockfile for a manifest that resolves to zero packages, so
# there is nothing to deliver and nothing for classic npm to get wrong.
NO_DEPENDENCIES_RC = 3
NO_DEPENDENCIES_SENTINEL = 'cl-bun:no-dependencies'
CLASSIC_NPM_FALLBACK = object()
CAPABILITY_UNAVAILABLE = ('Shared node_modules Store unavailable: cannot '
'determine Node.js Selector support')
class _UserPhaseResult(object):
def __init__(self, failed, returncode, output):
self.failed = failed
self.returncode = returncode
self.output = output
def __bool__(self):
return not self.failed
__nonzero__ = __bool__
def is_enabled(config_path=CONFIG_PATH):
"""The global administrator switch fails closed on bad configuration."""
config = node_modules_storage.read_config(config_path)
return (config['enabled']
and node_modules_storage.nodejs_selector_supported())
def is_deliverable(opts, interpreter, nodejs_interpreter, config_path=CONFIG_PATH):
"""Only a bare `install-modules` for a Node.js application qualifies.
Named modules and requirements files change package.json first, which
is the plain npm path's job. An account outside the administrator's
include/exclude lists takes that path too -- a policy narrowing, not a
failure, so it is silent by design where a failed delivery is loud.
"""
if interpreter != nodejs_interpreter:
return False
if not opts.get('install-modules'):
return False
if opts.get('--modules') or opts.get('--requirements-file'):
return False
user = opts.get('--user')
# Only an explicit exclusion narrows here. `is_user_included` also fails
# closed on an unreadable exclusion authority, which would silently route
# the whole fleet to classic npm; that case has to reach the root
# transaction, which refuses it loudly.
if user and user in (node_modules_storage.read_config(
config_path).get('excluded_users') or []):
return False
if not opts.get('--user') or not opts.get('--app-root'):
return False
return True
def recorded_node_major(user, app_root):
"""The Node.js major the selector recorded for this application, or
None when there is no usable record.
This is the authority on which `~/nodevenv//` an install
belongs to. A version change creates the new venv beside the old one
and never removes the old (`selectorlib._change_version`), so a
directory listing has two answers for any application whose version was
ever changed; the record has one. The selector already reads this file
as root through `secureio`, so nothing new crosses the boundary here.
Imported lazily: the storage module is imported inside every cage on
each `npm install`, and the apps manager pulls in the panel API.
"""
from clselect.clselectnodejs.apps_manager import ApplicationsManager
try:
config = ApplicationsManager().get_app_config(user, app_root)
except Exception: # noqa: BLE001 - a broken record is "no record"
return None
if not isinstance(config, dict):
return None
version = config.get('nodejs_version')
if not isinstance(version, str):
return None
# One to three ASCII digits: the record sits in the account's home,
# str.isdigit() also accepts '²' (on which int() raises) and int() itself
# refuses more than 4300 digits.
major = version.split('.')[0]
return int(major) if _ASCII_DIGITS.match(major) else None
def _user_phase(user, phase, app_root, major=None):
"""Run one account-side phase in the cage. `major` names the venv when
the selector has a record; npm_wrapper's path carries the same fact in
`CL_VIRTUAL_ENV`, which this path does not have."""
try:
home = pwd.getpwnam(user).pw_dir
except KeyError:
return _UserPhaseResult(True, None, '')
args = [phase, app_root]
if major is not None:
args.append(str(major))
result = run_process_in_cagefs(
user, USER_PHASE_BIN, args, env_vars={'HOME': home},
timeout=USER_PHASE_TIMEOUT)
return _UserPhaseResult(result['failed'], result.get('returncode'),
result.get('output', ''))
# The delivery CLI's exit code for a registry the administrator has not
# allowed; the message naming the registry is the last stdout line.
REGISTRY_REFUSED_RC = 4
UNSUPPORTED_SOURCE_RC = 5
REBUILD_FAILED_RC = 6
def _root_phase(user, app_root, major=None, lock_fd=None):
"""(returncode, last stdout line) of the privileged delivery, or
(None, '') when the subprocess could not run at all."""
try:
command = [DELIVERY_CLI, 'deliver', '--user', user, '--app-root', app_root]
if major is not None:
command.extend(['--node-major', str(major)])
if lock_fd is not None:
command.extend(['--lock-fd', str(lock_fd)])
# Bun and rebuild own their separate process groups and timeouts.
# An outer timeout here could kill only this launcher and orphan them.
proc = subprocess.run(
command,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
pass_fds=() if lock_fd is None else (lock_fd,))
except (OSError, subprocess.SubprocessError):
return None, ''
lines = proc.stdout.decode('utf-8', 'replace').strip().splitlines()
return proc.returncode, lines[-1] if lines else ''
def _restart_application(user, app_root, skip_web_check=False,
health_baseline=None, post_action_observations=1,
post_action_interval=0):
"""Run the same restart and pre/post health check as npm install."""
restart_attempted = False
def restart():
nonlocal restart_attempted
restart_attempted = True
if not _selector_restart(user, app_root):
raise RuntimeError('selector restart failed')
return True
if skip_web_check:
return _selector_restart(user, app_root)
try:
apps = nodejs_user.ApplicationsManager()
config = nodejs_user.utils.get_using_realpath_keys(
user, app_root, apps.get_user_config_data(user))
except Exception:
# Delivery has already committed the new tree. Even when the panel
# config cannot be read for a health probe, make the new tree visible
# to Passenger once and retain the failed restart/health verdict.
_selector_restart(user, app_root)
return False
if config['app_status'] != nodejs_user.APP_STARTED_CONST:
return _selector_restart(user, app_root)
try:
nodejs_user.check_response_from_webapp(
domain=config['domain'], alias=config['app_uri'], action=restart,
request_timeout=nodejs_user.WEBAPP_REQUEST_TIMEOUT,
retry_on_timeout=True, strict_status=True,
baseline=health_baseline,
post_action_observations=post_action_observations,
post_action_interval=post_action_interval)
except Exception:
# The shared web helper raises before calling `action` when its
# pre-restart probe is unavailable. The delivered tree was already
# swapped, so still attempt the selector restart exactly once; the
# probe failure remains a failed restart/health phase.
if not restart_attempted:
try:
restart()
except RuntimeError:
pass
return False
return True
def _selector_restart(user, app_root):
"""Restart through the selector so its established path validation runs."""
try:
proc = subprocess.Popen(
[SELECTOR_BIN, 'restart', '--json', '--interpreter', 'nodejs',
'--user', user, '--app-root', app_root],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
start_new_session=True)
except OSError:
return False
try:
proc.communicate(timeout=RESTART_TIMEOUT)
except subprocess.TimeoutExpired:
_terminate_process_group(proc)
return False
except subprocess.SubprocessError:
_terminate_process_group(proc)
return False
return proc.returncode == 0
@contextmanager
def _panel_app_lock(user, app_root):
"""Open the selector lock with no symlink traversal for all phases."""
parts = app_root.split('/') if app_root else []
if (not parts or os.path.isabs(app_root)
or any(part in ('', '.', '..') for part in parts)):
raise OSError('application lock path is invalid')
account = pwd.getpwnam(user)
current = os.open(account.pw_dir, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
# `_AppLock` is at the application venv base, above its Node-major
# directories, so every version of one app serializes on this inode.
for name in ['nodevenv'] + parts:
child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=current)
if os.fstat(child).st_uid != account.pw_uid:
os.close(child)
raise OSError('application directory is not account-owned')
os.close(current)
current = child
lock_fd = None
created = False
try:
try:
lock_fd = os.open('.lock', os.O_RDWR | os.O_NOFOLLOW, dir_fd=current)
except OSError as exc:
if exc.errno != errno.ENOENT:
raise
try:
lock_fd = os.open('.lock', os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o644, dir_fd=current)
created = True
os.fchown(lock_fd, account.pw_uid, account.pw_gid)
# The account now owns a live coordination inode. Another
# process may open and lock it before our flock below, so
# only a failed ownership handoff may remove this entry.
created = False
except OSError as create_exc:
if create_exc.errno != errno.EEXIST:
raise
lock_fd = os.open('.lock', os.O_RDWR | os.O_NOFOLLOW, dir_fd=current)
info = os.fstat(lock_fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != account.pw_uid:
raise OSError('application lock is not account-owned')
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except Exception:
if lock_fd is not None:
if created:
_unlink_same_inode(current, '.lock', lock_fd)
try:
os.close(lock_fd)
except OSError:
pass
raise
try:
yield lock_fd
finally:
os.close(lock_fd)
finally:
os.close(current)
def _unlink_same_inode(parent_fd, name, fd):
"""Best-effort removal of exactly the entry created by this operation."""
try:
pinned = os.fstat(fd)
current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
if (pinned.st_dev, pinned.st_ino) == (current.st_dev, current.st_ino):
os.unlink(name, dir_fd=parent_fd)
except OSError:
pass
def _unsupported_manifest_field(resolved):
"""The manifest field the account phase refused, or None.
Only a known field name is repeated: the rest of that phase's stdout is
account-controlled text and must not reach the panel message.
"""
if getattr(resolved, 'returncode', None) != UNSUPPORTED_MANIFEST_RC:
return None
parts = getattr(resolved, 'output', '').split()
if len(parts) != 2 or parts[0] != UNSUPPORTED_MANIFEST_SENTINEL:
return None
return parts[1] if parts[1] in UNSUPPORTED_MANIFEST_FIELDS else None
def deliver_modules(user, app_root, skip_web_check=False):
"""Run the phases. Returns None on success, or a message naming the
phase that failed.
The caller reports the message and fails the install rather than quietly
running npm. The
privileged phase retains the previous tree until the account-side rebuild
and marker commit succeed. Any failure restores that exact previous tree.
"""
if not node_modules_storage.engine_available():
return node_modules_storage.ENGINE_UNAVAILABLE
major = recorded_node_major(user, app_root)
try:
with _panel_app_lock(user, app_root) as lock_fd:
resolved = _user_phase(user, 'resolve', app_root, major)
if not resolved:
if (getattr(resolved, 'returncode', None) == LEGACY_PACKAGE_LOCK_RC
and getattr(resolved, 'output', '')
== LEGACY_PACKAGE_LOCK_SENTINEL):
return CLASSIC_NPM_FALLBACK
if (getattr(resolved, 'returncode', None) == SHRINKWRAP_UNSUPPORTED_RC
and getattr(resolved, 'output', '')
== SHRINKWRAP_UNSUPPORTED_SENTINEL):
# Shared Store cannot honour this pin, and classic npm
# can: the panel install proceeds there rather than
# failing an application that a published CLI pinned.
return CLASSIC_NPM_FALLBACK
if (getattr(resolved, 'returncode', None) == NO_DEPENDENCIES_RC
and getattr(resolved, 'output', '')
== NO_DEPENDENCIES_SENTINEL):
# Nothing to share: bun wrote no lockfile, so the
# privileged phase would have had nothing to read. The
# panel install proceeds on classic npm, which performs
# the no-op install.
return CLASSIC_NPM_FALLBACK
if _unsupported_manifest_field(resolved) is not None:
# Engine incapability, not a delivery failure: the panel
# install proceeds on classic npm, as it does for a
# legacy lockfile.
return CLASSIC_NPM_FALLBACK
return 'bun delivery failed in the resolve phase'
returncode, detail = _root_phase(user, app_root, major, lock_fd)
if returncode == REGISTRY_REFUSED_RC and detail:
return 'bun delivery refused: %s' % detail
if returncode == UNSUPPORTED_SOURCE_RC and detail:
return 'bun delivery refused: %s' % detail
if returncode == REBUILD_FAILED_RC:
return 'bun delivery failed in the rebuild phase; previous tree restored'
if returncode != 0:
return ('bun delivery failed in the privileged phase; see '
'/var/log/cloudlinux/bun-delivery.log')
except (OSError, KeyError):
return 'bun delivery failed acquiring the application lock'
# The selector restart takes this same lock through a separate open file
# description. The delivery transaction ends at rebuild, so release the
# panel descriptor before asking selector to take its own lock.
if not _restart_application(user, app_root, skip_web_check=skip_web_check):
return 'bun delivery failed in the restart phase'
return None