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:
oib
2026-02-12 19:08:17 +01:00
parent 76a2fc9b6d
commit 5120861e17
57 changed files with 11720 additions and 131 deletions

View 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)