"""CLI для dev/админ-посева конфигурации (креды/источники/типы/правила).
В проде всё это будет управляться SPA админ-панелью (постройка UI — #32+);
CLI нужен для локальной разработки и smoke-тестов.
python -m app.cli create-source monitoring [--label ...] [--description ...]
python -m app.cli create-key monitoring [--name ...] # токен печатается ОДИН раз
python -m app.cli add-type monitoring container down
python -m app.cli add-target telegram infra --config '{"chat_id": "@infra"}' [--description ...]
python -m app.cli add-rule "контейнеры вниз" --source monitoring \
--subjects container --actions down --priority high \
--template "Контейнер {{ payload.container }} упал" \
--throttle 300 \
--action telegram:infra --action internal_log
"""
import argparse
import json
import sys
from sqlalchemy import select
from app.auth.apikeys import generate_token, token_hash_of
from app.database import SessionLocal
from app.models import (
ApiKey,
ChannelTarget,
NotificationType,
RoutingRule,
RoutingRuleAction,
Source,
)
def cmd_create_source(args: argparse.Namespace) -> int:
with SessionLocal() as db:
exists = db.execute(select(Source).where(Source.name == args.name)).scalar_one_or_none()
if exists:
print(f"источник '{args.name}' уже существует (id={exists.id})")
return 1
source = Source(name=args.name, label=args.label, description=args.description)
db.add(source)
db.commit()
print(f"ok: source id={source.id} name={source.name}")
return 0
def cmd_create_key(args: argparse.Namespace) -> int:
token = generate_token()
with SessionLocal() as db:
source = db.execute(select(Source).where(Source.name == args.source)).scalar_one_or_none()
if source is None:
print(f"источник '{args.source}' не найден (сначала create-source)")
return 1
key = ApiKey(
source_id=source.id,
name=args.name or "key",
token_hash=token_hash_of(token),
token_hint=token[-4:],
)
db.add(key)
db.commit()
print(key.token_hint)
print(f"ok: key id={key.id} source={args.source}")
print(token) # показывается один раз; хранится только sha256
return 0
def cmd_add_type(args: argparse.Namespace) -> int:
with SessionLocal() as db:
source = db.execute(select(Source).where(Source.name == args.source)).scalar_one_or_none()
if source is None:
print(f"источник '{args.source}' не найден")
return 1
schema = json.loads(args.schema) if args.schema else None
nt = NotificationType(
source_id=source.id, subject=args.subject, action=args.action, payload_schema=schema
)
db.add(nt)
db.commit()
print(f"ok: type id={nt.id} ({args.source}, {args.subject}, {args.action})")
return 0
def cmd_add_target(args: argparse.Namespace) -> int:
with SessionLocal() as db:
target = ChannelTarget(
channel=args.channel,
name=args.name,
description=args.description,
config=json.loads(args.config),
)
db.add(target)
db.commit()
print(f"ok: target id={target.id} {target.channel}/{target.name}")
return 0
def cmd_add_rule(args: argparse.Namespace) -> int:
with SessionLocal() as db:
source_id = None
if args.source:
source = db.execute(select(Source).where(Source.name == args.source)).scalar_one_or_none()
if source is None:
print(f"источник '{args.source}' не найден")
return 1
source_id = source.id
targets: list[tuple[str, int | None]] = []
for spec in args.action:
channel, _, target_name = spec.partition(":")
target_id = None
if target_name:
target = db.execute(
select(ChannelTarget).where(ChannelTarget.name == target_name)
).scalar_one_or_none()
if target is None:
print(f"цель '{target_name}' не найдена")
return 1
target_id = target.id
targets.append((channel, target_id))
rule = RoutingRule(
name=args.name,
enabled=True,
weight=args.weight,
conditions={
"source": args.source, # None => любой источник
"subjects": args.subjects or [],
"actions": args.actions or [],
"priority_min": args.priority or None,
"payload": json.loads(args.payload) if args.payload else None,
},
template=args.template,
throttle_seconds=args.throttle,
)
db.add(rule)
db.flush()
for channel, target_id in targets:
db.add(
RoutingRuleAction(rule_id=rule.id, channel=channel, target_id=target_id)
)
db.commit()
print(f"ok: rule id={rule.id} '{rule.name}' ({len(targets)} действий)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Synapse admin CLI (dev/seed)")
sub = parser.add_subparsers(required=True)
p = sub.add_parser("create-source")
p.add_argument("name")
p.add_argument("--label")
p.add_argument("--description", help="что это за сервис — для ИИ-агента и человека")
p.set_defaults(func=cmd_create_source)
p = sub.add_parser("create-key")
p.add_argument("source")
p.add_argument("--name")
p.set_defaults(func=cmd_create_key)
p = sub.add_parser("add-type")
p.add_argument("source")
p.add_argument("subject")
p.add_argument("action")
p.add_argument("--schema") # JSON-строка схемы payload'а
p.set_defaults(func=cmd_add_type)
p = sub.add_parser("add-target")
p.add_argument("channel")
p.add_argument("name")
p.add_argument("--description", help="что это за клиент-сервис — для ИИ-агента и человека")
p.add_argument("--config", default="{}")
p.set_defaults(func=cmd_add_target)
p = sub.add_parser("add-rule")
p.add_argument("name")
p.add_argument("--source")
p.add_argument("--subjects", nargs="*")
p.add_argument("--actions", nargs="*")
p.add_argument("--priority")
p.add_argument("--payload", help='JSON условий по payload, напр. \'{"container": "api"}\'')
p.add_argument("--template")
p.add_argument("--weight", type=int, default=0)
p.add_argument("--throttle", type=int, help="шумодав: не чаще одной доставки в эту цель за N с")
p.add_argument("--action", action="append", default=[]) # channel[:target]
p.set_defaults(func=cmd_add_rule)
args = parser.parse_args()
assert hasattr(args, "func")
return args.func(args)
if __name__ == "__main__":
sys.exit(main())