Files
1Panel/scripts/openresty-modules/diagnose-install.sh
Snrat 2a2e6607b8 feat: 重构OpenResty模块部分以支持动态编译 (#13291)
* feat: support dynamic module build for OpenResty

* feat: add dynamic module build page for OpenResty

* refactor: drop auto fallback, gate dynamic build by version support

- remove auto-to-static fallback; dynamic build failure now reports the
  error and hints switching to static build manually
- gate dynamic builds on module support files (Dockerfile.modules +
  module.catalog.json) instead of version numbers, expose
  dynamicSupported in the modules API
- collect repeated path/status/operate strings into constants
- move nginx module regex patterns into utils/re with semantic helpers
- reorganize nginx_module.go around the main build flows and inline
  single-use thin helpers

* feat: limit nginx module build mode options by version support

- build mode radio offers only dynamic and static (auto maps to dynamic
  for legacy data)
- disable the dynamic option with a hint when the installed OpenResty
  version lacks dynamic build support

* feat: complete i18n for nginx module pages

Fill in the new nginx module keys for all eleven language files
(translations other than zh/en are draft machine translations).

* feat: probe dynamic module support on load and drop the auto build mode

- probe each non-static module's configure params when loading the
  module list and report dynamicSupport=supported/unsupported up front
- normalize the legacy auto build mode to dynamic

* feat: clarify module build modes in the UI

- build drawer lists dynamic modules (tagged, hot-reload) and static
  modules (tagged, full rebuild + container restart) separately
- disable the dynamic option per module when its params do not support
  dynamic build, distinct from the version gate hint
- drop the auto build mode wording everywhere and sync all eleven
  language files

* feat: clarify purpose of the nginx module build drawer

- add a purpose hint explaining dynamic (hot reload) vs static (full
  rebuild + container restart)
- drop the per-module mode tags now that section headers carry the
  semantics
- allow submitting with zero dynamic modules selected when static
  modules are present, so static-only users can trigger a build

* feat: pass apt mirror through to dynamic module builds

The mirror selected in the build dialog (or CONTAINER_PACKAGE_URL in the
app env as fallback) is now forwarded as a build arg so the module
builder uses the same apt source as the static build path. test-builder
gains a --mirror option.

* feat: add Lao translations for nginx module pages
2026-07-23 13:55:42 +08:00

476 lines
17 KiB
Bash

#!/usr/bin/env bash
set -Eeuo pipefail
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
INSTALL_DIR=""
OUTPUT_DIR="${OUTPUT_DIR:-${PWD}/openresty-module-diagnostics/${RUN_ID}}"
CONTAINER=""
FULL_CONFIG=0
CREATE_ARCHIVE=1
NGINX_TEST=1
UNEXPECTED_FAILURE=""
declare -a FAILED_CHECKS=()
usage() {
cat <<'EOF'
Usage: diagnose-install.sh INSTALL_DIR [options]
Collect a mostly read-only diagnostic report for an installed 1Panel OpenResty.
The only container command with behavior is `nginx -t`; no reload is performed.
Options:
--output PATH Result directory
--container NAME Override the container discovered from Docker Compose
--full-config Retain full `nginx -T` output (may contain sensitive data)
--no-nginx-test Do not execute nginx -t/-T in the running container
--no-archive Do not create a .tar.gz report
-h, --help Show this help
EOF
}
log() {
printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "${OUTPUT_DIR}/run.log"
}
mark_failed() {
FAILED_CHECKS+=("$1")
log "CHECK FAILED: $1"
}
mark_passed() {
log "CHECK PASSED: $1"
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Required command not found: %s\n' "$1" >&2
exit 2
fi
}
on_error() {
local status="$1" line="$2" command="$3"
UNEXPECTED_FAILURE="line ${line}: ${command} (exit ${status})"
return "${status}"
}
finalize() {
local status=$?
set +e
if [[ -n "${UNEXPECTED_FAILURE}" ]]; then
printf '%s\n' "${UNEXPECTED_FAILURE}" >"${OUTPUT_DIR}/unexpected-failure.txt"
fi
{
printf 'install_dir=%s\n' "${INSTALL_DIR}"
printf 'container=%s\n' "${CONTAINER}"
printf 'failed_checks=%s\n' "${#FAILED_CHECKS[@]}"
local check
for check in "${FAILED_CHECKS[@]:-}"; do
[[ -n "${check}" ]] && printf 'failure=%s\n' "${check}"
done
} >"${OUTPUT_DIR}/summary.txt"
if [[ "${CREATE_ARCHIVE}" -eq 1 ]]; then
local archive="${OUTPUT_DIR%/}.tar.gz"
tar -czf "${archive}" -C "$(dirname -- "${OUTPUT_DIR}")" "$(basename -- "${OUTPUT_DIR}")" 2>/dev/null || true
printf 'Diagnostic archive: %s\n' "${archive}"
fi
printf 'Diagnostic directory: %s\n' "${OUTPUT_DIR}"
if [[ "${status}" -eq 0 && "${#FAILED_CHECKS[@]}" -gt 0 ]]; then
status=1
fi
exit "${status}"
}
if [[ $# -eq 0 ]]; then
usage >&2
exit 2
fi
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
usage
exit 0
fi
INSTALL_DIR="$1"
shift
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="$2"
shift 2
;;
--container)
CONTAINER="$2"
shift 2
;;
--full-config)
FULL_CONFIG=1
shift
;;
--no-nginx-test)
NGINX_TEST=0
shift
;;
--no-archive)
CREATE_ARCHIVE=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown option: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
[[ -d "${INSTALL_DIR}" ]] || {
printf 'Install directory not found: %s\n' "${INSTALL_DIR}" >&2
exit 2
}
INSTALL_DIR="$(cd -- "${INSTALL_DIR}" && pwd -P)"
if [[ -d "${OUTPUT_DIR}" ]] && find "${OUTPUT_DIR}" -mindepth 1 -print -quit | grep -q .; then
printf 'Output directory must be empty: %s\n' "${OUTPUT_DIR}" >&2
exit 2
fi
mkdir -p "${OUTPUT_DIR}"
OUTPUT_DIR="$(cd -- "${OUTPUT_DIR}" && pwd -P)"
trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR
trap finalize EXIT
preflight() {
[[ "$(uname -s)" == "Linux" ]] || {
printf 'This diagnostic script must run on Linux.\n' >&2
exit 2
}
require_command docker
require_command jq
require_command python3
require_command sha256sum
require_command tar
docker version >"${OUTPUT_DIR}/docker-version.txt" 2>&1
docker info >"${OUTPUT_DIR}/docker-info.txt" 2>&1
uname -a >"${OUTPUT_DIR}/uname.txt"
cp /etc/os-release "${OUTPUT_DIR}/os-release.txt" 2>/dev/null || true
df -h >"${OUTPUT_DIR}/disk-free.txt"
free -h >"${OUTPUT_DIR}/memory.txt" 2>&1 || true
log "Inspecting ${INSTALL_DIR}"
}
collect_filesystem_state() {
[[ -d "${INSTALL_DIR}/modules" ]] || mark_failed "module artifact directory is missing"
[[ -d "${INSTALL_DIR}/conf/modules-enabled" ]] || mark_failed "managed module config directory is missing"
find "${INSTALL_DIR}/modules" -maxdepth 5 -printf '%M\t%u:%g\t%s\t%TY-%Tm-%TdT%TH:%TM:%TS\t%p\n' \
>"${OUTPUT_DIR}/module-files.txt" 2>&1 || true
find "${INSTALL_DIR}/conf/modules-enabled" -maxdepth 1 -type f -printf '%f\n' \
>"${OUTPUT_DIR}/managed-config-files.txt" 2>&1 || true
grep -RnsE '^[[:space:]]*load_module[[:space:]]+' "${INSTALL_DIR}/conf/modules-enabled" \
>"${OUTPUT_DIR}/load-module-directives.txt" 2>&1 || true
grep -E '^(RESTY_|CONTAINER_NAME=|PANEL_APP_PORT_HTTP=)' "${INSTALL_DIR}/.env" \
>"${OUTPUT_DIR}/relevant-env.txt" 2>/dev/null || true
if [[ -f "${INSTALL_DIR}/build/module.json" ]]; then
jq 'map(if has("script") then .script = "<redacted>" else . end)' \
"${INSTALL_DIR}/build/module.json" >"${OUTPUT_DIR}/module-state.json"
else
mark_failed "module state file is missing"
fi
if [[ -f "${INSTALL_DIR}/build/module.catalog.json" ]]; then
jq 'map(if has("script") then .script = "<redacted>" else . end)' \
"${INSTALL_DIR}/build/module.catalog.json" >"${OUTPUT_DIR}/module-catalog.json"
fi
}
validate_artifacts() {
local state="${INSTALL_DIR}/build/module.json"
[[ -f "${state}" ]] || return 0
if python3 - "${state}" "${INSTALL_DIR}/modules" >"${OUTPUT_DIR}/artifact-validation.tsv" <<'PY'
import hashlib
import json
import os
import pathlib
import sys
state_path = pathlib.Path(sys.argv[1])
modules_root = pathlib.Path(sys.argv[2]).resolve()
modules = json.loads(state_path.read_text(encoding="utf-8"))
failed = False
print("module\tbuild_status\ttarget_key\tartifact\texpected\tactual\tresult")
for module in modules:
for build in module.get("builds") or []:
target_key = (build.get("target") or {}).get("key", "")
for artifact in build.get("artifacts") or []:
relative = artifact.get("path", "")
expected = artifact.get("checksum", "")
result = "OK"
actual = ""
try:
pure = pathlib.PurePosixPath(relative)
if not relative or pure.is_absolute() or ".." in pure.parts or "\\" in relative:
raise ValueError("unsafe-path")
candidate = modules_root / pathlib.Path(*pure.parts)
if candidate.is_symlink():
raise ValueError("symlink-not-allowed")
full_path = candidate.resolve(strict=True)
if modules_root not in full_path.parents:
raise ValueError("outside-module-root")
if not full_path.is_file():
raise ValueError("not-regular-file")
digest = hashlib.sha256()
with full_path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
actual = digest.hexdigest()
if actual.lower() != expected.lower():
raise ValueError("checksum-mismatch")
except Exception as error:
result = str(error)
failed = True
print("\t".join([
module.get("name", ""), build.get("status", ""), target_key,
relative, expected, actual, result,
]))
sys.exit(1 if failed else 0)
PY
then
mark_passed "artifact paths and checksums"
else
mark_failed "artifact paths or checksums"
fi
}
validate_managed_configs() {
if python3 - "${INSTALL_DIR}/conf/modules-enabled" "${INSTALL_DIR}/modules" \
>"${OUTPUT_DIR}/managed-config-validation.tsv" <<'PY'
import pathlib
import re
import sys
config_root = pathlib.Path(sys.argv[1])
modules_root = pathlib.Path(sys.argv[2]).resolve()
container_prefix = "/usr/local/openresty/nginx/modules/1panel/"
pattern = re.compile(r"^\s*load_module\s+([^;]+);", re.MULTILINE)
failed = False
print("config\tcontainer_path\thost_path\tresult")
if config_root.exists():
for config in sorted(config_root.glob("1panel-module-*.conf")):
content = config.read_text(encoding="utf-8")
for value in pattern.findall(content):
container_path = value.strip().strip('"\'')
result = "OK"
host_path = ""
try:
if not container_path.startswith(container_prefix):
raise ValueError("unexpected-container-path")
relative = pathlib.PurePosixPath(container_path[len(container_prefix):])
if ".." in relative.parts:
raise ValueError("unsafe-path")
resolved = (modules_root / pathlib.Path(*relative.parts)).resolve(strict=True)
if modules_root not in resolved.parents or not resolved.is_file():
raise ValueError("missing-artifact")
host_path = str(resolved)
except Exception as error:
result = str(error)
failed = True
print("\t".join([config.name, container_path, host_path, result]))
sys.exit(1 if failed else 0)
PY
then
mark_passed "managed load_module configs"
else
mark_failed "managed load_module configs"
fi
}
collect_compose_state() {
local compose_file="${INSTALL_DIR}/docker-compose.yml"
if [[ ! -f "${compose_file}" ]]; then
mark_failed "docker-compose.yml is missing"
return 0
fi
if (cd "${INSTALL_DIR}" && docker compose config --format json) >"${OUTPUT_DIR}/compose.json" 2>"${OUTPUT_DIR}/compose-config.log"; then
mark_passed "docker compose config"
else
mark_failed "docker compose config"
return 0
fi
if jq -e '
[.services[].volumes[]?]
| (any(.[]; (.target | rtrimstr("/")) == "/usr/local/openresty/nginx/modules/1panel" and .read_only == true))
and (any(.[]; (.target | rtrimstr("/")) == "/usr/local/openresty/nginx/conf/modules-enabled" and .read_only == true))
' "${OUTPUT_DIR}/compose.json" >/dev/null; then
mark_passed "read-only module mounts"
else
mark_failed "read-only module mounts"
fi
(cd "${INSTALL_DIR}" && docker compose ps -a --format json) >"${OUTPUT_DIR}/compose-ps.json" 2>&1 || true
if [[ -z "${CONTAINER}" ]]; then
local cid
cid="$(cd "${INSTALL_DIR}" && docker compose ps -q 2>/dev/null | head -n 1)"
if [[ -n "${cid}" ]]; then
CONTAINER="$(docker inspect --format '{{.Name}}' "${cid}" | sed 's#^/##')"
else
CONTAINER="$(jq -r '[.services[] | select((.image // "") | test("openresty"; "i")) | .container_name][0] // empty' \
"${OUTPUT_DIR}/compose.json")"
fi
fi
}
compare_target_identity() {
local state="${INSTALL_DIR}/build/module.json"
local current_image_id="$1"
[[ -f "${state}" ]] || return 0
if python3 - "${state}" "${current_image_id}" "${INSTALL_DIR}/conf/modules-enabled" \
>"${OUTPUT_DIR}/target-identity.tsv" <<'PY'
import json
import pathlib
import re
import sys
modules = json.load(open(sys.argv[1], encoding="utf-8"))
current = sys.argv[2]
config_root = pathlib.Path(sys.argv[3])
pattern = re.compile(r"^\s*load_module\s+([^;]+);", re.MULTILINE)
prefix = "/usr/local/openresty/nginx/modules/1panel/"
loaded = set()
if config_root.exists():
for config in config_root.glob("1panel-module-*.conf"):
for value in pattern.findall(config.read_text(encoding="utf-8")):
container_path = value.strip().strip('"\'')
if container_path.startswith(prefix):
loaded.add(container_path[len(prefix):])
failed = False
print("module\tenabled\tmode\tready_image_ids\tmanaged_artifacts\tresult")
for module in modules:
mode = module.get("buildMode") or "static"
if module.get("deleted") or not module.get("enable") or mode == "static":
continue
ready = [build for build in module.get("builds") or [] if build.get("status") == "ready"]
digests = sorted({(build.get("target") or {}).get("imageDigest", "") for build in ready})
candidates = []
if not ready:
result = "NO-READY-BUILD"
failed = True
elif current in digests:
result = "MATCH"
candidates = [build for build in ready if (build.get("target") or {}).get("imageDigest") == current]
elif not any(digests):
result = "UNKNOWN-NO-IMAGE-DIGEST"
candidates = ready
else:
result = "MISMATCH"
failed = True
managed = []
if candidates:
for build in candidates:
paths = [artifact.get("path", "") for artifact in build.get("artifacts") or []]
if paths and all(path in loaded for path in paths):
managed = paths
break
if not managed:
result += "+NOT-IN-MANAGED-CONFIG"
failed = True
print("\t".join([
module.get("name", ""), str(module.get("enable", False)),
mode, ",".join(digests), ",".join(managed), result,
]))
sys.exit(1 if failed else 0)
PY
then
mark_passed "enabled module target image identity"
else
mark_failed "enabled module target image identity"
fi
}
collect_container_state() {
if [[ -z "${CONTAINER}" ]]; then
mark_failed "OpenResty container could not be discovered"
return 0
fi
if ! docker inspect "${CONTAINER}" >/dev/null 2>&1; then
mark_failed "container ${CONTAINER} does not exist"
return 0
fi
docker inspect "${CONTAINER}" | jq '.[0] | {
Id, Name, Image, State,
Config: {Image: .Config.Image},
Mounts: [.Mounts[] | {Type, Source, Destination, RW}]
}' >"${OUTPUT_DIR}/container.json"
docker logs --tail 1000 --timestamps "${CONTAINER}" >"${OUTPUT_DIR}/container.log" 2>&1 || true
local running image_id image_name
running="$(docker inspect --format '{{.State.Running}}' "${CONTAINER}")"
image_id="$(docker inspect --format '{{.Image}}' "${CONTAINER}")"
image_name="$(docker inspect --format '{{.Config.Image}}' "${CONTAINER}")"
docker image inspect "${image_id}" | jq '.[0] | {Id, RepoTags, RepoDigests, Architecture, Os, Created}' \
>"${OUTPUT_DIR}/runtime-image.json" 2>&1 || true
printf 'container=%s\nrunning=%s\nimage_name=%s\nimage_id=%s\n' \
"${CONTAINER}" "${running}" "${image_name}" "${image_id}" >"${OUTPUT_DIR}/runtime.txt"
compare_target_identity "${image_id}"
if [[ "${running}" != "true" ]]; then
mark_failed "container ${CONTAINER} is not running"
return 0
fi
mark_passed "container is running"
docker exec "${CONTAINER}" /usr/local/openresty/nginx/sbin/nginx -V \
>"${OUTPUT_DIR}/nginx-version.txt" 2>&1 || mark_failed "nginx -V"
docker exec "${CONTAINER}" /bin/sh -c \
'find /usr/local/openresty/nginx/modules/1panel -type f -name "*.so" -exec sha256sum {} \; | sort' \
>"${OUTPUT_DIR}/container-artifact-checksums.txt" 2>&1 || true
docker exec "${CONTAINER}" /bin/sh -c \
'for f in $(find /usr/local/openresty/nginx/modules/1panel -type f -name "*.so" | sort); do echo "### $f"; ldd "$f" || true; done' \
>"${OUTPUT_DIR}/container-artifact-ldd.txt" 2>&1 || true
if [[ "${NGINX_TEST}" -eq 1 ]]; then
if docker exec "${CONTAINER}" /usr/local/openresty/nginx/sbin/nginx -t \
>"${OUTPUT_DIR}/nginx-test.txt" 2>&1; then
mark_passed "running container nginx -t"
else
mark_failed "running container nginx -t"
fi
local full_output="${OUTPUT_DIR}/nginx-T.full.tmp"
docker exec "${CONTAINER}" /usr/local/openresty/nginx/sbin/nginx -T >"${full_output}" 2>&1 || true
if [[ "${FULL_CONFIG}" -eq 1 ]]; then
mv "${full_output}" "${OUTPUT_DIR}/nginx-T.full.txt"
log "WARNING: nginx-T.full.txt may contain credentials or private configuration"
else
grep -nE 'load_module|modules-enabled|nginx version:|configure arguments:' "${full_output}" \
>"${OUTPUT_DIR}/nginx-T-modules.txt" 2>/dev/null || true
rm -f "${full_output}"
fi
fi
}
main() {
preflight
collect_filesystem_state
validate_artifacts
validate_managed_configs
collect_compose_state
collect_container_state
if [[ "${#FAILED_CHECKS[@]}" -gt 0 ]]; then
log "Diagnostics completed with ${#FAILED_CHECKS[@]} failed checks"
return 0
fi
log "Diagnostics completed without failed checks"
}
main "$@"