# 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