Add Phase 2A installer framework and host validation
This commit is contained in:
parent
451b5b28d7
commit
6ff3a79cea
7
.gitignore
vendored
7
.gitignore
vendored
@ -38,6 +38,7 @@ overrides/
|
||||
*.log
|
||||
logs/
|
||||
log/
|
||||
atlas-logs/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
@ -83,6 +84,12 @@ releases/
|
||||
*.sql.gz
|
||||
*.sql.zip
|
||||
|
||||
# Generated Atlas reports
|
||||
reports/
|
||||
atlas-reports/
|
||||
*.report.json
|
||||
*.report.txt
|
||||
|
||||
# Package caches
|
||||
.npm/
|
||||
.pnpm-store/
|
||||
|
||||
36
README.md
36
README.md
@ -70,19 +70,34 @@ It must not contain secrets, passwords, tokens, private keys, live credentials,
|
||||
|
||||
Directory-level README files explain what belongs in each working area.
|
||||
|
||||
## Future Installation Model
|
||||
## Installation Model
|
||||
|
||||
Future installation scripts will live under `install/`. They will be ordered, idempotent Bash scripts, for example:
|
||||
Installation framework files live under `install/`. Phase 2A adds shared Bash libraries and a bootstrap scaffold, but component installation has not begun.
|
||||
|
||||
```text
|
||||
install/01-base-tools.sh
|
||||
install/02-dotnet.sh
|
||||
install/03-sqlserver.sh
|
||||
install/04-nginx.sh
|
||||
install/05-docker.sh
|
||||
Current commands:
|
||||
|
||||
```bash
|
||||
install/bootstrap.sh --help
|
||||
scripts/system/discover-system.sh
|
||||
scripts/system/validate-host.sh
|
||||
```
|
||||
|
||||
These scripts do not exist yet. Until they do, rebuild steps remain manual and are documented in [docs/REBUILD-GUIDE.md](docs/REBUILD-GUIDE.md).
|
||||
Future component installation scripts will live under `install/phases/`. They will be ordered, idempotent Bash scripts, for example:
|
||||
|
||||
```text
|
||||
install/phases/10-dotnet.sh
|
||||
install/phases/20-sql-server.sh
|
||||
install/phases/30-nginx.sh
|
||||
install/phases/40-docker.sh
|
||||
```
|
||||
|
||||
These component scripts do not exist yet. Until they do, rebuild steps remain manual and are documented in [docs/REBUILD-GUIDE.md](docs/REBUILD-GUIDE.md).
|
||||
|
||||
Generated logs and reports belong outside the repository:
|
||||
|
||||
- Installer logs: `/var/log/atlas/install/`
|
||||
- Discovery and validation reports: `/var/log/atlas/reports/`
|
||||
- Future installer state and markers: `/var/lib/atlas/`
|
||||
|
||||
## Change Control
|
||||
|
||||
@ -96,7 +111,8 @@ These scripts do not exist yet. Until they do, rebuild steps remain manual and a
|
||||
## Roadmap Summary
|
||||
|
||||
- Phase 1: Establish repository structure, standards, architecture documentation, rebuild guidance, disaster recovery guidance, operating model, and ADR process.
|
||||
- Future phases: Add idempotent installation scripts, configuration templates, operational scripts, smoke tests, backup automation, restore automation, deployment automation, and monitoring.
|
||||
- Phase 2A: Add shared installer framework, system discovery, host validation, runtime directory conventions, and validation thresholds.
|
||||
- Future phases: Add component installation scripts, configuration templates, operational scripts, smoke tests, backup automation, restore automation, deployment automation, and monitoring.
|
||||
|
||||
## Security Warning
|
||||
|
||||
|
||||
@ -39,7 +39,8 @@ Unknowns must be written exactly as `TODO: Confirm during the relevant implement
|
||||
- Print concise progress messages.
|
||||
- Use consistent prefixes: `[INFO]`, `[WARN]`, `[ERROR]`, `[OK]`.
|
||||
- Do not print secrets, tokens, passwords, connection strings, private keys, or certificate material.
|
||||
- Long-term logs must be stored outside this repository.
|
||||
- Installer logs must be stored outside this repository under `/var/log/atlas/install/`.
|
||||
- Generated discovery and validation reports must be stored outside this repository under `/var/log/atlas/reports/`.
|
||||
- Repository scripts may write temporary logs only under documented temporary paths and must clean them up where practical.
|
||||
|
||||
## Exit Codes
|
||||
@ -59,6 +60,7 @@ Unknowns must be written exactly as `TODO: Confirm during the relevant implement
|
||||
- Do not change ownership recursively outside documented paths.
|
||||
- Secrets outside the repository must use the narrowest practical permissions.
|
||||
- Templates in the repository must not contain live secrets.
|
||||
- Future persistent installer state and marker files belong under `/var/lib/atlas/`.
|
||||
|
||||
## Configuration Locations
|
||||
|
||||
@ -159,6 +161,7 @@ Unknowns must be written exactly as `TODO: Confirm during the relevant implement
|
||||
- Tests must avoid requiring secrets where practical.
|
||||
- Tests must not mutate production data unless explicitly designed and approved.
|
||||
- Run relevant tests before committing.
|
||||
- Host validation thresholds are `/` 10 GiB, `/srv` 20 GiB, and `/sql` 20 GiB unless an approved implementation phase changes them.
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
|
||||
@ -56,6 +56,7 @@ Branching policy is `TODO: Confirm during the relevant implementation phase.`
|
||||
The repository is intended to grow into the version-controlled control plane for Atlas. Planned components include:
|
||||
|
||||
- Idempotent installation scripts under `install/`.
|
||||
- Shared installer framework libraries under `install/lib/`.
|
||||
- Managed configuration templates under `config/` and `templates/`.
|
||||
- Operational scripts under `scripts/`.
|
||||
- Smoke tests under `tests/`.
|
||||
@ -121,6 +122,9 @@ The intended storage and responsibility model is:
|
||||
|
||||
- Source repositories: `/srv/repos`
|
||||
- Infrastructure repository: `/srv/repos/AI-Development-Server`
|
||||
- Installer logs: `/var/log/atlas/install/`
|
||||
- Discovery and validation reports: `/var/log/atlas/reports/`
|
||||
- Future installer state and markers: `/var/lib/atlas/`
|
||||
- Build artefacts: `TODO: Confirm during the relevant implementation phase.`
|
||||
- Deployed applications: `TODO: Confirm during the relevant implementation phase.`
|
||||
- SQL data: `/sql`
|
||||
|
||||
@ -6,6 +6,58 @@ This file records chronological Atlas build and infrastructure milestones. Do no
|
||||
|
||||
### Date
|
||||
|
||||
2026-07-10
|
||||
|
||||
### Phase
|
||||
|
||||
Phase 2A.
|
||||
|
||||
### Objective
|
||||
|
||||
Create the shared installation framework, system-discovery tooling, and host-validation conventions used by later Atlas installation scripts.
|
||||
|
||||
### Changes
|
||||
|
||||
- Added shared Bash installer libraries under `install/lib/`.
|
||||
- Added `install/bootstrap.sh` as a read-only Phase 2A scaffold.
|
||||
- Added baseline command manifest under `install/manifests/`.
|
||||
- Added system discovery tooling under `scripts/system/`.
|
||||
- Added host validation tooling under `scripts/system/`.
|
||||
- Documented runtime directories for logs, reports, and future state.
|
||||
- Documented free-space validation thresholds for `/`, `/srv`, and `/sql`.
|
||||
- No .NET, SQL Server, nginx, or Docker installation was started.
|
||||
|
||||
### Validation
|
||||
|
||||
- Bash syntax validation passed for all Phase 2A shell scripts.
|
||||
- `install/bootstrap.sh --help`, `--version`, and `--dry-run` passed.
|
||||
- `scripts/system/discover-system.sh` passed in human-readable and JSON modes.
|
||||
- Discovery JSON parsed successfully with `jq`.
|
||||
- `scripts/system/validate-host.sh` passed with no required failures.
|
||||
- Controlled failure-path tests returned the expected validation and usage exit codes.
|
||||
- Generated logs and reports were written outside the repository.
|
||||
|
||||
### Problems
|
||||
|
||||
- No unresolved Phase 2A implementation problems recorded.
|
||||
|
||||
### Decisions
|
||||
|
||||
- Installer logs use `/var/log/atlas/install/`.
|
||||
- Generated reports use `/var/log/atlas/reports/`.
|
||||
- Future persistent installer state and marker files use `/var/lib/atlas/`.
|
||||
- Phase 2A does not create speculative future component installer scripts.
|
||||
|
||||
### Git Commit
|
||||
|
||||
Not yet committed.
|
||||
|
||||
### Operator
|
||||
|
||||
Codex.
|
||||
|
||||
### Date
|
||||
|
||||
Not recorded.
|
||||
|
||||
### Phase
|
||||
|
||||
@ -99,6 +99,15 @@ For documentation-only changes, validation should include:
|
||||
|
||||
For scripts and service changes, validation should also include syntax checks, dry runs where available, and smoke tests.
|
||||
|
||||
Phase 2A adds two read-only host checks that should be used before later installation phases:
|
||||
|
||||
```bash
|
||||
scripts/system/discover-system.sh
|
||||
scripts/system/validate-host.sh
|
||||
```
|
||||
|
||||
Generated logs and reports should remain outside the repository unless a sanitised snapshot is deliberately reviewed and committed.
|
||||
|
||||
## Rollback Expectations
|
||||
|
||||
- Prefer changes that can be reversed through Git and idempotent scripts.
|
||||
|
||||
@ -5,7 +5,10 @@ This guide describes the planned rebuild path for Atlas. At Phase 1, the reposit
|
||||
## Automation Status
|
||||
|
||||
- Repository structure and documentation: present.
|
||||
- Installation scripts: not yet created.
|
||||
- Shared installation framework: present.
|
||||
- System discovery script: present.
|
||||
- Host validation script: present.
|
||||
- Component installation scripts: not yet created.
|
||||
- Configuration templates: not yet created.
|
||||
- Operational scripts: not yet created.
|
||||
- Smoke tests: not yet created.
|
||||
@ -106,9 +109,11 @@ Planned bootstrap flow:
|
||||
1. Review [README.md](../README.md).
|
||||
2. Review [SERVER-STANDARDS.md](../SERVER-STANDARDS.md).
|
||||
3. Review this rebuild guide.
|
||||
4. Run base installation validation.
|
||||
5. Run ordered installation scripts from `install/` when they exist.
|
||||
6. Run smoke tests from `tests/` when they exist.
|
||||
4. Run `scripts/system/discover-system.sh`.
|
||||
5. Run `scripts/system/validate-host.sh`.
|
||||
6. Run `install/bootstrap.sh --dry-run`.
|
||||
7. Run ordered installation scripts from `install/phases/` when they exist.
|
||||
8. Run smoke tests from `tests/` when they exist.
|
||||
|
||||
Bootstrap command sequence is `TODO: Confirm during the relevant implementation phase.`
|
||||
|
||||
@ -117,11 +122,10 @@ Bootstrap command sequence is `TODO: Confirm during the relevant implementation
|
||||
Planned scripts may include:
|
||||
|
||||
```text
|
||||
install/01-base-tools.sh
|
||||
install/02-dotnet.sh
|
||||
install/03-sqlserver.sh
|
||||
install/04-nginx.sh
|
||||
install/05-docker.sh
|
||||
install/phases/10-dotnet.sh
|
||||
install/phases/20-sql-server.sh
|
||||
install/phases/30-nginx.sh
|
||||
install/phases/40-docker.sh
|
||||
```
|
||||
|
||||
These scripts do not exist yet. When created, each script must:
|
||||
@ -147,7 +151,9 @@ Minimum rebuild validation should confirm:
|
||||
- Required installation phases have run successfully.
|
||||
- Smoke tests pass.
|
||||
|
||||
Validation scripts are `TODO: Confirm during the relevant implementation phase.`
|
||||
Validation scripts are currently available at `scripts/system/discover-system.sh` and `scripts/system/validate-host.sh`.
|
||||
|
||||
Phase 2A host validation currently checks `/` has at least 10 GiB free, `/srv` has at least 20 GiB free, and `/sql` has at least 20 GiB free.
|
||||
|
||||
## 9. Restoring Services and Data
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
This directory is reserved for ordered, idempotent installation scripts for Atlas.
|
||||
|
||||
Phase 2A provides the shared Bash framework and a bootstrap scaffold. It does not install .NET, SQL Server, nginx, Docker, or other future platform components.
|
||||
|
||||
## What Belongs Here
|
||||
|
||||
- Bootstrap scripts.
|
||||
@ -9,6 +11,8 @@ This directory is reserved for ordered, idempotent installation scripts for Atla
|
||||
- Service installation scripts.
|
||||
- Storage, runtime, and tooling setup scripts.
|
||||
- Validation wrappers for installation phases.
|
||||
- Shared Bash framework libraries under `lib/`.
|
||||
- Component manifests under `manifests/`.
|
||||
|
||||
## What Must Not Be Stored Here
|
||||
|
||||
@ -21,23 +25,34 @@ This directory is reserved for ordered, idempotent installation scripts for Atla
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
Use a two-digit numeric prefix followed by a short lower-case hyphenated name:
|
||||
Future phase scripts should use a two-digit numeric prefix followed by a short lower-case hyphenated name:
|
||||
|
||||
```text
|
||||
01-base-tools.sh
|
||||
02-dotnet.sh
|
||||
03-sqlserver.sh
|
||||
04-nginx.sh
|
||||
05-docker.sh
|
||||
phases/10-dotnet.sh
|
||||
phases/20-sql-server.sh
|
||||
phases/30-nginx.sh
|
||||
phases/40-docker.sh
|
||||
phases/90-validate-platform.sh
|
||||
```
|
||||
|
||||
Scripts must follow [SERVER-STANDARDS.md](../SERVER-STANDARDS.md).
|
||||
|
||||
## Expected Future Structure
|
||||
|
||||
Phase scripts may be added directly under this directory at first. If the directory grows, shared helpers may be added under a clearly named subdirectory.
|
||||
Current Phase 2A structure:
|
||||
|
||||
Future structure is `TODO: Confirm during the relevant implementation phase.`
|
||||
```text
|
||||
bootstrap.sh
|
||||
lib/
|
||||
manifests/
|
||||
phases/
|
||||
```
|
||||
|
||||
Runtime output belongs outside the repository:
|
||||
|
||||
- `/var/log/atlas/install/`
|
||||
- `/var/log/atlas/reports/`
|
||||
- `/var/lib/atlas/`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
|
||||
101
install/bootstrap.sh
Executable file
101
install/bootstrap.sh
Executable file
@ -0,0 +1,101 @@
|
||||
#!/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 "${SCRIPT_DIR}/lib/common.sh"
|
||||
# shellcheck source=install/lib/logging.sh
|
||||
source "${SCRIPT_DIR}/lib/logging.sh"
|
||||
# shellcheck source=install/lib/packages.sh
|
||||
source "${SCRIPT_DIR}/lib/packages.sh"
|
||||
# shellcheck source=install/lib/services.sh
|
||||
source "${SCRIPT_DIR}/lib/services.sh"
|
||||
# shellcheck source=install/lib/files.sh
|
||||
source "${SCRIPT_DIR}/lib/files.sh"
|
||||
# shellcheck source=install/lib/validation.sh
|
||||
source "${SCRIPT_DIR}/lib/validation.sh"
|
||||
|
||||
atlas_set_script_name "bootstrap.sh"
|
||||
atlas_install_error_trap
|
||||
atlas_install_cleanup_trap
|
||||
|
||||
RUN_VALIDATION=0
|
||||
INIT_LOG=0
|
||||
ATLAS_INSTALL_LOG_DIR="/var/log/atlas/install"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: install/bootstrap.sh [options]
|
||||
|
||||
Phase 2A bootstrap scaffold. This command does not install packages,
|
||||
alter services, change firewall rules, or modify system configuration.
|
||||
|
||||
Options:
|
||||
--help Show this help text.
|
||||
--version Show script/framework version.
|
||||
--dry-run Show intended actions without making changes.
|
||||
--verbose Print additional diagnostic output.
|
||||
--log Initialise a timestamped installer log file.
|
||||
--validate-host Run the read-only host validation script.
|
||||
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--help)
|
||||
usage
|
||||
exit "${ATLAS_EXIT_OK}"
|
||||
;;
|
||||
--version)
|
||||
atlas_print_version
|
||||
exit "${ATLAS_EXIT_OK}"
|
||||
;;
|
||||
--dry-run)
|
||||
atlas_enable_dry_run
|
||||
;;
|
||||
--verbose)
|
||||
atlas_enable_verbose
|
||||
;;
|
||||
--log)
|
||||
INIT_LOG=1
|
||||
;;
|
||||
--validate-host)
|
||||
RUN_VALIDATION=1
|
||||
;;
|
||||
*)
|
||||
atlas_usage_error "Unknown option: $1"
|
||||
exit "${ATLAS_EXIT_USAGE}"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ "$INIT_LOG" == "1" ]]; then
|
||||
atlas_init_log_file "$ATLAS_INSTALL_LOG_DIR" "bootstrap"
|
||||
fi
|
||||
|
||||
atlas_log_info "Atlas bootstrap scaffold"
|
||||
atlas_log_info "Repository root: ${REPO_ROOT}"
|
||||
atlas_log_info "Dry-run mode: ${ATLAS_DRY_RUN}"
|
||||
|
||||
atlas_require_command bash
|
||||
atlas_require_command git
|
||||
atlas_require_command systemctl
|
||||
atlas_require_command findmnt
|
||||
atlas_require_command df
|
||||
|
||||
atlas_log_info "Planned future phase directory: ${SCRIPT_DIR}/phases"
|
||||
atlas_log_info "No component installers are invoked during Phase 2A"
|
||||
|
||||
if [[ "$RUN_VALIDATION" == "1" ]]; then
|
||||
atlas_log_info "Running read-only host validation"
|
||||
"${REPO_ROOT}/scripts/system/validate-host.sh"
|
||||
else
|
||||
atlas_log_info "Host validation not requested; use --validate-host to run it"
|
||||
fi
|
||||
|
||||
atlas_log_ok "Bootstrap scaffold completed"
|
||||
|
||||
113
install/lib/common.sh
Normal file
113
install/lib/common.sh
Normal file
@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${ATLAS_COMMON_SH_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
ATLAS_COMMON_SH_LOADED=1
|
||||
|
||||
ATLAS_FRAMEWORK_VERSION="2.0.0"
|
||||
|
||||
ATLAS_EXIT_OK=0
|
||||
ATLAS_EXIT_GENERAL=1
|
||||
ATLAS_EXIT_USAGE=2
|
||||
ATLAS_EXIT_PREREQ=3
|
||||
ATLAS_EXIT_VALIDATION=4
|
||||
ATLAS_EXIT_UNSAFE=5
|
||||
ATLAS_EXIT_INTERNAL=10
|
||||
|
||||
ATLAS_DRY_RUN=0
|
||||
ATLAS_VERBOSE=0
|
||||
ATLAS_SCRIPT_NAME="$(basename "${0:-atlas-script}")"
|
||||
|
||||
atlas_strict_mode() {
|
||||
set -Eeuo pipefail
|
||||
}
|
||||
|
||||
atlas_set_script_name() {
|
||||
ATLAS_SCRIPT_NAME="$1"
|
||||
}
|
||||
|
||||
atlas_enable_dry_run() {
|
||||
ATLAS_DRY_RUN=1
|
||||
}
|
||||
|
||||
atlas_enable_verbose() {
|
||||
ATLAS_VERBOSE=1
|
||||
}
|
||||
|
||||
atlas_is_dry_run() {
|
||||
[[ "${ATLAS_DRY_RUN}" == "1" ]]
|
||||
}
|
||||
|
||||
atlas_is_verbose() {
|
||||
[[ "${ATLAS_VERBOSE}" == "1" ]]
|
||||
}
|
||||
|
||||
atlas_usage_error() {
|
||||
local message="$1"
|
||||
printf 'ERROR: %s\n' "$message" >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
atlas_require_command() {
|
||||
local command_name="$1"
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
printf 'ERROR: required command not found: %s\n' "$command_name" >&2
|
||||
return "${ATLAS_EXIT_PREREQ}"
|
||||
fi
|
||||
}
|
||||
|
||||
atlas_have_command() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
atlas_require_sudo_noninteractive() {
|
||||
if [[ "${EUID}" -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
sudo -n true >/dev/null 2>&1
|
||||
}
|
||||
|
||||
atlas_run() {
|
||||
if atlas_is_dry_run; then
|
||||
printf '[DRY-RUN] %s\n' "$*"
|
||||
return 0
|
||||
fi
|
||||
"$@"
|
||||
}
|
||||
|
||||
atlas_sudo_run() {
|
||||
if [[ "${EUID}" -eq 0 ]]; then
|
||||
atlas_run "$@"
|
||||
else
|
||||
atlas_run sudo -n "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
atlas_print_version() {
|
||||
printf '%s %s\n' "${ATLAS_SCRIPT_NAME}" "${ATLAS_FRAMEWORK_VERSION}"
|
||||
}
|
||||
|
||||
atlas_install_error_trap() {
|
||||
trap 'atlas_on_error $? $LINENO "$BASH_COMMAND"' ERR
|
||||
}
|
||||
|
||||
atlas_install_cleanup_trap() {
|
||||
trap 'atlas_on_exit' EXIT
|
||||
}
|
||||
|
||||
atlas_on_error() {
|
||||
local exit_code="$1"
|
||||
local line_no="$2"
|
||||
local command_text="$3"
|
||||
if declare -F atlas_log_error >/dev/null 2>&1; then
|
||||
atlas_log_error "Command failed at line ${line_no} with exit ${exit_code}: ${command_text}"
|
||||
else
|
||||
printf 'ERROR: command failed at line %s with exit %s\n' "$line_no" "$exit_code" >&2
|
||||
fi
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
atlas_on_exit() {
|
||||
:
|
||||
}
|
||||
120
install/lib/files.sh
Normal file
120
install/lib/files.sh
Normal file
@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${ATLAS_FILES_SH_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
ATLAS_FILES_SH_LOADED=1
|
||||
|
||||
atlas_ensure_directory() {
|
||||
local path="$1"
|
||||
local mode="${2:-0750}"
|
||||
local owner="${3:-}"
|
||||
|
||||
if [[ -d "$path" ]]; then
|
||||
atlas_log_verbose "Directory already exists: ${path}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${ATLAS_DRY_RUN:-0}" == "1" ]]; then
|
||||
atlas_log_info "Dry-run mode: would create directory ${path} with mode ${mode}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$owner" ]]; then
|
||||
atlas_sudo_run install -d -m "$mode" -o "${owner%%:*}" -g "${owner##*:}" "$path"
|
||||
else
|
||||
atlas_sudo_run install -d -m "$mode" "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
atlas_backup_file() {
|
||||
local path="$1"
|
||||
local backup_dir="${2:-$(dirname "$path")}"
|
||||
local timestamp
|
||||
timestamp="$(date -u '+%Y%m%dT%H%M%SZ')"
|
||||
|
||||
if [[ ! -e "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local backup_path="${backup_dir}/$(basename "$path").${timestamp}.bak"
|
||||
if [[ "${ATLAS_DRY_RUN:-0}" == "1" ]]; then
|
||||
atlas_log_info "Dry-run mode: would back up ${path} to ${backup_path}"
|
||||
printf '%s\n' "$backup_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "$backup_dir" ]]; then
|
||||
if [[ -w "$(dirname "$backup_dir")" ]]; then
|
||||
install -d -m 0750 "$backup_dir"
|
||||
else
|
||||
atlas_sudo_run install -d -m 0750 "$backup_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -w "$backup_dir" && -r "$path" ]]; then
|
||||
cp -a "$path" "$backup_path"
|
||||
else
|
||||
atlas_sudo_run cp -a "$path" "$backup_path"
|
||||
fi
|
||||
atlas_log_info "Backed up ${path} to ${backup_path}"
|
||||
printf '%s\n' "$backup_path"
|
||||
}
|
||||
|
||||
atlas_replace_file_if_changed() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local mode="${3:-0644}"
|
||||
local owner="${4:-}"
|
||||
local backup_dir="${5:-$(dirname "$destination")}"
|
||||
|
||||
if [[ ! -f "$source" ]]; then
|
||||
atlas_log_error "Replacement source is not a file: ${source}"
|
||||
return "${ATLAS_EXIT_USAGE:-2}"
|
||||
fi
|
||||
|
||||
if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then
|
||||
atlas_log_ok "No change required for ${destination}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${ATLAS_DRY_RUN:-0}" == "1" ]]; then
|
||||
if [[ -e "$destination" ]]; then
|
||||
atlas_log_info "Dry-run mode: would back up and replace ${destination}"
|
||||
else
|
||||
atlas_log_info "Dry-run mode: would create ${destination}"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -e "$destination" ]]; then
|
||||
atlas_backup_file "$destination" "$backup_dir" >/dev/null
|
||||
fi
|
||||
|
||||
local destination_dir tmp_path
|
||||
destination_dir="$(dirname "$destination")"
|
||||
if [[ -w "$destination_dir" ]]; then
|
||||
tmp_path="$(mktemp "${destination}.tmp.XXXXXX")"
|
||||
else
|
||||
tmp_path="$(mktemp)"
|
||||
fi
|
||||
cp "$source" "$tmp_path"
|
||||
chmod "$mode" "$tmp_path"
|
||||
if [[ -n "$owner" ]]; then
|
||||
if [[ "${EUID}" -eq 0 ]]; then
|
||||
chown "$owner" "$tmp_path"
|
||||
elif [[ -w "$destination_dir" ]]; then
|
||||
chown "$owner" "$tmp_path"
|
||||
else
|
||||
sudo -n chown "$owner" "$tmp_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -w "$destination_dir" ]]; then
|
||||
mv "$tmp_path" "$destination"
|
||||
else
|
||||
atlas_sudo_run install -m "$mode" "$tmp_path" "$destination"
|
||||
rm -f "$tmp_path"
|
||||
fi
|
||||
atlas_log_ok "Updated ${destination}"
|
||||
}
|
||||
84
install/lib/logging.sh
Normal file
84
install/lib/logging.sh
Normal file
@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${ATLAS_LOGGING_SH_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
ATLAS_LOGGING_SH_LOADED=1
|
||||
|
||||
ATLAS_LOG_FILE="${ATLAS_LOG_FILE:-}"
|
||||
ATLAS_LOG_TO_FILE=0
|
||||
|
||||
atlas_timestamp() {
|
||||
date -u '+%Y-%m-%dT%H:%M:%SZ'
|
||||
}
|
||||
|
||||
atlas_log_line() {
|
||||
local level="$1"
|
||||
shift
|
||||
local message="$*"
|
||||
local line
|
||||
line="$(printf '%s [%s] %s' "$(atlas_timestamp)" "$level" "$message")"
|
||||
printf '%s\n' "$line"
|
||||
if [[ "${ATLAS_LOG_TO_FILE}" == "1" && -n "${ATLAS_LOG_FILE}" ]]; then
|
||||
printf '%s\n' "$line" >>"${ATLAS_LOG_FILE}" 2>/dev/null || ATLAS_LOG_TO_FILE=0
|
||||
fi
|
||||
}
|
||||
|
||||
atlas_log_info() {
|
||||
atlas_log_line "INFO" "$@"
|
||||
}
|
||||
|
||||
atlas_log_warn() {
|
||||
atlas_log_line "WARN" "$@"
|
||||
}
|
||||
|
||||
atlas_log_error() {
|
||||
atlas_log_line "ERROR" "$@" >&2
|
||||
}
|
||||
|
||||
atlas_log_ok() {
|
||||
atlas_log_line "OK" "$@"
|
||||
}
|
||||
|
||||
atlas_log_verbose() {
|
||||
if [[ "${ATLAS_VERBOSE:-0}" == "1" ]]; then
|
||||
atlas_log_line "DEBUG" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
atlas_init_log_file() {
|
||||
local log_dir="$1"
|
||||
local name="${2:-${ATLAS_SCRIPT_NAME:-atlas}}"
|
||||
local timestamp
|
||||
timestamp="$(date -u '+%Y%m%dT%H%M%SZ')"
|
||||
ATLAS_LOG_FILE="${log_dir}/${name}-${timestamp}.log"
|
||||
|
||||
if [[ "${ATLAS_DRY_RUN:-0}" == "1" ]]; then
|
||||
atlas_log_info "Dry-run mode: would initialise log file ${ATLAS_LOG_FILE}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "$log_dir" ]]; then
|
||||
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
sudo -n install -d -m 0750 -o "$(id -u)" -g "$(id -g)" "$log_dir" 2>/dev/null || {
|
||||
atlas_log_warn "File logging disabled; cannot create ${log_dir}"
|
||||
return 0
|
||||
}
|
||||
else
|
||||
atlas_log_warn "File logging disabled; ${log_dir} does not exist and sudo is unavailable"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -w "$log_dir" ]]; then
|
||||
: >"${ATLAS_LOG_FILE}" 2>/dev/null && chmod 0640 "${ATLAS_LOG_FILE}" 2>/dev/null && ATLAS_LOG_TO_FILE=1 || ATLAS_LOG_TO_FILE=0
|
||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
sudo -n touch "${ATLAS_LOG_FILE}" 2>/dev/null && sudo -n chown "$(id -u):$(id -g)" "${ATLAS_LOG_FILE}" 2>/dev/null && sudo -n chmod 0640 "${ATLAS_LOG_FILE}" 2>/dev/null && ATLAS_LOG_TO_FILE=1 || ATLAS_LOG_TO_FILE=0
|
||||
fi
|
||||
|
||||
if [[ "${ATLAS_LOG_TO_FILE}" == "1" ]]; then
|
||||
atlas_log_info "Logging to ${ATLAS_LOG_FILE}"
|
||||
else
|
||||
atlas_log_warn "File logging unavailable; continuing with console logging"
|
||||
fi
|
||||
}
|
||||
44
install/lib/packages.sh
Normal file
44
install/lib/packages.sh
Normal file
@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${ATLAS_PACKAGES_SH_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
ATLAS_PACKAGES_SH_LOADED=1
|
||||
|
||||
atlas_package_installed() {
|
||||
local package_name="$1"
|
||||
dpkg-query -W -f='${Status}' "$package_name" 2>/dev/null | grep -q '^install ok installed$'
|
||||
}
|
||||
|
||||
atlas_package_status() {
|
||||
local package_name="$1"
|
||||
if atlas_package_installed "$package_name"; then
|
||||
printf 'installed\n'
|
||||
else
|
||||
printf 'missing\n'
|
||||
fi
|
||||
}
|
||||
|
||||
atlas_install_packages() {
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
atlas_log_warn "No packages requested"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${ATLAS_DRY_RUN:-0}" == "1" ]]; then
|
||||
atlas_log_info "Dry-run mode: would install packages: $*"
|
||||
return 0
|
||||
fi
|
||||
|
||||
atlas_log_info "Installing packages: $*"
|
||||
atlas_sudo_run apt-get install -y "$@"
|
||||
}
|
||||
|
||||
atlas_pending_update_count() {
|
||||
apt list --upgradable 2>/dev/null | awk 'NR > 1 {count++} END {print count + 0}'
|
||||
}
|
||||
|
||||
atlas_reboot_required() {
|
||||
[[ -f /var/run/reboot-required ]]
|
||||
}
|
||||
|
||||
58
install/lib/services.sh
Normal file
58
install/lib/services.sh
Normal file
@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${ATLAS_SERVICES_SH_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
ATLAS_SERVICES_SH_LOADED=1
|
||||
|
||||
atlas_service_exists() {
|
||||
local service_name="$1"
|
||||
systemctl list-unit-files --type=service --no-legend "$service_name" 2>/dev/null | awk '{print $1}' | grep -qx "$service_name"
|
||||
}
|
||||
|
||||
atlas_service_active() {
|
||||
local service_name="$1"
|
||||
systemctl is-active --quiet "$service_name" 2>/dev/null
|
||||
}
|
||||
|
||||
atlas_service_status() {
|
||||
local service_name="$1"
|
||||
if ! atlas_service_exists "$service_name"; then
|
||||
printf 'missing\n'
|
||||
elif atlas_service_active "$service_name"; then
|
||||
printf 'active\n'
|
||||
else
|
||||
printf 'inactive\n'
|
||||
fi
|
||||
}
|
||||
|
||||
atlas_service_enable() {
|
||||
local service_name="$1"
|
||||
if ! atlas_service_exists "$service_name"; then
|
||||
atlas_log_warn "Service not found: ${service_name}"
|
||||
return "${ATLAS_EXIT_PREREQ:-3}"
|
||||
fi
|
||||
atlas_log_info "Enabling service ${service_name}"
|
||||
atlas_sudo_run systemctl enable "$service_name"
|
||||
}
|
||||
|
||||
atlas_service_start() {
|
||||
local service_name="$1"
|
||||
if ! atlas_service_exists "$service_name"; then
|
||||
atlas_log_warn "Service not found: ${service_name}"
|
||||
return "${ATLAS_EXIT_PREREQ:-3}"
|
||||
fi
|
||||
atlas_log_info "Starting service ${service_name}"
|
||||
atlas_sudo_run systemctl start "$service_name"
|
||||
}
|
||||
|
||||
atlas_service_restart() {
|
||||
local service_name="$1"
|
||||
if ! atlas_service_exists "$service_name"; then
|
||||
atlas_log_warn "Service not found: ${service_name}"
|
||||
return "${ATLAS_EXIT_PREREQ:-3}"
|
||||
fi
|
||||
atlas_log_info "Restarting service ${service_name}"
|
||||
atlas_sudo_run systemctl restart "$service_name"
|
||||
}
|
||||
|
||||
68
install/lib/validation.sh
Normal file
68
install/lib/validation.sh
Normal file
@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${ATLAS_VALIDATION_SH_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
ATLAS_VALIDATION_SH_LOADED=1
|
||||
|
||||
ATLAS_VALIDATION_PASS=0
|
||||
ATLAS_VALIDATION_WARNING=0
|
||||
ATLAS_VALIDATION_FAIL=0
|
||||
ATLAS_VALIDATION_SKIPPED=0
|
||||
|
||||
atlas_validation_reset() {
|
||||
ATLAS_VALIDATION_PASS=0
|
||||
ATLAS_VALIDATION_WARNING=0
|
||||
ATLAS_VALIDATION_FAIL=0
|
||||
ATLAS_VALIDATION_SKIPPED=0
|
||||
}
|
||||
|
||||
atlas_validation_result() {
|
||||
local status="$1"
|
||||
local message="$2"
|
||||
case "$status" in
|
||||
PASS)
|
||||
ATLAS_VALIDATION_PASS=$((ATLAS_VALIDATION_PASS + 1))
|
||||
;;
|
||||
WARNING)
|
||||
ATLAS_VALIDATION_WARNING=$((ATLAS_VALIDATION_WARNING + 1))
|
||||
;;
|
||||
FAIL)
|
||||
ATLAS_VALIDATION_FAIL=$((ATLAS_VALIDATION_FAIL + 1))
|
||||
;;
|
||||
SKIPPED)
|
||||
ATLAS_VALIDATION_SKIPPED=$((ATLAS_VALIDATION_SKIPPED + 1))
|
||||
;;
|
||||
*)
|
||||
status="FAIL"
|
||||
ATLAS_VALIDATION_FAIL=$((ATLAS_VALIDATION_FAIL + 1))
|
||||
;;
|
||||
esac
|
||||
printf '%-7s %s\n' "$status" "$message"
|
||||
}
|
||||
|
||||
atlas_validation_summary() {
|
||||
printf 'Summary: PASS=%s WARNING=%s FAIL=%s SKIPPED=%s\n' \
|
||||
"$ATLAS_VALIDATION_PASS" \
|
||||
"$ATLAS_VALIDATION_WARNING" \
|
||||
"$ATLAS_VALIDATION_FAIL" \
|
||||
"$ATLAS_VALIDATION_SKIPPED"
|
||||
}
|
||||
|
||||
atlas_validation_exit_code() {
|
||||
if [[ "$ATLAS_VALIDATION_FAIL" -gt 0 ]]; then
|
||||
return "${ATLAS_EXIT_VALIDATION:-4}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
atlas_bytes_available() {
|
||||
local path="$1"
|
||||
df -B1 --output=avail "$path" 2>/dev/null | awk 'NR == 2 {print $1}'
|
||||
}
|
||||
|
||||
atlas_gib_to_bytes() {
|
||||
local gib="$1"
|
||||
awk -v gib="$gib" 'BEGIN {printf "%.0f\n", gib * 1024 * 1024 * 1024}'
|
||||
}
|
||||
|
||||
32
install/manifests/baseline-commands.txt
Normal file
32
install/manifests/baseline-commands.txt
Normal file
@ -0,0 +1,32 @@
|
||||
# Atlas baseline command manifest
|
||||
#
|
||||
# Format:
|
||||
# command-name|required|purpose
|
||||
#
|
||||
# The first field is a command name that should be resolvable through PATH.
|
||||
# It is not a Debian package name. Package metadata belongs in a separate
|
||||
# manifest if later installer phases need it.
|
||||
#
|
||||
# Lines beginning with # and blank lines are ignored.
|
||||
|
||||
bash|required|Shell used by Atlas automation
|
||||
git|required|Repository operations
|
||||
git-lfs|required|Large-file support for repositories that require it
|
||||
node|required|Node.js runtime for Codex and JavaScript tooling
|
||||
npm|required|Node.js package manager supplied with the Node.js installation
|
||||
curl|required|HTTP client used by diagnostics and future repository setup
|
||||
wget|required|Download client used by diagnostics and future repository setup
|
||||
jq|required|JSON validation and report inspection
|
||||
rsync|required|File synchronisation helper
|
||||
tar|required|Archive handling
|
||||
openssl|required|TLS and cryptographic diagnostics
|
||||
python3|required|Portable JSON and text processing fallback
|
||||
systemctl|required|systemd service inspection and management
|
||||
ss|required|Listening socket inspection
|
||||
findmnt|required|Mount inspection
|
||||
df|required|Filesystem capacity inspection
|
||||
awk|required|Text processing
|
||||
sed|required|Text processing
|
||||
grep|required|Text searching
|
||||
sudo|required|Non-interactive privileged command execution
|
||||
|
||||
20
install/phases/README.md
Normal file
20
install/phases/README.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Installation Phases
|
||||
|
||||
This directory is reserved for future ordered Atlas installation phases.
|
||||
|
||||
Phase 2A intentionally does not create speculative .NET, SQL Server, nginx, or Docker installer scripts. Future scripts should be added only when their implementation phase is approved.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
Use a two-digit phase number and lower-case hyphenated component name:
|
||||
|
||||
```text
|
||||
10-dotnet.sh
|
||||
20-sql-server.sh
|
||||
30-nginx.sh
|
||||
40-docker.sh
|
||||
90-validate-platform.sh
|
||||
```
|
||||
|
||||
Each phase must source the shared framework under `install/lib/`, support dry-run behaviour where practical, and follow [../../SERVER-STANDARDS.md](../../SERVER-STANDARDS.md).
|
||||
|
||||
@ -10,6 +10,7 @@ This directory is for operational scripts used after Atlas is installed.
|
||||
- Maintenance scripts.
|
||||
- Monitoring scripts.
|
||||
- Service validation helpers.
|
||||
- Read-only system discovery and host validation scripts.
|
||||
|
||||
## What Must Not Be Stored Here
|
||||
|
||||
@ -33,10 +34,12 @@ Current planned areas include:
|
||||
|
||||
```text
|
||||
scripts/backup/
|
||||
scripts/development/
|
||||
scripts/deploy/
|
||||
scripts/maintenance/
|
||||
scripts/monitoring/
|
||||
scripts/restore/
|
||||
scripts/system/
|
||||
```
|
||||
|
||||
Additional areas may be added when a repeated operational need exists.
|
||||
@ -47,3 +50,4 @@ Additional areas may be added when a repeated operational need exists.
|
||||
- Do not store credentials in scripts.
|
||||
- Make destructive actions explicit and documented.
|
||||
- Include validation and rollback guidance for service-affecting scripts.
|
||||
- Discovery and validation scripts must not dump environment variables, private keys, tokens, or complete sensitive configuration files.
|
||||
|
||||
8
scripts/development/README.md
Normal file
8
scripts/development/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Development Scripts
|
||||
|
||||
This directory is reserved for development workflow helpers that do not belong to installation, deployment, backup, restore, monitoring, maintenance, or system validation.
|
||||
|
||||
Do not store secrets, generated logs, package caches, or one-off experiments here.
|
||||
|
||||
Future structure is `TODO: Confirm during the relevant implementation phase.`
|
||||
|
||||
331
scripts/system/discover-system.sh
Executable file
331
scripts/system/discover-system.sh
Executable file
@ -0,0 +1,331 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
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 '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 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
|
||||
},
|
||||
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"
|
||||
273
scripts/system/validate-host.sh
Executable file
273
scripts/system/validate-host.sh
Executable file
@ -0,0 +1,273 @@
|
||||
#!/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"
|
||||
# shellcheck source=install/lib/validation.sh
|
||||
source "${REPO_ROOT}/install/lib/validation.sh"
|
||||
|
||||
atlas_set_script_name "validate-host.sh"
|
||||
|
||||
EXPECTED_HOSTNAME="atlas"
|
||||
EXPECTED_REMOTE="git@git.catherinelynwood.com:atlas-bot/AI-Development-Server.git"
|
||||
EXPECTED_BRANCH="main"
|
||||
EXPECTED_UPSTREAM="origin/main"
|
||||
EXPECTED_REPO_PATH="/srv/repos/AI-Development-Server"
|
||||
|
||||
MIN_ROOT_FREE_GIB=10
|
||||
MIN_SRV_FREE_GIB=20
|
||||
MIN_SQL_FREE_GIB=20
|
||||
|
||||
# Critical-service policy for Phase 2A:
|
||||
# A failed unit is critical only if it affects SSH access, network identity,
|
||||
# DNS resolution, time synchronisation, or required Atlas mounts.
|
||||
CRITICAL_FAILED_UNIT_PATTERN='^(ssh|sshd|systemd-networkd|systemd-resolved|chrony)\.service$|^(sql|srv)\.mount$'
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/system/validate-host.sh [options]
|
||||
|
||||
Read-only Atlas host validation.
|
||||
|
||||
Options:
|
||||
--help Show this help text.
|
||||
--version Show script/framework version.
|
||||
--expected-hostname NAME Override expected hostname for controlled tests.
|
||||
--min-root-gib N Override minimum free GiB for /.
|
||||
--min-srv-gib N Override minimum free GiB for /srv.
|
||||
--min-sql-gib N Override minimum free GiB for /sql.
|
||||
|
||||
Exit codes:
|
||||
0 All required checks passed. Advisory warnings may exist.
|
||||
2 Invalid invocation.
|
||||
4 One or more required validation checks failed.
|
||||
10 Internal script error.
|
||||
|
||||
USAGE
|
||||
}
|
||||
|
||||
numeric_arg() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
if [[ ! "$value" =~ ^[0-9]+$ ]]; then
|
||||
atlas_usage_error "${name} requires a non-negative integer"
|
||||
exit "${ATLAS_EXIT_USAGE}"
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--help)
|
||||
usage
|
||||
exit "${ATLAS_EXIT_OK}"
|
||||
;;
|
||||
--version)
|
||||
atlas_print_version
|
||||
exit "${ATLAS_EXIT_OK}"
|
||||
;;
|
||||
--expected-hostname)
|
||||
[[ "$#" -ge 2 ]] || { atlas_usage_error "--expected-hostname requires a value"; exit "${ATLAS_EXIT_USAGE}"; }
|
||||
EXPECTED_HOSTNAME="$2"
|
||||
shift
|
||||
;;
|
||||
--min-root-gib)
|
||||
[[ "$#" -ge 2 ]] || { atlas_usage_error "--min-root-gib requires a value"; exit "${ATLAS_EXIT_USAGE}"; }
|
||||
numeric_arg "--min-root-gib" "$2"
|
||||
MIN_ROOT_FREE_GIB="$2"
|
||||
shift
|
||||
;;
|
||||
--min-srv-gib)
|
||||
[[ "$#" -ge 2 ]] || { atlas_usage_error "--min-srv-gib requires a value"; exit "${ATLAS_EXIT_USAGE}"; }
|
||||
numeric_arg "--min-srv-gib" "$2"
|
||||
MIN_SRV_FREE_GIB="$2"
|
||||
shift
|
||||
;;
|
||||
--min-sql-gib)
|
||||
[[ "$#" -ge 2 ]] || { atlas_usage_error "--min-sql-gib requires a value"; exit "${ATLAS_EXIT_USAGE}"; }
|
||||
numeric_arg "--min-sql-gib" "$2"
|
||||
MIN_SQL_FREE_GIB="$2"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
atlas_usage_error "Unknown option: $1"
|
||||
exit "${ATLAS_EXIT_USAGE}"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
check_command() {
|
||||
local command_name="$1"
|
||||
if command -v "$command_name" >/dev/null 2>&1; then
|
||||
atlas_validation_result PASS "Command available: ${command_name}"
|
||||
else
|
||||
atlas_validation_result FAIL "Required command missing: ${command_name}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_mount() {
|
||||
local path="$1"
|
||||
local root_source mount_source
|
||||
if [[ -d "$path" ]]; then
|
||||
atlas_validation_result PASS "${path} exists"
|
||||
else
|
||||
atlas_validation_result FAIL "${path} does not exist"
|
||||
return
|
||||
fi
|
||||
|
||||
if findmnt -rn --target "$path" >/dev/null 2>&1; then
|
||||
atlas_validation_result PASS "${path} is mounted"
|
||||
else
|
||||
atlas_validation_result FAIL "${path} is not mounted"
|
||||
return
|
||||
fi
|
||||
|
||||
root_source="$(findmnt -rn --target / -o SOURCE 2>/dev/null || true)"
|
||||
mount_source="$(findmnt -rn --target "$path" -o SOURCE 2>/dev/null || true)"
|
||||
if [[ -n "$mount_source" && "$mount_source" != "$root_source" ]]; then
|
||||
atlas_validation_result PASS "${path} is a separate mounted filesystem"
|
||||
else
|
||||
atlas_validation_result FAIL "${path} is not a separate mounted filesystem"
|
||||
fi
|
||||
|
||||
if [[ -w "$path" ]]; then
|
||||
atlas_validation_result PASS "${path} is writable by the operational user"
|
||||
else
|
||||
atlas_validation_result FAIL "${path} is not writable by the operational user"
|
||||
fi
|
||||
}
|
||||
|
||||
check_free_space() {
|
||||
local path="$1"
|
||||
local min_gib="$2"
|
||||
local available required
|
||||
available="$(atlas_bytes_available "$path" || true)"
|
||||
required="$(atlas_gib_to_bytes "$min_gib")"
|
||||
if [[ -z "$available" ]]; then
|
||||
atlas_validation_result FAIL "Could not determine free space for ${path}"
|
||||
elif (( available >= required )); then
|
||||
atlas_validation_result PASS "${path} has at least ${min_gib} GiB free"
|
||||
else
|
||||
atlas_validation_result FAIL "${path} has less than ${min_gib} GiB free"
|
||||
fi
|
||||
}
|
||||
|
||||
check_git_state() {
|
||||
if [[ -d "${EXPECTED_REPO_PATH}/.git" ]]; then
|
||||
atlas_validation_result PASS "Repository exists at ${EXPECTED_REPO_PATH}"
|
||||
else
|
||||
atlas_validation_result FAIL "Repository missing at ${EXPECTED_REPO_PATH}"
|
||||
return
|
||||
fi
|
||||
|
||||
local remote_url branch upstream
|
||||
remote_url="$(git -C "$EXPECTED_REPO_PATH" remote get-url origin 2>/dev/null || true)"
|
||||
branch="$(git -C "$EXPECTED_REPO_PATH" branch --show-current 2>/dev/null || true)"
|
||||
upstream="$(git -C "$EXPECTED_REPO_PATH" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$remote_url" ]]; then
|
||||
atlas_validation_result PASS "Git remote origin is configured"
|
||||
else
|
||||
atlas_validation_result FAIL "Git remote origin is not configured"
|
||||
fi
|
||||
|
||||
if [[ "$remote_url" == "$EXPECTED_REMOTE" ]]; then
|
||||
atlas_validation_result PASS "Expected remote repository is configured"
|
||||
else
|
||||
atlas_validation_result FAIL "Expected remote repository is not configured"
|
||||
fi
|
||||
|
||||
if [[ "$branch" == "$EXPECTED_BRANCH" ]]; then
|
||||
atlas_validation_result PASS "Current branch is ${EXPECTED_BRANCH}"
|
||||
else
|
||||
atlas_validation_result FAIL "Current branch is ${branch:-unknown}, expected ${EXPECTED_BRANCH}"
|
||||
fi
|
||||
|
||||
if [[ "$upstream" == "$EXPECTED_UPSTREAM" ]]; then
|
||||
atlas_validation_result PASS "Upstream is ${EXPECTED_UPSTREAM}"
|
||||
else
|
||||
atlas_validation_result FAIL "Upstream is ${upstream:-unset}, expected ${EXPECTED_UPSTREAM}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_failed_services() {
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
atlas_validation_result SKIPPED "systemctl unavailable; failed-service check skipped"
|
||||
return
|
||||
fi
|
||||
|
||||
local failed_units critical_units warning_units
|
||||
failed_units="$(systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}' || true)"
|
||||
if [[ -z "$failed_units" ]]; then
|
||||
atlas_validation_result PASS "No failed systemd units"
|
||||
return
|
||||
fi
|
||||
|
||||
critical_units="$(printf '%s\n' "$failed_units" | grep -E "$CRITICAL_FAILED_UNIT_PATTERN" || true)"
|
||||
warning_units="$(printf '%s\n' "$failed_units" | grep -Ev "$CRITICAL_FAILED_UNIT_PATTERN" || true)"
|
||||
|
||||
if [[ -n "$critical_units" ]]; then
|
||||
atlas_validation_result FAIL "Critical failed systemd units: $(printf '%s' "$critical_units" | paste -sd ',')"
|
||||
fi
|
||||
if [[ -n "$warning_units" ]]; then
|
||||
atlas_validation_result WARNING "Non-critical failed systemd units: $(printf '%s' "$warning_units" | paste -sd ',')"
|
||||
fi
|
||||
}
|
||||
|
||||
atlas_validation_reset
|
||||
|
||||
printf 'Atlas Host Validation\n'
|
||||
printf 'Free-space thresholds: /=%s GiB, /srv=%s GiB, /sql=%s GiB\n\n' \
|
||||
"$MIN_ROOT_FREE_GIB" "$MIN_SRV_FREE_GIB" "$MIN_SQL_FREE_GIB"
|
||||
|
||||
if [[ "$(hostname)" == "$EXPECTED_HOSTNAME" ]]; then
|
||||
atlas_validation_result PASS "Hostname is ${EXPECTED_HOSTNAME}"
|
||||
else
|
||||
atlas_validation_result FAIL "Hostname is $(hostname), expected ${EXPECTED_HOSTNAME}"
|
||||
fi
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
atlas_validation_result PASS "Architecture is supported: $(uname -m)"
|
||||
;;
|
||||
*)
|
||||
atlas_validation_result FAIL "Architecture is unsupported: $(uname -m)"
|
||||
;;
|
||||
esac
|
||||
|
||||
check_mount /sql
|
||||
check_mount /srv
|
||||
check_git_state
|
||||
|
||||
while IFS='|' read -r command_name requirement _purpose; do
|
||||
[[ -z "$command_name" || "$command_name" =~ ^# ]] && continue
|
||||
if [[ "$requirement" == "required" ]]; then
|
||||
check_command "$command_name"
|
||||
fi
|
||||
done <"${REPO_ROOT}/install/manifests/baseline-commands.txt"
|
||||
|
||||
if sudo -n true >/dev/null 2>&1; then
|
||||
atlas_validation_result PASS "Passwordless non-interactive sudo works"
|
||||
else
|
||||
atlas_validation_result FAIL "Passwordless non-interactive sudo does not work"
|
||||
fi
|
||||
|
||||
check_free_space / "$MIN_ROOT_FREE_GIB"
|
||||
check_free_space /srv "$MIN_SRV_FREE_GIB"
|
||||
check_free_space /sql "$MIN_SQL_FREE_GIB"
|
||||
|
||||
check_failed_services
|
||||
|
||||
if [[ -f /var/run/reboot-required ]]; then
|
||||
atlas_validation_result WARNING "Reboot is pending"
|
||||
else
|
||||
atlas_validation_result PASS "No reboot pending"
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
atlas_validation_summary
|
||||
atlas_validation_exit_code
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user