mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-26 22:51:03 +03:00
Replace hardcoded Apache zip URL with dynamic parsing of the Apache Lounge download page. Finds the latest Win64 build automatically, so the script won't break when new versions are published. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
429 lines
17 KiB
Python
429 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
# web-publish v1.2 — Publish 1C infobase via Apache
|
||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||
|
||
"""
|
||
Публикация информационной базы 1С через Apache HTTP Server.
|
||
Генерирует default.vrd и настраивает httpd.conf для веб-доступа
|
||
к информационной базе 1С. При необходимости скачивает portable Apache.
|
||
Идемпотентный — повторный вызов обновляет конфигурацию.
|
||
"""
|
||
|
||
import argparse
|
||
import glob
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import urllib.request
|
||
import zipfile
|
||
|
||
import psutil
|
||
|
||
|
||
def get_our_httpd(httpd_exe_norm):
|
||
"""Filter httpd processes by our ApachePath."""
|
||
result = []
|
||
if not httpd_exe_norm:
|
||
return result
|
||
for p in psutil.process_iter(['pid', 'name', 'exe']):
|
||
try:
|
||
if p.info['name'] and 'httpd' in p.info['name'].lower():
|
||
if p.info['exe'] and os.path.normcase(os.path.normpath(p.info['exe'])) == httpd_exe_norm:
|
||
result.append(p)
|
||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||
pass
|
||
return result
|
||
|
||
|
||
def get_all_httpd():
|
||
"""Get all httpd processes."""
|
||
result = []
|
||
for p in psutil.process_iter(['pid', 'name', 'exe']):
|
||
try:
|
||
if p.info['name'] and 'httpd' in p.info['name'].lower():
|
||
result.append(p)
|
||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||
pass
|
||
return result
|
||
|
||
|
||
def check_port_in_use(port):
|
||
"""Check if a port is in use and return the owning PID, or None."""
|
||
for conn in psutil.net_connections(kind='tcp'):
|
||
if conn.laddr and conn.laddr.port == port and conn.status == 'LISTEN':
|
||
return conn.pid
|
||
return None
|
||
|
||
|
||
def main():
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
sys.stderr.reconfigure(encoding="utf-8")
|
||
parser = argparse.ArgumentParser(description='Publish 1C infobase via Apache', allow_abbrev=False)
|
||
parser.add_argument('-V8Path', type=str, default='', help='Path to 1C platform bin directory (for wsap24.dll)')
|
||
parser.add_argument('-InfoBasePath', type=str, default='', help='Path to file infobase')
|
||
parser.add_argument('-InfoBaseServer', type=str, default='', help='1C server (for server infobase)')
|
||
parser.add_argument('-InfoBaseRef', type=str, default='', help='Infobase name on server')
|
||
parser.add_argument('-UserName', type=str, default='', help='1C user name')
|
||
parser.add_argument('-Password', type=str, default='', help='1C password')
|
||
parser.add_argument('-AppName', type=str, default='', help='Publication name (default: from infobase folder name)')
|
||
parser.add_argument('-ApachePath', type=str, default='', help='Apache root (default: tools\\apache24)')
|
||
parser.add_argument('-Port', type=int, default=8081, help='Port (default: 8081)')
|
||
parser.add_argument('-Manual', action='store_true', help='Do not download Apache — only check and give instructions')
|
||
args = parser.parse_args()
|
||
|
||
# --- Resolve V8Path ---
|
||
v8_path = args.V8Path
|
||
if not v8_path:
|
||
candidates = glob.glob(r'C:\Program Files\1cv8\*\bin\1cv8.exe')
|
||
candidates.sort(reverse=True)
|
||
if candidates:
|
||
v8_path = os.path.dirname(candidates[0])
|
||
else:
|
||
print('Error: платформа 1С не найдена. Укажите -V8Path', file=sys.stderr)
|
||
sys.exit(1)
|
||
elif os.path.isfile(v8_path):
|
||
v8_path = os.path.dirname(v8_path)
|
||
|
||
# Validate wsap24.dll
|
||
wsap_dll = os.path.join(v8_path, 'wsap24.dll')
|
||
if not os.path.exists(wsap_dll):
|
||
print(f'Error: wsap24.dll не найден в {v8_path}', file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
# --- Validate connection ---
|
||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||
print('Error: укажите -InfoBasePath или -InfoBaseServer + -InfoBaseRef', file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
# --- Resolve ApachePath ---
|
||
apache_path = args.ApachePath
|
||
if not apache_path:
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(script_dir))))
|
||
apache_path = os.path.join(project_root, 'tools', 'apache24')
|
||
# Ensure absolute path (agent may pass relative like "tools/apache24")
|
||
if not os.path.isabs(apache_path):
|
||
apache_path = os.path.abspath(apache_path)
|
||
|
||
port = args.Port
|
||
|
||
# --- Check / Install Apache ---
|
||
httpd_exe = os.path.join(apache_path, 'bin', 'httpd.exe')
|
||
|
||
if not os.path.exists(httpd_exe):
|
||
if args.Manual:
|
||
print(f'Apache не найден: {apache_path}')
|
||
print('')
|
||
print('Установите Apache вручную:')
|
||
print(' 1. Скачайте Apache Lounge (x64) с https://www.apachelounge.com/download/')
|
||
print(f' 2. Распакуйте содержимое Apache24\\ в: {apache_path}')
|
||
print(' 3. Запустите скрипт повторно')
|
||
sys.exit(1)
|
||
|
||
print('Apache не найден. Скачиваю...')
|
||
tmp_zip = os.path.join(tempfile.gettempdir(), 'apache24.zip')
|
||
tmp_dir = os.path.join(tempfile.gettempdir(), 'apache24_extract')
|
||
|
||
try:
|
||
# Parse Apache Lounge download page for latest Win64 zip URL
|
||
download_page = 'https://www.apachelounge.com/download/'
|
||
print(f'Определяю актуальную версию с {download_page} ...')
|
||
req = urllib.request.Request(download_page, headers={
|
||
'User-Agent': 'Mozilla/5.0',
|
||
})
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
html = resp.read().decode('utf-8', errors='replace')
|
||
|
||
# Links are typically relative (/download/...), try that first
|
||
m = re.search(r'(?i)href="(/download/[^"]*?httpd-[^"]*?Win64[^"]*?\.zip)"', html)
|
||
if not m:
|
||
m = re.search(r'(?i)href="(https://[^"]*?httpd-[^"]*?Win64[^"]*?\.zip)"', html)
|
||
|
||
if m:
|
||
zip_url = m.group(1)
|
||
if zip_url.startswith('/'):
|
||
zip_url = f'https://www.apachelounge.com{zip_url}'
|
||
print(f'Найдено: {zip_url}')
|
||
else:
|
||
print('Не удалось определить ссылку автоматически.', file=sys.stderr)
|
||
print(f'Скачайте вручную: {download_page}')
|
||
sys.exit(1)
|
||
|
||
urllib.request.urlretrieve(zip_url, tmp_zip)
|
||
except SystemExit:
|
||
raise
|
||
except Exception as e:
|
||
print(f'Error: не удалось скачать Apache: {e}', file=sys.stderr)
|
||
print('Скачайте вручную: https://www.apachelounge.com/download/')
|
||
sys.exit(1)
|
||
|
||
print('Распаковка...')
|
||
if os.path.exists(tmp_dir):
|
||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||
|
||
with zipfile.ZipFile(tmp_zip, 'r') as zf:
|
||
zf.extractall(tmp_dir)
|
||
|
||
# Move Apache24 contents up to ApachePath
|
||
inner_dir = os.path.join(tmp_dir, 'Apache24')
|
||
if not os.path.isdir(inner_dir):
|
||
# Try to find Apache24 in nested folder
|
||
found_inner = None
|
||
for root, dirs, files in os.walk(tmp_dir):
|
||
if 'Apache24' in dirs:
|
||
found_inner = os.path.join(root, 'Apache24')
|
||
break
|
||
if found_inner:
|
||
inner_dir = found_inner
|
||
else:
|
||
print('Error: каталог Apache24 не найден в архиве', file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
os.makedirs(apache_path, exist_ok=True)
|
||
# Copy contents of inner_dir to apache_path
|
||
for item in os.listdir(inner_dir):
|
||
src = os.path.join(inner_dir, item)
|
||
dst = os.path.join(apache_path, item)
|
||
if os.path.isdir(src):
|
||
if os.path.exists(dst):
|
||
shutil.rmtree(dst)
|
||
shutil.copytree(src, dst)
|
||
else:
|
||
shutil.copy2(src, dst)
|
||
|
||
# Cleanup
|
||
try:
|
||
os.remove(tmp_zip)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||
except OSError:
|
||
pass
|
||
|
||
# Patch ServerRoot in httpd.conf
|
||
conf_file = os.path.join(apache_path, 'conf', 'httpd.conf')
|
||
if os.path.exists(conf_file):
|
||
apache_path_fwd = apache_path.replace('\\', '/')
|
||
with open(conf_file, 'r', encoding='utf-8-sig') as f:
|
||
conf_content = f.read()
|
||
conf_content = re.sub(
|
||
r'(?m)^Define SRVROOT .*$',
|
||
f'Define SRVROOT "{apache_path_fwd}"',
|
||
conf_content,
|
||
)
|
||
with open(conf_file, 'w', encoding='utf-8') as f:
|
||
f.write(conf_content)
|
||
print(f'ServerRoot обновлён: {apache_path_fwd}')
|
||
|
||
print(f'Apache установлен: {apache_path}')
|
||
|
||
# --- Derive AppName ---
|
||
app_name = args.AppName
|
||
if not app_name:
|
||
if args.InfoBasePath:
|
||
app_name = re.sub(r'[^\w]', '', os.path.basename(args.InfoBasePath))
|
||
else:
|
||
app_name = re.sub(r'[^\w]', '', args.InfoBaseRef)
|
||
app_name = app_name.lower()
|
||
app_name = app_name.lower()
|
||
|
||
if not app_name:
|
||
print('Error: не удалось определить имя публикации. Укажите -AppName', file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
print(f'Публикация: {app_name}')
|
||
|
||
# --- Create publish directory ---
|
||
publish_dir = os.path.join(apache_path, 'publish', app_name)
|
||
os.makedirs(publish_dir, exist_ok=True)
|
||
|
||
# --- Generate default.vrd ---
|
||
vrd_path = os.path.join(publish_dir, 'default.vrd')
|
||
|
||
ib_parts = []
|
||
if args.InfoBaseServer and args.InfoBaseRef:
|
||
ib_parts.append(f'Srvr="{args.InfoBaseServer}"')
|
||
ib_parts.append(f'Ref="{args.InfoBaseRef}"')
|
||
else:
|
||
ib_parts.append(f'File="{args.InfoBasePath}"')
|
||
if args.UserName:
|
||
ib_parts.append(f'Usr="{args.UserName}"')
|
||
if args.Password:
|
||
ib_parts.append(f'Pwd="{args.Password}"')
|
||
ib_string = ';'.join(ib_parts) + ';'
|
||
|
||
vrd_content = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||
<point xmlns="http://v8.1c.ru/8.2/virtual-resource-system"
|
||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||
base="/{app_name}"
|
||
ib="{ib_string}"
|
||
enableStandardOdata="true">
|
||
<ws pointEnableCommon="true"/>
|
||
<httpServices publishByDefault="true"/>
|
||
</point>'''
|
||
|
||
with open(vrd_path, 'wb') as f:
|
||
f.write(b'\xef\xbb\xbf')
|
||
f.write(vrd_content.encode('utf-8'))
|
||
print(f'default.vrd: {vrd_path}')
|
||
|
||
# --- Update httpd.conf ---
|
||
conf_file = os.path.join(apache_path, 'conf', 'httpd.conf')
|
||
if not os.path.exists(conf_file):
|
||
print(f'Error: httpd.conf не найден: {conf_file}', file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
with open(conf_file, 'r', encoding='utf-8-sig') as f:
|
||
conf_content = f.read()
|
||
|
||
apache_path_fwd = apache_path.replace('\\', '/')
|
||
wsap_dll_fwd = wsap_dll.replace('\\', '/')
|
||
publish_dir_fwd = publish_dir.replace('\\', '/')
|
||
vrd_path_fwd = vrd_path.replace('\\', '/')
|
||
|
||
# --- Global block (Listen + LoadModule) ---
|
||
global_marker_start = '# --- 1C: global ---'
|
||
global_marker_end = '# --- End: global ---'
|
||
global_block = (
|
||
f'{global_marker_start}\n'
|
||
f'Listen {port}\n'
|
||
f'LoadModule _1cws_module "{wsap_dll_fwd}"\n'
|
||
f'{global_marker_end}'
|
||
)
|
||
|
||
if re.search(re.escape(global_marker_start), conf_content):
|
||
# Replace existing global block
|
||
pattern = re.escape(global_marker_start) + r'[\s\S]*?' + re.escape(global_marker_end)
|
||
conf_content = re.sub(pattern, global_block, conf_content)
|
||
else:
|
||
# Comment out default Listen to avoid port conflict
|
||
conf_content = re.sub(r'(?m)^(Listen\s+\d+)', r'#\1 # commented by web-publish', conf_content)
|
||
# Append global block
|
||
conf_content = conf_content.rstrip() + '\n\n' + global_block + '\n'
|
||
|
||
# --- Publication block ---
|
||
pub_marker_start = f'# --- 1C Publication: {app_name} ---'
|
||
pub_marker_end = f'# --- End: {app_name} ---'
|
||
pub_block = (
|
||
f'{pub_marker_start}\n'
|
||
f'Alias "/{app_name}" "{publish_dir_fwd}"\n'
|
||
f'<Directory "{publish_dir_fwd}">\n'
|
||
f' AllowOverride All\n'
|
||
f' Require all granted\n'
|
||
f' SetHandler 1c-application\n'
|
||
f' ManagedApplicationDescriptor "{vrd_path_fwd}"\n'
|
||
f'</Directory>\n'
|
||
f'{pub_marker_end}'
|
||
)
|
||
|
||
if re.search(re.escape(pub_marker_start), conf_content):
|
||
# Replace existing publication block
|
||
pattern = re.escape(pub_marker_start) + r'[\s\S]*?' + re.escape(pub_marker_end)
|
||
conf_content = re.sub(pattern, pub_block, conf_content)
|
||
else:
|
||
# Append publication block
|
||
conf_content = conf_content.rstrip() + '\n\n' + pub_block + '\n'
|
||
|
||
with open(conf_file, 'w', encoding='utf-8') as f:
|
||
f.write(conf_content)
|
||
print('httpd.conf обновлён')
|
||
|
||
# --- Normalize httpd_exe for process matching ---
|
||
if os.path.exists(httpd_exe):
|
||
httpd_exe_norm = os.path.normcase(os.path.normpath(os.path.realpath(httpd_exe)))
|
||
else:
|
||
httpd_exe_norm = os.path.normcase(os.path.normpath(httpd_exe))
|
||
|
||
# --- Check port availability ---
|
||
holder_pid = check_port_in_use(port)
|
||
if holder_pid:
|
||
our_proc = get_our_httpd(httpd_exe_norm)
|
||
if not our_proc:
|
||
# Port is held by someone else
|
||
try:
|
||
holder_proc = psutil.Process(holder_pid)
|
||
holder_name = f'{holder_proc.name()} (PID: {holder_pid})'
|
||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||
holder_name = f'PID {holder_pid}'
|
||
print(f'Error: порт {port} занят процессом {holder_name}', file=sys.stderr)
|
||
print('Укажите другой порт: -Port 9090')
|
||
sys.exit(1)
|
||
|
||
# --- Start Apache if not running ---
|
||
httpd_proc = get_our_httpd(httpd_exe_norm)
|
||
if httpd_proc:
|
||
first_pid = httpd_proc[0].pid
|
||
print(f'Apache уже запущен (PID: {first_pid})')
|
||
print('Перезапуск для применения конфигурации...')
|
||
for p in httpd_proc:
|
||
try:
|
||
p.kill()
|
||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||
pass
|
||
time.sleep(1)
|
||
else:
|
||
# Check if a foreign httpd holds the port
|
||
foreign_httpd = get_all_httpd()
|
||
if foreign_httpd:
|
||
print(f'[WARN] Обнаружен сторонний Apache (PID: {foreign_httpd[0].pid})')
|
||
print(f' Наш Apache: {httpd_exe}')
|
||
|
||
print('Запуск Apache...')
|
||
subprocess.Popen(
|
||
[httpd_exe],
|
||
cwd=apache_path,
|
||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||
)
|
||
|
||
time.sleep(2)
|
||
|
||
httpd_check = get_our_httpd(httpd_exe_norm)
|
||
if httpd_check:
|
||
print(f'Apache запущен (PID: {httpd_check[0].pid})')
|
||
else:
|
||
print('Apache не удалось запустить', file=sys.stderr)
|
||
# Run config test for diagnostics
|
||
try:
|
||
result = subprocess.run(
|
||
[httpd_exe, '-t'],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
)
|
||
test_output = (result.stdout + result.stderr).strip()
|
||
if test_output:
|
||
print('--- httpd -t ---')
|
||
for line in test_output.splitlines():
|
||
print(f' {line}')
|
||
except Exception:
|
||
pass
|
||
error_log = os.path.join(apache_path, 'logs', 'error.log')
|
||
if os.path.exists(error_log):
|
||
print('--- error.log (последние 10 строк) ---')
|
||
try:
|
||
with open(error_log, 'r', encoding='utf-8-sig', errors='replace') as f:
|
||
all_lines = f.readlines()
|
||
for line in all_lines[-10:]:
|
||
print(line.rstrip())
|
||
except Exception:
|
||
pass
|
||
sys.exit(1)
|
||
|
||
# --- Result ---
|
||
print('')
|
||
print('=== Публикация готова ===')
|
||
print(f'URL: http://localhost:{port}/{app_name}')
|
||
print(f'OData: http://localhost:{port}/{app_name}/odata/standard.odata')
|
||
print(f'HTTP-сервисы: http://localhost:{port}/{app_name}/hs/<RootUrl>/...')
|
||
print(f'Web-сервисы: http://localhost:{port}/{app_name}/ws/<Имя>?wsdl')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|