Upload 4 files
Browse files
core/management/commands/__init__.py
ADDED
|
File without changes
|
core/management/commands/restart_workers.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Management command to restart qcluster workers.
|
| 3 |
+
|
| 4 |
+
This command signals the entrypoint script to restart the worker process.
|
| 5 |
+
It's designed to be called from the web UI or manually.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import signal
|
| 10 |
+
import time
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
from django.core.management.base import BaseCommand, CommandError
|
| 14 |
+
from django.utils import timezone
|
| 15 |
+
|
| 16 |
+
from core.models import GlobalSettings
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
PID_FILE = "/tmp/qcluster.pid"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Command(BaseCommand):
|
| 24 |
+
help = "Restart the django-q2 worker process"
|
| 25 |
+
|
| 26 |
+
def add_arguments(self, parser):
|
| 27 |
+
parser.add_argument(
|
| 28 |
+
"--force",
|
| 29 |
+
action="store_true",
|
| 30 |
+
help="Force restart even if workers appear healthy",
|
| 31 |
+
)
|
| 32 |
+
parser.add_argument(
|
| 33 |
+
"--timeout",
|
| 34 |
+
type=int,
|
| 35 |
+
default=30,
|
| 36 |
+
help="Seconds to wait for workers to restart (default: 30)",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
def handle(self, *args, **options):
|
| 40 |
+
timeout = options["timeout"]
|
| 41 |
+
|
| 42 |
+
# Check if PID file exists
|
| 43 |
+
if not os.path.exists(PID_FILE):
|
| 44 |
+
raise CommandError(
|
| 45 |
+
f"Worker PID file not found at {PID_FILE}. "
|
| 46 |
+
"Workers may not be running or not started via entrypoint.sh"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Read current worker PID
|
| 50 |
+
try:
|
| 51 |
+
with open(PID_FILE, "r") as f:
|
| 52 |
+
old_pid = int(f.read().strip())
|
| 53 |
+
except (ValueError, IOError) as e:
|
| 54 |
+
raise CommandError(f"Failed to read PID file: {e}")
|
| 55 |
+
|
| 56 |
+
# Verify process exists
|
| 57 |
+
try:
|
| 58 |
+
os.kill(old_pid, 0)
|
| 59 |
+
self.stdout.write(f"Current worker PID: {old_pid}")
|
| 60 |
+
except OSError:
|
| 61 |
+
self.stdout.write(
|
| 62 |
+
self.style.WARNING(
|
| 63 |
+
f"Worker process {old_pid} not found. May already be restarting."
|
| 64 |
+
)
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Record the restart request time
|
| 68 |
+
restart_requested_at = timezone.now()
|
| 69 |
+
|
| 70 |
+
# Try to send SIGUSR1 to PID 1 (entrypoint process in Docker)
|
| 71 |
+
restart_sent = False
|
| 72 |
+
try:
|
| 73 |
+
os.kill(1, signal.SIGUSR1)
|
| 74 |
+
self.stdout.write(self.style.SUCCESS("Restart signal sent to entrypoint"))
|
| 75 |
+
restart_sent = True
|
| 76 |
+
except OSError as e:
|
| 77 |
+
# Fallback: try to kill the worker directly (non-Docker case)
|
| 78 |
+
self.stdout.write(
|
| 79 |
+
self.style.WARNING(f"Could not signal PID 1: {e}")
|
| 80 |
+
)
|
| 81 |
+
self.stdout.write("Attempting direct worker termination...")
|
| 82 |
+
try:
|
| 83 |
+
os.kill(old_pid, signal.SIGTERM)
|
| 84 |
+
self.stdout.write(
|
| 85 |
+
self.style.SUCCESS(f"SIGTERM sent to worker {old_pid}")
|
| 86 |
+
)
|
| 87 |
+
restart_sent = True
|
| 88 |
+
except OSError as e2:
|
| 89 |
+
raise CommandError(f"Failed to stop worker: {e2}")
|
| 90 |
+
|
| 91 |
+
if not restart_sent:
|
| 92 |
+
raise CommandError("Failed to send restart signal")
|
| 93 |
+
|
| 94 |
+
# Wait for restart to complete
|
| 95 |
+
self.stdout.write(f"Waiting up to {timeout}s for workers to restart...")
|
| 96 |
+
|
| 97 |
+
start_time = time.time()
|
| 98 |
+
while time.time() - start_time < timeout:
|
| 99 |
+
time.sleep(2)
|
| 100 |
+
|
| 101 |
+
# Check if PID changed (new worker started)
|
| 102 |
+
if os.path.exists(PID_FILE):
|
| 103 |
+
try:
|
| 104 |
+
with open(PID_FILE, "r") as f:
|
| 105 |
+
new_pid = int(f.read().strip())
|
| 106 |
+
if new_pid != old_pid:
|
| 107 |
+
self.stdout.write(
|
| 108 |
+
self.style.SUCCESS(
|
| 109 |
+
f"New worker started with PID {new_pid}"
|
| 110 |
+
)
|
| 111 |
+
)
|
| 112 |
+
return
|
| 113 |
+
except (ValueError, IOError):
|
| 114 |
+
pass
|
| 115 |
+
|
| 116 |
+
# Check if new heartbeat received after restart
|
| 117 |
+
settings = GlobalSettings.get_settings()
|
| 118 |
+
settings.refresh_from_db()
|
| 119 |
+
if (
|
| 120 |
+
settings.worker_heartbeat_at
|
| 121 |
+
and settings.worker_heartbeat_at > restart_requested_at
|
| 122 |
+
):
|
| 123 |
+
self.stdout.write(
|
| 124 |
+
self.style.SUCCESS("Workers restarted successfully!")
|
| 125 |
+
)
|
| 126 |
+
return
|
| 127 |
+
|
| 128 |
+
self.stdout.write(
|
| 129 |
+
self.style.WARNING(
|
| 130 |
+
f"Timeout waiting for restart confirmation. "
|
| 131 |
+
"Check worker logs for status."
|
| 132 |
+
)
|
| 133 |
+
)
|
core/management/commands/setup.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Management command for initial setup.
|
| 3 |
+
|
| 4 |
+
This command is designed to be used as a Docker entrypoint or for
|
| 5 |
+
scripted deployments where browser-based setup is not available.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from django.core.management.base import BaseCommand
|
| 9 |
+
|
| 10 |
+
from core.services.setup_service import SetupService
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Command(BaseCommand):
|
| 14 |
+
help = "Run initial setup (migrations + default environment)"
|
| 15 |
+
|
| 16 |
+
def add_arguments(self, parser):
|
| 17 |
+
parser.add_argument(
|
| 18 |
+
"--skip-env",
|
| 19 |
+
action="store_true",
|
| 20 |
+
help="Skip creating the default Python environment",
|
| 21 |
+
)
|
| 22 |
+
parser.add_argument(
|
| 23 |
+
"--force",
|
| 24 |
+
action="store_true",
|
| 25 |
+
help="Run setup even if already completed",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def handle(self, *args, **options):
|
| 29 |
+
skip_env = options["skip_env"]
|
| 30 |
+
force = options["force"]
|
| 31 |
+
|
| 32 |
+
self.stdout.write("Starting PyRunner setup...\n")
|
| 33 |
+
|
| 34 |
+
# ALWAYS run migrations (required for upgrades)
|
| 35 |
+
self.stdout.write("Running database migrations...")
|
| 36 |
+
success, message = SetupService.run_migrations()
|
| 37 |
+
if success:
|
| 38 |
+
self.stdout.write(self.style.SUCCESS(" Migrations complete"))
|
| 39 |
+
else:
|
| 40 |
+
self.stdout.write(self.style.ERROR(f" Migration failed: {message}"))
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
# Check if full setup is needed (environment creation, etc.)
|
| 44 |
+
if not force and not SetupService.is_setup_needed():
|
| 45 |
+
self.stdout.write(
|
| 46 |
+
self.style.SUCCESS("Setup already completed.")
|
| 47 |
+
)
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
# Create default environment (only on initial setup)
|
| 51 |
+
if not skip_env:
|
| 52 |
+
self.stdout.write("Creating default Python environment...")
|
| 53 |
+
success, message = SetupService.create_default_environment()
|
| 54 |
+
if success:
|
| 55 |
+
self.stdout.write(self.style.SUCCESS(f" {message}"))
|
| 56 |
+
else:
|
| 57 |
+
self.stdout.write(self.style.ERROR(f" Failed: {message}"))
|
| 58 |
+
return
|
| 59 |
+
else:
|
| 60 |
+
self.stdout.write(
|
| 61 |
+
self.style.WARNING(" Skipping default environment (--skip-env)")
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Mark setup as complete
|
| 65 |
+
SetupService.complete_setup()
|
| 66 |
+
|
| 67 |
+
self.stdout.write("")
|
| 68 |
+
self.stdout.write(self.style.SUCCESS("Setup completed successfully!"))
|
| 69 |
+
self.stdout.write("")
|
| 70 |
+
self.stdout.write("Next steps:")
|
| 71 |
+
self.stdout.write(" 1. Start the task worker: python manage.py qcluster")
|
| 72 |
+
self.stdout.write(" 2. Start the web server: python manage.py runserver")
|
| 73 |
+
self.stdout.write(" 3. Log in - the first user becomes the administrator")
|
core/management/commands/setup_default_env.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Management command to set up the default Python environment.
|
| 3 |
+
|
| 4 |
+
Creates a virtual environment at data/environments/default/ and registers it
|
| 5 |
+
in the database as the default environment for script execution.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import subprocess
|
| 10 |
+
import sys
|
| 11 |
+
|
| 12 |
+
from django.conf import settings
|
| 13 |
+
from django.core.management.base import BaseCommand, CommandError
|
| 14 |
+
|
| 15 |
+
from core.models import Environment
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Command(BaseCommand):
|
| 19 |
+
help = "Create and register the default Python environment"
|
| 20 |
+
|
| 21 |
+
def add_arguments(self, parser):
|
| 22 |
+
parser.add_argument(
|
| 23 |
+
"--force",
|
| 24 |
+
action="store_true",
|
| 25 |
+
help="Recreate the environment even if it already exists",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def handle(self, *args, **options):
|
| 29 |
+
force = options["force"]
|
| 30 |
+
|
| 31 |
+
# Check if default environment already exists in DB
|
| 32 |
+
existing = Environment.objects.filter(is_default=True).first()
|
| 33 |
+
if existing and not force:
|
| 34 |
+
self.stdout.write(
|
| 35 |
+
self.style.WARNING(
|
| 36 |
+
f'Default environment already exists: "{existing.name}" '
|
| 37 |
+
f"at {existing.get_full_path()}"
|
| 38 |
+
)
|
| 39 |
+
)
|
| 40 |
+
self.stdout.write("Use --force to recreate it.")
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
# Define paths
|
| 44 |
+
env_path = "default"
|
| 45 |
+
full_path = os.path.join(settings.ENVIRONMENTS_ROOT, env_path)
|
| 46 |
+
|
| 47 |
+
# Check if directory already exists
|
| 48 |
+
if os.path.exists(full_path):
|
| 49 |
+
if not force:
|
| 50 |
+
self.stdout.write(
|
| 51 |
+
self.style.WARNING(
|
| 52 |
+
f"Environment directory already exists: {full_path}"
|
| 53 |
+
)
|
| 54 |
+
)
|
| 55 |
+
self.stdout.write("Use --force to recreate it.")
|
| 56 |
+
return
|
| 57 |
+
else:
|
| 58 |
+
self.stdout.write(f"Removing existing environment at {full_path}...")
|
| 59 |
+
import shutil
|
| 60 |
+
|
| 61 |
+
shutil.rmtree(full_path)
|
| 62 |
+
|
| 63 |
+
# Create the virtual environment
|
| 64 |
+
self.stdout.write(f"Creating virtual environment at {full_path}...")
|
| 65 |
+
try:
|
| 66 |
+
subprocess.run(
|
| 67 |
+
[sys.executable, "-m", "venv", full_path],
|
| 68 |
+
check=True,
|
| 69 |
+
capture_output=True,
|
| 70 |
+
text=True,
|
| 71 |
+
)
|
| 72 |
+
except subprocess.CalledProcessError as e:
|
| 73 |
+
raise CommandError(f"Failed to create virtual environment: {e.stderr}")
|
| 74 |
+
|
| 75 |
+
# Get Python version from the new venv
|
| 76 |
+
if os.name == "nt":
|
| 77 |
+
python_path = os.path.join(full_path, "Scripts", "python.exe")
|
| 78 |
+
else:
|
| 79 |
+
python_path = os.path.join(full_path, "bin", "python")
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
result = subprocess.run(
|
| 83 |
+
[python_path, "--version"],
|
| 84 |
+
check=True,
|
| 85 |
+
capture_output=True,
|
| 86 |
+
text=True,
|
| 87 |
+
)
|
| 88 |
+
python_version = result.stdout.strip().replace("Python ", "")
|
| 89 |
+
except subprocess.CalledProcessError:
|
| 90 |
+
python_version = "unknown"
|
| 91 |
+
|
| 92 |
+
# Create or update the Environment record
|
| 93 |
+
if existing and force:
|
| 94 |
+
# Update existing record
|
| 95 |
+
existing.path = env_path
|
| 96 |
+
existing.python_version = python_version
|
| 97 |
+
existing.save()
|
| 98 |
+
self.stdout.write(
|
| 99 |
+
self.style.SUCCESS(
|
| 100 |
+
f'Updated default environment "{existing.name}" '
|
| 101 |
+
f"(Python {python_version})"
|
| 102 |
+
)
|
| 103 |
+
)
|
| 104 |
+
else:
|
| 105 |
+
# Create new record
|
| 106 |
+
Environment.objects.create(
|
| 107 |
+
name="Default Environment",
|
| 108 |
+
description="Auto-created default Python environment",
|
| 109 |
+
path=env_path,
|
| 110 |
+
python_version=python_version,
|
| 111 |
+
is_default=True,
|
| 112 |
+
is_active=True,
|
| 113 |
+
)
|
| 114 |
+
self.stdout.write(
|
| 115 |
+
self.style.SUCCESS(
|
| 116 |
+
f"Created default environment (Python {python_version})"
|
| 117 |
+
)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
self.stdout.write(f"Environment path: {full_path}")
|
| 121 |
+
self.stdout.write(f"Python executable: {python_path}")
|