#!/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 } 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 '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 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 }, 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"