|
import argparse
|
|
import sys
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils import strip_html
|
|
|
|
|
|
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)
|
|
|
|
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
|
|
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"])
|
|
print(new_key)
|
|
|
|
|
|
def cmd_apikey_backfill(args):
|
|
from devplacepy.database import backfill_api_keys
|
|
updated = backfill_api_keys()
|
|
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()
|
|
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")
|
|
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"])
|
|
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
|
|
|
|
|
|
def cmd_news_clear(args):
|
|
from devplacepy.database import db
|
|
for table in ("news", "news_images", "news_sync"):
|
|
if table in db.tables:
|
|
count = db[table].count()
|
|
db[table].delete()
|
|
print(f"Deleted {count} rows from '{table}'")
|
|
else:
|
|
print(f"Table '{table}' does not exist, skipping")
|
|
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
|
|
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"])
|
|
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
|
|
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"])
|
|
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
|
|
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"])
|
|
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())
|
|
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())
|
|
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())
|
|
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
|
|
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()
|