mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-05 17:50:53 +03:00
1024 lines
46 KiB
Python
1024 lines
46 KiB
Python
#!/usr/bin/env python3
|
||
# db-cfe-admin v1.0 — Configuration extensions in a 1C infobase: list, check, properties, delete
|
||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||
|
||
import argparse
|
||
import atexit
|
||
import glob
|
||
import json
|
||
import os
|
||
import random
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
|
||
# Общий блок группы db-*: реквизиты хранилища, дополнительные аргументы, запуск платформы.
|
||
# Копии держит одинаковыми tests/skills/check-inline-drift.mjs — правку вносить в навык-эталон.
|
||
|
||
def ci_parse_args(parser, argv=None):
|
||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||
argv = list(sys.argv[1:] if argv is None else argv)
|
||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||
for i, tok in enumerate(argv):
|
||
if tok.startswith('-') and tok.lower() in names:
|
||
argv[i] = names[tok.lower()]
|
||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||
choice_map = {}
|
||
for a in parser._actions:
|
||
if a.choices:
|
||
for s in a.option_strings:
|
||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||
for i in range(len(argv) - 1):
|
||
m = choice_map.get(argv[i])
|
||
if m and argv[i + 1].lower() in m:
|
||
argv[i + 1] = m[argv[i + 1].lower()]
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
|
||
def _find_project_v8path():
|
||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||
d = os.getcwd()
|
||
while True:
|
||
pf = os.path.join(d, ".v8-project.json")
|
||
if os.path.isfile(pf):
|
||
try:
|
||
with open(pf, encoding="utf-8-sig") as f:
|
||
data = json.load(f)
|
||
v = data.get("v8path")
|
||
if v:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
return None
|
||
parent = os.path.dirname(d)
|
||
if parent == d:
|
||
return None
|
||
d = parent
|
||
|
||
|
||
# --- Additional platform arguments ---
|
||
V8_OWNED_KEYS = [
|
||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||
]
|
||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||
V8_BATCH_KEYS = [
|
||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||
]
|
||
|
||
IBCMD_OWNED_KEYS = [
|
||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||
"--import", "--export", "--apply", "--force", "--create-database",
|
||
"--user", "--password",
|
||
]
|
||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||
|
||
|
||
# --- Реквизиты хранилища из .v8-project.json ---
|
||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||
def _sg_find_v8project(start_dir):
|
||
d = start_dir
|
||
for _ in range(20):
|
||
if not d:
|
||
break
|
||
pj = os.path.join(d, ".v8-project.json")
|
||
if os.path.isfile(pj):
|
||
return pj
|
||
parent = os.path.dirname(d)
|
||
if parent == d:
|
||
break
|
||
d = parent
|
||
return None
|
||
|
||
def same_path(a, b):
|
||
if not a or not b:
|
||
return False
|
||
try:
|
||
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def find_project_database(args):
|
||
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||
pf = _sg_find_v8project(os.getcwd())
|
||
if not pf:
|
||
return None
|
||
try:
|
||
with open(pf, encoding="utf-8-sig") as f:
|
||
proj = json.load(f)
|
||
except Exception:
|
||
return None
|
||
for db in proj.get("databases") or []:
|
||
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||
return db
|
||
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||
return db
|
||
return None
|
||
|
||
|
||
def resolve_repository_settings(args):
|
||
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||
db_rec = find_project_database(args)
|
||
rec = None
|
||
if db_rec:
|
||
if args.Extension:
|
||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||
for ext in db_rec.get("extensions") or []:
|
||
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||
rec = ext.get("repository")
|
||
break
|
||
else:
|
||
rec = db_rec.get("repository")
|
||
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||
return {
|
||
"path": path.strip().strip('"') if path else None,
|
||
"user": user,
|
||
"password": pwd,
|
||
"from_registry": bool(rec and rec.get("path")),
|
||
}
|
||
|
||
|
||
def repository_args(repo):
|
||
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||
a = []
|
||
if not repo or not repo.get("path"):
|
||
return a
|
||
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||
if repo.get("user"):
|
||
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||
if repo.get("password"):
|
||
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||
return a
|
||
|
||
|
||
def arg_key_match(token, key):
|
||
"""Token matches a key when it equals it, or starts with it and the next character
|
||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||
/ClearCache distinct from /C."""
|
||
if len(token) < len(key):
|
||
return False
|
||
if token[: len(key)].lower() != key.lower():
|
||
return False
|
||
if len(token) == len(key):
|
||
return True
|
||
return not token[len(key)].isalpha()
|
||
|
||
|
||
def project_extra_args(name):
|
||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||
d = os.getcwd()
|
||
while True:
|
||
pf = os.path.join(d, ".v8-project.json")
|
||
if os.path.isfile(pf):
|
||
try:
|
||
with open(pf, encoding="utf-8-sig") as f:
|
||
data = json.load(f)
|
||
v = data.get(name)
|
||
if v:
|
||
return [str(x) for x in v]
|
||
except Exception:
|
||
pass
|
||
return []
|
||
parent = os.path.dirname(d)
|
||
if parent == d:
|
||
return []
|
||
d = parent
|
||
|
||
|
||
def assert_extra_args(extra, engine, hints):
|
||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||
for tok in extra:
|
||
if engine == "ibcmd" and not tok.startswith("-"):
|
||
print(
|
||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||
f"({param} cannot extend the ibcmd command)",
|
||
)
|
||
sys.exit(1)
|
||
if engine != "ibcmd":
|
||
for b in V8_BATCH_KEYS:
|
||
if arg_key_match(tok, b):
|
||
print(
|
||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||
f"the skill's own operation (a command line runs only its last batch command)",
|
||
)
|
||
sys.exit(1)
|
||
for k in owned:
|
||
if arg_key_match(tok, k):
|
||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||
print(
|
||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||
)
|
||
sys.exit(1)
|
||
|
||
|
||
def format_args_for_display(arglist, engine):
|
||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||
a leaked password does."""
|
||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||
res = []
|
||
mask_next = False
|
||
for tok in arglist:
|
||
if mask_next:
|
||
res.append("***")
|
||
mask_next = False
|
||
continue
|
||
hit = None
|
||
for k in keys:
|
||
if tok[: len(k)].lower() == k.lower():
|
||
hit = k
|
||
break
|
||
if hit is None:
|
||
res.append(tok)
|
||
elif len(tok) == len(hit):
|
||
res.append(tok)
|
||
mask_next = True
|
||
elif tok[len(hit)] == "=":
|
||
res.append(hit + "=***")
|
||
else:
|
||
res.append(hit + "***")
|
||
return res
|
||
|
||
|
||
def extract_extra_args(argv, known_opts):
|
||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||
rest, v8, ibcmd = [], [], []
|
||
i = 0
|
||
while i < len(argv):
|
||
low = argv[i].lower()
|
||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||
i += 1
|
||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||
target.append(argv[i])
|
||
i += 1
|
||
continue
|
||
rest.append(argv[i])
|
||
i += 1
|
||
return rest, v8, ibcmd
|
||
|
||
|
||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||
passed parameter for the other engine is an error; the same keys coming from
|
||
.v8-project.json simply do not apply — a project may describe both engines.
|
||
|
||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||
so that form is the documented one and both ports must accept it. A value containing
|
||
a comma is not supported."""
|
||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||
if engine == "ibcmd" and v8_extra:
|
||
print(
|
||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||
"(use -AdditionalIbcmdArguments)",
|
||
)
|
||
sys.exit(1)
|
||
if engine != "ibcmd" and ibcmd_extra:
|
||
print(
|
||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||
"(use -AdditionalV8Arguments)",
|
||
)
|
||
sys.exit(1)
|
||
if engine == "ibcmd":
|
||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||
else:
|
||
extra = project_extra_args("v8args") + list(v8_extra)
|
||
if extra:
|
||
assert_extra_args(extra, engine, hints)
|
||
return extra
|
||
|
||
|
||
def _version_dir(p):
|
||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||
parent = os.path.dirname(p)
|
||
if os.path.basename(parent).lower() == "bin":
|
||
parent = os.path.dirname(parent)
|
||
return os.path.basename(parent)
|
||
|
||
|
||
def _version_key(p):
|
||
"""Numeric sort key from version dir name."""
|
||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||
|
||
|
||
def resolve_v8path(v8path):
|
||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||
if not v8path:
|
||
v8path = _find_project_v8path()
|
||
if not v8path:
|
||
if os.name == "nt":
|
||
candidates = (
|
||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||
)
|
||
else:
|
||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||
if candidates:
|
||
v8path = max(candidates, key=_version_key)
|
||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||
else:
|
||
print("Error: 1C executable not found. Specify -V8Path")
|
||
sys.exit(1)
|
||
if os.path.isdir(v8path):
|
||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||
v8path = os.path.join(v8path, exe)
|
||
if not os.path.isfile(v8path):
|
||
print(f"Error: 1C executable not found at {v8path}")
|
||
sys.exit(1)
|
||
return v8path
|
||
|
||
|
||
IBCMD_NOUSER_HINT = (
|
||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||
"call may block instead of failing. If it does not return promptly, abort and "
|
||
"re-run with -UserName and -Password.\n"
|
||
)
|
||
|
||
|
||
def decode_platform_bytes(data):
|
||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||
code page (what text=True uses) mangles both."""
|
||
if not data:
|
||
return ""
|
||
try:
|
||
return data.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
return data.decode("cp866", errors="replace")
|
||
|
||
|
||
def assert_infobase_exists(path):
|
||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||
if not path:
|
||
return
|
||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||
sys.exit(1)
|
||
|
||
|
||
def clean_path(value, param=""):
|
||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||
if not value:
|
||
return value
|
||
v = value.strip()
|
||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||
v = v[1:-1].strip()
|
||
if len(v) > 3 and v[-1] in "\\/":
|
||
v = v[:-1]
|
||
if '"' in v:
|
||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||
sys.exit(1)
|
||
return v
|
||
|
||
|
||
def quote_if_needed(token):
|
||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||
verbatim, so a token with a space needs quotes of its own."""
|
||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||
return f'"{token}"'
|
||
return token
|
||
|
||
|
||
def run_v8(v8path, arguments):
|
||
"""Run 1cv8 in batch mode and capture its console output.
|
||
|
||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||
escape those quotes, so there the command line is handed over ready-made.
|
||
|
||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||
"""
|
||
if os.name == "nt":
|
||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||
else:
|
||
def strip_framing_quotes(a):
|
||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||
# токеном даёт «Неопределена информационная база», а склеенный
|
||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||
# без кавычек обе формы работают.
|
||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||
return a[1:-1] # "значение" отдельным токеном
|
||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||
i = a.index('"')
|
||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||
return a # File="…" не трогаем: там кавычки —
|
||
# часть синтаксиса строки соединения,
|
||
# и с ними на POSIX всё работает
|
||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||
r.stdout = decode_platform_bytes(r.stdout)
|
||
r.stderr = decode_platform_bytes(r.stderr)
|
||
return r
|
||
|
||
|
||
def print_platform_output(result):
|
||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||
if not text:
|
||
return
|
||
limit = 65536
|
||
if len(text) > limit:
|
||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||
print("--- Вывод платформы ---")
|
||
print(text)
|
||
print("--- End ---")
|
||
|
||
|
||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||
"""Run an ibcmd command non-interactively.
|
||
|
||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||
"""
|
||
if warn_no_user and os.name == "nt" and not has_username:
|
||
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||
sys.stderr.flush()
|
||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||
r.stdout = decode_platform_bytes(r.stdout)
|
||
r.stderr = decode_platform_bytes(r.stderr)
|
||
return r
|
||
|
||
|
||
def describe_exit(code):
|
||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||
if code is None:
|
||
return ""
|
||
win = {
|
||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||
}
|
||
if code in win:
|
||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||
if -64 <= code < 0:
|
||
try:
|
||
import signal
|
||
name = signal.Signals(-code).name
|
||
except (ValueError, AttributeError):
|
||
name = f"signal {-code}"
|
||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||
return ""
|
||
|
||
|
||
def _redact(text, *secrets):
|
||
"""Redact literal secret values (password, user) from a display string —
|
||
precise, never touches lookalike paths."""
|
||
for s in secrets:
|
||
if s:
|
||
text = text.replace(s, "***")
|
||
return text
|
||
|
||
|
||
KNOWN_COMMANDS = ["list", "check", "set-properties", "delete"]
|
||
KNOWN_CHECKS = ["apply", "modules", "config"]
|
||
KNOWN_CONTEXTS = ["ThinClient", "WebClient", "MobileClient", "MobileClientStandalone", "MobileAppClient",
|
||
"Server", "MobileAppServer", "ExternalConnection", "ExternalConnectionServer",
|
||
"ThickClientManagedApplication", "ThickClientServerManagedApplication",
|
||
"ThickClientOrdinaryApplication", "ThickClientServerOrdinaryApplication"]
|
||
|
||
PROP_RU = {
|
||
"safe-mode": "безопасный режим",
|
||
"active": "активно",
|
||
"unsafe-action-protection": "защита от опасных действий",
|
||
"used-in-distributed-infobase": "используется в РИБ",
|
||
"scope": "область действия",
|
||
"security-profile-name": "профиль безопасности",
|
||
"purpose": "назначение",
|
||
"version": "версия",
|
||
}
|
||
FLAG_RU = {"yes": "да", "no": "нет"}
|
||
SCOPE_RU = {"infobase": "Информационная база", "data-separation": "Область данных"}
|
||
PURPOSE_RU = {"customization": "Адаптация", "add-on": "Дополнение", "patch": "Исправление"}
|
||
|
||
|
||
def prop_ru(key):
|
||
return PROP_RU.get(key, key)
|
||
|
||
|
||
def flag_value(value):
|
||
return "yes" if str(value).lower() in ("on", "yes", "+") else "no"
|
||
|
||
|
||
def format_prop_value(key, value):
|
||
if value is None or value == "":
|
||
return ""
|
||
v = str(value)
|
||
if key == "scope" and v in SCOPE_RU:
|
||
return SCOPE_RU[v]
|
||
if key == "purpose" and v in PURPOSE_RU:
|
||
return PURPOSE_RU[v]
|
||
return FLAG_RU.get(v, v)
|
||
|
||
|
||
def parse_ibcmd_records(text):
|
||
"""Вывод ibcmd: строки «ключ : значение», записи разделены пустой строкой."""
|
||
records = []
|
||
cur = {}
|
||
for line in (text or "").splitlines():
|
||
if not line.strip():
|
||
if cur:
|
||
records.append(cur)
|
||
cur = {}
|
||
continue
|
||
if ":" not in line:
|
||
continue
|
||
key, val = line.split(":", 1)
|
||
key = key.strip()
|
||
val = val.strip().strip('"')
|
||
if key:
|
||
cur[key] = val
|
||
if cur:
|
||
records.append(cur)
|
||
return records
|
||
|
||
|
||
def write_table(headers, rows):
|
||
widths = []
|
||
for i, h in enumerate(headers):
|
||
w = len(h)
|
||
for row in rows:
|
||
w = max(w, len(str(row[i])))
|
||
widths.append(w)
|
||
print((" " + "".join(h.ljust(widths[i] + 2) for i, h in enumerate(headers))).rstrip())
|
||
for row in rows:
|
||
print((" " + "".join(str(c).ljust(widths[i] + 2) for i, c in enumerate(row))).rstrip())
|
||
|
||
|
||
def main():
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
sys.stderr.reconfigure(encoding="utf-8")
|
||
parser = argparse.ArgumentParser(
|
||
description="Configuration extensions in a 1C infobase",
|
||
allow_abbrev=False,
|
||
)
|
||
parser.add_argument("-Command", default="")
|
||
parser.add_argument("-V8Path", default="")
|
||
parser.add_argument("-InfoBasePath", default="")
|
||
parser.add_argument("-InfoBaseServer", default="")
|
||
parser.add_argument("-InfoBaseRef", default="")
|
||
parser.add_argument("-UserName", default="")
|
||
parser.add_argument("-Password", default="")
|
||
parser.add_argument("-RepositoryPath", default="")
|
||
parser.add_argument("-RepositoryUser", default="")
|
||
parser.add_argument("-RepositoryPassword", default="")
|
||
parser.add_argument("-Name", default=None)
|
||
parser.add_argument("-All", action="store_true")
|
||
parser.add_argument("-Checks", default="")
|
||
parser.add_argument("-Context", default="")
|
||
# Тристабильные флаги: on включить, off выключить, не указан — не трогать.
|
||
# Значение "-" через powershell.exe -File парсер съедает молча (проверено), поэтому
|
||
# каноническая форма словесная; "+"/"-" принимаются, но в инструкции не значатся.
|
||
for flag in ("-SafeMode", "-Active", "-UnsafeActionProtection", "-UsedInDistributedInfobase"):
|
||
parser.add_argument(flag, default="", choices=["", "on", "off", "yes", "no", "+", "-"])
|
||
parser.add_argument("-Scope", default="", choices=["", "infobase", "data-separation"])
|
||
parser.add_argument("-SecurityProfile", default=None)
|
||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||
help="Extra ibcmd arguments in --key=value form")
|
||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||
args = ci_parse_args(parser, argv)
|
||
|
||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||
assert_infobase_exists(args.InfoBasePath)
|
||
|
||
v8path = resolve_v8path(args.V8Path)
|
||
|
||
# --- Утилиты платформы: нужны обе, выбор по команде ---
|
||
# -V8Path указывает на каталог bin либо на любой из двух файлов; второй берётся соседом.
|
||
bin_dir = os.path.dirname(v8path)
|
||
leaf = os.path.basename(v8path)
|
||
# Расширение файла сохраняем: на Windows это .exe, на *nix его нет, в тестах — .cmd/.sh.
|
||
suffix = os.path.splitext(leaf)[1]
|
||
if leaf.lower().startswith("ibcmd"):
|
||
ibcmd_exe = v8path
|
||
v8_exe = os.path.join(bin_dir, "1cv8" + suffix)
|
||
else:
|
||
v8_exe = v8path
|
||
ibcmd_exe = os.path.join(bin_dir, "ibcmd" + suffix)
|
||
has_v8 = os.path.isfile(v8_exe)
|
||
has_ibcmd = os.path.isfile(ibcmd_exe)
|
||
|
||
# --- Разбор и проверка команды ---
|
||
cmd = (args.Command or "").strip().lower()
|
||
if not cmd:
|
||
print("Error: specify a command: " + " | ".join(KNOWN_COMMANDS))
|
||
sys.exit(1)
|
||
if cmd not in KNOWN_COMMANDS:
|
||
print("Error: unknown command '%s' (expected: %s)" % (args.Command, " | ".join(KNOWN_COMMANDS)))
|
||
sys.exit(1)
|
||
|
||
# Пустое имя платформа трактует разрушительно: /DeleteCfg -Extension "" удаляет первое
|
||
# расширение из списка и рапортует успех. Пустое значение до платформы не доходит.
|
||
if args.Name is not None and not args.Name.strip():
|
||
print("Error: -Name is empty; omit it to address all extensions, or pass a name")
|
||
sys.exit(1)
|
||
name = args.Name.strip() if args.Name else ""
|
||
has_name = bool(name)
|
||
|
||
if args.All and cmd != "delete":
|
||
print("Error: -All applies to delete only (list and check address all extensions when -Name is omitted)")
|
||
sys.exit(1)
|
||
if cmd == "delete":
|
||
if has_name and args.All:
|
||
print("Error: -Name and -All are mutually exclusive - pass one or the other")
|
||
sys.exit(1)
|
||
if not has_name and not args.All:
|
||
print("Error: specify -Name <extension> or -All (an omitted name never means all)")
|
||
sys.exit(1)
|
||
if cmd == "set-properties" and not has_name:
|
||
print("Error: set-properties needs -Name <extension>")
|
||
sys.exit(1)
|
||
|
||
# --- Проверки (-Checks) и контексты (-Context) ---
|
||
check_list = [c.strip().lower() for c in args.Checks.split(",") if c.strip()] if args.Checks else []
|
||
for c in check_list:
|
||
if c not in KNOWN_CHECKS:
|
||
print("Error: unknown check '%s' (expected: %s)" % (c, ", ".join(KNOWN_CHECKS)))
|
||
sys.exit(1)
|
||
if not check_list:
|
||
check_list = ["apply", "modules"]
|
||
|
||
context_list = []
|
||
if args.Context:
|
||
for c in [x.strip() for x in args.Context.split(",") if x.strip()]:
|
||
match = next((k for k in KNOWN_CONTEXTS if k.lower() == c.lower()), None)
|
||
if not match:
|
||
print("Error: unknown context '%s' (expected: %s)" % (c, ", ".join(KNOWN_CONTEXTS)))
|
||
sys.exit(1)
|
||
context_list.append(match)
|
||
if args.Context and check_list and "modules" not in check_list:
|
||
print("Error: -Context applies to the syntax check - add 'modules' to -Checks")
|
||
sys.exit(1)
|
||
if not context_list:
|
||
context_list = ["ThinClient", "Server"]
|
||
|
||
# --- Свойства для set-properties ---
|
||
prop_flags = []
|
||
if args.SafeMode:
|
||
prop_flags.append(("safe-mode", flag_value(args.SafeMode)))
|
||
if args.Active:
|
||
prop_flags.append(("active", flag_value(args.Active)))
|
||
if args.UnsafeActionProtection:
|
||
prop_flags.append(("unsafe-action-protection", flag_value(args.UnsafeActionProtection)))
|
||
if args.UsedInDistributedInfobase:
|
||
prop_flags.append(("used-in-distributed-infobase", flag_value(args.UsedInDistributedInfobase)))
|
||
if args.Scope:
|
||
prop_flags.append(("scope", args.Scope))
|
||
if args.SecurityProfile is not None:
|
||
prop_flags.append(("security-profile-name", args.SecurityProfile))
|
||
if cmd == "set-properties" and not prop_flags:
|
||
print("Error: set-properties needs at least one property (-SafeMode, -Active, "
|
||
"-UnsafeActionProtection, -UsedInDistributedInfobase, -Scope, -SecurityProfile)")
|
||
sys.exit(1)
|
||
|
||
# --- Соединение ---
|
||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||
sys.exit(1)
|
||
|
||
# --- Дополнительные аргументы: у каждой утилиты свои ---
|
||
arg_hints = {
|
||
"/F": "-InfoBasePath",
|
||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||
"/N": "-UserName",
|
||
"/P": "-Password",
|
||
"--db-path": "-InfoBasePath",
|
||
"--user": "-UserName",
|
||
"--password": "-Password",
|
||
}
|
||
v8_extra_args = resolve_extra_args("1cv8", v8_extra, [], arg_hints)
|
||
ib_extra_args = resolve_extra_args("ibcmd", [], ibcmd_extra, arg_hints)
|
||
if ibcmd_extra and cmd in ("check", "delete"):
|
||
print("Error: -AdditionalIbcmdArguments does not apply to '%s' - it runs the Designer only" % cmd)
|
||
sys.exit(1)
|
||
|
||
# Общий блок хранилища адресует расширение через args.Extension (у расширения своё
|
||
# хранилище) — зеркало $Extension = $Name из PS-порта.
|
||
args.Extension = name
|
||
repo = resolve_repository_settings(args)
|
||
base_label = args.InfoBasePath if args.InfoBasePath else "%s/%s" % (args.InfoBaseServer, args.InfoBaseRef)
|
||
|
||
def invoke_designer(op_args):
|
||
"""Соединение, реквизиты хранилища и /Out навык держит сам."""
|
||
if not has_v8:
|
||
print("Error: 1C executable not found at %s" % v8_exe)
|
||
sys.exit(1)
|
||
temp_dir = tempfile.mkdtemp(prefix="db_cfe_admin_")
|
||
try:
|
||
arguments = ["DESIGNER"]
|
||
if args.InfoBaseServer and args.InfoBaseRef:
|
||
arguments += ["/S", '"%s/%s"' % (args.InfoBaseServer, args.InfoBaseRef)]
|
||
else:
|
||
arguments += ["/F", '"%s"' % args.InfoBasePath]
|
||
if args.UserName:
|
||
arguments.append('/N"%s"' % args.UserName)
|
||
if args.Password:
|
||
arguments.append('/P"%s"' % args.Password)
|
||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов.
|
||
arguments += repository_args(repo)
|
||
arguments += op_args
|
||
out_file = os.path.join(temp_dir, "out.txt")
|
||
arguments += ["/Out", '"%s"' % out_file, "/DisableStartupDialogs"]
|
||
arguments += v8_extra_args
|
||
print("Running: 1cv8.exe " + _redact(" ".join(format_args_for_display(arguments, "1cv8")),
|
||
args.Password, args.UserName, repo.get("password")))
|
||
r = run_v8(v8_exe, arguments)
|
||
log = ""
|
||
if os.path.isfile(out_file):
|
||
with open(out_file, encoding="utf-8-sig", errors="replace") as f:
|
||
log = f.read().strip()
|
||
return {"exit": r.returncode, "log": log, "result": r}
|
||
finally:
|
||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||
|
||
def invoke_ibcmd(op_args):
|
||
arguments = [ibcmd_exe] + list(op_args) + ["--db-path=%s" % args.InfoBasePath]
|
||
if args.UserName:
|
||
arguments.append("--user=%s" % args.UserName)
|
||
if args.Password:
|
||
arguments.append("--password=%s" % args.Password)
|
||
arguments += ib_extra_args
|
||
print("Running: ibcmd " + _redact(" ".join(format_args_for_display(arguments[1:], "ibcmd")),
|
||
args.Password, args.UserName))
|
||
r = run_ibcmd(arguments, has_username=bool(args.UserName), warn_no_user=False)
|
||
return {"exit": r.returncode, "result": r}
|
||
|
||
def platform_failure(res, what):
|
||
print("Error: %s (code: %s)%s" % (what, res["exit"], describe_exit(res["exit"])))
|
||
if res.get("log"):
|
||
print("--- Log ---")
|
||
print(res["log"])
|
||
print("--- End ---")
|
||
print_platform_output(res["result"])
|
||
|
||
def properties_unavailable_reason():
|
||
if not args.InfoBasePath:
|
||
return "свойства читает ibcmd, а он подключается к файловой базе (--db-path)"
|
||
if not has_ibcmd:
|
||
return "рядом с 1cv8 нет ibcmd (%s) - эта установка платформы его не содержит" % ibcmd_exe
|
||
return None
|
||
|
||
def get_extension_properties():
|
||
"""Словарь «имя расширения» -> запись свойств. Пустой, если ibcmd недоступен."""
|
||
if properties_unavailable_reason():
|
||
return {}
|
||
r = invoke_ibcmd(["infobase", "config", "extension", "list"])
|
||
if r["exit"] != 0:
|
||
return {}
|
||
out = {}
|
||
for rec in parse_ibcmd_records((r["result"].stdout or "") + (r["result"].stderr or "")):
|
||
if rec.get("name"):
|
||
out[rec["name"]] = rec
|
||
return out
|
||
|
||
def get_extension_names():
|
||
"""Имена расширений базы - Конфигуратором: работает и без ibcmd, и на серверной базе."""
|
||
r = invoke_designer(["/DumpDBCfgList", "-AllExtensions"])
|
||
if r["exit"] != 0:
|
||
platform_failure(r, "cannot list extensions")
|
||
sys.exit(1)
|
||
return [x.strip() for x in r["log"].splitlines() if x.strip()]
|
||
|
||
def prop_cell(rec, key):
|
||
if rec is None:
|
||
return "—"
|
||
if rec.get(key):
|
||
return format_prop_value(key, rec[key])
|
||
return ""
|
||
|
||
# ========================================================================
|
||
# Команды
|
||
# ========================================================================
|
||
|
||
if cmd == "list":
|
||
names = get_extension_names()
|
||
if has_name:
|
||
names = [n for n in names if n.lower() == name.lower()]
|
||
if not names:
|
||
print("[РАСШИРЕНИЯ] %s (0)" % base_label)
|
||
print(" расширение '%s' в базе не найдено" % name)
|
||
sys.exit(1)
|
||
print("[РАСШИРЕНИЯ] %s (%d)" % (base_label, len(names)))
|
||
if not names:
|
||
print(" расширений нет")
|
||
sys.exit(0)
|
||
props = get_extension_properties()
|
||
reason = properties_unavailable_reason()
|
||
rows = []
|
||
for n in names:
|
||
rec = props.get(n)
|
||
rows.append([n,
|
||
prop_cell(rec, "purpose"),
|
||
prop_cell(rec, "active"),
|
||
prop_cell(rec, "safe-mode"),
|
||
prop_cell(rec, "unsafe-action-protection"),
|
||
prop_cell(rec, "used-in-distributed-infobase"),
|
||
prop_cell(rec, "scope")])
|
||
write_table(["Имя", "Назначение", "Активно", "Безопасный режим", "Защита", "РИБ", "Область"], rows)
|
||
if reason:
|
||
print(" свойства недоступны: %s" % reason)
|
||
sys.exit(0)
|
||
|
||
if cmd == "check":
|
||
# Список расширений заранее не запрашиваем: платформа сама отвечает «расширение не
|
||
# найдено», а лишний запуск конфигуратора стоит дороже разницы в формулировке.
|
||
target = name if has_name else None
|
||
if target:
|
||
print("[ПРОВЕРКА] %s · %s" % (base_label, target))
|
||
else:
|
||
print("[ПРОВЕРКА] %s · все расширения" % base_label)
|
||
|
||
failed = 0
|
||
done = 0
|
||
rows = []
|
||
|
||
# Тот же вопрос платформе задаёт постусловие загрузчиков (семья run_apply_check в
|
||
# db-load-xml): там ответ нужен одной строкой предупреждения, здесь — строкой отчёта
|
||
# рядом с остальными проверками, поэтому запуск идёт своим invoke_designer.
|
||
if "apply" in check_list:
|
||
op_args = ["/CheckCanApplyConfigurationExtensions"]
|
||
if target:
|
||
op_args += ["-Extension", '"%s"' % target]
|
||
r = invoke_designer(op_args)
|
||
done += 1
|
||
log_lines = [x for x in r["log"].splitlines() if x.strip()]
|
||
if r["exit"] == 0:
|
||
rows.append(["применимость", "ОК", "", []])
|
||
elif r["exit"] == 1:
|
||
failed += 1
|
||
rows.append(["применимость", "ОШИБКА", "", log_lines])
|
||
else:
|
||
failed += 1
|
||
rows.append(["применимость", "СБОЙ", "код %s%s" % (r["exit"], describe_exit(r["exit"])), log_lines])
|
||
|
||
# modules и config - одна и та же команда платформы с разным набором флагов, поэтому
|
||
# при запросе обеих делается ОДИН запуск. Без флагов контекста платформа рапортует
|
||
# «ошибок не обнаружено» на заведомо сломанном модуле - набор всегда явный.
|
||
want_modules = "modules" in check_list
|
||
want_config = "config" in check_list
|
||
if want_modules or want_config:
|
||
op_args = ["/CheckConfig"]
|
||
if want_modules:
|
||
op_args += ["-" + c for c in context_list]
|
||
if want_config:
|
||
op_args += ["-ConfigLogIntegrity", "-IncorrectReferences", "-UnreferenceProcedures",
|
||
"-HandlersExistence", "-EmptyHandlers"]
|
||
if target:
|
||
op_args += ["-Extension", '"%s"' % target]
|
||
else:
|
||
op_args.append("-AllExtensions")
|
||
r = invoke_designer(op_args)
|
||
label = "модули и конфигурация" if (want_modules and want_config) else ("модули" if want_modules else "конфигурация")
|
||
done += 1
|
||
log_lines = [x for x in r["log"].splitlines() if x.strip()]
|
||
if r["exit"] == 0:
|
||
rows.append([label, "ОК", "(%s)" % ", ".join(context_list) if want_modules else "", []])
|
||
elif r["exit"] in (1, 101):
|
||
failed += 1
|
||
rows.append([label, "ОШИБКА", "", log_lines])
|
||
else:
|
||
failed += 1
|
||
rows.append([label, "СБОЙ", "код %s%s" % (r["exit"], describe_exit(r["exit"])), log_lines])
|
||
|
||
# Сообщения платформы печатаются построчно под своей проверкой: они называют расширение и
|
||
# место ошибки, и при нескольких расширениях склейка в одну строку нечитаема.
|
||
w = max(len(row[0]) for row in rows) if rows else 0
|
||
for row in rows:
|
||
line = " " + row[0].ljust(w + 2) + row[1].ljust(9)
|
||
if row[2]:
|
||
line += row[2]
|
||
print(line.rstrip())
|
||
for msg in row[3]:
|
||
print(" " + msg.strip())
|
||
if failed:
|
||
print("Итог: провалено %d из %d" % (failed, done))
|
||
sys.exit(1)
|
||
print("Итог: пройдено %d из %d" % (done, done))
|
||
sys.exit(0)
|
||
|
||
if cmd == "set-properties":
|
||
reason = properties_unavailable_reason()
|
||
if reason:
|
||
print("Error: cannot set properties - %s" % reason)
|
||
sys.exit(1)
|
||
before = get_extension_properties()
|
||
if name not in before:
|
||
print("Error: extension '%s' not found in the infobase" % name)
|
||
sys.exit(1)
|
||
op_args = ["infobase", "config", "extension", "update", "--name=%s" % name]
|
||
for key, val in prop_flags:
|
||
op_args.append("--%s=%s" % (key, val))
|
||
r = invoke_ibcmd(op_args)
|
||
if r["exit"] != 0:
|
||
print("Error: cannot set properties (code: %s)%s" % (r["exit"], describe_exit(r["exit"])))
|
||
print_platform_output(r["result"])
|
||
sys.exit(1)
|
||
# Постусловие: состояние перечитывается, а не берётся из кода возврата.
|
||
after = get_extension_properties()
|
||
if name not in after:
|
||
print("Error: extension '%s' disappeared after the update" % name)
|
||
sys.exit(1)
|
||
print("[СВОЙСТВА] %s · %s" % (base_label, name))
|
||
changed = 0
|
||
stale = []
|
||
for key, val in prop_flags:
|
||
was = format_prop_value(key, before[name].get(key, ""))
|
||
now = format_prop_value(key, after[name].get(key, ""))
|
||
want = format_prop_value(key, val)
|
||
if was != now:
|
||
print(" " + prop_ru(key).ljust(30) + "%s → %s" % (was, now))
|
||
changed += 1
|
||
elif now != want:
|
||
stale.append("%s: просили '%s', в базе осталось '%s'" % (prop_ru(key), want, now))
|
||
if stale:
|
||
for s in stale:
|
||
print(" " + s)
|
||
print("Итог: изменено %d, не применено %d" % (changed, len(stale)))
|
||
sys.exit(1)
|
||
if changed == 0:
|
||
print(" свойства уже в этом состоянии")
|
||
print("Итог: изменено %d" % changed)
|
||
sys.exit(0)
|
||
|
||
if cmd == "delete":
|
||
names = get_extension_names()
|
||
if not names:
|
||
print("[УДАЛЕНИЕ] %s" % base_label)
|
||
print(" расширений нет - удалять нечего")
|
||
sys.exit(0)
|
||
if has_name:
|
||
targets = [n for n in names if n.lower() == name.lower()]
|
||
if not targets:
|
||
print("Error: extension '%s' not found in the infobase" % name)
|
||
sys.exit(1)
|
||
else:
|
||
targets = names
|
||
print("[УДАЛЕНИЕ] %s (будет удалено: %d)" % (base_label, len(targets)))
|
||
for t in targets:
|
||
# Имя непустое по построению: пустое отбито разбором, список получен от платформы.
|
||
r = invoke_designer(["/DeleteCfg", "-Extension", '"%s"' % t])
|
||
if r["exit"] != 0:
|
||
platform_failure(r, "cannot delete extension '%s'" % t)
|
||
sys.exit(1)
|
||
print(" удалено: %s" % t)
|
||
# Постусловие: список перечитывается - код возврата платформы ничего не доказывает.
|
||
rest = get_extension_names()
|
||
for t in targets:
|
||
if [n for n in rest if n.lower() == t.lower()]:
|
||
print("Error: platform reported success, but '%s' is still in the infobase" % t)
|
||
sys.exit(1)
|
||
print("Итог: удалено %d, осталось %d" % (len(targets), len(rest)))
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|