Files
cc-1c-skills/.roo/skills/db-cfe-admin/scripts/db-cfe-admin.py
T

1002 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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",
]
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)
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 = []
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()