#!/usr/bin/env python3 """ServerKit CLI - Administrative commands for ServerKit.""" import os import click import json import secrets import sys from pathlib import Path # Load .env file before importing app # Check multiple locations for the .env file def load_env(): """Load environment variables from .env file.""" try: from dotenv import load_dotenv # Try multiple locations env_locations = [ Path(__file__).parent / '.env', # Same directory as cli.py Path(__file__).parent.parent / '.env', # Parent directory Path('/opt/serverkit/.env'), # Production location Path('/opt/serverkit/backend/.env'), # Alternative production ] for env_path in env_locations: if env_path.exists(): load_dotenv(env_path) return str(env_path) # Also check if DATABASE_URL is already set if os.environ.get('DATABASE_URL'): return 'environment' return None except ImportError: # python-dotenv not installed return None # Load env before any other imports that might use config _env_loaded = load_env() from werkzeug.security import generate_password_hash from app import create_app, db from app.models import User @click.group() @click.option('--debug', is_flag=True, help='Show debug information') @click.pass_context def cli(ctx, debug): """ServerKit administrative CLI.""" ctx.ensure_object(dict) ctx.obj['debug'] = debug if debug and _env_loaded: click.echo(f"Loaded environment from: {_env_loaded}") def _echo_json(payload): """Machine-readable output for the --json flags (stdout only).""" click.echo(json.dumps(payload, indent=2, default=str)) @cli.command() @click.option('--email', prompt=True, help='Admin email address') @click.option('--username', prompt=True, help='Admin username') @click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='Admin password') def create_admin(email, username, password): """Create a new admin user.""" app = create_app() with app.app_context(): # Check if user already exists if User.query.filter((User.email == email) | (User.username == username)).first(): click.echo(click.style('Error: User with this email or username already exists.', fg='red')) sys.exit(1) user = User( email=email, username=username, role='admin', is_active=True ) user.set_password(password) db.session.add(user) db.session.commit() # Mark setup as complete so the UI doesn't show the setup wizard from app.services.settings_service import SettingsService SettingsService.complete_setup(user_id=user.id) click.echo(click.style(f'Admin user "{username}" created successfully!', fg='green')) @cli.command() @click.option('--email', prompt=True, help='User email address') @click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='New password') def reset_password(email, password): """Reset a user's password.""" app = create_app() with app.app_context(): user = User.query.filter_by(email=email).first() if not user: click.echo(click.style(f'Error: User with email "{email}" not found.', fg='red')) sys.exit(1) user.set_password(password) user.failed_login_count = 0 user.locked_until = None db.session.commit() click.echo(click.style(f'Password reset successfully for "{user.username}"!', fg='green')) @cli.command() @click.option('--email', prompt=True, help='User email address') def unlock_user(email): """Unlock a locked user account.""" app = create_app() with app.app_context(): user = User.query.filter_by(email=email).first() if not user: click.echo(click.style(f'Error: User with email "{email}" not found.', fg='red')) sys.exit(1) user.failed_login_count = 0 user.locked_until = None db.session.commit() click.echo(click.style(f'User "{user.username}" unlocked successfully!', fg='green')) @cli.command() @click.option('--email', prompt=True, help='User email address') def make_admin(email): """Promote a user to admin role.""" app = create_app() with app.app_context(): user = User.query.filter_by(email=email).first() if not user: click.echo(click.style(f'Error: User with email "{email}" not found.', fg='red')) sys.exit(1) user.role = 'admin' db.session.commit() click.echo(click.style(f'User "{user.username}" is now an admin!', fg='green')) @cli.command() @click.option('--email', prompt=True, help='User email address') def deactivate_user(email): """Deactivate a user account.""" app = create_app() with app.app_context(): user = User.query.filter_by(email=email).first() if not user: click.echo(click.style(f'Error: User with email "{email}" not found.', fg='red')) sys.exit(1) user.is_active = False db.session.commit() click.echo(click.style(f'User "{user.username}" has been deactivated.', fg='yellow')) @cli.command() @click.option('--email', prompt=True, help='User email address') def activate_user(email): """Activate a user account.""" app = create_app() with app.app_context(): user = User.query.filter_by(email=email).first() if not user: click.echo(click.style(f'Error: User with email "{email}" not found.', fg='red')) sys.exit(1) user.is_active = True db.session.commit() click.echo(click.style(f'User "{user.username}" has been activated.', fg='green')) @cli.command() @click.option('--json', 'as_json', is_flag=True, help='Output machine-readable JSON') def list_users(as_json): """List all users.""" app = create_app() with app.app_context(): users = User.query.all() if as_json: _echo_json({'users': [ { 'id': user.id, 'username': user.username, 'email': user.email, 'role': user.role, 'is_active': user.is_active, 'is_locked': user.is_locked, } for user in users ]}) return if not users: click.echo('No users found.') return click.echo(f"\n{'ID':<5} {'Username':<20} {'Email':<30} {'Role':<10} {'Active':<8} {'Locked':<8}") click.echo('-' * 85) for user in users: locked = 'Yes' if user.is_locked else 'No' active = 'Yes' if user.is_active else 'No' click.echo(f"{user.id:<5} {user.username:<20} {user.email:<30} {user.role:<10} {active:<8} {locked:<8}") click.echo(f"\nTotal: {len(users)} user(s)") @cli.command() def generate_keys(): """Generate secure SECRET_KEY and JWT_SECRET_KEY.""" secret_key = secrets.token_hex(32) jwt_secret_key = secrets.token_hex(32) click.echo("\nAdd these to your .env file:\n") click.echo(f"SECRET_KEY={secret_key}") click.echo(f"JWT_SECRET_KEY={jwt_secret_key}") click.echo() @cli.command() def init_db(): """Initialize the database using Alembic migrations.""" app = create_app() with app.app_context(): from app.services.migration_service import MigrationService result = MigrationService.apply_migrations(app) if result['success']: click.echo(click.style(f'Database initialized successfully (revision: {result["revision"]})!', fg='green')) else: click.echo(click.style(f'Database initialization failed: {result["error"]}', fg='red')) sys.exit(1) @cli.command() @click.option('--json', 'as_json', is_flag=True, help='Output machine-readable JSON') def db_status(as_json): """Show current database migration status.""" app = create_app() with app.app_context(): from app.services.migration_service import MigrationService status = MigrationService.get_status() if as_json: _echo_json(status) return click.echo(f"\nCurrent revision: {status['current_revision'] or 'none'}") click.echo(f"Head revision: {status['head_revision'] or 'none'}") click.echo(f"Pending: {status['pending_count']}") if status['pending_migrations']: click.echo(f"\nPending migrations:") for m in status['pending_migrations']: click.echo(f" - {m['revision']}: {m['description']}") else: click.echo(click.style('\nDatabase is up to date.', fg='green')) click.echo() @cli.command() @click.option('--no-backup', is_flag=True, help='Skip creating a backup before migrating') def db_migrate(no_backup): """Apply pending database migrations.""" app = create_app() with app.app_context(): from app.services.migration_service import MigrationService status = MigrationService.get_status() if not status['needs_migration']: click.echo(click.style('Database is up to date. No migrations needed.', fg='green')) return click.echo(f'Found {status["pending_count"]} pending migration(s):') for m in status['pending_migrations']: click.echo(f' - {m["revision"]}: {m["description"]}') if not no_backup: click.echo('\nCreating backup...') backup = MigrationService.create_backup(app) if backup['success']: click.echo(click.style(f' Backup saved to: {backup["path"]}', fg='green')) else: click.echo(click.style(f' Backup failed: {backup["error"]}', fg='red')) if not click.confirm('Continue without backup?'): return click.echo('\nApplying migrations...') result = MigrationService.apply_migrations(app) if result['success']: click.echo(click.style(f'\nMigrations applied! Now at revision: {result["revision"]}', fg='green')) else: click.echo(click.style(f'\nMigration failed: {result["error"]}', fg='red')) sys.exit(1) @cli.command() def db_history(): """Show all database migration revisions.""" app = create_app() with app.app_context(): from app.services.migration_service import MigrationService history = MigrationService.get_migration_history(app) if not history: click.echo('No migration history found.') return click.echo(f"\n{'Revision':<20} {'Description':<50} {'Status'}") click.echo('-' * 80) for rev in history: status_parts = [] if rev['is_current']: status_parts.append('CURRENT') if rev['is_head']: status_parts.append('HEAD') status = ', '.join(status_parts) if status_parts else '' desc = rev['description'][:48] if rev['description'] else '' click.echo(f"{rev['revision']:<20} {desc:<50} {status}") click.echo() @cli.command() @click.confirmation_option(prompt='Are you sure you want to drop all tables?') def drop_db(): """Drop all database tables.""" app = create_app() with app.app_context(): db.drop_all() click.echo(click.style('All tables dropped!', fg='yellow')) @cli.command() @click.option('--delete-volumes', is_flag=True, help='Also delete Docker volumes') @click.option('--keep-db', is_flag=True, help='Keep database records') @click.confirmation_option(prompt='Are you sure you want to delete ALL applications and their data?') def cleanup_apps(delete_volumes, keep_db): """Delete all applications, containers, and app folders. This removes: - All Docker containers and networks for apps - All app folders in /var/serverkit/apps/ - Orphaned Docker containers (excludes serverkit-* infrastructure) - Optionally Docker volumes (--delete-volumes) - Database records (unless --keep-db) """ import shutil import subprocess from app.models import Application # Infrastructure containers to never touch PROTECTED_CONTAINERS = ['serverkit-frontend', 'serverkit-backend', 'serverkit'] app = create_app() with app.app_context(): # Live apps only. `docker compose down -v` on a soft-deleted app would # destroy the data volumes the delete deliberately KEPT for restore, # leaving a restorable row with nothing behind it. Recycle-bin entries # are removed by purging them, not here. apps = Application.query_active().all() click.echo(f'Found {len(apps)} application(s) in database...\n') # 1. Clean up tracked applications for application in apps: click.echo(f'Cleaning up: {application.name}') # Stop and remove Docker containers if application.root_path and os.path.exists(application.root_path): try: cmd = ['docker', 'compose', 'down'] if delete_volumes: cmd.append('-v') cmd.extend(['--remove-orphans']) subprocess.run( cmd, cwd=application.root_path, capture_output=True, timeout=60 ) click.echo(click.style(f' ✓ Stopped containers', fg='green')) except Exception as e: click.echo(click.style(f' ✗ Failed to stop containers: {e}', fg='red')) # Delete app folder try: shutil.rmtree(application.root_path) click.echo(click.style(f' ✓ Deleted folder: {application.root_path}', fg='green')) except Exception as e: click.echo(click.style(f' ✗ Failed to delete folder: {e}', fg='red')) # Delete database record if not keep_db: try: db.session.delete(application) click.echo(click.style(f' ✓ Removed from database', fg='green')) except Exception as e: click.echo(click.style(f' ✗ Failed to remove from database: {e}', fg='red')) if not keep_db: db.session.commit() # 2. Clean up orphaned containers (not in database, not infrastructure) click.echo('\nCleaning up orphaned Docker containers...') try: result = subprocess.run( ['docker', 'ps', '-a', '--format', '{{.Names}}'], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: containers = result.stdout.strip().split('\n') orphaned = 0 for container in containers: if not container: continue # Skip protected infrastructure containers if any(container.startswith(p) for p in PROTECTED_CONTAINERS): continue # Skip if it's a serverkit network container if container == 'serverkit-frontend' or container == 'serverkit-backend': continue # Stop and remove orphaned container try: subprocess.run(['docker', 'stop', container], capture_output=True, timeout=30) subprocess.run(['docker', 'rm', container], capture_output=True, timeout=30) click.echo(click.style(f' ✓ Removed orphaned container: {container}', fg='yellow')) orphaned += 1 except Exception: pass if orphaned == 0: click.echo(' No orphaned containers found.') else: click.echo(f' Removed {orphaned} orphaned container(s).') except Exception as e: click.echo(click.style(f' ✗ Failed to clean orphaned containers: {e}', fg='red')) # 3. Clean up orphaned Docker networks (except serverkit-network) click.echo('\nCleaning up orphaned Docker networks...') try: subprocess.run( ['docker', 'network', 'prune', '-f'], capture_output=True, timeout=30 ) click.echo(click.style(' ✓ Pruned unused networks', fg='green')) except Exception as e: click.echo(click.style(f' ✗ Failed to prune networks: {e}', fg='red')) # 4. Optionally clean up volumes if delete_volumes: click.echo('\nCleaning up orphaned Docker volumes...') try: subprocess.run( ['docker', 'volume', 'prune', '-f'], capture_output=True, timeout=30 ) click.echo(click.style(' ✓ Pruned unused volumes', fg='green')) except Exception as e: click.echo(click.style(f' ✗ Failed to prune volumes: {e}', fg='red')) # 5. Clean up any remaining folders in /var/serverkit/apps/ apps_dir = '/var/serverkit/apps' if os.path.exists(apps_dir): click.echo(f'\nCleaning up app folders in {apps_dir}...') for folder in os.listdir(apps_dir): folder_path = os.path.join(apps_dir, folder) if os.path.isdir(folder_path): try: shutil.rmtree(folder_path) click.echo(click.style(f' ✓ Deleted: {folder}', fg='yellow')) except Exception as e: click.echo(click.style(f' ✗ Failed to delete {folder}: {e}', fg='red')) click.echo(click.style('\nCleanup completed!', fg='green')) @cli.command() @click.confirmation_option(prompt='This will delete ALL data and reset ServerKit. Continue?') def factory_reset(): """Complete factory reset - delete everything and start fresh. This removes: - All applications and Docker containers - All app folders - All orphaned Docker containers (preserves serverkit infrastructure) - All Docker volumes and networks (except serverkit-network) - All database tables - Template installation cache """ import shutil import subprocess from app.models import Application # Infrastructure to never touch PROTECTED_CONTAINERS = ['serverkit-frontend', 'serverkit-backend', 'serverkit'] PROTECTED_NETWORKS = ['serverkit-network', 'serverkit_default'] app = create_app() with app.app_context(): click.echo('Starting factory reset...\n') # 1. Clean up all applications from database # Only live apps have a stack to bring down; a soft-deleted app's # containers are already stopped, and steps 2-4 + drop_all() below sweep # up whatever it left behind anyway. apps = Application.query_active().all() click.echo(f'Stopping {len(apps)} tracked application(s)...') for application in apps: if application.root_path and os.path.exists(application.root_path): try: subprocess.run( ['docker', 'compose', 'down', '-v', '--remove-orphans'], cwd=application.root_path, capture_output=True, timeout=60 ) except Exception: pass # 2. Stop and remove ALL non-infrastructure containers click.echo('Removing all app containers...') try: result = subprocess.run( ['docker', 'ps', '-a', '--format', '{{.Names}}'], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: containers = [c for c in result.stdout.strip().split('\n') if c] for container in containers: # Skip protected infrastructure if any(container.startswith(p) for p in PROTECTED_CONTAINERS): continue try: subprocess.run(['docker', 'stop', container], capture_output=True, timeout=30) subprocess.run(['docker', 'rm', '-f', container], capture_output=True, timeout=30) except Exception: pass click.echo(click.style('✓ Removed all app containers', fg='green')) except Exception as e: click.echo(click.style(f'✗ Failed to remove containers: {e}', fg='red')) # 3. Delete entire apps directory apps_dir = '/var/serverkit/apps' if os.path.exists(apps_dir): try: shutil.rmtree(apps_dir) os.makedirs(apps_dir, exist_ok=True) click.echo(click.style('✓ Deleted all app folders', fg='green')) except Exception as e: click.echo(click.style(f'✗ Failed to delete apps folder: {e}', fg='red')) # 4. Prune Docker volumes (except protected) click.echo('Pruning Docker volumes...') try: subprocess.run(['docker', 'volume', 'prune', '-f'], capture_output=True, timeout=60) click.echo(click.style('✓ Pruned unused volumes', fg='green')) except Exception as e: click.echo(click.style(f'✗ Failed to prune volumes: {e}', fg='red')) # 5. Prune Docker networks (except protected) click.echo('Pruning Docker networks...') try: subprocess.run(['docker', 'network', 'prune', '-f'], capture_output=True, timeout=30) click.echo(click.style('✓ Pruned unused networks', fg='green')) except Exception as e: click.echo(click.style(f'✗ Failed to prune networks: {e}', fg='red')) # 6. Clear template installation cache template_config = '/etc/serverkit/templates.json' if os.path.exists(template_config): try: import json with open(template_config, 'r') as f: config = json.load(f) config['installed'] = {} with open(template_config, 'w') as f: json.dump(config, f, indent=2) click.echo(click.style('✓ Cleared template cache', fg='green')) except Exception as e: click.echo(click.style(f'✗ Failed to clear template cache: {e}', fg='red')) # 7. Drop and recreate database via Alembic try: db.drop_all() from app.services.migration_service import MigrationService result = MigrationService.apply_migrations(app) if result['success']: click.echo(click.style('✓ Reset database', fg='green')) else: click.echo(click.style(f'✗ Migration after reset failed: {result["error"]}', fg='red')) except Exception as e: click.echo(click.style(f'✗ Failed to reset database: {e}', fg='red')) click.echo(click.style('\nFactory reset completed!', fg='green')) click.echo('Run "serverkit create-admin" to create a new admin user.') @cli.command() @click.option('--all', 'show_all', is_flag=True, help='Also show Docker container status') @click.option('--json', 'as_json', is_flag=True, help='Output machine-readable JSON (not combinable with --all)') def list_apps(show_all, as_json): """List all user applications (excludes ServerKit infrastructure).""" import subprocess from app.models import Application if as_json and show_all: _fail('--json cannot be combined with --all (the Docker section is table-only).') app = create_app() with app.app_context(): apps = Application.query_active().all() if as_json: _echo_json({'apps': [ { 'id': application.id, 'name': application.name, 'app_type': application.app_type, 'status': application.status, 'port': application.port, 'root_path': application.root_path, } for application in apps ]}) return click.echo('\n' + '=' * 90) click.echo(' USER APPLICATIONS') click.echo('=' * 90) if not apps: click.echo('\n No applications found in database.') click.echo(' Install apps from the Templates page in the web UI.\n') else: click.echo(f"\n{'ID':<5} {'Name':<25} {'Type':<10} {'Status':<10} {'Port':<8} {'Path'}") click.echo('-' * 90) for application in apps: click.echo( f"{application.id:<5} " f"{application.name:<25} " f"{application.app_type:<10} " f"{application.status:<10} " f"{str(application.port or '-'):<8} " f"{application.root_path or '-'}" ) click.echo(f"\nTotal: {len(apps)} application(s)") if show_all: click.echo('\n' + '=' * 90) click.echo(' DOCKER CONTAINERS') click.echo('=' * 90 + '\n') try: result = subprocess.run( ['docker', 'ps', '-a', '--format', 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: # Filter out header and show lines = result.stdout.strip().split('\n') for line in lines: # Mark serverkit infrastructure if 'serverkit-frontend' in line or 'serverkit-backend' in line: click.echo(click.style(f'{line} [INFRASTRUCTURE]', fg='blue')) else: click.echo(line) else: click.echo(' Failed to list Docker containers') except Exception as e: click.echo(f' Error: {e}') click.echo('') @cli.command('list-servers') @click.option('--json', 'as_json', is_flag=True, help='Output machine-readable JSON') def list_servers(as_json): """List deployment targets.""" app = create_app() with app.app_context(): from app.services.remote_docker_service import RemoteDockerService servers = RemoteDockerService.get_available_servers() if as_json: _echo_json({'servers': servers}) return click.echo(f"\n{'ID':<38} {'Name':<28} {'Status':<12} {'Target'}") click.echo('-' * 90) for server in servers: target = 'local' if server.get('is_local') else server.get('group_name') or 'remote' click.echo( f"{server.get('id'):<38} " f"{server.get('name'):<28} " f"{server.get('status', '-'):<12} " f"{target}" ) click.echo('') @cli.command('deploy-template') @click.argument('template_id') @click.option('--name', 'app_name', required=True, help='Application name') @click.option('--target', 'server_id', default='local', help='Target server ID or "local"') @click.option('--var', 'variables', multiple=True, help='Template variable in KEY=VALUE form') @click.option('--wait', is_flag=True, help='Wait for deployment to finish') def deploy_template(template_id, app_name, server_id, variables, wait): """Deploy a template to local or remote ServerKit target.""" app = create_app() with app.app_context(): from app.services.deployment_job_service import DeploymentJobService parsed_vars = {} for item in variables: if '=' not in item: click.echo(click.style(f'Invalid --var value: {item}. Use KEY=VALUE.', fg='red')) sys.exit(1) key, value = item.split('=', 1) parsed_vars[key] = value result = DeploymentJobService.install_template( template_id=template_id, app_name=app_name, user_variables=parsed_vars, server_id=server_id, wait=wait, ) if not result.get('success'): click.echo(click.style(result.get('error', 'Deployment failed'), fg='red')) sys.exit(1) job = result.get('job', {}) click.echo(click.style(f'Deployment job created: {job.get("id")}', fg='green')) click.echo(f'Status: {job.get("status")}') click.echo(f'Target: {job.get("target_server_name")}') if wait: if job.get('status') == 'succeeded': click.echo(click.style(f'App created: {job.get("result", {}).get("app_name")}', fg='green')) else: click.echo(click.style(job.get('error_message') or 'Deployment did not complete successfully', fg='red')) sys.exit(1) else: click.echo(f'Check status: serverkit deployment-status {job.get("id")}') @cli.command('deployment-status') @click.argument('job_id') @click.option('--logs', is_flag=True, help='Show job logs') @click.option('--json', 'as_json', is_flag=True, help='Output machine-readable JSON') def deployment_status(job_id, logs, as_json): """Show deployment job status.""" app = create_app() with app.app_context(): from app.services.deployment_job_service import DeploymentJobService job = DeploymentJobService.get_job(job_id, include_logs=logs) if not job: click.echo(click.style('Deployment job not found', fg='red')) sys.exit(1) if as_json: _echo_json({'job': job}) return click.echo(f"\nJob: {job['id']}") click.echo(f"Kind: {job['kind']}") click.echo(f"Status: {job['status']} ({job['progress_percent']}%)") click.echo(f"Target: {job['target_server_name']}") if job.get('app_name'): click.echo(f"App: {job['app_name']}") if job.get('error_message'): click.echo(click.style(f"Error: {job['error_message']}", fg='red')) if logs: click.echo('\nLogs') click.echo('-' * 80) for entry in job.get('logs', []): prefix = f"[{entry['step_index']}] " if entry.get('step_index') else '' click.echo(f"{entry['created_at']} {entry['level'].upper():<5} {prefix}{entry['message']}") click.echo('') # ───────────────────────────────────────────────────────────────────────────── # API-backed commands: talk to the running panel over the local HTTP API using # a short-lived break-glass admin token (root/shell on the box already implies # full control; every mint is audit-logged as 'cli.breakglass'). # ───────────────────────────────────────────────────────────────────────────── STATUS_SYMBOLS = { 'ok': ('✓', 'green'), 'pass': ('✓', 'green'), 'healthy': ('✓', 'green'), 'warn': ('!', 'yellow'), 'warning': ('!', 'yellow'), } def _api_client(with_token=True): """Build an ApiClient for the local panel, minting a break-glass token.""" from app.services.cli_api_client import ApiClient, mint_breakglass_token token = None if with_token: token, _user = mint_breakglass_token() return ApiClient(token=token) def _fail(message): click.echo(click.style(f'Error: {message}', fg='red'), err=True) sys.exit(1) def _echo_table(headers, rows): """Plain fixed-width table via click.echo (no extra deps).""" rows = [[('' if cell is None else str(cell)) for cell in row] for row in rows] widths = [len(h) for h in headers] for row in rows: for i, cell in enumerate(row): widths[i] = max(widths[i], len(cell)) fmt = ' '.join(f'{{:<{w}}}' for w in widths) click.echo(fmt.format(*headers)) click.echo('-' * (sum(widths) + 2 * (len(widths) - 1))) for row in rows: click.echo(fmt.format(*row)) @cli.command() @click.option('--json', 'as_json', is_flag=True, help='Output machine-readable JSON') def status(as_json): """Show panel health and version.""" from app.utils.version import get_panel_version from app.services.cli_api_client import CliApiError version = get_panel_version() try: # /system/health is unauthenticated by design. health = _api_client(with_token=False).get('/system/health') except CliApiError as exc: _fail(str(exc)) if as_json: _echo_json({'version': version, 'health': health}) return rows = [ ('Panel version', version), ('API status', health.get('status', 'unknown')), ('Service', health.get('service', '-')), ('Canonical domain', health.get('canonical_domain') or '-'), ('HTTPS enabled', 'yes' if health.get('canonical_https_enabled') else 'no'), ('Encryption configured', 'yes' if health.get('encryption_configured') else 'no'), ] _echo_table(['Field', 'Value'], rows) @cli.group() def services(): """Inspect and control system services via the panel API.""" @services.command('list') @click.option('--json', 'as_json', is_flag=True, help='Output machine-readable JSON') def services_list(as_json): """List monitored system services.""" from app.services.cli_api_client import CliApiError try: data = _api_client().get('/processes/services') except CliApiError as exc: _fail(str(exc)) entries = data.get('services', []) if as_json: _echo_json({'services': entries}) return if not entries: click.echo('No services reported.') return rows = [(s.get('name', '-'), s.get('status', '-'), s.get('pid', '-')) for s in entries] _echo_table(['Service', 'Status', 'PID'], rows) @services.command('restart') @click.argument('name') def services_restart(name): """Restart a system service.""" from app.services.cli_api_client import CliApiError try: result = _api_client().post(f'/processes/services/{name}', {'action': 'restart'}) except CliApiError as exc: _fail(str(exc)) click.echo(click.style(result.get('message', f'Service "{name}" restarted.'), fg='green')) @cli.group() def apps(): """Inspect applications via the panel API. Read-only. Application lifecycle -- start, stop, restart, logs, delete -- lives in the web UI and the HTTP API, not here; `serverkit apps stop ` has never existed. `serverkit services` does control system services (nginx, docker), which is a different thing and a common source of the confusion. """ @apps.command('list') @click.option('--json', 'as_json', is_flag=True, help='Output machine-readable JSON') def apps_list(as_json): """List applications known to the panel.""" from app.services.cli_api_client import CliApiError try: data = _api_client().get('/apps') except CliApiError as exc: _fail(str(exc)) entries = data.get('apps', []) if as_json: _echo_json({'apps': entries}) return if not entries: click.echo('No applications found.') return rows = [(a.get('name', '-'), a.get('app_type', '-'), a.get('status', '-')) for a in entries] _echo_table(['Name', 'Type', 'Status'], rows) @cli.command('login-url') @click.option('--ttl', default=15, show_default=True, help='Link lifetime in minutes') @click.option('--ip', default=None, help='Bind the link to this client IP address') @click.option('--user', 'user_ref', default=None, help='Username or email (default: first active admin)') def login_url(ttl, ip, user_ref): """Mint a one-time login link for the panel.""" app = create_app() with app.app_context(): from app.services import login_link_service from app.services.cli_api_client import find_breakglass_admin, resolve_port if user_ref: user = User.query.filter( (User.username == user_ref) | (User.email == user_ref) ).first() if not user: _fail(f'User "{user_ref}" not found.') if not user.is_active: _fail(f'User "{user.username}" is not active.') else: user = find_breakglass_admin() if not user: _fail('No active admin user found — create one with "serverkit create-admin".') token, link = login_link_service.mint(user.id, ttl_minutes=ttl, bound_ip=ip) origin = None try: from app.services.site_domain_service import SiteDomainService origin = SiteDomainService.panel_origin() except Exception: origin = None if not origin: origin = f'http://localhost:{resolve_port()}' click.echo(f'{origin}/login?link={token}') click.echo( f'Single-use link for "{user.username}", expires {link.expires_at.isoformat()}Z' + (f', bound to {ip}' if ip else ''), err=True, ) @cli.command() @click.option('--repair', 'do_repair', is_flag=True, help='Repair all repairable findings') @click.option('--yes', is_flag=True, help='Skip the repair confirmation prompt') @click.option('--json', 'as_json', is_flag=True, help='Output the report as JSON (not combinable with --repair)') def doctor(do_repair, yes, as_json): """Run panel diagnostics (and optionally repair findings).""" from app.services.cli_api_client import CliApiError if as_json and do_repair: _fail('--json cannot be combined with --repair (JSON mode is read-only).') try: client = _api_client() data = client.post('/doctor/run') except CliApiError as exc: _fail(str(exc)) if as_json: _echo_json(data.get('report') or {}) return checks = (data.get('report') or {}).get('checks', []) if not checks: click.echo('Doctor reported no checks.') return rows = [] for check in checks: raw_status = str(check.get('status', '')).lower() symbol, color = STATUS_SYMBOLS.get(raw_status, ('✗', 'red')) rows.append(( click.style(symbol, fg=color), check.get('title') or check.get('key', '-'), raw_status or '-', check.get('detail') or '', )) _echo_table(['', 'Check', 'Status', 'Detail'], rows) if not do_repair: return repairable = [ c for c in checks if c.get('repairable') and str(c.get('status', '')).lower() not in ('ok', 'pass', 'healthy') ] if not repairable: click.echo('Nothing repairable.') return click.echo(f'\n{len(repairable)} repairable finding(s): ' + ', '.join(c.get('key', '?') for c in repairable)) if not yes and not click.confirm('Repair all of these?', default=False): click.echo('Aborted.') return # The repair endpoint takes the repair_ref dicts the report carries # ({'kind': 'drift', 'type', 'id'} / {'kind': 'service', 'name'}). items = [c.get('repair_ref') for c in repairable if c.get('repair_ref')] if not items: click.echo('Nothing repairable.') return try: result = client.post('/doctor/repair', {'items': items}) except CliApiError as exc: _fail(str(exc)) results = result.get('results', []) ok = sum(1 for r in results if r.get('success')) for r in results: mark = click.style('OK', fg='green') if r.get('success') else click.style('FAIL', fg='red') detail = r.get('error') or '' click.echo(f" [{mark}] {r.get('item')} {detail}".rstrip()) click.echo(click.style(f'{ok}/{len(results)} repairs succeeded.', fg='green' if ok == len(results) else 'yellow')) @cli.command() @click.argument('drift_type') @click.argument('drift_id') def repair(drift_type, drift_id): """Repair a single drift finding (e.g. serverkit repair nginx ).""" from app.services.cli_api_client import CliApiError try: result = _api_client().post( f'/doctor/drift/{drift_type}/{drift_id}/repair', {'confirm': True} ) except CliApiError as exc: _fail(str(exc)) click.echo(click.style(result.get('message', 'Repair triggered.'), fg='green')) # ── manifest (serverkit.yaml) ──────────────────────────────────────────────── def _manifest_body(project_id, file_path): """Build the request body for the manifest endpoints.""" body = {'project_id': project_id} if file_path: body['content'] = Path(file_path).read_text(encoding='utf-8') return body def _echo_plan_steps(steps): for step in steps: click.echo( f" {step.get('type', '-')} {step.get('service', '-')} " f"{step.get('description', '')}".rstrip() ) def _echo_plan_issues(issues): for issue in issues or []: text = issue if isinstance(issue, str) else ( issue.get('message') or issue.get('detail') or str(issue) ) click.secho(f' ! {text}', fg='yellow') @cli.group() def manifest(): """Work with a project's declarative serverkit.yaml manifest.""" @manifest.command('plan') @click.option('--project', 'project_id', type=int, required=True, help='Project id') @click.option('--file', 'file_path', type=click.Path(exists=True), default=None, help='Manifest file to plan (defaults to the stored manifest)') @click.option('--json', 'as_json', is_flag=True, help='Output the plan as JSON') def manifest_plan(project_id, file_path, as_json): """Compute the change plan for a project's manifest (dry run).""" from app.services.cli_api_client import CliApiError try: data = _api_client().post('/manifests/plan', _manifest_body(project_id, file_path)) except CliApiError as exc: _fail(str(exc)) plan = data.get('plan') or {} if as_json: _echo_json(plan) return steps = plan.get('steps') or [] _echo_plan_steps(steps) _echo_plan_issues(plan.get('issues')) summary = plan.get('summary') or 'Plan computed.' click.echo(f"{summary} ({plan.get('step_count', len(steps))} steps)") @manifest.command('diff') @click.option('--project', 'project_id', type=int, required=True, help='Project id') @click.option('--file', 'file_path', type=click.Path(exists=True), default=None, help='Manifest file to diff (defaults to the stored manifest)') def manifest_diff(project_id, file_path): """Show the human-readable diff between the manifest and live state.""" from app.services.cli_api_client import CliApiError try: data = _api_client().post('/manifests/plan', _manifest_body(project_id, file_path)) except CliApiError as exc: _fail(str(exc)) plan = data.get('plan') or {} steps = plan.get('steps') or [] if plan.get('step_count', len(steps)) == 0: click.echo('No changes — live state matches the manifest.') return click.echo('Planned changes:') _echo_plan_steps(steps) _echo_plan_issues(plan.get('issues')) @manifest.command('apply') @click.option('--project', 'project_id', type=int, required=True, help='Project id') @click.option('--file', 'file_path', type=click.Path(exists=True), default=None, help='Manifest file to apply (defaults to the stored manifest)') @click.option('--yes', is_flag=True, help='Skip the confirmation prompt') def manifest_apply(project_id, file_path, yes): """Apply a project's manifest to live state.""" from app.services.cli_api_client import CliApiError body = _manifest_body(project_id, file_path) client = _api_client() if not yes: try: data = client.post('/manifests/plan', body) except CliApiError as exc: _fail(str(exc)) plan = data.get('plan') or {} steps = plan.get('steps') or [] if plan.get('step_count', len(steps)) == 0: click.echo('No changes — live state matches the manifest.') return click.echo('Planned changes:') _echo_plan_steps(steps) _echo_plan_issues(plan.get('issues')) if not click.confirm('Apply these changes?', default=False): click.echo('Aborted.') return try: result = client.post('/manifests/apply', body) except CliApiError as exc: _fail(str(exc)) results = result.get('results') or [] for r in results: ok = str(r.get('status', '')).lower() in ('ok', 'skipped', 'success', 'applied', 'done') mark = click.style('OK', fg='green') if ok else click.style('FAIL', fg='red') detail = r.get('error') or '' click.echo(f" [{mark}] {r.get('type', '-')} {r.get('service', '-')} {detail}".rstrip()) _echo_plan_issues(result.get('issues')) click.echo(f"Applied {result.get('applied', len(results))} change(s).") if not result.get('success'): failed = next((r for r in results if r.get('error')), None) if failed: _fail(f"Apply failed on {failed.get('service', '?')}: {failed.get('error')}") _fail('Apply did not complete successfully.') @cli.command() @click.option('--yes', is_flag=True, help='Skip the confirmation prompt') def update(yes): """Update ServerKit via the bundled update script (Linux only).""" import subprocess if os.name == 'nt': _fail('serverkit update is Linux-only — run scripts/update.sh on the server itself.') from app.utils.version import get_install_dir candidates = [ os.path.join(get_install_dir(), 'scripts', 'update.sh'), str(Path(__file__).resolve().parent.parent / 'scripts' / 'update.sh'), '/opt/serverkit/scripts/update.sh', ] script = next((c for c in candidates if os.path.isfile(c)), None) if not script: _fail('update.sh not found (looked in: ' + ', '.join(candidates) + ')') click.echo(f'This will run: bash {script}') if not yes and not click.confirm('Update ServerKit now?', default=False): click.echo('Aborted.') return # Inherit stdio so the operator sees the updater output live. proc = subprocess.Popen(['bash', script]) sys.exit(proc.wait()) @cli.command('support-bundle') @click.option('--out', 'out_path', default=None, help='Output path for the zip') @click.option('--passphrase', default=None, help='Requested bundle passphrase (see output: encryption needs external tooling)') def support_bundle(out_path, passphrase): """Build a scrubbed diagnostic support bundle zip.""" app = create_app() with app.app_context(): from app.services import support_bundle_service path = support_bundle_service.build(out_path=out_path, passphrase=passphrase) if passphrase: click.echo(click.style( 'Note: built-in zip encryption is unavailable (pyzipper not installed); ' 'the bundle is NOT encrypted. Encrypt before sharing, e.g. gpg -c ' + path, fg='yellow', )) click.echo(click.style(f'Support bundle written: {path}', fg='green')) # ── disk (reclaim space) ───────────────────────────────────────────────────── @cli.command() @click.option('--days', default=None, type=int, help='Prune telemetry rows older than this many days (default 7)') @click.option('--keep', default=None, type=int, help='Upgrade snapshots to keep, newest first (default 1)') @click.option('--older-than', 'older_than', default=None, type=int, help='Only offer upgrade snapshots at least this many days old') @click.option('--only', default=None, help='Comma-separated candidate keys to reclaim, skipping the menu') @click.option('--safe', is_flag=True, help='Reclaim every candidate marked safe, no prompt') @click.option('--all', 'do_all', is_flag=True, help='Reclaim every candidate, no prompt') @click.option('--dry-run', is_flag=True, help='Report what would be freed, change nothing') @click.option('--allow-restart', is_flag=True, help='Let the panel restart if the database is locked during VACUUM') @click.option('--yes', is_flag=True, help='Skip the confirmation prompt') @click.option('--json', 'as_json', is_flag=True, help='Output machine-readable JSON') def disk(days, keep, older_than, only, safe, do_all, dry_run, allow_restart, yes, as_json): """Show what is using disk space and reclaim it. With no options this lists every reclaimable item with its measured size and asks which to free — pick by number ("1,3"), by "safe", or "all". """ from app.services import disk_reclaim_service as svc days = svc.DEFAULT_RETENTION_DAYS if days is None else days keep = svc.DEFAULT_KEEP_SNAPSHOTS if keep is None else keep if keep < 0: _fail('--keep cannot be negative.') if days < 0: _fail('--days cannot be negative.') app = create_app() with app.app_context(): report = svc.scan(days=days, keep=keep, older_than_days=older_than) candidates = report['candidates'] usable = [c for c in candidates if (c['bytes'] or 0) > 0] if as_json and not (only or safe or do_all): _echo_json(report) return disk_info = report['disk'] or {} if not as_json: click.echo() if disk_info: bar_used = disk_info['percent_used'] colour = 'red' if bar_used >= 90 else ('yellow' if bar_used >= 75 else 'green') click.echo(click.style( f"Disk {disk_info['path']}: {svc.human_bytes(disk_info['used'])} used " f"of {svc.human_bytes(disk_info['total'])} " f"({bar_used}%), {svc.human_bytes(disk_info['free'])} free", fg=colour)) click.echo() # Number only the items that can actually free something, and # number them exactly the way the prompt reads them back. numbers = {cand['key']: n for n, cand in enumerate(usable, start=1)} rows = [] for cand in candidates: size = cand['bytes'] or 0 rows.append(( str(numbers.get(cand['key'], '-')), svc.human_bytes(size) if size else '—', cand['safety'], cand['title'], cand['detail'], )) _echo_table(['#', 'Reclaims', 'Safety', 'Item', 'Detail'], rows) click.echo() click.echo(f"Total reclaimable: " f"{click.style(svc.human_bytes(report['total_bytes']), bold=True)}") # Work out the selection. if do_all: chosen = [c['key'] for c in usable] elif safe: chosen = [c['key'] for c in usable if c['safety'] == 'safe'] elif only: wanted = {k.strip() for k in only.split(',') if k.strip()} known = {c['key'] for c in candidates} unknown = wanted - known if unknown: _fail('unknown candidate(s): ' + ', '.join(sorted(unknown)) + '. Known: ' + ', '.join(sorted(known))) chosen = [c['key'] for c in candidates if c['key'] in wanted] elif not usable: click.echo('\nNothing to reclaim.') return else: chosen = _prompt_disk_selection(usable) if chosen is None: click.echo('Aborted.') return if not chosen: click.echo('Nothing selected.') return # When snapshots were chosen interactively, let the operator say which # ones rather than assuming the keep/age rule. stamps = None interactive = not (do_all or safe or only) if 'upgrade-snapshots' in chosen and interactive and not as_json: snapshot_cand = next(c for c in candidates if c['key'] == 'upgrade-snapshots') stamps = _prompt_snapshot_choice(snapshot_cand) if not stamps: chosen = [k for k in chosen if k != 'upgrade-snapshots'] if not chosen: click.echo('Nothing selected.') return picked = [c for c in candidates if c['key'] in chosen] planned = sum( (sum(s['bytes'] for s in c['snapshots'] if s['stamp'] in stamps) if (stamps is not None and c['key'] == 'upgrade-snapshots') else (c['bytes'] or 0)) for c in picked) if not as_json: click.echo() for cand in picked: size = cand['bytes'] or 0 label = cand['title'] if stamps is not None and cand['key'] == 'upgrade-snapshots': size = sum(s['bytes'] for s in cand['snapshots'] if s['stamp'] in stamps) label = f'{len(stamps)} upgrade snapshot(s)' click.echo(f' • {label} ({svc.human_bytes(size)})') verb = 'Would free' if dry_run else 'Free' click.echo(f"\n{verb} about {click.style(svc.human_bytes(planned), bold=True)}.") if not dry_run and not yes and not as_json: if not click.confirm('Proceed?', default=False): click.echo('Aborted.') return result = svc.reclaim(chosen, days=days, keep=keep, dry_run=dry_run, allow_restart=allow_restart, older_than_days=older_than, snapshot_stamps=stamps) if as_json: _echo_json(result) return click.echo() _echo_table(['Item', 'Freed', 'Note'], [(r['key'], svc.human_bytes(r['bytes'] or 0), r['note']) for r in result['results']]) after = result['disk_after'] or {} click.echo() click.echo(click.style( f"{'Would free' if dry_run else 'Freed'} " f"{svc.human_bytes(result['freed_bytes'])}.", fg='green', bold=True)) if after and not dry_run: click.echo(f"Disk now {after['percent_used']}% used, " f"{svc.human_bytes(after['free'])} free.") def _prompt_snapshot_choice(candidate): """Offer the upgrade snapshots one by one. Returns the stamps to delete. One update writes a database copy and a whole tree backup under the same timestamp, so snapshots are picked as units — a half-deleted snapshot is not a restore point. """ from app.services import disk_reclaim_service as svc snapshots = candidate.get('snapshots') or [] if len(snapshots) <= 1: return candidate.get('doomed_stamps') or [] default_stamps = candidate.get('doomed_stamps') or [] click.echo(f'\n{len(snapshots)} upgrade snapshot(s), newest first:') rows = [] for index, snap in enumerate(snapshots, start=1): age = snap['age_days'] if age is None: when = '-' elif age == 0: when = 'today' else: when = f'{age} day{"s" if age != 1 else ""} ago' rows.append(( str(index), snap['stamp'], when, svc.human_bytes(snap['bytes']), 'newest — kept by default' if index == 1 else ('would delete' if snap['stamp'] in default_stamps else 'kept'), )) _echo_table(['#', 'Snapshot', 'Taken', 'Size', ''], rows) click.echo('\nDelete which? numbers like "1,3", "older-than 14" (days), ' '"all-but-newest", or "none".') by_index = {index: snap for index, snap in enumerate(snapshots, start=1)} while True: answer = click.prompt('Snapshots', default='all-but-newest', show_default=True).strip().lower() if answer in ('none', 'n', 'q'): return [] if answer == 'all-but-newest': return [s['stamp'] for s in snapshots[1:]] if answer.startswith('older-than'): raw = answer.replace('older-than', '').strip() if not raw.isdigit(): click.echo(click.style('Give a number of days, e.g. "older-than 14".', fg='yellow')) continue chosen = svc.select_snapshots(snapshots, keep=1, older_than_days=int(raw)) if not chosen: click.echo(click.style( f'No snapshot besides the newest is {raw}+ days old.', fg='yellow')) continue return [s['stamp'] for s in chosen] picked, bad = [], [] for token in answer.replace(' ', ',').split(','): if not token: continue if token.isdigit() and int(token) in by_index: picked.append(by_index[int(token)]['stamp']) else: bad.append(token) if bad: click.echo(click.style('Not a listed number: ' + ', '.join(bad), fg='yellow')) continue if picked: if len(picked) == len(snapshots) and not click.confirm( 'That deletes every snapshot, leaving no restore point. Sure?', default=False): continue return picked click.echo(click.style('Nothing selected — pick at least one number.', fg='yellow')) def _prompt_disk_selection(usable): """Ask which candidates to reclaim. Returns keys, or None to abort.""" by_index = {index: cand for index, cand in enumerate(usable, start=1)} click.echo('\nSelect what to free: numbers like "1,3", "safe" for everything ' 'marked safe, "all", or "q" to quit.') while True: answer = click.prompt('Selection', default='safe', show_default=True).strip().lower() if answer in ('q', 'quit', 'n', 'no'): return None if answer == 'all': return [c['key'] for c in usable] if answer == 'safe': return [c['key'] for c in usable if c['safety'] == 'safe'] picked, bad = [], [] for token in answer.replace(' ', ',').split(','): if not token: continue if token.isdigit() and int(token) in by_index: picked.append(by_index[int(token)]['key']) else: bad.append(token) if bad: click.echo(click.style('Not a listed number: ' + ', '.join(bad), fg='yellow')) continue if picked: return picked click.echo(click.style('Nothing selected — pick at least one number.', fg='yellow')) if __name__ == '__main__': cli()