|
# retoor <retoor@molodetz.nl>
|
|
|
|
import argparse
|
|
import sys
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils import strip_html
|
|
|
|
|
|
def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None):
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
audit.record_system(
|
|
event_key,
|
|
actor_kind="cli",
|
|
actor_role="system",
|
|
origin="cli",
|
|
target_type=target_type,
|
|
target_uid=target_uid,
|
|
target_label=target_label,
|
|
summary=summary,
|
|
metadata=metadata,
|
|
links=links,
|
|
)
|
|
|
|
|
|
def cmd_role_get(args):
|
|
users = get_table("users")
|
|
user = users.find_one(username=args.username)
|
|
if not user:
|
|
print(f"User '{args.username}' not found")
|
|
sys.exit(1)
|
|
print(user.get("role", "member").lower())
|
|
|
|
|
|
def cmd_role_set(args):
|
|
role = args.role.lower()
|
|
if role not in ("member", "admin"):
|
|
print("Role must be 'member' or 'admin'")
|
|
sys.exit(1)
|
|
|
|
users = get_table("users")
|
|
user = users.find_one(username=args.username)
|
|
if not user:
|
|
print(f"User '{args.username}' not found")
|
|
sys.exit(1)
|
|
|
|
old_role = user.get("role")
|
|
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
_audit_cli(
|
|
"cli.role.set",
|
|
f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}",
|
|
metadata={"old": old_role, "new": role.capitalize()},
|
|
target_type="user",
|
|
target_uid=user["uid"],
|
|
target_label=args.username,
|
|
links=[audit.target("user", user["uid"], args.username)],
|
|
)
|
|
print(f"User '{args.username}' role set to '{role}'")
|
|
|
|
|
|
def cmd_apikey_get(args):
|
|
users = get_table("users")
|
|
user = users.find_one(username=args.username)
|
|
if not user:
|
|
print(f"User '{args.username}' not found")
|
|
sys.exit(1)
|
|
print(user.get("api_key", "") or "")
|
|
|
|
|
|
def cmd_apikey_reset(args):
|
|
from devplacepy.utils import generate_uid, clear_user_cache
|
|
|
|
users = get_table("users")
|
|
user = users.find_one(username=args.username)
|
|
if not user:
|
|
print(f"User '{args.username}' not found")
|
|
sys.exit(1)
|
|
new_key = generate_uid()
|
|
users.update({"uid": user["uid"], "api_key": new_key}, ["uid"])
|
|
clear_user_cache(user["uid"])
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
_audit_cli(
|
|
"cli.apikey.reset",
|
|
f"CLI regenerated the API key of user {args.username}",
|
|
target_type="user",
|
|
target_uid=user["uid"],
|
|
target_label=args.username,
|
|
links=[audit.target("user", user["uid"], args.username)],
|
|
)
|
|
print(new_key)
|
|
|
|
|
|
def cmd_apikey_backfill(args):
|
|
from devplacepy.database import backfill_api_keys
|
|
|
|
updated = backfill_api_keys()
|
|
_audit_cli(
|
|
"cli.apikey.backfill",
|
|
f"CLI backfilled API keys for {updated} users",
|
|
metadata={"count": updated},
|
|
)
|
|
print(f"Assigned API keys to {updated} user(s) without one")
|
|
|
|
|
|
def cmd_devii_reset_quota(args):
|
|
from devplacepy.database import db
|
|
|
|
table_name = "devii_usage_ledger"
|
|
if table_name not in db.tables:
|
|
print(f"Table '{table_name}' does not exist, nothing to reset")
|
|
return
|
|
table = db[table_name]
|
|
if args.all:
|
|
count = table.count()
|
|
table.delete()
|
|
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count})
|
|
print(f"Reset all AI quotas ({count} ledger rows deleted)")
|
|
return
|
|
if args.guests:
|
|
count = table.count(owner_kind="guest")
|
|
table.delete(owner_kind="guest")
|
|
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count})
|
|
print(f"Reset all guest AI quotas ({count} ledger rows deleted)")
|
|
return
|
|
if not args.username:
|
|
print("Provide a username, or --guests, or --all")
|
|
sys.exit(1)
|
|
user = get_table("users").find_one(username=args.username)
|
|
if not user:
|
|
print(f"User '{args.username}' not found")
|
|
sys.exit(1)
|
|
count = table.count(owner_kind="user", owner_id=user["uid"])
|
|
table.delete(owner_kind="user", owner_id=user["uid"])
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
_audit_cli(
|
|
"cli.devii.quota.reset",
|
|
f"CLI reset AI quota for {args.username}",
|
|
metadata={"scope": "user", "rows_removed": count},
|
|
target_type="user",
|
|
target_uid=user["uid"],
|
|
target_label=args.username,
|
|
links=[audit.target("user", user["uid"], args.username)],
|
|
)
|
|
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
|
|
|
|
|
|
def cmd_news_clear(args):
|
|
from devplacepy.database import db
|
|
|
|
deleted = {}
|
|
for table in ("news", "news_images", "news_sync"):
|
|
if table in db.tables:
|
|
count = db[table].count()
|
|
db[table].delete()
|
|
deleted[table] = count
|
|
print(f"Deleted {count} rows from '{table}'")
|
|
else:
|
|
print(f"Table '{table}' does not exist, skipping")
|
|
_audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted})
|
|
print("News data cleared")
|
|
|
|
|
|
def cmd_news_sanitize(args):
|
|
from devplacepy.database import db
|
|
|
|
if "news" not in db.tables:
|
|
print("News table does not exist")
|
|
return
|
|
news_table = db["news"]
|
|
updated = 0
|
|
for row in news_table.all():
|
|
desc = (strip_html(row.get("description", "") or ""))[:5000]
|
|
content = (strip_html(row.get("content", "") or ""))[:10000]
|
|
if desc != row.get("description", "") or content != row.get("content", ""):
|
|
news_table.update(
|
|
{"id": row["id"], "description": desc, "content": content}, ["id"]
|
|
)
|
|
updated += 1
|
|
_audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated})
|
|
print(f"Sanitized {updated} news article(s)")
|
|
|
|
|
|
def cmd_attachments_prune(args):
|
|
from datetime import datetime, timezone, timedelta
|
|
from devplacepy.database import db
|
|
from devplacepy.attachments import delete_attachment
|
|
|
|
if "attachments" not in db.tables:
|
|
print("Attachments table does not exist")
|
|
return
|
|
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
|
|
orphans = [
|
|
att
|
|
for att in db["attachments"].find(target_type="", target_uid="")
|
|
if att.get("created_at", "") < cutoff
|
|
]
|
|
for att in orphans:
|
|
delete_attachment(att["uid"])
|
|
_audit_cli(
|
|
"cli.attachments.prune",
|
|
f"CLI pruned {len(orphans)} orphan attachments",
|
|
metadata={"count": len(orphans), "hours": args.hours},
|
|
)
|
|
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
|
|
|
|
|
|
def _remove_zip_artifacts(job):
|
|
import shutil
|
|
from pathlib import Path
|
|
from devplacepy.services.jobs.zip_service import STAGING_DIR
|
|
|
|
local_path = (job.get("result") or {}).get("local_path")
|
|
if local_path:
|
|
Path(local_path).unlink(missing_ok=True)
|
|
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)
|
|
|
|
|
|
def cmd_zips_prune(args):
|
|
from datetime import datetime, timezone
|
|
from devplacepy.services.jobs import queue
|
|
|
|
now = datetime.now(timezone.utc)
|
|
removed = 0
|
|
for job in queue.list_jobs(kind="zip", status=queue.DONE):
|
|
expires_at = job.get("expires_at")
|
|
if not expires_at:
|
|
continue
|
|
try:
|
|
expiry = datetime.fromisoformat(expires_at)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if expiry < now:
|
|
_remove_zip_artifacts(job)
|
|
get_table("jobs").delete(uid=job["uid"])
|
|
removed += 1
|
|
_audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed})
|
|
print(f"Pruned {removed} expired zip job(s)")
|
|
|
|
|
|
def cmd_zips_clear(args):
|
|
from devplacepy.services.jobs import queue
|
|
|
|
jobs = queue.list_jobs(kind="zip")
|
|
for job in jobs:
|
|
_remove_zip_artifacts(job)
|
|
get_table("jobs").delete(uid=job["uid"])
|
|
_audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)})
|
|
print(f"Cleared {len(jobs)} zip job(s) and their archives")
|
|
|
|
|
|
def cmd_forks_prune(args):
|
|
from datetime import datetime, timezone
|
|
from devplacepy.services.jobs import queue
|
|
|
|
now = datetime.now(timezone.utc)
|
|
removed = 0
|
|
for job in queue.list_jobs(kind="fork", status=queue.DONE):
|
|
expires_at = job.get("expires_at")
|
|
if not expires_at:
|
|
continue
|
|
try:
|
|
expiry = datetime.fromisoformat(expires_at)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if expiry < now:
|
|
get_table("jobs").delete(uid=job["uid"])
|
|
removed += 1
|
|
_audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed})
|
|
print(f"Pruned {removed} expired fork job(s)")
|
|
|
|
|
|
def cmd_forks_clear(args):
|
|
from devplacepy.services.jobs import queue
|
|
|
|
jobs = queue.list_jobs(kind="fork")
|
|
for job in jobs:
|
|
get_table("jobs").delete(uid=job["uid"])
|
|
_audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)})
|
|
print(f"Cleared {len(jobs)} fork job(s)")
|
|
|
|
|
|
def cmd_containers_list(args):
|
|
from devplacepy.services.containers import store
|
|
|
|
instances = store.all_instances()
|
|
if not instances:
|
|
print("No container instances")
|
|
return
|
|
for inst in instances:
|
|
print(
|
|
f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} "
|
|
f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}"
|
|
)
|
|
|
|
|
|
def cmd_containers_reconcile(args):
|
|
import asyncio
|
|
from devplacepy.services.containers.service import ContainerService
|
|
|
|
asyncio.run(ContainerService().run_once())
|
|
_audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass")
|
|
print("Reconcile pass complete")
|
|
|
|
|
|
def cmd_containers_prune(args):
|
|
import asyncio
|
|
from devplacepy.services.containers.runtime import get_backend
|
|
from devplacepy.services.containers.service import ContainerService
|
|
|
|
async def run():
|
|
await ContainerService().run_once()
|
|
await get_backend().image_prune()
|
|
|
|
asyncio.run(run())
|
|
_audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images")
|
|
print("Reaped orphans and pruned dangling images")
|
|
|
|
|
|
def cmd_containers_prune_builds(args):
|
|
import asyncio
|
|
from devplacepy.database import db, get_table
|
|
from devplacepy.services.containers.runtime import get_backend
|
|
|
|
async def run():
|
|
backend = get_backend()
|
|
removed = 0
|
|
if "builds" in db.tables:
|
|
for build in list(get_table("builds").find()):
|
|
tag = build.get("image_tag")
|
|
if tag:
|
|
await backend.remove_image(tag)
|
|
removed += 1
|
|
for table in ("builds", "dockerfile_versions", "dockerfiles"):
|
|
if table in db.tables:
|
|
get_table(table).delete()
|
|
return removed
|
|
|
|
removed = asyncio.run(run())
|
|
_audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed})
|
|
print(
|
|
f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables"
|
|
)
|
|
|
|
|
|
def cmd_containers_gc_workspaces(args):
|
|
import shutil
|
|
from pathlib import Path
|
|
from devplacepy import config
|
|
from devplacepy.services.containers import store
|
|
|
|
active = {inst["project_uid"] for inst in store.all_instances()}
|
|
base = Path(config.CONTAINER_WORKSPACES_DIR)
|
|
removed = 0
|
|
if base.is_dir():
|
|
for child in base.iterdir():
|
|
if child.is_dir() and child.name not in active:
|
|
shutil.rmtree(child, ignore_errors=True)
|
|
removed += 1
|
|
_audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
|
|
print(
|
|
f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
|
|
)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="DevPlace admin CLI")
|
|
sub = parser.add_subparsers(title="commands", dest="command")
|
|
|
|
role = sub.add_parser("role", help="Manage user roles")
|
|
role_sub = role.add_subparsers(title="action", dest="action")
|
|
|
|
role_get = role_sub.add_parser("get", help="Get a user's role")
|
|
role_get.add_argument("username")
|
|
role_get.set_defaults(func=cmd_role_get)
|
|
|
|
role_set = role_sub.add_parser("set", help="Set a user's role")
|
|
role_set.add_argument("username")
|
|
role_set.add_argument("role", choices=["member", "admin"])
|
|
role_set.set_defaults(func=cmd_role_set)
|
|
|
|
apikey = sub.add_parser("apikey", help="Manage user API keys")
|
|
apikey_sub = apikey.add_subparsers(title="action", dest="action")
|
|
apikey_get = apikey_sub.add_parser("get", help="Print a user's API key")
|
|
apikey_get.add_argument("username")
|
|
apikey_get.set_defaults(func=cmd_apikey_get)
|
|
apikey_reset = apikey_sub.add_parser("reset", help="Regenerate a user's API key")
|
|
apikey_reset.add_argument("username")
|
|
apikey_reset.set_defaults(func=cmd_apikey_reset)
|
|
apikey_backfill = apikey_sub.add_parser(
|
|
"backfill", help="Assign API keys to users that lack one"
|
|
)
|
|
apikey_backfill.set_defaults(func=cmd_apikey_backfill)
|
|
|
|
news = sub.add_parser("news", help="News management")
|
|
news_sub = news.add_subparsers(title="action", dest="action")
|
|
news_clear = news_sub.add_parser(
|
|
"clear", help="Delete all news from local database"
|
|
)
|
|
news_clear.set_defaults(func=cmd_news_clear)
|
|
news_sanitize = news_sub.add_parser(
|
|
"sanitize", help="Strip HTML from all existing news descriptions and content"
|
|
)
|
|
news_sanitize.set_defaults(func=cmd_news_sanitize)
|
|
|
|
attachments = sub.add_parser("attachments", help="Attachment management")
|
|
att_sub = attachments.add_subparsers(title="action", dest="action")
|
|
att_prune = att_sub.add_parser(
|
|
"prune", help="Remove orphaned attachment records and files"
|
|
)
|
|
att_prune.add_argument(
|
|
"--hours",
|
|
type=int,
|
|
default=24,
|
|
help="Only prune orphans older than this many hours",
|
|
)
|
|
att_prune.set_defaults(func=cmd_attachments_prune)
|
|
|
|
devii = sub.add_parser("devii", help="Devii assistant management")
|
|
devii_sub = devii.add_subparsers(title="action", dest="action")
|
|
devii_reset = devii_sub.add_parser(
|
|
"reset-quota", help="Reset the rolling 24h AI spend quota"
|
|
)
|
|
devii_reset.add_argument(
|
|
"username", nargs="?", help="Reset the quota for a single user"
|
|
)
|
|
devii_reset.add_argument(
|
|
"--guests", action="store_true", help="Reset every guest quota"
|
|
)
|
|
devii_reset.add_argument(
|
|
"--all", action="store_true", help="Reset every quota (users and guests)"
|
|
)
|
|
devii_reset.set_defaults(func=cmd_devii_reset_quota)
|
|
|
|
zips = sub.add_parser("zips", help="Zip archive job management")
|
|
zips_sub = zips.add_subparsers(title="action", dest="action")
|
|
zips_prune = zips_sub.add_parser(
|
|
"prune", help="Delete expired zip archives and their job rows"
|
|
)
|
|
zips_prune.set_defaults(func=cmd_zips_prune)
|
|
zips_clear = zips_sub.add_parser(
|
|
"clear", help="Delete every zip archive and job row"
|
|
)
|
|
zips_clear.set_defaults(func=cmd_zips_clear)
|
|
|
|
forks = sub.add_parser("forks", help="Fork job management")
|
|
forks_sub = forks.add_subparsers(title="action", dest="action")
|
|
forks_prune = forks_sub.add_parser(
|
|
"prune", help="Delete expired completed fork job rows (forked projects persist)"
|
|
)
|
|
forks_prune.set_defaults(func=cmd_forks_prune)
|
|
forks_clear = forks_sub.add_parser(
|
|
"clear", help="Delete every fork job row (forked projects persist)"
|
|
)
|
|
forks_clear.set_defaults(func=cmd_forks_clear)
|
|
|
|
containers = sub.add_parser("containers", help="Container manager")
|
|
containers_sub = containers.add_subparsers(title="action", dest="action")
|
|
containers_sub.add_parser("list", help="List container instances").set_defaults(
|
|
func=cmd_containers_list
|
|
)
|
|
containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(
|
|
func=cmd_containers_reconcile
|
|
)
|
|
containers_sub.add_parser(
|
|
"prune", help="Reap orphan containers and dangling images"
|
|
).set_defaults(func=cmd_containers_prune)
|
|
containers_sub.add_parser(
|
|
"prune-builds",
|
|
help="Remove legacy per-project images and clear the dockerfiles/builds tables",
|
|
).set_defaults(func=cmd_containers_prune_builds)
|
|
containers_sub.add_parser(
|
|
"gc-workspaces", help="Remove workspace dirs with no instances"
|
|
).set_defaults(func=cmd_containers_gc_workspaces)
|
|
|
|
args = parser.parse_args()
|
|
if hasattr(args, "func"):
|
|
args.func(args)
|
|
else:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|