diff --git a/.env.example b/.env.example deleted file mode 100644 index 24ecb84..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -COMPOSE_PROFILES=nginx,postgresql -PHP_VERSION=8.2 -UID=1000 -PHP_EXTENSIONS=gettext,pdo_mysql,mysqli,bcmath,zip,gd -LINUX_PACKAGES=git,curl -NODE_VERSION=lts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d893095 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +.gitignore export-ignore +docs export-ignore +.readthedocs.yaml export-ignore +.gitattributes export-ignore diff --git a/.gitignore b/.gitignore index 3bf780b..5ec79f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .idea -.env \ No newline at end of file +.env +*~ +Local +*.txt +!docs/requirements.txt diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..8111fe2 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,18 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +python: + install: + - requirements: docs/requirements.txt + +sphinx: + configuration: docs/conf.py + +formats: + - pdf + - epub + diff --git a/LICENSE b/LICENSE index cdcf6d2..7ceb1ae 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 A. B. M. Mahmudul Hasan +Copyright (c) 2024 Infocyph Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.mdx similarity index 96% rename from README.md rename to README.mdx index 3d7342a..c4ec6b8 100644 --- a/README.md +++ b/README.mdx @@ -4,13 +4,12 @@ This project provides easy to use docker based development environment for your can be enabled based on Environment file (.env)! Supports multiple domains. >1. This is for local development only! ->2. Don't state both apache & nginx in COMPOSE_PROFILES. ->3. Domain(s) should be available in the hosts file ` `. +>2. Domain(s) should be available in the hosts file ` ` or DNS propageted to your host unless the TLD is localhost (*.localhost). ## Prerequisites (Docker) Install docker on your system first. If you already have docker installed, you can skip this step. -- To install on Linux, it is recommended to use [Docker Engine](https://docs.docker.com/engine/install/). -- To install on other OS, use [Docker Desktop](https://docs.docker.com/desktop/) (although you can also install this on linux as well). +- It is recommended to use [Docker Engine](https://docs.docker.com/engine/install/). +- If Docker Engine not supported in your OS, use [Docker Desktop](https://docs.docker.com/desktop/) (although you can also install this on linux as well). ## The directory structure diff --git a/bin/cert b/bin/cert deleted file mode 100644 index a1fa642..0000000 --- a/bin/cert +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -sites=$@ -docker exec -it SERVER_TOOLS bash -c "cd /etc/ssl/custom && mkcert ${sites}" diff --git a/bin/cert.bat b/bin/cert.bat deleted file mode 100644 index 08cb4a5..0000000 --- a/bin/cert.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off - -set sites=%* -docker exec -it SERVER_TOOLS bash -c "cd /etc/ssl/custom && mkcert %sites%" \ No newline at end of file diff --git a/bin/composer b/bin/composer new file mode 100755 index 0000000..292f617 --- /dev/null +++ b/bin/composer @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + echo "Error: $*" >&2 + exit 1 +} + +# ───────────────────────────────────────────────────────────────────────────── +# Repo layout: +# /bin/composer (this script) +# /docker/.env +# /configuration/ssh +# ───────────────────────────────────────────────────────────────────────────── +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT_DIR/docker/.env" +SSH_DIR="$ROOT_DIR/configuration/ssh" + +# ───────────────────────────────────────────────────────────────────────────── +# Pretty output (auto-disable when stderr isn't a TTY) +# ───────────────────────────────────────────────────────────────────────────── +if [[ -t 2 ]]; then + C_RESET=$'\033[0m' + C_CYAN=$'\033[0;36m' + C_YELLOW=$'\033[0;33m' +else + C_RESET="" + C_CYAN="" + C_YELLOW="" +fi + +kv() { + local title="$1" value="$2" + printf "%s%s%s %s\n" "$C_CYAN" "$title" "$C_RESET" "$value" >&2 +} + +# ───────────────────────────────────────────────────────────────────────────── +# Load docker/.env safely (skip UID/EUID because bash has readonly UID/EUID) +# ───────────────────────────────────────────────────────────────────────────── +if [[ -r "$ENV_FILE" ]]; then + set -o allexport + # shellcheck disable=SC1090 + source <(grep -vE '^(UID|EUID)=' "$ENV_FILE") + set +o allexport +fi + +# ───────────────────────────────────────────────────────────────────────────── +# MSYS/Git Bash detection (parser-safe) +# ───────────────────────────────────────────────────────────────────────────── +is_msys() { + case "${OSTYPE:-}" in + msys* | cygwin*) return 0 ;; + *) return 1 ;; + esac +} + +# ───────────────────────────────────────────────────────────────────────────── +# MSYS/Git Bash: prevent path auto-conversion when calling docker.exe +# Fixes: "-w /workspace" becoming "-w D:/Program Files/Git/workspace" +# ───────────────────────────────────────────────────────────────────────────── +msys_no_pathconv() { + if is_msys; then + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + fi +} + +# ───────────────────────────────────────────────────────────────────────────── +# Resolve host working directory for Docker Desktop bind mount. +# Priority: +# 1) WORKDIR_WIN (from server.bat wrapper) +# 2) WORKING_DIR (from docker/.env) +# 3) $PWD +# 4) repo root (safe fallback) +# NOTE: host path should be Windows absolute for Docker Desktop on Windows. +# ───────────────────────────────────────────────────────────────────────────── +resolve_host_pwd() { + local host="${WORKDIR_WIN:-${WORKING_DIR:-}}" + + if [[ -z "${host}" ]]; then + host="$PWD" + fi + [[ -n "${host}" ]] || host="$ROOT_DIR" + + if command -v cygpath >/dev/null 2>&1; then + # Convert MSYS /c/... -> C:\... for docker.exe bind mount + [[ "$host" == /* ]] && host="$(cygpath -w "$host")" + fi + + # Guard: if launched in Git install "workspace", fallback to repo root + case "$host" in + *"Program Files/Git/workspace"* | *"Program Files\\Git\\workspace"*) + if command -v cygpath >/dev/null 2>&1; then + host="$(cygpath -w "$ROOT_DIR")" + else + host="$ROOT_DIR" + fi + ;; + esac + + printf '%s' "$host" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Version helpers +# ───────────────────────────────────────────────────────────────────────────── +ver_norm() { + local v="$1" + if [[ "$v" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then + local a b c + IFS='.' read -r a b c <<<"$v" + b="${b:-0}" + c="${c:-0}" + echo "${a}.${b}.${c}" + else echo "$v"; fi +} +ver_cmp_ge() { + local a b + a="$(ver_norm "$1")" + b="$(ver_norm "$2")" + [[ "$(printf "%s\n%s\n" "$b" "$a" | sort -V | tail -n1)" == "$a" ]] +} +ver_cmp_gt() { + local a b + a="$(ver_norm "$1")" + b="$(ver_norm "$2")" + [[ "$a" != "$b" ]] && ver_cmp_ge "$a" "$b" +} +ver_cmp_le() { + local a b + a="$(ver_norm "$1")" + b="$(ver_norm "$2")" + [[ "$(printf "%s\n%s\n" "$a" "$b" | sort -V | head -n1)" == "$a" ]] +} +ver_cmp_lt() { + local a b + a="$(ver_norm "$1")" + b="$(ver_norm "$2")" + [[ "$a" != "$b" ]] && ver_cmp_le "$a" "$b" +} + +read_php_constraint() { + [[ -f composer.json ]] || return 1 + + if command -v jq >/dev/null 2>&1; then + local v + v="$(jq -r '.require.php // empty' composer.json 2>/dev/null || true)" + [[ -n "$v" && "$v" != "null" ]] && { + echo "$v" + return 0 + } + fi + + if command -v python3 >/dev/null 2>&1; then + python3 - <<'PY' 2>/dev/null || true +import json +try: + with open("composer.json","r",encoding="utf-8") as f: + data=json.load(f) + v=(data.get("require") or {}).get("php") or "" + if isinstance(v,str) and v.strip(): + print(v.strip()) +except Exception: + pass +PY + return 0 + fi + + local line + line="$(grep -Eo '"php"[[:space:]]*:[[:space:]]*"[^"]+"' composer.json 2>/dev/null | head -n1 || true)" + [[ -n "$line" ]] || return 1 + echo "$line" | sed -E 's/.*"php"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' +} + +constraint_allows() { + local constraint="$1" cand="$2" + local cand_full + cand_full="$(ver_norm "$cand")" + [[ -z "${constraint// /}" ]] && return 0 + local c="$constraint" + c="${c//,/ }" + + local IFS=$'\n' parts=() + # shellcheck disable=SC2207 + parts=($(echo "$c" | sed -E 's/[[:space:]]*\|\|[[:space:]]*/\n/g; s/[[:space:]]+\|[[:space:]]+/\n/g')) + + local part tok + for part in "${parts[@]}"; do + part="${part#"${part%%[![:space:]]*}"}" + part="${part%"${part##*[![:space:]]}"}" + [[ -z "$part" ]] && continue + + local ok=true + for tok in $part; do + tok="${tok#"${tok%%[![:space:]]*}"}" + tok="${tok%"${tok##*[![:space:]]}"}" + [[ -z "$tok" ]] && continue + + if [[ "$tok" == "*" || "$tok" == "x" || "$tok" == "X" ]]; then continue; fi + if [[ "$tok" =~ ^[0-9]+\.x$ || "$tok" =~ ^[0-9]+\.X$ || "$tok" =~ ^[0-9]+\.\*$ ]]; then + [[ "${cand%%.*}" == "${tok%%.*}" ]] || ok=false + continue + fi + if [[ "$tok" =~ ^[0-9]+\.[0-9]+\.x$ || "$tok" =~ ^[0-9]+\.[0-9]+\.X$ || "$tok" =~ ^[0-9]+\.[0-9]+\.?\*$ ]]; then + [[ "$cand" == "${tok%.*}" ]] || ok=false + continue + fi + + if [[ "$tok" =~ ^\^([0-9]+)\.([0-9]+) ]]; then + local maj="${BASH_REMATCH[1]}" min="${BASH_REMATCH[2]}" + local lower="${maj}.${min}" upper="$((maj + 1)).0" + ver_cmp_ge "$cand_full" "$(ver_norm "$lower")" && ver_cmp_lt "$cand_full" "$(ver_norm "$upper")" || ok=false + continue + fi + + if [[ "$tok" =~ ^~([0-9]+)\.([0-9]+) ]]; then + local maj="${BASH_REMATCH[1]}" min="${BASH_REMATCH[2]}" + local lower="${maj}.${min}" upper="${maj}.$((min + 1))" + ver_cmp_ge "$cand_full" "$(ver_norm "$lower")" && ver_cmp_lt "$cand_full" "$(ver_norm "$upper")" || ok=false + continue + fi + + if [[ "$tok" =~ ^(>=|<=|>|<)([0-9]+(\.[0-9]+)?)$ ]]; then + local op="${BASH_REMATCH[1]}" v="${BASH_REMATCH[2]}" + case "$op" in + '>=') ver_cmp_ge "$cand_full" "$(ver_norm "$v")" || ok=false ;; + '<=') ver_cmp_le "$cand_full" "$(ver_norm "$v")" || ok=false ;; + '>') ver_cmp_gt "$cand_full" "$(ver_norm "$v")" || ok=false ;; + '<') ver_cmp_lt "$cand_full" "$(ver_norm "$v")" || ok=false ;; + esac + continue + fi + + if [[ "$tok" =~ ^([0-9]+)\.([0-9]+)$ ]]; then + [[ "$cand" == "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" ]] || ok=false + continue + fi + : + done + + [[ "$ok" == true ]] && return 0 + done + return 1 +} + +running_php_versions() { + docker ps --format '{{.Names}}' | awk -F'_' '/^PHP_[0-9]+\.[0-9]+$/ {print $2}' | sort -Vr +} +pick_container_by_version() { echo "PHP_$1"; } + +pick_best_container() { + local explicit_ver="$1" constraint="${2:-}" + local versions=() + mapfile -t versions < <(running_php_versions) + [[ ${#versions[@]} -gt 0 ]] || die "no PHP container is running (expected names like PHP_8.4)." + + if [[ -n "$explicit_ver" ]]; then + local cname + cname="$(pick_container_by_version "$explicit_ver")" + docker ps --format '{{.Names}}' | grep -qx "$cname" || die "container '$cname' is not running." + printf "%s\n%s\n%s\n" "$cname" "$explicit_ver" "explicit via -V" + return 0 + fi + + if [[ -n "${constraint// /}" ]]; then + local v + for v in "${versions[@]}"; do + if constraint_allows "$constraint" "$v"; then + printf "%s\n%s\n%s\n" "$(pick_container_by_version "$v")" "$v" "composer.json: ${constraint}" + return 0 + fi + done + fi + + printf "%s\n%s\n%s\n" "$(pick_container_by_version "${versions[0]}")" "${versions[0]}" "fallback: highest running container" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Args +# ───────────────────────────────────────────────────────────────────────────── +EXPLICIT_VER="" +ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + -V | --php) + [[ -n "${2:-}" ]] || die "-V/--php requires a version (e.g. -V 8.3)" + EXPLICIT_VER="$2" + shift 2 + ;; + --) + shift + ARGS+=("$@") + break + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +PHP_CONSTRAINT="" +[[ -z "$EXPLICIT_VER" ]] && PHP_CONSTRAINT="$(read_php_constraint || true)" + +TARGET_CONTAINER="" +DETECTED_VER="" +DETECT_REASON="" +{ + read -r TARGET_CONTAINER + read -r DETECTED_VER + read -r DETECT_REASON +} < <(pick_best_container "$EXPLICIT_VER" "$PHP_CONSTRAINT") + +IMAGE="$(docker inspect -f '{{.Config.Image}}' "$TARGET_CONTAINER" 2>/dev/null)" || + die "failed to inspect container '$TARGET_CONTAINER'." + +kv "PHP detected:" "${C_YELLOW}${DETECTED_VER}${C_RESET} ${C_CYAN}(${DETECT_REASON})${C_RESET}" +kv "Container:" "${C_YELLOW}${TARGET_CONTAINER}${C_RESET}" +kv "Image:" "${C_YELLOW}${IMAGE}${C_RESET}" + +HOST_PWD="$(resolve_host_pwd)" +msys_no_pathconv + +RUN_FLAGS=(--rm -v "$HOST_PWD":/workspace -w /workspace --network "container:$TARGET_CONTAINER") +[[ -t 0 ]] && RUN_FLAGS+=(-it) || RUN_FLAGS+=(-i) +RUN_FLAGS+=(-u "$(id -u):$(id -g)") + +if [[ -d "$SSH_DIR" ]]; then + RUN_FLAGS+=(-v "$SSH_DIR:/home/${USER:-root}/.ssh:ro") +fi + +if [[ -n "${HOME:-}" && -d "$HOME" ]]; then + mkdir -p "$HOME/.cache/composer" "$HOME/.composer" 2>/dev/null || true + RUN_FLAGS+=( + -e "COMPOSER_CACHE_DIR=/tmp/composer-cache" + -v "$HOME/.cache/composer:/tmp/composer-cache" + -v "$HOME/.composer:/tmp/composer-home" + -e "COMPOSER_HOME=/tmp/composer-home" + ) +fi + +exec docker run "${RUN_FLAGS[@]}" "$IMAGE" composer "${ARGS[@]}" diff --git a/bin/lzd b/bin/lzd deleted file mode 100644 index ad2e9e6..0000000 --- a/bin/lzd +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker exec -it SERVER_TOOLS lazydocker diff --git a/bin/lzd.bat b/bin/lzd.bat deleted file mode 100644 index 4215121..0000000 --- a/bin/lzd.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -docker exec -it SERVER_TOOLS lazydocker diff --git a/bin/maria b/bin/maria new file mode 100755 index 0000000..8f26cf0 --- /dev/null +++ b/bin/maria @@ -0,0 +1,597 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + echo "Error: $*" >&2 + exit 1 +} + +# ───────────────────────────────────────────────────────────────────────────── +# Repo layout: +# /bin/ma +# /docker/.env +# ───────────────────────────────────────────────────────────────────────────── +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT_DIR/docker/.env" + +# ───────────────────────────────────────────────────────────────────────────── +# MSYS/Git Bash: prevent path auto-conversion when calling docker.exe +# ───────────────────────────────────────────────────────────────────────────── +is_msys() { + case "${OSTYPE:-}" in + msys* | cygwin*) return 0 ;; + *) return 1 ;; + esac +} +msys_no_pathconv() { + if is_msys; then + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + fi +} + +# ───────────────────────────────────────────────────────────────────────────── +# Safe host .env load (optional; whitelist only) +# ───────────────────────────────────────────────────────────────────────────── +if [[ -r "$ENV_FILE" ]]; then + while IFS='=' read -r k v; do + [[ -z "${k:-}" || "$k" =~ ^[[:space:]]*# ]] && continue + k="${k//[[:space:]]/}" + case "$k" in + MARIADB_CONTAINER | MARIADB_PORT_IN) ;; + # allow legacy env naming if you want + MYSQL_CONTAINER | MYSQL_PORT_IN) ;; + *) continue ;; + esac + v="${v%$'\r'}" + v="${v#\"}" + v="${v%\"}" + v="${v#\'}" + v="${v%\'}" + export "$k=$v" + done <"$ENV_FILE" +fi + +SERVICE="${MARIADB_CONTAINER:-${MYSQL_CONTAINER:-MARIADB}}" +MARIADB_PORT_IN="${MARIADB_PORT_IN:-${MYSQL_PORT_IN:-}}" + +require_container() { + docker inspect "$SERVICE" >/dev/null 2>&1 || die "container '$SERVICE' is not running." +} + +is_tty() { [[ -t 0 ]] && echo "-it" || echo "-i"; } +is_interactive_stdin() { [[ -t 0 ]] && echo "-it" || echo "-i"; } + +# Read env var from inside the running container (POSIX-safe; no ${!var}) +cenv() { + local key="$1" + docker exec -i "$SERVICE" sh -lc 'printenv "$1" 2>/dev/null || true' sh "$key" +} + +cenv_any() { + local v key + for key in "$@"; do + v="$(cenv "$key")" + [[ -n "${v:-}" ]] && { + printf "%s" "$v" + return 0 + } + done + return 1 +} + +db_image() { + docker inspect -f '{{.Config.Image}}' "$SERVICE" 2>/dev/null || die "failed to inspect image for '$SERVICE'." +} + +infer_port() { + [[ -n "${MARIADB_PORT_IN:-}" ]] && return 0 + # Maria images usually don't set a port env; fallback to 3306. + MARIADB_PORT_IN="$(cenv_any MARIADB_PORT MYSQL_PORT || true)" + MARIADB_PORT_IN="${MARIADB_PORT_IN:-3306}" +} + +# Host mount (keep it simple & reliable like your working mysql script): +# always mount *current directory* for file ops. +mount_host_pwd() { + msys_no_pathconv + if is_msys && command -v cygpath >/dev/null 2>&1; then + cygpath -w "$PWD" + else + printf "%s" "$PWD" + fi +} + +# file must be under $PWD (because we mount only $PWD) +to_workspace_path() { + local host_path="$1" + [[ -f "$host_path" ]] || die "file not found: $host_path" + + local abs cwd rel + cwd="$(cd "$PWD" && pwd)" + abs="$(cd "$(dirname "$host_path")" && pwd)/$(basename "$host_path")" + + if command -v python3 >/dev/null 2>&1; then + rel="$( + python3 - "$cwd" "$abs" <<'PY' 2>/dev/null || true +import os,sys +cwd=sys.argv[1]; p=sys.argv[2] +try: + r=os.path.relpath(p,cwd) + print(r) +except Exception: + pass +PY + )" + else + [[ "$abs" == "$cwd/"* ]] && rel="${abs#"$cwd/"}" || rel="" + fi + + [[ -n "${rel:-}" && "$rel" != /* && "$rel" != ..* ]] || die "file must be under current directory: $host_path" + printf "/workspace/%s" "$rel" +} + +has_execute_flag() { + local a + for a in "$@"; do + [[ "$a" == "-e" || "$a" == "--execute" ]] && return 0 + done + return 1 +} + +looks_like_sql() { + local s="$1" + [[ "$s" == *";"* || "$s" == *" "* ]] && return 0 + [[ "$s" =~ ^[[:space:]]*(select|insert|update|delete|with|show|describe|desc|explain|use|set|create|alter|drop|truncate|grant|revoke)[[:space:]] ]] && return 0 + return 1 +} + +# choose mariadb client binary +pick_client_bin() { + if docker exec -i "$SERVICE" sh -lc 'command -v mariadb >/dev/null 2>&1' >/dev/null 2>&1; then + echo "mariadb" + else + echo "mysql" + fi +} + +# choose dump binary +pick_dump_bin() { + if docker exec -i "$SERVICE" sh -lc 'command -v mariadb-dump >/dev/null 2>&1' >/dev/null 2>&1; then + echo "mariadb-dump" + else + echo "mysqldump" + fi +} + +db_exec_root() { + local client="$1" + shift + local flags + flags="$(is_tty)" + exec docker exec $flags "$SERVICE" sh -lc ' + client="$1"; shift + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <"$f" < ./reporting_db.sql or ./reporting_db.sql.gz; db defaults to reporting_db +resolve_import_file() { + local in="$1" + if [[ -f "$in" ]]; then + printf "%s" "$in" + return 0 + fi + if [[ -f "./${in}.sql" ]]; then + printf "./%s.sql" "$in" + return 0 + fi + if [[ -f "./${in}.sql.gz" ]]; then + printf "./%s.sql.gz" "$in" + return 0 + fi + return 1 +} + +usage() { + cat >&2 <<'TXT' +maria (docker wrapper) — MariaDB/MySQL-compatible client + dump helpers + +Core: + maria [--user] [mysql/mariadb args...] + maria dbs + maria tables [db] + maria create-db [db] + maria drop-db + maria status + +Import (SQL only): + maria import [db] + - NAME shorthand: uses ./NAME.sql or ./NAME.sql.gz if exists; db defaults to NAME + +Export/Dump (writes local files; mounts current dir): + maria dump [db] [out.sql|out.sql.gz] + maria export [db] [out.sql|out.sql.gz] # alias of dump + +Convenience: + maria "SELECT 1" -> runs on default DB + maria mydb "SELECT 1" -> runs on mydb + cat file.sql | ma -> runs on default DB (stdin) + +Notes: +- tables shows all tables across schemas via information_schema.tables +TXT +} + +main() { + case "${1:-}" in help | -h | --help) + usage + exit 0 + ;; + esac + + require_container + infer_port + + local as_user=false + if [[ "${1:-}" == "--user" ]]; then + as_user=true + shift + fi + + local client dumpbin IMAGE + client="$(pick_client_bin)" + dumpbin="$(pick_dump_bin)" + IMAGE="$(db_image)" + + # Defaults from container env (Maria or legacy MySQL naming) + local ROOT_PW USER USER_PW DEF_DB + ROOT_PW="$(cenv_any MARIADB_ROOT_PASSWORD MYSQL_ROOT_PASSWORD || true)" + USER="$(cenv_any MARIADB_USER MYSQL_USER || true)" + USER_PW="$(cenv_any MARIADB_PASSWORD MYSQL_PASSWORD || true)" + DEF_DB="$(cenv_any MARIADB_DATABASE MYSQL_DATABASE || true)" + + # stdin: cat file.sql | ma (runs on default db) + if [[ $# -eq 0 ]] && [[ ! -t 0 ]]; then + [[ -n "${DEF_DB:-}" ]] || die "No default database set in container (MARIADB_DATABASE/MYSQL_DATABASE)." + if [[ "$as_user" == true ]]; then + exec docker exec -i "$SERVICE" sh -lc ' + db="$1" + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <"$f" < interactive + if [[ $# -eq 0 ]]; then + if [[ "$as_user" == true ]]; then db_exec_user "$client"; else db_exec_root "$client"; fi + exit 0 + fi + + local cmd="${1:-}" + case "$cmd" in + status) + docker ps --filter "name=^/${SERVICE}$" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + exit 0 + ;; + esac + + case "$cmd" in + dbs) + if [[ "$as_user" == true ]]; then + db_exec_user "$client" -e "SHOW DATABASES;" + else + db_exec_root "$client" -e "SHOW DATABASES;" + fi + ;; + + tables) + shift + local db="${1:-$DEF_DB}" + [[ -n "$db" ]] || die "tables requires a db name (or set MARIADB_DATABASE/MYSQL_DATABASE)." + # across schemas (but scoped to selected DB unless you want global — keep predictable): + if [[ "$as_user" == true ]]; then + db_exec_user "$client" "$db" -e \ + "SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + ORDER BY table_name;" + else + db_exec_root "$client" "$db" -e \ + "SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + ORDER BY table_name;" + fi + ;; + + create-db) + shift + local db="${1:-$DEF_DB}" + [[ -n "$db" ]] || die "create-db requires a db name." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + + docker exec -i "$SERVICE" sh -lc ' + db="$1" + u="${MARIADB_USER-${MYSQL_USER-}}" + sql="CREATE DATABASE IF NOT EXISTS \`$db\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +GRANT ALL PRIVILEGES ON \`$db\`.* TO '\''$u'\''@'\''%'\''; FLUSH PRIVILEGES;" + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <&2 + ;; + + drop-db) + shift + local db="${1:-}" + [[ -n "$db" ]] || die "drop-db requires a db name." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + + docker exec -i "$SERVICE" sh -lc ' + db="$1" + sql="DROP DATABASE IF EXISTS \`$db\`;" + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <&2 + ;; + + import) + shift + local in="${1:-}" + shift || true + [[ -n "$in" ]] || die "import requires " + + local file="" + if file="$(resolve_import_file "$in" 2>/dev/null)"; then + : + else + die "file not found: $in (also tried ./${in}.sql and ./${in}.sql.gz)" + fi + + local db="${1:-}" + if [[ -z "${db:-}" ]]; then + # if NAME shorthand was used (not a real path), default db to NAME + if [[ "$in" != */* && "$in" != *.* ]]; then + db="$in" + else + db="${DEF_DB:-}" + fi + fi + [[ -n "${db:-}" ]] || die "import requires a db name (or set MARIADB_DATABASE/MYSQL_DATABASE)." + + local ws_file + ws_file="$(to_workspace_path "$file")" + + if [[ "$file" == *.gz ]]; then + run_client_mount_pwd "$IMAGE" sh -lc ' + db="$1"; f="$2"; pw="$3"; port="$4"; client="$5" + umask 077 + cf="$(mktemp)"; trap "rm -f $cf" EXIT + cat >"$cf" </dev/null 2>&1 && gunzip -c "$f" || gzip -dc "$f") \ + | "$client" --defaults-extra-file="$cf" "$db" + ' sh "$db" "$ws_file" "$ROOT_PW" "$MARIADB_PORT_IN" "$client" + else + run_client_mount_pwd "$IMAGE" sh -lc ' + db="$1"; f="$2"; pw="$3"; port="$4"; client="$5" + umask 077 + cf="$(mktemp)"; trap "rm -f $cf" EXIT + cat >"$cf" < db '$db'" >&2 + ;; + + dump | export) + shift + local db="${1:-$DEF_DB}" + [[ -n "$db" ]] || die "dump requires a db name (or set MARIADB_DATABASE/MYSQL_DATABASE)." + local out="${2:-${db}.sql}" + + # host-side precheck (common cause of "Permission denied") + if [[ -e "$out" && ! -w "$out" ]]; then + die "output exists but not writable: ./$out" + fi + + if [[ "$out" == *.gz ]]; then + run_client_mount_pwd "$IMAGE" sh -lc ' + db="$1"; out="$2"; pw="$3"; port="$4"; dumpbin="$5" + umask 077 + cf="$(mktemp)"; trap "rm -f $cf" EXIT + cat >"$cf" </dev/null 2>&1 || { echo "gzip not found in image; install gzip or use out.sql (no .gz)" >&2; exit 2; } + "$dumpbin" --defaults-extra-file="$cf" \ + --single-transaction --quick --routines --events "$db" \ + | gzip -c > "/workspace/$out" + ' sh "$db" "$out" "$ROOT_PW" "$MARIADB_PORT_IN" "$dumpbin" + else + run_client_mount_pwd "$IMAGE" sh -lc ' + db="$1"; out="$2"; pw="$3"; port="$4"; dumpbin="$5" + umask 077 + cf="$(mktemp)"; trap "rm -f $cf" EXIT + cat >"$cf" < "/workspace/$out" + ' sh "$db" "$out" "$ROOT_PW" "$MARIADB_PORT_IN" "$dumpbin" + fi + + echo "OK: dumped '$db' -> ./$out" >&2 + ;; + + *) + # passthrough / SQL convenience + if has_execute_flag "$@"; then + if [[ "$as_user" == true ]]; then db_exec_user "$client" "$@"; else db_exec_root "$client" "$@"; fi + exit 0 + fi + + # ma "SQL..." + if [[ $# -eq 1 ]]; then + if looks_like_sql "$1"; then + [[ -n "${DEF_DB:-}" ]] || die "No default database set. Provide db: ma \"SQL...\"" + if [[ "$as_user" == true ]]; then + db_exec_user "$client" "$DEF_DB" -e "$1" + else + db_exec_root "$client" "$DEF_DB" -e "$1" + fi + exit 0 + fi + fi + + # ma mydb "SQL..." + if [[ $# -ge 2 ]]; then + if [[ "$1" =~ ^[A-Za-z0-9_]+$ ]]; then + if looks_like_sql "$2"; then + local db="$1" + shift + local sql="$*" + if [[ "$as_user" == true ]]; then + db_exec_user "$client" "$db" -e "$sql" + else + db_exec_root "$client" "$db" -e "$sql" + fi + exit 0 + fi + fi + fi + + # If only flags provided, append default db + local has_pos=false a + for a in "$@"; do + [[ "$a" != "-"* ]] && has_pos=true && break + done + [[ "$has_pos" == false && -n "${DEF_DB:-}" ]] && set -- "$@" "$DEF_DB" + + if [[ "$as_user" == true ]]; then db_exec_user "$client" "$@"; else db_exec_root "$client" "$@"; fi + ;; + esac +} + +main "$@" diff --git a/bin/mariadb b/bin/mariadb deleted file mode 100644 index a0cb66f..0000000 --- a/bin/mariadb +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker exec -it MYSQL mariadb $@ diff --git a/bin/mariadb-dump b/bin/mariadb-dump deleted file mode 100644 index 478042e..0000000 --- a/bin/mariadb-dump +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker exec -it MYSQL mariadb-dump $@ diff --git a/bin/mariadb-dump.bat b/bin/mariadb-dump.bat deleted file mode 100644 index 35907a6..0000000 --- a/bin/mariadb-dump.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -docker exec -it MYSQL mariadb-dump %* diff --git a/bin/mariadb.bat b/bin/mariadb.bat deleted file mode 100644 index 50db44f..0000000 --- a/bin/mariadb.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -docker exec -it MYSQL mariadb %* diff --git a/bin/my b/bin/my new file mode 100755 index 0000000..c775923 --- /dev/null +++ b/bin/my @@ -0,0 +1,544 @@ +#!/usr/bin/env bash +set -euo pipefail + +die(){ echo "Error: $*" >&2; exit 1; } + +# ───────────────────────────────────────────────────────────────────────────── +# Repo layout: +# /bin/my +# /docker/.env +# ───────────────────────────────────────────────────────────────────────────── +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT_DIR/docker/.env" + +# ───────────────────────────────────────────────────────────────────────────── +# MSYS/Git Bash: prevent path auto-conversion when calling docker.exe +# ───────────────────────────────────────────────────────────────────────────── +is_msys() { + case "${OSTYPE:-}" in + msys*|cygwin*) return 0 ;; + *) return 1 ;; + esac +} + +msys_no_pathconv() { + if is_msys; then + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + fi +} + +# ───────────────────────────────────────────────────────────────────────────── +# Optional host .env load (whitelist only) +# ───────────────────────────────────────────────────────────────────────────── +if [[ -r "$ENV_FILE" ]]; then + while IFS='=' read -r k v; do + [[ -z "${k:-}" || "$k" =~ ^[[:space:]]*# ]] && continue + k="${k//[[:space:]]/}" + case "$k" in + MYSQL_CONTAINER|MYSQL_PORT_IN) ;; + *) continue ;; + esac + v="${v%$'\r'}" + v="${v#\"}"; v="${v%\"}" + v="${v#\'}"; v="${v%\'}" + export "$k=$v" + done < "$ENV_FILE" +fi + +SERVICE="${MYSQL_CONTAINER:-MYSQL}" +MYSQL_PORT_IN="${MYSQL_PORT_IN:-}" + +require_container() { + docker inspect "$SERVICE" >/dev/null 2>&1 || die "container '$SERVICE' is not running." +} + +is_tty(){ [[ -t 0 ]] && echo "-it" || echo "-i"; } + +# POSIX-safe env read from container (works in sh/ash/dash) +cenv() { + local key="$1" + docker exec -i "$SERVICE" sh -lc 'eval "printf %s \"\${$1-}\""' sh "$key" 2>/dev/null || true +} + +cenv_any() { + local v key + for key in "$@"; do + v="$(cenv "$key")" + [[ -n "${v:-}" ]] && { printf "%s" "$v"; return 0; } + done + return 1 +} + +infer_port() { + [[ -n "${MYSQL_PORT_IN:-}" ]] && return 0 + MYSQL_PORT_IN="$(cenv_any MYSQL_PORT MYSQL_TCP_PORT || true)" + MYSQL_PORT_IN="${MYSQL_PORT_IN:-3306}" +} + +# Run mysql inside the container using a temp defaults file (no password in argv) +mysql_exec_root() { + local flags; flags="$(is_tty)" + exec docker exec $flags "$SERVICE" sh -lc ' + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <"$f" <"$f" <&2 <<'TXT' +my (docker wrapper) — includes dump helpers + +Core: + my [--user] [mysql args...] + my dbs + my tables [db] + my create-db [db] + my drop-db + my status + +Import (SQL only): + my import [db] + - NAME shorthand: uses ./NAME.sql or ./NAME.sql.gz if exists; db defaults to NAME + +Export/Dump (writes local files; uses mount): + my dump [db] [out.sql|out.sql.gz] + my export [db] [out.sql|out.sql.gz] # alias of dump + +Convenience: + my "SELECT 1" -> runs on default DB + my mydb "SELECT 1" -> runs on mydb + cat file.sql | mysql -> runs on default DB (stdin) + +Notes: +- tables shows all tables across schemas: information_schema.tables +TXT +} + +main() { + case "${1:-}" in help|-h|--help) usage; exit 0;; esac + + require_container + infer_port + msys_no_pathconv + + local as_user=false + if [[ "${1:-}" == "--user" ]]; then + as_user=true + shift + fi + + # Prefer container env (these are your defaults) + local ROOT_PW USER USER_PW DEF_DB + ROOT_PW="$(cenv_any MYSQL_ROOT_PASSWORD || true)" + USER="$(cenv_any MYSQL_USER || true)" + USER_PW="$(cenv_any MYSQL_PASSWORD || true)" + DEF_DB="$(cenv_any MYSQL_DATABASE || true)" + + # Stdin pipe mode: cat file.sql | my + if [[ $# -eq 0 && ! -t 0 ]]; then + [[ -n "${DEF_DB:-}" ]] || die "No default database set in container (MYSQL_DATABASE)." + if [[ "$as_user" == true ]]; then + exec docker exec -i "$SERVICE" sh -lc ' + db="$1" + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <"$f" < interactive mysql shell + if [[ $# -eq 0 ]]; then + if [[ "$as_user" == true ]]; then mysql_exec_user; else mysql_exec_root; fi + exit 0 + fi + + local cmd="${1:-}" + case "$cmd" in + status) + docker ps --filter "name=^/${SERVICE}$" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + exit 0 + ;; + esac + + case "$cmd" in + dbs) + if [[ "$as_user" == true ]]; then + mysql_exec_user -e "SHOW DATABASES;" + else + mysql_exec_root -e "SHOW DATABASES;" + fi + ;; + + tables) + shift + local db="${1:-$DEF_DB}" + + if [[ -n "${db:-}" ]]; then + # Show tables for a specific db (includes views) + if [[ "$as_user" == true ]]; then + mysql_exec_user -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA='${db}' ORDER BY TABLE_NAME;" + else + mysql_exec_root -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA='${db}' ORDER BY TABLE_NAME;" + fi + else + # All schemas (avoid "no tables" confusion) + if [[ "$as_user" == true ]]; then + mysql_exec_user -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA NOT IN ('mysql','sys','performance_schema','information_schema') ORDER BY TABLE_SCHEMA, TABLE_NAME;" + else + mysql_exec_root -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA NOT IN ('mysql','sys','performance_schema','information_schema') ORDER BY TABLE_SCHEMA, TABLE_NAME;" + fi + fi + ;; + + create-db) + shift + local db="${1:-$DEF_DB}" + [[ -n "${db:-}" ]] || die "create-db requires a db name (or set MYSQL_DATABASE)." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + + docker exec -i "$SERVICE" sh -lc ' + db="$1" + u="${MYSQL_USER-}" + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <&2 + ;; + + drop-db) + shift + local db="${1:-}" + [[ -n "${db:-}" ]] || die "drop-db requires a db name." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + + docker exec -i "$SERVICE" sh -lc ' + db="$1" + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <&2 + ;; + + import) + shift + local in="${1:-}"; shift || true + [[ -n "${in:-}" ]] || die "import requires ." + + local file + if ! file="$(resolve_import_path "$in")"; then + die "file not found: $in (also tried ./${in}.sql and ./${in}.sql.gz)" + fi + + local db="${1:-}" + if [[ -z "${db:-}" ]]; then + # If shorthand NAME was used, default DB = NAME + if [[ "$in" != *.* ]]; then db="$in"; else db="${DEF_DB:-}"; fi + fi + [[ -n "${db:-}" ]] || die "import requires a db name (or set MYSQL_DATABASE)." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + + if [[ "$file" == *.gz ]]; then + if command -v gunzip >/dev/null 2>&1; then + gunzip -c "$file" | docker exec -i "$SERVICE" sh -lc ' + db="$1" + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <"$f" < db '$db'" >&2 + ;; + + export|dump) + shift + local db="${1:-$DEF_DB}" + [[ -n "${db:-}" ]] || die "dump requires a db name (or set MYSQL_DATABASE)." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + + local out="${2:-${db}.sql}" + # Write on HOST (fixes /workspace permission + uid mapping issues) + umask 077 + + if [[ "$out" == *.gz ]]; then + if command -v gzip >/dev/null 2>&1; then + mysqldump_stdout_root "$db" | gzip -c > "$out" + else + # fallback gzip inside container + docker exec -i "$SERVICE" sh -lc ' + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" < "$out" + fi + else + mysqldump_stdout_root "$db" > "$out" + fi + + echo "OK: dumped '$db' -> ./$out" >&2 + ;; + + *) + shift 0 + + # explicit -e passthrough + if has_execute_flag "$@"; then + if [[ "$as_user" == true ]]; then mysql_exec_user "$@"; else mysql_exec_root "$@"; fi + exit 0 + fi + + # Convenience: my "SELECT 1" + if [[ $# -eq 1 ]]; then + if looks_like_sql "$1"; then + [[ -n "${DEF_DB:-}" ]] || die "No default database set (MYSQL_DATABASE). Provide db: my \"SQL...\"" + if [[ "$as_user" == true ]]; then + exec docker exec "$(is_tty)" "$SERVICE" sh -lc ' + db="$1"; sql="$2" + umask 077 + f="$(mktemp)"; trap "rm -f $f" EXIT + cat >"$f" <"$f" <"$f" <"$f" <&2 + exit 1 +} + +# ───────────────────────────────────────────────────────────────────────────── +# Repo layout: +# /bin/pg +# /docker/.env +# ───────────────────────────────────────────────────────────────────────────── +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT_DIR/docker/.env" + +# ───────────────────────────────────────────────────────────────────────────── +# MSYS/Git Bash: prevent path auto-conversion when calling docker.exe +# ───────────────────────────────────────────────────────────────────────────── +is_msys() { + case "${OSTYPE:-}" in + msys* | cygwin*) return 0 ;; + *) return 1 ;; + esac +} + +msys_no_pathconv() { + if is_msys; then + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + fi +} + +# ───────────────────────────────────────────────────────────────────────────── +# Resolve mount root for docker-run file ops (import/export/dump/restore) +# Produces: +# MOUNT_POSIX_ROOT (posix absolute) +# MOUNT_HOST_PATH (host path suitable for docker -v; windows path on MSYS) +# ───────────────────────────────────────────────────────────────────────────── +MOUNT_POSIX_ROOT="" +MOUNT_HOST_PATH="" + +resolve_mount_root() { + local posix_root="" host_path="" + + if [[ -n "${WORKDIR:-}" && -d "${WORKDIR}" ]]; then + posix_root="$WORKDIR" + fi + + if [[ -z "$posix_root" && -n "${WORKDIR_WIN:-}" ]]; then + if command -v cygpath >/dev/null 2>&1; then + posix_root="$(cygpath -u "$WORKDIR_WIN" 2>/dev/null || true)" + fi + fi + + if [[ -z "$posix_root" && -n "${WORKING_DIR:-}" ]]; then + if [[ -d "$WORKING_DIR" ]]; then + posix_root="$WORKING_DIR" + else + if command -v cygpath >/dev/null 2>&1; then + posix_root="$(cygpath -u "$WORKING_DIR" 2>/dev/null || true)" + fi + fi + fi + + [[ -n "$posix_root" && -d "$posix_root" ]] || posix_root="$PWD" + posix_root="$(cd "$posix_root" && pwd)" + + if is_msys && command -v cygpath >/dev/null 2>&1; then + host_path="$(cygpath -w "$posix_root")" + else + host_path="$posix_root" + fi + + MOUNT_POSIX_ROOT="$posix_root" + MOUNT_HOST_PATH="$host_path" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Optional host .env load (whitelist only; optional fallback) +# ───────────────────────────────────────────────────────────────────────────── +if [[ -r "$ENV_FILE" ]]; then + while IFS='=' read -r k v; do + [[ -z "${k:-}" || "$k" =~ ^[[:space:]]*# ]] && continue + k="${k//[[:space:]]/}" + case "$k" in + POSTGRESQL_CONTAINER | POSTGRES_CONTAINER | POSTGRES_PORT_IN | WORKING_DIR) ;; + *) continue ;; + esac + v="${v%$'\r'}" + v="${v#\"}" + v="${v%\"}" + v="${v#\'}" + v="${v%\'}" + export "$k=$v" + done <"$ENV_FILE" +fi + +SERVICE="${POSTGRESQL_CONTAINER:-${POSTGRES_CONTAINER:-POSTGRESQL}}" +POSTGRES_PORT_IN="${POSTGRES_PORT_IN:-}" + +usage() { + cat >&2 <<'TXT' +pg (docker wrapper) — includes pg_dump + pg_restore shortcuts + +USAGE: + pg [command] [args...] + +CORE: + pg + Open interactive psql session (default DB if available) + + cat file.sql | pg + Run SQL from stdin against default DB (POSTGRES_DATABASE) + + pg status + Show container status + + pg dbs + List databases + + pg tables [db] + List tables in ALL schemas (uses: \dt *.*) + Examples: + pg tables + pg tables reporting_db + + pg create-db [db] + Create DB if missing (owner = POSTGRES_USER) + Examples: + pg create-db + pg create-db reporting_db + + pg drop-db + Drop DB (terminates active connections first) + Examples: + pg drop-db reporting_db + +IMPORT (SQL ONLY): + pg import [db] + Import SQL file into db. + NAME shorthand resolves to: + ./NAME.sql (if exists) + ./NAME.sql.gz (if exists) + If NAME shorthand is used and [db] is omitted, db defaults to NAME. + + Examples: + pg import ./schema.sql reporting_db + pg import ./seed.sql.gz reporting_db + pg import reporting_db # uses ./reporting_db.sql(.gz), db=reporting_db + pg import reporting_db mydb # uses ./reporting_db.sql(.gz), db=mydb + +DUMP (WRITES LOCAL FILES; MOUNT-AWARE): + pg dump [db] [out.sql|out.sql.gz] + Dump one database (pg_dump) + Defaults: db=POSTGRES_DATABASE, out=.sql + + Examples: + pg dump + pg dump reporting_db + pg dump reporting_db reporting_db.sql.gz + + pg dump-all [out.sql|out.sql.gz] + Dump ALL databases (pg_dumpall) + Default: out=all.sql + + Examples: + pg dump-all + pg dump-all all.sql.gz + + pg roles [out.sql|out.sql.gz] + Dump globals only (roles/tablespaces) (pg_dumpall --globals-only) + Default: out=globals.sql + + Examples: + pg roles + pg roles globals.sql.gz + + pg schema [db] [out.sql|out.sql.gz] + Schema-only dump (pg_dump --schema-only) + Defaults: db=POSTGRES_DATABASE, out=.schema.sql + + Examples: + pg schema reporting_db + pg schema reporting_db reporting_db.schema.sql.gz + + pg data [db] [out.sql|out.sql.gz] + Data-only dump (pg_dump --data-only) + Defaults: db=POSTGRES_DATABASE, out=.data.sql + + Examples: + pg data reporting_db + pg data reporting_db reporting_db.data.sql.gz + +RESTORE (CUSTOM/TAR FORMAT RECOMMENDED): + pg list + List contents of a pg_restore-compatible dump (mount-aware) + Examples: + pg list ./backup.dump + + pg restore [db] + Restore a pg_restore-compatible dump into db: + --clean --if-exists --no-owner --no-privileges + Defaults: db=POSTGRES_DATABASE + + Examples: + pg restore ./backup.dump reporting_db + pg restore ./backup.dump + +PASSTHROUGH: + pg [psql flags...] + Anything not matched above is passed to psql in the container. + Examples: + pg -c "select now()" + pg -d reporting_db -c "select count(*) from public.users" + pg reporting_db -c "select 1" # if psql supports positional dbname + +NOTES: +- Plain .sql dumps must be restored via: psql import file.sql +- tables uses: \dt *.* (all schemas) to avoid "no tables" confusion when schema != public. +- Credentials/Defaults are read from container env (preferred): + POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DATABASE +TXT +} + +case "${1:-}" in help | -h | --help) + usage + exit 0 + ;; +esac + +require_container() { docker inspect "$SERVICE" >/dev/null 2>&1 || die "container '$SERVICE' is not running."; } +is_tty() { [[ -t 0 ]] && echo "-it" || echo "-i"; } + +# POSIX-safe env read from container +cenv() { + local key="$1" + docker exec -i "$SERVICE" sh -lc 'eval "printf %s \"\${$1-}\""' sh "$key" 2>/dev/null || true +} + +cenv_any() { + local v key + for key in "$@"; do + v="$(cenv "$key")" + [[ -n "${v:-}" ]] && { + printf "%s" "$v" + return 0 + } + done + return 1 +} + +infer_port() { + [[ -n "${POSTGRES_PORT_IN:-}" ]] && return 0 + POSTGRES_PORT_IN="$(cenv_any POSTGRES_PORT PGPORT || true)" + POSTGRES_PORT_IN="${POSTGRES_PORT_IN:-5432}" +} + +pg_image() { docker inspect -f '{{.Config.Image}}' "$SERVICE" 2>/dev/null || die "failed to inspect image for '$SERVICE'."; } + +run_pg_mount() { + local image="$1" + shift + local flags=() + [[ -t 0 ]] && flags+=(-it) || flags+=(-i) + msys_no_pathconv + docker run --rm "${flags[@]}" \ + -v "$MOUNT_HOST_PATH":/workspace -w /workspace \ + --network "container:$SERVICE" \ + "$image" "$@" +} + +# file -> /workspace/ if under mount root; else empty +to_workspace_path_under_mount() { + local file="$1" + [[ -f "$file" ]] || return 1 + local abs rel="" + abs="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" + + if command -v python3 >/dev/null 2>&1; then + rel="$( + python3 - "$MOUNT_POSIX_ROOT" "$abs" <<'PY' 2>/dev/null || true +import os,sys +root=sys.argv[1]; p=sys.argv[2] +try: + r=os.path.relpath(p,root) + if r and not r.startswith("..") and not os.path.isabs(r): + print(r) +except Exception: + pass +PY + )" + else + case "$abs/" in + "$MOUNT_POSIX_ROOT"/*) rel="${abs#"$MOUNT_POSIX_ROOT/"}" ;; + *) rel="" ;; + esac + fi + + [[ -n "$rel" ]] || return 1 + printf "/workspace/%s" "$rel" +} + +psql_exec() { + local flags + flags="$(is_tty)" + local db="$1" user="$2" pass="$3" + shift 3 + exec docker exec $flags -e "PGPASSWORD=$pass" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$user" ${db:+-d "$db"} "$@" +} + +is_safe_ident() { [[ "$1" =~ ^[A-Za-z0-9_]+$ ]]; } + +ensure_db() { + local db="$1" user="$2" pass="$3" + is_safe_ident "$db" || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + + local exists + exists="$(docker exec -i -e "PGPASSWORD=$pass" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$user" -d postgres \ + -tA -v ON_ERROR_STOP=1 \ + -c "SELECT 1 FROM pg_database WHERE datname = '$db' LIMIT 1;" 2>/dev/null || true)" + + if [[ "$exists" == "1" ]]; then + echo "OK: database '$db' already exists" >&2 + return 0 + fi + + docker exec -i -e "PGPASSWORD=$pass" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$user" -d postgres \ + -v ON_ERROR_STOP=1 \ + -c "CREATE DATABASE \"$db\" OWNER \"$user\";" + echo "OK: database '$db' created (owner: $user)" >&2 +} + +drop_db() { + local db="$1" user="$2" pass="$3" + is_safe_ident "$db" || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + + docker exec -i -e "PGPASSWORD=$pass" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$user" -d postgres \ + -v ON_ERROR_STOP=1 \ + -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db' AND pid <> pg_backend_pid();" + + docker exec -i -e "PGPASSWORD=$pass" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$user" -d postgres \ + -v ON_ERROR_STOP=1 \ + -c "DROP DATABASE IF EXISTS \"$db\";" + echo "OK: database '$db' dropped" >&2 +} + +main() { + require_container + infer_port + resolve_mount_root + msys_no_pathconv + + # Your container env names: + # POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DATABASE + local PGUSER PGPASS PGDB IMAGE + PGUSER="$(cenv_any POSTGRES_USER PGUSER || true)" + [[ -n "${PGUSER:-}" ]] || PGUSER="postgres" + PGPASS="$(cenv_any POSTGRES_PASSWORD PGPASSWORD || true)" + [[ -n "${PGPASS:-}" ]] || PGPASS="postgres" + PGDB="$(cenv_any POSTGRES_DATABASE POSTGRES_DB PGDATABASE || true)" + IMAGE="$(pg_image)" + + # stdin pipe -> default db + if [[ $# -eq 0 && ! -t 0 ]]; then + [[ -n "${PGDB:-}" ]] || die "No default database set (POSTGRES_DATABASE/POSTGRES_DB)." + exec docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$PGDB" -v ON_ERROR_STOP=1 + fi + + # interactive + if [[ $# -eq 0 ]]; then + psql_exec "${PGDB:-}" "$PGUSER" "$PGPASS" + exit 0 + fi + + case "${1:-}" in + status) + docker ps --filter "name=^/${SERVICE}$" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + exit 0 + ;; + esac + + local cmd="${1:-}" + case "$cmd" in + dbs) + psql_exec "${PGDB:-postgres}" "$PGUSER" "$PGPASS" -v ON_ERROR_STOP=1 -c '\l' + ;; + + tables) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "tables requires a db name (or set POSTGRES_DATABASE)." + psql_exec "$db" "$PGUSER" "$PGPASS" -v ON_ERROR_STOP=1 -c '\dt *.*' + ;; + + create-db) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "create-db requires a db name." + ensure_db "$db" "$PGUSER" "$PGPASS" + ;; + + drop-db) + shift + local db="${1:-}" + [[ -n "$db" ]] || die "drop-db requires a db name." + drop_db "$db" "$PGUSER" "$PGPASS" + ;; + + import) + shift + local in="${1:-}" + shift || true + [[ -n "$in" ]] || die "import requires ." + + local file="$in" + local db="${1:-}" + + # shorthand: "import reporting_db" -> ./reporting_db.sql(.gz), db defaults to reporting_db + if [[ ! -f "$file" ]]; then + if [[ -f "./${in}.sql" ]]; then + file="./${in}.sql" + [[ -n "${db:-}" ]] || db="$in" + elif [[ -f "./${in}.sql.gz" ]]; then + file="./${in}.sql.gz" + [[ -n "${db:-}" ]] || db="$in" + fi + fi + + [[ -f "$file" ]] || die "file not found: $file" + [[ -n "${db:-$PGDB}" ]] || die "import requires a db name (or set POSTGRES_DATABASE)." + db="${db:-$PGDB}" + + local ws="" + ws="$(to_workspace_path_under_mount "$file" || true)" + if [[ -n "$ws" ]]; then + if [[ "$file" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' + f="$1"; db="$2"; user="$3"; pass="$4"; port="$5" + gunzip -c "$f" | PGPASSWORD="$pass" psql -h127.0.0.1 -p"$port" -U"$user" -d "$db" -v ON_ERROR_STOP=1 + ' sh "$ws" "$db" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + else + run_pg_mount "$IMAGE" sh -lc ' + f="$1"; db="$2"; user="$3"; pass="$4"; port="$5" + PGPASSWORD="$pass" psql -h127.0.0.1 -p"$port" -U"$user" -d "$db" -v ON_ERROR_STOP=1 -f "$f" + ' sh "$ws" "$db" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + fi + else + if [[ "$file" == *.gz ]]; then + gunzip -c "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$db" -v ON_ERROR_STOP=1 + else + cat "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$db" -v ON_ERROR_STOP=1 + fi + fi + + echo "OK: imported '$file' -> db '$db'" >&2 + ;; + + dump) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "dump requires a db name (or set POSTGRES_DATABASE)." + local out="${2:-${db}.sql}" + + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' + db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" + PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --no-owner --no-privileges \ + | gzip -c > "/workspace/$out" + ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + else + run_pg_mount "$IMAGE" sh -lc ' + db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" + PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --no-owner --no-privileges \ + > "/workspace/$out" + ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + fi + echo "OK: dumped '$db' -> ./$out" >&2 + ;; + + dump-all) + shift + local out="${1:-all.sql}" + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' + out="$1"; user="$2"; pass="$3"; port="$4" + PGPASSWORD="$pass" pg_dumpall -h127.0.0.1 -p"$port" -U"$user" \ + | gzip -c > "/workspace/$out" + ' sh "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + else + run_pg_mount "$IMAGE" sh -lc ' + out="$1"; user="$2"; pass="$3"; port="$4" + PGPASSWORD="$pass" pg_dumpall -h127.0.0.1 -p"$port" -U"$user" \ + > "/workspace/$out" + ' sh "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + fi + echo "OK: dumped all databases -> ./$out" >&2 + ;; + + roles) + shift + local out="${1:-globals.sql}" + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' + out="$1"; user="$2"; pass="$3"; port="$4" + PGPASSWORD="$pass" pg_dumpall -h127.0.0.1 -p"$port" -U"$user" --globals-only \ + | gzip -c > "/workspace/$out" + ' sh "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + else + run_pg_mount "$IMAGE" sh -lc ' + out="$1"; user="$2"; pass="$3"; port="$4" + PGPASSWORD="$pass" pg_dumpall -h127.0.0.1 -p"$port" -U"$user" --globals-only \ + > "/workspace/$out" + ' sh "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + fi + echo "OK: dumped globals (roles) -> ./$out" >&2 + ;; + + schema) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "schema requires a db name." + local out="${2:-${db}.schema.sql}" + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' + db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" + PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --schema-only --no-owner --no-privileges \ + | gzip -c > "/workspace/$out" + ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + else + run_pg_mount "$IMAGE" sh -lc ' + db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" + PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --schema-only --no-owner --no-privileges \ + > "/workspace/$out" + ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + fi + echo "OK: schema dumped '$db' -> ./$out" >&2 + ;; + + data) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "data requires a db name." + local out="${2:-${db}.data.sql}" + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' + db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" + PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --data-only --no-owner --no-privileges \ + | gzip -c > "/workspace/$out" + ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + else + run_pg_mount "$IMAGE" sh -lc ' + db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" + PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --data-only --no-owner --no-privileges \ + > "/workspace/$out" + ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + fi + echo "OK: data dumped '$db' -> ./$out" >&2 + ;; + + list) + shift + local file="${1:-}" + [[ -n "$file" ]] || die "list requires a dump file." + [[ -f "$file" ]] || die "file not found: $file" + + local ws="" + ws="$(to_workspace_path_under_mount "$file" || true)" + if [[ -n "$ws" ]]; then + run_pg_mount "$IMAGE" sh -lc ' + file="$1"; user="$2"; pass="$3"; port="$4" + PGPASSWORD="$pass" exec pg_restore -h127.0.0.1 -p"$port" -U"$user" -l "$file" + ' sh "$ws" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + else + cat "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ + pg_restore -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -l - + fi + ;; + + restore) + shift + local file="${1:-}" + shift || true + [[ -n "$file" ]] || die "restore requires a dump file." + [[ -f "$file" ]] || die "file not found: $file" + + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "restore requires a db name (or set POSTGRES_DATABASE)." + ensure_db "$db" "$PGUSER" "$PGPASS" + + local ws="" + ws="$(to_workspace_path_under_mount "$file" || true)" + if [[ -n "$ws" ]]; then + run_pg_mount "$IMAGE" sh -lc ' + file="$1"; db="$2"; user="$3"; pass="$4"; port="$5" + PGPASSWORD="$pass" exec pg_restore -h127.0.0.1 -p"$port" -U"$user" -d "$db" \ + --clean --if-exists --no-owner --no-privileges "$file" + ' sh "$ws" "$db" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" + else + cat "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ + pg_restore -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$db" \ + --clean --if-exists --no-owner --no-privileges - + fi + + echo "OK: restored '$file' -> db '$db'" >&2 + ;; + + *) + # passthrough (psql args...) + psql_exec "${PGDB:-}" "$PGUSER" "$PGPASS" "$@" + ;; + esac +} + +main "$@" diff --git a/bin/pg_dump b/bin/pg_dump deleted file mode 100644 index a3d8020..0000000 --- a/bin/pg_dump +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker exec -it POSTGRESQL pg_dump $@ diff --git a/bin/pg_dump.bat b/bin/pg_dump.bat deleted file mode 100644 index 66b1880..0000000 --- a/bin/pg_dump.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -docker exec -it POSTGRESQL pg_dump %* diff --git a/bin/pg_restore b/bin/pg_restore deleted file mode 100644 index f9ea6c9..0000000 --- a/bin/pg_restore +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker exec -it POSTGRESQL pg_restore $@ diff --git a/bin/pg_restore.bat b/bin/pg_restore.bat deleted file mode 100644 index 89fc6f5..0000000 --- a/bin/pg_restore.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -docker exec -it POSTGRESQL pg_restore %* diff --git a/bin/php b/bin/php old mode 100644 new mode 100755 index 6ebb10d..ca1955a --- a/bin/php +++ b/bin/php @@ -1,3 +1,278 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail -docker exec -it Core php $@ +die() { + echo "Error: $*" >&2 + exit 1 +} + +if [[ -t 2 ]]; then + C_RESET=$'\033[0m' + C_CYAN=$'\033[0;36m' + C_YELLOW=$'\033[0;33m' + C_DIM=$'\033[2m' +else + C_RESET="" + C_CYAN="" + C_YELLOW="" + C_DIM="" +fi +kv() { printf "%s%s%s %s%s%s\n" "$C_CYAN" "$1" "$C_RESET" "$C_YELLOW" "$2" "$C_RESET" >&2; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +normalize_container() { + local v="$1" + [[ -z "$v" ]] && return 0 + if [[ "$v" =~ ^PHP_[0-9]+\.[0-9]+$ ]]; then + echo "$v" + elif [[ "$v" =~ ^[0-9]+\.[0-9]+$ ]]; then + echo "PHP_$v" + else + die "invalid version '$v' (use like 8.4)" + fi +} + +relpath_under_pwd() { + local p="$1" + local cwd abs rel + cwd="$(cd "$PWD" && pwd)" + abs="$(cd "$(dirname "$p")" && pwd)/$(basename "$p")" + + if command -v python3 >/dev/null 2>&1; then + rel="$( + python3 - "$cwd" "$abs" <<'PY' 2>/dev/null || true +import os,sys +cwd=sys.argv[1]; p=sys.argv[2] +try: + r=os.path.relpath(p,cwd) + print(r) +except Exception: + pass +PY + )" + else + if [[ "$abs" == "$cwd/"* ]]; then + rel="${abs#"$cwd/"}" + else + rel="" + fi + fi + + [[ -n "${rel:-}" && "$rel" != /* && "$rel" != ..* ]] || return 1 + printf "%s" "$rel" +} + +port_in_use() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + lsof -i:"$port" >/dev/null 2>&1 && return 0 + return 1 + fi + if command -v ss >/dev/null 2>&1; then + ss -lnt 2>/dev/null | grep -qE "[:.]$port[[:space:]]" && return 0 + return 1 + fi + if command -v netstat >/dev/null 2>&1; then + netstat -lnt 2>/dev/null | grep -qE "[:.]$port[[:space:]]" && return 0 + return 1 + fi + return 1 +} + +pick_highest_php_container() { + local names + names="$(docker ps --format '{{.Names}}' | awk '/^PHP_[0-9]+\.[0-9]+$/')" + [[ -n "$names" ]] || return 1 + printf "%s\n" "$names" | sed 's/^PHP_//' | sort -Vr | head -n1 | awk 'NF{print "PHP_"$0}' +} + +resolve_host_pwd() { + local host="${WORKDIR_WIN:-${WORKING_DIR:-}}" + if [[ -z "${host}" ]]; then + host="$PWD" + fi + [[ -n "${host}" ]] || host="$REPO_ROOT" + + if command -v cygpath >/dev/null 2>&1; then + [[ "$host" == /* ]] && host="$(cygpath -w "$host")" + fi + + printf '%s' "$host" +} + +is_msys() { + case "${OSTYPE:-}" in + msys* | cygwin*) return 0 ;; + *) return 1 ;; + esac +} + +msys_no_pathconv() { + if is_msys; then + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + fi +} + +EXPLICIT_VER="" +ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + -V | --v | --php) + [[ -n "${2:-}" ]] || die "$1 requires a version (e.g. $1 8.4)" + EXPLICIT_VER="$2" + shift 2 + ;; + --) + shift + ARGS+=("$@") + break + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +TARGET="$(normalize_container "$EXPLICIT_VER")" + +if [[ -n "$TARGET" ]]; then + docker ps --format '{{.Names}}' | grep -qx "$TARGET" || die "container '$TARGET' is not running." +else + TARGET="$(pick_highest_php_container)" || die "no PHP container is running (expected names like PHP_8.4)." +fi + +IMAGE="$(docker inspect -f '{{.Config.Image}}' "$TARGET" 2>/dev/null)" || die "failed to inspect '$TARGET'." + +kv "Container:" "$TARGET" +kv "Image:" "$IMAGE" + +HOST_PWD="$(resolve_host_pwd)" +msys_no_pathconv + +serve_mode() { + local host="127.0.0.1" + local port="8000" + local root_dir="$PWD" + local verbose=false + local router_file="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --host=*) host="${1#*=}" ;; + --host) + shift + host="${1:-}" + ;; + --port=*) port="${1#*=}" ;; + --port) + shift + port="${1:-}" + ;; + --root=*) root_dir="${1#*=}" ;; + --root) + shift + root_dir="${1:-}" + ;; + --router=*) router_file="${1#*=}" ;; + --router) + shift + router_file="${1:-}" + ;; + -v | --verbose) verbose=true ;; + *) die "unknown serve option: $1" ;; + esac + shift + done + + [[ -n "${host:-}" ]] || die "serve: --host requires a value" + [[ "$port" =~ ^[0-9]+$ ]] || die "serve: invalid --port '$port'" + ((port >= 1 && port <= 65535)) || die "serve: port out of range: $port" + + if [[ "$root_dir" != /* ]]; then root_dir="$PWD/$root_dir"; fi + [[ -d "$root_dir" ]] || die "serve: root directory not found: $root_dir" + + local root_rel="" + if [[ "$root_dir" == "$PWD" ]]; then + root_rel="." + else + root_rel="$(relpath_under_pwd "$root_dir")" || die "serve: --root must be under current directory ($PWD)" + fi + + local router_rel="" + if [[ -n "$router_file" ]]; then + [[ "$router_file" != /* ]] && router_file="$PWD/$router_file" + [[ -f "$router_file" ]] || die "serve: router file not found: $router_file" + router_rel="$(relpath_under_pwd "$router_file")" || die "serve: router must be under current directory ($PWD)" + fi + + if port_in_use "$port"; then + die "serve: port $port is already in use (use --port )" + fi + + local found_root="$root_dir" + if [[ -z "$router_rel" ]]; then + local candidates=( + "$root_dir/public/index.php" + "$root_dir/public/index.html" + "$root_dir/index.php" + "$root_dir/index.html" + ) + local c + for c in "${candidates[@]}"; do + if [[ -f "$c" ]]; then + found_root="$(dirname "$c")" + break + fi + done + fi + + local found_rel="" + if [[ "$found_root" == "$PWD" ]]; then + found_rel="." + else + found_rel="$(relpath_under_pwd "$found_root")" || die "serve: discovered root must be under current directory ($PWD)" + fi + + if $verbose; then + kv "Serve:" "${C_DIM}host=${host} port=${port} root=${found_rel} router=${router_rel:-}${C_RESET}" + else + kv "Serve:" "${C_DIM}${host}:${port} root=${found_rel}${C_RESET}" + fi + + local publish=() + if [[ "$host" == "0.0.0.0" || "$host" == "::" ]]; then + publish=(-p "${port}:${port}") + else + publish=(-p "${host}:${port}:${port}") + fi + + local RUN_FLAGS=(--rm -v "$HOST_PWD":/workspace -w /workspace) + [[ -t 0 ]] && RUN_FLAGS+=(-it) || RUN_FLAGS+=(-i) + RUN_FLAGS+=(-u "$(id -u):$(id -g)") + RUN_FLAGS+=("${publish[@]}") + + if [[ -n "$router_rel" ]]; then + exec docker run "${RUN_FLAGS[@]}" "$IMAGE" php -S "0.0.0.0:${port}" -t "/workspace/${found_rel#./}" "/workspace/${router_rel#./}" + fi + + if [[ "$found_rel" == "." ]]; then + exec docker run "${RUN_FLAGS[@]}" "$IMAGE" php -S "0.0.0.0:${port}" + else + exec docker run "${RUN_FLAGS[@]}" "$IMAGE" php -S "0.0.0.0:${port}" -t "/workspace/${found_rel#./}" + fi +} + +if [[ "${ARGS[0]:-}" == "serve" ]]; then + serve_mode "${ARGS[@]:1}" +fi + +RUN_FLAGS=(--rm -v "$HOST_PWD":/workspace -w /workspace) +[[ -t 0 ]] && RUN_FLAGS+=(-it) || RUN_FLAGS+=(-i) +RUN_FLAGS+=(-u "$(id -u):$(id -g)") + +exec docker run "${RUN_FLAGS[@]}" "$IMAGE" php "${ARGS[@]}" diff --git a/bin/php.bat b/bin/php.bat deleted file mode 100644 index 59b79ef..0000000 --- a/bin/php.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -docker exec -it Core php %* diff --git a/bin/psql b/bin/psql deleted file mode 100644 index a7a57f5..0000000 --- a/bin/psql +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker exec -it POSTGRESQL psql $@ diff --git a/bin/psql.bat b/bin/psql.bat deleted file mode 100644 index 86faec3..0000000 --- a/bin/psql.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -docker exec -it POSTGRESQL psql %* diff --git a/bin/redis-cli b/bin/redis-cli old mode 100644 new mode 100755 index f8aa9fc..8a82277 --- a/bin/redis-cli +++ b/bin/redis-cli @@ -1,3 +1,122 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail -docker exec -it REDIS redis-cli $@ +die() { + echo "Error: $*" >&2 + exit 1 +} + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT_DIR/docker/.env" + +# Safe host .env load (optional; whitelist) +if [[ -r "$ENV_FILE" ]]; then + while IFS='=' read -r k v; do + [[ -z "${k:-}" || "$k" =~ ^[[:space:]]*# ]] && continue + k="${k//[[:space:]]/}" + case "$k" in + REDIS_CONTAINER | REDIS_DB | REDIS_PASSWORD) ;; + *) continue ;; + esac + v="${v%$'\r'}" + v="${v#\"}" + v="${v%\"}" + v="${v#\'}" + v="${v%\'}" + export "$k=$v" + done <"$ENV_FILE" +fi + +SERVICE="${REDIS_CONTAINER:-REDIS}" + +require_container() { docker inspect "$SERVICE" >/dev/null 2>&1 || die "container '$SERVICE' is not running."; } +is_tty() { [[ -t 0 ]] && echo "-it" || echo "-i"; } + +# Read env var from container (best source of truth) +cenv() { + local key="$1" + docker exec -i "$SERVICE" sh -lc 'printf "%s" "${!1-}"' sh "$key" 2>/dev/null || true +} + +redis_password() { + local pw="${REDIS_PASSWORD:-}" + [[ -n "$pw" ]] && { + printf '%s' "$pw" + return 0 + } + pw="$(cenv REDIS_PASSWORD || true)" + printf '%s' "$pw" +} + +redis_db() { + local db="${REDIS_DB:-}" + [[ -n "$db" ]] || db="0" + printf '%s' "$db" +} + +usage() { + cat >&2 <<'TXT' +Usage: + redis-cli # interactive + redis-cli # passthrough + +Shortcuts: + redis-cli db [cmd...] # run on a specific DB index + redis-cli status +TXT +} + +redis_exec() { + local flags + flags="$(is_tty)" + local pw db + pw="$(redis_password)" + db="$(redis_db)" + if [[ -n "$pw" ]]; then + exec docker exec $flags -e "REDISCLI_AUTH=$pw" "$SERVICE" redis-cli -n "$db" "$@" + else + exec docker exec $flags "$SERVICE" redis-cli -n "$db" "$@" + fi +} + +main() { + require_container + + if [[ $# -eq 0 ]]; then + redis_exec + exit 0 + fi + + case "${1:-}" in + -h | --help | help) + usage + exit 0 + ;; + status) + docker ps --filter "name=^/${SERVICE}$" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + exit 0 + ;; + db) + shift + local idx="${1:-}" + [[ -n "$idx" ]] || die "db requires an index." + [[ "$idx" =~ ^[0-9]+$ ]] || die "db index must be numeric." + shift || true + + local flags + flags="$(is_tty)" + local pw + pw="$(redis_password)" + if [[ -n "$pw" ]]; then + exec docker exec $flags -e "REDISCLI_AUTH=$pw" "$SERVICE" redis-cli -n "$idx" "$@" + else + exec docker exec $flags "$SERVICE" redis-cli -n "$idx" "$@" + fi + ;; + *) + redis_exec "$@" + ;; + esac +} + +main "$@" diff --git a/bin/redis-cli.bat b/bin/redis-cli.bat deleted file mode 100644 index 03b22c6..0000000 --- a/bin/redis-cli.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -docker exec -it REDIS redis-cli %* diff --git a/configuration/apache/.gitignore b/configuration/apache/.gitignore index 804911a..c96a04f 100644 --- a/configuration/apache/.gitignore +++ b/configuration/apache/.gitignore @@ -1 +1,2 @@ -*.conf \ No newline at end of file +* +!.gitignore \ No newline at end of file diff --git a/configuration/apache/site1.conf.example b/configuration/apache/site1.conf.example deleted file mode 100644 index 82ab218..0000000 --- a/configuration/apache/site1.conf.example +++ /dev/null @@ -1,77 +0,0 @@ -## http host example (http) - - - # Site Identity - ServerName site1.local - ServerAlias www.site1.local - ServerAdmin admin@site1.local - - # Document Root (where website files are located) - DocumentRoot /var/www/html/site1 - - # Directory Configuration - DirectoryIndex index.php index.html - - Options Indexes FollowSymLinks - AllowOverride All - Require all granted - - - # Log file locations - ErrorLog ${APACHE_LOG_DIR}/site1-error.log - CustomLog ${APACHE_LOG_DIR}/site1-access.log combined - - - -## http to https redirect example (all host) - - - # Server Alias (optional, list all domain variations if needed) - ServerAlias www.site1.local site1.local - - # Redirect all requests to HTTPS version - RewriteEngine On - RewriteCond %{SERVER_PORT} !^443$ - RewriteRule ^/(.*) https://%{SERVER_NAME}/$1 [R=301,L] - - - -## https host example (https) - - - # Site Identity - ServerName site1.local - ServerAlias www.site1.local - ServerAdmin admin@site1.local - - # Document Root (where website files are located) - DocumentRoot /var/www/html/site1 - - # Directory Configuration - DirectoryIndex index.php index.html - - Options Indexes FollowSymLinks - AllowOverride All - Require all granted - - - # Security measures - - SSLEngine on - # optional - Certificate verify (on/off/optional) - SSLCertificateVerify optional - # optional - Certificate type (PEM/DER/ASN1) - SSLCertificateType PEM - # optional - Certificate Chain - SSLCertificateChainFile /etc/apache2/ssl/site1/ca-bundle.crt - # Certificate - SSLCertificateFile /etc/apache2/ssl/site1/server.crt - # Private key - SSLCertificateKeyFile /etc/apache2/ssl/site1/server.key - - - # Log file locations - ErrorLog ${APACHE_LOG_DIR}/site1-error.log - CustomLog ${APACHE_LOG_DIR}/site1-access.log combined - - diff --git a/configuration/nginx/.gitignore b/configuration/nginx/.gitignore index 804911a..c96a04f 100644 --- a/configuration/nginx/.gitignore +++ b/configuration/nginx/.gitignore @@ -1 +1,2 @@ -*.conf \ No newline at end of file +* +!.gitignore \ No newline at end of file diff --git a/configuration/nginx/default.conf.example b/configuration/nginx/default.conf.example deleted file mode 100644 index 3d5fdab..0000000 --- a/configuration/nginx/default.conf.example +++ /dev/null @@ -1,169 +0,0 @@ -## Example 1: host (site1.local) -server { - listen 80; - - index index.php index.html; - server_name site1.local; - error_log /var/log/nginx/error.log; - access_log /var/log/nginx/access.log; - - root /app/site1; - client_max_body_size 12M; - - location / { - index index.php index.html index.htm; - try_files $uri $uri/ /index.php$is_args$args; - } - - location ~ \.php$ { - try_files $uri =404; - fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass php:9000; - fastcgi_index index.php; - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param PATH_INFO $fastcgi_path_info; - } -} - -## Example2: default fallback host -server { - listen 80; - - index index.php index.html; - server_name _; - - root /app/site2; - client_max_body_size 12M; - - location / { - index index.php index.html index.htm; - try_files $uri $uri/ /index.php$is_args$args; - } - - location ~ \.php$ { - try_files $uri =404; - fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass php:9000; - fastcgi_index index.php; - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param PATH_INFO $fastcgi_path_info; - } -} - -## Example 3: a complete example for http - -# User account under which Nginx runs -user www-data; - -# Number of worker processes for handling requests concurrently -worker_processes auto; - -# Main HTTP configuration block -http { - - # Include additional server block configurations from a specific directory - include /etc/nginx/conf.d/*.conf; - - # Default server configuration (optional, for unmatched requests) - server { - listen 80 default_server; - server_name _; - - # Serve a placeholder page for unmatched requests - root /app/site2; - location / { - try_files $uri $uri/ /index.html; - } - } - - # Example server block for a website (replace with your details) - server { - listen 80; - server_name yourdomain.com www.yourdomain.com; - - # Document root for website files - root /var/www/yourdomain; - - # Default index files - index index.html index.htm; - - # Serve logs for access and errors - access_log /var/log/nginx/yourdomain.access.log; - error_log /var/log/nginx/yourdomain.error.log; - - # Character encoding for serving content - charset utf-8; - - # Enable automatic directory listing if requested directory exists - autoindex on; - - # Example location block for a specific path (/images) - location /images/ { - # Serve images from a different directory - root /var/www/images; - - # Disable directory listing for security reasons - autoindex off; - - # Set appropriate expiration headers for image caching - expires 30d; - } - } -} - -## Example 4: a complete example for https -# User account under which Nginx runs -user www-data; - -# Number of worker processes -worker_processes auto; - -# Main HTTP configuration block -http { - - # Include additional server block configurations - include /etc/nginx/conf.d/*.conf; - - # Default server (optional) - server { - listen 80 default_server; - server_name _; - return 444; # Redirect to HTTPS server - } - - # HTTPS server block for your website (replace with your details) - server { - listen 443 ssl; - server_name yourdomain.com www.yourdomain.com; - - # Replace with paths to your SSL certificate and key files - ssl_certificate /path/to/yourdomain.crt; - ssl_certificate_key /path/to/yourdomain.key; - - # Additional SSL/TLS ciphers and protocols (adjust based on security best practices) - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!ADH:!eNULL:!LOW:!EXP:!MD5+SHA1+RC4; - - # Document root for website files - root /var/www/yourdomain; - - # Default index files - index index.html index.htm; - - # Serve logs - access_log /var/log/nginx/yourdomain.access.log; - error_log /var/log/nginx/yourdomain.error.log; - - # Character encoding - charset utf-8; - - # Similar configurations as the previous HTTP server block... - - # Redirect all HTTP traffic to HTTPS - location / { - return 301 https://$host$request_uri; - } - } -} diff --git a/docker/conf/docker-files/apache/.gitignore b/configuration/rootCA/.gitignore similarity index 100% rename from docker/conf/docker-files/apache/.gitignore rename to configuration/rootCA/.gitignore diff --git a/docker/data/.gitignore b/configuration/scheduler/cron-jobs/.gitignore similarity index 100% rename from docker/data/.gitignore rename to configuration/scheduler/cron-jobs/.gitignore diff --git a/docker/logs/.gitignore b/configuration/scheduler/supervisor/.gitignore similarity index 100% rename from docker/logs/.gitignore rename to configuration/scheduler/supervisor/.gitignore diff --git a/configuration/sops/config/.gitignore b/configuration/sops/config/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/configuration/sops/config/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/configuration/sops/global/.gitignore b/configuration/sops/global/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/configuration/sops/global/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/configuration/sops/keys/.gitignore b/configuration/sops/keys/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/configuration/sops/keys/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/cruise b/cruise deleted file mode 100644 index 37cacfd..0000000 --- a/cruise +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash - -if [ ! -f ".env" ]; then - echo ".env file is missing!" - exit 1 -fi - -loadEnv() { - (set -a && . ./.env && "$@") -} - -directory="$(dirname -- "$( readlink -f -- "$0" || greadlink -f -- "$0"; )")"; -if grep -qF "WORKING_DIR=" "$directory/.env"; then - existing=$(grep "WORKING_DIR=" "$directory/.env" | cut -d '=' -f 2) - if [ -z "$existing" ]; then - sed -i "s|WORKING_DIR=.*|WORKING_DIR=$directory|" "$directory/.env" - fi -else - printf "\nWORKING_DIR=%s" "$directory" >> "$directory/.env" -fi - -find "${directory}/configuration/apache/" -type f -name "*.conf" -exec cp -ua {} "${directory}/docker/conf/docker-files/apache/" \; - -if [ ! -f "${directory}/configuration/php/php.ini" ]; then - touch "${directory}/configuration/php/php.ini" -fi - -case "$1" in - up) - docker compose --project-directory "$directory" -f docker/compose/docker-compose.yml up - ;; - start | reload) - docker compose --project-directory "$directory" -f docker/compose/docker-compose.yml up -d - ;; - stop | down) - docker compose --project-directory "$directory" -f docker/compose/docker-compose.yml down - ;; - reboot | restart) - docker compose --project-directory "$directory" -f docker/compose/docker-compose.yml down - docker compose --project-directory "$directory" -f docker/compose/docker-compose.yml up -d - ;; - rebuild) - docker compose --project-directory "$directory" -f docker/compose/docker-compose.yml down - docker compose --project-directory "$directory" -f docker/compose/docker-compose.yml build --no-cache --pull $2 $3 $4 $5 $6 $7 $8 $9 - ;; - core) - docker exec -it Core bash -c "sudo -u devuser /bin/bash" - ;; - lzd) - docker exec -it SERVER_TOOLS lazydocker - ;; - *) - docker compose --project-directory "$directory" -f docker/compose/docker-compose.yml $@ - ;; -esac diff --git a/cruise.bat b/cruise.bat deleted file mode 100644 index ab67f3e..0000000 --- a/cruise.bat +++ /dev/null @@ -1,65 +0,0 @@ -@echo off - -IF NOT EXIST ".env" ( - echo .env file is missing! - goto incomplete -) - -set "directory=%~dp0" -set "directory=%directory:~0,-1%" -setlocal enabledelayedexpansion -findstr /C:"WORKING_DIR=" "%directory%\.env" > nul -if %errorlevel% equ 0 ( - for /f "tokens=2 delims==" %%a in ('findstr /C:"WORKING_DIR=" "%directory%\.env"') do ( - set "existing_working_dir=%%a" - ) - if "!existing_working_dir!"=="" ( - echo WORKING_DIR is set in .env file with empty value, remove it for automatic path setting. - goto incomplete - ) -) else ( - echo.>> "%directory%\.env" - echo WORKING_DIR=!directory!>> "%directory%\.env" -) -endlocal - -for /r "%directory%\configuration\apache\" %%f in (*.conf) do ( - for %%g in ("%directory%\docker\conf\docker-files\apache\%%~nxf") do ( - if "%%~tf" gtr "%%~tg" ( - copy "%%f" "%directory%\docker\conf\docker-files\apache\" /Y > nul - ) - ) -) - -if not exist "%directory%\configuration\php\php.ini" ( - type nul > "%directory%\configuration\php\php.ini" -) - -if "%1" == "start" ( - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml up -d -) else if %1 == reload ( - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml up -d -) else if %1 == up ( - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml up -) else if %1 == stop ( - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml down -) else if %1 == down ( - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml down -) else if %1 == reboot ( - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml down - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml up -d -) else if %1 == restart ( - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml down - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml up -d -) else if %1 == rebuild ( - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml down - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml build --no-cache --pull %2 %3 %4 %5 %6 %7 %8 %9 -) else if %1 == core ( - docker exec -it Core bash -c "sudo -u devuser /bin/bash" -) else if %1 == lzd ( - docker exec -it SERVER_TOOLS lazydocker -) else ( - docker compose --project-directory "%directory%" -f docker/compose/docker-compose.yml %* -) - -:incomplete diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/docker/compose/companion.yaml b/docker/compose/companion.yaml new file mode 100644 index 0000000..9266426 --- /dev/null +++ b/docker/compose/companion.yaml @@ -0,0 +1,74 @@ +services: + server-tools: + container_name: SERVER_TOOLS + hostname: local-dock + image: infocyph/tools:latest + restart: unless-stopped + environment: + - TZ=${TZ:-} + volumes: + - ../../configuration/apache:/etc/share/vhosts/apache + - ../../configuration/nginx:/etc/share/vhosts/nginx + - ../extras:/etc/share/vhosts/node + - ../../configuration/ssl:/etc/mkcert + - "../../configuration/ssh:/home/root/.ssh:ro" + - "${PROJECT_DIR:-./../../../application}:/app" + - ../../configuration/rootCA:/etc/share/rootCA + - /var/run/docker.sock:/var/run/docker.sock + - ../../configuration/sops/config:/etc/share/sops/config + - ../../configuration/sops/global:/etc/share/sops/global + - ../../configuration/sops/keys:/etc/share/sops/keys + - "${SOP_REPO:-./../../../sops-repo}:/etc/share/vhosts/sops" + - "${HOME}/.gitconfig:/home/root/.gitconfig:ro" + networks: + backend: + ipv4_address: 172.29.0.10 + + runner: + container_name: RUNNER + hostname: runner + image: infocyph/runner:latest + restart: unless-stopped + environment: + - TZ=${TZ:-} + volumes: + - ../../configuration/scheduler/supervisor:/etc/supervisor/conf.d:ro + - ../../configuration/scheduler/cron-jobs:/etc/cron.d:ro + - ../../logs/runner:/var/log/supervisor + - ../../logs/apache:/global/log/apache + - ../../logs/nginx:/global/log/nginx + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - server-tools + networks: + backend: + ipv4_address: 172.29.0.11 + + mailpit: + container_name: MAILPIT + hostname: mailpit + image: axllent/mailpit:latest + restart: unless-stopped + environment: + - TZ=${TZ:-} + - MP_MAX_MESSAGES=5000 + - MP_DATABASE=/data/mailpit.db + - MP_SMTP_TLS_CERT=/certs/local.pem + - MP_SMTP_TLS_KEY=/certs/local-key.pem + - MP_SMTP_REQUIRE_STARTTLS=true + - MP_SMTP_AUTH_ACCEPT_ANY=1 + volumes: + - ../../data/mailpit:/data + - ../../configuration/ssl:/certs:ro + depends_on: + - server-tools + networks: + frontend: + ipv4_address: 172.28.0.12 + backend: + ipv4_address: 172.29.0.12 + healthcheck: + test: [ "CMD", "wget", "-qO-", "http://127.0.0.1:8025/" ] + interval: 10s + timeout: 3s + retries: 10 diff --git a/docker/compose/db-client.yaml b/docker/compose/db-client.yaml new file mode 100644 index 0000000..57ae971 --- /dev/null +++ b/docker/compose/db-client.yaml @@ -0,0 +1,87 @@ +x-db-client-service: &db-client-service + restart: unless-stopped + networks: + datastore: {} + frontend: {} + +services: + redis-insight: + <<: *db-client-service + container_name: REDIS_INSIGHT + hostname: redis-insight + image: redis/redisinsight:latest + profiles: [redis] + depends_on: + - redis + ports: + - "${REDIS_INSIGHT_PORT:-5540}:5540" + environment: + - RI_REDIS_HOST=redis + volumes: + - ../../data/redis-insight:/data + networks: + datastore: + ipv4_address: 172.30.0.150 + frontend: + ipv4_address: 172.28.0.150 + + cloudbeaver: + <<: *db-client-service + container_name: CLOUD_BEAVER + hostname: cloud-beaver + image: dbeaver/cloudbeaver:latest + profiles: [mysql, mariadb, postgresql] + ports: + - "${DBEAVER_PORT:-8080}:8978" + environment: + - TZ=${TZ:-} + volumes: + - ../../data/cloudbeaver:/opt/cloudbeaver/workspace + networks: + datastore: + ipv4_address: 172.30.0.151 + frontend: + ipv4_address: 172.28.0.151 + + mongo-express: + <<: *db-client-service + container_name: MONGO_EXPRESS + hostname: mongo-express + image: mongo-express:${MONGO_EXPRESS_VERSION:-latest} + profiles: [mongodb] + depends_on: + - mongodb + ports: + - "${MONGO_EXPRESS_PORT:-8081}:8081" + environment: + - TZ=${TZ:-} + - ME_CONFIG_BASICAUTH=false + - ME_CONFIG_MONGODB_ADMINUSERNAME=${MONGODB_ROOT_USERNAME:-root} + - ME_CONFIG_MONGODB_ADMINPASSWORD=${MONGODB_ROOT_PASSWORD:-12345} + - ME_CONFIG_MONGODB_URL=mongodb://${MONGODB_ROOT_USERNAME:-root}:${MONGODB_ROOT_PASSWORD:-12345}@mongodb:${MONGODB_PORT:-27017}/admin + networks: + datastore: + ipv4_address: 172.30.0.152 + frontend: + ipv4_address: 172.28.0.152 + + kibana: + <<: *db-client-service + container_name: KIBANA + hostname: kibana + image: kibana:${ELASTICSEARCH_VERSION:-8.18.0} + profiles: [elasticsearch] + depends_on: + - elasticsearch + ports: + - "${KIBANA_PORT:-5601}:5601" + environment: + - TZ=${TZ:-} + - "ELASTICSEARCH_HOSTS=http://elasticsearch:9200" + volumes: + - ../../data/kibana:/usr/share/kibana/data + networks: + datastore: + ipv4_address: 172.30.0.153 + frontend: + ipv4_address: 172.28.0.153 diff --git a/docker/compose/db.yaml b/docker/compose/db.yaml new file mode 100644 index 0000000..dfef125 --- /dev/null +++ b/docker/compose/db.yaml @@ -0,0 +1,165 @@ +x-db-service: &db-service + restart: unless-stopped + networks: + datastore: {} + +services: + redis: + <<: *db-service + container_name: REDIS + hostname: redis + image: redis/redis-stack-server:${REDIS_VERSION:-latest} + profiles: [redis] + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - ../../data/redis:/data + environment: + - TZ=${TZ:-} + - REDIS_ARGS=--save 10 1 --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + networks: + datastore: + ipv4_address: 172.30.0.100 + + postgres: + <<: *db-service + container_name: POSTGRESQL + hostname: postgres + image: postgres:${POSTGRES_VERSION:-alpine} + profiles: [postgresql] + environment: + - TZ=${TZ:-} + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_DB=${POSTGRES_DATABASE:-postgres} + volumes: + - ../../data/postgresql:/var/lib/postgresql + - ../conf/pg_hba.conf:/etc/postgresql/pg_hba.conf +# - ../conf/postgresql.conf:/etc/postgresql/postgresql.conf + command: + [ + "postgres", + "-c", "hba_file=/etc/postgresql/pg_hba.conf", +# "-c", "config_file=/etc/postgresql/postgresql.conf", + ] + healthcheck: + test: + [ + "CMD-SHELL", + "PGPASSWORD=${POSTGRES_PASSWORD:-postgres} pg_isready -U ${POSTGRES_USER:-postgres} -h localhost -d ${POSTGRES_DB:-postgres}" + ] + interval: 30s + timeout: 10s + retries: 5 + networks: + datastore: + ipv4_address: 172.30.0.101 + + mysql: + <<: *db-service + container_name: MYSQL + hostname: mysql + image: mysql:${MYSQL_VERSION:-latest} + profiles: [mysql] + ports: + - "${MYSQL_PORT:-3306}:3306" + environment: + - TZ=${TZ:-} + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-12345} + - MYSQL_USER=${MYSQL_USER:-infocyph} + - MYSQL_PASSWORD=${MYSQL_PASSWORD:-12345} + - MYSQL_DATABASE=${MYSQL_DATABASE:-localdb} + volumes: + - ../../data/mysql:/var/lib/mysql + - ../../logs/mysql:/var/log/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h127.0.0.1 -u root -p${MYSQL_ROOT_PASSWORD:-12345}"] + interval: 30s + timeout: 10s + retries: 3 + networks: + datastore: + ipv4_address: 172.30.0.102 + + mongodb: + <<: *db-service + container_name: MONGODB + hostname: mongodb + image: mongo:${MONGODB_VERSION:-latest} + profiles: [mongodb] + ports: + - "${MONGODB_PORT:-27017}:27017" + environment: + - TZ=${TZ:-} + - MONGO_INITDB_ROOT_USERNAME=${MONGODB_ROOT_USERNAME:-root} + - MONGO_INITDB_ROOT_PASSWORD=${MONGODB_ROOT_PASSWORD:-12345} + volumes: + - ../../data/mongo:/data/db + healthcheck: + test: + [ + "CMD-SHELL", + "mongosh \"mongodb://${MONGODB_ROOT_USERNAME:-root}:${MONGODB_ROOT_PASSWORD:-12345}@localhost:${MONGODB_PORT:-27017}/admin\" --eval \"db.adminCommand('ping')\" --quiet || exit 1" + ] + interval: 30s + timeout: 10s + retries: 3 + networks: + datastore: + ipv4_address: 172.30.0.103 + + mariadb: + <<: *db-service + container_name: MARIADB + hostname: mariadb + image: mariadb:${MARIADB_VERSION:-latest} + profiles: [mariadb] + ports: + - "${MARIADB_PORT:-3306}:3306" + environment: + - TZ=${TZ:-} + - MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-12345} + - MARIADB_USER=${MARIADB_USER:-infocyph} + - MARIADB_PASSWORD=${MARIADB_PASSWORD:-12345} + - MARIADB_DATABASE=${MARIADB_DATABASE:-localdb} + volumes: + - ../../data/mariadb:/var/lib/mysql + - ../../logs/mariadb:/var/log/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p${MARIADB_ROOT_PASSWORD:-12345}"] + interval: 30s + timeout: 10s + retries: 3 + networks: + datastore: + ipv4_address: 172.30.0.104 + + elasticsearch: + <<: *db-service + container_name: ELASTICSEARCH + hostname: elasticsearch + image: elasticsearch:${ELASTICSEARCH_VERSION:-8.18.0} + profiles: [elasticsearch] + ports: + - "${ELASTICSEARCH_PORT:-9200}:9200" + environment: + - TZ=${TZ:-} + - "discovery.type=single-node" + - "xpack.security.enabled=false" + - "cluster.name=single_node_cluster" + - "node.name=elasticsearch-node-single" + volumes: + - ../../data/elasticsearch:/usr/share/elasticsearch/data + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + networks: + datastore: + ipv4_address: 172.30.0.105 diff --git a/docker/compose/docker-compose.yml b/docker/compose/docker-compose.yml deleted file mode 100644 index 89a6359..0000000 --- a/docker/compose/docker-compose.yml +++ /dev/null @@ -1,300 +0,0 @@ -name: cruise -networks: - cruise-net: - name: cruise network - driver: bridge - -services: - web: - container_name: WEB - image: nginx:${NGINX_VERSION:-latest} - profiles: - - nginx - restart: always - ports: - - "${HTTP_PORT:-80}:80" - - "${HTTPS_PORT:-443}:443" - environment: - - TZ=${TZ:-Asia/Dhaka} - volumes: - - "${PROJECT_DIR:-./../application}:/app" - - ./docker/logs/nginx:/var/log/nginx - - ./configuration/nginx:/etc/nginx/conf.d - - ./configuration/ssl:/etc/ssl/certs:rw - - ./configuration/ssl:/etc/ssl/private:rw - extra_hosts: - - "host.docker.internal:host-gateway" - networks: - - cruise-net - - php: - container_name: Core - build: - context: docker/conf/docker-files - dockerfile: nginx.Dockerfile - args: - PHP_VERSION: ${PHP_VERSION:-8.3} - UID: ${UID:-1000} - PHP_EXTENSIONS: ${PHP_EXTENSIONS:-} - LINUX_PACKAGES: ${LINUX_PACKAGES:-} - NODE_VERSION: ${NODE_VERSION:-} - WORKING_DIR: ${WORKING_DIR} - profiles: - - nginx - restart: always - environment: - - TZ=${TZ:-Asia/Dhaka} - env_file: - - "./.env" - volumes: - - "${PROJECT_DIR:-./../application}:/app" - - ./docker/conf/php/php.ini:/usr/local/etc/php/conf.d/99-overrides.ini - - ./docker/conf/php/www.conf:/usr/local/etc/php-fpm.d/www.conf:rw - - ./docker/conf/php/openssl.cnf:/etc/ssl/openssl.cnf:rw - - ./configuration/ssh:/home/devuser/.ssh - extra_hosts: - - "host.docker.internal:host-gateway" - networks: - - cruise-net - - app: - container_name: Core - build: - context: docker/conf/docker-files - dockerfile: apache.Dockerfile - args: - PHP_VERSION: ${PHP_VERSION:-8.3} - UID: ${UID:-1000} - PHP_EXTENSIONS: ${PHP_EXTENSIONS:-} - LINUX_PACKAGES: ${LINUX_PACKAGES:-} - NODE_VERSION: ${NODE_VERSION:-} - WORKING_DIR: ${WORKING_DIR} - profiles: - - apache - restart: always - environment: - - TZ=${TZ:-Asia/Dhaka} - env_file: - - "./.env" - ports: - - "${HTTP_PORT:-80}:80" - - "${HTTPS_PORT:-443}:443" - volumes: - - "${PROJECT_DIR:-./../application}:/var/www/html" - - ./docker/conf/php/php.ini:/usr/local/etc/php/conf.d/99-overrides.ini - - ./docker/conf/php/openssl.cnf:/etc/ssl/openssl.cnf - - ./configuration/ssl:/etc/apache2/ssl:rw - - ./docker/logs/apache:/var/log/apache2 - - ./configuration/ssh:/home/devuser/.ssh - - ./configuration/apache:/etc/apache2/sites-available - extra_hosts: - - 'host.docker.internal:host-gateway' - networks: - - cruise-net - - mysql-server: - container_name: MYSQL - image: ${MYSQL_IMAGE:-mariadb}:${MYSQL_VERSION:-latest} - profiles: - - mysql - - mariadb - restart: always - ports: - - "${MYSQL_PORT:-3306}:3306" - environment: - - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-12345} - - MYSQL_USER=${MYSQL_USER:-devuser} - - MYSQL_PASSWORD=${MYSQL_PASSWORD:-12345} - - MYSQL_DATABASE=${MYSQL_DATABASE:-localdb} - - TZ=${TZ:-Asia/Dhaka} - volumes: - - ./docker/data/mysql:/var/lib/mysql - - ./docker/logs/mysql:/var/log/mysql - networks: - - cruise-net - - mysql-client: - container_name: MY_ADMIN - image: phpmyadmin:latest - profiles: - - mysql - - mariadb - restart: always - ports: - - "${MYADMIN_PORT:-3300}:80" - environment: - - PMA_ARBITRARY=1 - - PMA_HOST=mysql-server - - PMA_PORT=${MYSQL_PORT:-3306} - - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-12345} - - MYSQL_USER=${MYSQL_USER:-devuser} - - MYSQL_PASSWORD=${MYSQL_PASSWORD:-12345} - - TZ=${TZ:-Asia/Dhaka} - volumes: - - ./docker/conf/php/php.ini:/usr/local/etc/php/conf.d/99-overrides.ini - - ./docker/data/phpmyadmin:/etc/phpmyadmin/conf.d:ro - depends_on: - - mysql-server - networks: - - cruise-net - - postgres-server: - container_name: POSTGRESQL - image: postgres:${POSTGRESQL_VERSION:-latest} - profiles: - - postgresql - restart: always - ports: - - "${POSTGRESQL_PORT:-5432}:5432" - environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - - POSTGRES_DB=${POSTGRES_DATABASE:-postgres} - - TZ=${TZ:-Asia/Dhaka} - volumes: - - ./docker/data/postgresql:/var/lib/postgresql/data - networks: - - cruise-net - - postgres-client: - container_name: PG_ADMIN - image: dpage/pgadmin4:latest - profiles: - - postgresql - restart: always - ports: - - "${PGADMIN_PORT:-5400}:80" - environment: - - PGADMIN_DEFAULT_EMAIL=admin@email.com - - PGADMIN_DEFAULT_PASSWORD=admin - - TZ=${TZ:-Asia/Dhaka} - volumes: - - ./docker/data/pgadmin:/var/lib/pgadmin - depends_on: - - postgres-server - networks: - - cruise-net - - mongo-server: - container_name: MONGODB - image: mongo:${MONGODB_VERSION:-latest} - profiles: - - mongodb - restart: always - ports: - - "${MONGODB_PORT:-27017}:27017" - environment: - - MONGO_INITDB_ROOT_USERNAME=${MONGODB_ROOT_USERNAME:-root} - - MONGO_INITDB_ROOT_PASSWORD=${MONGODB_ROOT_PASSWORD:-12345} - - TZ=${TZ:-Asia/Dhaka} - logging: - options: - max-size: 1g - networks: - - cruise-net - - mongo-client: - container_name: MONGO_ADMIN - image: mongo-express:${ME_VERSION:-latest} - profiles: - - mongodb - restart: always - ports: - - "${ME_PORT:-8081}:8081" - environment: - - ME_CONFIG_BASICAUTH_USERNAME=${ME_BA_USERNAME:-root} - - ME_CONFIG_BASICAUTH_PASSWORD=${ME_BA_PASSWORD:-12345} - - ME_CONFIG_MONGODB_ADMINUSERNAME=${MONGODB_ROOT_USERNAME:-root} - - ME_CONFIG_MONGODB_ADMINPASSWORD=${MONGODB_ROOT_PASSWORD:-12345} - - ME_CONFIG_MONGODB_URL=mongodb://${MONGODB_ROOT_USERNAME:-root}:${MONGODB_ROOT_PASSWORD:-12345}@mongo:${MONGODB_PORT:-27017}/ - - TZ=${TZ:-Asia/Dhaka} - depends_on: - - mongo-server - networks: - - cruise-net - - elasticsearch-server: - container_name: ELASTICSEARCH - image: elasticsearch:${ELASTICSEARCH_VERSION:-8.12.2} - profiles: - - elasticsearch - restart: always - ports: - - "${ELASTICSEARCH_PORT:-9200}:9200" - environment: - - TZ=${TZ:-Asia/Dhaka} - - discovery.type='single-node' - - xpack.security.enabled='false' - volumes: - - ./docker/data/elasticsearch:/usr/share/elasticsearch/data - networks: - - cruise-net - - elasticsearch-client: - container_name: KIBANA - image: kibana:${ELASTICSEARCH_VERSION:-8.12.2} - profiles: - - elasticsearch - restart: always - ports: - - "${KIBANA_PORT:-5601}:5601" - environment: - - TZ=${TZ:-Asia/Dhaka} - - ELASTICSEARCH_HOSTS='["http://elasticsearch-server:${ELASTICSEARCH_PORT:-9200}"]' - depends_on: - - elasticsearch-server - networks: - - cruise-net - - redis-server: - container_name: REDIS - image: redis/redis-stack-server:${REDIS_VERSION:-latest} - profiles: - - redis - restart: always - ports: - - "${REDIS_PORT:-6379}:6379" - volumes: - - ./docker/data/redis:/data - environment: - - TZ=${TZ:-Asia/Dhaka} - - REDIS_ARGS = "--save 10 1 --appendonly yes" - networks: - - cruise-net - - redis-client: - container_name: REDIS_INSIGHT - image: redis/redisinsight:latest - profiles: - - redis - restart: always - environment: - - TZ=${TZ:-Asia/Dhaka} - ports: - - "${RI_PORT:-5540}:5540" - volumes: - - ./docker/data/redis-insight:/data - depends_on: - - redis-server - networks: - - cruise-net - - server-tools: - container_name: SERVER_TOOLS - build: - context: docker/conf/docker-files - dockerfile: tools.Dockerfile - args: - UID: ${UID:-1000} - profiles: - - tools - restart: always - environment: - - TZ=${TZ:-Asia/Dhaka} - volumes: - - ./configuration/ssl:/etc/ssl/custom - - ./configuration/ssh:/home/devuser/.ssh - - ./../application:/app - - /var/run/docker.sock:/var/run/docker.sock - networks: - - cruise-net diff --git a/docker/compose/http.yaml b/docker/compose/http.yaml new file mode 100644 index 0000000..c8bbc8f --- /dev/null +++ b/docker/compose/http.yaml @@ -0,0 +1,45 @@ +services: + nginx: + container_name: NGINX + hostname: nginx + image: infocyph/nginx:latest + restart: unless-stopped + environment: + - TZ=${TZ:-} + ports: + - "${NGINX_HTTP_PORT:-80}:80" + - "${NGINX_HTTPS_PORT:-443}:443" + volumes: + - ../../logs/nginx:/var/log/nginx + - ../../configuration/nginx:/etc/nginx/conf.d:ro + - ../../configuration/ssl:/etc/mkcert:ro + - ../../configuration/rootCA:/etc/share/rootCA:ro + - "${PROJECT_DIR:-./../../../application}:/app" + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + frontend: + ipv4_address: 172.28.0.10 + depends_on: + - server-tools + + apache: + container_name: APACHE + hostname: apache + image: infocyph/apache:latest + restart: always + profiles: + - apache + environment: + - TZ=${TZ:-} + volumes: + - ../../configuration/ssl:/etc/mkcert:ro + - ../../configuration/apache:/usr/local/apache2/conf/extra:ro + - ../../logs/apache:/var/log/apache2 + - ../../configuration/rootCA:/etc/share/rootCA:ro + - "${PROJECT_DIR:-./../../../application}:/app" + depends_on: + - nginx + networks: + frontend: + ipv4_address: 172.28.0.11 diff --git a/docker/compose/main.yaml b/docker/compose/main.yaml new file mode 100644 index 0000000..9362489 --- /dev/null +++ b/docker/compose/main.yaml @@ -0,0 +1,33 @@ +name: LocalDevStack + +networks: + frontend: + name: Frontend + driver: bridge + ipam: + config: + - subnet: 172.28.0.0/24 + gateway: 172.28.0.1 + + backend: + name: Backend + driver: bridge + ipam: + config: + - subnet: 172.29.0.0/24 + gateway: 172.29.0.1 + + datastore: + name: DataStore + driver: bridge + ipam: + config: + - subnet: 172.30.0.0/24 + gateway: 172.30.0.1 + +include: + - docker/compose/companion.yaml + - docker/compose/db.yaml + - docker/compose/db-client.yaml + - docker/compose/php.yaml + - docker/compose/http.yaml diff --git a/docker/compose/php.yaml b/docker/compose/php.yaml new file mode 100644 index 0000000..657ac84 --- /dev/null +++ b/docker/compose/php.yaml @@ -0,0 +1,234 @@ +x-php-service: &php-service + restart: unless-stopped + environment: + - TZ=${TZ:-} + env_file: + - "../../.env" + networks: + frontend: {} + backend: {} + datastore: {} + volumes: + - "${PROJECT_DIR:-./../../../application}:/app" + - ../conf/www.conf:/usr/local/etc/php-fpm.d/www.conf + - ../conf/openssl.cnf:/etc/ssl/openssl.cnf + - ../../configuration/php/php.ini:/usr/local/etc/php/conf.d/99-overrides.ini + - "../../configuration/ssh:/home/${USER}/.ssh:ro" + - "${HOME}/.gitconfig:/home/${USER}/.gitconfig:ro" + - ../../configuration/rootCA:/etc/share/rootCA:ro + depends_on: + - server-tools + healthcheck: + test: ["CMD-SHELL", "php-fpm -t || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + +services: + php73: + <<: *php-service + container_name: PHP_7.3 + hostname: php-73 + networks: + frontend: + ipv4_address: 172.28.0.50 + backend: + ipv4_address: 172.29.0.50 + datastore: + ipv4_address: 172.30.0.50 + build: + context: ../dockerfiles + dockerfile: php.Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-root} + USERNAME: ${USER} + PHP_EXT: ${PHP_EXT:-} + LINUX_PKG: ${LINUX_PKG:-} + WORKING_DIR: ${WORKING_DIR} + PHP_VERSION: 7.3 + PHP_EXT_VERSIONED: ${PHP_EXT_73:-} + LINUX_PKG_VERSIONED: ${LINUX_PKG_73:-} + profiles: [php, php73] + + php74: + <<: *php-service + container_name: PHP_7.4 + hostname: php-74 + networks: + frontend: + ipv4_address: 172.28.0.51 + backend: + ipv4_address: 172.29.0.51 + datastore: + ipv4_address: 172.30.0.51 + build: + context: ../dockerfiles + dockerfile: php.Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-1000} + USERNAME: ${USER} + PHP_EXT: ${PHP_EXT:-} + LINUX_PKG: ${LINUX_PKG:-} + WORKING_DIR: ${WORKING_DIR} + PHP_VERSION: 7.4 + PHP_EXT_VERSIONED: ${PHP_EXT_74:-} + LINUX_PKG_VERSIONED: ${LINUX_PKG_74:-} + profiles: [php, php74] + + php80: + <<: *php-service + container_name: PHP_8.0 + hostname: php-80 + networks: + frontend: + ipv4_address: 172.28.0.52 + backend: + ipv4_address: 172.29.0.52 + datastore: + ipv4_address: 172.30.0.52 + build: + context: ../dockerfiles + dockerfile: php.Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-root} + USERNAME: ${USER} + PHP_EXT: ${PHP_EXT:-} + LINUX_PKG: ${LINUX_PKG:-} + WORKING_DIR: ${WORKING_DIR} + PHP_VERSION: 8.0 + PHP_EXT_VERSIONED: ${PHP_EXT_80:-} + LINUX_PKG_VERSIONED: ${LINUX_PKG_80:-} + profiles: [php, php80] + + php81: + <<: *php-service + container_name: PHP_8.1 + hostname: php-81 + networks: + frontend: + ipv4_address: 172.28.0.53 + backend: + ipv4_address: 172.29.0.53 + datastore: + ipv4_address: 172.30.0.53 + build: + context: ../dockerfiles + dockerfile: php.Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-root} + USERNAME: ${USER} + PHP_EXT: ${PHP_EXT:-} + LINUX_PKG: ${LINUX_PKG:-} + WORKING_DIR: ${WORKING_DIR} + PHP_VERSION: 8.1 + PHP_EXT_VERSIONED: ${PHP_EXT_81:-} + LINUX_PKG_VERSIONED: ${LINUX_PKG_81:-} + profiles: [php, php81] + + php82: + <<: *php-service + container_name: PHP_8.2 + hostname: php-82 + networks: + frontend: + ipv4_address: 172.28.0.54 + backend: + ipv4_address: 172.29.0.54 + datastore: + ipv4_address: 172.30.0.54 + build: + context: ../dockerfiles + dockerfile: php.Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-root} + USERNAME: ${USER} + PHP_EXT: ${PHP_EXT:-} + LINUX_PKG: ${LINUX_PKG:-} + WORKING_DIR: ${WORKING_DIR} + PHP_VERSION: 8.2 + PHP_EXT_VERSIONED: ${PHP_EXT_82:-} + LINUX_PKG_VERSIONED: ${LINUX_PKG_82:-} + profiles: [php, php82] + + php83: + <<: *php-service + container_name: PHP_8.3 + hostname: php-83 + networks: + frontend: + ipv4_address: 172.28.0.55 + backend: + ipv4_address: 172.29.0.55 + datastore: + ipv4_address: 172.30.0.55 + build: + context: ../dockerfiles + dockerfile: php.Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-root} + USERNAME: ${USER} + PHP_EXT: ${PHP_EXT:-} + LINUX_PKG: ${LINUX_PKG:-} + WORKING_DIR: ${WORKING_DIR} + PHP_VERSION: 8.3 + PHP_EXT_VERSIONED: ${PHP_EXT_83:-} + LINUX_PKG_VERSIONED: ${LINUX_PKG_83:-} + profiles: [php, php83] + + php84: + <<: *php-service + container_name: PHP_8.4 + hostname: php-84 + networks: + frontend: + ipv4_address: 172.28.0.56 + backend: + ipv4_address: 172.29.0.56 + datastore: + ipv4_address: 172.30.0.56 + build: + context: ../dockerfiles + dockerfile: php.Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-root} + USERNAME: ${USER} + PHP_EXT: ${PHP_EXT:-} + LINUX_PKG: ${LINUX_PKG:-} + WORKING_DIR: ${WORKING_DIR} + PHP_VERSION: 8.4 + PHP_EXT_VERSIONED: ${PHP_EXT_84:-} + LINUX_PKG_VERSIONED: ${LINUX_PKG_84:-} + profiles: [php, php84] + + php85: + <<: *php-service + container_name: PHP_8.5 + hostname: php-85 + networks: + frontend: + ipv4_address: 172.28.0.57 + backend: + ipv4_address: 172.29.0.57 + datastore: + ipv4_address: 172.30.0.57 + build: + context: ../dockerfiles + dockerfile: php.Dockerfile + args: + UID: ${UID:-1000} + GID: ${GID:-root} + USERNAME: ${USER} + PHP_EXT: ${PHP_EXT:-} + LINUX_PKG: ${LINUX_PKG:-} + WORKING_DIR: ${WORKING_DIR} + PHP_VERSION: 8.5 + PHP_EXT_VERSIONED: ${PHP_EXT_85:-} + LINUX_PKG_VERSIONED: ${LINUX_PKG_85:-} + profiles: [php, php85] diff --git a/docker/conf/docker-files/apache.Dockerfile b/docker/conf/docker-files/apache.Dockerfile deleted file mode 100644 index bb00f7b..0000000 --- a/docker/conf/docker-files/apache.Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# Use an official PHP Apache runtime -ARG PHP_VERSION -FROM php:${PHP_VERSION}-apache - -# Required installations/updates -ARG LINUX_PACKAGES -RUN apt update && apt upgrade -y -RUN ["/bin/bash", "-c", "if [[ -n \"$LINUX_PACKAGES\" ]]; then apt install ${LINUX_PACKAGES//,/ } -y; fi"] - -# Prepare prerequisites -ENV APACHE_LOG_DIR=/var/log/apache2 -ADD ./apache/*.conf /etc/apache2/sites-available -ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ -RUN chmod +x /usr/local/bin/install-php-extensions && \ - rm -f /etc/apache2/sites-available/000-default.conf /etc/apache2/sites-available/default-ssl.conf - -# Configureing Apache -RUN a2enmod rewrite ssl socache_shmcb headers && a2ensite * - -# Install PHP extensions -ARG PHP_EXTENSIONS -RUN ["/bin/bash", "-c", "install-php-extensions @composer ${PHP_EXTENSIONS//,/ }"] - -# Install Node -ARG NODE_VERSION -RUN ["/bin/bash", "-c", "if [[ -n \"$NODE_VERSION\" ]]; then curl -fsSL https://deb.nodesource.com/setup_$NODE_VERSION.x | bash - && apt install nodejs -y && npm i -g npm@latest; fi"] - -RUN apt update && \ - apt install sudo -y && \ - apt clean && \ - rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/cache/apt/archives/* - -# Add synced system user -ARG UID -RUN useradd -G www-data,root -u $UID -d /home/devuser devuser && \ - mkdir -p /home/devuser/.composer && \ - chown -R devuser:devuser /home/devuser && \ - echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser -USER devuser - -WORKDIR /var/www/html diff --git a/docker/conf/docker-files/nginx.Dockerfile b/docker/conf/docker-files/nginx.Dockerfile deleted file mode 100644 index 4801e95..0000000 --- a/docker/conf/docker-files/nginx.Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# Use an official PHP Apache runtime -ARG PHP_VERSION -FROM php:${PHP_VERSION}-fpm - -# Required installations/updates -ARG LINUX_PACKAGES -RUN apt update && apt upgrade -y -RUN ["/bin/bash", "-c", "if [[ -n \"$LINUX_PACKAGES\" ]]; then apt install ${LINUX_PACKAGES//,/ } -y; fi"] - -ARG PHP_EXTENSIONS -ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ -RUN chmod +x /usr/local/bin/install-php-extensions -RUN ["/bin/bash", "-c", "install-php-extensions @composer ${PHP_EXTENSIONS//,/ }"] - -# Install Node -ARG NODE_VERSION -RUN ["/bin/bash", "-c", "if [[ -n \"$NODE_VERSION\" ]]; then curl -fsSL https://deb.nodesource.com/setup_$NODE_VERSION.x | bash - && apt install nodejs -y && npm i -g npm@latest; fi"] - -RUN apt update && \ - apt install sudo -y && \ - apt clean && \ - rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/cache/apt/archives/* - -# Add synced system user -ARG UID -RUN useradd -G www-data,root -u $UID -d /home/devuser devuser && \ - mkdir -p /home/devuser/.composer && \ - chown -R devuser:devuser /home/devuser && \ - echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser -USER devuser - -WORKDIR /app \ No newline at end of file diff --git a/docker/conf/docker-files/tools.Dockerfile b/docker/conf/docker-files/tools.Dockerfile deleted file mode 100644 index 8b51c30..0000000 --- a/docker/conf/docker-files/tools.Dockerfile +++ /dev/null @@ -1,44 +0,0 @@ -FROM debian:latest - -# Required/Common Packages -RUN apt update && apt upgrade -y -RUN apt install python3 python3-pip curl git wget net-tools libnss3-tools \ - build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg -y && \ - rm -f /usr/lib/python3.*/EXTERNALLY-MANAGED - -# mkcert -RUN curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64" && \ - chmod +x mkcert-v*-linux-amd64 && \ - cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert && \ - rm -f mkcert-v*-linux-amd64 && \ - mkdir -p /etc/ssl/custom - -# lazydocker -ENV DIR=/usr/local/bin -RUN curl https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | bash - -# git fame -RUN pip install git-fame && git config --global alias.fame '!python3 -m gitfame' && \ - wget -O /usr/local/bin/owners https://raw.githubusercontent.com/abmmhasan/misc-ref/main/git/owners.sh && \ - chmod +x /usr/local/bin/owners - -# Git story -RUN pip install manim gitpython git-story - -# Add sudo & clean up -RUN apt update && \ - apt install sudo -y && \ - apt clean && \ - rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/cache/apt/archives/* - -# Add synced system user -ARG UID -RUN useradd -G root -u ${UID} -d /home/devuser devuser && \ - mkdir -p /home/devuser && \ - chown -R devuser:devuser /home/devuser && \ - echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser -USER devuser - -WORKDIR /app - -CMD ["tail", "-f", "/dev/null"] diff --git a/docker/conf/php/openssl.cnf b/docker/conf/openssl.cnf similarity index 100% rename from docker/conf/php/openssl.cnf rename to docker/conf/openssl.cnf diff --git a/docker/conf/pg_hba.conf b/docker/conf/pg_hba.conf new file mode 100644 index 0000000..5534f88 --- /dev/null +++ b/docker/conf/pg_hba.conf @@ -0,0 +1,20 @@ +################################################################################ +# TYPE DATABASE USER ADDRESS METHOD +################################################################################ + +# 1. Local Unix-socket connections (strong auth) +local all all scram-sha-256 + +# 2. IPv4 loopback +host all all 127.0.0.1/32 scram-sha-256 + +# 3. IPv6 loopback +host all all ::1/128 scram-sha-256 + +# 4. Docker “datastore” network +host all all 172.30.0.0/24 scram-sha-256 + +# 5. (Optional) pgAdmin or external tools +# If you really need to allow any host, you can—but it’s safer to lock this +# down to the specific CIDR(s) you expect clients on. +#host all all 0.0.0.0/0 scram-sha-256 diff --git a/docker/conf/postgresql.conf b/docker/conf/postgresql.conf new file mode 100644 index 0000000..83ad60d --- /dev/null +++ b/docker/conf/postgresql.conf @@ -0,0 +1,47 @@ +########################## +# CONNECTIONS & AUTHENTICATION +########################## +listen_addresses = '*' # allow connections from any host +port = 5432 +max_connections = 100 # adjust to expected client load + +# Use SCRAM for stronger password hashing +password_encryption = scram-sha-256 + +########################## +# RESOURCE USAGE (tune to your available RAM) +########################## +shared_buffers = 256MB # typically 1/4 of system RAM +effective_cache_size = 768MB # roughly 3× shared_buffers +work_mem = 4MB # per-sorting-hash memory +maintenance_work_mem = 64MB # for VACUUM, CREATE INDEX +min_wal_size = 80MB +max_wal_size = 1GB +checkpoint_timeout = 10min +checkpoint_completion_target = 0.7 # spread checkpoint I/O + +# WAL settings for standalone; minimal logging overhead +wal_level = minimal +synchronous_commit = on # or 'off' if you can tolerate some data loss +full_page_writes = off + +########################## +# QUERY PLANNING & TUNING +########################## +default_statistics_target = 100 # higher can improve planning +random_page_cost = 1.1 # SSDs can be closer to 1.0 +effective_io_concurrency = 200 # Linux asynchronous I/O depth + +########################## +# LOGGING +########################## +logging_collector = on +log_directory = 'pg_log' +log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' +log_min_messages = warning +log_min_error_statement = error + +########################## +# Include any other defaults +########################## +include '/var/lib/postgresql/data/postgresql.conf' diff --git a/docker/conf/php/www.conf b/docker/conf/www.conf similarity index 99% rename from docker/conf/php/www.conf rename to docker/conf/www.conf index 0cd92e2..70bc610 100644 --- a/docker/conf/php/www.conf +++ b/docker/conf/www.conf @@ -37,7 +37,7 @@ group = www-data ; (IPv6 and IPv4-mapped) on a specific port; ; '/path/to/unix/socket' - to listen on a unix socket. ; Note: This value is mandatory. -listen = 127.0.0.1:9000 +listen = 0.0.0.0:9000 ; Set listen(2) backlog. ; Default Value: 511 (-1 on FreeBSD and OpenBSD) diff --git a/docker/dockerfiles/node.Dockerfile b/docker/dockerfiles/node.Dockerfile new file mode 100644 index 0000000..8f0321e --- /dev/null +++ b/docker/dockerfiles/node.Dockerfile @@ -0,0 +1,34 @@ +ARG NODE_VERSION=current +FROM node:${NODE_VERSION}-alpine + +LABEL org.opencontainers.image.source="https://github.com/infocyph/LocalDock" +LABEL org.opencontainers.image.description="NodeJS Alpine" +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.authors="infocyph,abmmhasan" + +ARG USERNAME=dockery +ENV USERNAME=${USERNAME} +ARG UID=1000 +ARG GID=1000 +ARG LINUX_PKG +ARG LINUX_PKG_VERSIONED +ARG NODE_GLOBAL +ARG NODE_GLOBAL_VERSIONED +ENV PATH="/usr/local/bin:/usr/bin:/bin:/usr/games:$PATH" \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + NPM_CONFIG_CACHE=/tmp/.npm-cache \ + GIT_CREDENTIAL_STORE=/home/${USERNAME}/.git-credentials + +ADD https://raw.githubusercontent.com/infocyph/Scriptomatic/master/bash/node-cli-setup.sh /usr/local/bin/cli-setup.sh +RUN apk add --no-cache bash && \ + NODE_VERSION="$(node -v | sed 's/^v//')" && \ + bash /usr/local/bin/cli-setup.sh "${USERNAME}" "${NODE_VERSION}" && \ + rm -f /usr/local/bin/cli-setup.sh && \ + rm -rf /var/cache/apk/* /tmp/* /var/tmp/* + +USER ${USERNAME} +RUN sudo /usr/local/bin/git-default +WORKDIR /app +ENTRYPOINT ["/usr/local/bin/node-entry"] +CMD [] \ No newline at end of file diff --git a/docker/dockerfiles/php.Dockerfile b/docker/dockerfiles/php.Dockerfile new file mode 100644 index 0000000..2f2acc4 --- /dev/null +++ b/docker/dockerfiles/php.Dockerfile @@ -0,0 +1,35 @@ +ARG PHP_VERSION=8.4 +FROM php:${PHP_VERSION}-fpm-alpine + +LABEL org.opencontainers.image.source="https://github.com/infocyph/LocalDock" +LABEL org.opencontainers.image.description="PHP FPM Alpine" +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.authors="infocyph,abmmhasan" + +ARG USERNAME=dockery +ENV USERNAME=${USERNAME} +ARG LINUX_PKG +ARG LINUX_PKG_VERSIONED +ARG PHP_EXT +ARG PHP_EXT_VERSIONED +ARG UID=1000 +ARG GID=1000 +ENV GIT_USER_NAME="" \ + GIT_USER_EMAIL="" \ + GIT_SAFE_DIR_PATTERN="/app/*" \ + GIT_CREDENTIAL_STORE="/home/${USERNAME}/.git-credentials" \ + PATH="/usr/local/bin:/usr/bin:/bin:/usr/games:$PATH" \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +ADD https://raw.githubusercontent.com/infocyph/Scriptomatic/master/bash/php-cli-setup.sh /usr/local/bin/cli-setup.sh +RUN apk add --no-cache bash && \ + bash /usr/local/bin/cli-setup.sh "${USERNAME}" "${PHP_VERSION}" && \ + rm -f /usr/local/bin/cli-setup.sh && \ + rm -rf /var/cache/apk/* /tmp/* /var/tmp/* + +USER ${USERNAME} +RUN sudo /usr/local/bin/git-default +WORKDIR /app +ENTRYPOINT ["/usr/local/bin/php-entry"] +CMD ["php-fpm"] \ No newline at end of file diff --git a/docker/extras/.gitignore b/docker/extras/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/docker/extras/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/docs/_static/theme.css b/docs/_static/theme.css new file mode 100644 index 0000000..ae81ea7 --- /dev/null +++ b/docs/_static/theme.css @@ -0,0 +1,3 @@ +.highlight-php .k { + color: #0077aa; /* Example: make PHP keywords a different color */ +} diff --git a/docs/concepts/architecture.rst b/docs/concepts/architecture.rst new file mode 100644 index 0000000..f70063c --- /dev/null +++ b/docs/concepts/architecture.rst @@ -0,0 +1,32 @@ +Architecture +============ + +LocalDevStack is composed of: + +- The **orchestrator**: ``server`` / ``server.bat`` (selects profiles, runs Compose, common workflows) +- The **HTTP layer**: Nginx (front proxy) and optionally Apache (backend HTTP) depending on your stack choice +- The **runtimes**: PHP (FPM) and Node (and future stacks) +- The **control plane**: the **server-tools** image (domain/vhost generation, TLS automation, secrets helpers) +- The **runner**: supervisord + cron + logrotate and helper exec wrappers + +Key idea +-------- + +Instead of a monolithic "one container does everything" model, LocalDevStack uses: + +- Compose profiles to enable only what you need +- Generated configuration artifacts (vhosts, certificates) persisted on the host +- Stable container names/hostnames to keep local routing predictable + +How containers cooperate +------------------------ + +1. You generate or edit vhost configs (usually via ``mkhost`` in the Tools container). +2. The Tools container can scan all vhosts and generate certificates (``certify`` + ``mkcert``). +3. Nginx loads vhosts and routes requests either: + + - directly to PHP-FPM (fastcgi), or + - to Apache (reverse proxy) when Apache mode is enabled, or + - to a Node service (reverse proxy). + +4. The Runner handles background services (cron/logrotate) and gives you a consistent place for helper utilities. diff --git a/docs/concepts/profiles-and-env.rst b/docs/concepts/profiles-and-env.rst new file mode 100644 index 0000000..26c0968 --- /dev/null +++ b/docs/concepts/profiles-and-env.rst @@ -0,0 +1,39 @@ +Profiles and Environment +======================== + +LocalDevStack uses Docker Compose **profiles** so you can enable only the services you want for a given project. + +Where profiles are set +---------------------- + +- Primary: ``docker/.env`` (this repo) +- Optional: root ``.env`` (project-level overrides) + +The key variable is typically: + +- ``COMPOSE_PROFILES``: comma-separated profile list + +Examples +-------- + +Enable Nginx + PHP 8.4 + tools + MariaDB + Redis: + +.. code-block:: none + + COMPOSE_PROFILES=nginx,php,php84,tools,mariadb,redis + +Enable Apache mode (Nginx -> Apache -> PHP-FPM): + +.. code-block:: none + + COMPOSE_PROFILES=nginx,apache,php,php84,tools + +Guided setup +------------ + +The ``server`` CLI typically includes helpers like: + +- ``server setup profiles`` +- ``server setup domain`` + +These helpers are opinionated: they try to keep profiles and generated configs consistent. diff --git a/docs/concepts/storage-layout.rst b/docs/concepts/storage-layout.rst new file mode 100644 index 0000000..bd51924 --- /dev/null +++ b/docs/concepts/storage-layout.rst @@ -0,0 +1,23 @@ +Storage Layout +============== + +To keep LocalDevStack reproducible, generated artifacts should be persisted on the host and mounted into containers. + +Recommended host folders +------------------------ + +This repository uses a ``configuration/`` root (mounted into the stack): + +- ``configuration/nginx``: Nginx vhost configs +- ``configuration/apache``: Apache vhost configs (only if Apache mode is used) +- ``configuration/ssl``: generated certificates (mkcert output) +- ``configuration/rootCA``: mkcert Root CA store +- ``configuration/php``: PHP runtime ini overrides +- ``configuration/ssh``: optional SSH mount (e.g., for Node deps or private repos) + +Why this matters +---------------- + +- Keeping vhosts stable keeps cert generation stable. +- Persisting Root CA avoids repeated trust resets. +- Persisting ``php.ini`` overrides keeps runtime behavior consistent between rebuilds. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..e46463d --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,122 @@ +# docs/conf.py +# Main Sphinx configuration file, adapted from your Webrick example. +from __future__ import annotations +import os, datetime +from subprocess import Popen, PIPE + +# --- Project information ----------------------------------------------------- +project = "LocalDevStack" +author = "Infocyph" +year_now = datetime.date.today().strftime("%Y") +copyright = f"2024-{year_now}" + +def get_version() -> str: + # Prefer RTD version (e.g., 'latest', tag names, or branch) + if os.environ.get("READTHEDOCS") == "True": + v = os.environ.get("READTHEDOCS_VERSION") + if v: + return v + try: + # Works in detached HEAD + pipe = Popen("git rev-parse --abbrev-ref HEAD", stdout=PIPE, shell=True, universal_newlines=True) + v = (pipe.stdout.read() or "").strip() + return v or "latest" + except Exception: + return "latest" + +version = get_version() +release = version +language = "en" + +# Sphinx 8 root document +root_doc = "index" + +# --- Syntax highlighting (PHP) ---------------------------------------------- +from pygments.lexers.web import PhpLexer +from sphinx.highlighting import lexers +highlight_language = "php" +lexers["php"] = PhpLexer(startinline=True) +lexers["php-annotations"] = PhpLexer(startinline=True) + +# --- Extensions -------------------------------------------------------------- +extensions = [ + "myst_parser", + "sphinx.ext.todo", + "sphinx.ext.autosectionlabel", + "sphinx.ext.intersphinx", + "sphinx_copybutton", + "sphinx_design", + "sphinxcontrib.phpdomain", # Essential for PHP projects + "sphinx.ext.extlinks", +] + +# MyST (Markdown) settings +myst_enable_extensions = [ + "colon_fence", + "deflist", + "attrs_block", + "attrs_inline", + "tasklist", + "fieldlist", + "linkify", +] +myst_heading_anchors = 3 + +# Autodoc/Napoleon are for Python, so they are omitted. +autosectionlabel_prefix_document = True + +# Intersphinx: Link to PHP manual +intersphinx_mapping = { + "php": ("https://www.php.net/manual/en/", None), +} + +# extlinks shortcut for PHP manual +extlinks = { + "php": ("https://www.php.net/manual/en/%s.php", "%s"), +} + +# TODOs visible in HTML +todo_include_todos = True + +# --- HTML output (Book Theme) ----------------------------------------------- +html_theme = "sphinx_book_theme" +html_theme_options = { + "repository_url": "https://github.com/infocyph/LocalDevStack", + "repository_branch": "main", + "path_to_docs": "docs", + "use_repository_button": True, + "use_issues_button": True, + "use_download_button": True, # PDF/ePub from RTD + "home_page_in_toc": True, + "show_toc_level": 2, # depth in right sidebar +} +templates_path = ["_templates"] +html_static_path = ["_static"] +html_css_files = ["theme.css"] +html_title = f"LocalDevStack – {version} Documentation" +html_show_sourcelink = True +html_show_sphinx = False +html_last_updated_fmt = "%Y-%m-%d" + +# --- PDF (LaTeX) options (optional) ----------------------------------------- +latex_engine = "xelatex" +latex_elements = { + "papersize": "a4paper", + "pointsize": "11pt", + "preamble": "", + "figure_align": "H", +} + +# GitHub context (book theme uses the buttons above) +html_context = { + "display_github": False, + "github_user": "infocyph", + "github_repo": "LocalDevStack", + "github_version": version, + "conf_py_path": "/docs/", +} + +# Substitution for current year +rst_prolog = f""" +.. |current_year| replace:: {year_now} +""" diff --git a/docs/guides/domain-setup.rst b/docs/guides/domain-setup.rst new file mode 100644 index 0000000..8f05e02 --- /dev/null +++ b/docs/guides/domain-setup.rst @@ -0,0 +1,18 @@ +Domain Setup +============ + +Most users create domains and vhosts through the Tools container (``mkhost``). + +What ``mkhost`` typically does +------------------------------ + +- Generates Nginx vhost config for your domain. +- Generates Apache vhost config when Apache mode is enabled. +- Generates a Node compose fragment when you choose a Node app. +- Optionally triggers certificate generation workflow (see :doc:`tls-and-certificates`). + +Tips +---- + +- Keep vhost configs under ``configuration/nginx`` / ``configuration/apache``. +- Prefer a consistent domain scheme (e.g., ``project.localhost``) for easy routing. diff --git a/docs/guides/node-apps.rst b/docs/guides/node-apps.rst new file mode 100644 index 0000000..7ce1329 --- /dev/null +++ b/docs/guides/node-apps.rst @@ -0,0 +1,24 @@ +Node Apps +========= + +LocalDevStack supports Node applications behind Nginx reverse proxy. + +Common model +------------ + +- Nginx terminates HTTP/HTTPS +- Nginx proxies to the Node container (e.g., port 3000) +- Node app source is mounted under ``/app`` + +Vhost generation +---------------- + +When you choose a Node app in the domain wizard, the Tools container can generate: + +- a domain-specific Nginx vhost +- a compose fragment describing the Node service + +Health checks +------------- + +Node services are typically checked via a simple TCP connect probe. diff --git a/docs/guides/notifications.rst b/docs/guides/notifications.rst new file mode 100644 index 0000000..c0e2acc --- /dev/null +++ b/docs/guides/notifications.rst @@ -0,0 +1,18 @@ +Notifications +============= + +LocalDevStack can optionally emit notifications from containers to the host. + +Common pattern +-------------- + +- A small TCP server (often ``notifierd``) runs in the Tools container. +- Other scripts send events (often via a ``notify`` client). +- The host can tail container logs and forward events to OS notifications (toast, notify-send, etc.). + +Message format +-------------- + +Implementations commonly use a single-line, tab-separated payload (token, timeout, urgency, source, title, body). + +This is intentionally simple: it survives log streaming and is easy to parse. diff --git a/docs/guides/secrets-sops-age.rst b/docs/guides/secrets-sops-age.rst new file mode 100644 index 0000000..bb13730 --- /dev/null +++ b/docs/guides/secrets-sops-age.rst @@ -0,0 +1,22 @@ +Secrets with SOPS and Age +========================= + +LocalDevStack can integrate an encrypted secrets workflow using **SOPS** and **Age**. + +The goal is simple: + +- Keep ``.env``-like secrets encrypted in Git +- Decrypt only when needed (locally) into runtime containers or build steps + +Typical workflow (high level) +----------------------------- + +1. Store secrets as ``*.enc.env`` (or similar) in a repo. +2. Keep Age private keys outside the repo (mounted into Tools container). +3. Use a helper (often called ``senv``) to decrypt into a target env file. + +Safety notes +------------ + +- Prefer read-only mounts for secrets repos. +- Do not bake private keys into images. diff --git a/docs/guides/tls-and-certificates.rst b/docs/guides/tls-and-certificates.rst new file mode 100644 index 0000000..2b6d419 --- /dev/null +++ b/docs/guides/tls-and-certificates.rst @@ -0,0 +1,25 @@ +TLS and Certificates +==================== + +LocalDevStack uses mkcert-based local TLS. + +Two components are commonly involved: + +- ``mkcert``: creates a local CA and issues dev certificates +- ``certify``: scans vhost configs and (re)generates certificates for all detected domains + +Domain discovery +---------------- + +``certify`` typically scans all ``*.conf`` files under a shared vhost directory (mounted from your host). +From that, it derives domain names and generates SAN certificates. + +Persistence +----------- + +Persist these host-mounted directories: + +- ``configuration/rootCA``: the local Root CA +- ``configuration/ssl``: generated cert/key output + +After persistence, you can rebuild containers without losing trust. diff --git a/docs/images/apache.rst b/docs/images/apache.rst new file mode 100644 index 0000000..813e6e7 --- /dev/null +++ b/docs/images/apache.rst @@ -0,0 +1,16 @@ +apache Image +============ + +Purpose +------- + +The Apache image is used when you run LocalDevStack in Apache mode: + +- Nginx terminates HTTP/HTTPS +- Nginx reverse-proxies to Apache +- Apache forwards PHP requests to PHP-FPM (proxy_fcgi) + +Why use Apache mode +------------------- + +Apache mode is useful if you need Apache-specific behaviors/modules while still keeping Nginx as the edge proxy. diff --git a/docs/images/nginx.rst b/docs/images/nginx.rst new file mode 100644 index 0000000..2aa9b25 --- /dev/null +++ b/docs/images/nginx.rst @@ -0,0 +1,20 @@ +nginx Image +=========== + +Purpose +------- + +The Nginx image provides the front proxy for LocalDevStack. + +Typical responsibilities: + +- Serve as the main entry point (80/443) +- Reverse proxy to Node services +- FastCGI to PHP-FPM when in Nginx+FPM mode +- Reverse proxy to Apache when in Nginx->Apache mode + +Local routing helpers +--------------------- + +This image can generate an include file (e.g., ``locals.conf``) for convenience routes under ``*.localhost``. +This is useful for local dashboards (db UI, mail UI, redis UI, etc.). diff --git a/docs/images/runner.rst b/docs/images/runner.rst new file mode 100644 index 0000000..bc8ee13 --- /dev/null +++ b/docs/images/runner.rst @@ -0,0 +1,18 @@ +runner Image +============ + +Purpose +------- + +The Runner image provides background process management and housekeeping: + +- ``supervisord`` as PID 1 +- cron daemon +- logrotate worker + +It is also a convenient place for helper wrappers around ``docker exec``. + +Healthcheck +----------- + +Runner images commonly expose a healthcheck that verifies supervisor is responsive via ``supervisorctl``. diff --git a/docs/images/server-tools.rst b/docs/images/server-tools.rst new file mode 100644 index 0000000..5be9de5 --- /dev/null +++ b/docs/images/server-tools.rst @@ -0,0 +1,18 @@ +server-tools Image +================== + +Purpose +------- + +The ``server-tools`` image acts as LocalDevStack's control plane: + +- Domain/vhost generation (e.g., ``mkhost`` / ``delhost``) +- TLS automation (mkcert + ``certify``) +- Secrets helpers (SOPS + Age wrappers) +- Optional notification utilities + +How it fits +----------- + +LocalDevStack mounts shared folders (vhosts, ssl, rootCA, etc.) into the Tools container so that generated artifacts +are persisted on the host and visible to the HTTP containers. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..a2e8ec2 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,41 @@ +LocalDevStack Documentation +========================== + +LocalDevStack is a modular, Docker-based local development stack designed to replace traditional local bundles +(XAMPP/MAMP/LAMP) with a reproducible, profile-driven setup. + +It is built around a small orchestrator (the ``server`` CLI + Compose profiles) and a set of purpose-built images +that work together (tools, HTTP, runner). + +.. toctree:: + :maxdepth: 2 + :caption: Getting Started + + quickstart + +.. toctree:: + :maxdepth: 2 + :caption: Concepts + + concepts/architecture + concepts/profiles-and-env + concepts/storage-layout + +.. toctree:: + :maxdepth: 2 + :caption: Guides + + guides/domain-setup + guides/tls-and-certificates + guides/node-apps + guides/secrets-sops-age + guides/notifications + +.. toctree:: + :maxdepth: 2 + :caption: Images + + images/server-tools + images/nginx + images/apache + images/runner diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 0000000..173c6fd --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,28 @@ +Quickstart +========== + +This quickstart is intentionally short. It gets you running with a basic HTTP + runtime stack, then points you to the +guides for domains, TLS, Node, secrets, and notifications. + +Prerequisites +------------- + +- Docker (Docker Engine recommended; Docker Desktop is fine) + +Typical flow +------------ + +1. Configure profiles (what services to run). +2. Start the stack. +3. Add a domain + vhost config. +4. (Optional) Generate and trust TLS certificates. +5. Reload HTTP services. + +Next steps +---------- + +- Domain and vhosts: :doc:`guides/domain-setup` +- Local TLS (mkcert + certify): :doc:`guides/tls-and-certificates` +- Node apps behind Nginx: :doc:`guides/node-apps` +- Encrypted secrets (SOPS + Age): :doc:`guides/secrets-sops-age` +- Notifications: :doc:`guides/notifications` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..cddeba8 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,7 @@ +sphinx>=8.0,<9 +myst-parser>=2.0 +sphinx-book-theme>=1.1 +sphinxcontrib-phpdomain>=0.9 +sphinx-copybutton>=0.5.2 +sphinx-design>=0.6 +linkify-it-py>=2.0 diff --git a/logs/.gitignore b/logs/.gitignore new file mode 100755 index 0000000..e5704d6 --- /dev/null +++ b/logs/.gitignore @@ -0,0 +1,8 @@ +* +!runner +!apache +!nginx +!mariadb +!mysql +!olds +!.gitignore \ No newline at end of file diff --git a/logs/apache/.gitignore b/logs/apache/.gitignore new file mode 100755 index 0000000..c96a04f --- /dev/null +++ b/logs/apache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/logs/mariadb/.gitignore b/logs/mariadb/.gitignore new file mode 100755 index 0000000..c96a04f --- /dev/null +++ b/logs/mariadb/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/logs/mysql/.gitignore b/logs/mysql/.gitignore new file mode 100755 index 0000000..c96a04f --- /dev/null +++ b/logs/mysql/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/logs/nginx/.gitignore b/logs/nginx/.gitignore new file mode 100755 index 0000000..c96a04f --- /dev/null +++ b/logs/nginx/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/logs/olds/.gitignore b/logs/olds/.gitignore new file mode 100755 index 0000000..c96a04f --- /dev/null +++ b/logs/olds/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/logs/runner/.gitignore b/logs/runner/.gitignore new file mode 100755 index 0000000..c96a04f --- /dev/null +++ b/logs/runner/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/server b/server new file mode 100755 index 0000000..ad6d64a --- /dev/null +++ b/server @@ -0,0 +1,2064 @@ +#!/usr/bin/env bash +# Force bash even if invoked via sh +if [ -z "${BASH_VERSION:-}" ]; then exec /usr/bin/env bash "$0" "$@"; fi + +# shellcheck disable=SC1090,SC2155 + +set -euo pipefail + +############################################################################### +# 0. PATHS & CONSTANTS +############################################################################### +# Resolve script directory portably (Linux/macOS/WSL) +_realpath() { + local p="$1" + + if command -v realpath >/dev/null 2>&1; then + realpath "$p" + return 0 + fi + + # GNU readlink + if readlink -f / >/dev/null 2>&1; then + readlink -f "$p" + return 0 + fi + + # macOS with coreutils + if command -v greadlink >/dev/null 2>&1; then + greadlink -f "$p" + return 0 + fi + + # python fallback + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$p" + return 0 + fi + + # last resort: absolute physical dir + basename + local d b + d="$(cd -P -- "$(dirname -- "$p")" 2>/dev/null && pwd -P)" || return 1 + b="$(basename -- "$p")" + printf '%s/%s\n' "$d" "$b" +} + +DIR="$(dirname -- "$(_realpath "$0")")" +CFG="$DIR/docker" +ENV_MAIN="$DIR/.env" +ENV_DOCKER="$CFG/.env" +COMPOSE_FILE="$CFG/compose/main.yaml" +EXTRAS_DIR="$CFG/extras" + +if [[ "${1:-}" == "--__win_workdir" ]]; then + export WORKDIR_WIN="${2:-}" + shift 2 +fi + +COLOR() { printf '\033[%sm' "$1"; } +RED=$(COLOR '0;31') GREEN=$(COLOR '0;32') CYAN=$(COLOR '0;36') +YELLOW=$(COLOR '1;33') BLUE=$(COLOR '0;34') MAGENTA=$(COLOR '0;35') +NC=$(COLOR '0') + +# Default behavior: QUIET +VERBOSE=0 + +#─────────────────────────────────────────────────────────────────────────────── +# 0. GLOBAL ERROR HANDLER +#─────────────────────────────────────────────────────────────────────────────── +command_not_found_handle() { + local unknown="$1" + [[ $unknown == cmd_* ]] && unknown=${unknown#cmd_} + printf "\n%bError:%b Unknown command '%b'\n\n" "$RED" "$NC" "$unknown" + cmd_help + exit 1 +} + +trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR +on_error() { + printf "\n%bError:%b '%s' failed at line %d (exit %d)\n\n" \ + "$RED" "$NC" "$3" "$2" "$1" + exit "$1" +} + +############################################################################### +# 1. COMMON HELPERS +############################################################################### +die() { + printf "%bError:%b %s\n" "$RED" "$NC" "$*" + exit 1 +} + +need() { + local group found cmd + for group in "$@"; do + IFS='|,' read -ra alts <<<"$group" + found=0 + for cmd in "${alts[@]}"; do + command -v "$cmd" &>/dev/null && { + found=1 + break + } + done + ((found)) && continue + local miss=${alts[*]} + miss=${miss// / or } + die "Missing command(s): $miss" + done +} + +ensure_files_exist() { + local rel abs dir + for rel in "$@"; do + abs="${DIR}${rel}" + dir="${abs%/*}" + + if [[ ! -d $dir ]]; then + if mkdir -p "$dir" 2>/dev/null; then + printf "%b- Created directory %s%b\n" "$YELLOW" "$dir" "$NC" + else + printf "%b- Warning:%b cannot create directory %s (permissions?)\n" \ + "$YELLOW" "$NC" "$dir" + continue + fi + elif [[ ! -w $dir ]]; then + printf "%b- Warning:%b directory not writable: %s\n" "$YELLOW" "$NC" "$dir" + fi + + if [[ -e $abs ]]; then + [[ -w $abs ]] || printf "%b- Warning:%b file not writable: %s\n" "$YELLOW" "$NC" "$abs" + else + if : >"$abs" 2>/dev/null; then + printf "%b- Created file %s%b\n" "$YELLOW" "$abs" "$NC" + else + printf "%b- Error:%b cannot create file %s (permissions?)\n" "$RED" "$NC" "$abs" + fi + fi + done +} + +# ── compose extras (docker/extras/*.y{a,}ml) ──────────────────────────────── +__EXTRAS_LOADED=0 +declare -a __EXTRA_FILES=() + +load_extras() { + ((__EXTRAS_LOADED)) && return 0 + __EXTRAS_LOADED=1 + + [[ -d "$EXTRAS_DIR" ]] || return 0 + + # Stable ordering: later -f overrides earlier. + mapfile -t __EXTRA_FILES < <( + find "$EXTRAS_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) 2>/dev/null | sort + ) +} + +docker_compose() { + load_extras + + local -a files=() + if ((${#__EXTRA_FILES[@]})); then + local f + for f in "${__EXTRA_FILES[@]}"; do + files+=(-f "$f") + done + fi + + # Prefer Docker Compose v2 ("docker compose"), fallback to v1 ("docker-compose") + local -a dc=(docker compose) + if ! docker compose version >/dev/null 2>&1; then + dc=(docker-compose) + fi + + "${dc[@]}" \ + --project-directory "$DIR" \ + -f "$COMPOSE_FILE" \ + "${files[@]}" \ + --env-file "$ENV_DOCKER" \ + "$@" +} + +# ── docker compose wrappers (QUIET by default) ──────────────────────────────── +dc_up() { + if ((VERBOSE)); then + docker_compose up "$@" + else + docker_compose up --quiet-pull "$@" + fi +} + +dc_pull() { + if ((VERBOSE)); then + docker_compose pull "$@" + else + docker_compose pull -q "$@" + fi +} + +dc_build() { + if ((VERBOSE)); then + docker_compose build "$@" + else + docker_compose build --quiet "$@" + fi +} + +# helper for our own minimal logging (still shows in quiet mode) +logv() { ((VERBOSE)) && printf "%b[%s]%b %s\n" "$CYAN" "${1:-info}" "$NC" "${2:-}" >&2 || true; } +logq() { printf "%b[%s]%b %s\n" "$CYAN" "${1:-info}" "$NC" "${2:-}" >&2; } + +# Unified prompt helper (used by env_init + profiles) +read_default() { + local prompt=$1 default=$2 input + read -rp "$(printf '%b%s [default: %s]:%b ' "$CYAN" "$prompt" "$default" "$NC")" input + printf '%s' "${input:-$default}" +} + +ask_yes() { + local prompt="$1" ans + read -rp "$(printf "%b%s (y/n): %b" "$BLUE" "$prompt" "$NC")" ans + [[ "${ans,,}" == "y" ]] +} + +# ── dotenv quoting: only quote when needed (spaces, tabs, #, quotes, leading/trailing whitespace) ── +env_quote() { + # Wrap in double-quotes and escape backslash + double-quote + newlines + local s=${1-} + s=${s//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\n'/\\n} + printf '"%s"' "$s" +} + +env_quote_if_needed() { + local v=${1-} + + # Already quoted (single or double) => keep as-is + if [[ "$v" =~ ^\".*\"$ || "$v" =~ ^\'.*\'$ ]]; then + printf '%s' "$v" + return 0 + fi + + # Leading/trailing whitespace or any internal whitespace or # or quotes => quote + if [[ "$v" =~ ^[[:space:]] || "$v" =~ [[:space:]]$ || "$v" == *$'\t'* || "$v" == *" "* || "$v" == *"#"* || "$v" == *"\""* ]]; then + env_quote "$v" + return 0 + fi + + printf '%s' "$v" +} + +# Escape replacement for sed (delimiter '|') +sed_escape_repl() { + local s=${1-} + s=${s//\\/\\\\} + s=${s//&/\\&} + s=${s//|/\\|} + printf '%s' "$s" +} + +update_env() { + local file=$1 var=$2 val=${3-} + mkdir -p "$(dirname "$file")" + [[ -f "$file" ]] || { + printf "%bFile '%s' not found. Creating one.%b\n" "$YELLOW" "$file" "$NC" + : >"$file" + } + + # Apply quoting only when needed (spaces etc.) + val="$(env_quote_if_needed "$val")" + + # Sed-safe replacement + local val_sed + val_sed="$(sed_escape_repl "$val")" + + var=$(echo "$var" | sed 's/[]\/$*.^|[]/\\&/g') + if grep -qE "^[# ]*$var=" "$file" 2>/dev/null; then + sed -Ei "s|^[# ]*($var)=.*|\1=$val_sed|" "$file" + else + printf "%s=%s\n" "$var" "$val" >>"$file" + fi +} + +http_reload() { + printf "%bReloading HTTP...%b" "$MAGENTA" "$NC" + docker ps -qf name=NGINX &>/dev/null && docker exec NGINX nginx -s reload &>/dev/null || true + docker ps -qf name=APACHE &>/dev/null && docker exec APACHE apachectl graceful &>/dev/null || true + printf "\r%bHTTP reloaded! %b\n" "$GREEN" "$NC" +} + +############################################################################### +# 2. PERMISSIONS FIX-UP +############################################################################### +fix_perms() { + [[ "$OSTYPE" =~ (msys|cygwin) ]] && return + ((EUID == 0)) || die "Please run with sudo." + + chmod 755 "$DIR" + chmod 2775 "$DIR/configuration" + find "$DIR/configuration" -type f ! -perm 664 -exec chmod 664 {} + + + chmod 755 "$DIR/docker" + find "$DIR/docker" -type f ! -perm 644 -exec chmod 644 {} + + + chmod 2777 "$DIR/data" + mkdir -p "$DIR/data/cloudbeaver" "$DIR/data/mysql" "$DIR/data/postgresql" "$DIR/data/mongo" \ + "$DIR/data/mariadb" "$DIR/data/elasticsearch" "$DIR/data/redis" "$DIR/data/mailpit" \ + "$DIR/data/redis-insight" "$DIR/data/kibana" + find "$DIR/data" -mindepth 1 -maxdepth 1 -type d -exec chmod 2777 {} + + find "$DIR/data" -type f -exec chmod 666 {} + + + chmod -R 777 "$DIR/logs" + chown -R "$USER:docker" "$DIR/logs" + + chmod 755 "$DIR/bin" + find "$DIR/bin" -type f -exec chmod +x {} + + chmod +x "$DIR/server" + + ln -fs "$DIR/server" /usr/local/bin/server + printf "%bPermissions assigned.%b\n" "$GREEN" "$NC" +} + +############################################################################### +# 3. DOMAIN & PROFILE UTILITIES +############################################################################### +mkhost() { docker exec SERVER_TOOLS mkhost "$@"; } +delhost() { docker exec SERVER_TOOLS delhost "$@"; } + +setup_domain() { + mkhost --RESET + docker exec -it SERVER_TOOLS mkhost + local php_prof svr_prof node_prof + php_prof=$(mkhost --ACTIVE_PHP_PROFILE || true) + svr_prof=$(mkhost --APACHE_ACTIVE || true) + node_prof=$(mkhost --ACTIVE_NODE_PROFILE || true) + [[ -n $php_prof ]] && modify_profiles add "$php_prof" + [[ -n $svr_prof ]] && modify_profiles add "$svr_prof" + [[ -n $node_prof ]] && modify_profiles add "$node_prof" + mkhost --RESET + dc_up -d + http_reload +} + +cmd_delhost() { + local domain="${1:-}" + [[ -n "$domain" ]] || die "Usage: server delhost " + + delhost "$domain" + delhost --RESET >/dev/null 2>&1 || true + + http_reload +} + +modify_profiles() { + local action=$1 + shift + local file=$ENV_DOCKER var=COMPOSE_PROFILES + local -a existing updated + + if [[ -r $file ]]; then + local line value + line=$(grep -E "^${var}=" "$file" | tail -n1 || true) + value=${line#*=} + IFS=',' read -r -a existing <<<"$value" + fi + + case $action in + add) + local p + for p; do + [[ -n $p && ! " ${existing[*]} " =~ " $p " ]] && updated+=("$p") + done + updated+=("${existing[@]}") + ;; + remove) + local old + for old in "${existing[@]}"; do + [[ ! " $* " =~ " $old " ]] && updated+=("$old") + done + ;; + *) die "modify_profiles: invalid action '$action'" ;; + esac + + update_env "$file" "$var" "$( + IFS=, + echo "${updated[*]}" + )" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Profiles (merged from docker/utilities/profiles) +# ───────────────────────────────────────────────────────────────────────────── + +declare -A SERVICES=( + [ELASTICSEARCH]="elasticsearch" + [MYSQL]="mysql" + [MARIADB]="mariadb" + [MONGODB]="mongodb" + [REDIS]="redis" + [POSTGRESQL]="postgresql" +) + +declare -a SERVICE_ORDER=(ELASTICSEARCH MYSQL MARIADB MONGODB REDIS POSTGRESQL) + +declare -A PROFILE_ENV=( + [elasticsearch]="ELASTICSEARCH_VERSION=9.2.4 ELASTICSEARCH_PORT=9200" + [mysql]="MYSQL_VERSION=latest MYSQL_PORT=3306 MYSQL_ROOT_PASSWORD=12345 MYSQL_USER=infocyph MYSQL_PASSWORD=12345 MYSQL_DATABASE=localdb" + [mariadb]="MARIADB_VERSION=latest MARIADB_PORT=3306 MARIADB_ROOT_PASSWORD=12345 MARIADB_USER=infocyph MARIADB_PASSWORD=12345 MARIADB_DATABASE=localdb" + [mongodb]="MONGODB_VERSION=latest MONGODB_PORT=27017 MONGODB_ROOT_USERNAME=root MONGODB_ROOT_PASSWORD=12345" + [redis]="REDIS_VERSION=latest REDIS_PORT=6379" + [postgresql]="POSTGRES_VERSION=latest POSTGRES_PORT=5432 POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DATABASE=postgres" +) + +declare -a PENDING_ENVS=() +declare -a PENDING_PROFILES=() + +queue_env() { PENDING_ENVS+=("$1"); } +queue_profile() { PENDING_PROFILES+=("$1"); } + +flush_envs() { + local env_file="$ENV_DOCKER" kv key val + for kv in "${PENDING_ENVS[@]}"; do + IFS='=' read -r key val <<<"$kv" + update_env "$env_file" "$key" "$val" + done +} + +flush_profiles() { + local profile + for profile in "${PENDING_PROFILES[@]}"; do + modify_profiles add "$profile" + done +} + +process_service() { + local service="$1" + printf "\n%b→ %s%b\n" "$YELLOW" "$service" "$NC" + ask_yes "Enable $service?" || { + printf "%bSkipping %s%b\n" "$RED" "$service" "$NC" + return + } + + local profile="${SERVICES[$service]}" + queue_profile "$profile" + + printf "%bEnter value(s) for %s:%b\n" "$BLUE" "$service" "$NC" + local pair key def val + for pair in ${PROFILE_ENV[$profile]}; do + IFS='=' read -r key def <<<"$pair" + val=$(read_default "$key" "$def") + queue_env "$key=$val" + done +} + +process_all() { + local svc + for svc in "${SERVICE_ORDER[@]}"; do + process_service "$svc" + done + flush_envs + flush_profiles + printf "\n%b✅ All services configured!%b\n" "$GREEN" "$NC" +} + +############################################################################### +# 4a. LAUNCH PHP CONTAINER INSIDE DOCROOT +############################################################################### +launch_php() { + local domain=$1 suffix + local nconf="$DIR/configuration/nginx/$domain.conf" + local aconf="$DIR/configuration/apache/$domain.conf" + [[ -f $nconf ]] || die "No Nginx config for $domain" + + local docroot php + if grep -q fastcgi_pass "$nconf"; then + php=$(grep -Eo 'fastcgi_pass ([^:]+):9000' "$nconf" | awk '{print $2}' | sed 's/:9000$//') + docroot=$(grep -m1 -Eo 'root [^;]+' "$nconf" | awk '{print $2}') + else + [[ -f $aconf ]] || die "No Apache config for $domain" + docroot=$(grep -m1 -Eo 'DocumentRoot [^ ]+' "$aconf" | awk '{print $2}') + php=$(grep -Eo 'proxy:fcgi://([^:]+):9000' "$aconf" | sed 's/.*:\/\/\([^:]*\):.*/\1/') + fi + + [[ $php ]] || die "Could not detect PHP container for $domain" + [[ $docroot ]] || docroot=/app + for suffix in public dist public_html; do + [[ $docroot == */$suffix ]] && { + docroot=${docroot%/*} + break + } + done + + php=$(echo "$php" | tr ' \n' '\n' | awk 'NF && !seen[$0]++' | paste -sd' ' -) + docker exec -it "$php" bash --login -c "cd '$docroot' && exec bash" +} + +############################################################################### +# 4b. LAUNCH NODE CONTAINER (always /app) +############################################################################### +launch_node() { + local domain="${1:-}" + [[ -n "$domain" ]] || die "Usage: server core " + + local nconf="$DIR/configuration/nginx/$domain.conf" + [[ -f "$nconf" ]] || die "No Nginx config for $domain" + + # Expect: proxy_pass http://node_:; + local upstream host token ctr + upstream="$( + grep -m1 -Eo 'proxy_pass[[:space:]]+http://[^;]+' "$nconf" 2>/dev/null | + awk '{print $2}' | + sed 's|^http://||' + )" + + [[ -n "${upstream:-}" ]] || die "Could not detect node upstream for $domain" + host="${upstream%%:*}" # node_resume_sparkle_localhost + + [[ -n "${host:-}" ]] || die "Could not parse upstream host for $domain" + + # Standard mapping: node_ -> NODE_ + ctr="" + if docker inspect "$host" >/dev/null 2>&1; then + ctr="$host" + elif [[ "$host" == node_* ]]; then + token="${host#node_}" + ctr="NODE_${token^^}" + docker inspect "$ctr" >/dev/null 2>&1 || ctr="" + fi + + [[ -n "${ctr:-}" ]] || die "Node container not found for upstream '$host' (domain: $domain)" + docker inspect -f '{{.State.Running}}' "$ctr" 2>/dev/null | grep -qx true || die "Container not running: $ctr" + + docker exec -it "$ctr" sh -lc ' + cd /app 2>/dev/null || cd / || true + if command -v bash >/dev/null 2>&1; then exec bash --login; fi + exec sh + ' +} + +conf_node_container() { + local f="$1" + + # Nginx node vhost: proxy_pass http://node_:; + local host token ctr + + host="$( + grep -m1 -Eo 'proxy_pass[[:space:]]+http://[^;]+' "$f" 2>/dev/null | + awk '{print $2}' | + sed 's|^http://||' | + awk -F: '{print $1}' + )" + + [[ -n "${host:-}" ]] || return 0 + [[ "$host" == node_* ]] || return 0 + + token="${host#node_}" + ctr="NODE_${token^^}" + + docker inspect "$ctr" >/dev/null 2>&1 || return 0 + printf '%s' "$ctr" +} + +############################################################################### +# 5. ENV + CERT +############################################################################### +detect_timezone() { + if command -v timedatectl &>/dev/null; then + timedatectl show -p Timezone --value + elif [[ -n ${TZ-} ]]; then + printf '%s' "$TZ" + elif [[ -r /etc/timezone ]]; then + /dev/null; then + powershell.exe -NoProfile -Command "[System.TimeZoneInfo]::Local.Id" 2>/dev/null | tr -d '\r' + else + date +%Z + fi +} + +env_init() { + local env_file="$ENV_DOCKER" + printf "%bBootstrapping environment defaults…%b\n" "$YELLOW" "$NC" + + local default_tz tz + default_tz="$(detect_timezone)" + tz="$(read_default "Timezone (TZ)" "$default_tz")" + + local default_git_name default_git_email git_name git_email + default_git_name="$(git config --global --get user.name 2>/dev/null || true)" + default_git_email="$(git config --global --get user.email 2>/dev/null || true)" + git_name="$(read_default "Git user.name (GIT_USER_NAME)" "$default_git_name")" + git_email="$(read_default "Git user.email (GIT_USER_EMAIL)" "$default_git_email")" + + # update_env now quotes automatically when needed + update_env "$env_file" "TZ" "$tz" + update_env "$env_file" "GIT_USER_NAME" "$git_name" + update_env "$env_file" "GIT_USER_EMAIL" "$git_email" + + printf "%bConfiguration saved!%b\n" "$GREEN" "$NC" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Root CA helpers (cross-distro) +# ───────────────────────────────────────────────────────────────────────────── +detect_os_family() { + local os_id="unknown" os_like="unknown" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + . /etc/os-release + os_id="${ID:-unknown}" + os_like="${ID_LIKE:-unknown}" + fi + printf '%s|%s +' "$os_id" "$os_like" +} + +# Decide destination path + update mechanism. Echo: family|dest|updater +ca_plan() { + local os_id os_like + IFS='|' read -r os_id os_like < <(detect_os_family) + + case " $os_id $os_like " in + *" debian "* | *" ubuntu "* | *" linuxmint "* | *" pop "* | *" raspbian "*) + printf 'debian|/usr/local/share/ca-certificates/rootCA.crt|update-ca-certificates +' + ;; + *" alpine "*) + printf 'alpine|/usr/local/share/ca-certificates/rootCA.crt|update-ca-certificates +' + ;; + *" fedora "* | *" rhel "* | *" redhat "* | *" centos "* | *" rocky "* | *" alma "* | *" amzn "* | *" amazon "* | *" sles "* | *" suse "*) + printf 'rhel|/etc/pki/ca-trust/source/anchors/rootCA.crt|update-ca-trust +' + ;; + *" arch "* | *" manjaro "*) + printf 'arch|/etc/ca-certificates/trust-source/anchors/rootCA.crt|trust +' + ;; + *) + # best default: Debian-style location (works on many distros even if updater differs) + printf 'fallback|/usr/local/share/ca-certificates/rootCA.crt| +' + ;; + esac +} + +install_ca() { + local src_ca="$DIR/configuration/rootCA/rootCA.pem" + [[ ${EUID:-$(id -u)} -eq 0 ]] || die "certificate install requires sudo" + [[ -r "$src_ca" ]] || die "certificate not found: $src_ca" + + local family dest updater os_id os_like + IFS='|' read -r os_id os_like < <(detect_os_family) + IFS='|' read -r family dest updater < <(ca_plan) + + printf "%bInstalling root CA…%b +" "$CYAN" "$NC" + printf "%bDetected OS%b: id=%s like=%s → %s +" "$CYAN" "$NC" "$os_id" "$os_like" "$family" + + install -d -m 755 "$(dirname "$dest")" + install -m 644 "$src_ca" "$dest" + printf "%b✔ Copied%b → %s +" "$GREEN" "$NC" "$dest" + + case "$family" in + debian | alpine) + if command -v update-ca-certificates >/dev/null 2>&1; then + printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" + if update-ca-certificates; then + printf "%b✔ Trust store updated%b +" "$GREEN" "$NC" + printf "%bNote:%b If you see \"rehash: skipping ca-certificates.crt…\", that’s normal (it’s a bundle). +" "$YELLOW" "$NC" + else + printf "%bWARN%b: update-ca-certificates failed. CA is installed but may not be active yet. +" "$YELLOW" "$NC" >&2 + fi + else + printf "%bWARN%b: update-ca-certificates not found. CA is installed but auto-update is unavailable. +" "$YELLOW" "$NC" >&2 + fi + + # Optional p11-kit sync: best-effort only (can be missing helper on minimal installs) + if command -v trust >/dev/null 2>&1; then + printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" + if trust extract-compat >/dev/null 2>&1; then + printf "%b✔ p11-kit trust synced%b +" "$GREEN" "$NC" + else + printf "%bWARN%b: trust extract-compat failed (helper missing on some installs). Skipping. +" "$YELLOW" "$NC" >&2 + fi + else + printf "%bINFO%b: 'trust' not found — skipping p11-kit sync. +" "$YELLOW" "$NC" + fi + ;; + rhel) + if command -v update-ca-trust >/dev/null 2>&1; then + printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" + if update-ca-trust extract; then + printf "%b✔ Trust store updated%b +" "$GREEN" "$NC" + else + printf "%bWARN%b: update-ca-trust extract failed. CA is installed but may not be active yet. +" "$YELLOW" "$NC" >&2 + fi + else + printf "%bWARN%b: update-ca-trust not found. CA is installed but auto-update is unavailable. +" "$YELLOW" "$NC" >&2 + fi + ;; + arch) + if command -v trust >/dev/null 2>&1; then + printf "%bUpdating trust store%b (trust extract-compat)…\n" "$CYAN" "$NC" + if trust extract-compat >/dev/null 2>&1; then + printf "%b✔ Trust store updated%b +" "$GREEN" "$NC" + else + printf "%bWARN%b: trust extract-compat failed. CA is installed, but trust sync may be incomplete. +" "$YELLOW" "$NC" >&2 + fi + else + printf "%bWARN%b: 'trust' not found. CA is installed, but trust sync is unavailable. +" "$YELLOW" "$NC" >&2 + fi + ;; + *) + printf "%bINFO%b: Unknown distro; CA copied to %s. +" "$YELLOW" "$NC" "$dest" + printf "%bINFO%b: You may need to update trust store manually for your OS. +" "$YELLOW" "$NC" + ;; + esac + + printf "%bRoot CA installed%b → %s +" "$GREEN" "$NC" "$dest" +} + +uninstall_ca() { + [[ ${EUID:-$(id -u)} -eq 0 ]] || die "certificate uninstall requires sudo" + + local all=0 + if [[ "${1:-}" == "--all" ]]; then + all=1 + shift + fi + + local family dest updater os_id os_like + IFS='|' read -r os_id os_like < <(detect_os_family) + IFS='|' read -r family dest updater < <(ca_plan) + + printf "%bUninstalling root CA…%b +" "$CYAN" "$NC" + printf "%bDetected OS%b: id=%s like=%s → %s +" "$CYAN" "$NC" "$os_id" "$os_like" "$family" + + # Always remove the planned destination first + local removed=0 + if [[ -e "$dest" ]]; then + rm -f "$dest" + removed=$((removed + 1)) + printf "%b✔ Removed%b → %s +" "$GREEN" "$NC" "$dest" + else + printf "%bINFO%b: CA file not found at %s (nothing to remove) +" "$YELLOW" "$NC" "$dest" + fi + + # Optional: remove from all common anchor locations (for people who switched distros/paths) + if ((all)); then + printf "%bScanning all known CA anchor paths…%b +" "$CYAN" "$NC" + local f + for f in \ + /usr/local/share/ca-certificates/rootCA.crt \ + /usr/local/share/ca-certificates/rootCA.pem \ + /etc/pki/ca-trust/source/anchors/rootCA.crt \ + /etc/pki/ca-trust/source/anchors/rootCA.pem \ + /etc/ca-certificates/trust-source/anchors/rootCA.crt \ + /etc/ca-certificates/trust-source/anchors/rootCA.pem; do + [[ "$f" == "$dest" ]] && continue + if [[ -e "$f" ]]; then + rm -f "$f" + removed=$((removed + 1)) + printf "%b✔ Removed%b → %s +" "$GREEN" "$NC" "$f" + fi + done + fi + + # Refresh trust store (best-effort, do not fail uninstall) + case "$family" in + debian | alpine) + if command -v update-ca-certificates >/dev/null 2>&1; then + printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" + update-ca-certificates || printf "%bWARN%b: update-ca-certificates failed. +" "$YELLOW" "$NC" >&2 + else + printf "%bWARN%b: update-ca-certificates not found; trust store not refreshed. +" "$YELLOW" "$NC" >&2 + fi + + # Optional p11-kit sync: best-effort only + if command -v trust >/dev/null 2>&1; then + printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" + trust extract-compat >/dev/null 2>&1 || printf "%bWARN%b: trust extract-compat failed. Skipping. +" "$YELLOW" "$NC" >&2 + fi + ;; + rhel) + if command -v update-ca-trust >/dev/null 2>&1; then + printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" + update-ca-trust extract || printf "%bWARN%b: update-ca-trust extract failed. +" "$YELLOW" "$NC" >&2 + else + printf "%bWARN%b: update-ca-trust not found; trust store not refreshed. +" "$YELLOW" "$NC" >&2 + fi + ;; + arch) + if command -v trust >/dev/null 2>&1; then + printf "%bUpdating trust store%b (trust extract-compat)…\n" "$CYAN" "$NC" + trust extract-compat >/dev/null 2>&1 || printf "%bWARN%b: trust extract-compat failed. +" "$YELLOW" "$NC" >&2 + else + printf "%bWARN%b: 'trust' not found; trust store not refreshed. +" "$YELLOW" "$NC" >&2 + fi + ;; + *) + # If unknown family, try what exists + if command -v update-ca-certificates >/dev/null 2>&1; then + printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" + update-ca-certificates || true + fi + if command -v update-ca-trust >/dev/null 2>&1; then + printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" + update-ca-trust extract || true + fi + if command -v trust >/dev/null 2>&1; then + printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" + trust extract-compat >/dev/null 2>&1 || true + fi + printf "%bINFO%b: Unknown distro; removed CA file(s) if present. Refresh trust store manually if needed. +" "$YELLOW" "$NC" + ;; + esac + + if ((removed)); then + printf "%bRoot CA uninstalled%b (removed %d file(s)) +" "$GREEN" "$NC" "$removed" + else + printf "%bRoot CA already absent%b (no files removed) +" "$YELLOW" "$NC" + fi +} + +add_required_env() { + update_env "$ENV_DOCKER" WORKING_DIR "$DIR" + ((EUID == 0)) && return 0 + update_env "$ENV_DOCKER" USER "$(id -un)" + update_env "$ENV_DOCKER" UID "$(id -u)" + update_env "$ENV_DOCKER" GID "$(id -g)" +} + +############################################################################### +# Compose helpers for rebuild (robust: supports service key OR container name) +############################################################################### +__COMPOSE_CFG_JSON="" +__COMPOSE_CFG_YAML="" +__COMPOSE_SVCS_LOADED=0 +declare -a __COMPOSE_SVCS=() + +compose_cfg_json() { + if [[ -z "${__COMPOSE_CFG_JSON}" ]]; then + __COMPOSE_CFG_JSON="$(docker_compose config --format json 2>/dev/null || true)" + fi + printf '%s' "${__COMPOSE_CFG_JSON}" +} + +compose_cfg_yaml() { + if [[ -z "${__COMPOSE_CFG_YAML}" ]]; then + __COMPOSE_CFG_YAML="$(docker_compose config 2>/dev/null || true)" + fi + printf '%s' "${__COMPOSE_CFG_YAML}" +} + +compose_services_load() { + ((__COMPOSE_SVCS_LOADED)) && return 0 + mapfile -t __COMPOSE_SVCS < <(docker_compose config --services 2>/dev/null || true) + __COMPOSE_SVCS_LOADED=1 +} + +compose_service_exists() { + local want="${1:-}" s + [[ -n "$want" ]] || return 1 + compose_services_load + for s in "${__COMPOSE_SVCS[@]}"; do + [[ "$s" == "$want" ]] && return 0 + done + return 1 +} + +resolve_service() { + local raw="${1:-}" norm svc + raw="${raw//[[:space:]]/}" + [[ -n "$raw" ]] || { + printf '' + return 0 + } + + compose_service_exists "$raw" && { + printf '%s' "$raw" + return 0 + } + + norm="$(normalize_service "$raw")" + compose_service_exists "$norm" && { + printf '%s' "$norm" + return 0 + } + + if docker inspect "$raw" >/dev/null 2>&1; then + svc="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.service" }}' "$raw" 2>/dev/null || true)" + if [[ -n "$svc" ]] && compose_service_exists "$svc"; then + printf '%s' "$svc" + return 0 + fi + fi + + printf '%s' "$norm" +} + +compose_has_build() { + local svc="$1" json + json="$(compose_cfg_json)" + if [[ -n "$json" ]]; then + if command -v jq >/dev/null 2>&1; then + jq -e --arg s "$svc" '.services[$s].build != null' >/dev/null <<<"$json" + return $? + fi + if command -v python3 >/dev/null 2>&1; then + COMPOSE_CFG_JSON="$json" python3 - "$svc" <<'PY' +import json, os, sys +svc = sys.argv[1] +cfg = json.loads(os.environ.get("COMPOSE_CFG_JSON", "") or "{}") +sys.exit(0 if cfg.get("services", {}).get(svc, {}).get("build") is not None else 1) +PY + return $? + fi + fi + + compose_cfg_yaml | awk -v s="$svc" ' + $1=="services:" {in_services=1; next} + in_services && $0 ~ ("^ " s ":$") {in_svc=1; next} + in_svc && $0 ~ /^ [A-Za-z0-9_.-]+:$/ {exit 1} + in_svc && $0 ~ /^ build:/ {exit 0} + END {exit 1} + ' +} + +compose_image_for_service() { + local svc="$1" json + json="$(compose_cfg_json)" + if [[ -n "$json" ]]; then + if command -v jq >/dev/null 2>&1; then + jq -r --arg s "$svc" '.services[$s].image // empty' <<<"$json" + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + COMPOSE_CFG_JSON="$json" python3 - "$svc" <<'PY' +import json, os, sys +svc = sys.argv[1] +cfg = json.loads(os.environ.get("COMPOSE_CFG_JSON", "") or "{}") +print(cfg.get("services", {}).get(svc, {}).get("image", "") or "") +PY + return 0 + fi + fi + + compose_cfg_yaml | awk -v s="$svc" ' + $1=="services:" {in_services=1; next} + in_services && $0 ~ ("^ " s ":$") {in_svc=1; next} + in_svc && $0 ~ /^ [A-Za-z0-9_.-]+:$/ {exit 0} + in_svc && $0 ~ /^ image:/ { + sub(/^ image:[[:space:]]*/, "", $0) + print $0 + exit 0 + } + ' +} + +############################################################################### +# 6. COMMANDS +############################################################################### +cmd_up() { dc_up "$@"; } + +cmd_start() { + dc_up -d "$@" + http_reload +} + +cmd_reload() { cmd_start "$@"; } +cmd_stop() { docker_compose down; } +cmd_down() { cmd_stop; } +cmd_restart() { + cmd_stop + cmd_start +} +cmd_reboot() { cmd_restart; } + +normalize_service() { + local raw="${1:-}" + local s="${raw//[[:space:]]/}" + [[ -n "$s" ]] || { + printf '%s' "" + return 0 + } + + local low="${s,,}" + + local key="${low//_/}" + key="${key//-/}" + if [[ "$key" =~ ^php ]]; then + local ver="${key#php}" + ver="${ver//[^0-9]/}" + if [[ "$ver" =~ ^([0-9])([0-9]).* ]]; then + printf 'php%s%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + return 0 + fi + printf 'php' + return 0 + fi + + low="${low//_/-}" + while [[ "$low" == *"--"* ]]; do low="${low//--/-}"; done + printf '%s' "$low" +} + +cmd_rebuild() { + local -a targets=() all_svcs=() + local arg svc img + declare -A seen=() + + # ----------------------------- + # helper: add a service once + # ----------------------------- + _add_target() { + local s="$1" + [[ -n "$s" ]] || return 0 + [[ -n "${seen[$s]:-}" ]] && return 0 + seen[$s]=1 + targets+=("$s") + } + + # ----------------------------- + # helper: trim + # ----------------------------- + _trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" + } + + # ----------------------------- + # helper: interactive selection (comma separated, supports ranges) + # accepts: "all" or "1,3,5-7" or mix with names "nginx,2,5-6" + # ----------------------------- + _pick_targets_interactive() { + mapfile -t all_svcs < <(docker_compose config --services 2>/dev/null) + [[ ${#all_svcs[@]} -gt 0 ]] || die "No services found (docker compose config --services failed?)" + + echo + echo "Select services to rebuild (comma separated; ranges allowed)." + echo "Examples: 1,3,5-7 | nginx,2,5-6 | all" + echo + + local i + for i in "${!all_svcs[@]}"; do + printf " %2d) %s\n" "$((i+1))" "${all_svcs[$i]}" + done + + echo + local sel + read -r -p "Pick: " sel + sel="$(_trim "${sel:-}")" + [[ -n "$sel" ]] || die "No selection provided." + + if [[ "${sel,,}" == "all" ]]; then + for svc in "${all_svcs[@]}"; do _add_target "$svc"; done + return 0 + fi + + # split by comma + local IFS=, + for arg in $sel; do + arg="$(_trim "$arg")" + [[ -n "$arg" ]] || continue + + # range like 3-7 + if [[ "$arg" =~ ^[0-9]+-[0-9]+$ ]]; then + local a b + a="${arg%-*}"; b="${arg#*-}" + (( a >= 1 )) || continue + (( b >= 1 )) || continue + (( a <= b )) || { local t="$a"; a="$b"; b="$t"; } + + local n + for ((n=a; n<=b; n++)); do + (( n >= 1 && n <= ${#all_svcs[@]} )) || continue + _add_target "${all_svcs[$((n-1))]}" + done + continue + fi + + # single index + if [[ "$arg" =~ ^[0-9]+$ ]]; then + local n="$arg" + (( n >= 1 && n <= ${#all_svcs[@]} )) || continue + _add_target "${all_svcs[$((n-1))]}" + continue + fi + + # treat as service/container name + svc="$(resolve_service "$arg")" + [[ -n "$svc" ]] && _add_target "$svc" + done + + [[ ${#targets[@]} -gt 0 ]] || die "No valid services selected." + } + + # ----------------------------- + # build target list + # ----------------------------- + if (($# == 0)); then + _pick_targets_interactive + elif [[ "${1,,}" == "all" ]]; then + mapfile -t targets < <(docker_compose config --services 2>/dev/null) + [[ ${#targets[@]} -gt 0 ]] || die "No services found (docker compose config --services failed?)" + else + for arg in "$@"; do + svc="$(resolve_service "$arg")" + [[ -n "$svc" ]] || continue + _add_target "$svc" + done + [[ ${#targets[@]} -gt 0 ]] || die "No valid services provided." + fi + + # ----------------------------- + # rebuild each target + # ----------------------------- + for svc in "${targets[@]}"; do + [[ -n "$svc" ]] || continue + compose_service_exists "$svc" || die "Unknown service/container: '$svc'" + + if compose_has_build "$svc"; then + logq rebuild "build/recreate $svc" + dc_build --no-cache --pull "$svc" + dc_up -d --no-deps --force-recreate "$svc" + continue + fi + + img="$(compose_image_for_service "$svc")" + logq rebuild "pull/recreate $svc${img:+ ($img)}" + + docker_compose rm -sf "$svc" >/dev/null 2>&1 || true + + if [[ -n "${img:-}" ]]; then + docker rmi -f "$img" >/dev/null 2>&1 || true + dc_pull "$svc" || true + else + dc_build --no-cache --pull "$svc" >/dev/null 2>&1 || true + fi + + dc_up -d --no-deps --force-recreate "$svc" + done + dc_up -d +} + +cmd_config() { docker_compose config; } + +docker_shell() { + local c="${1:-}" + [[ -n "$c" ]] || die "container name required" + if docker exec "$c" sh -lc 'command -v bash >/dev/null 2>&1' >/dev/null 2>&1; then + exec docker exec -it "$c" bash + else + exec docker exec -it "$c" sh + fi +} +cmd_tools() { docker_shell SERVER_TOOLS; } +cmd_lzd() { docker exec -it SERVER_TOOLS lazydocker; } +cmd_lazydocker() { cmd_lzd; } +cmd_http() { [[ ${1:-} == reload ]] && http_reload; } +cmd_cli() { + local ctr="${1:-}" + shift || true + + [[ -n "$ctr" ]] || die "Usage: server cli [cmd...]" + + docker inspect "$ctr" >/dev/null 2>&1 || die "Container not found: $ctr" + docker inspect -f '{{.State.Running}}' "$ctr" 2>/dev/null | grep -qx true || die "Container not running: $ctr" + + # If user provided a command, run it; otherwise open an interactive shell. + if [[ "$#" -gt 0 ]]; then + local cmd="$*" + docker exec -it "$ctr" sh -lc ' + if command -v bash >/dev/null 2>&1; then + exec bash --login -lc "$1" + fi + exec sh -lc "$1" + ' sh "$cmd" + return + fi + + docker exec -it "$ctr" sh -lc ' + if command -v bash >/dev/null 2>&1; then + exec bash --login + fi + exec sh + ' +} + +cmd_core() { + # Usage: + # server core -> open correct container for that domain (PHP/Node) + # server core -> auto-detect project by current directory and open correct container + + local domain="${1:-}" + if [[ -n "$domain" ]]; then + local nconf="$DIR/configuration/nginx/$domain.conf" + [[ -f "$nconf" ]] || die "No Nginx config for $domain" + + # Node vhost? + if grep -Eq 'proxy_pass[[:space:]]+http://node_[A-Za-z0-9._-]+:[0-9]+' "$nconf"; then + launch_node "$domain" + return 0 + fi + + # Otherwise keep legacy PHP behavior + launch_php "$domain" + return 0 + fi + + core_auto +} + +# ------------------------------------------------------------ +# core auto-detect (host PWD -> /app in php container) +# ------------------------------------------------------------ +core_auto() { + local pwd="${PWD}" + + local working_dir + if [[ -n "${WORKING_DIR:-}" ]]; then + working_dir="${WORKING_DIR}" + elif [[ -d /opt/project/LocalDevStack ]]; then + working_dir="/opt/project/LocalDevStack" + else + working_dir="${DIR}" + fi + + # Compose default: "${PROJECT_DIR:-./../../../application}:/app" + local host_app_root + if [[ -n "${PROJECT_DIR:-}" ]]; then + host_app_root="${PROJECT_DIR}" + else + host_app_root="${working_dir%/}/../application" + fi + + local -a confs=() + local d f + for d in "${working_dir%/}/configuration/nginx" "${working_dir%/}/configuration/apache" "${working_dir%/}/configuration/httpd" "${working_dir%/}/configuration/apache/sites-enabled" "${working_dir%/}/configuration/httpd/sites-enabled"; do + [[ -d "$d" ]] || continue + while IFS= read -r -d "" f; do + confs+=("$f") + done < <(find "$d" -maxdepth 1 -type f -name "*.conf" -print0 2>/dev/null || true) + done + + local best_container="" best_host_root="" best_len=0 + + local php c_src app_path app_root rel host_proj l + for f in "${confs[@]:-}"; do + php="$(conf_php_container "$f")" + if [[ -n "$php" ]]; then + # Real host source for /app from the php container (beats env guesses) + c_src="$(docker_mount_source "$php" "/app")" + [[ -n "$c_src" ]] || c_src="$host_app_root" + + while IFS= read -r app_path; do + [[ -n "$app_path" ]] || continue + app_root="$(normalize_app_root "$app_path")" + + rel="${app_root#/app/}" + host_proj="${c_src%/}/${rel}" + + # Candidate is meaningful only if PWD is inside it + [[ "$pwd" == "$host_proj"* ]] || continue + + l="${#host_proj}" + if ((l > best_len)); then + best_len=$l + best_container="$php" + best_host_root="$host_proj" + fi + done < <(conf_app_paths "$f") + + # fallback: if no /app paths were detected in conf + if [[ -z "$best_container" ]]; then + [[ "$pwd" == "$c_src"* ]] || continue + l="${#c_src}" + if ((l > best_len)); then + best_len=$l + best_container="$php" + best_host_root="$c_src" + fi + fi + + continue + fi + + local node ctr_src + node="$(conf_node_container "$f")" + [[ -n "$node" ]] || continue + + ctr_src="$(docker_mount_source "$node" "/app")" + [[ -n "$ctr_src" ]] || ctr_src="$host_app_root" + + [[ "$pwd" == "$ctr_src"* ]] || continue + + l="${#ctr_src}" + if ((l > best_len)); then + best_len=$l + best_container="$node" + best_host_root="$ctr_src" + fi + done + + # Fallback: if PWD is under /app mount of a running PHP_* container, use it + if [[ -z "$best_container" ]]; then + local cand cand_src + cand="$(docker ps --format "{{.Names}}" | grep -m1 "^PHP_" || true)" + if [[ -n "$cand" ]]; then + cand_src="$(docker_mount_source "$cand" "/app")" + if [[ -n "$cand_src" && "$pwd" == "${cand_src%/}/"* ]]; then + best_container="$cand" + best_host_root="$cand_src" + fi + fi + fi + + [[ -n "$best_container" ]] || die "No matching domain for current directory: $pwd (try: server core )" + + # Map host PWD -> /app path inside container + local rel_path container_path + rel_path="${pwd#${best_host_root%/}/}" + container_path="/app/${rel_path}" + + printf "%b[core]%b %s -> %s:%s +" "$CYAN" "$NC" "$pwd" "$best_container" "$container_path" + + docker exec -it "$best_container" sh -lc "cd \"$container_path\" 2>/dev/null || exit 1; if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi" +} + +conf_php_container() { + local f="$1" + + # 1) Apache: SetHandler "proxy:fcgi://PHP_8.4:9000" + local c="" + c="$(grep -Eo 'proxy:fcgi://[^:/"]+' "$f" 2>/dev/null | head -n1 | sed 's#proxy:fcgi://##')" + [[ -n "$c" ]] && { + printf "%s" "$c" + return 0 + } + + # 2) Nginx: fastcgi_pass PHP_8.4:9000; + c="$(grep -Eo 'fastcgi_pass[[:space:]]+[^;:]+:9000' "$f" 2>/dev/null | head -n1 | awk '{print $2}' | sed 's/:9000$//')" + [[ -n "$c" ]] && { + printf "%s" "$c" + return 0 + } + + # 3) Nginx: proxy_cookie_domain PHP_8.4 local.example; + c="$(grep -Eo 'proxy_cookie_domain[[:space:]]+[^[:space:];]+' "$f" 2>/dev/null | head -n1 | awk '{print $2}')" + [[ -n "$c" ]] && { + printf "%s" "$c" + return 0 + } + + return 0 +} + +conf_app_paths() { + local f="$1" + [[ -r "$f" ]] || return 0 + + LC_ALL=C awk ' + { + s=$0 + while (match(s, /\/app\/[^;[:space:]"'\''<>]+/)) { + print substr(s, RSTART, RLENGTH) + s = substr(s, RSTART + RLENGTH) + } + } + ' "$f" | LC_ALL=C sort -u || true +} + +normalize_app_root() { + local p="$1" + p="${p%/}" + p="${p%/public}" + p="${p%/public_html}" + p="${p%/dist}" + printf "%s" "$p" +} + +docker_mount_source() { + local container="$1" dest="$2" + docker inspect -f '{{range .Mounts}}{{if eq .Destination "'"$dest"'"}}{{.Source}}{{end}}{{end}}' "$container" 2>/dev/null || true +} + +cmd_setup() { + add_required_env + case ${1:-} in + init) env_init ;; + permission | permissions | perms | perm) fix_perms ;; + domain) setup_domain ;; + profiles | profile) process_all ;; + *) die "setup " ;; + esac +} + +cmd_certificate() { + case ${1:-} in + install) + shift || true + install_ca + ;; + uninstall | remove | rm) + shift || true + uninstall_ca "${@:-}" + ;; + *) + die "certificate " + ;; + esac +} + +# Backward-compatible aliases (not documented) +cmd_install() { + # deprecated: use `server certificate install` + cmd_certificate install "${@:-}" +} + +cmd_uninstall() { + # deprecated: use `server certificate uninstall` + cmd_certificate uninstall "${@:-}" +} + +cmd_doctor() { + # Quick environment diagnostics + install hints (best-effort). + local os_id os_like family + IFS='|' read -r os_id os_like < <(detect_os_family) + + printf "%bDoctor%b — environment checks\n" "$MAGENTA" "$NC" + printf "%bOS%b: id=%s like=%s\n" "$CYAN" "$NC" "$os_id" "$os_like" + + _ok() { printf " %b✔%b %s\n" "$GREEN" "$NC" "$*"; } + _warn() { printf " %b!%b %s\n" "$YELLOW" "$NC" "$*"; } + _bad() { printf " %b✘%b %s\n" "$RED" "$NC" "$*"; } + + _has() { command -v "$1" >/dev/null 2>&1; } + + # Docker CLI + daemon + if _has docker; then + _ok "docker: $(docker --version 2>/dev/null || echo 'present')" + if docker info >/dev/null 2>&1; then + _ok "docker daemon: reachable" + else + _bad "docker daemon: not reachable (is Docker running? permissions?)" + fi + else + _bad "docker: missing" + fi + + # Compose + if docker compose version >/dev/null 2>&1; then + _ok "compose: docker compose" + elif _has docker-compose; then + _ok "compose: docker-compose" + else + _bad "compose: missing (need docker compose plugin or docker-compose)" + fi + + # Common tools used by this script + for c in awk sed grep find sort; do + _has "$c" && _ok "$c: present" || _bad "$c: missing" + done + + # Optional-but-valuable tools + _has mkcert && _ok "mkcert: present" || _warn "mkcert: not found (needed for local TLS automation)" + _has openssl && _ok "openssl: present" || _warn "openssl: not found (useful for debugging certs)" + (_has wget || _has curl) && _ok "http client: present" || _warn "http client: missing (wget/curl recommended)" + + # layout checks + [[ -f "$COMPOSE_FILE" ]] && _ok "compose file: $COMPOSE_FILE" || _bad "compose file missing: $COMPOSE_FILE" + [[ -f "$ENV_DOCKER" ]] && _ok "env file: $ENV_DOCKER" || _warn "env file missing: $ENV_DOCKER (run: server setup init)" + + # CA tooling by distro family + if _has update-ca-certificates; then + _ok "update-ca-certificates: present" + else + _warn "update-ca-certificates: not found (Debian/Ubuntu/Alpine CA updates)" + fi + + if _has update-ca-trust; then + _ok "update-ca-trust: present" + else + _warn "update-ca-trust: not found (RHEL/Fedora CA updates)" + fi + + if _has trust; then + _ok "trust (p11-kit): present" + else + _warn "trust (p11-kit): not found (Arch / p11-kit trust store)" + fi + + printf "\n%bInstall hints%b (best guess)\n" "$CYAN" "$NC" + + # Package manager heuristics (print commands, do NOT execute) + if _has apt-get; then + _warn "Debian/Ubuntu:" + printf " sudo apt-get update && sudo apt-get install -y ca-certificates curl wget openssl p11-kit\n" + printf " # Docker: https://docs.docker.com/engine/install/\n" + printf " sudo apt-get install -y docker-compose-plugin\n" + elif _has dnf; then + _warn "Fedora/RHEL (dnf):" + printf " sudo dnf install -y ca-certificates curl wget openssl p11-kit-trust\n" + printf " # Docker: https://docs.docker.com/engine/install/\n" + elif _has yum; then + _warn "RHEL/CentOS (yum):" + printf " sudo yum install -y ca-certificates curl wget openssl p11-kit-trust\n" + printf " # Docker: https://docs.docker.com/engine/install/\n" + elif _has pacman; then + _warn "Arch:" + printf " sudo pacman -Syu --noconfirm ca-certificates curl wget openssl p11-kit\n" + printf " # Docker + compose plugin per Arch wiki\n" + elif _has apk; then + _warn "Alpine:" + printf " sudo apk add --no-cache ca-certificates curl wget openssl p11-kit\n" + printf " sudo update-ca-certificates\n" + elif _has zypper; then + _warn "SUSE:" + printf " sudo zypper install -y ca-certificates curl wget openssl p11-kit\n" + else + _warn "No known package manager detected. Install: docker + compose + ca-certificates + openssl + p11-kit." + fi + + printf "\n%bTips%b\n" "$CYAN" "$NC" + printf " - Use: %bserver certificate uninstall --all%b if you switched distros/paths.\n" "$BLUE" "$NC" + printf " - Use: %bserver run --publish 8025:8025%b to expose ports for 'server run open'.\n" "$BLUE" "$NC" +} + +############################################################################### +# NOTIFY +############################################################################### +notify_watch() { + local container="${1:-SERVER_TOOLS}" + local prefix="__HOST_NOTIFY__" + + need docker notify-send + + trap - ERR + set +e + set +o pipefail + + local _disp="${DISPLAY-}" + local _dbus="${DBUS_SESSION_BUS_ADDRESS-}" + local _stop=0 + + _watcher_notify() { + local urgency="${1:-critical}" title="${2:-Notifier}" body="${3:-Watcher event}" + (env DISPLAY="${_disp-}" DBUS_SESSION_BUS_ADDRESS="${_dbus-}" \ + setsid -f notify-send -u "$urgency" -t 2500 "$title" "$body" \ + >/dev/null 2>&1 || true) & + } + + _watcher_int_term() { + _stop=1 + _watcher_notify critical "Notifier" "Notification watcher interrupted/exiting" + printf "%b[watcher]%b Notification watcher interrupted/exiting\n" "$RED" "$NC" >&2 + } + trap _watcher_int_term INT TERM + + local grep_cmd=(grep -a --line-buffered -E "^${prefix}([[:space:]]|$)") + command -v stdbuf &>/dev/null && grep_cmd=(stdbuf -oL -eL "${grep_cmd[@]}") + + printf "%bNotify Watch:%b monitoring is active. Ctrl+C to stop.\n" "$GREEN" "$NC" + + while ((_stop == 0)); do + if ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then + _watcher_notify critical "Notifier" "Watcher stopped: $container is not running" + printf "%b[watcher]%b %s is not running; exiting.\n" "$RED" "$NC" "$container" >&2 + break + fi + + docker logs -f --tail 0 "$container" 2>&1 | + ("${grep_cmd[@]}" || true) | + while IFS=$'\t' read -r _ f1 f2 f3 f4 rest; do + local timeout urgency title body + if [[ "${f1:-}" =~ ^[0-9]{1,6}$ ]]; then + timeout="$f1" + urgency="${f2:-normal}" + title="${f3:-Notification}" + body="${f4:-}" + else + timeout="2500" + urgency="${f1:-normal}" + title="${f2:-Notification}" + body="${f3:-}" + fi + [[ -n "${rest:-}" ]] && body+=$'\t'"${rest}" + case "$urgency" in low | normal | critical) ;; *) urgency="normal" ;; esac + + notify-send -t "$timeout" -u "$urgency" "$title" "$body" >/dev/null 2>&1 || true + printf "%s [%s] %s - %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$urgency" "$title" "$body" >&2 + done + + ((_stop)) && break + + if docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then + _watcher_notify critical "Notifier" "Watcher lost log stream (docker logs ended). Reconnecting…" + printf "%b[watcher]%b docker logs ended; reconnecting...\n" "$YELLOW" "$NC" >&2 + sleep 1 + continue + fi + + _watcher_notify critical "Notifier" "Watcher stopped: $container stopped" + printf "%b[watcher]%b %s stopped; exiting.\n" "$RED" "$NC" "$container" >&2 + break + done + + trap - INT TERM + set -euo pipefail + + ((_stop)) && return 130 + return 0 +} + +notify_test() { + local title="${1:-Notifier OK}" + local body="${2:-Hello from host via SERVER_TOOLS}" + docker exec SERVER_TOOLS notify -t 2500 -u normal "$title" "$body" +} + +cmd_notify() { + case ${1:-watch} in + watch) notify_watch "${2:-SERVER_TOOLS}" ;; + test) notify_test "${2:-Notifier OK}" "${3:-Hello from host}" ;; + *) die "notify " ;; + esac +} + +open_url() { + local url="${1:-}" + [[ -n "$url" ]] || return 0 + + # WSL/Windows helpers first when available + if grep -qi microsoft /proc/version 2>/dev/null; then + if command -v powershell.exe >/dev/null 2>&1; then + powershell.exe -NoProfile -Command "Start-Process '$url'" >/dev/null 2>&1 || true + return 0 + fi + if command -v cmd.exe >/dev/null 2>&1; then + cmd.exe /c start "" "$url" >/dev/null 2>&1 || true + return 0 + fi + fi + + if command -v xdg-open >/dev/null 2>&1; then + (xdg-open "$url" >/dev/null 2>&1 &) + return 0 + fi + if command -v open >/dev/null 2>&1; then + (open "$url" >/dev/null 2>&1 &) + return 0 + fi + if command -v powershell >/dev/null 2>&1; then + (powershell -NoProfile -Command "Start-Process '$url'" >/dev/null 2>&1 &) + return 0 + fi + + printf "%bINFO%b: open this URL manually → %s\n" "$YELLOW" "$NC" "$url" +} + +############################################################################### +# RUN (ad-hoc Dockerfile runner) +############################################################################### +hash_short() { + local s="$1" + if command -v sha1sum >/dev/null 2>&1; then + printf '%s' "$s" | sha1sum | cut -c1-8 + elif command -v shasum >/dev/null 2>&1; then + printf '%s' "$s" | shasum -a 1 | cut -c1-8 + else + # POSIX fallback; stable (not cryptographic) + printf '%s' "$s" | cksum | awk '{print $1}' + fi +} + +run_slug() { + local dir="$1" base hash + base="$(basename "$dir" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')" + hash="$(hash_short "$dir")" + printf '%s-%s' "$base" "$hash" +} + +run_plan() { + local dir="$1" slug + slug="$(run_slug "$dir")" + printf '%s|%s|%s\n' \ + "server-run-${slug}" \ + "${slug}:local" \ + "$dir" +} + +run_find_container() { + local dir="$1" + docker ps -a --filter "label=com.infocyph.server.run=1" \ + --filter "label=com.infocyph.server.dir=${dir}" \ + --format '{{.Names}}' | head -n 1 +} + +run_build() { + local tag="$1" dir="$2" + printf "%b[run]%b Building image %b%s%b from %s\n" "$CYAN" "$NC" "$BLUE" "$tag" "$NC" "$dir" + docker build -t "$tag" "$dir" +} + +run_start() { + local name="$1" tag="$2" dir="$3" keepalive="$4" sock="$5" + shift 5 || true + + local -a args=(docker run -d --name "$name" + --label "com.infocyph.server.run=1" + --label "com.infocyph.server.dir=$dir" + --label "com.infocyph.server.tag=$tag" + -w /workspace + -v "$dir:/workspace" + ) + + # Optional: allow containers to talk to Docker (DinD via host socket) + if [[ "${sock:-0}" == 1 ]]; then + [[ -S /var/run/docker.sock ]] || printf "%b[run]%b Warning: /var/run/docker.sock not found on host, mount may fail.\n" "$YELLOW" "$NC" >&2 + args+=(-v "/var/run/docker.sock:/var/run/docker.sock") + fi + + # Publish ports: pass multiple --publish/-p flags from cmd_run + local pub + for pub in "$@"; do + [[ -n "$pub" ]] || continue + args+=(-p "$pub") + done + + if [[ "$keepalive" == 1 ]]; then + # keep container alive even if image CMD exits (good for "exec bash" workflow) + args+=(--entrypoint sh "$tag" -c "trap : TERM INT; sleep infinity & wait") + else + args+=("$tag") + fi + + printf "%b[run]%b Starting container %b%s%b +" "$CYAN" "$NC" "$BLUE" "$name" "$NC" + "${args[@]}" >/dev/null +} + +run_exec_shell() { + local name="$1" + if docker exec "$name" sh -lc 'command -v bash >/dev/null 2>&1' >/dev/null 2>&1; then + exec docker exec -it "$name" bash + else + exec docker exec -it "$name" sh + fi +} + +cmd_run() { + # Usage: + # server run # build+start+exec for current directory + # server run stop # stop container for current directory + # server run rm # remove container (and image tag) for current directory + # server run ps # list run containers + # server run logs # follow logs for current directory container + # server run open [--port P] # open first published port (or P) in browser + # + # Flags: + # --dir PATH # run from another directory + # --name NAME # container name override + # --tag TAG # image tag override + # --no-build # do not build + # --no-keepalive # run image's default CMD/ENTRYPOINT + # --sock # mount docker sock: /var/run/docker.sock + # -p|--publish HOST:CONT # publish port (repeatable) + # --port CONT_PORT # for 'open': choose container port + # --path /some/path # for 'open': default / + # --https # for 'open': use https + local action="shell" dir="$PWD" name="" tag="" nobuild=0 keepalive=1 sock=0 + local -a publish=() + local open_port="" open_path="/" open_proto="http" + + while [[ $# -gt 0 ]]; do + case "$1" in + stop | rm | ps | shell | logs | open) + action="$1" + shift + ;; + --dir) + dir="${2:-}" + shift 2 + ;; + --name) + name="${2:-}" + shift 2 + ;; + --tag) + tag="${2:-}" + shift 2 + ;; + --no-build) + nobuild=1 + shift + ;; + --no-keepalive) + keepalive=0 + shift + ;; + --sock) + sock=1 + shift + ;; + -p | --publish) + publish+=("${2:-}") + shift 2 + ;; + --port) + open_port="${2:-}" + shift 2 + ;; + --path) + open_path="${2:-/}" + shift 2 + ;; + --https) + open_proto="https" + shift + ;; + --http) + open_proto="http" + shift + ;; + *) break ;; + esac + done + + dir="$(cd "$dir" && pwd)" + IFS='|' read -r def_name def_tag _def_dir < <(run_plan "$dir") + name="${name:-$def_name}" + tag="${tag:-$def_tag}" + + # Helper to locate "the" container for a dir + _find_for_dir() { + local found + found="$(run_find_container "$dir" || true)" + if [[ -n "$found" ]]; then + printf '%s' "$found" + return 0 + fi + # fallback to explicit name if present + if docker inspect "$name" >/dev/null 2>&1; then + printf '%s' "$name" + return 0 + fi + return 1 + } + + case "$action" in + ps) + docker ps -a --filter "label=com.infocyph.server.run=1" \ + --format 'table {{.Names}} {{.Image}} {{.Status}} {{.Labels}}' + return 0 + ;; + stop) + local existing + existing="$(_find_for_dir)" || die "no run container found for: $dir" + docker stop "$existing" >/dev/null + printf "%b[run]%b Stopped %s +" "$GREEN" "$NC" "$existing" + return 0 + ;; + logs) + local existing + existing="$(_find_for_dir)" || die "no run container found for: $dir" + exec docker logs -f "$existing" + ;; + open) + local existing line addr hp url + existing="$(_find_for_dir)" || die "no run container found for: $dir" + + # Normalize path + [[ -n "$open_path" ]] || open_path="/" + [[ "$open_path" == /* ]] || open_path="/$open_path" + + if [[ -n "$open_port" ]]; then + line="$(docker port "$existing" "$open_port" 2>/dev/null | head -n 1 || true)" + # Some docker versions require proto, try tcp as fallback + [[ -n "$line" ]] || line="$(docker port "$existing" "${open_port}/tcp" 2>/dev/null | head -n 1 || true)" + else + line="$(docker port "$existing" 2>/dev/null | head -n 1 || true)" + fi + + if [[ -z "$line" ]]; then + printf "%b[run]%b No published ports found. +" "$YELLOW" "$NC" + printf "%b[run]%b Tip: start with %bserver run --publish 8025:8025%b then %bserver run open%b +" \ + "$YELLOW" "$NC" "$BLUE" "$NC" "$BLUE" "$NC" + return 1 + fi + + # Example line: "8025/tcp -> 0.0.0.0:8025" + addr="${line##*-> }" + hp="${addr##*:}" + + url="${open_proto}://localhost:${hp}${open_path}" + open_url "$url" + printf "%b[run]%b Opened: %s +" "$GREEN" "$NC" "$url" + return 0 + ;; + rm) + local existing img + existing="$(_find_for_dir)" || true + if [[ -n "${existing:-}" ]]; then + img="$(docker inspect -f '{{.Config.Image}}' "$existing" 2>/dev/null || true)" + docker rm -f "$existing" >/dev/null + printf "%b[run]%b Removed container %s +" "$GREEN" "$NC" "$existing" + if [[ -n "${img:-}" ]]; then + docker rmi -f "$img" >/dev/null 2>&1 || true + printf "%b[run]%b Removed image %s +" "$GREEN" "$NC" "$img" + fi + else + printf "%b[run]%b No container found for %s +" "$YELLOW" "$NC" "$dir" + fi + return 0 + ;; + shell | *) + if ((nobuild == 0)); then + run_build "$tag" "$dir" + else + printf "%b[run]%b Skipping build (--no-build) +" "$YELLOW" "$NC" + fi + + if docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null | grep -q true; then + printf "%b[run]%b Container already running: %s +" "$GREEN" "$NC" "$name" + else + # if a previous container with same name exists but stopped, remove it + if docker inspect "$name" >/dev/null 2>&1; then + docker rm -f "$name" >/dev/null 2>&1 || true + fi + run_start "$name" "$tag" "$dir" "$keepalive" "$sock" "${publish[@]}" + fi + + run_exec_shell "$name" + ;; + esac +} + +cmd_help() { + cat < [options] + +${CYAN}Default:${NC} quiet docker compose operations. Add ${CYAN}-v${NC} / ${CYAN}--verbose${NC} to see pull/build progress. + +${CYAN}Core commands:${NC} + up / start Start docker stack (quiet pull by default) + stop / down Stop stack + reload / restart Restart stack + reload HTTP + rebuild all| Rebuild/pull services (no full down) + config Validate compose + tools Enter SERVER_TOOLS container + lzd | lazydocker Start LazyDocker + http reload Reload Nginx/Apache + core Open bash in PHP container for + +${CYAN}Setup commands:${NC} + setup init Initialize Primaries! + setup permissions Assign/Fix directory/file permissions + setup domain Setup domain + setup profiles Setup database profiles + +${CYAN}Misc:${NC} + doctor Diagnose environment + show install hints + certificate install Install local rootCA + certificate uninstall [--all] Remove local rootCA (use --all to remove from all known anchor paths) + run [stop|rm|ps|logs|open] [opts] Build/run/exec ad-hoc Dockerfile in current dir + notify watch [container] Watch SERVER_TOOLS notifications and show desktop popups + notify test "T" "B" Send a test notification into SERVER_TOOLS + help This help +EOF +} + +############################################################################### +# 7. MAIN +############################################################################### +main() { + need docker + ensure_files_exist "/docker/.env" "/configuration/php/php.ini" "/.env" + + [[ $# -gt 0 ]] || { + cmd_help + exit 1 + } + + while [[ $# -gt 0 ]]; do + case "$1" in + -v | --verbose) + VERBOSE=1 + shift + ;; + -q | --quiet) + VERBOSE=0 + shift + ;; + --) + shift + break + ;; + -*) die "Unknown global option: $1" ;; + *) break ;; + esac + done + + [[ $# -gt 0 ]] || { + cmd_help + exit 1 + } + + case "${1,,}" in + php | composer | node | npm | npx) exec "$DIR/bin/$1" "${@:2}" ;; + pg | pg_restore | pg-restore | pgrestore | pg_dump | pgdump | pg-dump | psql) exec "$DIR/bin/pg" "${@:2}" ;; + maria | mariadb | mariadbdump | mariadb-dump | mariadb_dump) exec "$DIR/bin/maria" "${@:2}" ;; + my | mysql | mysqldump | mysql-dump | mysql_dump) exec "$DIR/bin/my" "${@:2}" ;; + redis | redis-cli) exec "$DIR/bin/redis-cli" "${@:2}" ;; + *) cmd_"${1,,}" "${@:2}" ;; + esac +} + +main "$@" diff --git a/server.bat b/server.bat new file mode 100644 index 0000000..529d308 --- /dev/null +++ b/server.bat @@ -0,0 +1,54 @@ +@echo off +setlocal EnableExtensions + +set "WARN=[WARN]" +set "OK=[OK]" +set "INFO=[INFO]" + +set "DEVHOME=%~dp0" +if "%DEVHOME:~-1%"=="\" set "DEVHOME=%DEVHOME:~0,-1%" + +set "WORKDIR=%CD%" + +set "GIT_EXE=" +for /f "delims=" %%A in ('where git.exe 2^>nul') do ( set "GIT_EXE=%%A" & goto :got_git ) +for /f "usebackq delims=" %%A in (`powershell -NoProfile -Command "(Get-Command git -ErrorAction SilentlyContinue).Source"`) do ( set "GIT_EXE=%%A" & goto :got_git ) + +:got_git +if not defined GIT_EXE ( + echo %WARN% git.exe not found. Install Git for Windows or add it to PATH. + exit /b 2 +) + +set "GIT_DIR=" +for %%I in ("%GIT_EXE%") do set "GIT_DIR=%%~dpI" +if "%GIT_DIR:~-1%"=="\" set "GIT_DIR=%GIT_DIR:~0,-1%" + +set "GIT_ROOT=%GIT_DIR%" +for %%I in ("%GIT_ROOT%\..") do set "GIT_ROOT=%%~fI" + +set "BASH_EXE=" +if exist "%GIT_ROOT%\bin\bash.exe" set "BASH_EXE=%GIT_ROOT%\bin\bash.exe" +if not defined BASH_EXE if exist "%GIT_ROOT%\usr\bin\bash.exe" set "BASH_EXE=%GIT_ROOT%\usr\bin\bash.exe" + +if not defined BASH_EXE ( + echo %WARN% Found git at "%GIT_EXE%" but bash.exe not found under "%GIT_ROOT%". + exit /b 3 +) + +if not exist "%DEVHOME%\server" ( + echo %WARN% Cannot find server script: "%DEVHOME%\server" + exit /b 4 +) + +where docker.exe >nul 2>&1 +if errorlevel 1 echo %WARN% docker.exe not found on Windows PATH. If ./server uses docker, it may fail. +if errorlevel 1 goto :run + +docker info >nul 2>&1 +if errorlevel 1 echo %WARN% Docker installed but NOT running/reachable (docker info failed). Start Docker Desktop / engine. + +:run +"%BASH_EXE%" -lc "set -euo pipefail; export TERM=xterm-256color; DEVHOME_WIN=\"$1\"; CALLER_WIN=\"$2\"; DEVHOME=$(cygpath -u \"$DEVHOME_WIN\"); CALLER=$(cygpath -u \"$CALLER_WIN\"); cd \"$DEVHOME\"; chmod +x ./server >/dev/null 2>&1 || true; cd \"$CALLER\"; shift 2; exec \"$DEVHOME/server\" --__win_workdir \"$CALLER_WIN\" \"$@\"" bash "%DEVHOME%" "%WORKDIR%" %* + +exit /b %ERRORLEVEL%