Newer
Older
gn-synapse / app / cli.py
"""CLI для dev/админ-посева конфигурации (креды/источники/типы/правила).

В проде всё это будет управляться SPA админ-панелью (постройка UI — #32+);
CLI нужен для локальной разработки и smoke-тестов.

    python -m app.cli create-source monitoring [--label ...]
    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"}'
    python -m app.cli add-rule "контейнеры вниз" --source monitoring \
        --subjects container --actions down --priority high \
        --template "Контейнер {{ payload.container }} упал" \
        --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,
            },
            template=args.template,
        )
        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("--template")
    p.add_argument("--weight", type=int, default=0)
    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())