feat: add GPU-specific fields to marketplace offers and create dedicated GPU marketplace router
- Add GPU fields (model, memory, count, CUDA version, price, region) to MarketplaceOffer model - Create new marketplace_gpu router for GPU-specific operations - Update offer sync to populate GPU fields from miner capabilities - Move GPU attributes from generic attributes dict to dedicated fields - Update MarketplaceOfferView schema with GPU fields - Expand CLI README with comprehensive documentation and
This commit is contained in:
5
cli/aitbc_cli/__init__.py
Normal file
5
cli/aitbc_cli/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""AITBC CLI - Command Line Interface for AITBC Network"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "AITBC Team"
|
||||
__email__ = "team@aitbc.net"
|
||||
70
cli/aitbc_cli/auth/__init__.py
Normal file
70
cli/aitbc_cli/auth/__init__.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Authentication and credential management for AITBC CLI"""
|
||||
|
||||
import keyring
|
||||
import os
|
||||
from typing import Optional, Dict
|
||||
from ..utils import success, error, warning
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manages authentication credentials using secure keyring storage"""
|
||||
|
||||
SERVICE_NAME = "aitbc-cli"
|
||||
|
||||
def __init__(self):
|
||||
self.keyring = keyring.get_keyring()
|
||||
|
||||
def store_credential(self, name: str, api_key: str, environment: str = "default"):
|
||||
"""Store an API key securely"""
|
||||
try:
|
||||
key = f"{environment}_{name}"
|
||||
self.keyring.set_password(self.SERVICE_NAME, key, api_key)
|
||||
success(f"Credential '{name}' stored for environment '{environment}'")
|
||||
except Exception as e:
|
||||
error(f"Failed to store credential: {e}")
|
||||
|
||||
def get_credential(self, name: str, environment: str = "default") -> Optional[str]:
|
||||
"""Retrieve an API key"""
|
||||
try:
|
||||
key = f"{environment}_{name}"
|
||||
return self.keyring.get_password(self.SERVICE_NAME, key)
|
||||
except Exception as e:
|
||||
warning(f"Failed to retrieve credential: {e}")
|
||||
return None
|
||||
|
||||
def delete_credential(self, name: str, environment: str = "default"):
|
||||
"""Delete an API key"""
|
||||
try:
|
||||
key = f"{environment}_{name}"
|
||||
self.keyring.delete_password(self.SERVICE_NAME, key)
|
||||
success(f"Credential '{name}' deleted for environment '{environment}'")
|
||||
except Exception as e:
|
||||
error(f"Failed to delete credential: {e}")
|
||||
|
||||
def list_credentials(self, environment: str = None) -> Dict[str, str]:
|
||||
"""List all stored credentials (without showing the actual keys)"""
|
||||
# Note: keyring doesn't provide a direct way to list all keys
|
||||
# This is a simplified version that checks for common credential names
|
||||
credentials = []
|
||||
envs = [environment] if environment else ["default", "dev", "staging", "prod"]
|
||||
names = ["client", "miner", "admin"]
|
||||
|
||||
for env in envs:
|
||||
for name in names:
|
||||
key = f"{env}_{name}"
|
||||
if self.get_credential(name, env):
|
||||
credentials.append(f"{name}@{env}")
|
||||
|
||||
return credentials
|
||||
|
||||
def store_env_credential(self, name: str):
|
||||
"""Store credential from environment variable"""
|
||||
env_var = f"{name.upper()}_API_KEY"
|
||||
api_key = os.getenv(env_var)
|
||||
|
||||
if not api_key:
|
||||
error(f"Environment variable {env_var} not set")
|
||||
return False
|
||||
|
||||
self.store_credential(name, api_key)
|
||||
return True
|
||||
1
cli/aitbc_cli/commands/__init__.py
Normal file
1
cli/aitbc_cli/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Command modules for AITBC CLI"""
|
||||
445
cli/aitbc_cli/commands/admin.py
Normal file
445
cli/aitbc_cli/commands/admin.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""Admin commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional, List, Dict, Any
|
||||
from ..utils import output, error, success
|
||||
|
||||
|
||||
@click.group()
|
||||
def admin():
|
||||
"""System administration commands"""
|
||||
pass
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.pass_context
|
||||
def status(ctx):
|
||||
"""Get system status"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/admin/status",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
status_data = response.json()
|
||||
output(status_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get system status: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.option("--limit", default=50, help="Number of jobs to show")
|
||||
@click.option("--status", help="Filter by status")
|
||||
@click.pass_context
|
||||
def jobs(ctx, limit: int, status: Optional[str]):
|
||||
"""List all jobs in the system"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
params = {"limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/admin/jobs",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
jobs = response.json()
|
||||
output(jobs, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get jobs: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.argument("job_id")
|
||||
@click.pass_context
|
||||
def job_details(ctx, job_id: str):
|
||||
"""Get detailed job information"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/admin/jobs/{job_id}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
job_data = response.json()
|
||||
output(job_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Job not found: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.argument("job_id")
|
||||
@click.pass_context
|
||||
def delete_job(ctx, job_id: str):
|
||||
"""Delete a job from the system"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
if not click.confirm(f"Are you sure you want to delete job {job_id}?"):
|
||||
return
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.delete(
|
||||
f"{config.coordinator_url}/v1/admin/jobs/{job_id}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
success(f"Job {job_id} deleted")
|
||||
output({"status": "deleted", "job_id": job_id}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to delete job: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.option("--limit", default=50, help="Number of miners to show")
|
||||
@click.option("--status", help="Filter by status")
|
||||
@click.pass_context
|
||||
def miners(ctx, limit: int, status: Optional[str]):
|
||||
"""List all registered miners"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
params = {"limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/admin/miners",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
miners = response.json()
|
||||
output(miners, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get miners: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.argument("miner_id")
|
||||
@click.pass_context
|
||||
def miner_details(ctx, miner_id: str):
|
||||
"""Get detailed miner information"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/admin/miners/{miner_id}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
miner_data = response.json()
|
||||
output(miner_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Miner not found: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.argument("miner_id")
|
||||
@click.pass_context
|
||||
def deactivate_miner(ctx, miner_id: str):
|
||||
"""Deactivate a miner"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
if not click.confirm(f"Are you sure you want to deactivate miner {miner_id}?"):
|
||||
return
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/admin/miners/{miner_id}/deactivate",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
success(f"Miner {miner_id} deactivated")
|
||||
output({"status": "deactivated", "miner_id": miner_id}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to deactivate miner: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.argument("miner_id")
|
||||
@click.pass_context
|
||||
def activate_miner(ctx, miner_id: str):
|
||||
"""Activate a miner"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/admin/miners/{miner_id}/activate",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
success(f"Miner {miner_id} activated")
|
||||
output({"status": "activated", "miner_id": miner_id}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to activate miner: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.option("--days", type=int, default=7, help="Number of days to analyze")
|
||||
@click.pass_context
|
||||
def analytics(ctx, days: int):
|
||||
"""Get system analytics"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/admin/analytics",
|
||||
params={"days": days},
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
analytics_data = response.json()
|
||||
output(analytics_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get analytics: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.option("--level", default="INFO", help="Log level (DEBUG, INFO, WARNING, ERROR)")
|
||||
@click.option("--limit", default=100, help="Number of log entries to show")
|
||||
@click.pass_context
|
||||
def logs(ctx, level: str, limit: int):
|
||||
"""Get system logs"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/admin/logs",
|
||||
params={"level": level, "limit": limit},
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logs_data = response.json()
|
||||
output(logs_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get logs: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.argument("job_id")
|
||||
@click.option("--reason", help="Reason for priority change")
|
||||
@click.pass_context
|
||||
def prioritize_job(ctx, job_id: str, reason: Optional[str]):
|
||||
"""Set job to high priority"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/admin/jobs/{job_id}/prioritize",
|
||||
json={"reason": reason or "Admin priority"},
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
success(f"Job {job_id} prioritized")
|
||||
output({"status": "prioritized", "job_id": job_id}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to prioritize job: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command()
|
||||
@click.option("--action", required=True, help="Action to perform")
|
||||
@click.option("--target", help="Target of the action")
|
||||
@click.option("--data", help="Additional data (JSON)")
|
||||
@click.pass_context
|
||||
def execute(ctx, action: str, target: Optional[str], data: Optional[str]):
|
||||
"""Execute custom admin action"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Parse data if provided
|
||||
parsed_data = {}
|
||||
if data:
|
||||
try:
|
||||
parsed_data = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
error("Invalid JSON data")
|
||||
return
|
||||
|
||||
if target:
|
||||
parsed_data["target"] = target
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/admin/execute/{action}",
|
||||
json=parsed_data,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
output(result, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to execute action: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.group()
|
||||
def maintenance():
|
||||
"""Maintenance operations"""
|
||||
pass
|
||||
|
||||
|
||||
@maintenance.command()
|
||||
@click.pass_context
|
||||
def cleanup(ctx):
|
||||
"""Clean up old jobs and data"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
if not click.confirm("This will clean up old jobs and temporary data. Continue?"):
|
||||
return
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/admin/maintenance/cleanup",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
success("Cleanup completed")
|
||||
output(result, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Cleanup failed: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@maintenance.command()
|
||||
@click.pass_context
|
||||
def reindex(ctx):
|
||||
"""Reindex the database"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
if not click.confirm("This will reindex the entire database. Continue?"):
|
||||
return
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/admin/maintenance/reindex",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
success("Reindex started")
|
||||
output(result, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Reindex failed: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@maintenance.command()
|
||||
@click.pass_context
|
||||
def backup(ctx):
|
||||
"""Create system backup"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/admin/maintenance/backup",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
success("Backup created")
|
||||
output(result, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Backup failed: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@admin.command(name="audit-log")
|
||||
@click.option("--limit", default=50, help="Number of entries to show")
|
||||
@click.option("--action", "action_filter", help="Filter by action type")
|
||||
@click.pass_context
|
||||
def audit_log(ctx, limit: int, action_filter: Optional[str]):
|
||||
"""View audit log"""
|
||||
from ..utils import AuditLogger
|
||||
|
||||
logger = AuditLogger()
|
||||
entries = logger.get_logs(limit=limit, action_filter=action_filter)
|
||||
|
||||
if not entries:
|
||||
output({"message": "No audit log entries found"}, ctx.obj['output_format'])
|
||||
return
|
||||
|
||||
output(entries, ctx.obj['output_format'])
|
||||
|
||||
|
||||
# Add maintenance group to admin
|
||||
admin.add_command(maintenance)
|
||||
220
cli/aitbc_cli/commands/auth.py
Normal file
220
cli/aitbc_cli/commands/auth.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Authentication commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import os
|
||||
from typing import Optional
|
||||
from ..auth import AuthManager
|
||||
from ..utils import output, success, error, warning
|
||||
|
||||
|
||||
@click.group()
|
||||
def auth():
|
||||
"""Manage API keys and authentication"""
|
||||
pass
|
||||
|
||||
|
||||
@auth.command()
|
||||
@click.argument("api_key")
|
||||
@click.option("--environment", default="default", help="Environment name (default, dev, staging, prod)")
|
||||
@click.pass_context
|
||||
def login(ctx, api_key: str, environment: str):
|
||||
"""Store API key for authentication"""
|
||||
auth_manager = AuthManager()
|
||||
|
||||
# Validate API key format (basic check)
|
||||
if not api_key or len(api_key) < 10:
|
||||
error("Invalid API key format")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
auth_manager.store_credential("client", api_key, environment)
|
||||
|
||||
output({
|
||||
"status": "logged_in",
|
||||
"environment": environment,
|
||||
"note": "API key stored securely"
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@auth.command()
|
||||
@click.option("--environment", default="default", help="Environment name")
|
||||
@click.pass_context
|
||||
def logout(ctx, environment: str):
|
||||
"""Remove stored API key"""
|
||||
auth_manager = AuthManager()
|
||||
|
||||
auth_manager.delete_credential("client", environment)
|
||||
|
||||
output({
|
||||
"status": "logged_out",
|
||||
"environment": environment
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@auth.command()
|
||||
@click.option("--environment", default="default", help="Environment name")
|
||||
@click.option("--show", is_flag=True, help="Show the actual API key")
|
||||
@click.pass_context
|
||||
def token(ctx, environment: str, show: bool):
|
||||
"""Show stored API key"""
|
||||
auth_manager = AuthManager()
|
||||
|
||||
api_key = auth_manager.get_credential("client", environment)
|
||||
|
||||
if api_key:
|
||||
if show:
|
||||
output({
|
||||
"api_key": api_key,
|
||||
"environment": environment
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
output({
|
||||
"api_key": "***REDACTED***",
|
||||
"environment": environment,
|
||||
"length": len(api_key)
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
output({
|
||||
"message": "No API key stored",
|
||||
"environment": environment
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@auth.command()
|
||||
@click.pass_context
|
||||
def status(ctx):
|
||||
"""Show authentication status"""
|
||||
auth_manager = AuthManager()
|
||||
|
||||
credentials = auth_manager.list_credentials()
|
||||
|
||||
if credentials:
|
||||
output({
|
||||
"status": "authenticated",
|
||||
"stored_credentials": credentials
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
output({
|
||||
"status": "not_authenticated",
|
||||
"message": "No stored credentials found"
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@auth.command()
|
||||
@click.option("--environment", default="default", help="Environment name")
|
||||
@click.pass_context
|
||||
def refresh(ctx, environment: str):
|
||||
"""Refresh authentication (placeholder for token refresh)"""
|
||||
auth_manager = AuthManager()
|
||||
|
||||
api_key = auth_manager.get_credential("client", environment)
|
||||
|
||||
if api_key:
|
||||
# In a real implementation, this would refresh the token
|
||||
output({
|
||||
"status": "refreshed",
|
||||
"environment": environment,
|
||||
"message": "Authentication refreshed (placeholder)"
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"No API key found for environment: {environment}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@auth.group()
|
||||
def keys():
|
||||
"""Manage multiple API keys"""
|
||||
pass
|
||||
|
||||
|
||||
@keys.command()
|
||||
@click.pass_context
|
||||
def list(ctx):
|
||||
"""List all stored API keys"""
|
||||
auth_manager = AuthManager()
|
||||
credentials = auth_manager.list_credentials()
|
||||
|
||||
if credentials:
|
||||
output({
|
||||
"credentials": credentials
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
output({
|
||||
"message": "No credentials stored"
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@keys.command()
|
||||
@click.argument("name")
|
||||
@click.argument("api_key")
|
||||
@click.option("--permissions", help="Comma-separated permissions (client,miner,admin)")
|
||||
@click.option("--environment", default="default", help="Environment name")
|
||||
@click.pass_context
|
||||
def create(ctx, name: str, api_key: str, permissions: Optional[str], environment: str):
|
||||
"""Create a new API key entry"""
|
||||
auth_manager = AuthManager()
|
||||
|
||||
if not api_key or len(api_key) < 10:
|
||||
error("Invalid API key format")
|
||||
return
|
||||
|
||||
auth_manager.store_credential(name, api_key, environment)
|
||||
|
||||
output({
|
||||
"status": "created",
|
||||
"name": name,
|
||||
"environment": environment,
|
||||
"permissions": permissions or "none"
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@keys.command()
|
||||
@click.argument("name")
|
||||
@click.option("--environment", default="default", help="Environment name")
|
||||
@click.pass_context
|
||||
def revoke(ctx, name: str, environment: str):
|
||||
"""Revoke an API key"""
|
||||
auth_manager = AuthManager()
|
||||
|
||||
auth_manager.delete_credential(name, environment)
|
||||
|
||||
output({
|
||||
"status": "revoked",
|
||||
"name": name,
|
||||
"environment": environment
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@keys.command()
|
||||
@click.pass_context
|
||||
def rotate(ctx):
|
||||
"""Rotate all API keys (placeholder)"""
|
||||
warning("Key rotation not implemented yet")
|
||||
|
||||
output({
|
||||
"message": "Key rotation would update all stored keys",
|
||||
"status": "placeholder"
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@auth.command()
|
||||
@click.argument("name")
|
||||
@click.pass_context
|
||||
def import_env(ctx, name: str):
|
||||
"""Import API key from environment variable"""
|
||||
env_var = f"{name.upper()}_API_KEY"
|
||||
api_key = os.getenv(env_var)
|
||||
|
||||
if not api_key:
|
||||
error(f"Environment variable {env_var} not set")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
auth_manager = AuthManager()
|
||||
auth_manager.store_credential(name, api_key)
|
||||
|
||||
output({
|
||||
"status": "imported",
|
||||
"name": name,
|
||||
"source": env_var
|
||||
}, ctx.obj['output_format'])
|
||||
236
cli/aitbc_cli/commands/blockchain.py
Normal file
236
cli/aitbc_cli/commands/blockchain.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Blockchain commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from typing import Optional, List
|
||||
from ..utils import output, error
|
||||
|
||||
|
||||
@click.group()
|
||||
def blockchain():
|
||||
"""Query blockchain information and status"""
|
||||
pass
|
||||
|
||||
|
||||
@blockchain.command()
|
||||
@click.option("--limit", type=int, default=10, help="Number of blocks to show")
|
||||
@click.option("--from-height", type=int, help="Start from this block height")
|
||||
@click.pass_context
|
||||
def blocks(ctx, limit: int, from_height: Optional[int]):
|
||||
"""List recent blocks"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
params = {"limit": limit}
|
||||
if from_height:
|
||||
params["from_height"] = from_height
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/explorer/blocks",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
output(data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to fetch blocks: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@blockchain.command()
|
||||
@click.argument("block_hash")
|
||||
@click.pass_context
|
||||
def block(ctx, block_hash: str):
|
||||
"""Get details of a specific block"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/explorer/blocks/{block_hash}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
block_data = response.json()
|
||||
output(block_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Block not found: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@blockchain.command()
|
||||
@click.argument("tx_hash")
|
||||
@click.pass_context
|
||||
def transaction(ctx, tx_hash: str):
|
||||
"""Get transaction details"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/explorer/transactions/{tx_hash}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
tx_data = response.json()
|
||||
output(tx_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Transaction not found: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@blockchain.command()
|
||||
@click.option("--node", type=int, default=1, help="Node number (1, 2, or 3)")
|
||||
@click.pass_context
|
||||
def status(ctx, node: int):
|
||||
"""Get blockchain node status"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Map node to RPC URL
|
||||
node_urls = {
|
||||
1: "http://localhost:8082",
|
||||
2: "http://localhost:8081",
|
||||
3: "http://aitbc.keisanki.net/rpc"
|
||||
}
|
||||
|
||||
rpc_url = node_urls.get(node)
|
||||
if not rpc_url:
|
||||
error(f"Invalid node number: {node}")
|
||||
return
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{rpc_url}/status",
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
status_data = response.json()
|
||||
output({
|
||||
"node": node,
|
||||
"rpc_url": rpc_url,
|
||||
"status": status_data
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Node {node} not responding: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Failed to connect to node {node}: {e}")
|
||||
|
||||
|
||||
@blockchain.command()
|
||||
@click.pass_context
|
||||
def sync_status(ctx):
|
||||
"""Get blockchain synchronization status"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/blockchain/sync",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
sync_data = response.json()
|
||||
output(sync_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get sync status: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@blockchain.command()
|
||||
@click.pass_context
|
||||
def peers(ctx):
|
||||
"""List connected peers"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/blockchain/peers",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
peers_data = response.json()
|
||||
output(peers_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get peers: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@blockchain.command()
|
||||
@click.pass_context
|
||||
def info(ctx):
|
||||
"""Get blockchain information"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/blockchain/info",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
info_data = response.json()
|
||||
output(info_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get blockchain info: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@blockchain.command()
|
||||
@click.pass_context
|
||||
def supply(ctx):
|
||||
"""Get token supply information"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/blockchain/supply",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
supply_data = response.json()
|
||||
output(supply_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get supply info: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@blockchain.command()
|
||||
@click.pass_context
|
||||
def validators(ctx):
|
||||
"""List blockchain validators"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/blockchain/validators",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
validators_data = response.json()
|
||||
output(validators_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get validators: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
373
cli/aitbc_cli/commands/client.py
Normal file
373
cli/aitbc_cli/commands/client.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""Client commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import json
|
||||
import time
|
||||
from typing import Optional
|
||||
from ..utils import output, error, success
|
||||
|
||||
|
||||
@click.group()
|
||||
def client():
|
||||
"""Submit and manage jobs"""
|
||||
pass
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.option("--type", "job_type", default="inference", help="Job type")
|
||||
@click.option("--prompt", help="Prompt for inference jobs")
|
||||
@click.option("--model", help="Model name")
|
||||
@click.option("--ttl", default=900, help="Time to live in seconds")
|
||||
@click.option("--file", type=click.File('r'), help="Submit job from JSON file")
|
||||
@click.option("--retries", default=0, help="Number of retry attempts (0 = no retry)")
|
||||
@click.option("--retry-delay", default=1.0, help="Initial retry delay in seconds")
|
||||
@click.pass_context
|
||||
def submit(ctx, job_type: str, prompt: Optional[str], model: Optional[str],
|
||||
ttl: int, file, retries: int, retry_delay: float):
|
||||
"""Submit a job to the coordinator"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Build job data
|
||||
if file:
|
||||
try:
|
||||
task_data = json.load(file)
|
||||
except Exception as e:
|
||||
error(f"Failed to read job file: {e}")
|
||||
return
|
||||
else:
|
||||
task_data = {"type": job_type}
|
||||
if prompt:
|
||||
task_data["prompt"] = prompt
|
||||
if model:
|
||||
task_data["model"] = model
|
||||
|
||||
# Submit job with retry and exponential backoff
|
||||
max_attempts = retries + 1
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/jobs",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={
|
||||
"payload": task_data,
|
||||
"ttl_seconds": ttl
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
job = response.json()
|
||||
result = {
|
||||
"job_id": job.get('job_id'),
|
||||
"status": "submitted",
|
||||
"message": "Job submitted successfully"
|
||||
}
|
||||
if attempt > 1:
|
||||
result["attempts"] = attempt
|
||||
output(result, ctx.obj['output_format'])
|
||||
return
|
||||
else:
|
||||
if attempt < max_attempts:
|
||||
delay = retry_delay * (2 ** (attempt - 1))
|
||||
click.echo(f"Attempt {attempt}/{max_attempts} failed ({response.status_code}), retrying in {delay:.1f}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
error(f"Failed to submit job: {response.status_code} - {response.text}")
|
||||
ctx.exit(response.status_code)
|
||||
except Exception as e:
|
||||
if attempt < max_attempts:
|
||||
delay = retry_delay * (2 ** (attempt - 1))
|
||||
click.echo(f"Attempt {attempt}/{max_attempts} failed ({e}), retrying in {delay:.1f}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
error(f"Network error after {max_attempts} attempts: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.argument("job_id")
|
||||
@click.pass_context
|
||||
def status(ctx, job_id: str):
|
||||
"""Check job status"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/jobs/{job_id}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
output(data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get job status: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.option("--limit", default=10, help="Number of blocks to show")
|
||||
@click.pass_context
|
||||
def blocks(ctx, limit: int):
|
||||
"""List recent blocks"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/explorer/blocks",
|
||||
params={"limit": limit},
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
blocks = response.json()
|
||||
output(blocks, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get blocks: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.argument("job_id")
|
||||
@click.pass_context
|
||||
def cancel(ctx, job_id: str):
|
||||
"""Cancel a job"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/jobs/{job_id}/cancel",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
success(f"Job {job_id} cancelled")
|
||||
else:
|
||||
error(f"Failed to cancel job: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.option("--limit", default=10, help="Number of receipts to show")
|
||||
@click.option("--job-id", help="Filter by job ID")
|
||||
@click.option("--status", help="Filter by status")
|
||||
@click.pass_context
|
||||
def receipts(ctx, limit: int, job_id: Optional[str], status: Optional[str]):
|
||||
"""List job receipts"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
params = {"limit": limit}
|
||||
if job_id:
|
||||
params["job_id"] = job_id
|
||||
if status:
|
||||
params["status"] = status
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/explorer/receipts",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
receipts = response.json()
|
||||
output(receipts, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get receipts: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.option("--limit", default=10, help="Number of jobs to show")
|
||||
@click.option("--status", help="Filter by status (pending, running, completed, failed)")
|
||||
@click.option("--type", help="Filter by job type")
|
||||
@click.option("--from-time", help="Filter jobs from this timestamp (ISO format)")
|
||||
@click.option("--to-time", help="Filter jobs until this timestamp (ISO format)")
|
||||
@click.pass_context
|
||||
def history(ctx, limit: int, status: Optional[str], type: Optional[str],
|
||||
from_time: Optional[str], to_time: Optional[str]):
|
||||
"""Show job history with filtering options"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
params = {"limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if type:
|
||||
params["type"] = type
|
||||
if from_time:
|
||||
params["from_time"] = from_time
|
||||
if to_time:
|
||||
params["to_time"] = to_time
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/jobs/history",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
jobs = response.json()
|
||||
output(jobs, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get job history: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command(name="batch-submit")
|
||||
@click.argument("file_path", type=click.Path(exists=True))
|
||||
@click.option("--format", "file_format", type=click.Choice(["json", "csv"]), default=None, help="File format (auto-detected if not specified)")
|
||||
@click.option("--retries", default=0, help="Retry attempts per job")
|
||||
@click.option("--delay", default=0.5, help="Delay between submissions (seconds)")
|
||||
@click.pass_context
|
||||
def batch_submit(ctx, file_path: str, file_format: Optional[str], retries: int, delay: float):
|
||||
"""Submit multiple jobs from a CSV or JSON file"""
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from ..utils import progress_bar
|
||||
|
||||
config = ctx.obj['config']
|
||||
path = Path(file_path)
|
||||
|
||||
if not file_format:
|
||||
file_format = "csv" if path.suffix.lower() == ".csv" else "json"
|
||||
|
||||
jobs_data = []
|
||||
if file_format == "json":
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
jobs_data = data if isinstance(data, list) else [data]
|
||||
else:
|
||||
with open(path) as f:
|
||||
reader = csv.DictReader(f)
|
||||
jobs_data = list(reader)
|
||||
|
||||
if not jobs_data:
|
||||
error("No jobs found in file")
|
||||
return
|
||||
|
||||
results = {"submitted": 0, "failed": 0, "job_ids": []}
|
||||
|
||||
with progress_bar("Submitting jobs...", total=len(jobs_data)) as (progress, task):
|
||||
for i, job in enumerate(jobs_data):
|
||||
try:
|
||||
task_data = {"type": job.get("type", "inference")}
|
||||
if "prompt" in job:
|
||||
task_data["prompt"] = job["prompt"]
|
||||
if "model" in job:
|
||||
task_data["model"] = job["model"]
|
||||
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.post(
|
||||
f"{config.coordinator_url}/v1/jobs",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={"payload": task_data, "ttl_seconds": int(job.get("ttl", 900))}
|
||||
)
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
results["submitted"] += 1
|
||||
results["job_ids"].append(result.get("job_id"))
|
||||
else:
|
||||
results["failed"] += 1
|
||||
except Exception:
|
||||
results["failed"] += 1
|
||||
|
||||
progress.update(task, advance=1)
|
||||
if delay and i < len(jobs_data) - 1:
|
||||
time.sleep(delay)
|
||||
|
||||
output(results, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@client.command(name="template")
|
||||
@click.argument("action", type=click.Choice(["save", "list", "run", "delete"]))
|
||||
@click.option("--name", help="Template name")
|
||||
@click.option("--type", "job_type", help="Job type")
|
||||
@click.option("--prompt", help="Prompt text")
|
||||
@click.option("--model", help="Model name")
|
||||
@click.option("--ttl", type=int, default=900, help="TTL in seconds")
|
||||
@click.pass_context
|
||||
def template(ctx, action: str, name: Optional[str], job_type: Optional[str],
|
||||
prompt: Optional[str], model: Optional[str], ttl: int):
|
||||
"""Manage job templates for repeated tasks"""
|
||||
from pathlib import Path
|
||||
|
||||
template_dir = Path.home() / ".aitbc" / "templates"
|
||||
template_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if action == "save":
|
||||
if not name:
|
||||
error("Template name required (--name)")
|
||||
return
|
||||
template_data = {"type": job_type or "inference", "ttl": ttl}
|
||||
if prompt:
|
||||
template_data["prompt"] = prompt
|
||||
if model:
|
||||
template_data["model"] = model
|
||||
with open(template_dir / f"{name}.json", "w") as f:
|
||||
json.dump(template_data, f, indent=2)
|
||||
output({"status": "saved", "name": name, "template": template_data}, ctx.obj['output_format'])
|
||||
|
||||
elif action == "list":
|
||||
templates = []
|
||||
for tf in template_dir.glob("*.json"):
|
||||
with open(tf) as f:
|
||||
data = json.load(f)
|
||||
templates.append({"name": tf.stem, **data})
|
||||
output(templates if templates else {"message": "No templates found"}, ctx.obj['output_format'])
|
||||
|
||||
elif action == "run":
|
||||
if not name:
|
||||
error("Template name required (--name)")
|
||||
return
|
||||
tf = template_dir / f"{name}.json"
|
||||
if not tf.exists():
|
||||
error(f"Template '{name}' not found")
|
||||
return
|
||||
with open(tf) as f:
|
||||
tmpl = json.load(f)
|
||||
if prompt:
|
||||
tmpl["prompt"] = prompt
|
||||
if model:
|
||||
tmpl["model"] = model
|
||||
ctx.invoke(submit, job_type=tmpl.get("type", "inference"),
|
||||
prompt=tmpl.get("prompt"), model=tmpl.get("model"),
|
||||
ttl=tmpl.get("ttl", 900), file=None, retries=0, retry_delay=1.0)
|
||||
|
||||
elif action == "delete":
|
||||
if not name:
|
||||
error("Template name required (--name)")
|
||||
return
|
||||
tf = template_dir / f"{name}.json"
|
||||
if not tf.exists():
|
||||
error(f"Template '{name}' not found")
|
||||
return
|
||||
tf.unlink()
|
||||
output({"status": "deleted", "name": name}, ctx.obj['output_format'])
|
||||
470
cli/aitbc_cli/commands/config.py
Normal file
470
cli/aitbc_cli/commands/config.py
Normal file
@@ -0,0 +1,470 @@
|
||||
"""Configuration commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import os
|
||||
import yaml
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from ..config import get_config, Config
|
||||
from ..utils import output, error, success
|
||||
|
||||
|
||||
@click.group()
|
||||
def config():
|
||||
"""Manage CLI configuration"""
|
||||
pass
|
||||
|
||||
|
||||
@config.command()
|
||||
@click.pass_context
|
||||
def show(ctx):
|
||||
"""Show current configuration"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
config_dict = {
|
||||
"coordinator_url": config.coordinator_url,
|
||||
"api_key": "***REDACTED***" if config.api_key else None,
|
||||
"timeout": getattr(config, 'timeout', 30),
|
||||
"config_file": getattr(config, 'config_file', None)
|
||||
}
|
||||
|
||||
output(config_dict, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@config.command()
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
@click.option("--global", "global_config", is_flag=True, help="Set global config")
|
||||
@click.pass_context
|
||||
def set(ctx, key: str, value: str, global_config: bool):
|
||||
"""Set configuration value"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Determine config file path
|
||||
if global_config:
|
||||
config_dir = Path.home() / ".config" / "aitbc"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "config.yaml"
|
||||
else:
|
||||
config_file = Path.cwd() / ".aitbc.yaml"
|
||||
|
||||
# Load existing config
|
||||
if config_file.exists():
|
||||
with open(config_file) as f:
|
||||
config_data = yaml.safe_load(f) or {}
|
||||
else:
|
||||
config_data = {}
|
||||
|
||||
# Set the value
|
||||
if key == "api_key":
|
||||
config_data["api_key"] = value
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success("API key set (use --global to set permanently)")
|
||||
elif key == "coordinator_url":
|
||||
config_data["coordinator_url"] = value
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success(f"Coordinator URL set to: {value}")
|
||||
elif key == "timeout":
|
||||
try:
|
||||
config_data["timeout"] = int(value)
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success(f"Timeout set to: {value}s")
|
||||
except ValueError:
|
||||
error("Timeout must be an integer")
|
||||
ctx.exit(1)
|
||||
else:
|
||||
error(f"Unknown configuration key: {key}")
|
||||
ctx.exit(1)
|
||||
|
||||
# Save config
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False)
|
||||
|
||||
output({
|
||||
"config_file": str(config_file),
|
||||
"key": key,
|
||||
"value": value
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@config.command()
|
||||
@click.option("--global", "global_config", is_flag=True, help="Show global config")
|
||||
def path(global_config: bool):
|
||||
"""Show configuration file path"""
|
||||
if global_config:
|
||||
config_dir = Path.home() / ".config" / "aitbc"
|
||||
config_file = config_dir / "config.yaml"
|
||||
else:
|
||||
config_file = Path.cwd() / ".aitbc.yaml"
|
||||
|
||||
output({
|
||||
"config_file": str(config_file),
|
||||
"exists": config_file.exists()
|
||||
})
|
||||
|
||||
|
||||
@config.command()
|
||||
@click.option("--global", "global_config", is_flag=True, help="Edit global config")
|
||||
@click.pass_context
|
||||
def edit(ctx, global_config: bool):
|
||||
"""Open configuration file in editor"""
|
||||
# Determine config file path
|
||||
if global_config:
|
||||
config_dir = Path.home() / ".config" / "aitbc"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "config.yaml"
|
||||
else:
|
||||
config_file = Path.cwd() / ".aitbc.yaml"
|
||||
|
||||
# Create if doesn't exist
|
||||
if not config_file.exists():
|
||||
config = ctx.obj['config']
|
||||
config_data = {
|
||||
"coordinator_url": config.coordinator_url,
|
||||
"timeout": getattr(config, 'timeout', 30)
|
||||
}
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False)
|
||||
|
||||
# Open in editor
|
||||
editor = os.getenv('EDITOR', 'nano')
|
||||
os.system(f"{editor} {config_file}")
|
||||
|
||||
|
||||
@config.command()
|
||||
@click.option("--global", "global_config", is_flag=True, help="Reset global config")
|
||||
@click.pass_context
|
||||
def reset(ctx, global_config: bool):
|
||||
"""Reset configuration to defaults"""
|
||||
# Determine config file path
|
||||
if global_config:
|
||||
config_dir = Path.home() / ".config" / "aitbc"
|
||||
config_file = config_dir / "config.yaml"
|
||||
else:
|
||||
config_file = Path.cwd() / ".aitbc.yaml"
|
||||
|
||||
if not config_file.exists():
|
||||
output({"message": "No configuration file found"})
|
||||
return
|
||||
|
||||
if not click.confirm(f"Reset configuration at {config_file}?"):
|
||||
return
|
||||
|
||||
# Remove config file
|
||||
config_file.unlink()
|
||||
success("Configuration reset to defaults")
|
||||
|
||||
|
||||
@config.command()
|
||||
@click.option("--format", "output_format", type=click.Choice(['yaml', 'json']), default='yaml', help="Output format")
|
||||
@click.option("--global", "global_config", is_flag=True, help="Export global config")
|
||||
@click.pass_context
|
||||
def export(ctx, output_format: str, global_config: bool):
|
||||
"""Export configuration"""
|
||||
# Determine config file path
|
||||
if global_config:
|
||||
config_dir = Path.home() / ".config" / "aitbc"
|
||||
config_file = config_dir / "config.yaml"
|
||||
else:
|
||||
config_file = Path.cwd() / ".aitbc.yaml"
|
||||
|
||||
if not config_file.exists():
|
||||
error("No configuration file found")
|
||||
ctx.exit(1)
|
||||
|
||||
with open(config_file) as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
|
||||
# Redact sensitive data
|
||||
if 'api_key' in config_data:
|
||||
config_data['api_key'] = "***REDACTED***"
|
||||
|
||||
if output_format == 'json':
|
||||
click.echo(json.dumps(config_data, indent=2))
|
||||
else:
|
||||
click.echo(yaml.dump(config_data, default_flow_style=False))
|
||||
|
||||
|
||||
@config.command()
|
||||
@click.argument("file_path")
|
||||
@click.option("--merge", is_flag=True, help="Merge with existing config")
|
||||
@click.option("--global", "global_config", is_flag=True, help="Import to global config")
|
||||
@click.pass_context
|
||||
def import_config(ctx, file_path: str, merge: bool, global_config: bool):
|
||||
"""Import configuration from file"""
|
||||
import_file = Path(file_path)
|
||||
|
||||
if not import_file.exists():
|
||||
error(f"File not found: {file_path}")
|
||||
ctx.exit(1)
|
||||
|
||||
# Load import file
|
||||
try:
|
||||
with open(import_file) as f:
|
||||
if import_file.suffix.lower() == '.json':
|
||||
import_data = json.load(f)
|
||||
else:
|
||||
import_data = yaml.safe_load(f)
|
||||
except json.JSONDecodeError:
|
||||
error("Invalid JSON data")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Failed to parse file: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
# Determine target config file
|
||||
if global_config:
|
||||
config_dir = Path.home() / ".config" / "aitbc"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "config.yaml"
|
||||
else:
|
||||
config_file = Path.cwd() / ".aitbc.yaml"
|
||||
|
||||
# Load existing config if merging
|
||||
if merge and config_file.exists():
|
||||
with open(config_file) as f:
|
||||
config_data = yaml.safe_load(f) or {}
|
||||
config_data.update(import_data)
|
||||
else:
|
||||
config_data = import_data
|
||||
|
||||
# Save config
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False)
|
||||
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success(f"Configuration imported to {config_file}")
|
||||
|
||||
|
||||
@config.command()
|
||||
@click.pass_context
|
||||
def validate(ctx):
|
||||
"""Validate configuration"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# Validate coordinator URL
|
||||
if not config.coordinator_url:
|
||||
errors.append("Coordinator URL is not set")
|
||||
elif not config.coordinator_url.startswith(('http://', 'https://')):
|
||||
errors.append("Coordinator URL must start with http:// or https://")
|
||||
|
||||
# Validate API key
|
||||
if not config.api_key:
|
||||
warnings.append("API key is not set")
|
||||
elif len(config.api_key) < 10:
|
||||
errors.append("API key appears to be too short")
|
||||
|
||||
# Validate timeout
|
||||
timeout = getattr(config, 'timeout', 30)
|
||||
if not isinstance(timeout, (int, float)) or timeout <= 0:
|
||||
errors.append("Timeout must be a positive number")
|
||||
|
||||
# Output results
|
||||
result = {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings
|
||||
}
|
||||
|
||||
if errors:
|
||||
error("Configuration validation failed")
|
||||
ctx.exit(1)
|
||||
elif warnings:
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success("Configuration valid with warnings")
|
||||
else:
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success("Configuration is valid")
|
||||
|
||||
output(result, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@config.command()
|
||||
def environments():
|
||||
"""List available environments"""
|
||||
env_vars = [
|
||||
'AITBC_COORDINATOR_URL',
|
||||
'AITBC_API_KEY',
|
||||
'AITBC_TIMEOUT',
|
||||
'AITBC_CONFIG_FILE',
|
||||
'CLIENT_API_KEY',
|
||||
'MINER_API_KEY',
|
||||
'ADMIN_API_KEY'
|
||||
]
|
||||
|
||||
env_data = {}
|
||||
for var in env_vars:
|
||||
value = os.getenv(var)
|
||||
if value:
|
||||
if 'API_KEY' in var:
|
||||
value = "***REDACTED***"
|
||||
env_data[var] = value
|
||||
|
||||
output({
|
||||
"environment_variables": env_data,
|
||||
"note": "Use export VAR=value to set environment variables"
|
||||
})
|
||||
|
||||
|
||||
@config.group()
|
||||
def profiles():
|
||||
"""Manage configuration profiles"""
|
||||
pass
|
||||
|
||||
|
||||
@profiles.command()
|
||||
@click.argument("name")
|
||||
@click.pass_context
|
||||
def save(ctx, name: str):
|
||||
"""Save current configuration as a profile"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Create profiles directory
|
||||
profiles_dir = Path.home() / ".config" / "aitbc" / "profiles"
|
||||
profiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
profile_file = profiles_dir / f"{name}.yaml"
|
||||
|
||||
# Save profile (without API key)
|
||||
profile_data = {
|
||||
"coordinator_url": config.coordinator_url,
|
||||
"timeout": getattr(config, 'timeout', 30)
|
||||
}
|
||||
|
||||
with open(profile_file, 'w') as f:
|
||||
yaml.dump(profile_data, f, default_flow_style=False)
|
||||
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success(f"Profile '{name}' saved")
|
||||
|
||||
|
||||
@profiles.command()
|
||||
def list():
|
||||
"""List available profiles"""
|
||||
profiles_dir = Path.home() / ".config" / "aitbc" / "profiles"
|
||||
|
||||
if not profiles_dir.exists():
|
||||
output({"profiles": []})
|
||||
return
|
||||
|
||||
profiles = []
|
||||
for profile_file in profiles_dir.glob("*.yaml"):
|
||||
with open(profile_file) as f:
|
||||
profile_data = yaml.safe_load(f)
|
||||
|
||||
profiles.append({
|
||||
"name": profile_file.stem,
|
||||
"coordinator_url": profile_data.get("coordinator_url"),
|
||||
"timeout": profile_data.get("timeout", 30)
|
||||
})
|
||||
|
||||
output({"profiles": profiles})
|
||||
|
||||
|
||||
@profiles.command()
|
||||
@click.argument("name")
|
||||
@click.pass_context
|
||||
def load(ctx, name: str):
|
||||
"""Load a configuration profile"""
|
||||
profiles_dir = Path.home() / ".config" / "aitbc" / "profiles"
|
||||
profile_file = profiles_dir / f"{name}.yaml"
|
||||
|
||||
if not profile_file.exists():
|
||||
error(f"Profile '{name}' not found")
|
||||
ctx.exit(1)
|
||||
|
||||
with open(profile_file) as f:
|
||||
profile_data = yaml.safe_load(f)
|
||||
|
||||
# Load to current config
|
||||
config_file = Path.cwd() / ".aitbc.yaml"
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(profile_data, f, default_flow_style=False)
|
||||
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success(f"Profile '{name}' loaded")
|
||||
|
||||
|
||||
@profiles.command()
|
||||
@click.argument("name")
|
||||
@click.pass_context
|
||||
def delete(ctx, name: str):
|
||||
"""Delete a configuration profile"""
|
||||
profiles_dir = Path.home() / ".config" / "aitbc" / "profiles"
|
||||
profile_file = profiles_dir / f"{name}.yaml"
|
||||
|
||||
if not profile_file.exists():
|
||||
error(f"Profile '{name}' not found")
|
||||
ctx.exit(1)
|
||||
|
||||
if not click.confirm(f"Delete profile '{name}'?"):
|
||||
return
|
||||
|
||||
profile_file.unlink()
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success(f"Profile '{name}' deleted")
|
||||
|
||||
|
||||
@config.command(name="set-secret")
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
@click.pass_context
|
||||
def set_secret(ctx, key: str, value: str):
|
||||
"""Set an encrypted configuration value"""
|
||||
from ..utils import encrypt_value
|
||||
|
||||
config_dir = Path.home() / ".config" / "aitbc"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
secrets_file = config_dir / "secrets.json"
|
||||
|
||||
secrets = {}
|
||||
if secrets_file.exists():
|
||||
with open(secrets_file) as f:
|
||||
secrets = json.load(f)
|
||||
|
||||
secrets[key] = encrypt_value(value)
|
||||
|
||||
with open(secrets_file, "w") as f:
|
||||
json.dump(secrets, f, indent=2)
|
||||
|
||||
# Restrict file permissions
|
||||
secrets_file.chmod(0o600)
|
||||
|
||||
if ctx.obj['output_format'] == 'table':
|
||||
success(f"Secret '{key}' saved (encrypted)")
|
||||
output({"key": key, "status": "encrypted"}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@config.command(name="get-secret")
|
||||
@click.argument("key")
|
||||
@click.pass_context
|
||||
def get_secret(ctx, key: str):
|
||||
"""Get a decrypted configuration value"""
|
||||
from ..utils import decrypt_value
|
||||
|
||||
secrets_file = Path.home() / ".config" / "aitbc" / "secrets.json"
|
||||
|
||||
if not secrets_file.exists():
|
||||
error("No secrets file found")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
with open(secrets_file) as f:
|
||||
secrets = json.load(f)
|
||||
|
||||
if key not in secrets:
|
||||
error(f"Secret '{key}' not found")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
decrypted = decrypt_value(secrets[key])
|
||||
output({"key": key, "value": decrypted}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
# Add profiles group to config
|
||||
config.add_command(profiles)
|
||||
307
cli/aitbc_cli/commands/marketplace.py
Normal file
307
cli/aitbc_cli/commands/marketplace.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""Marketplace commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional, List
|
||||
from ..utils import output, error, success
|
||||
|
||||
|
||||
@click.group()
|
||||
def marketplace():
|
||||
"""GPU marketplace operations"""
|
||||
pass
|
||||
|
||||
|
||||
@marketplace.group()
|
||||
def gpu():
|
||||
"""GPU marketplace operations"""
|
||||
pass
|
||||
|
||||
|
||||
@gpu.command()
|
||||
@click.option("--name", required=True, help="GPU name/model")
|
||||
@click.option("--memory", type=int, help="GPU memory in GB")
|
||||
@click.option("--cuda-cores", type=int, help="Number of CUDA cores")
|
||||
@click.option("--compute-capability", help="Compute capability (e.g., 8.9)")
|
||||
@click.option("--price-per-hour", type=float, help="Price per hour in AITBC")
|
||||
@click.option("--description", help="GPU description")
|
||||
@click.option("--miner-id", help="Miner ID (uses auth key if not provided)")
|
||||
@click.pass_context
|
||||
def register(ctx, name: str, memory: Optional[int], cuda_cores: Optional[int],
|
||||
compute_capability: Optional[str], price_per_hour: Optional[float],
|
||||
description: Optional[str], miner_id: Optional[str]):
|
||||
"""Register GPU on marketplace"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Build GPU specs
|
||||
gpu_specs = {
|
||||
"name": name,
|
||||
"memory_gb": memory,
|
||||
"cuda_cores": cuda_cores,
|
||||
"compute_capability": compute_capability,
|
||||
"price_per_hour": price_per_hour,
|
||||
"description": description
|
||||
}
|
||||
|
||||
# Remove None values
|
||||
gpu_specs = {k: v for k, v in gpu_specs.items() if v is not None}
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/marketplace/gpu/register",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or "",
|
||||
"X-Miner-ID": miner_id or "default"
|
||||
},
|
||||
json={"gpu": gpu_specs}
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
success(f"GPU registered successfully: {result.get('gpu_id')}")
|
||||
output(result, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to register GPU: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@gpu.command()
|
||||
@click.option("--available", is_flag=True, help="Show only available GPUs")
|
||||
@click.option("--model", help="Filter by GPU model (supports wildcards)")
|
||||
@click.option("--memory-min", type=int, help="Minimum memory in GB")
|
||||
@click.option("--price-max", type=float, help="Maximum price per hour")
|
||||
@click.option("--limit", type=int, default=20, help="Maximum number of results")
|
||||
@click.pass_context
|
||||
def list(ctx, available: bool, model: Optional[str], memory_min: Optional[int],
|
||||
price_max: Optional[float], limit: int):
|
||||
"""List available GPUs"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Build query params
|
||||
params = {"limit": limit}
|
||||
if available:
|
||||
params["available"] = "true"
|
||||
if model:
|
||||
params["model"] = model
|
||||
if memory_min:
|
||||
params["memory_min"] = memory_min
|
||||
if price_max:
|
||||
params["price_max"] = price_max
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/marketplace/gpu/list",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
gpus = response.json()
|
||||
output(gpus, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to list GPUs: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@gpu.command()
|
||||
@click.argument("gpu_id")
|
||||
@click.pass_context
|
||||
def details(ctx, gpu_id: str):
|
||||
"""Get GPU details"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
gpu_data = response.json()
|
||||
output(gpu_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"GPU not found: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@gpu.command()
|
||||
@click.argument("gpu_id")
|
||||
@click.option("--hours", type=float, required=True, help="Rental duration in hours")
|
||||
@click.option("--job-id", help="Job ID to associate with rental")
|
||||
@click.pass_context
|
||||
def book(ctx, gpu_id: str, hours: float, job_id: Optional[str]):
|
||||
"""Book a GPU"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
booking_data = {
|
||||
"gpu_id": gpu_id,
|
||||
"duration_hours": hours
|
||||
}
|
||||
if job_id:
|
||||
booking_data["job_id"] = job_id
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}/book",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json=booking_data
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
booking = response.json()
|
||||
success(f"GPU booked successfully: {booking.get('booking_id')}")
|
||||
output(booking, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to book GPU: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@gpu.command()
|
||||
@click.argument("gpu_id")
|
||||
@click.pass_context
|
||||
def release(ctx, gpu_id: str):
|
||||
"""Release a booked GPU"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}/release",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
success(f"GPU {gpu_id} released")
|
||||
output({"status": "released", "gpu_id": gpu_id}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to release GPU: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@marketplace.command()
|
||||
@click.option("--status", help="Filter by status (active, completed, cancelled)")
|
||||
@click.option("--limit", type=int, default=10, help="Number of orders to show")
|
||||
@click.pass_context
|
||||
def orders(ctx, status: Optional[str], limit: int):
|
||||
"""List marketplace orders"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
params = {"limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/marketplace/orders",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
orders = response.json()
|
||||
output(orders, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get orders: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@marketplace.command()
|
||||
@click.argument("model")
|
||||
@click.pass_context
|
||||
def pricing(ctx, model: str):
|
||||
"""Get pricing information for a GPU model"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/marketplace/pricing/{model}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
pricing_data = response.json()
|
||||
output(pricing_data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Pricing not found: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@marketplace.command()
|
||||
@click.argument("gpu_id")
|
||||
@click.option("--limit", type=int, default=10, help="Number of reviews to show")
|
||||
@click.pass_context
|
||||
def reviews(ctx, gpu_id: str, limit: int):
|
||||
"""Get GPU reviews"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}/reviews",
|
||||
params={"limit": limit},
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
reviews = response.json()
|
||||
output(reviews, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get reviews: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@marketplace.command()
|
||||
@click.argument("gpu_id")
|
||||
@click.option("--rating", type=int, required=True, help="Rating (1-5)")
|
||||
@click.option("--comment", help="Review comment")
|
||||
@click.pass_context
|
||||
def review(ctx, gpu_id: str, rating: int, comment: Optional[str]):
|
||||
"""Add a review for a GPU"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
if not 1 <= rating <= 5:
|
||||
error("Rating must be between 1 and 5")
|
||||
return
|
||||
|
||||
try:
|
||||
review_data = {
|
||||
"rating": rating,
|
||||
"comment": comment
|
||||
}
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}/reviews",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json=review_data
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
success("Review added successfully")
|
||||
output({"status": "review_added", "gpu_id": gpu_id}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to add review: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
457
cli/aitbc_cli/commands/miner.py
Normal file
457
cli/aitbc_cli/commands/miner.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""Miner commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import json
|
||||
import time
|
||||
import concurrent.futures
|
||||
from typing import Optional, Dict, Any, List
|
||||
from ..utils import output, error, success
|
||||
|
||||
|
||||
@click.group()
|
||||
def miner():
|
||||
"""Register as miner and process jobs"""
|
||||
pass
|
||||
|
||||
|
||||
@miner.command()
|
||||
@click.option("--gpu", help="GPU model name")
|
||||
@click.option("--memory", type=int, help="GPU memory in GB")
|
||||
@click.option("--cuda-cores", type=int, help="Number of CUDA cores")
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.pass_context
|
||||
def register(ctx, gpu: Optional[str], memory: Optional[int],
|
||||
cuda_cores: Optional[int], miner_id: str):
|
||||
"""Register as a miner with the coordinator"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Build capabilities
|
||||
capabilities = {}
|
||||
if gpu:
|
||||
capabilities["gpu"] = {"model": gpu}
|
||||
if memory:
|
||||
if "gpu" not in capabilities:
|
||||
capabilities["gpu"] = {}
|
||||
capabilities["gpu"]["memory_gb"] = memory
|
||||
if cuda_cores:
|
||||
if "gpu" not in capabilities:
|
||||
capabilities["gpu"] = {}
|
||||
capabilities["gpu"]["cuda_cores"] = cuda_cores
|
||||
|
||||
# Default capabilities if none provided
|
||||
if not capabilities:
|
||||
capabilities = {
|
||||
"cpu": {"cores": 4},
|
||||
"memory": {"gb": 16}
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/miners/register?miner_id={miner_id}",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={"capabilities": capabilities}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
output({
|
||||
"miner_id": miner_id,
|
||||
"status": "registered",
|
||||
"capabilities": capabilities
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to register: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@miner.command()
|
||||
@click.option("--wait", type=int, default=5, help="Max wait time in seconds")
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.pass_context
|
||||
def poll(ctx, wait: int, miner_id: str):
|
||||
"""Poll for a single job"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/miners/poll",
|
||||
headers={
|
||||
"X-Api-Key": config.api_key or "",
|
||||
"X-Miner-ID": miner_id
|
||||
},
|
||||
timeout=wait + 5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
job = response.json()
|
||||
if job:
|
||||
output(job, ctx.obj['output_format'])
|
||||
else:
|
||||
output({"message": "No jobs available"}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to poll: {response.status_code}")
|
||||
except httpx.TimeoutException:
|
||||
output({"message": f"No jobs available within {wait} seconds"}, ctx.obj['output_format'])
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@miner.command()
|
||||
@click.option("--jobs", type=int, default=1, help="Number of jobs to process")
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.pass_context
|
||||
def mine(ctx, jobs: int, miner_id: str):
|
||||
"""Mine continuously for specified number of jobs"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
processed = 0
|
||||
while processed < jobs:
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
# Poll for job
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/miners/poll",
|
||||
headers={
|
||||
"X-Api-Key": config.api_key or "",
|
||||
"X-Miner-ID": miner_id
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
job = response.json()
|
||||
if job:
|
||||
job_id = job.get('job_id')
|
||||
output({
|
||||
"job_id": job_id,
|
||||
"status": "processing",
|
||||
"job_number": processed + 1
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
# Simulate processing (in real implementation, do actual work)
|
||||
time.sleep(2)
|
||||
|
||||
# Submit result
|
||||
result_response = client.post(
|
||||
f"{config.coordinator_url}/v1/miners/{job_id}/result",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or "",
|
||||
"X-Miner-ID": miner_id
|
||||
},
|
||||
json={
|
||||
"result": f"Processed job {job_id}",
|
||||
"success": True
|
||||
}
|
||||
)
|
||||
|
||||
if result_response.status_code == 200:
|
||||
success(f"Job {job_id} completed successfully")
|
||||
processed += 1
|
||||
else:
|
||||
error(f"Failed to submit result: {result_response.status_code}")
|
||||
else:
|
||||
# No job available, wait a bit
|
||||
time.sleep(5)
|
||||
else:
|
||||
error(f"Failed to poll: {response.status_code}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
error(f"Error: {e}")
|
||||
break
|
||||
|
||||
output({
|
||||
"total_processed": processed,
|
||||
"miner_id": miner_id
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@miner.command()
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.pass_context
|
||||
def heartbeat(ctx, miner_id: str):
|
||||
"""Send heartbeat to coordinator"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/miners/heartbeat?miner_id={miner_id}",
|
||||
headers={
|
||||
"X-Api-Key": config.api_key or ""
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
output({
|
||||
"miner_id": miner_id,
|
||||
"status": "heartbeat_sent",
|
||||
"timestamp": time.time()
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to send heartbeat: {response.status_code}")
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
|
||||
@miner.command()
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.pass_context
|
||||
def status(ctx, miner_id: str):
|
||||
"""Check miner status"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# This would typically query a miner status endpoint
|
||||
# For now, we'll just show the miner info
|
||||
output({
|
||||
"miner_id": miner_id,
|
||||
"coordinator": config.coordinator_url,
|
||||
"status": "active"
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@miner.command()
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.option("--from-time", help="Filter from timestamp (ISO format)")
|
||||
@click.option("--to-time", help="Filter to timestamp (ISO format)")
|
||||
@click.pass_context
|
||||
def earnings(ctx, miner_id: str, from_time: Optional[str], to_time: Optional[str]):
|
||||
"""Show miner earnings"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
params = {"miner_id": miner_id}
|
||||
if from_time:
|
||||
params["from_time"] = from_time
|
||||
if to_time:
|
||||
params["to_time"] = to_time
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/miners/{miner_id}/earnings",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
output(data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get earnings: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@miner.command(name="update-capabilities")
|
||||
@click.option("--gpu", help="GPU model name")
|
||||
@click.option("--memory", type=int, help="GPU memory in GB")
|
||||
@click.option("--cuda-cores", type=int, help="Number of CUDA cores")
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.pass_context
|
||||
def update_capabilities(ctx, gpu: Optional[str], memory: Optional[int],
|
||||
cuda_cores: Optional[int], miner_id: str):
|
||||
"""Update miner GPU capabilities"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
capabilities = {}
|
||||
if gpu:
|
||||
capabilities["gpu"] = {"model": gpu}
|
||||
if memory:
|
||||
if "gpu" not in capabilities:
|
||||
capabilities["gpu"] = {}
|
||||
capabilities["gpu"]["memory_gb"] = memory
|
||||
if cuda_cores:
|
||||
if "gpu" not in capabilities:
|
||||
capabilities["gpu"] = {}
|
||||
capabilities["gpu"]["cuda_cores"] = cuda_cores
|
||||
|
||||
if not capabilities:
|
||||
error("No capabilities specified. Use --gpu, --memory, or --cuda-cores.")
|
||||
return
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.put(
|
||||
f"{config.coordinator_url}/v1/miners/{miner_id}/capabilities",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={"capabilities": capabilities}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
output({
|
||||
"miner_id": miner_id,
|
||||
"status": "capabilities_updated",
|
||||
"capabilities": capabilities
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to update capabilities: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@miner.command()
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.option("--force", is_flag=True, help="Force deregistration without confirmation")
|
||||
@click.pass_context
|
||||
def deregister(ctx, miner_id: str, force: bool):
|
||||
"""Deregister miner from the coordinator"""
|
||||
if not force:
|
||||
if not click.confirm(f"Deregister miner '{miner_id}'?"):
|
||||
click.echo("Cancelled.")
|
||||
return
|
||||
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.delete(
|
||||
f"{config.coordinator_url}/v1/miners/{miner_id}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
output({
|
||||
"miner_id": miner_id,
|
||||
"status": "deregistered"
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to deregister: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@miner.command()
|
||||
@click.option("--limit", default=10, help="Number of jobs to show")
|
||||
@click.option("--type", "job_type", help="Filter by job type")
|
||||
@click.option("--min-reward", type=float, help="Minimum reward threshold")
|
||||
@click.option("--status", "job_status", help="Filter by status (pending, running, completed, failed)")
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.pass_context
|
||||
def jobs(ctx, limit: int, job_type: Optional[str], min_reward: Optional[float],
|
||||
job_status: Optional[str], miner_id: str):
|
||||
"""List miner jobs with filtering"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
params = {"limit": limit, "miner_id": miner_id}
|
||||
if job_type:
|
||||
params["type"] = job_type
|
||||
if min_reward is not None:
|
||||
params["min_reward"] = min_reward
|
||||
if job_status:
|
||||
params["status"] = job_status
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/miners/{miner_id}/jobs",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
output(data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get jobs: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
def _process_single_job(config, miner_id: str, worker_id: int) -> Dict[str, Any]:
|
||||
"""Process a single job (used by concurrent mine)"""
|
||||
try:
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.get(
|
||||
f"{config.coordinator_url}/v1/miners/poll",
|
||||
headers={
|
||||
"X-Api-Key": config.api_key or "",
|
||||
"X-Miner-ID": miner_id
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
job = response.json()
|
||||
if job:
|
||||
job_id = job.get('job_id')
|
||||
time.sleep(2) # Simulate processing
|
||||
|
||||
result_response = http_client.post(
|
||||
f"{config.coordinator_url}/v1/miners/{job_id}/result",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or "",
|
||||
"X-Miner-ID": miner_id
|
||||
},
|
||||
json={"result": f"Processed by worker {worker_id}", "success": True}
|
||||
)
|
||||
|
||||
return {
|
||||
"worker": worker_id,
|
||||
"job_id": job_id,
|
||||
"status": "completed" if result_response.status_code == 200 else "failed"
|
||||
}
|
||||
return {"worker": worker_id, "status": "no_job"}
|
||||
except Exception as e:
|
||||
return {"worker": worker_id, "status": "error", "error": str(e)}
|
||||
|
||||
|
||||
@miner.command(name="concurrent-mine")
|
||||
@click.option("--workers", type=int, default=2, help="Number of concurrent workers")
|
||||
@click.option("--jobs", "total_jobs", type=int, default=5, help="Total jobs to process")
|
||||
@click.option("--miner-id", default="cli-miner", help="Miner ID")
|
||||
@click.pass_context
|
||||
def concurrent_mine(ctx, workers: int, total_jobs: int, miner_id: str):
|
||||
"""Mine with concurrent job processing"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
success(f"Starting concurrent mining: {workers} workers, {total_jobs} jobs")
|
||||
|
||||
completed = 0
|
||||
failed = 0
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
remaining = total_jobs
|
||||
while remaining > 0:
|
||||
batch_size = min(remaining, workers)
|
||||
futures = [
|
||||
executor.submit(_process_single_job, config, miner_id, i)
|
||||
for i in range(batch_size)
|
||||
]
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
if result.get("status") == "completed":
|
||||
completed += 1
|
||||
remaining -= 1
|
||||
output(result, ctx.obj['output_format'])
|
||||
elif result.get("status") == "no_job":
|
||||
time.sleep(2)
|
||||
else:
|
||||
failed += 1
|
||||
remaining -= 1
|
||||
|
||||
output({
|
||||
"status": "finished",
|
||||
"completed": completed,
|
||||
"failed": failed,
|
||||
"workers": workers
|
||||
}, ctx.obj['output_format'])
|
||||
381
cli/aitbc_cli/commands/monitor.py
Normal file
381
cli/aitbc_cli/commands/monitor.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""Monitoring and dashboard commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from ..utils import output, error, success, console
|
||||
|
||||
|
||||
@click.group()
|
||||
def monitor():
|
||||
"""Monitoring, metrics, and alerting commands"""
|
||||
pass
|
||||
|
||||
|
||||
@monitor.command()
|
||||
@click.option("--refresh", type=int, default=5, help="Refresh interval in seconds")
|
||||
@click.option("--duration", type=int, default=0, help="Duration in seconds (0 = indefinite)")
|
||||
@click.pass_context
|
||||
def dashboard(ctx, refresh: int, duration: int):
|
||||
"""Real-time system dashboard"""
|
||||
config = ctx.obj['config']
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
while True:
|
||||
elapsed = time.time() - start_time
|
||||
if duration > 0 and elapsed >= duration:
|
||||
break
|
||||
|
||||
console.clear()
|
||||
console.rule("[bold blue]AITBC Dashboard[/bold blue]")
|
||||
console.print(f"[dim]Refreshing every {refresh}s | Elapsed: {int(elapsed)}s[/dim]\n")
|
||||
|
||||
# Fetch system status
|
||||
try:
|
||||
with httpx.Client(timeout=5) as client:
|
||||
# Node status
|
||||
try:
|
||||
resp = client.get(
|
||||
f"{config.coordinator_url}/v1/status",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
status = resp.json()
|
||||
console.print("[bold green]Coordinator:[/bold green] Online")
|
||||
for k, v in status.items():
|
||||
console.print(f" {k}: {v}")
|
||||
else:
|
||||
console.print(f"[bold yellow]Coordinator:[/bold yellow] HTTP {resp.status_code}")
|
||||
except Exception:
|
||||
console.print("[bold red]Coordinator:[/bold red] Offline")
|
||||
|
||||
console.print()
|
||||
|
||||
# Jobs summary
|
||||
try:
|
||||
resp = client.get(
|
||||
f"{config.coordinator_url}/v1/jobs",
|
||||
headers={"X-Api-Key": config.api_key or ""},
|
||||
params={"limit": 5}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
jobs = resp.json()
|
||||
if isinstance(jobs, list):
|
||||
console.print(f"[bold cyan]Recent Jobs:[/bold cyan] {len(jobs)}")
|
||||
for job in jobs[:5]:
|
||||
status_color = "green" if job.get("status") == "completed" else "yellow"
|
||||
console.print(f" [{status_color}]{job.get('id', 'N/A')}: {job.get('status', 'unknown')}[/{status_color}]")
|
||||
except Exception:
|
||||
console.print("[dim]Jobs: unavailable[/dim]")
|
||||
|
||||
console.print()
|
||||
|
||||
# Miners summary
|
||||
try:
|
||||
resp = client.get(
|
||||
f"{config.coordinator_url}/v1/miners",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
miners = resp.json()
|
||||
if isinstance(miners, list):
|
||||
online = sum(1 for m in miners if m.get("status") == "ONLINE")
|
||||
console.print(f"[bold cyan]Miners:[/bold cyan] {online}/{len(miners)} online")
|
||||
except Exception:
|
||||
console.print("[dim]Miners: unavailable[/dim]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error fetching data: {e}[/red]")
|
||||
|
||||
console.print(f"\n[dim]Press Ctrl+C to exit[/dim]")
|
||||
time.sleep(refresh)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[bold]Dashboard stopped[/bold]")
|
||||
|
||||
|
||||
@monitor.command()
|
||||
@click.option("--period", default="24h", help="Time period (1h, 24h, 7d, 30d)")
|
||||
@click.option("--export", "export_path", type=click.Path(), help="Export metrics to file")
|
||||
@click.pass_context
|
||||
def metrics(ctx, period: str, export_path: Optional[str]):
|
||||
"""Collect and display system metrics"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Parse period
|
||||
multipliers = {"h": 3600, "d": 86400}
|
||||
unit = period[-1]
|
||||
value = int(period[:-1])
|
||||
seconds = value * multipliers.get(unit, 3600)
|
||||
since = datetime.now() - timedelta(seconds=seconds)
|
||||
|
||||
metrics_data = {
|
||||
"period": period,
|
||||
"since": since.isoformat(),
|
||||
"collected_at": datetime.now().isoformat(),
|
||||
"coordinator": {},
|
||||
"jobs": {},
|
||||
"miners": {}
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=10) as client:
|
||||
# Coordinator metrics
|
||||
try:
|
||||
resp = client.get(
|
||||
f"{config.coordinator_url}/v1/status",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
metrics_data["coordinator"] = resp.json()
|
||||
metrics_data["coordinator"]["status"] = "online"
|
||||
else:
|
||||
metrics_data["coordinator"]["status"] = f"error_{resp.status_code}"
|
||||
except Exception:
|
||||
metrics_data["coordinator"]["status"] = "offline"
|
||||
|
||||
# Job metrics
|
||||
try:
|
||||
resp = client.get(
|
||||
f"{config.coordinator_url}/v1/jobs",
|
||||
headers={"X-Api-Key": config.api_key or ""},
|
||||
params={"limit": 100}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
jobs = resp.json()
|
||||
if isinstance(jobs, list):
|
||||
metrics_data["jobs"] = {
|
||||
"total": len(jobs),
|
||||
"completed": sum(1 for j in jobs if j.get("status") == "completed"),
|
||||
"pending": sum(1 for j in jobs if j.get("status") == "pending"),
|
||||
"failed": sum(1 for j in jobs if j.get("status") == "failed"),
|
||||
}
|
||||
except Exception:
|
||||
metrics_data["jobs"] = {"error": "unavailable"}
|
||||
|
||||
# Miner metrics
|
||||
try:
|
||||
resp = client.get(
|
||||
f"{config.coordinator_url}/v1/miners",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
miners = resp.json()
|
||||
if isinstance(miners, list):
|
||||
metrics_data["miners"] = {
|
||||
"total": len(miners),
|
||||
"online": sum(1 for m in miners if m.get("status") == "ONLINE"),
|
||||
"offline": sum(1 for m in miners if m.get("status") != "ONLINE"),
|
||||
}
|
||||
except Exception:
|
||||
metrics_data["miners"] = {"error": "unavailable"}
|
||||
|
||||
except Exception as e:
|
||||
error(f"Failed to collect metrics: {e}")
|
||||
|
||||
if export_path:
|
||||
with open(export_path, "w") as f:
|
||||
json.dump(metrics_data, f, indent=2)
|
||||
success(f"Metrics exported to {export_path}")
|
||||
|
||||
output(metrics_data, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@monitor.command()
|
||||
@click.argument("action", type=click.Choice(["add", "list", "remove", "test"]))
|
||||
@click.option("--name", help="Alert name")
|
||||
@click.option("--type", "alert_type", type=click.Choice(["coordinator_down", "miner_offline", "job_failed", "low_balance"]), help="Alert type")
|
||||
@click.option("--threshold", type=float, help="Alert threshold value")
|
||||
@click.option("--webhook", help="Webhook URL for notifications")
|
||||
@click.pass_context
|
||||
def alerts(ctx, action: str, name: Optional[str], alert_type: Optional[str],
|
||||
threshold: Optional[float], webhook: Optional[str]):
|
||||
"""Configure monitoring alerts"""
|
||||
alerts_dir = Path.home() / ".aitbc" / "alerts"
|
||||
alerts_dir.mkdir(parents=True, exist_ok=True)
|
||||
alerts_file = alerts_dir / "alerts.json"
|
||||
|
||||
# Load existing alerts
|
||||
existing = []
|
||||
if alerts_file.exists():
|
||||
with open(alerts_file) as f:
|
||||
existing = json.load(f)
|
||||
|
||||
if action == "add":
|
||||
if not name or not alert_type:
|
||||
error("Alert name and type required (--name, --type)")
|
||||
return
|
||||
alert = {
|
||||
"name": name,
|
||||
"type": alert_type,
|
||||
"threshold": threshold,
|
||||
"webhook": webhook,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"enabled": True
|
||||
}
|
||||
existing.append(alert)
|
||||
with open(alerts_file, "w") as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
success(f"Alert '{name}' added")
|
||||
output(alert, ctx.obj['output_format'])
|
||||
|
||||
elif action == "list":
|
||||
if not existing:
|
||||
output({"message": "No alerts configured"}, ctx.obj['output_format'])
|
||||
else:
|
||||
output(existing, ctx.obj['output_format'])
|
||||
|
||||
elif action == "remove":
|
||||
if not name:
|
||||
error("Alert name required (--name)")
|
||||
return
|
||||
existing = [a for a in existing if a["name"] != name]
|
||||
with open(alerts_file, "w") as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
success(f"Alert '{name}' removed")
|
||||
|
||||
elif action == "test":
|
||||
if not name:
|
||||
error("Alert name required (--name)")
|
||||
return
|
||||
alert = next((a for a in existing if a["name"] == name), None)
|
||||
if not alert:
|
||||
error(f"Alert '{name}' not found")
|
||||
return
|
||||
if alert.get("webhook"):
|
||||
try:
|
||||
with httpx.Client(timeout=10) as client:
|
||||
resp = client.post(alert["webhook"], json={
|
||||
"alert": name,
|
||||
"type": alert["type"],
|
||||
"message": f"Test alert from AITBC CLI",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
output({"status": "sent", "response_code": resp.status_code}, ctx.obj['output_format'])
|
||||
except Exception as e:
|
||||
error(f"Webhook test failed: {e}")
|
||||
else:
|
||||
output({"status": "no_webhook", "alert": alert}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@monitor.command()
|
||||
@click.option("--period", default="7d", help="Analysis period (1d, 7d, 30d)")
|
||||
@click.pass_context
|
||||
def history(ctx, period: str):
|
||||
"""Historical data analysis"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
multipliers = {"h": 3600, "d": 86400}
|
||||
unit = period[-1]
|
||||
value = int(period[:-1])
|
||||
seconds = value * multipliers.get(unit, 3600)
|
||||
since = datetime.now() - timedelta(seconds=seconds)
|
||||
|
||||
analysis = {
|
||||
"period": period,
|
||||
"since": since.isoformat(),
|
||||
"analyzed_at": datetime.now().isoformat(),
|
||||
"summary": {}
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=10) as client:
|
||||
try:
|
||||
resp = client.get(
|
||||
f"{config.coordinator_url}/v1/jobs",
|
||||
headers={"X-Api-Key": config.api_key or ""},
|
||||
params={"limit": 500}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
jobs = resp.json()
|
||||
if isinstance(jobs, list):
|
||||
completed = [j for j in jobs if j.get("status") == "completed"]
|
||||
failed = [j for j in jobs if j.get("status") == "failed"]
|
||||
analysis["summary"] = {
|
||||
"total_jobs": len(jobs),
|
||||
"completed": len(completed),
|
||||
"failed": len(failed),
|
||||
"success_rate": f"{len(completed) / max(1, len(jobs)) * 100:.1f}%",
|
||||
}
|
||||
except Exception:
|
||||
analysis["summary"] = {"error": "Could not fetch job data"}
|
||||
|
||||
except Exception as e:
|
||||
error(f"Analysis failed: {e}")
|
||||
|
||||
output(analysis, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@monitor.command()
|
||||
@click.argument("action", type=click.Choice(["add", "list", "remove", "test"]))
|
||||
@click.option("--name", help="Webhook name")
|
||||
@click.option("--url", help="Webhook URL")
|
||||
@click.option("--events", help="Comma-separated event types (job_completed,miner_offline,alert)")
|
||||
@click.pass_context
|
||||
def webhooks(ctx, action: str, name: Optional[str], url: Optional[str], events: Optional[str]):
|
||||
"""Manage webhook notifications"""
|
||||
webhooks_dir = Path.home() / ".aitbc" / "webhooks"
|
||||
webhooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
webhooks_file = webhooks_dir / "webhooks.json"
|
||||
|
||||
existing = []
|
||||
if webhooks_file.exists():
|
||||
with open(webhooks_file) as f:
|
||||
existing = json.load(f)
|
||||
|
||||
if action == "add":
|
||||
if not name or not url:
|
||||
error("Webhook name and URL required (--name, --url)")
|
||||
return
|
||||
webhook = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
"events": events.split(",") if events else ["all"],
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"enabled": True
|
||||
}
|
||||
existing.append(webhook)
|
||||
with open(webhooks_file, "w") as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
success(f"Webhook '{name}' added")
|
||||
output(webhook, ctx.obj['output_format'])
|
||||
|
||||
elif action == "list":
|
||||
if not existing:
|
||||
output({"message": "No webhooks configured"}, ctx.obj['output_format'])
|
||||
else:
|
||||
output(existing, ctx.obj['output_format'])
|
||||
|
||||
elif action == "remove":
|
||||
if not name:
|
||||
error("Webhook name required (--name)")
|
||||
return
|
||||
existing = [w for w in existing if w["name"] != name]
|
||||
with open(webhooks_file, "w") as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
success(f"Webhook '{name}' removed")
|
||||
|
||||
elif action == "test":
|
||||
if not name:
|
||||
error("Webhook name required (--name)")
|
||||
return
|
||||
wh = next((w for w in existing if w["name"] == name), None)
|
||||
if not wh:
|
||||
error(f"Webhook '{name}' not found")
|
||||
return
|
||||
try:
|
||||
with httpx.Client(timeout=10) as client:
|
||||
resp = client.post(wh["url"], json={
|
||||
"event": "test",
|
||||
"source": "aitbc-cli",
|
||||
"message": "Test webhook notification",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
output({"status": "sent", "response_code": resp.status_code}, ctx.obj['output_format'])
|
||||
except Exception as e:
|
||||
error(f"Webhook test failed: {e}")
|
||||
441
cli/aitbc_cli/commands/simulate.py
Normal file
441
cli/aitbc_cli/commands/simulate.py
Normal file
@@ -0,0 +1,441 @@
|
||||
"""Simulation commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import json
|
||||
import time
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
from ..utils import output, error, success
|
||||
|
||||
|
||||
@click.group()
|
||||
def simulate():
|
||||
"""Run simulations and manage test users"""
|
||||
pass
|
||||
|
||||
|
||||
@simulate.command()
|
||||
@click.option("--distribute", default="10000,1000",
|
||||
help="Initial distribution: client_amount,miner_amount")
|
||||
@click.option("--reset", is_flag=True, help="Reset existing simulation")
|
||||
@click.pass_context
|
||||
def init(ctx, distribute: str, reset: bool):
|
||||
"""Initialize test economy"""
|
||||
home_dir = Path("/home/oib/windsurf/aitbc/home")
|
||||
|
||||
if reset:
|
||||
success("Resetting simulation...")
|
||||
# Reset wallet files
|
||||
for wallet_file in ["client_wallet.json", "miner_wallet.json"]:
|
||||
wallet_path = home_dir / wallet_file
|
||||
if wallet_path.exists():
|
||||
wallet_path.unlink()
|
||||
|
||||
# Parse distribution
|
||||
try:
|
||||
client_amount, miner_amount = map(float, distribute.split(","))
|
||||
except:
|
||||
error("Invalid distribution format. Use: client_amount,miner_amount")
|
||||
return
|
||||
|
||||
# Initialize genesis wallet
|
||||
genesis_path = home_dir / "genesis_wallet.json"
|
||||
if not genesis_path.exists():
|
||||
genesis_wallet = {
|
||||
"address": "aitbc1genesis",
|
||||
"balance": 1000000,
|
||||
"transactions": []
|
||||
}
|
||||
with open(genesis_path, 'w') as f:
|
||||
json.dump(genesis_wallet, f, indent=2)
|
||||
success("Genesis wallet created")
|
||||
|
||||
# Initialize client wallet
|
||||
client_path = home_dir / "client_wallet.json"
|
||||
if not client_path.exists():
|
||||
client_wallet = {
|
||||
"address": "aitbc1client",
|
||||
"balance": client_amount,
|
||||
"transactions": [{
|
||||
"type": "receive",
|
||||
"amount": client_amount,
|
||||
"from": "aitbc1genesis",
|
||||
"timestamp": time.time()
|
||||
}]
|
||||
}
|
||||
with open(client_path, 'w') as f:
|
||||
json.dump(client_wallet, f, indent=2)
|
||||
success(f"Client wallet initialized with {client_amount} AITBC")
|
||||
|
||||
# Initialize miner wallet
|
||||
miner_path = home_dir / "miner_wallet.json"
|
||||
if not miner_path.exists():
|
||||
miner_wallet = {
|
||||
"address": "aitbc1miner",
|
||||
"balance": miner_amount,
|
||||
"transactions": [{
|
||||
"type": "receive",
|
||||
"amount": miner_amount,
|
||||
"from": "aitbc1genesis",
|
||||
"timestamp": time.time()
|
||||
}]
|
||||
}
|
||||
with open(miner_path, 'w') as f:
|
||||
json.dump(miner_wallet, f, indent=2)
|
||||
success(f"Miner wallet initialized with {miner_amount} AITBC")
|
||||
|
||||
output({
|
||||
"status": "initialized",
|
||||
"distribution": {
|
||||
"client": client_amount,
|
||||
"miner": miner_amount
|
||||
},
|
||||
"total_supply": client_amount + miner_amount
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@simulate.group()
|
||||
def user():
|
||||
"""Manage test users"""
|
||||
pass
|
||||
|
||||
|
||||
@user.command()
|
||||
@click.option("--type", type=click.Choice(["client", "miner"]), required=True)
|
||||
@click.option("--name", required=True, help="User name")
|
||||
@click.option("--balance", type=float, default=100, help="Initial balance")
|
||||
@click.pass_context
|
||||
def create(ctx, type: str, name: str, balance: float):
|
||||
"""Create a test user"""
|
||||
home_dir = Path("/home/oib/windsurf/aitbc/home")
|
||||
|
||||
user_id = f"{type}_{name}"
|
||||
wallet_path = home_dir / f"{user_id}_wallet.json"
|
||||
|
||||
if wallet_path.exists():
|
||||
error(f"User {name} already exists")
|
||||
return
|
||||
|
||||
wallet = {
|
||||
"address": f"aitbc1{user_id}",
|
||||
"balance": balance,
|
||||
"transactions": [{
|
||||
"type": "receive",
|
||||
"amount": balance,
|
||||
"from": "aitbc1genesis",
|
||||
"timestamp": time.time()
|
||||
}]
|
||||
}
|
||||
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet, f, indent=2)
|
||||
|
||||
success(f"Created {type} user: {name}")
|
||||
output({
|
||||
"user_id": user_id,
|
||||
"address": wallet["address"],
|
||||
"balance": balance
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@user.command()
|
||||
@click.pass_context
|
||||
def list(ctx):
|
||||
"""List all test users"""
|
||||
home_dir = Path("/home/oib/windsurf/aitbc/home")
|
||||
|
||||
users = []
|
||||
for wallet_file in home_dir.glob("*_wallet.json"):
|
||||
if wallet_file.name in ["genesis_wallet.json"]:
|
||||
continue
|
||||
|
||||
with open(wallet_file) as f:
|
||||
wallet = json.load(f)
|
||||
|
||||
user_type = "client" if "client" in wallet_file.name else "miner"
|
||||
user_name = wallet_file.stem.replace("_wallet", "").replace(f"{user_type}_", "")
|
||||
|
||||
users.append({
|
||||
"name": user_name,
|
||||
"type": user_type,
|
||||
"address": wallet["address"],
|
||||
"balance": wallet["balance"]
|
||||
})
|
||||
|
||||
output({"users": users}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@user.command()
|
||||
@click.argument("user")
|
||||
@click.pass_context
|
||||
def balance(ctx, user: str):
|
||||
"""Check user balance"""
|
||||
home_dir = Path("/home/oib/windsurf/aitbc/home")
|
||||
wallet_path = home_dir / f"{user}_wallet.json"
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"User {user} not found")
|
||||
return
|
||||
|
||||
with open(wallet_path) as f:
|
||||
wallet = json.load(f)
|
||||
|
||||
output({
|
||||
"user": user,
|
||||
"address": wallet["address"],
|
||||
"balance": wallet["balance"]
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@user.command()
|
||||
@click.argument("user")
|
||||
@click.argument("amount", type=float)
|
||||
@click.pass_context
|
||||
def fund(ctx, user: str, amount: float):
|
||||
"""Fund a test user"""
|
||||
home_dir = Path("/home/oib/windsurf/aitbc/home")
|
||||
|
||||
# Load genesis wallet
|
||||
genesis_path = home_dir / "genesis_wallet.json"
|
||||
with open(genesis_path) as f:
|
||||
genesis = json.load(f)
|
||||
|
||||
if genesis["balance"] < amount:
|
||||
error(f"Insufficient genesis balance: {genesis['balance']}")
|
||||
return
|
||||
|
||||
# Load user wallet
|
||||
wallet_path = home_dir / f"{user}_wallet.json"
|
||||
if not wallet_path.exists():
|
||||
error(f"User {user} not found")
|
||||
return
|
||||
|
||||
with open(wallet_path) as f:
|
||||
wallet = json.load(f)
|
||||
|
||||
# Transfer funds
|
||||
genesis["balance"] -= amount
|
||||
genesis["transactions"].append({
|
||||
"type": "send",
|
||||
"amount": -amount,
|
||||
"to": wallet["address"],
|
||||
"timestamp": time.time()
|
||||
})
|
||||
|
||||
wallet["balance"] += amount
|
||||
wallet["transactions"].append({
|
||||
"type": "receive",
|
||||
"amount": amount,
|
||||
"from": genesis["address"],
|
||||
"timestamp": time.time()
|
||||
})
|
||||
|
||||
# Save wallets
|
||||
with open(genesis_path, 'w') as f:
|
||||
json.dump(genesis, f, indent=2)
|
||||
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet, f, indent=2)
|
||||
|
||||
success(f"Funded {user} with {amount} AITBC")
|
||||
output({
|
||||
"user": user,
|
||||
"amount": amount,
|
||||
"new_balance": wallet["balance"]
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@simulate.command()
|
||||
@click.option("--jobs", type=int, default=5, help="Number of jobs to simulate")
|
||||
@click.option("--rounds", type=int, default=3, help="Number of rounds")
|
||||
@click.option("--delay", type=float, default=1.0, help="Delay between operations (seconds)")
|
||||
@click.pass_context
|
||||
def workflow(ctx, jobs: int, rounds: int, delay: float):
|
||||
"""Simulate complete workflow"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
success(f"Starting workflow simulation: {jobs} jobs x {rounds} rounds")
|
||||
|
||||
for round_num in range(1, rounds + 1):
|
||||
click.echo(f"\n--- Round {round_num} ---")
|
||||
|
||||
# Submit jobs
|
||||
submitted_jobs = []
|
||||
for i in range(jobs):
|
||||
prompt = f"Test job {i+1} (round {round_num})"
|
||||
|
||||
# Simulate job submission
|
||||
job_id = f"job_{round_num}_{i+1}_{int(time.time())}"
|
||||
submitted_jobs.append(job_id)
|
||||
|
||||
output({
|
||||
"action": "submit_job",
|
||||
"job_id": job_id,
|
||||
"prompt": prompt,
|
||||
"round": round_num
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
time.sleep(delay)
|
||||
|
||||
# Simulate job processing
|
||||
for job_id in submitted_jobs:
|
||||
# Simulate miner picking up job
|
||||
output({
|
||||
"action": "job_assigned",
|
||||
"job_id": job_id,
|
||||
"miner": f"miner_{random.randint(1, 3)}",
|
||||
"status": "processing"
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
time.sleep(delay * 0.5)
|
||||
|
||||
# Simulate job completion
|
||||
earnings = random.uniform(1, 10)
|
||||
output({
|
||||
"action": "job_completed",
|
||||
"job_id": job_id,
|
||||
"earnings": earnings,
|
||||
"status": "completed"
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
time.sleep(delay * 0.5)
|
||||
|
||||
output({
|
||||
"status": "completed",
|
||||
"total_jobs": jobs * rounds,
|
||||
"rounds": rounds
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@simulate.command()
|
||||
@click.option("--clients", type=int, default=10, help="Number of clients")
|
||||
@click.option("--miners", type=int, default=3, help="Number of miners")
|
||||
@click.option("--duration", type=int, default=300, help="Test duration in seconds")
|
||||
@click.option("--job-rate", type=float, default=1.0, help="Jobs per second")
|
||||
@click.pass_context
|
||||
def load_test(ctx, clients: int, miners: int, duration: int, job_rate: float):
|
||||
"""Run load test"""
|
||||
start_time = time.time()
|
||||
end_time = start_time + duration
|
||||
job_interval = 1.0 / job_rate
|
||||
|
||||
success(f"Starting load test: {clients} clients, {miners} miners, {duration}s")
|
||||
|
||||
stats = {
|
||||
"jobs_submitted": 0,
|
||||
"jobs_completed": 0,
|
||||
"errors": 0,
|
||||
"start_time": start_time
|
||||
}
|
||||
|
||||
while time.time() < end_time:
|
||||
# Submit jobs
|
||||
for client_id in range(clients):
|
||||
if time.time() >= end_time:
|
||||
break
|
||||
|
||||
job_id = f"load_test_{stats['jobs_submitted']}_{int(time.time())}"
|
||||
stats["jobs_submitted"] += 1
|
||||
|
||||
# Simulate random job completion
|
||||
if random.random() > 0.1: # 90% success rate
|
||||
stats["jobs_completed"] += 1
|
||||
else:
|
||||
stats["errors"] += 1
|
||||
|
||||
time.sleep(job_interval)
|
||||
|
||||
# Show progress
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed % 30 < 1: # Every 30 seconds
|
||||
output({
|
||||
"elapsed": elapsed,
|
||||
"jobs_submitted": stats["jobs_submitted"],
|
||||
"jobs_completed": stats["jobs_completed"],
|
||||
"errors": stats["errors"],
|
||||
"success_rate": stats["jobs_completed"] / max(1, stats["jobs_submitted"]) * 100
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
# Final stats
|
||||
total_time = time.time() - start_time
|
||||
output({
|
||||
"status": "completed",
|
||||
"duration": total_time,
|
||||
"jobs_submitted": stats["jobs_submitted"],
|
||||
"jobs_completed": stats["jobs_completed"],
|
||||
"errors": stats["errors"],
|
||||
"avg_jobs_per_second": stats["jobs_submitted"] / total_time,
|
||||
"success_rate": stats["jobs_completed"] / max(1, stats["jobs_submitted"]) * 100
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@simulate.command()
|
||||
@click.option("--file", required=True, help="Scenario file path")
|
||||
@click.pass_context
|
||||
def scenario(ctx, file: str):
|
||||
"""Run predefined scenario"""
|
||||
scenario_path = Path(file)
|
||||
|
||||
if not scenario_path.exists():
|
||||
error(f"Scenario file not found: {file}")
|
||||
return
|
||||
|
||||
with open(scenario_path) as f:
|
||||
scenario = json.load(f)
|
||||
|
||||
success(f"Running scenario: {scenario.get('name', 'Unknown')}")
|
||||
|
||||
# Execute scenario steps
|
||||
for step in scenario.get("steps", []):
|
||||
step_type = step.get("type")
|
||||
step_name = step.get("name", "Unnamed step")
|
||||
|
||||
click.echo(f"\nExecuting: {step_name}")
|
||||
|
||||
if step_type == "submit_jobs":
|
||||
count = step.get("count", 1)
|
||||
for i in range(count):
|
||||
output({
|
||||
"action": "submit_job",
|
||||
"step": step_name,
|
||||
"job_num": i + 1,
|
||||
"prompt": step.get("prompt", f"Scenario job {i+1}")
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
elif step_type == "wait":
|
||||
duration = step.get("duration", 1)
|
||||
time.sleep(duration)
|
||||
|
||||
elif step_type == "check_balance":
|
||||
user = step.get("user", "client")
|
||||
# Would check actual balance
|
||||
output({
|
||||
"action": "check_balance",
|
||||
"user": user
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
output({
|
||||
"status": "completed",
|
||||
"scenario": scenario.get('name', 'Unknown')
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@simulate.command()
|
||||
@click.argument("simulation_id")
|
||||
@click.pass_context
|
||||
def results(ctx, simulation_id: str):
|
||||
"""Show simulation results"""
|
||||
# In a real implementation, this would query stored results
|
||||
# For now, return mock data
|
||||
output({
|
||||
"simulation_id": simulation_id,
|
||||
"status": "completed",
|
||||
"start_time": time.time() - 3600,
|
||||
"end_time": time.time(),
|
||||
"duration": 3600,
|
||||
"total_jobs": 50,
|
||||
"successful_jobs": 48,
|
||||
"failed_jobs": 2,
|
||||
"success_rate": 96.0
|
||||
}, ctx.obj['output_format'])
|
||||
990
cli/aitbc_cli/commands/wallet.py
Normal file
990
cli/aitbc_cli/commands/wallet.py
Normal file
@@ -0,0 +1,990 @@
|
||||
"""Wallet commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
from ..utils import output, error, success
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option("--wallet-name", help="Name of the wallet to use")
|
||||
@click.option("--wallet-path", help="Direct path to wallet file (overrides --wallet-name)")
|
||||
@click.pass_context
|
||||
def wallet(ctx, wallet_name: Optional[str], wallet_path: Optional[str]):
|
||||
"""Manage your AITBC wallets and transactions"""
|
||||
# Ensure wallet object exists
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
# If direct wallet path is provided, use it
|
||||
if wallet_path:
|
||||
wp = Path(wallet_path)
|
||||
wp.parent.mkdir(parents=True, exist_ok=True)
|
||||
ctx.obj['wallet_name'] = wp.stem
|
||||
ctx.obj['wallet_dir'] = wp.parent
|
||||
ctx.obj['wallet_path'] = wp
|
||||
return
|
||||
|
||||
# Set wallet directory
|
||||
wallet_dir = Path.home() / ".aitbc" / "wallets"
|
||||
wallet_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Set active wallet
|
||||
if not wallet_name:
|
||||
# Try to get from config or use 'default'
|
||||
config_file = Path.home() / ".aitbc" / "config.yaml"
|
||||
if config_file.exists():
|
||||
with open(config_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
if config:
|
||||
wallet_name = config.get('active_wallet', 'default')
|
||||
else:
|
||||
wallet_name = 'default'
|
||||
else:
|
||||
wallet_name = 'default'
|
||||
|
||||
ctx.obj['wallet_name'] = wallet_name
|
||||
ctx.obj['wallet_dir'] = wallet_dir
|
||||
ctx.obj['wallet_path'] = wallet_dir / f"{wallet_name}.json"
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument('name')
|
||||
@click.option('--type', 'wallet_type', default='hd', help='Wallet type (hd, simple)')
|
||||
@click.pass_context
|
||||
def create(ctx, name: str, wallet_type: str):
|
||||
"""Create a new wallet"""
|
||||
wallet_dir = ctx.obj['wallet_dir']
|
||||
wallet_path = wallet_dir / f"{name}.json"
|
||||
|
||||
if wallet_path.exists():
|
||||
error(f"Wallet '{name}' already exists")
|
||||
return
|
||||
|
||||
# Generate new wallet
|
||||
if wallet_type == 'hd':
|
||||
# Hierarchical Deterministic wallet
|
||||
import secrets
|
||||
seed = secrets.token_hex(32)
|
||||
address = f"aitbc1{seed[:40]}"
|
||||
private_key = f"0x{seed}"
|
||||
public_key = f"0x{secrets.token_hex(32)}"
|
||||
else:
|
||||
# Simple wallet
|
||||
import secrets
|
||||
private_key = f"0x{secrets.token_hex(32)}"
|
||||
public_key = f"0x{secrets.token_hex(32)}"
|
||||
address = f"aitbc1{secrets.token_hex(20)}"
|
||||
|
||||
wallet_data = {
|
||||
"wallet_id": name,
|
||||
"type": wallet_type,
|
||||
"address": address,
|
||||
"public_key": public_key,
|
||||
"private_key": private_key,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"balance": 0,
|
||||
"transactions": []
|
||||
}
|
||||
|
||||
# Save wallet
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
|
||||
success(f"Wallet '{name}' created successfully")
|
||||
output({
|
||||
"name": name,
|
||||
"type": wallet_type,
|
||||
"address": address,
|
||||
"path": str(wallet_path)
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.pass_context
|
||||
def list(ctx):
|
||||
"""List all wallets"""
|
||||
wallet_dir = ctx.obj['wallet_dir']
|
||||
config_file = Path.home() / ".aitbc" / "config.yaml"
|
||||
|
||||
# Get active wallet
|
||||
active_wallet = 'default'
|
||||
if config_file.exists():
|
||||
with open(config_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
active_wallet = config.get('active_wallet', 'default')
|
||||
|
||||
wallets = []
|
||||
for wallet_file in wallet_dir.glob("*.json"):
|
||||
with open(wallet_file, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
wallets.append({
|
||||
"name": wallet_data['wallet_id'],
|
||||
"type": wallet_data.get('type', 'simple'),
|
||||
"address": wallet_data['address'],
|
||||
"created_at": wallet_data['created_at'],
|
||||
"active": wallet_data['wallet_id'] == active_wallet
|
||||
})
|
||||
|
||||
output(wallets, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument('name')
|
||||
@click.pass_context
|
||||
def switch(ctx, name: str):
|
||||
"""Switch to a different wallet"""
|
||||
wallet_dir = ctx.obj['wallet_dir']
|
||||
wallet_path = wallet_dir / f"{name}.json"
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{name}' does not exist")
|
||||
return
|
||||
|
||||
# Update config
|
||||
config_file = Path.home() / ".aitbc" / "config.yaml"
|
||||
config = {}
|
||||
|
||||
if config_file.exists():
|
||||
import yaml
|
||||
with open(config_file, 'r') as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
config['active_wallet'] = name
|
||||
|
||||
# Save config
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config, f, default_flow_style=False)
|
||||
|
||||
success(f"Switched to wallet '{name}'")
|
||||
output({
|
||||
"active_wallet": name,
|
||||
"address": json.load(open(wallet_path))['address']
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument('name')
|
||||
@click.option('--confirm', is_flag=True, help='Skip confirmation prompt')
|
||||
@click.pass_context
|
||||
def delete(ctx, name: str, confirm: bool):
|
||||
"""Delete a wallet"""
|
||||
wallet_dir = ctx.obj['wallet_dir']
|
||||
wallet_path = wallet_dir / f"{name}.json"
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{name}' does not exist")
|
||||
return
|
||||
|
||||
if not confirm:
|
||||
if not click.confirm(f"Are you sure you want to delete wallet '{name}'? This cannot be undone."):
|
||||
return
|
||||
|
||||
wallet_path.unlink()
|
||||
success(f"Wallet '{name}' deleted")
|
||||
|
||||
# If deleted wallet was active, reset to default
|
||||
config_file = Path.home() / ".aitbc" / "config.yaml"
|
||||
if config_file.exists():
|
||||
import yaml
|
||||
with open(config_file, 'r') as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
if config.get('active_wallet') == name:
|
||||
config['active_wallet'] = 'default'
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config, f, default_flow_style=False)
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument('name')
|
||||
@click.option('--destination', help='Destination path for backup file')
|
||||
@click.pass_context
|
||||
def backup(ctx, name: str, destination: Optional[str]):
|
||||
"""Backup a wallet"""
|
||||
wallet_dir = ctx.obj['wallet_dir']
|
||||
wallet_path = wallet_dir / f"{name}.json"
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{name}' does not exist")
|
||||
return
|
||||
|
||||
if not destination:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
destination = f"{name}_backup_{timestamp}.json"
|
||||
|
||||
# Copy wallet file
|
||||
shutil.copy2(wallet_path, destination)
|
||||
success(f"Wallet '{name}' backed up to '{destination}'")
|
||||
output({
|
||||
"wallet": name,
|
||||
"backup_path": destination,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||||
})
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument('backup_path')
|
||||
@click.argument('name')
|
||||
@click.option('--force', is_flag=True, help='Override existing wallet')
|
||||
@click.pass_context
|
||||
def restore(ctx, backup_path: str, name: str, force: bool):
|
||||
"""Restore a wallet from backup"""
|
||||
wallet_dir = ctx.obj['wallet_dir']
|
||||
wallet_path = wallet_dir / f"{name}.json"
|
||||
|
||||
if wallet_path.exists() and not force:
|
||||
error(f"Wallet '{name}' already exists. Use --force to override.")
|
||||
return
|
||||
|
||||
if not Path(backup_path).exists():
|
||||
error(f"Backup file '{backup_path}' not found")
|
||||
return
|
||||
|
||||
# Load and verify backup
|
||||
with open(backup_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
# Update wallet name if needed
|
||||
wallet_data['wallet_id'] = name
|
||||
wallet_data['restored_at'] = datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
# Save restored wallet
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
|
||||
success(f"Wallet '{name}' restored from backup")
|
||||
output({
|
||||
"wallet": name,
|
||||
"restored_from": backup_path,
|
||||
"address": wallet_data['address']
|
||||
})
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.pass_context
|
||||
def info(ctx):
|
||||
"""Show current wallet information"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
config_file = Path.home() / ".aitbc" / "config.yaml"
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found. Use 'aitbc wallet create' to create one.")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
# Get active wallet from config
|
||||
active_wallet = 'default'
|
||||
if config_file.exists():
|
||||
import yaml
|
||||
with open(config_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
active_wallet = config.get('active_wallet', 'default')
|
||||
|
||||
wallet_info = {
|
||||
"name": wallet_data['wallet_id'],
|
||||
"type": wallet_data.get('type', 'simple'),
|
||||
"address": wallet_data['address'],
|
||||
"public_key": wallet_data['public_key'],
|
||||
"created_at": wallet_data['created_at'],
|
||||
"active": wallet_data['wallet_id'] == active_wallet,
|
||||
"path": str(wallet_path)
|
||||
}
|
||||
|
||||
if 'balance' in wallet_data:
|
||||
wallet_info['balance'] = wallet_data['balance']
|
||||
|
||||
output(wallet_info, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.pass_context
|
||||
def balance(ctx):
|
||||
"""Check wallet balance"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
config = ctx.obj.get('config')
|
||||
|
||||
# Auto-create wallet if it doesn't exist
|
||||
if not wallet_path.exists():
|
||||
import secrets
|
||||
wallet_data = {
|
||||
"wallet_id": wallet_name,
|
||||
"type": "simple",
|
||||
"address": f"aitbc1{secrets.token_hex(20)}",
|
||||
"public_key": f"0x{secrets.token_hex(32)}",
|
||||
"private_key": f"0x{secrets.token_hex(32)}",
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"balance": 0.0,
|
||||
"transactions": []
|
||||
}
|
||||
wallet_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
else:
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
# Try to get balance from blockchain if available
|
||||
if config:
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url.replace('/api', '')}/rpc/balance/{wallet_data['address']}",
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
blockchain_balance = response.json().get('balance', 0)
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"address": wallet_data['address'],
|
||||
"local_balance": wallet_data.get('balance', 0),
|
||||
"blockchain_balance": blockchain_balance,
|
||||
"synced": wallet_data.get('balance', 0) == blockchain_balance
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
return
|
||||
except:
|
||||
pass
|
||||
|
||||
# Fallback to local balance only
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"address": wallet_data['address'],
|
||||
"balance": wallet_data.get('balance', 0),
|
||||
"note": "Local balance only (blockchain not accessible)"
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.option("--limit", type=int, default=10, help="Number of transactions to show")
|
||||
@click.pass_context
|
||||
def history(ctx, limit: int):
|
||||
"""Show transaction history"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
transactions = wallet_data.get('transactions', [])[-limit:]
|
||||
|
||||
# Format transactions
|
||||
formatted_txs = []
|
||||
for tx in transactions:
|
||||
formatted_txs.append({
|
||||
"type": tx['type'],
|
||||
"amount": tx['amount'],
|
||||
"description": tx.get('description', ''),
|
||||
"timestamp": tx['timestamp']
|
||||
})
|
||||
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"address": wallet_data['address'],
|
||||
"transactions": formatted_txs
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("amount", type=float)
|
||||
@click.argument("job_id")
|
||||
@click.option("--desc", help="Description of the work")
|
||||
@click.pass_context
|
||||
def earn(ctx, amount: float, job_id: str, desc: Optional[str]):
|
||||
"""Add earnings from completed job"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
# Add transaction
|
||||
transaction = {
|
||||
"type": "earn",
|
||||
"amount": amount,
|
||||
"job_id": job_id,
|
||||
"description": desc or f"Job {job_id}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
wallet_data['transactions'].append(transaction)
|
||||
wallet_data['balance'] = wallet_data.get('balance', 0) + amount
|
||||
|
||||
# Save wallet
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
|
||||
success(f"Earnings added: {amount} AITBC")
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"amount": amount,
|
||||
"job_id": job_id,
|
||||
"new_balance": wallet_data['balance']
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("amount", type=float)
|
||||
@click.argument("description")
|
||||
@click.pass_context
|
||||
def spend(ctx, amount: float, description: str):
|
||||
"""Spend AITBC"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
balance = wallet_data.get('balance', 0)
|
||||
if balance < amount:
|
||||
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
# Add transaction
|
||||
transaction = {
|
||||
"type": "spend",
|
||||
"amount": -amount,
|
||||
"description": description,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
wallet_data['transactions'].append(transaction)
|
||||
wallet_data['balance'] = balance - amount
|
||||
|
||||
# Save wallet
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
|
||||
success(f"Spent: {amount} AITBC")
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"amount": amount,
|
||||
"description": description,
|
||||
"new_balance": wallet_data['balance']
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.pass_context
|
||||
def address(ctx):
|
||||
"""Show wallet address"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"address": wallet_data['address']
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("to_address")
|
||||
@click.argument("amount", type=float)
|
||||
@click.option("--description", help="Transaction description")
|
||||
@click.pass_context
|
||||
def send(ctx, to_address: str, amount: float, description: Optional[str]):
|
||||
"""Send AITBC to another address"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
config = ctx.obj.get('config')
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
balance = wallet_data.get('balance', 0)
|
||||
if balance < amount:
|
||||
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
# Try to send via blockchain
|
||||
if config:
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url.replace('/api', '')}/rpc/transactions",
|
||||
json={
|
||||
"from": wallet_data['address'],
|
||||
"to": to_address,
|
||||
"amount": amount,
|
||||
"description": description or ""
|
||||
},
|
||||
headers={"X-Api-Key": getattr(config, 'api_key', '') or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
tx = response.json()
|
||||
# Update local wallet
|
||||
transaction = {
|
||||
"type": "send",
|
||||
"amount": -amount,
|
||||
"to_address": to_address,
|
||||
"tx_hash": tx.get('hash'),
|
||||
"description": description or "",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
wallet_data['transactions'].append(transaction)
|
||||
wallet_data['balance'] = balance - amount
|
||||
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
|
||||
success(f"Sent {amount} AITBC to {to_address}")
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"tx_hash": tx.get('hash'),
|
||||
"amount": amount,
|
||||
"to": to_address,
|
||||
"new_balance": wallet_data['balance']
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
return
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
|
||||
# Fallback: just record locally
|
||||
transaction = {
|
||||
"type": "send",
|
||||
"amount": -amount,
|
||||
"to_address": to_address,
|
||||
"description": description or "",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"pending": True
|
||||
}
|
||||
|
||||
wallet_data['transactions'].append(transaction)
|
||||
wallet_data['balance'] = balance - amount
|
||||
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"amount": amount,
|
||||
"to": to_address,
|
||||
"new_balance": wallet_data['balance'],
|
||||
"note": "Transaction recorded locally (pending blockchain confirmation)"
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("to_address")
|
||||
@click.argument("amount", type=float)
|
||||
@click.option("--description", help="Transaction description")
|
||||
@click.pass_context
|
||||
def request_payment(ctx, to_address: str, amount: float, description: Optional[str]):
|
||||
"""Request payment from another address"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
# Create payment request
|
||||
request = {
|
||||
"from_address": to_address,
|
||||
"to_address": wallet_data['address'],
|
||||
"amount": amount,
|
||||
"description": description or "",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"payment_request": request,
|
||||
"note": "Share this with the payer to request payment"
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.pass_context
|
||||
def stats(ctx):
|
||||
"""Show wallet statistics"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
transactions = wallet_data.get('transactions', [])
|
||||
|
||||
# Calculate stats
|
||||
total_earned = sum(tx['amount'] for tx in transactions if tx['type'] == 'earn' and tx['amount'] > 0)
|
||||
total_spent = sum(abs(tx['amount']) for tx in transactions if tx['type'] in ['spend', 'send'] and tx['amount'] < 0)
|
||||
jobs_completed = len([tx for tx in transactions if tx['type'] == 'earn'])
|
||||
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"address": wallet_data['address'],
|
||||
"current_balance": wallet_data.get('balance', 0),
|
||||
"total_earned": total_earned,
|
||||
"total_spent": total_spent,
|
||||
"jobs_completed": jobs_completed,
|
||||
"transaction_count": len(transactions),
|
||||
"wallet_created": wallet_data.get('created_at')
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("amount", type=float)
|
||||
@click.option("--duration", type=int, default=30, help="Staking duration in days")
|
||||
@click.pass_context
|
||||
def stake(ctx, amount: float, duration: int):
|
||||
"""Stake AITBC tokens"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
balance = wallet_data.get('balance', 0)
|
||||
if balance < amount:
|
||||
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
# Record stake
|
||||
stake_id = f"stake_{int(datetime.now().timestamp())}"
|
||||
stake_record = {
|
||||
"stake_id": stake_id,
|
||||
"amount": amount,
|
||||
"duration_days": duration,
|
||||
"start_date": datetime.now().isoformat(),
|
||||
"end_date": (datetime.now() + timedelta(days=duration)).isoformat(),
|
||||
"status": "active",
|
||||
"apy": 5.0 + (duration / 30) * 1.5 # Higher APY for longer stakes
|
||||
}
|
||||
|
||||
staking = wallet_data.setdefault('staking', [])
|
||||
staking.append(stake_record)
|
||||
wallet_data['balance'] = balance - amount
|
||||
|
||||
# Add transaction
|
||||
wallet_data['transactions'].append({
|
||||
"type": "stake",
|
||||
"amount": -amount,
|
||||
"stake_id": stake_id,
|
||||
"description": f"Staked {amount} AITBC for {duration} days",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
|
||||
success(f"Staked {amount} AITBC for {duration} days")
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"stake_id": stake_id,
|
||||
"amount": amount,
|
||||
"duration_days": duration,
|
||||
"apy": stake_record['apy'],
|
||||
"new_balance": wallet_data['balance']
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command()
|
||||
@click.argument("stake_id")
|
||||
@click.pass_context
|
||||
def unstake(ctx, stake_id: str):
|
||||
"""Unstake AITBC tokens"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
staking = wallet_data.get('staking', [])
|
||||
stake_record = next((s for s in staking if s['stake_id'] == stake_id and s['status'] == 'active'), None)
|
||||
|
||||
if not stake_record:
|
||||
error(f"Active stake '{stake_id}' not found")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
# Calculate rewards
|
||||
start = datetime.fromisoformat(stake_record['start_date'])
|
||||
days_staked = max(1, (datetime.now() - start).days)
|
||||
daily_rate = stake_record['apy'] / 100 / 365
|
||||
rewards = stake_record['amount'] * daily_rate * days_staked
|
||||
|
||||
# Return principal + rewards
|
||||
returned = stake_record['amount'] + rewards
|
||||
wallet_data['balance'] = wallet_data.get('balance', 0) + returned
|
||||
stake_record['status'] = 'completed'
|
||||
stake_record['rewards'] = rewards
|
||||
stake_record['completed_date'] = datetime.now().isoformat()
|
||||
|
||||
# Add transaction
|
||||
wallet_data['transactions'].append({
|
||||
"type": "unstake",
|
||||
"amount": returned,
|
||||
"stake_id": stake_id,
|
||||
"rewards": rewards,
|
||||
"description": f"Unstaked {stake_record['amount']} AITBC + {rewards:.4f} rewards",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
with open(wallet_path, 'w') as f:
|
||||
json.dump(wallet_data, f, indent=2)
|
||||
|
||||
success(f"Unstaked {stake_record['amount']} AITBC + {rewards:.4f} rewards")
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"stake_id": stake_id,
|
||||
"principal": stake_record['amount'],
|
||||
"rewards": rewards,
|
||||
"total_returned": returned,
|
||||
"days_staked": days_staked,
|
||||
"new_balance": wallet_data['balance']
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command(name="staking-info")
|
||||
@click.pass_context
|
||||
def staking_info(ctx):
|
||||
"""Show staking information"""
|
||||
wallet_name = ctx.obj['wallet_name']
|
||||
wallet_path = ctx.obj['wallet_path']
|
||||
|
||||
if not wallet_path.exists():
|
||||
error(f"Wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(wallet_path, 'r') as f:
|
||||
wallet_data = json.load(f)
|
||||
|
||||
staking = wallet_data.get('staking', [])
|
||||
active_stakes = [s for s in staking if s['status'] == 'active']
|
||||
completed_stakes = [s for s in staking if s['status'] == 'completed']
|
||||
|
||||
total_staked = sum(s['amount'] for s in active_stakes)
|
||||
total_rewards = sum(s.get('rewards', 0) for s in completed_stakes)
|
||||
|
||||
output({
|
||||
"wallet": wallet_name,
|
||||
"total_staked": total_staked,
|
||||
"total_rewards_earned": total_rewards,
|
||||
"active_stakes": len(active_stakes),
|
||||
"completed_stakes": len(completed_stakes),
|
||||
"stakes": [
|
||||
{
|
||||
"stake_id": s['stake_id'],
|
||||
"amount": s['amount'],
|
||||
"apy": s['apy'],
|
||||
"duration_days": s['duration_days'],
|
||||
"status": s['status'],
|
||||
"start_date": s['start_date']
|
||||
}
|
||||
for s in staking
|
||||
]
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command(name="multisig-create")
|
||||
@click.argument("signers", nargs=-1, required=True)
|
||||
@click.option("--threshold", type=int, required=True, help="Required signatures to approve")
|
||||
@click.option("--name", required=True, help="Multisig wallet name")
|
||||
@click.pass_context
|
||||
def multisig_create(ctx, signers: tuple, threshold: int, name: str):
|
||||
"""Create a multi-signature wallet"""
|
||||
wallet_dir = ctx.obj.get('wallet_dir', Path.home() / ".aitbc" / "wallets")
|
||||
wallet_dir.mkdir(parents=True, exist_ok=True)
|
||||
multisig_path = wallet_dir / f"{name}_multisig.json"
|
||||
|
||||
if multisig_path.exists():
|
||||
error(f"Multisig wallet '{name}' already exists")
|
||||
return
|
||||
|
||||
if threshold > len(signers):
|
||||
error(f"Threshold ({threshold}) cannot exceed number of signers ({len(signers)})")
|
||||
return
|
||||
|
||||
import secrets
|
||||
multisig_data = {
|
||||
"wallet_id": name,
|
||||
"type": "multisig",
|
||||
"address": f"aitbc1ms{secrets.token_hex(18)}",
|
||||
"signers": list(signers),
|
||||
"threshold": threshold,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"balance": 0.0,
|
||||
"transactions": [],
|
||||
"pending_transactions": []
|
||||
}
|
||||
|
||||
with open(multisig_path, "w") as f:
|
||||
json.dump(multisig_data, f, indent=2)
|
||||
|
||||
success(f"Multisig wallet '{name}' created ({threshold}-of-{len(signers)})")
|
||||
output({
|
||||
"name": name,
|
||||
"address": multisig_data["address"],
|
||||
"signers": list(signers),
|
||||
"threshold": threshold
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command(name="multisig-propose")
|
||||
@click.option("--wallet", "wallet_name", required=True, help="Multisig wallet name")
|
||||
@click.argument("to_address")
|
||||
@click.argument("amount", type=float)
|
||||
@click.option("--description", help="Transaction description")
|
||||
@click.pass_context
|
||||
def multisig_propose(ctx, wallet_name: str, to_address: str, amount: float, description: Optional[str]):
|
||||
"""Propose a multisig transaction"""
|
||||
wallet_dir = ctx.obj.get('wallet_dir', Path.home() / ".aitbc" / "wallets")
|
||||
multisig_path = wallet_dir / f"{wallet_name}_multisig.json"
|
||||
|
||||
if not multisig_path.exists():
|
||||
error(f"Multisig wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(multisig_path) as f:
|
||||
ms_data = json.load(f)
|
||||
|
||||
if ms_data.get("balance", 0) < amount:
|
||||
error(f"Insufficient balance. Available: {ms_data['balance']}, Required: {amount}")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
import secrets
|
||||
tx_id = f"mstx_{secrets.token_hex(8)}"
|
||||
pending_tx = {
|
||||
"tx_id": tx_id,
|
||||
"to": to_address,
|
||||
"amount": amount,
|
||||
"description": description or "",
|
||||
"proposed_at": datetime.now().isoformat(),
|
||||
"proposed_by": os.environ.get("USER", "unknown"),
|
||||
"signatures": [],
|
||||
"status": "pending"
|
||||
}
|
||||
|
||||
ms_data.setdefault("pending_transactions", []).append(pending_tx)
|
||||
with open(multisig_path, "w") as f:
|
||||
json.dump(ms_data, f, indent=2)
|
||||
|
||||
success(f"Transaction proposed: {tx_id}")
|
||||
output({
|
||||
"tx_id": tx_id,
|
||||
"to": to_address,
|
||||
"amount": amount,
|
||||
"signatures_needed": ms_data["threshold"],
|
||||
"status": "pending"
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@wallet.command(name="multisig-sign")
|
||||
@click.option("--wallet", "wallet_name", required=True, help="Multisig wallet name")
|
||||
@click.argument("tx_id")
|
||||
@click.option("--signer", required=True, help="Signer address")
|
||||
@click.pass_context
|
||||
def multisig_sign(ctx, wallet_name: str, tx_id: str, signer: str):
|
||||
"""Sign a pending multisig transaction"""
|
||||
wallet_dir = ctx.obj.get('wallet_dir', Path.home() / ".aitbc" / "wallets")
|
||||
multisig_path = wallet_dir / f"{wallet_name}_multisig.json"
|
||||
|
||||
if not multisig_path.exists():
|
||||
error(f"Multisig wallet '{wallet_name}' not found")
|
||||
return
|
||||
|
||||
with open(multisig_path) as f:
|
||||
ms_data = json.load(f)
|
||||
|
||||
if signer not in ms_data.get("signers", []):
|
||||
error(f"'{signer}' is not an authorized signer")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
pending = ms_data.get("pending_transactions", [])
|
||||
tx = next((t for t in pending if t["tx_id"] == tx_id and t["status"] == "pending"), None)
|
||||
|
||||
if not tx:
|
||||
error(f"Pending transaction '{tx_id}' not found")
|
||||
ctx.exit(1)
|
||||
return
|
||||
|
||||
if signer in tx["signatures"]:
|
||||
error(f"'{signer}' has already signed this transaction")
|
||||
return
|
||||
|
||||
tx["signatures"].append(signer)
|
||||
|
||||
# Check if threshold met
|
||||
if len(tx["signatures"]) >= ms_data["threshold"]:
|
||||
tx["status"] = "approved"
|
||||
# Execute the transaction
|
||||
ms_data["balance"] = ms_data.get("balance", 0) - tx["amount"]
|
||||
ms_data["transactions"].append({
|
||||
"type": "multisig_send",
|
||||
"amount": -tx["amount"],
|
||||
"to": tx["to"],
|
||||
"tx_id": tx["tx_id"],
|
||||
"signatures": tx["signatures"],
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
success(f"Transaction {tx_id} approved and executed!")
|
||||
else:
|
||||
success(f"Signed. {len(tx['signatures'])}/{ms_data['threshold']} signatures collected")
|
||||
|
||||
with open(multisig_path, "w") as f:
|
||||
json.dump(ms_data, f, indent=2)
|
||||
|
||||
output({
|
||||
"tx_id": tx_id,
|
||||
"signatures": tx["signatures"],
|
||||
"threshold": ms_data["threshold"],
|
||||
"status": tx["status"]
|
||||
}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
68
cli/aitbc_cli/config/__init__.py
Normal file
68
cli/aitbc_cli/config/__init__.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Configuration management for AITBC CLI"""
|
||||
|
||||
import os
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Configuration object for AITBC CLI"""
|
||||
coordinator_url: str = "http://127.0.0.1:18000"
|
||||
api_key: Optional[str] = None
|
||||
config_dir: Path = field(default_factory=lambda: Path.home() / ".aitbc")
|
||||
config_file: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize configuration"""
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Set default config file if not specified
|
||||
if not self.config_file:
|
||||
self.config_file = str(self.config_dir / "config.yaml")
|
||||
|
||||
# Load config from file if it exists
|
||||
self.load_from_file()
|
||||
|
||||
# Override with environment variables
|
||||
if os.getenv("AITBC_URL"):
|
||||
self.coordinator_url = os.getenv("AITBC_URL")
|
||||
if os.getenv("AITBC_API_KEY"):
|
||||
self.api_key = os.getenv("AITBC_API_KEY")
|
||||
|
||||
def load_from_file(self):
|
||||
"""Load configuration from YAML file"""
|
||||
if self.config_file and Path(self.config_file).exists():
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
self.coordinator_url = data.get('coordinator_url', self.coordinator_url)
|
||||
self.api_key = data.get('api_key', self.api_key)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load config file: {e}")
|
||||
|
||||
def save_to_file(self):
|
||||
"""Save configuration to YAML file"""
|
||||
if not self.config_file:
|
||||
return
|
||||
|
||||
# Ensure config directory exists
|
||||
Path(self.config_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = {
|
||||
'coordinator_url': self.coordinator_url,
|
||||
'api_key': self.api_key
|
||||
}
|
||||
|
||||
with open(self.config_file, 'w') as f:
|
||||
yaml.dump(data, f, default_flow_style=False)
|
||||
|
||||
|
||||
def get_config(config_file: Optional[str] = None) -> Config:
|
||||
"""Get configuration instance"""
|
||||
return Config(config_file=config_file)
|
||||
136
cli/aitbc_cli/main.py
Normal file
136
cli/aitbc_cli/main.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AITBC CLI - Main entry point for the AITBC Command Line Interface
|
||||
"""
|
||||
|
||||
import click
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from . import __version__
|
||||
from .config import get_config
|
||||
from .utils import output, setup_logging
|
||||
from .commands.client import client
|
||||
from .commands.miner import miner
|
||||
from .commands.wallet import wallet
|
||||
from .commands.auth import auth
|
||||
from .commands.blockchain import blockchain
|
||||
from .commands.marketplace import marketplace
|
||||
from .commands.simulate import simulate
|
||||
from .commands.admin import admin
|
||||
from .commands.config import config
|
||||
from .commands.monitor import monitor
|
||||
from .plugins import plugin, load_plugins
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option(
|
||||
"--url",
|
||||
default=None,
|
||||
help="Coordinator API URL (overrides config)"
|
||||
)
|
||||
@click.option(
|
||||
"--api-key",
|
||||
default=None,
|
||||
help="API key (overrides config)"
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
type=click.Choice(["table", "json", "yaml"]),
|
||||
default="table",
|
||||
help="Output format"
|
||||
)
|
||||
@click.option(
|
||||
"--verbose", "-v",
|
||||
count=True,
|
||||
help="Increase verbosity (use -v, -vv, -vvv)"
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
is_flag=True,
|
||||
help="Enable debug mode"
|
||||
)
|
||||
@click.option(
|
||||
"--config-file",
|
||||
default=None,
|
||||
help="Path to config file"
|
||||
)
|
||||
@click.version_option(version=__version__, prog_name="aitbc")
|
||||
@click.pass_context
|
||||
def cli(ctx, url: Optional[str], api_key: Optional[str], output: str,
|
||||
verbose: int, debug: bool, config_file: Optional[str]):
|
||||
"""
|
||||
AITBC CLI - Command Line Interface for AITBC Network
|
||||
|
||||
Manage jobs, mining, wallets, and blockchain operations from the command line.
|
||||
"""
|
||||
# Ensure context object exists
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
# Setup logging based on verbosity
|
||||
log_level = setup_logging(verbose, debug)
|
||||
|
||||
# Load configuration
|
||||
config = get_config(config_file)
|
||||
|
||||
# Override config with command line options
|
||||
if url:
|
||||
config.coordinator_url = url
|
||||
if api_key:
|
||||
config.api_key = api_key
|
||||
|
||||
# Store in context for subcommands
|
||||
ctx.obj['config'] = config
|
||||
ctx.obj['output_format'] = output
|
||||
ctx.obj['log_level'] = log_level
|
||||
|
||||
|
||||
# Add command groups
|
||||
cli.add_command(client)
|
||||
cli.add_command(miner)
|
||||
cli.add_command(wallet)
|
||||
cli.add_command(auth)
|
||||
cli.add_command(blockchain)
|
||||
cli.add_command(marketplace)
|
||||
cli.add_command(simulate)
|
||||
cli.add_command(admin)
|
||||
cli.add_command(config)
|
||||
cli.add_command(monitor)
|
||||
cli.add_command(plugin)
|
||||
load_plugins(cli)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_context
|
||||
def version(ctx):
|
||||
"""Show version information"""
|
||||
output(f"AITBC CLI version {__version__}", ctx.obj['output_format'])
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_context
|
||||
def config_show(ctx):
|
||||
"""Show current configuration"""
|
||||
config = ctx.obj['config']
|
||||
output({
|
||||
"coordinator_url": config.coordinator_url,
|
||||
"api_key": "***REDACTED***" if config.api_key else None,
|
||||
"output_format": ctx.obj['output_format'],
|
||||
"config_file": config.config_file
|
||||
}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
try:
|
||||
cli()
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nAborted by user", err=True)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
186
cli/aitbc_cli/plugins.py
Normal file
186
cli/aitbc_cli/plugins.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Plugin system for AITBC CLI custom commands"""
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import click
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
PLUGIN_DIR = Path.home() / ".aitbc" / "plugins"
|
||||
|
||||
|
||||
def get_plugin_dir() -> Path:
|
||||
"""Get and ensure plugin directory exists"""
|
||||
PLUGIN_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return PLUGIN_DIR
|
||||
|
||||
|
||||
def load_plugins(cli_group):
|
||||
"""Load all plugins and register them with the CLI group"""
|
||||
plugin_dir = get_plugin_dir()
|
||||
manifest_file = plugin_dir / "plugins.json"
|
||||
|
||||
if not manifest_file.exists():
|
||||
return
|
||||
|
||||
with open(manifest_file) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
for plugin_info in manifest.get("plugins", []):
|
||||
if not plugin_info.get("enabled", True):
|
||||
continue
|
||||
|
||||
plugin_path = plugin_dir / plugin_info["file"]
|
||||
if not plugin_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
plugin_info["name"], str(plugin_path)
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Look for a click group or command named 'plugin_command'
|
||||
if hasattr(module, "plugin_command"):
|
||||
cli_group.add_command(module.plugin_command)
|
||||
except Exception:
|
||||
pass # Skip broken plugins silently
|
||||
|
||||
|
||||
@click.group()
|
||||
def plugin():
|
||||
"""Manage CLI plugins"""
|
||||
pass
|
||||
|
||||
|
||||
@plugin.command(name="list")
|
||||
@click.pass_context
|
||||
def list_plugins(ctx):
|
||||
"""List installed plugins"""
|
||||
from .utils import output
|
||||
|
||||
plugin_dir = get_plugin_dir()
|
||||
manifest_file = plugin_dir / "plugins.json"
|
||||
|
||||
if not manifest_file.exists():
|
||||
output({"message": "No plugins installed"}, ctx.obj.get('output_format', 'table'))
|
||||
return
|
||||
|
||||
with open(manifest_file) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
plugins = manifest.get("plugins", [])
|
||||
if not plugins:
|
||||
output({"message": "No plugins installed"}, ctx.obj.get('output_format', 'table'))
|
||||
else:
|
||||
output(plugins, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@plugin.command()
|
||||
@click.argument("name")
|
||||
@click.argument("file_path", type=click.Path(exists=True))
|
||||
@click.option("--description", default="", help="Plugin description")
|
||||
@click.pass_context
|
||||
def install(ctx, name: str, file_path: str, description: str):
|
||||
"""Install a plugin from a Python file"""
|
||||
import shutil
|
||||
from .utils import output, error, success
|
||||
|
||||
plugin_dir = get_plugin_dir()
|
||||
manifest_file = plugin_dir / "plugins.json"
|
||||
|
||||
# Copy plugin file
|
||||
dest = plugin_dir / f"{name}.py"
|
||||
shutil.copy2(file_path, dest)
|
||||
|
||||
# Update manifest
|
||||
manifest = {"plugins": []}
|
||||
if manifest_file.exists():
|
||||
with open(manifest_file) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Remove existing entry with same name
|
||||
manifest["plugins"] = [p for p in manifest["plugins"] if p["name"] != name]
|
||||
manifest["plugins"].append({
|
||||
"name": name,
|
||||
"file": f"{name}.py",
|
||||
"description": description,
|
||||
"enabled": True
|
||||
})
|
||||
|
||||
with open(manifest_file, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
success(f"Plugin '{name}' installed")
|
||||
output({"name": name, "file": str(dest), "status": "installed"}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@plugin.command()
|
||||
@click.argument("name")
|
||||
@click.pass_context
|
||||
def uninstall(ctx, name: str):
|
||||
"""Uninstall a plugin"""
|
||||
from .utils import output, error, success
|
||||
|
||||
plugin_dir = get_plugin_dir()
|
||||
manifest_file = plugin_dir / "plugins.json"
|
||||
|
||||
if not manifest_file.exists():
|
||||
error(f"Plugin '{name}' not found")
|
||||
return
|
||||
|
||||
with open(manifest_file) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
plugin_entry = next((p for p in manifest["plugins"] if p["name"] == name), None)
|
||||
if not plugin_entry:
|
||||
error(f"Plugin '{name}' not found")
|
||||
return
|
||||
|
||||
# Remove file
|
||||
plugin_file = plugin_dir / plugin_entry["file"]
|
||||
if plugin_file.exists():
|
||||
plugin_file.unlink()
|
||||
|
||||
# Update manifest
|
||||
manifest["plugins"] = [p for p in manifest["plugins"] if p["name"] != name]
|
||||
with open(manifest_file, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
success(f"Plugin '{name}' uninstalled")
|
||||
output({"name": name, "status": "uninstalled"}, ctx.obj.get('output_format', 'table'))
|
||||
|
||||
|
||||
@plugin.command()
|
||||
@click.argument("name")
|
||||
@click.argument("state", type=click.Choice(["enable", "disable"]))
|
||||
@click.pass_context
|
||||
def toggle(ctx, name: str, state: str):
|
||||
"""Enable or disable a plugin"""
|
||||
from .utils import output, error, success
|
||||
|
||||
plugin_dir = get_plugin_dir()
|
||||
manifest_file = plugin_dir / "plugins.json"
|
||||
|
||||
if not manifest_file.exists():
|
||||
error(f"Plugin '{name}' not found")
|
||||
return
|
||||
|
||||
with open(manifest_file) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
plugin_entry = next((p for p in manifest["plugins"] if p["name"] == name), None)
|
||||
if not plugin_entry:
|
||||
error(f"Plugin '{name}' not found")
|
||||
return
|
||||
|
||||
plugin_entry["enabled"] = (state == "enable")
|
||||
|
||||
with open(manifest_file, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
success(f"Plugin '{name}' {'enabled' if state == 'enable' else 'disabled'}")
|
||||
output({"name": name, "enabled": plugin_entry["enabled"]}, ctx.obj.get('output_format', 'table'))
|
||||
268
cli/aitbc_cli/utils/__init__.py
Normal file
268
cli/aitbc_cli/utils/__init__.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Utility functions for AITBC CLI"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
|
||||
import json
|
||||
import yaml
|
||||
from tabulate import tabulate
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def progress_bar(description: str = "Working...", total: Optional[int] = None):
|
||||
"""Context manager for progress bar display"""
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[bold blue]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
TimeElapsedColumn(),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task(description, total=total)
|
||||
yield progress, task
|
||||
|
||||
|
||||
def progress_spinner(description: str = "Working..."):
|
||||
"""Simple spinner for indeterminate operations"""
|
||||
return console.status(f"[bold blue]{description}")
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
"""Audit logging for CLI operations"""
|
||||
|
||||
def __init__(self, log_dir: Optional[Path] = None):
|
||||
self.log_dir = log_dir or Path.home() / ".aitbc" / "audit"
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.log_file = self.log_dir / "audit.jsonl"
|
||||
|
||||
def log(self, action: str, details: dict = None, user: str = None):
|
||||
"""Log an audit event"""
|
||||
import datetime
|
||||
entry = {
|
||||
"timestamp": datetime.datetime.now().isoformat(),
|
||||
"action": action,
|
||||
"user": user or os.environ.get("USER", "unknown"),
|
||||
"details": details or {}
|
||||
}
|
||||
with open(self.log_file, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
def get_logs(self, limit: int = 50, action_filter: str = None) -> list:
|
||||
"""Read audit log entries"""
|
||||
if not self.log_file.exists():
|
||||
return []
|
||||
entries = []
|
||||
with open(self.log_file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
entry = json.loads(line)
|
||||
if action_filter and entry.get("action") != action_filter:
|
||||
continue
|
||||
entries.append(entry)
|
||||
return entries[-limit:]
|
||||
|
||||
|
||||
def encrypt_value(value: str, key: str = None) -> str:
|
||||
"""Simple XOR-based obfuscation for config values (not cryptographic security)"""
|
||||
import base64
|
||||
key = key or "aitbc_config_key_2026"
|
||||
encrypted = bytes([ord(c) ^ ord(key[i % len(key)]) for i, c in enumerate(value)])
|
||||
return base64.b64encode(encrypted).decode()
|
||||
|
||||
|
||||
def decrypt_value(encrypted: str, key: str = None) -> str:
|
||||
"""Decrypt an XOR-obfuscated config value"""
|
||||
import base64
|
||||
key = key or "aitbc_config_key_2026"
|
||||
data = base64.b64decode(encrypted)
|
||||
return ''.join(chr(b ^ ord(key[i % len(key)])) for i, b in enumerate(data))
|
||||
|
||||
|
||||
def setup_logging(verbosity: int, debug: bool = False) -> str:
|
||||
"""Setup logging with Rich"""
|
||||
log_level = "WARNING"
|
||||
|
||||
if verbosity >= 3 or debug:
|
||||
log_level = "DEBUG"
|
||||
elif verbosity == 2:
|
||||
log_level = "INFO"
|
||||
elif verbosity == 1:
|
||||
log_level = "WARNING"
|
||||
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="%(message)s",
|
||||
datefmt="[%X]",
|
||||
handlers=[RichHandler(console=console, rich_tracebacks=True)]
|
||||
)
|
||||
|
||||
return log_level
|
||||
|
||||
|
||||
def output(data: Any, format_type: str = "table"):
|
||||
"""Format and output data"""
|
||||
if format_type == "json":
|
||||
console.print(json.dumps(data, indent=2, default=str))
|
||||
elif format_type == "yaml":
|
||||
console.print(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
||||
elif format_type == "table":
|
||||
if isinstance(data, dict) and not isinstance(data, list):
|
||||
# Simple key-value table
|
||||
table = Table(show_header=False, box=None)
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Value", style="green")
|
||||
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
value = json.dumps(value, default=str)
|
||||
table.add_row(str(key), str(value))
|
||||
|
||||
console.print(table)
|
||||
elif isinstance(data, list) and data:
|
||||
if all(isinstance(item, dict) for item in data):
|
||||
# Table from list of dicts
|
||||
headers = list(data[0].keys())
|
||||
table = Table()
|
||||
|
||||
for header in headers:
|
||||
table.add_column(header, style="cyan")
|
||||
|
||||
for item in data:
|
||||
row = [str(item.get(h, "")) for h in headers]
|
||||
table.add_row(*row)
|
||||
|
||||
console.print(table)
|
||||
else:
|
||||
# Simple list
|
||||
for item in data:
|
||||
console.print(f"• {item}")
|
||||
else:
|
||||
console.print(data)
|
||||
else:
|
||||
console.print(data)
|
||||
|
||||
|
||||
def error(message: str):
|
||||
"""Print error message"""
|
||||
console.print(Panel(f"[red]Error: {message}[/red]", title="❌"))
|
||||
|
||||
|
||||
def success(message: str):
|
||||
"""Print success message"""
|
||||
console.print(Panel(f"[green]{message}[/green]", title="✅"))
|
||||
|
||||
|
||||
def warning(message: str):
|
||||
"""Print warning message"""
|
||||
console.print(Panel(f"[yellow]{message}[/yellow]", title="⚠️"))
|
||||
|
||||
|
||||
def retry_with_backoff(
|
||||
func,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0,
|
||||
backoff_factor: float = 2.0,
|
||||
exceptions: tuple = (Exception,)
|
||||
):
|
||||
"""
|
||||
Retry function with exponential backoff
|
||||
|
||||
Args:
|
||||
func: Function to retry
|
||||
max_retries: Maximum number of retries
|
||||
base_delay: Initial delay in seconds
|
||||
max_delay: Maximum delay in seconds
|
||||
backoff_factor: Multiplier for delay after each retry
|
||||
exceptions: Tuple of exceptions to catch and retry on
|
||||
|
||||
Returns:
|
||||
Result of function call
|
||||
"""
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return func()
|
||||
except exceptions as e:
|
||||
last_exception = e
|
||||
|
||||
if attempt == max_retries:
|
||||
error(f"Max retries ({max_retries}) exceeded. Last error: {e}")
|
||||
raise
|
||||
|
||||
# Calculate delay with exponential backoff
|
||||
delay = min(base_delay * (backoff_factor ** attempt), max_delay)
|
||||
|
||||
warning(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay:.1f}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
def create_http_client_with_retry(
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0,
|
||||
timeout: float = 30.0
|
||||
):
|
||||
"""
|
||||
Create an HTTP client with retry capabilities
|
||||
|
||||
Args:
|
||||
max_retries: Maximum number of retries
|
||||
base_delay: Initial delay in seconds
|
||||
max_delay: Maximum delay in seconds
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
httpx.Client with retry transport
|
||||
"""
|
||||
import httpx
|
||||
|
||||
class RetryTransport(httpx.Transport):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.max_retries = max_retries
|
||||
self.base_delay = base_delay
|
||||
self.max_delay = max_delay
|
||||
self.backoff_factor = 2.0
|
||||
|
||||
def handle_request(self, request):
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
return super().handle_request(request)
|
||||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||||
last_exception = e
|
||||
|
||||
if attempt == self.max_retries:
|
||||
break
|
||||
|
||||
delay = min(
|
||||
self.base_delay * (self.backoff_factor ** attempt),
|
||||
self.max_delay
|
||||
)
|
||||
time.sleep(delay)
|
||||
|
||||
raise last_exception
|
||||
|
||||
return httpx.Client(
|
||||
transport=RetryTransport(),
|
||||
timeout=timeout
|
||||
)
|
||||
Reference in New Issue
Block a user