cmd_serve now reads api_host from Config with a 127.0.0.1 safe default, matching the existing api_port pattern. --host/--port CLI flags still override. Local config is bumped to bind 0.0.0.0:8200. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
352 lines
11 KiB
Python
352 lines
11 KiB
Python
"""Pipekit CLI — `pipekit doctor`, `pipekit init`, later `serve` and `tui`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
from . import __version__
|
|
from . import db, drivers, engine, jrunner, repo
|
|
from .config import get_config
|
|
|
|
|
|
def cmd_init(args) -> int:
|
|
db.init_db()
|
|
print(f"initialised {get_config().database}")
|
|
return 0
|
|
|
|
|
|
def cmd_doctor(args) -> int:
|
|
checks: list[tuple[str, bool, str]] = []
|
|
|
|
try:
|
|
cfg = get_config()
|
|
checks.append(("config", True, str(cfg.source)))
|
|
except Exception as e:
|
|
checks.append(("config", False, f"{type(e).__name__}: {e}"))
|
|
_report(checks)
|
|
return 1
|
|
|
|
ok, msg = jrunner.version()
|
|
checks.append(("jrunner", ok, msg))
|
|
|
|
ok, msg = db.ping()
|
|
checks.append(("database", ok, msg))
|
|
|
|
return _report(checks)
|
|
|
|
|
|
def cmd_drivers_list(args) -> int:
|
|
kinds = drivers.available_kinds()
|
|
width = max(len(k) for k, _ in kinds)
|
|
print("available drivers:")
|
|
for kind, label in kinds:
|
|
print(f" {kind.ljust(width)} {label}")
|
|
return 0
|
|
|
|
|
|
_DEFAULT_JDBC_CLASSES = {
|
|
"db2": "com.ibm.as400.access.AS400JDBCDriver",
|
|
"pg": "org.postgresql.Driver",
|
|
"mssql": "com.microsoft.sqlserver.jdbc.SQLServerDriver",
|
|
}
|
|
|
|
|
|
def cmd_drivers_register(args) -> int:
|
|
try:
|
|
drivers.get_driver(args.kind)
|
|
except ValueError as e:
|
|
print(f"error: {e}")
|
|
return 1
|
|
|
|
existing = [d for d in repo.list_drivers() if d["kind"] == args.kind]
|
|
if existing and not args.force:
|
|
print(f"driver kind {args.kind!r} already registered as "
|
|
f"{existing[0]['name']!r} (id={existing[0]['id']}). "
|
|
f"Use --force to add a second row.")
|
|
return 0
|
|
|
|
class_name = args.class_name or _DEFAULT_JDBC_CLASSES.get(args.kind)
|
|
if not class_name:
|
|
print(f"error: no built-in JDBC class for kind {args.kind!r}; "
|
|
f"pass --class explicitly")
|
|
return 1
|
|
|
|
import os
|
|
if not os.path.exists(args.jar):
|
|
print(f"warning: jar {args.jar!r} does not exist "
|
|
f"(registering anyway)")
|
|
|
|
name = args.name or f"{args.kind}-jdbc"
|
|
row = repo.create_driver(
|
|
name=name, kind=args.kind, jar_file=args.jar,
|
|
class_name=class_name, url_template=args.url_template,
|
|
)
|
|
print(f"registered driver id={row['id']} name={row['name']!r} "
|
|
f"kind={row['kind']!r}")
|
|
return 0
|
|
|
|
|
|
def cmd_drivers_show(args) -> int:
|
|
try:
|
|
d = drivers.get_driver(args.kind)
|
|
except ValueError as e:
|
|
print(f"error: {e}")
|
|
return 1
|
|
fields = d.browse_fields()
|
|
print(f"driver: {d.kind} {d.label}")
|
|
print(f"wizard browse fields ({len(fields)}):")
|
|
for f in fields:
|
|
req = "required" if f.required else "optional"
|
|
default = f" default={f.default!r}" if f.default else ""
|
|
help_ = f" — {f.help}" if f.help else ""
|
|
print(f" {f.name:<16} {req:<8} [{f.label}]{default}{help_}")
|
|
return 0
|
|
|
|
|
|
def cmd_run(args) -> int:
|
|
module = repo.get_module_by_name(args.module)
|
|
if module is None:
|
|
print(f"error: module {args.module!r} not found")
|
|
return 1
|
|
try:
|
|
outcome = engine.run_module(module["id"], dry_run=args.dry_run)
|
|
except engine.LockBusy as e:
|
|
print(f"busy: {e}")
|
|
return 1
|
|
|
|
tag = "DRY RUN — no jrunner calls made" if args.dry_run else ""
|
|
print(f"run_id={outcome.run_id} status={outcome.status} "
|
|
f"rows={outcome.row_count} {tag}".rstrip())
|
|
print()
|
|
if outcome.resolved_source_sql:
|
|
print("-- resolved source SQL --")
|
|
print(outcome.resolved_source_sql)
|
|
print()
|
|
if outcome.merge_sql:
|
|
print("-- merge SQL --")
|
|
print(outcome.merge_sql)
|
|
print()
|
|
if outcome.error:
|
|
print("-- error --")
|
|
print(outcome.error)
|
|
return 0 if outcome.status == "success" else 1
|
|
|
|
|
|
def cmd_serve(args) -> int:
|
|
import uvicorn
|
|
from .api import create_app
|
|
|
|
cfg = get_config()
|
|
host = args.host or cfg.api_host
|
|
port = args.port or cfg.api_port
|
|
uvicorn.run(create_app(), host=host, port=port, reload=args.reload)
|
|
return 0
|
|
|
|
|
|
def cmd_set_password(args) -> int:
|
|
import getpass
|
|
pw = getpass.getpass(f"password for {args.username}: ")
|
|
if not pw:
|
|
print("error: empty password")
|
|
return 1
|
|
repo.set_setting("api_user", args.username)
|
|
repo.set_setting("api_pass", pw)
|
|
print(f"credentials saved for user {args.username!r}")
|
|
print("(set `api_auth_enabled: true` in config.yaml to enforce)")
|
|
return 0
|
|
|
|
|
|
_DEFAULT_SECRETS_FILE = "/etc/pipekit/secrets.env"
|
|
|
|
|
|
def _secrets_path(args) -> str:
|
|
import os
|
|
return (getattr(args, "file", None)
|
|
or os.environ.get("PIPEKIT_SECRETS")
|
|
or _DEFAULT_SECRETS_FILE)
|
|
|
|
|
|
def cmd_secrets_list(args) -> int:
|
|
import os
|
|
path = _secrets_path(args)
|
|
if not os.path.exists(path):
|
|
print(f"{path}: no such file")
|
|
return 0
|
|
keys: list[str] = []
|
|
with open(path) as f:
|
|
for line in f:
|
|
s = line.strip()
|
|
if not s or s.startswith("#") or "=" not in s:
|
|
continue
|
|
keys.append(s.split("=", 1)[0])
|
|
print(f"{path} — {len(keys)} secret(s):")
|
|
for k in keys:
|
|
print(f" {k}")
|
|
return 0
|
|
|
|
|
|
def cmd_secrets_set(args) -> int:
|
|
import getpass
|
|
import os
|
|
import stat
|
|
path = _secrets_path(args)
|
|
value = args.value if args.value is not None else getpass.getpass(
|
|
f"value for {args.key}: ")
|
|
if not value:
|
|
print("error: empty value")
|
|
return 1
|
|
|
|
lines: list[str] = []
|
|
replaced = False
|
|
if os.path.exists(path):
|
|
with open(path) as f:
|
|
for line in f:
|
|
s = line.strip()
|
|
if s and not s.startswith("#") and "=" in s \
|
|
and s.split("=", 1)[0] == args.key:
|
|
lines.append(f"{args.key}={value}\n")
|
|
replaced = True
|
|
else:
|
|
lines.append(line)
|
|
if not replaced:
|
|
if lines and not lines[-1].endswith("\n"):
|
|
lines.append("\n")
|
|
lines.append(f"{args.key}={value}\n")
|
|
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
f.writelines(lines)
|
|
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
os.replace(tmp, path)
|
|
print(f"{'updated' if replaced else 'added'} {args.key} in {path}")
|
|
return 0
|
|
|
|
|
|
def cmd_secrets_unset(args) -> int:
|
|
import os
|
|
path = _secrets_path(args)
|
|
if not os.path.exists(path):
|
|
print(f"{path}: no such file")
|
|
return 1
|
|
kept: list[str] = []
|
|
removed = False
|
|
with open(path) as f:
|
|
for line in f:
|
|
s = line.strip()
|
|
if s and not s.startswith("#") and "=" in s \
|
|
and s.split("=", 1)[0] == args.key:
|
|
removed = True
|
|
continue
|
|
kept.append(line)
|
|
if not removed:
|
|
print(f"{args.key} not set in {path}")
|
|
return 0
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
f.writelines(kept)
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, path)
|
|
print(f"removed {args.key} from {path}")
|
|
return 0
|
|
|
|
|
|
def _report(checks) -> int:
|
|
width = max(len(name) for name, _, _ in checks)
|
|
failures = 0
|
|
for name, ok, msg in checks:
|
|
mark = "OK " if ok else "FAIL"
|
|
print(f" [{mark}] {name.ljust(width)} {msg}")
|
|
if not ok:
|
|
failures += 1
|
|
print()
|
|
if failures:
|
|
print(f"{failures} check(s) failed")
|
|
return 1
|
|
print("all checks passed")
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(prog="pipekit")
|
|
p.add_argument("--version", action="version", version=f"pipekit {__version__}")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
p_init = sub.add_parser("init", help="create/upgrade the SQLite schema")
|
|
p_init.set_defaults(func=cmd_init)
|
|
|
|
p_doc = sub.add_parser("doctor", help="check config, jrunner, database")
|
|
p_doc.set_defaults(func=cmd_doctor)
|
|
|
|
p_drv = sub.add_parser("drivers", help="inspect the driver registry")
|
|
drv_sub = p_drv.add_subparsers(dest="drv_cmd", required=True)
|
|
|
|
p_drv_list = drv_sub.add_parser("list", help="list available drivers")
|
|
p_drv_list.set_defaults(func=cmd_drivers_list)
|
|
|
|
p_drv_show = drv_sub.add_parser("show", help="show a driver's wizard browse fields")
|
|
p_drv_show.add_argument("kind", help="one of the kinds from `pipekit drivers list`")
|
|
p_drv_show.set_defaults(func=cmd_drivers_show)
|
|
|
|
p_drv_reg = drv_sub.add_parser(
|
|
"register", help="add a driver row to the database")
|
|
p_drv_reg.add_argument("kind", help="driver kind (db2, pg, mssql)")
|
|
p_drv_reg.add_argument("--jar", required=True,
|
|
help="absolute path to the JDBC jar")
|
|
p_drv_reg.add_argument("--name",
|
|
help="registry name (default: <kind>-jdbc)")
|
|
p_drv_reg.add_argument("--class", dest="class_name",
|
|
help="JDBC Driver class (default: built-in per kind)")
|
|
p_drv_reg.add_argument("--url-template",
|
|
help="optional JDBC URL template for the wizard")
|
|
p_drv_reg.add_argument("--force", action="store_true",
|
|
help="register even if a row for this kind exists")
|
|
p_drv_reg.set_defaults(func=cmd_drivers_register)
|
|
|
|
p_run = sub.add_parser("run", help="run a module by name (synchronous)")
|
|
p_run.add_argument("module", help="module name")
|
|
p_run.add_argument("--dry-run", action="store_true",
|
|
help="build SQL but do not invoke jrunner")
|
|
p_run.set_defaults(func=cmd_run)
|
|
|
|
p_serve = sub.add_parser("serve", help="start the HTTP API")
|
|
p_serve.add_argument("--host", default=None,
|
|
help="defaults to config.yaml api_host (127.0.0.1)")
|
|
p_serve.add_argument("--port", type=int, default=None,
|
|
help="defaults to config.yaml api_port")
|
|
p_serve.add_argument("--reload", action="store_true")
|
|
p_serve.set_defaults(func=cmd_serve)
|
|
|
|
p_pw = sub.add_parser("set-password", help="set API Basic Auth credentials")
|
|
p_pw.add_argument("username")
|
|
p_pw.set_defaults(func=cmd_set_password)
|
|
|
|
p_sec = sub.add_parser(
|
|
"secrets",
|
|
help=f"manage the env-var file for DB passwords (default {_DEFAULT_SECRETS_FILE})")
|
|
sec_sub = p_sec.add_subparsers(dest="sec_cmd", required=True)
|
|
|
|
p_sec_list = sec_sub.add_parser("list", help="list keys in the secrets file")
|
|
p_sec_list.add_argument("--file", help=f"override path (default {_DEFAULT_SECRETS_FILE})")
|
|
p_sec_list.set_defaults(func=cmd_secrets_list)
|
|
|
|
p_sec_set = sec_sub.add_parser("set", help="add or update a KEY (prompted if value omitted)")
|
|
p_sec_set.add_argument("key")
|
|
p_sec_set.add_argument("value", nargs="?", default=None,
|
|
help="value; omit to be prompted (safer)")
|
|
p_sec_set.add_argument("--file", help=f"override path (default {_DEFAULT_SECRETS_FILE})")
|
|
p_sec_set.set_defaults(func=cmd_secrets_set)
|
|
|
|
p_sec_unset = sec_sub.add_parser("unset", help="remove a KEY from the secrets file")
|
|
p_sec_unset.add_argument("key")
|
|
p_sec_unset.add_argument("--file", help=f"override path (default {_DEFAULT_SECRETS_FILE})")
|
|
p_sec_unset.set_defaults(func=cmd_secrets_unset)
|
|
|
|
args = p.parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|