500 lines
14 KiB
Bash
500 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
LOG_FILE="${LOG_FILE:-/tmp/laravel-site-bootstrap.log}"
|
|
|
|
SITE_KEY="${SITE_KEY:-}"
|
|
PROJECT_NAME="${PROJECT_NAME:-Laravel Site}"
|
|
APP_DIR="${APP_DIR:-}"
|
|
BRANCH="${BRANCH:-main}"
|
|
REPO_URL="${REPO_URL:-}"
|
|
APP_URL="${APP_URL:-http://127.0.0.1}"
|
|
SERVER_NAME="${SERVER_NAME:-_}"
|
|
DB_NAME="${DB_NAME:-}"
|
|
DB_USER="${DB_USER:-}"
|
|
DB_PASSWORD="${DB_PASSWORD:-}"
|
|
DB_HOST="${DB_HOST:-127.0.0.1}"
|
|
DB_PORT="${DB_PORT:-3306}"
|
|
PHP_VERSION="${PHP_VERSION:-8.3}"
|
|
RUN_NPM_BUILD=1
|
|
RUN_MIGRATIONS=1
|
|
CONFIGURE_SCHEDULER=1
|
|
CONFIGURE_QUEUE_WORKER=0
|
|
ALLOW_EXISTING_APP_DIR=0
|
|
ALLOW_EXISTING_DATABASE=0
|
|
REPLACE_NGINX_SITE=0
|
|
QUEUE_COMMAND="${QUEUE_COMMAND:-php artisan queue:work --sleep=3 --tries=3 --max-time=3600}"
|
|
|
|
OWNER_USER="${SUDO_USER:-$USER}"
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: laravel-site-bootstrap.sh [options]
|
|
|
|
Required options:
|
|
--repo-url <url> Git repository URL to clone
|
|
--db-password <password> MySQL password for the application user
|
|
|
|
Optional options:
|
|
--site-key <slug> Unique site key used for nginx/cron/supervisor names
|
|
--project-name <name> APP_NAME value (default: Laravel Site)
|
|
--branch <name> Git branch to checkout (default: main)
|
|
--app-dir <path> Laravel application directory (default: /var/www/<site-key>)
|
|
--app-url <url> Public APP_URL value (default: http://127.0.0.1)
|
|
--server-name <name> Nginx server_name value (default: _)
|
|
--db-name <name> MySQL database name (default derived from site key)
|
|
--db-user <user> MySQL app user (default derived from site key)
|
|
--db-host <host> MySQL host (default: 127.0.0.1)
|
|
--db-port <port> MySQL port (default: 3306)
|
|
--php-version <version> PHP FPM version for nginx upstream (default: 8.3)
|
|
--skip-build Skip npm install/build
|
|
--skip-migrations Skip artisan migrate --force
|
|
--without-scheduler Do not create the cron scheduler entry
|
|
--with-queue-worker Create a supervisor queue worker service
|
|
--queue-command <cmd> Queue worker command when --with-queue-worker is used
|
|
--allow-existing-app-dir Reuse an existing git worktree in the target app dir
|
|
--allow-existing-database Reuse an existing database/user instead of failing
|
|
--replace-nginx-site Replace an existing nginx site file for this site key
|
|
--help Show this message
|
|
|
|
Example:
|
|
sudo bash scripts/install/laravel-site-bootstrap.sh \
|
|
--site-key demo-netgescon \
|
|
--project-name "Demo NetGescon" \
|
|
--repo-url git@your-gitea:studio/demo-netgescon.git \
|
|
--branch main \
|
|
--app-url https://demo.example.it \
|
|
--server-name demo.example.it \
|
|
--db-password 'StrongDbPass123!'
|
|
EOF
|
|
}
|
|
|
|
log() {
|
|
local level="$1"
|
|
shift
|
|
printf '[%s] %s\n' "$level" "$*" >&2
|
|
printf '[%s] %s\n' "$level" "$*" >> "$LOG_FILE" 2>/dev/null || true
|
|
}
|
|
|
|
info() { log INFO "$@"; }
|
|
warn() { log WARN "$@"; }
|
|
fail() { log ERROR "$@"; exit 1; }
|
|
|
|
on_error() {
|
|
local exit_code="$1"
|
|
local line_no="$2"
|
|
local command_text="$3"
|
|
|
|
log ERROR "Installer failed at line ${line_no}: ${command_text} (exit ${exit_code})"
|
|
log ERROR "See log file: ${LOG_FILE}"
|
|
exit "$exit_code"
|
|
}
|
|
|
|
trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR
|
|
|
|
run_root() {
|
|
if [[ ${EUID} -eq 0 ]]; then
|
|
"$@"
|
|
else
|
|
sudo "$@"
|
|
fi
|
|
}
|
|
|
|
run_owner() {
|
|
local target_user="$1"
|
|
shift
|
|
|
|
if [[ ${EUID} -eq 0 ]]; then
|
|
runuser -u "$target_user" -- "$@"
|
|
else
|
|
sudo -u "$target_user" "$@"
|
|
fi
|
|
}
|
|
|
|
sql_escape() {
|
|
printf '%s' "$1" | sed "s/'/''/g"
|
|
}
|
|
|
|
set_env_value() {
|
|
local key="$1"
|
|
local value="$2"
|
|
local file="$3"
|
|
local escaped
|
|
|
|
escaped=$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')
|
|
|
|
if grep -q "^${key}=" "$file"; then
|
|
sed -i "s/^${key}=.*/${key}=${escaped}/" "$file"
|
|
else
|
|
printf '%s=%s\n' "$key" "$value" >> "$file"
|
|
fi
|
|
}
|
|
|
|
slugify() {
|
|
local value="$1"
|
|
|
|
value=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')
|
|
value=$(printf '%s' "$value" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')
|
|
|
|
if [[ -z "$value" ]]; then
|
|
value='laravel-site'
|
|
fi
|
|
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
ensure_site_key() {
|
|
if [[ -z "$SITE_KEY" ]]; then
|
|
if [[ -n "$APP_DIR" ]]; then
|
|
SITE_KEY="$(slugify "$(basename "$APP_DIR")")"
|
|
elif [[ "$SERVER_NAME" != '_' ]]; then
|
|
SITE_KEY="$(slugify "$SERVER_NAME")"
|
|
elif [[ -n "$DB_NAME" ]]; then
|
|
SITE_KEY="$(slugify "$DB_NAME")"
|
|
else
|
|
SITE_KEY='laravel-site'
|
|
fi
|
|
else
|
|
SITE_KEY="$(slugify "$SITE_KEY")"
|
|
fi
|
|
|
|
if [[ -z "$APP_DIR" ]]; then
|
|
APP_DIR="/var/www/${SITE_KEY}"
|
|
fi
|
|
|
|
if [[ -z "$DB_NAME" ]]; then
|
|
DB_NAME="$(printf 'laravel_%s' "$SITE_KEY" | tr '-' '_' | cut -c1-64)"
|
|
fi
|
|
|
|
if [[ -z "$DB_USER" ]]; then
|
|
DB_USER="$(printf '%s_user' "$DB_NAME" | cut -c1-32)"
|
|
fi
|
|
}
|
|
|
|
mysql_database_exists() {
|
|
run_root mysql -Nse "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$1'" | grep -qx "$1"
|
|
}
|
|
|
|
mysql_user_exists() {
|
|
run_root mysql -Nse "SELECT User FROM mysql.user WHERE User = '$1' AND Host = 'localhost'" | grep -qx "$1"
|
|
}
|
|
|
|
assert_safe_targets() {
|
|
if [[ -d "$APP_DIR" ]] && [[ -n "$(find "$APP_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | head -n 1)" ]]; then
|
|
if [[ $ALLOW_EXISTING_APP_DIR -ne 1 ]]; then
|
|
fail "App directory already exists and is not empty: $APP_DIR. Re-run with --allow-existing-app-dir only if you really want to reuse it."
|
|
fi
|
|
|
|
if [[ ! -d "$APP_DIR/.git" || ! -f "$APP_DIR/artisan" ]]; then
|
|
fail "Existing app directory must already be a Laravel git worktree: $APP_DIR"
|
|
fi
|
|
fi
|
|
|
|
if [[ -e "$NGINX_SITE_FILE" || -L "$NGINX_SITE_ENABLED" ]]; then
|
|
if [[ $REPLACE_NGINX_SITE -ne 1 ]]; then
|
|
fail "Nginx site already exists for ${SITE_KEY}. Re-run with --replace-nginx-site only if replacement is intentional."
|
|
fi
|
|
fi
|
|
|
|
if mysql_database_exists "$DB_NAME"; then
|
|
if [[ $ALLOW_EXISTING_DATABASE -ne 1 ]]; then
|
|
fail "Database already exists: $DB_NAME. Re-run with --allow-existing-database only if reuse is intentional."
|
|
fi
|
|
fi
|
|
|
|
if mysql_user_exists "$DB_USER"; then
|
|
if [[ $ALLOW_EXISTING_DATABASE -ne 1 ]]; then
|
|
fail "Database user already exists: $DB_USER. Re-run with --allow-existing-database only if reuse is intentional."
|
|
fi
|
|
fi
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--site-key)
|
|
SITE_KEY="$2"
|
|
shift 2
|
|
;;
|
|
--project-name)
|
|
PROJECT_NAME="$2"
|
|
shift 2
|
|
;;
|
|
--repo-url)
|
|
REPO_URL="$2"
|
|
shift 2
|
|
;;
|
|
--branch)
|
|
BRANCH="$2"
|
|
shift 2
|
|
;;
|
|
--app-dir)
|
|
APP_DIR="$2"
|
|
shift 2
|
|
;;
|
|
--app-url)
|
|
APP_URL="$2"
|
|
shift 2
|
|
;;
|
|
--server-name)
|
|
SERVER_NAME="$2"
|
|
shift 2
|
|
;;
|
|
--db-name)
|
|
DB_NAME="$2"
|
|
shift 2
|
|
;;
|
|
--db-user)
|
|
DB_USER="$2"
|
|
shift 2
|
|
;;
|
|
--db-password)
|
|
DB_PASSWORD="$2"
|
|
shift 2
|
|
;;
|
|
--db-host)
|
|
DB_HOST="$2"
|
|
shift 2
|
|
;;
|
|
--db-port)
|
|
DB_PORT="$2"
|
|
shift 2
|
|
;;
|
|
--php-version)
|
|
PHP_VERSION="$2"
|
|
shift 2
|
|
;;
|
|
--skip-build)
|
|
RUN_NPM_BUILD=0
|
|
shift
|
|
;;
|
|
--skip-migrations)
|
|
RUN_MIGRATIONS=0
|
|
shift
|
|
;;
|
|
--without-scheduler)
|
|
CONFIGURE_SCHEDULER=0
|
|
shift
|
|
;;
|
|
--with-queue-worker)
|
|
CONFIGURE_QUEUE_WORKER=1
|
|
shift
|
|
;;
|
|
--queue-command)
|
|
QUEUE_COMMAND="$2"
|
|
shift 2
|
|
;;
|
|
--allow-existing-app-dir)
|
|
ALLOW_EXISTING_APP_DIR=1
|
|
shift
|
|
;;
|
|
--allow-existing-database)
|
|
ALLOW_EXISTING_DATABASE=1
|
|
shift
|
|
;;
|
|
--replace-nginx-site)
|
|
REPLACE_NGINX_SITE=1
|
|
shift
|
|
;;
|
|
--help|-h)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
fail "Unknown option: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
info "Starting Laravel site bootstrap"
|
|
info "Execution user: $(id -un)"
|
|
info "Log file: $LOG_FILE"
|
|
|
|
if [[ -z "$REPO_URL" ]]; then
|
|
fail "--repo-url is required"
|
|
fi
|
|
|
|
if [[ -z "$DB_PASSWORD" ]]; then
|
|
fail "--db-password is required"
|
|
fi
|
|
|
|
for command in git php composer mysql nginx; do
|
|
if ! command -v "$command" >/dev/null 2>&1; then
|
|
fail "Missing required command: $command"
|
|
fi
|
|
done
|
|
|
|
ensure_site_key
|
|
|
|
APP_PARENT_DIR="$(dirname "$APP_DIR")"
|
|
NGINX_SITE_FILE="/etc/nginx/sites-available/${SITE_KEY}.conf"
|
|
NGINX_SITE_ENABLED="/etc/nginx/sites-enabled/${SITE_KEY}.conf"
|
|
CRON_FILE="/etc/cron.d/${SITE_KEY}-scheduler"
|
|
SUPERVISOR_FILE="/etc/supervisor/conf.d/${SITE_KEY}-worker.conf"
|
|
TEMP_DIR="$(mktemp -d)"
|
|
trap 'rm -rf "$TEMP_DIR"' EXIT
|
|
|
|
assert_safe_targets
|
|
|
|
info "Preparing directories"
|
|
run_root install -d -o "$OWNER_USER" -g www-data -m 2775 "$APP_PARENT_DIR"
|
|
|
|
if [[ -d "$APP_DIR/.git" ]]; then
|
|
info "Refreshing application worktree"
|
|
run_owner "$OWNER_USER" git -C "$APP_DIR" remote set-url origin "$REPO_URL"
|
|
run_owner "$OWNER_USER" git -C "$APP_DIR" fetch --all --prune
|
|
run_owner "$OWNER_USER" git -C "$APP_DIR" checkout "$BRANCH"
|
|
run_owner "$OWNER_USER" git -C "$APP_DIR" reset --hard "origin/$BRANCH"
|
|
else
|
|
info "Cloning application worktree"
|
|
run_owner "$OWNER_USER" git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
|
|
fi
|
|
|
|
if [[ ! -f "$APP_DIR/artisan" ]]; then
|
|
fail "Laravel artisan file not found in $APP_DIR"
|
|
fi
|
|
|
|
info "Configuring MySQL database and user"
|
|
DB_PASSWORD_ESCAPED="$(sql_escape "$DB_PASSWORD")"
|
|
run_root mysql <<SQL
|
|
CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD_ESCAPED';
|
|
ALTER USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD_ESCAPED';
|
|
GRANT ALL PRIVILEGES ON \`$DB_NAME\`.* TO '$DB_USER'@'localhost';
|
|
FLUSH PRIVILEGES;
|
|
SQL
|
|
|
|
cd "$APP_DIR"
|
|
|
|
info "Installing PHP dependencies"
|
|
run_owner "$OWNER_USER" composer install --no-dev --optimize-autoloader
|
|
|
|
if [[ $RUN_NPM_BUILD -eq 1 && -f package.json ]]; then
|
|
info "Installing frontend dependencies"
|
|
if [[ -f package-lock.json ]]; then
|
|
run_owner "$OWNER_USER" npm ci
|
|
else
|
|
run_owner "$OWNER_USER" npm install
|
|
fi
|
|
run_owner "$OWNER_USER" npm run build
|
|
fi
|
|
|
|
if [[ ! -f .env ]]; then
|
|
if [[ -f .env.example ]]; then
|
|
cp .env.example .env
|
|
else
|
|
fail ".env and .env.example are both missing"
|
|
fi
|
|
fi
|
|
|
|
info "Configuring Laravel environment"
|
|
set_env_value APP_NAME "$PROJECT_NAME" .env
|
|
set_env_value APP_ENV production .env
|
|
set_env_value APP_DEBUG false .env
|
|
set_env_value APP_URL "$APP_URL" .env
|
|
set_env_value DB_CONNECTION mysql .env
|
|
set_env_value DB_HOST "$DB_HOST" .env
|
|
set_env_value DB_PORT "$DB_PORT" .env
|
|
set_env_value DB_DATABASE "$DB_NAME" .env
|
|
set_env_value DB_USERNAME "$DB_USER" .env
|
|
set_env_value DB_PASSWORD "$DB_PASSWORD" .env
|
|
|
|
php artisan key:generate --force
|
|
php artisan storage:link || true
|
|
php artisan optimize:clear
|
|
|
|
if [[ $RUN_MIGRATIONS -eq 1 ]]; then
|
|
info "Running migrations"
|
|
php artisan migrate --force
|
|
fi
|
|
|
|
info "Caching Laravel configuration"
|
|
php artisan config:cache
|
|
php artisan route:cache
|
|
php artisan view:cache
|
|
|
|
info "Setting permissions"
|
|
run_root chown -R www-data:www-data "$APP_DIR"
|
|
run_root chmod -R 775 "$APP_DIR/storage" "$APP_DIR/bootstrap/cache"
|
|
|
|
info "Writing nginx virtual host"
|
|
cat > "$TEMP_DIR/${SITE_KEY}.conf" <<EOF
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name $SERVER_NAME;
|
|
root $APP_DIR/public;
|
|
|
|
index index.php index.html;
|
|
client_max_body_size 64m;
|
|
|
|
access_log /var/log/nginx/${SITE_KEY}-access.log;
|
|
error_log /var/log/nginx/${SITE_KEY}-error.log;
|
|
|
|
location / {
|
|
try_files \$uri \$uri/ /index.php?\$query_string;
|
|
}
|
|
|
|
location ~ \.php$ {
|
|
include snippets/fastcgi-php.conf;
|
|
fastcgi_pass unix:/run/php/php${PHP_VERSION}-fpm.sock;
|
|
fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name;
|
|
include fastcgi_params;
|
|
}
|
|
|
|
location ~ /\.(?!well-known).* {
|
|
deny all;
|
|
}
|
|
}
|
|
EOF
|
|
run_root install -m 0644 "$TEMP_DIR/${SITE_KEY}.conf" "$NGINX_SITE_FILE"
|
|
run_root ln -sfn "$NGINX_SITE_FILE" "$NGINX_SITE_ENABLED"
|
|
run_root nginx -t
|
|
run_root systemctl reload nginx
|
|
|
|
if [[ $CONFIGURE_SCHEDULER -eq 1 ]]; then
|
|
info "Configuring scheduler cron"
|
|
cat > "$TEMP_DIR/${SITE_KEY}-scheduler" <<EOF
|
|
* * * * * www-data cd $APP_DIR && php artisan schedule:run >> /var/log/${SITE_KEY}-scheduler.log 2>&1
|
|
EOF
|
|
run_root install -m 0644 "$TEMP_DIR/${SITE_KEY}-scheduler" "$CRON_FILE"
|
|
fi
|
|
|
|
if [[ $CONFIGURE_QUEUE_WORKER -eq 1 ]]; then
|
|
info "Configuring supervisor worker"
|
|
cat > "$TEMP_DIR/${SITE_KEY}-worker.conf" <<EOF
|
|
[program:${SITE_KEY}-worker]
|
|
command=$QUEUE_COMMAND
|
|
directory=$APP_DIR
|
|
autostart=true
|
|
autorestart=true
|
|
user=www-data
|
|
redirect_stderr=true
|
|
stdout_logfile=/var/log/${SITE_KEY}-worker.log
|
|
stopwaitsecs=3600
|
|
EOF
|
|
run_root install -m 0644 "$TEMP_DIR/${SITE_KEY}-worker.conf" "$SUPERVISOR_FILE"
|
|
run_root supervisorctl reread
|
|
run_root supervisorctl update
|
|
run_root supervisorctl restart "${SITE_KEY}-worker" || true
|
|
fi
|
|
|
|
SUMMARY_FILE="$APP_DIR/install-site-summary.txt"
|
|
cat > "$TEMP_DIR/install-site-summary.txt" <<EOF
|
|
Laravel site bootstrap completed
|
|
Date: $(date -Is)
|
|
Site key: $SITE_KEY
|
|
Project name: $PROJECT_NAME
|
|
Application dir: $APP_DIR
|
|
Branch: $BRANCH
|
|
APP_URL: $APP_URL
|
|
Server name: $SERVER_NAME
|
|
Database: $DB_NAME
|
|
Database user: $DB_USER
|
|
Scheduler configured: $CONFIGURE_SCHEDULER
|
|
Queue worker configured: $CONFIGURE_QUEUE_WORKER
|
|
EOF
|
|
run_root install -o www-data -g www-data -m 0644 "$TEMP_DIR/install-site-summary.txt" "$SUMMARY_FILE"
|
|
|
|
info "Laravel site bootstrap completed"
|
|
printf '\nSummary file:\n'
|
|
printf ' %s\n' "$SUMMARY_FILE" |