mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 15:47:50 +09:00
Introduce a deployment jobs system to support cross-server template installations and remote execution. Adds DeploymentJob and DeploymentJobLog models, DeploymentJobService for job lifecycle, and DeploymentPlanRunner to execute plans locally or via agents. TemplateService gains build_install_plan and related helpers to produce executable plans; Template installs can run async (202) or synchronously. Backend API/CLI updated: new deployment-jobs endpoints, apps API uses RemoteDockerService for remote compose actions, and CLI commands (deploy-template, deployment-status, list-servers) were added. Agent and config changes: file access validation with allowed_paths, file write create_dirs option, default allowed paths, and install script/systemd read-write paths updated to permit /var/lib/serverkit and /var/serverkit. Migration file for deployment_jobs schema included.
752 lines
28 KiB
Python
752 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""ServerKit CLI - Administrative commands for ServerKit."""
|
|
|
|
import os
|
|
import click
|
|
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}")
|
|
|
|
|
|
@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()
|
|
def list_users():
|
|
"""List all users."""
|
|
app = create_app()
|
|
with app.app_context():
|
|
users = User.query.all()
|
|
|
|
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()
|
|
def db_status():
|
|
"""Show current database migration status."""
|
|
app = create_app()
|
|
with app.app_context():
|
|
from app.services.migration_service import MigrationService
|
|
status = MigrationService.get_status()
|
|
|
|
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():
|
|
apps = Application.query.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
|
|
apps = Application.query.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')
|
|
def list_apps(show_all):
|
|
"""List all user applications (excludes ServerKit infrastructure)."""
|
|
import subprocess
|
|
from app.models import Application
|
|
|
|
app = create_app()
|
|
with app.app_context():
|
|
apps = Application.query.all()
|
|
|
|
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')
|
|
def list_servers():
|
|
"""List deployment targets."""
|
|
app = create_app()
|
|
with app.app_context():
|
|
from app.services.remote_docker_service import RemoteDockerService
|
|
|
|
servers = RemoteDockerService.get_available_servers()
|
|
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')
|
|
def deployment_status(job_id, logs):
|
|
"""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)
|
|
|
|
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('')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
cli()
|