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:
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)
|
||||
Reference in New Issue
Block a user