Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
Previously install_pve_cli silently skipped installation when deploy.sh was run without root, leaving the 'pve' command unavailable. Now it tries sudo first (password is usually cached from docker operations) and falls back to ~/.local/bin with automatic PATH configuration.
1206 lines
40 KiB
Bash
Executable File
1206 lines
40 KiB
Bash
Executable File
#!/bin/bash
|
||
|
||
# PVEmanager Deployment Script
|
||
# Supports deployment with or without NGINX and SSL
|
||
|
||
set -e
|
||
set -o pipefail
|
||
|
||
# Colors for output
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
NC='\033[0m' # No Color
|
||
|
||
# Functions
|
||
print_info() {
|
||
echo -e "${BLUE}[INFO]${NC} $1"
|
||
}
|
||
|
||
print_success() {
|
||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||
}
|
||
|
||
print_warning() {
|
||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||
}
|
||
|
||
print_error() {
|
||
echo -e "${RED}[ERROR]${NC} $1"
|
||
}
|
||
|
||
# Detect OS
|
||
detect_os() {
|
||
if [ -f /etc/os-release ]; then
|
||
. /etc/os-release
|
||
OS=$ID
|
||
OS_VERSION=$VERSION_ID
|
||
elif [ -f /etc/redhat-release ]; then
|
||
OS="centos"
|
||
elif [ "$(uname)" == "Darwin" ]; then
|
||
OS="macos"
|
||
else
|
||
OS="unknown"
|
||
fi
|
||
echo $OS
|
||
}
|
||
|
||
# Install Docker
|
||
install_docker() {
|
||
local os=$1
|
||
print_info "Installing Docker..."
|
||
|
||
case $os in
|
||
ubuntu|debian)
|
||
# Remove old versions
|
||
sudo apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
|
||
|
||
# Install prerequisites
|
||
sudo apt-get update
|
||
sudo apt-get install -y \
|
||
ca-certificates \
|
||
curl \
|
||
gnupg \
|
||
lsb-release
|
||
|
||
# Add Docker GPG key
|
||
sudo mkdir -p /etc/apt/keyrings
|
||
curl -fsSL https://download.docker.com/linux/$os/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||
|
||
# Add Docker repository
|
||
echo \
|
||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$os \
|
||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||
|
||
# Install Docker
|
||
sudo apt-get update
|
||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||
;;
|
||
centos|rhel|fedora)
|
||
# Remove old versions
|
||
sudo yum remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine 2>/dev/null || true
|
||
|
||
# Install prerequisites
|
||
sudo yum install -y yum-utils
|
||
|
||
# Add Docker repository
|
||
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
|
||
|
||
# Install Docker
|
||
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||
|
||
# Start Docker
|
||
sudo systemctl start docker
|
||
sudo systemctl enable docker
|
||
;;
|
||
macos)
|
||
print_error "Please install Docker Desktop for Mac from https://www.docker.com/products/docker-desktop"
|
||
exit 1
|
||
;;
|
||
*)
|
||
print_error "Unsupported OS. Please install Docker manually."
|
||
print_info "Visit: https://docs.docker.com/engine/install/"
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
# Add current user to docker group
|
||
if [ "$os" != "macos" ]; then
|
||
sudo usermod -aG docker $USER
|
||
print_warning "You may need to log out and back in for docker group changes to take effect"
|
||
fi
|
||
|
||
print_success "Docker installed successfully"
|
||
}
|
||
|
||
# Install other dependencies
|
||
install_dependencies() {
|
||
local os=$1
|
||
print_info "Installing additional dependencies..."
|
||
|
||
case $os in
|
||
ubuntu|debian)
|
||
sudo apt-get update
|
||
sudo apt-get install -y \
|
||
git \
|
||
curl \
|
||
wget \
|
||
openssl \
|
||
jq
|
||
;;
|
||
centos|rhel|fedora)
|
||
sudo yum install -y \
|
||
git \
|
||
curl \
|
||
wget \
|
||
openssl \
|
||
jq
|
||
;;
|
||
macos)
|
||
if command -v brew &> /dev/null; then
|
||
brew install git curl wget openssl jq
|
||
else
|
||
print_warning "Homebrew not found. Installing Homebrew..."
|
||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||
brew install git curl wget openssl jq
|
||
fi
|
||
;;
|
||
esac
|
||
|
||
print_success "Dependencies installed"
|
||
}
|
||
|
||
check_requirements() {
|
||
print_info "Checking requirements..."
|
||
|
||
local os=$(detect_os)
|
||
print_info "Detected OS: $os"
|
||
|
||
local need_install=false
|
||
|
||
# Check Docker
|
||
if ! command -v docker &> /dev/null; then
|
||
print_warning "Docker is not installed"
|
||
need_install=true
|
||
else
|
||
print_success "Docker: $(docker --version | cut -d' ' -f3 | tr -d ',')"
|
||
fi
|
||
|
||
# Check Docker Compose
|
||
if ! docker compose version &> /dev/null; then
|
||
print_warning "Docker Compose plugin is not installed"
|
||
need_install=true
|
||
else
|
||
print_success "Docker Compose: $(docker compose version --short)"
|
||
fi
|
||
|
||
# Check other tools
|
||
local missing_tools=""
|
||
for tool in git curl openssl; do
|
||
if ! command -v $tool &> /dev/null; then
|
||
missing_tools="$missing_tools $tool"
|
||
fi
|
||
done
|
||
|
||
if [ -n "$missing_tools" ]; then
|
||
print_warning "Missing tools:$missing_tools"
|
||
need_install=true
|
||
fi
|
||
|
||
# Install if needed
|
||
if [ "$need_install" = true ]; then
|
||
echo ""
|
||
print_info "Some dependencies are missing."
|
||
read -p "Do you want to install them automatically? (y/n): " INSTALL_CHOICE
|
||
|
||
if [ "$INSTALL_CHOICE" = "y" ] || [ "$INSTALL_CHOICE" = "Y" ]; then
|
||
# Install dependencies
|
||
install_dependencies "$os"
|
||
|
||
# Install Docker if needed
|
||
if ! command -v docker &> /dev/null; then
|
||
install_docker "$os"
|
||
fi
|
||
|
||
# Verify installation
|
||
if ! command -v docker &> /dev/null; then
|
||
print_error "Docker installation failed. Please install manually."
|
||
exit 1
|
||
fi
|
||
|
||
if ! docker compose version &> /dev/null; then
|
||
print_error "Docker Compose installation failed. Please install manually."
|
||
exit 1
|
||
fi
|
||
|
||
print_success "All dependencies installed successfully"
|
||
else
|
||
print_error "Please install missing dependencies and try again."
|
||
echo ""
|
||
echo "Install Docker: https://docs.docker.com/engine/install/"
|
||
exit 1
|
||
fi
|
||
else
|
||
print_success "All requirements satisfied"
|
||
fi
|
||
|
||
# Check if Docker daemon is running
|
||
if ! docker info &> /dev/null; then
|
||
print_warning "Docker daemon is not running. Starting..."
|
||
sudo systemctl start docker 2>/dev/null || sudo service docker start 2>/dev/null || true
|
||
sleep 2
|
||
|
||
if ! docker info &> /dev/null; then
|
||
print_error "Cannot connect to Docker daemon. Please start Docker and try again."
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
print_success "Docker daemon is running"
|
||
}
|
||
|
||
create_env_file() {
|
||
# Generate random passwords and keys
|
||
local RANDOM_DB_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-25)
|
||
local RANDOM_SECRET_KEY=$(openssl rand -hex 32)
|
||
# Fernet key: url-safe base64 of 32 random bytes (same format as
|
||
# cryptography.fernet.Fernet.generate_key())
|
||
local RANDOM_FERNET_KEY=$(openssl rand -base64 32 | tr '+/' '-_')
|
||
|
||
# Ensure required directories exist
|
||
mkdir -p logs nginx/conf.d nginx/ssl nginx/certbot/conf nginx/certbot/www
|
||
|
||
# Create init.sql if it doesn't exist
|
||
if [ ! -f init.sql ]; then
|
||
print_info "Creating init.sql file..."
|
||
cat > init.sql << 'EOF'
|
||
-- PVEmanager Database Initialization
|
||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||
GRANT ALL PRIVILEGES ON DATABASE pvemanager TO pvemanager;
|
||
EOF
|
||
print_success "init.sql file created"
|
||
fi
|
||
|
||
# Check if .env.example exists
|
||
if [ ! -f .env.example ]; then
|
||
print_error ".env.example file not found!"
|
||
print_info "Creating default .env.example..."
|
||
cat > .env.example << 'EOF'
|
||
# Database Configuration
|
||
POSTGRES_PASSWORD=pvemanager_secure_password
|
||
|
||
# Timezone
|
||
TZ=Asia/Tashkent
|
||
TIMEZONE=Asia/Tashkent
|
||
EOF
|
||
fi
|
||
|
||
# Check if backend/.env.example exists
|
||
if [ ! -f backend/.env.example ]; then
|
||
print_error "backend/.env.example file not found!"
|
||
print_info "Creating default backend/.env.example..."
|
||
cat > backend/.env.example << 'EOF'
|
||
# Application
|
||
PANEL_NAME=PVEmanager
|
||
DEBUG=false
|
||
LOG_LEVEL=INFO
|
||
|
||
# Database
|
||
DB_HOST=db
|
||
DB_PORT=5432
|
||
DB_USER=pvemanager
|
||
DB_PASSWORD=pvemanager_secure_password
|
||
DB_NAME=pvemanager
|
||
DATABASE_URL=postgresql://pvemanager:pvemanager_secure_password@db:5432/pvemanager
|
||
|
||
# JWT
|
||
SECRET_KEY=your-very-secure-secret-key-change-this-in-production-minimum-32-chars
|
||
ALGORITHM=HS256
|
||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||
|
||
# SSH
|
||
SSH_TIMEOUT=10
|
||
DEFAULT_SSH_USER=root
|
||
DEFAULT_SSH_PORT=22
|
||
|
||
# Security
|
||
CORS_ORIGINS=*
|
||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||
EOF
|
||
fi
|
||
|
||
# Create root .env file
|
||
if [ ! -f .env ]; then
|
||
print_info "Creating .env file..."
|
||
cp .env.example .env
|
||
|
||
# Replace default password with random one
|
||
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" .env
|
||
|
||
print_success ".env file created"
|
||
else
|
||
print_info ".env file already exists"
|
||
# Read existing password for backend .env
|
||
RANDOM_DB_PASSWORD=$(grep "^POSTGRES_PASSWORD=" .env | cut -d'=' -f2)
|
||
fi
|
||
|
||
# Create backend/.env file
|
||
if [ ! -f backend/.env ]; then
|
||
print_info "Creating backend/.env file..."
|
||
cp backend/.env.example backend/.env
|
||
|
||
# Replace passwords and keys
|
||
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" backend/.env
|
||
sed -i "s/your-very-secure-secret-key-change-this-in-production-minimum-32-chars/${RANDOM_SECRET_KEY}/g" backend/.env
|
||
# Fill the (empty) FERNET_KEY so sensitive fields are encrypted at rest
|
||
sed -i "s|^FERNET_KEY=.*|FERNET_KEY=${RANDOM_FERNET_KEY}|" backend/.env
|
||
|
||
print_success "backend/.env file created"
|
||
else
|
||
print_info "backend/.env file already exists"
|
||
fi
|
||
|
||
echo ""
|
||
print_success "Environment files configured successfully"
|
||
print_info "Note: SMTP and Telegram settings can be configured in the panel's Settings -> Notifications tab"
|
||
}
|
||
|
||
setup_nginx_config() {
|
||
local domain=$1
|
||
local use_ssl=$2
|
||
|
||
print_info "Setting up NGINX configuration..."
|
||
|
||
# Create directories
|
||
mkdir -p nginx/conf.d nginx/ssl nginx/certbot/conf nginx/certbot/www
|
||
|
||
if [ "$use_ssl" = true ]; then
|
||
# Copy SSL template
|
||
cp nginx/conf.d/pvemanager.conf.template nginx/conf.d/pvemanager.conf
|
||
print_info "Using SSL configuration"
|
||
else
|
||
# Copy non-SSL template
|
||
cp nginx/conf.d/pvemanager-nossl.conf.template nginx/conf.d/pvemanager.conf
|
||
print_info "Using non-SSL configuration"
|
||
fi
|
||
|
||
# Replace domain name
|
||
sed -i "s/DOMAIN_NAME/${domain}/g" nginx/conf.d/pvemanager.conf
|
||
|
||
print_success "NGINX configuration created for domain: ${domain}"
|
||
}
|
||
|
||
# Check whether a usable certificate for the domain is already on disk.
|
||
#
|
||
# The check has to run as root inside a container: certbot creates
|
||
# /etc/letsencrypt/{live,archive,accounts} owned by root with mode 0700, so a
|
||
# plain `[ -d nginx/certbot/conf/live/$domain ]` from the (unprivileged) deploy
|
||
# user always fails with EACCES — even when the certificate is perfectly valid.
|
||
certificate_exists() {
|
||
local domain=$1
|
||
|
||
docker run --rm \
|
||
-v "$(pwd)/nginx/certbot/conf:/etc/letsencrypt" \
|
||
--entrypoint sh \
|
||
certbot/certbot -c \
|
||
"[ -s /etc/letsencrypt/live/${domain}/fullchain.pem ] && [ -s /etc/letsencrypt/live/${domain}/privkey.pem ]" \
|
||
>/dev/null 2>&1
|
||
}
|
||
|
||
obtain_ssl_certificate() {
|
||
local domain=$1
|
||
local email=$2
|
||
|
||
print_info "Obtaining SSL certificate for ${domain}..."
|
||
|
||
# Create certbot directories. Only the ACME webroot needs relaxing — the
|
||
# rest belongs to certbot (root, 0700 on purpose: it holds private keys),
|
||
# and chmod -R here would only spam errors it cannot act on anyway.
|
||
mkdir -p nginx/certbot/conf nginx/certbot/www
|
||
chmod 755 nginx/certbot nginx/certbot/www 2>/dev/null || true
|
||
|
||
# Wait for nginx to be fully ready
|
||
print_info "Waiting for nginx to be ready..."
|
||
local max_wait=30
|
||
local waited=0
|
||
while [ $waited -lt $max_wait ]; do
|
||
if curl -s -o /dev/null -w "%{http_code}" "http://localhost/.well-known/acme-challenge/test" 2>/dev/null | grep -q "404\|200"; then
|
||
print_success "NGINX is ready for certificate challenge"
|
||
break
|
||
fi
|
||
sleep 2
|
||
waited=$((waited + 2))
|
||
done
|
||
|
||
if [ $waited -ge $max_wait ]; then
|
||
print_warning "NGINX may not be fully ready, continuing anyway..."
|
||
fi
|
||
|
||
# Request certificate via the webroot nginx is already serving.
|
||
# --keep-until-expiring makes an existing, still-valid certificate a plain
|
||
# success instead of an ambiguous "no action taken" result.
|
||
print_info "Requesting SSL certificate from Let's Encrypt..."
|
||
docker run --rm \
|
||
-v "$(pwd)/nginx/certbot/conf:/etc/letsencrypt" \
|
||
-v "$(pwd)/nginx/certbot/www:/var/www/certbot" \
|
||
certbot/certbot certonly \
|
||
--webroot \
|
||
--webroot-path=/var/www/certbot \
|
||
--email "${email}" \
|
||
--agree-tos \
|
||
--no-eff-email \
|
||
--non-interactive \
|
||
--keep-until-expiring \
|
||
-d "${domain}"
|
||
|
||
local result=$?
|
||
|
||
# The certificate on disk is the source of truth, not certbot's exit code:
|
||
# a certificate that is present and not yet due for renewal is a success
|
||
# even on the runs where certbot reports it had nothing to do.
|
||
if certificate_exists "$domain"; then
|
||
if [ $result -ne 0 ]; then
|
||
print_warning "certbot exited with code ${result}, but a valid certificate for ${domain} is present"
|
||
fi
|
||
print_success "SSL certificate is available for ${domain}"
|
||
return 0
|
||
fi
|
||
|
||
print_error "Failed to obtain SSL certificate (certbot exit code: ${result})"
|
||
print_warning "Check that ${domain} resolves to this server and that port 80 is reachable"
|
||
print_warning "Continuing with HTTP only..."
|
||
return 1
|
||
}
|
||
|
||
# Resilient Docker build: try with cache + --pull first; on failure retry once
|
||
# without cache. Returns non-zero only if both attempts fail.
|
||
# Usage: build_images_resilient "-f compose.yml [-f compose.prod.yml]"
|
||
build_images_resilient() {
|
||
local compose_args="$1"
|
||
print_info "Building Docker images (parallel, with pull)..."
|
||
if DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 \
|
||
docker compose $compose_args build --parallel --pull; then
|
||
return 0
|
||
fi
|
||
|
||
print_warning "Build failed. Retrying without cache (this can take a few minutes)..."
|
||
# Free space from any half-baked layers from the failed build
|
||
docker builder prune -f >/dev/null 2>&1 || true
|
||
if DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 \
|
||
docker compose $compose_args build --parallel --pull --no-cache; then
|
||
print_success "Rebuild without cache succeeded"
|
||
return 0
|
||
fi
|
||
|
||
print_error "Docker build failed twice. Showing recent build context:"
|
||
docker compose $compose_args config --services || true
|
||
return 1
|
||
}
|
||
|
||
# Detect a stale Postgres data volume whose stored password no longer matches
|
||
# the freshly generated one in .env. This is the most common reason for
|
||
# repeated deploys to fail with backend stuck in a restart loop.
|
||
check_stale_db_volume() {
|
||
local volume_name
|
||
# Compose v2 prefixes volumes with the project (folder) name.
|
||
# The volume key in compose.yml is `db_data`, so the real name is
|
||
# <project>_db_data (NOT _postgres_data).
|
||
volume_name="$(basename "$(pwd)")_db_data"
|
||
|
||
if ! docker volume inspect "$volume_name" >/dev/null 2>&1; then
|
||
return 0 # no volume yet, fresh install
|
||
fi
|
||
|
||
local env_password
|
||
env_password=$(grep "^POSTGRES_PASSWORD=" .env 2>/dev/null | cut -d'=' -f2)
|
||
[ -z "$env_password" ] && return 0
|
||
|
||
print_warning "Existing Postgres volume detected: $volume_name"
|
||
print_warning "If it was initialized with a different password, the backend will fail to connect."
|
||
if [ "$RESET_DATA" = "true" ]; then
|
||
print_warning "--reset-data given: removing old Postgres volume..."
|
||
docker compose -f compose.yml down -v 2>/dev/null || true
|
||
docker volume rm "$volume_name" 2>/dev/null || true
|
||
print_success "Old volume removed"
|
||
else
|
||
print_info "If deploy fails on the 'app' / backend container, re-run with: ./deploy.sh --reset-data"
|
||
fi
|
||
}
|
||
|
||
# Wait for the nginx container to settle into a running state and accept its
|
||
# configuration. A bad ssl_certificate path makes nginx exit at startup, so
|
||
# "up -d" returning 0 is not on its own proof that the site is being served.
|
||
verify_nginx_running() {
|
||
local max_wait=20
|
||
local waited=0
|
||
|
||
while [ $waited -lt $max_wait ]; do
|
||
if docker compose -f compose.yml -f compose.prod.yml ps nginx 2>/dev/null | grep -qi "running\|healthy"; then
|
||
if docker compose -f compose.yml -f compose.prod.yml exec -T nginx nginx -t >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
fi
|
||
sleep 2
|
||
waited=$((waited + 2))
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
deploy_with_nginx() {
|
||
local domain=$1
|
||
local use_ssl=$2
|
||
local email=$3
|
||
local ssl_success=false
|
||
|
||
print_info "Deploying with NGINX..."
|
||
|
||
# Clean up any orphaned containers and networks first
|
||
print_info "Cleaning up previous deployment..."
|
||
docker compose -f compose.yml -f compose.prod.yml down --remove-orphans 2>/dev/null || true
|
||
docker network prune -f 2>/dev/null || true
|
||
|
||
check_stale_db_volume
|
||
|
||
# Build images locally first (parallel + pull base images, retry on failure)
|
||
if ! build_images_resilient "-f compose.yml -f compose.prod.yml"; then
|
||
print_error "Cannot continue without successfully built images"
|
||
exit 1
|
||
fi
|
||
|
||
if [ "$use_ssl" = true ]; then
|
||
# Setup HTTP config first for certificate challenge
|
||
setup_nginx_config "$domain" false
|
||
|
||
# First start db, app and nginx for SSL certificate challenge
|
||
print_info "Starting services for SSL certificate challenge..."
|
||
docker compose -f compose.yml -f compose.prod.yml up -d --build db
|
||
|
||
# Wait for database to be ready
|
||
print_info "Waiting for database to be ready..."
|
||
sleep 10
|
||
|
||
docker compose -f compose.yml -f compose.prod.yml up -d --build app
|
||
|
||
# Wait for app to be healthy
|
||
print_info "Waiting for application to be ready..."
|
||
local max_wait=60
|
||
local waited=0
|
||
while [ $waited -lt $max_wait ]; do
|
||
if docker compose -f compose.yml -f compose.prod.yml ps app | grep -q "healthy"; then
|
||
print_success "Application is healthy"
|
||
break
|
||
fi
|
||
sleep 5
|
||
waited=$((waited + 5))
|
||
print_info "Waiting... ($waited/$max_wait seconds)"
|
||
done
|
||
|
||
# Start nginx
|
||
docker compose -f compose.yml -f compose.prod.yml up -d nginx
|
||
sleep 5
|
||
|
||
# Try to obtain SSL certificate
|
||
if obtain_ssl_certificate "$domain" "$email"; then
|
||
# Stop nginx to reconfigure with SSL
|
||
docker compose -f compose.yml -f compose.prod.yml stop nginx
|
||
|
||
# Setup SSL config
|
||
setup_nginx_config "$domain" true
|
||
|
||
print_info "Starting NGINX with SSL..."
|
||
docker compose -f compose.yml -f compose.prod.yml up -d nginx
|
||
|
||
if verify_nginx_running; then
|
||
ssl_success=true
|
||
print_success "NGINX is serving HTTPS for ${domain}"
|
||
|
||
# Start certbot renewal service
|
||
print_info "Starting certbot renewal service..."
|
||
docker compose -f compose.yml -f compose.prod.yml --profile ssl up -d certbot
|
||
else
|
||
# A crash-looping nginx takes the whole panel offline, which is
|
||
# worse than no HTTPS — roll back to the HTTP-only config.
|
||
print_error "NGINX failed to start with the SSL configuration:"
|
||
docker compose -f compose.yml -f compose.prod.yml logs --tail 20 nginx || true
|
||
print_warning "Rolling back to the HTTP-only configuration..."
|
||
docker compose -f compose.yml -f compose.prod.yml stop nginx
|
||
setup_nginx_config "$domain" false
|
||
docker compose -f compose.yml -f compose.prod.yml up -d nginx
|
||
ssl_success=false
|
||
fi
|
||
else
|
||
# Already running with non-SSL config, just continue
|
||
print_warning "Continuing with HTTP only (no SSL)..."
|
||
ssl_success=false
|
||
fi
|
||
else
|
||
setup_nginx_config "$domain" false
|
||
|
||
print_info "Starting services without SSL..."
|
||
|
||
# Start in correct order
|
||
docker compose -f compose.yml -f compose.prod.yml up -d db
|
||
print_info "Waiting for database..."
|
||
sleep 10
|
||
|
||
docker compose -f compose.yml -f compose.prod.yml up -d app
|
||
print_info "Waiting for application..."
|
||
sleep 10
|
||
|
||
docker compose -f compose.yml -f compose.prod.yml up -d nginx
|
||
fi
|
||
|
||
print_success "Deployment with NGINX completed"
|
||
|
||
# Return SSL status for show_deployment_info
|
||
if [ "$ssl_success" = true ]; then
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
deploy_standalone() {
|
||
print_info "Deploying standalone (without NGINX)..."
|
||
|
||
# Clean up any previous deployment
|
||
print_info "Cleaning up previous deployment..."
|
||
docker compose -f compose.yml down --remove-orphans 2>/dev/null || true
|
||
docker network prune -f 2>/dev/null || true
|
||
|
||
check_stale_db_volume
|
||
|
||
# Build images (parallel + pull, with one no-cache retry on failure)
|
||
if ! build_images_resilient "-f compose.yml"; then
|
||
print_error "Cannot continue without successfully built images"
|
||
exit 1
|
||
fi
|
||
|
||
print_info "Starting containers..."
|
||
docker compose -f compose.yml up -d
|
||
|
||
print_success "Standalone deployment completed"
|
||
}
|
||
|
||
get_server_ip() {
|
||
local ip
|
||
# Try to get the primary non-loopback IP
|
||
ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -1)
|
||
if [ -z "$ip" ]; then
|
||
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||
fi
|
||
if [ -z "$ip" ]; then
|
||
ip="localhost"
|
||
fi
|
||
echo "$ip"
|
||
}
|
||
|
||
show_deployment_info() {
|
||
local mode=$1
|
||
local domain=$2
|
||
local use_ssl=$3
|
||
|
||
# Get admin credentials
|
||
local admin_user="admin"
|
||
local admin_password
|
||
admin_password=$(grep "^ADMIN_PASSWORD=" backend/.env 2>/dev/null | cut -d'=' -f2)
|
||
if [ -z "$admin_password" ]; then
|
||
admin_password="admin123"
|
||
fi
|
||
|
||
echo ""
|
||
echo "=========================================="
|
||
print_success "🎉 Deployment completed successfully!"
|
||
echo "=========================================="
|
||
echo ""
|
||
|
||
if [ "$mode" = "nginx" ]; then
|
||
if [ "$use_ssl" = true ]; then
|
||
echo "📍 Access URL: https://${domain}"
|
||
else
|
||
echo "📍 Access URL: http://${domain}"
|
||
fi
|
||
echo "🔒 SSL: ${use_ssl}"
|
||
else
|
||
local server_ip
|
||
server_ip=$(get_server_ip)
|
||
echo "📍 Access URL: http://${server_ip}:3001"
|
||
echo "🔒 SSL: Not configured (standalone mode)"
|
||
fi
|
||
|
||
echo ""
|
||
echo "👤 Admin credentials:"
|
||
echo " Login: ${admin_user}"
|
||
echo " Password: ${admin_password}"
|
||
echo ""
|
||
echo "📊 Service Status:"
|
||
docker compose ps
|
||
echo ""
|
||
echo "📝 View logs: docker compose logs -f"
|
||
echo "🛑 Stop services: docker compose down"
|
||
echo ""
|
||
}
|
||
|
||
# Main deployment logic
|
||
main() {
|
||
echo "=========================================="
|
||
echo " PVEmanager Deployment Tool v1.0"
|
||
echo "=========================================="
|
||
echo ""
|
||
|
||
check_requirements
|
||
create_env_file
|
||
install_update_watchdog
|
||
install_pve_cli --auto
|
||
|
||
# Ask deployment mode
|
||
echo ""
|
||
print_info "Select deployment mode:"
|
||
echo " 1) Standalone (without NGINX, direct port 3001)"
|
||
echo " 2) Production with NGINX (HTTP only)"
|
||
echo " 3) Production with NGINX and SSL (HTTPS)"
|
||
echo ""
|
||
read -p "Enter your choice (1-3): " MODE_CHOICE
|
||
|
||
case $MODE_CHOICE in
|
||
1)
|
||
deploy_standalone
|
||
show_deployment_info "standalone"
|
||
;;
|
||
2)
|
||
read -p "Enter your domain name or server IP: " DOMAIN
|
||
deploy_with_nginx "$DOMAIN" false
|
||
show_deployment_info "nginx" "$DOMAIN" false
|
||
;;
|
||
3)
|
||
read -p "Enter your domain name: " DOMAIN
|
||
read -p "Enter your email for Let's Encrypt: " EMAIL
|
||
|
||
if [[ ! "$EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
||
print_error "Invalid email address"
|
||
exit 1
|
||
fi
|
||
|
||
deploy_with_nginx "$DOMAIN" true "$EMAIL"
|
||
ssl_result=$?
|
||
if [ $ssl_result -eq 0 ]; then
|
||
show_deployment_info "nginx" "$DOMAIN" true
|
||
else
|
||
show_deployment_info "nginx" "$DOMAIN" false
|
||
fi
|
||
;;
|
||
*)
|
||
print_error "Invalid choice"
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# Quick deploy functions (non-interactive)
|
||
quick_deploy_standalone() {
|
||
print_info "Quick deploy: Standalone mode"
|
||
check_requirements
|
||
|
||
# Create env files silently
|
||
mkdir -p logs nginx/conf.d nginx/ssl nginx/certbot/conf nginx/certbot/www
|
||
|
||
local RANDOM_DB_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-25)
|
||
local RANDOM_SECRET_KEY=$(openssl rand -hex 32)
|
||
local RANDOM_FERNET_KEY=$(openssl rand -base64 32 | tr '+/' '-_')
|
||
|
||
if [ ! -f .env ]; then
|
||
if [ -f .env.example ]; then
|
||
cp .env.example .env
|
||
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" .env 2>/dev/null || true
|
||
else
|
||
create_default_env "${RANDOM_DB_PASSWORD}"
|
||
fi
|
||
fi
|
||
|
||
if [ ! -f backend/.env ]; then
|
||
if [ -f backend/.env.example ]; then
|
||
cp backend/.env.example backend/.env
|
||
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" backend/.env 2>/dev/null || true
|
||
sed -i "s/your-very-secure-secret-key-change-this-in-production-minimum-32-chars/${RANDOM_SECRET_KEY}/g" backend/.env 2>/dev/null || true
|
||
sed -i "s|^FERNET_KEY=.*|FERNET_KEY=${RANDOM_FERNET_KEY}|" backend/.env 2>/dev/null || true
|
||
else
|
||
create_default_backend_env "${RANDOM_DB_PASSWORD}" "${RANDOM_SECRET_KEY}" "${RANDOM_FERNET_KEY}"
|
||
fi
|
||
fi
|
||
|
||
deploy_standalone
|
||
install_pve_cli --auto
|
||
show_deployment_info "standalone"
|
||
}
|
||
|
||
quick_deploy_nginx() {
|
||
local domain=$1
|
||
local email=$2
|
||
local use_ssl=false
|
||
|
||
if [ -z "$domain" ]; then
|
||
print_error "Domain is required. Usage: ./deploy.sh --nginx <domain> [email]"
|
||
exit 1
|
||
fi
|
||
|
||
if [ -n "$email" ]; then
|
||
use_ssl=true
|
||
fi
|
||
|
||
print_info "Quick deploy: NGINX mode (domain: $domain, ssl: $use_ssl)"
|
||
check_requirements
|
||
|
||
# Create env files silently
|
||
mkdir -p logs nginx/conf.d nginx/ssl nginx/certbot/conf nginx/certbot/www
|
||
|
||
local RANDOM_DB_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-25)
|
||
local RANDOM_SECRET_KEY=$(openssl rand -hex 32)
|
||
local RANDOM_FERNET_KEY=$(openssl rand -base64 32 | tr '+/' '-_')
|
||
|
||
if [ ! -f .env ]; then
|
||
if [ -f .env.example ]; then
|
||
cp .env.example .env
|
||
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" .env 2>/dev/null || true
|
||
else
|
||
create_default_env "${RANDOM_DB_PASSWORD}"
|
||
fi
|
||
fi
|
||
|
||
if [ ! -f backend/.env ]; then
|
||
if [ -f backend/.env.example ]; then
|
||
cp backend/.env.example backend/.env
|
||
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" backend/.env 2>/dev/null || true
|
||
sed -i "s/your-very-secure-secret-key-change-this-in-production-minimum-32-chars/${RANDOM_SECRET_KEY}/g" backend/.env 2>/dev/null || true
|
||
sed -i "s|^FERNET_KEY=.*|FERNET_KEY=${RANDOM_FERNET_KEY}|" backend/.env 2>/dev/null || true
|
||
else
|
||
create_default_backend_env "${RANDOM_DB_PASSWORD}" "${RANDOM_SECRET_KEY}" "${RANDOM_FERNET_KEY}"
|
||
fi
|
||
fi
|
||
|
||
deploy_with_nginx "$domain" "$use_ssl" "$email"
|
||
ssl_result=$?
|
||
|
||
install_pve_cli --auto
|
||
|
||
if [ "$use_ssl" = true ] && [ $ssl_result -eq 0 ]; then
|
||
show_deployment_info "nginx" "$domain" true
|
||
else
|
||
show_deployment_info "nginx" "$domain" false
|
||
fi
|
||
}
|
||
|
||
create_default_env() {
|
||
local db_password="${1:-pvemanager_secure_password}"
|
||
cat > .env << EOF
|
||
POSTGRES_PASSWORD=${db_password}
|
||
TZ=Asia/Tashkent
|
||
TIMEZONE=Asia/Tashkent
|
||
EOF
|
||
}
|
||
|
||
create_default_backend_env() {
|
||
local db_password="${1:-pvemanager_secure_password}"
|
||
local secret_key="${2:-your-very-secure-secret-key-change-this-in-production-minimum-32-chars}"
|
||
local fernet_key="${3:-$(openssl rand -base64 32 | tr '+/' '-_')}"
|
||
cat > backend/.env << EOF
|
||
PANEL_NAME=PVEmanager
|
||
DEBUG=false
|
||
LOG_LEVEL=INFO
|
||
DB_HOST=db
|
||
DB_PORT=5432
|
||
DB_USER=pvemanager
|
||
DB_PASSWORD=${db_password}
|
||
DB_NAME=pvemanager
|
||
DATABASE_URL=postgresql://pvemanager:${db_password}@db:5432/pvemanager
|
||
SECRET_KEY=${secret_key}
|
||
FERNET_KEY=${fernet_key}
|
||
ALGORITHM=HS256
|
||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||
SSH_TIMEOUT=10
|
||
DEFAULT_SSH_USER=root
|
||
DEFAULT_SSH_PORT=22
|
||
CORS_ORIGINS=*
|
||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||
EOF
|
||
}
|
||
|
||
# Install the host-side update watchdog (systemd service)
|
||
install_update_watchdog() {
|
||
local project_dir
|
||
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
local service_name="pvemanager-update"
|
||
local env_file="/etc/pvemanager-update.env"
|
||
local service_file="/etc/systemd/system/${service_name}.service"
|
||
local watchdog_script="$project_dir/update_host.sh"
|
||
local run_user="${SUDO_USER:-$USER}"
|
||
|
||
print_info "Installing update watchdog (systemd service)..."
|
||
print_info " Project directory : $project_dir"
|
||
print_info " Running as user : $run_user"
|
||
|
||
# Make sure the watchdog script is executable
|
||
chmod +x "$watchdog_script"
|
||
|
||
# Check that the user is in the docker group (root always has access, skip check)
|
||
if [ "$run_user" != "root" ] && ! id -nG "$run_user" | grep -qw docker; then
|
||
print_warning "User '$run_user' is not in the 'docker' group."
|
||
print_warning "The watchdog needs docker access. Run:"
|
||
print_warning " sudo usermod -aG docker $run_user (then log out/in)"
|
||
fi
|
||
|
||
# Write environment file (stores PROJECT_DIR so systemd can pass it to the script)
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
cat > "$env_file" <<EOF
|
||
PROJECT_DIR=$project_dir
|
||
EOF
|
||
print_success "Wrote $env_file"
|
||
|
||
# Write the systemd unit
|
||
cat > "$service_file" <<EOF
|
||
[Unit]
|
||
Description=PVEmanager host-side update watchdog
|
||
After=docker.service
|
||
Requires=docker.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=$run_user
|
||
WorkingDirectory=$project_dir
|
||
EnvironmentFile=$env_file
|
||
ExecStart=$watchdog_script
|
||
Restart=always
|
||
RestartSec=5
|
||
StandardOutput=append:$project_dir/logs/update_host.log
|
||
StandardError=append:$project_dir/logs/update_host.log
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
EOF
|
||
print_success "Wrote $service_file"
|
||
|
||
systemctl daemon-reload
|
||
systemctl enable "$service_name"
|
||
systemctl restart "$service_name"
|
||
|
||
print_success "pvemanager-update.service is active and enabled"
|
||
else
|
||
# Not root — generate the files locally and show the user what to run with sudo
|
||
print_warning "Not running as root. Generating systemd files locally..."
|
||
|
||
mkdir -p "$project_dir/systemd"
|
||
local local_env="$project_dir/systemd/pvemanager-update.env"
|
||
local local_svc="$project_dir/systemd/pvemanager-update.service"
|
||
|
||
cat > "$local_env" <<EOF
|
||
PROJECT_DIR=$project_dir
|
||
EOF
|
||
|
||
cat > "$local_svc" <<EOF
|
||
[Unit]
|
||
Description=PVEmanager host-side update watchdog
|
||
After=docker.service
|
||
Requires=docker.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=$run_user
|
||
WorkingDirectory=$project_dir
|
||
EnvironmentFile=$env_file
|
||
ExecStart=$watchdog_script
|
||
Restart=always
|
||
RestartSec=5
|
||
StandardOutput=append:$project_dir/logs/update_host.log
|
||
StandardError=append:$project_dir/logs/update_host.log
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
EOF
|
||
print_success "Files generated in $project_dir/systemd/"
|
||
print_info "Run the following commands with sudo to install the watchdog:"
|
||
echo ""
|
||
echo " sudo cp \"$local_env\" \"$env_file\""
|
||
echo " sudo cp \"$local_svc\" \"$service_file\""
|
||
echo " sudo systemctl daemon-reload"
|
||
echo " sudo systemctl enable $service_name"
|
||
echo " sudo systemctl start $service_name"
|
||
echo ""
|
||
fi
|
||
}
|
||
|
||
# Install pve CLI tool to /usr/local/bin/pve
|
||
# Usage:
|
||
# install_pve_cli — интерактивный режим (--install-cli): печатает help в конце
|
||
# install_pve_cli --auto — авто-установка во время deploy: тихая, не падает без root
|
||
install_pve_cli() {
|
||
local mode="${1:-manual}"
|
||
local project_dir
|
||
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
local pve_source="$project_dir/pve"
|
||
local install_path="/usr/local/bin/pve"
|
||
|
||
if [ ! -f "$pve_source" ]; then
|
||
if [ "$mode" = "--auto" ]; then
|
||
print_warning "CLI скрипт 'pve' не найден рядом с deploy.sh — пропускаю авто-установку CLI."
|
||
return 0
|
||
fi
|
||
print_error "CLI скрипт не найден: $pve_source"
|
||
print_info "Убедитесь, что файл 'pve' находится в той же директории, что и deploy.sh"
|
||
return 1
|
||
fi
|
||
|
||
# Идемпотентность: если уже установлен и указывает на ту же директорию — пропускаем
|
||
if [ "$mode" = "--auto" ] && [ -f "$install_path" ]; then
|
||
if grep -q "^PVE_DIR=\"$project_dir\"" "$install_path" 2>/dev/null; then
|
||
print_info "pve CLI уже установлен ($install_path → $project_dir)"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
# Prepare the patched script in a temp file
|
||
local tmp_cli
|
||
tmp_cli=$(mktemp /tmp/pve-cli-XXXXXX)
|
||
if ! sed "s|PVE_DIR=\"/opt/pvemanager\"|PVE_DIR=\"$project_dir\"|g" \
|
||
"$pve_source" > "$tmp_cli"; then
|
||
rm -f "$tmp_cli"
|
||
print_error "Не удалось подготовить pve CLI"
|
||
return 1
|
||
fi
|
||
chmod +x "$tmp_cli"
|
||
|
||
# Try to install to /usr/local/bin (global PATH)
|
||
local installed=false
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
cp "$tmp_cli" "$install_path" && installed=true
|
||
else
|
||
# Not root — try sudo (password may already be cached from docker ops)
|
||
if sudo cp "$tmp_cli" "$install_path" 2>/dev/null && \
|
||
sudo chmod +x "$install_path" 2>/dev/null; then
|
||
installed=true
|
||
fi
|
||
fi
|
||
|
||
if [ "$installed" = true ]; then
|
||
rm -f "$tmp_cli"
|
||
print_success "pve CLI установлен в $install_path"
|
||
print_info "Теперь вы можете использовать команду 'pve' из любого места."
|
||
if [ "$mode" != "--auto" ]; then
|
||
echo ""
|
||
"$install_path" help
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
# Fallback: install to ~/.local/bin (works without root)
|
||
local user_bin="${HOME}/.local/bin"
|
||
local user_install_path="$user_bin/pve"
|
||
mkdir -p "$user_bin"
|
||
cp "$tmp_cli" "$user_install_path"
|
||
rm -f "$tmp_cli"
|
||
|
||
print_success "pve CLI установлен в $user_install_path"
|
||
|
||
# Ensure ~/.local/bin is in PATH
|
||
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$user_bin"; then
|
||
local shell_rc=""
|
||
if [ -f "$HOME/.bashrc" ]; then
|
||
shell_rc="$HOME/.bashrc"
|
||
elif [ -f "$HOME/.profile" ]; then
|
||
shell_rc="$HOME/.profile"
|
||
fi
|
||
|
||
if [ -n "$shell_rc" ] && ! grep -q '\.local/bin' "$shell_rc" 2>/dev/null; then
|
||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$shell_rc"
|
||
print_info "Добавлен ~/.local/bin в PATH ($shell_rc)"
|
||
fi
|
||
|
||
export PATH="$user_bin:$PATH"
|
||
print_warning "Для текущей сессии PATH обновлён. Для новых сессий выполните:"
|
||
print_info " source $shell_rc"
|
||
fi
|
||
|
||
if [ "$mode" != "--auto" ]; then
|
||
echo ""
|
||
"$user_install_path" help
|
||
fi
|
||
}
|
||
|
||
show_help() {
|
||
echo ""
|
||
echo "Usage: $0 [OPTIONS]"
|
||
echo ""
|
||
echo "Interactive mode (default):"
|
||
echo " $0 Run interactive deployment wizard"
|
||
echo ""
|
||
echo "Quick deploy options:"
|
||
echo " $0 --standalone Deploy without NGINX (port 3001)"
|
||
echo " $0 --nginx <domain> Deploy with NGINX (HTTP only)"
|
||
echo " $0 --nginx <domain> <email> Deploy with NGINX and SSL (HTTPS)"
|
||
echo ""
|
||
echo "Other options:"
|
||
echo " $0 --help Show this help message"
|
||
echo " $0 --status Show service status"
|
||
echo " $0 --stop Stop all services"
|
||
echo " $0 --restart Restart all services"
|
||
echo " $0 --logs Show live logs"
|
||
echo " $0 --watchdog Install/reinstall the host-side update watchdog (systemd)"
|
||
echo " $0 --reset-data Wipe existing Postgres volume before deploy (DANGER: data loss)"
|
||
echo " sudo $0 --install-cli Install 'pve' CLI tool to /usr/local/bin/pve"
|
||
echo ""
|
||
echo "Examples:"
|
||
echo " $0 --standalone"
|
||
echo " $0 --nginx example.com"
|
||
echo " $0 --nginx example.com admin@example.com"
|
||
echo " sudo $0 --watchdog"
|
||
echo " sudo $0 --install-cli"
|
||
}
|
||
|
||
# Parse command line arguments
|
||
# --reset-data is a modifier flag that may appear in any position; strip it here
|
||
# so the remaining arguments are interpreted normally.
|
||
RESET_DATA="false"
|
||
NEW_ARGS=()
|
||
for arg in "$@"; do
|
||
if [ "$arg" = "--reset-data" ]; then
|
||
RESET_DATA="true"
|
||
else
|
||
NEW_ARGS+=("$arg")
|
||
fi
|
||
done
|
||
set -- "${NEW_ARGS[@]}"
|
||
export RESET_DATA
|
||
|
||
if [ $# -gt 0 ]; then
|
||
case "$1" in
|
||
--help|-h)
|
||
show_help
|
||
exit 0
|
||
;;
|
||
--standalone)
|
||
quick_deploy_standalone
|
||
exit 0
|
||
;;
|
||
--nginx)
|
||
quick_deploy_nginx "$2" "$3"
|
||
exit 0
|
||
;;
|
||
--status)
|
||
docker compose -f compose.yml -f compose.prod.yml ps 2>/dev/null || docker compose ps
|
||
exit 0
|
||
;;
|
||
--stop)
|
||
print_info "Stopping services..."
|
||
docker compose -f compose.yml -f compose.prod.yml down 2>/dev/null || docker compose down
|
||
print_success "Services stopped"
|
||
exit 0
|
||
;;
|
||
--restart)
|
||
print_info "Restarting services..."
|
||
docker compose -f compose.yml -f compose.prod.yml restart 2>/dev/null || docker compose restart
|
||
print_success "Services restarted"
|
||
exit 0
|
||
;;
|
||
--logs)
|
||
docker compose -f compose.yml -f compose.prod.yml logs -f 2>/dev/null || docker compose logs -f
|
||
exit 0
|
||
;;
|
||
--watchdog)
|
||
install_update_watchdog
|
||
exit 0
|
||
;;
|
||
--install-cli)
|
||
install_pve_cli
|
||
exit 0
|
||
;;
|
||
*)
|
||
print_error "Unknown option: $1"
|
||
show_help
|
||
exit 1
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
# Run main function (interactive mode)
|
||
main
|