AI-Development-Server/scripts/system/discover-system.sh

369 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# shellcheck source=install/lib/common.sh
source "${REPO_ROOT}/install/lib/common.sh"
atlas_set_script_name "discover-system.sh"
OUTPUT_FORMAT="human"
OUTPUT_PATH=""
usage() {
cat <<'USAGE'
Usage: scripts/system/discover-system.sh [options]
Read-only Atlas host discovery. Default output is human-readable text on stdout.
Options:
--help Show this help text.
--version Show script/framework version.
--json Emit valid JSON only.
--output PATH Write the report to PATH instead of stdout.
--verbose Include limited extra non-secret diagnostics.
USAGE
}
while [[ "$#" -gt 0 ]]; do
case "$1" in
--help)
usage
exit "${ATLAS_EXIT_OK}"
;;
--version)
atlas_print_version
exit "${ATLAS_EXIT_OK}"
;;
--json)
OUTPUT_FORMAT="json"
;;
--output)
if [[ "$#" -lt 2 || -z "$2" ]]; then
atlas_usage_error "--output requires a path"
exit "${ATLAS_EXIT_USAGE}"
fi
OUTPUT_PATH="$2"
shift
;;
--verbose)
atlas_enable_verbose
;;
*)
atlas_usage_error "Unknown option: $1"
exit "${ATLAS_EXIT_USAGE}"
;;
esac
shift
done
have() {
command -v "$1" >/dev/null 2>&1
}
value_or_unavailable() {
local command_name="$1"
shift
if have "$command_name"; then
"$@" 2>/dev/null || printf 'unavailable\n'
else
printf 'command unavailable: %s\n' "$command_name"
fi
}
first_line_or_unavailable() {
value_or_unavailable "$@" | sed -n '1p'
}
os_name() {
if [[ -r /etc/os-release ]]; then
. /etc/os-release
printf '%s\n' "${PRETTY_NAME:-unknown}"
else
printf 'unknown\n'
fi
}
pending_updates() {
if have apt; then
apt list --upgradable 2>/dev/null | awk 'NR > 1 {count++} END {print count + 0}'
else
printf 'unknown\n'
fi
}
reboot_required() {
if [[ -f /var/run/reboot-required ]]; then
printf 'yes\n'
else
printf 'no\n'
fi
}
component_version() {
local command_name="$1"
shift
if have "$command_name"; then
"$command_name" "$@" 2>&1 | sed -n '1p'
else
printf 'not installed\n'
fi
}
docker_status() {
if ! have docker; then
printf 'not installed\n'
elif sudo -n docker info >/dev/null 2>&1 || docker info >/dev/null 2>&1; then
printf 'active\n'
else
printf 'installed-not-active-or-not-accessible\n'
fi
}
sql_container_health() {
if ! have docker; then
printf 'docker unavailable\n'
else
sudo -n docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' atlas-sql-server 2>/dev/null || printf 'not present\n'
fi
}
nginx_status() {
if ! have nginx; then
printf 'not installed\n'
elif systemctl is-active --quiet nginx 2>/dev/null && sudo -n nginx -t >/dev/null 2>&1; then
printf 'active-config-valid\n'
else
printf 'installed-needs-attention\n'
fi
}
mount_status() {
local path="$1"
if have findmnt && findmnt -rn --target "$path" >/dev/null 2>&1; then
printf 'mounted\n'
elif [[ -e "$path" ]]; then
printf 'exists-not-mounted\n'
else
printf 'missing\n'
fi
}
local_host_overrides_present() {
if [[ -r /etc/hosts ]] && awk '($1 !~ /^#/ && $1 !~ /^(127\.|::1)/ && NF > 1) {found=1} END {exit found ? 0 : 1}' /etc/hosts; then
printf 'yes\n'
else
printf 'no\n'
fi
}
firewall_summary() {
if have ufw; then
sudo -n ufw status 2>/dev/null | sed -n '1p' || printf 'ufw status unavailable\n'
else
printf 'ufw not installed\n'
fi
}
time_sync_status() {
if have timedatectl; then
timedatectl show -p NTPSynchronized --value 2>/dev/null | awk '{print ($1 == "yes") ? "yes" : "no"}'
else
printf 'unknown\n'
fi
}
write_output() {
local content="$1"
if [[ -z "$OUTPUT_PATH" ]]; then
printf '%s\n' "$content"
return 0
fi
local output_dir
output_dir="$(dirname "$OUTPUT_PATH")"
if [[ ! -d "$output_dir" ]]; then
if [[ "$output_dir" == /var/log/atlas/reports* ]] && have sudo && sudo -n true >/dev/null 2>&1; then
sudo -n install -d -m 0750 -o "$(id -u)" -g "$(id -g)" "$output_dir"
else
install -d -m 0750 "$output_dir"
fi
fi
if [[ -w "$output_dir" ]]; then
printf '%s\n' "$content" >"$OUTPUT_PATH"
chmod 0640 "$OUTPUT_PATH"
elif have sudo && sudo -n true >/dev/null 2>&1; then
local tmp_file
tmp_file="$(mktemp)"
printf '%s\n' "$content" >"$tmp_file"
sudo -n install -m 0640 "$tmp_file" "$OUTPUT_PATH"
rm -f "$tmp_file"
else
printf 'ERROR: output path is not writable: %s\n' "$OUTPUT_PATH" >&2
return "${ATLAS_EXIT_USAGE}"
fi
}
human_report() {
{
printf 'Atlas System Discovery Report\n'
printf 'Generated: %s\n\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf 'Operating System\n'
printf ' Distribution: %s\n' "$(os_name)"
printf ' Kernel: %s\n' "$(uname -r)"
printf ' Architecture: %s\n' "$(uname -m)"
printf ' dpkg architecture: %s\n\n' "$(value_or_unavailable dpkg dpkg --print-architecture | sed -n '1p')"
printf 'Hardware\n'
printf ' CPU: %s\n' "$(first_line_or_unavailable lscpu lscpu | sed 's/^Architecture:.*/Architecture: see operating system section/')"
if have lscpu; then
lscpu | awk -F: '/Model name|CPU\\(s\\)|Core\\(s\\) per socket|Thread\\(s\\) per core/ {gsub(/^[ \t]+/, "", $2); printf " %s: %s\n", $1, $2}'
fi
printf ' Memory and swap:\n'
value_or_unavailable free free -h | sed 's/^/ /'
printf '\n'
printf 'Storage\n'
printf ' /sql status: %s\n' "$(mount_status /sql)"
printf ' /srv status: %s\n' "$(mount_status /srv)"
if have df; then
df -hT / /sql /srv 2>/dev/null | sed 's/^/ /'
fi
printf '\n'
printf 'Package State\n'
printf ' Pending package updates: %s\n' "$(pending_updates)"
printf ' Reboot required: %s\n\n' "$(reboot_required)"
printf 'Platform Components\n'
printf ' Bash: %s\n' "$(component_version bash --version)"
printf ' Git: %s\n' "$(component_version git --version)"
printf ' Git LFS: %s\n' "$(component_version git-lfs --version)"
printf ' Node.js: %s\n' "$(component_version node --version)"
printf ' npm: %s\n' "$(component_version npm --version)"
printf ' .NET: %s\n' "$(component_version dotnet --version)"
printf ' SQL Server command: %s\n' "$(component_version sqlservr --version)"
printf ' nginx: %s\n' "$(component_version nginx -v)"
printf ' Docker: %s\n' "$(component_version docker --version)"
printf ' Docker Compose: %s\n\n' "$(if have docker; then docker compose version 2>/dev/null | sed -n '1p'; else printf 'not installed'; fi)"
printf ' Docker status: %s\n' "$(docker_status)"
printf ' SQL Server container health: %s\n\n' "$(sql_container_health)"
printf ' nginx status: %s\n\n' "$(nginx_status)"
printf 'Services\n'
printf ' Failed systemd services:\n'
if have systemctl; then
systemctl --failed --no-legend --plain 2>/dev/null | awk 'NF {print " " $0} END {if (NR == 0) print " none"}'
else
printf ' systemctl unavailable\n'
fi
printf '\n'
printf 'Network\n'
printf ' Hostname: %s\n' "$(hostname)"
printf ' Local host overrides present: %s\n' "$(local_host_overrides_present)"
printf ' Listening TCP ports:\n'
if have ss; then
ss -ltn 2>/dev/null | sed 's/^/ /'
else
printf ' ss unavailable\n'
fi
printf ' Firewall: %s\n' "$(firewall_summary)"
printf '\n'
printf 'Time and DNS\n'
printf ' Time zone: %s\n' "$(if have timedatectl; then timedatectl show -p Timezone --value 2>/dev/null; else printf 'unknown'; fi)"
printf ' Time synchronised: %s\n' "$(time_sync_status)"
printf ' DNS configured: %s\n' "$(if have resolvectl && resolvectl dns >/dev/null 2>&1; then printf 'yes'; else printf 'unknown'; fi)"
}
}
json_report() {
if ! have jq; then
printf 'ERROR: --json requires jq\n' >&2
return "${ATLAS_EXIT_PREREQ}"
fi
local failed_services listening_ports filesystems
failed_services="$(if have systemctl; then systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}'; fi)"
listening_ports="$(if have ss; then ss -ltnH 2>/dev/null | awk '{print $4}'; fi)"
filesystems="$(if have df; then df -B1 --output=target,fstype,size,used,avail,pcent / /sql /srv 2>/dev/null | awk 'NR > 1 {print $0}'; fi)"
jq -n \
--arg generated_at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
--arg os "$(os_name)" \
--arg kernel "$(uname -r)" \
--arg arch "$(uname -m)" \
--arg dpkg_arch "$(value_or_unavailable dpkg dpkg --print-architecture | sed -n '1p')" \
--arg cpu_model "$(if have lscpu; then lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}'; else printf 'unknown'; fi)" \
--arg memory "$(if have free; then free -h | awk '/^Mem:/ {print $2}'; else printf 'unknown'; fi)" \
--arg swap "$(if have free; then free -h | awk '/^Swap:/ {print $2}'; else printf 'unknown'; fi)" \
--arg sql_status "$(mount_status /sql)" \
--arg srv_status "$(mount_status /srv)" \
--arg pending_updates "$(pending_updates)" \
--arg reboot_required "$(reboot_required)" \
--arg bash_version "$(component_version bash --version)" \
--arg git_version "$(component_version git --version)" \
--arg node_version "$(component_version node --version)" \
--arg dotnet_version "$(component_version dotnet --version)" \
--arg sqlserver_version "$(component_version sqlservr --version)" \
--arg nginx_version "$(component_version nginx -v)" \
--arg docker_version "$(component_version docker --version)" \
--arg docker_status "$(docker_status)" \
--arg sql_container_health "$(sql_container_health)" \
--arg nginx_status "$(nginx_status)" \
--arg hostname "$(hostname)" \
--arg host_overrides "$(local_host_overrides_present)" \
--arg firewall "$(firewall_summary)" \
--arg timezone "$(if have timedatectl; then timedatectl show -p Timezone --value 2>/dev/null; else printf 'unknown'; fi)" \
--arg timesync "$(time_sync_status)" \
--arg dns_configured "$(if have resolvectl && resolvectl dns >/dev/null 2>&1; then printf 'yes'; else printf 'unknown'; fi)" \
--arg failed_services "$failed_services" \
--arg listening_ports "$listening_ports" \
--arg filesystems "$filesystems" \
'{
generated_at: $generated_at,
operating_system: {distribution: $os, kernel: $kernel, architecture: $arch, dpkg_architecture: $dpkg_arch},
hardware: {cpu_model: $cpu_model, memory_total: $memory, swap_total: $swap},
storage: {
sql_status: $sql_status,
srv_status: $srv_status,
filesystems: ($filesystems | split("\n") | map(select(length > 0)))
},
package_state: {pending_updates: ($pending_updates | tonumber? // $pending_updates), reboot_required: $reboot_required},
platform_components: {
bash: $bash_version,
git: $git_version,
node: $node_version,
dotnet: $dotnet_version,
sql_server: $sqlserver_version,
nginx: $nginx_version,
docker: $docker_version,
docker_status: $docker_status,
sql_server_container_health: $sql_container_health,
nginx_status: $nginx_status
},
services: {failed_systemd_units: ($failed_services | split("\n") | map(select(length > 0)))},
network: {
hostname: $hostname,
local_host_overrides_present: $host_overrides,
listening_tcp_addresses: ($listening_ports | split("\n") | map(select(length > 0))),
firewall: $firewall
},
time_and_dns: {timezone: $timezone, time_synchronised: $timesync, dns_configured: $dns_configured}
}'
}
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
report="$(json_report)"
else
report="$(human_report)"
fi
write_output "$report"