CaiWuXiTong/deploy/run-finance-import.py
WuYongJie 36af0e211a 修复生产内存保护:网页限额与重型导入隔离 (#430)
网页设置 2GiB 内存边界;生产重型导入统一进入 1CPU/1GiB 独立容器,保留只读文件、互斥、超时清理和发布保护。相关回归与资源隔离测试通过。
2026-10-21 14:30:06 +08:00

90 lines
4.0 KiB
Python

#!/usr/bin/env python3
"""Run registered imports in the existing, resource-limited batch profile."""
import json
import os
import pathlib
import shutil
import signal
import subprocess
import sys
import tempfile
import uuid
def available_memory_kib():
meminfo = dict(line.split(':', 1) for line in pathlib.Path('/proc/meminfo').read_text().splitlines())
return int(meminfo['MemAvailable'].split()[0])
def main():
arguments = sys.argv[1:]
registry = json.loads(pathlib.Path(__file__).with_name('finance-batch-imports.json').read_text())
if not arguments or arguments[0] not in registry:
print('Unsupported import; register the command in finance-batch-imports.json first.', file=sys.stderr)
return 64
# Keep a GiB available to the host in addition to this import's one-GiB cap.
if available_memory_kib() < 2 * 1024 * 1024:
print('Available memory is below 2GiB; no import container started. Retry after other tasks finish.', file=sys.stderr)
return 75
environment = os.environ.copy()
environment['FINANCE_BATCH_MEMORY_LIMIT'] = '1g'
environment['RUN_MIGRATIONS'] = '0'
image = subprocess.check_output(['docker', 'inspect', 'caiwu_app', '--format', '{{.Config.Image}}'], text=True).strip()
if not image.startswith('caiwu_akaunting:'):
print('Unsupported active finance image; no import container started.', file=sys.stderr)
return 65
environment['IMAGE_TAG'] = image.split(':', 1)[1]
container_name = 'caiwu_import_' + uuid.uuid4().hex
command = ['docker', 'compose', '-f', 'compose.jiangling.yml', '--profile', 'batch', 'run', '--rm', '-T', '--no-deps', '--name', container_name]
# Copy explicit host input files into a private per-run directory. The source
# is never modified; the container gets read-only copies readable by www-data.
with tempfile.TemporaryDirectory(prefix='caiwu-import-') as staging:
inputs = pathlib.Path(staging) / 'inputs'
inputs.mkdir(mode=0o755)
staged_arguments = [arguments[0]]
staged_count = 0
for argument in arguments[1:]:
prefix = ''
value = argument
if argument.startswith(('--file=', '--path=')):
prefix, value = argument.split('=', 1)
prefix += '='
source = pathlib.Path(value)
if not source.is_absolute():
source = pathlib.Path(os.environ.get('CAIWU_BATCH_CALLER_DIR', os.getcwd())) / source
if (prefix or not argument.startswith('-')) and source.is_file():
parent = inputs / str(staged_count)
parent.mkdir(mode=0o755)
destination = parent / source.name
shutil.copyfile(source, destination)
destination.chmod(0o444)
staged_arguments.append(prefix + '/run/caiwu-import/' + parent.name + '/' + destination.name)
staged_count += 1
else:
staged_arguments.append(argument)
if staged_count:
command += ['--volume', str(inputs) + ':/run/caiwu-import:ro']
command += ['--entrypoint', 'php', 'batch', '-d', 'memory_limit=512M', 'artisan', *staged_arguments]
print('Import limits: 1 CPU, 1GiB RAM, no Swap, PHP 512MiB; existing synchronization lock held.', flush=True)
try:
return subprocess.run(command, env=environment, timeout=3600).returncode
except subprocess.TimeoutExpired:
print('Import exceeded one hour; stop and inspect its checkpoint before retrying.', file=sys.stderr)
return 124
except KeyboardInterrupt:
return 130
finally:
# Killing only the Compose client would leave the job consuming RAM.
subprocess.run(['docker', 'rm', '--force', container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
if __name__ == '__main__':
def interrupted(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
signal.signal(signal.SIGHUP, interrupted)
sys.exit(main())