Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c138d9201e | |||
| ba5a8c9397 | |||
| a08efd54c4 | |||
| 3e78f8afe6 | |||
| afea447887 | |||
| e2b3a0a88d | |||
| 3fe7959850 | |||
| 46f42c8893 | |||
| f9af0f4823 | |||
| a25caf6f3d | |||
| f3c649341a | |||
| 2ed34b97be | |||
| 9de65f9aa5 |
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Apply consistent coding standards, preserve existing project conventions, and avoid introducing breaking changes in all files unless explicitly requested.
|
||||||
|
|
||||||
|
Also read and consider the repository state file when it exists:
|
||||||
|
- `state.md`
|
||||||
|
|
||||||
|
When this file is present:
|
||||||
|
- use it as evolving project context,
|
||||||
|
- keep recommendations aligned with it,
|
||||||
|
- update suggestions to remain consistent with documented architecture, constraints, and review notes,
|
||||||
|
- update `state.md` as part of the same task whenever changes materially affect repository behavior, architecture, constraints, or review notes.
|
||||||
@@ -1,32 +1,117 @@
|
|||||||
# netupgrade
|
# netupgrade
|
||||||
|
|
||||||
Servers full upgrade script
|
Interactive CLI tool to run upgrade and maintenance actions on multiple remote hosts over SSH.
|
||||||
|
|
||||||
## Where to use
|
## Where to use
|
||||||
|
|
||||||
- On a dedicated server (bastion, ...)
|
- On a dedicated server (bastion, jump host, ...)
|
||||||
- On your computer with an alias to your dedicated server
|
- On your computer with an alias to your dedicated server
|
||||||
- On your computer (not recommended)
|
- On your computer directly (not recommended)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Select one or more hosts from an interactive checklist
|
||||||
|
- Run predefined actions on each selected host
|
||||||
|
- Write execution logs to `~/netupgrade.log`
|
||||||
|
|
||||||
|
Supported actions:
|
||||||
|
|
||||||
|
- `apt`
|
||||||
|
- `yum`
|
||||||
|
- `pkg`
|
||||||
|
- `pacman`
|
||||||
|
- `apk`
|
||||||
|
- `reboot`
|
||||||
|
- `cmd:<remote command>`
|
||||||
|
- `docker-stacks:<directory>`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Required locally:
|
||||||
|
|
||||||
|
- `bash`
|
||||||
|
- `ssh`
|
||||||
|
- `whiptail`
|
||||||
|
- core utilities such as `cat`, `tee`, `rm`, `touch`, and `mv`
|
||||||
|
|
||||||
|
Optional log viewer:
|
||||||
|
|
||||||
|
- `$EDITOR` if it points to an installed command
|
||||||
|
- otherwise one of: `nano`, `vi`, `less`
|
||||||
|
|
||||||
|
Remote hosts must also provide the commands needed by the configured actions, such as:
|
||||||
|
`apt-get`, `yum`, `pkg`, `pacman`, `apk`, `docker`, `docker compose`, or `reboot`.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### Bin as root
|
### Install the executable
|
||||||
|
|
||||||
``` bash
|
```bash README.md
|
||||||
cp bin/netupgrade to /usr/local/bin
|
cp bin/netupgrade /usr/local/bin/netupgrade
|
||||||
|
chmod +x /usr/local/bin/netupgrade
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config as user
|
### Create the config directory
|
||||||
|
|
||||||
``` bash
|
```bash README.md
|
||||||
mkdir -p ~/.config/netuprade
|
mkdir -p ~/.config/netupgrade
|
||||||
touch ~/.config/netuprade/index.cfg
|
touch ~/.config/netupgrade/index.cfg
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alias on your computer with a dedicated server
|
### Alias on your computer with a dedicated server
|
||||||
|
|
||||||
You can save it in your .bashrc
|
You can save it in your `.bashrc`
|
||||||
|
|
||||||
``` bash
|
```bash README.md
|
||||||
alias netupgrade='ssh -t user@10.0.0.10 netupgrade'
|
alias netupgrade='ssh -t user@10.0.0.10 netupgrade'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The default config file is:
|
||||||
|
|
||||||
|
```text README.md
|
||||||
|
~/.config/netupgrade/index.cfg
|
||||||
|
```
|
||||||
|
|
||||||
|
The script sources this file as Bash code. It must define a `NODES` array.
|
||||||
|
|
||||||
|
Each entry uses this format:
|
||||||
|
|
||||||
|
```text README.md
|
||||||
|
host;display-name;action1;action2;...
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash README.md
|
||||||
|
SSH_USER="root"
|
||||||
|
|
||||||
|
NODES=(
|
||||||
|
"192.168.1.10;web-01;apt;reboot"
|
||||||
|
"192.168.1.11;db-01;apt;cmd:systemctl restart postgresql"
|
||||||
|
"192.168.1.12;docker-01;docker-stacks:/opt/stacks"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash README.md
|
||||||
|
netupgrade [--help] [-f] [-y] [configfilename]
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- `--help`: show help
|
||||||
|
- `-f`: preselect all nodes in the interactive checklist
|
||||||
|
- `-y`: pass non-interactive confirmation flags to supported package managers
|
||||||
|
- `configfilename`: path to a config file
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- SSH connections use `root@host` by default and can be changed with `SSH_USER` in the config file
|
||||||
|
- `cmd:<remote command>` is executed through a remote shell, so shell operators such as pipes, redirections, `&&`, and `||` are supported
|
||||||
|
- The tool is interactive and intended for manual administration workflows
|
||||||
|
- After execution, the log file is opened with `$EDITOR` when available, otherwise with `nano`, `vi`, or `less`
|
||||||
|
- If no supported log viewer is available, the script keeps running and prints the log file path
|
||||||
|
- The configuration file is sourced as shell code, so only use trusted config files
|
||||||
|
|||||||
+202
-81
@@ -1,12 +1,47 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
showHelp() {
|
showHelp() {
|
||||||
echo "netupgrade [-f] [-y] [configfilename]"
|
echo "netupgrade [--help] [-f] [-y] [configfilename]"
|
||||||
echo ""
|
echo ""
|
||||||
echo " -f : Select all nodes"
|
echo " --help Show this help message"
|
||||||
echo " -y : No confirmation"
|
echo " -f Preselect all nodes in the checklist"
|
||||||
echo " -b : Breack on error"
|
echo " -y No confirmation for supported package managers"
|
||||||
echo " configfilename : a cfg filename"
|
echo " configfilename Path to a cfg file"
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDependencies() {
|
||||||
|
local -a REQUIRED_CMDS=(ssh whiptail cat tee rm touch mv)
|
||||||
|
local -a MISSING_CMDS=()
|
||||||
|
local CMD
|
||||||
|
|
||||||
|
for CMD in "${REQUIRED_CMDS[@]}"; do
|
||||||
|
if ! command -v "${CMD}" >/dev/null 2>&1; then
|
||||||
|
MISSING_CMDS+=("${CMD}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
|
||||||
|
echo "Error: missing required dependencies: ${MISSING_CMDS[*]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveLogViewer() {
|
||||||
|
if [ -n "${EDITOR}" ] && command -v "${EDITOR}" >/dev/null 2>&1; then
|
||||||
|
LOGVIEWER="${EDITOR}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local -a CANDIDATES=(nano vi less)
|
||||||
|
local CANDIDATE
|
||||||
|
for CANDIDATE in "${CANDIDATES[@]}"; do
|
||||||
|
if command -v "${CANDIDATE}" >/dev/null 2>&1; then
|
||||||
|
LOGVIEWER="${CANDIDATE}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
LOGVIEWER=""
|
||||||
}
|
}
|
||||||
|
|
||||||
pressAnyKey(){
|
pressAnyKey(){
|
||||||
@@ -23,25 +58,76 @@ loadConfig(){
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parseNode() {
|
||||||
|
local NODE_VALUE="${1}"
|
||||||
|
local -n NODE_FIELDS_REF="${2}"
|
||||||
|
IFS=';' read -r -a NODE_FIELDS_REF <<< "${NODE_VALUE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
parseSelection() {
|
||||||
|
local SELECTION_RAW="${1}"
|
||||||
|
local -n SELECTION_REF="${2}"
|
||||||
|
local ITEM_INDEX=0
|
||||||
|
|
||||||
|
SELECTION_REF=()
|
||||||
|
read -r -a SELECTION_REF <<< "${SELECTION_RAW}"
|
||||||
|
|
||||||
|
for ITEM_INDEX in "${!SELECTION_REF[@]}"; do
|
||||||
|
SELECTION_REF[ITEM_INDEX]="${SELECTION_REF[ITEM_INDEX]%\"}"
|
||||||
|
SELECTION_REF[ITEM_INDEX]="${SELECTION_REF[ITEM_INDEX]#\"}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
runSSH() {
|
||||||
|
local HOST="${1}"
|
||||||
|
shift
|
||||||
|
ssh "${SSH_USER}@${HOST}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
prependLogSummary() {
|
||||||
|
local SUMMARY_CONTENT="${1}"
|
||||||
|
local TEMP_LOGFILENAME="${LOGFILENAME}.tmp"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "Results :"
|
||||||
|
echo "---------"
|
||||||
|
echo ""
|
||||||
|
echo -e "${SUMMARY_CONTENT}"
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
cat "${LOGFILENAME}"
|
||||||
|
} > "${TEMP_LOGFILENAME}"
|
||||||
|
mv "${TEMP_LOGFILENAME}" "${LOGFILENAME}"
|
||||||
|
}
|
||||||
|
|
||||||
selectNodes(){
|
selectNodes(){
|
||||||
local -a OPTIONS=()
|
local -a OPTIONS=()
|
||||||
|
local -a SELECTED_ITEMS=()
|
||||||
local -i INDEX=1
|
local -i INDEX=1
|
||||||
|
local -i I=0
|
||||||
|
local -a FIELDS=()
|
||||||
|
local DESC=""
|
||||||
|
local FIELD=""
|
||||||
|
local SEL=""
|
||||||
|
local DEFAULT_STATE="OFF"
|
||||||
|
|
||||||
|
if [ "${FULL}" -eq 1 ]; then
|
||||||
|
DEFAULT_STATE="ON"
|
||||||
|
fi
|
||||||
|
|
||||||
for NODE in "${NODES[@]}"; do
|
for NODE in "${NODES[@]}"; do
|
||||||
# shellcheck disable=SC2206
|
FIELDS=()
|
||||||
local FIELDS=(${NODE//;/ })
|
DESC=""
|
||||||
local DESKSKIP=0
|
parseNode "${NODE}" FIELDS
|
||||||
local DESC=""
|
for ((I = 2; I < ${#FIELDS[@]}; ++I)); do
|
||||||
for FIELD in "${FIELDS[@]}"; do
|
FIELD="${FIELDS[I]}"
|
||||||
if [ ${DESKSKIP} -gt 1 ]; then
|
if [ -z "${DESC}" ]; then
|
||||||
if [ "${DESC}" == "" ]; then
|
DESC="${FIELD%%:*}"
|
||||||
DESC="${FIELD/:*/}"
|
else
|
||||||
else
|
DESC="${DESC}|${FIELD%%:*}"
|
||||||
DESC="${DESC}|${FIELD/:*/}"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
DESKSKIP=$(( DESKSKIP + 1))
|
|
||||||
done
|
done
|
||||||
OPTIONS+=("${INDEX}:${FIELDS[0]}" "${FIELDS[1]} [${DESC}]" "${FULL}")
|
OPTIONS+=("${INDEX}:${FIELDS[0]}" "${FIELDS[1]} [${DESC}]" "${DEFAULT_STATE}")
|
||||||
INDEX+=1
|
INDEX+=1
|
||||||
done
|
done
|
||||||
if ! SEL=$(whiptail --title "NetUpgrade" --checklist "" 0 0 0 \
|
if ! SEL=$(whiptail --title "NetUpgrade" --checklist "" 0 0 0 \
|
||||||
@@ -49,7 +135,12 @@ selectNodes(){
|
|||||||
3>&1 1>&2 2>&3); then
|
3>&1 1>&2 2>&3); then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ ${#SEL} == 0 ]; then
|
if [ -z "${SEL}" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
parseSelection "${SEL}" SELECTED_ITEMS
|
||||||
|
if [ ${#SELECTED_ITEMS[@]} -eq 0 ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -59,17 +150,17 @@ selectNodes(){
|
|||||||
touch "${LOGFILENAME}"
|
touch "${LOGFILENAME}"
|
||||||
local RESULT="\n"
|
local RESULT="\n"
|
||||||
|
|
||||||
for ITM in ${SEL}; do
|
for ITM in "${SELECTED_ITEMS[@]}"; do
|
||||||
INDEX=1
|
INDEX=1
|
||||||
for NODE in "${NODES[@]}"; do
|
for NODE in "${NODES[@]}"; do
|
||||||
# shellcheck disable=SC2206
|
FIELDS=()
|
||||||
local FIELDS=(${NODE//;/ })
|
parseNode "${NODE}" FIELDS
|
||||||
if [ "${ITM}" = "\"${INDEX}:${FIELDS[0]}\"" ]; then
|
if [ "${ITM}" = "${INDEX}:${FIELDS[0]}" ]; then
|
||||||
for ((I = 2; I < ${#FIELDS[@]}; ++I)); do
|
for ((I = 2; I < ${#FIELDS[@]}; ++I)); do
|
||||||
if runCmd "${FIELDS[0]}" "${FIELDS[1]}" "${FIELDS[${I}]}"; then
|
if runCmd "${FIELDS[0]}" "${FIELDS[1]}" "${FIELDS[I]}"; then
|
||||||
RESULT+="Ok: ${FIELDS[1]} @ ${FIELDS[0]} : ${FIELDS[${I}]}\n"
|
RESULT+="Ok: ${FIELDS[1]} @ ${FIELDS[0]} : ${FIELDS[I]}\n"
|
||||||
else
|
else
|
||||||
RESULT+="Error: ${FIELDS[1]} @ ${FIELDS[0]} : ${FIELDS[${I}]}\n"
|
RESULT+="Error: ${FIELDS[1]} @ ${FIELDS[0]} : ${FIELDS[I]}\n"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
@@ -77,10 +168,12 @@ selectNodes(){
|
|||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
sed -i "1s/^/${RESULT//\//\\\/}\n\n\n\n/" "${LOGFILENAME}"
|
prependLogSummary "${RESULT}"
|
||||||
sed -i "1s/^/---------\n\n/" "${LOGFILENAME}"
|
if [ -n "${LOGVIEWER}" ]; then
|
||||||
sed -i "1s/^/Results :\n/" "${LOGFILENAME}"
|
"${LOGVIEWER}" "${LOGFILENAME}"
|
||||||
nano "${LOGFILENAME}"
|
else
|
||||||
|
echo "Warning: no log viewer found, showing log path instead: ${LOGFILENAME}"
|
||||||
|
fi
|
||||||
rm -i "${LOGFILENAME}"
|
rm -i "${LOGFILENAME}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Results :"
|
echo "Results :"
|
||||||
@@ -88,136 +181,160 @@ selectNodes(){
|
|||||||
echo -e "${RESULT}"
|
echo -e "${RESULT}"
|
||||||
}
|
}
|
||||||
|
|
||||||
runCmd() { #$1=host $2=name #3=cmd
|
runCmd() { # $1=host $2=name $3=cmd
|
||||||
local -r HOST=${1}
|
local -r HOST="${1}"
|
||||||
local -r NAME=${2}
|
local -r NAME="${2}"
|
||||||
local -r CMD=${3//:*}
|
local -r ACTION="${3}"
|
||||||
local -r CMDVAL=${3//*:}
|
local -r CMD="${ACTION%%:*}"
|
||||||
|
local -r CMDVAL="${ACTION#*:}"
|
||||||
local -i ERROR=0
|
local -i ERROR=0
|
||||||
echo "${NAME} @ ${HOST} : ${CMD}" | tee -a "${LOGFILENAME}"
|
local TITLELENGTH=0
|
||||||
local TITLELENGTH=$((${#NAME} + ${#HOST} + ${#CMD} + 6))
|
|
||||||
local SUBTITLE="-----------------------------------------------------------------------------"
|
local SUBTITLE="-----------------------------------------------------------------------------"
|
||||||
echo ${SUBTITLE:0:${TITLELENGTH}} | tee -a "${LOGFILENAME}"
|
local YESARG=""
|
||||||
|
|
||||||
|
echo "${NAME} @ ${HOST} : ${CMD}" | tee -a "${LOGFILENAME}"
|
||||||
|
TITLELENGTH=$((${#NAME} + ${#HOST} + ${#CMD} + 6))
|
||||||
|
echo "${SUBTITLE:0:${TITLELENGTH}}" | tee -a "${LOGFILENAME}"
|
||||||
date +'%Y-%m-%d %H:%M:%S %A' | tee -a "${LOGFILENAME}"
|
date +'%Y-%m-%d %H:%M:%S %A' | tee -a "${LOGFILENAME}"
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
local YESARG=""
|
|
||||||
case ${CMD} in
|
case ${CMD} in
|
||||||
reboot)
|
reboot)
|
||||||
ssh root@"${HOST}" reboot | tee -a "${LOGFILENAME}"
|
echo "reboot (detached)" | tee -a "${LOGFILENAME}"
|
||||||
|
if ! runSSH "${HOST}" sh -c 'nohup sh -c "reboot || /sbin/reboot || shutdown -r now" >/dev/null 2>&1 </dev/null &' | tee -a "${LOGFILENAME}"; then
|
||||||
|
ERROR=1
|
||||||
|
else
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
apt)
|
apt)
|
||||||
if [ ${YES} == 1 ]; then
|
if [ "${YES}" -eq 1 ]; then
|
||||||
YESARG="-y"
|
YESARG="-y"
|
||||||
fi
|
fi
|
||||||
echo "apt-get ${YESARG} update" | tee -a "${LOGFILENAME}"
|
echo "apt-get ${YESARG} update" | tee -a "${LOGFILENAME}"
|
||||||
if ! ssh root@"${HOST}" apt-get ${YESARG} update | tee -a "${LOGFILENAME}"; then
|
if ! runSSH "${HOST}" apt-get ${YESARG} update | tee -a "${LOGFILENAME}"; then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
else
|
else
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
echo "apt-get ${YESARG} dist-upgrade" | tee -a "${LOGFILENAME}"
|
echo "apt-get ${YESARG} dist-upgrade" | tee -a "${LOGFILENAME}"
|
||||||
if ! ssh root@"${HOST}" apt-get ${YESARG} dist-upgrade | tee -a "${LOGFILENAME}"; then
|
if ! runSSH "${HOST}" apt-get ${YESARG} dist-upgrade | tee -a "${LOGFILENAME}"; then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
fi
|
fi
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
echo "apt-get ${YESARG} autoremove" | tee -a "${LOGFILENAME}"
|
echo "apt-get ${YESARG} autoremove --purge" | tee -a "${LOGFILENAME}"
|
||||||
ssh root@"${HOST}" apt-get ${YESARG} autoremove | tee -a "${LOGFILENAME}"
|
runSSH "${HOST}" apt-get ${YESARG} autoremove --purge | tee -a "${LOGFILENAME}"
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
echo "apt-get ${YESARG} autoclean" | tee -a "${LOGFILENAME}"
|
echo "apt-get ${YESARG} autoclean" | tee -a "${LOGFILENAME}"
|
||||||
ssh root@"${HOST}" apt-get ${YESARG} autoclean | tee -a "${LOGFILENAME}"
|
runSSH "${HOST}" apt-get ${YESARG} autoclean | tee -a "${LOGFILENAME}"
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
echo "apt-get ${YESARG} clean" | tee -a "${LOGFILENAME}"
|
echo "apt-get ${YESARG} clean" | tee -a "${LOGFILENAME}"
|
||||||
ssh root@"${HOST}" apt-get ${YESARG} clean | tee -a "${LOGFILENAME}"
|
runSSH "${HOST}" apt-get ${YESARG} clean | tee -a "${LOGFILENAME}"
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
|
||||||
echo "apt-get ${YESARG} purge" | tee -a "${LOGFILENAME}"
|
|
||||||
ssh root@"${HOST}" apt-get ${YESARG} purge | tee -a "${LOGFILENAME}"
|
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
yum)
|
yum)
|
||||||
if [ ${YES} == 1 ]; then
|
if [ "${YES}" -eq 1 ]; then
|
||||||
YESARG="-y"
|
YESARG="-y"
|
||||||
fi
|
fi
|
||||||
echo "yum ${YESARG} update" | tee -a "${LOGFILENAME}"
|
echo "yum ${YESARG} update" | tee -a "${LOGFILENAME}"
|
||||||
if ! ssh root@"${HOST}" yum ${YESARG} update | tee -a "${LOGFILENAME}"; then
|
if ! runSSH "${HOST}" yum ${YESARG} update | tee -a "${LOGFILENAME}"; then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
pkg)
|
pkg)
|
||||||
if [ ${YES} == 1 ]; then
|
if [ "${YES}" -eq 1 ]; then
|
||||||
YESARG="-y"
|
YESARG="-y"
|
||||||
fi
|
fi
|
||||||
echo "pkg ${YESARG} update" | tee -a "${LOGFILENAME}"
|
echo "pkg ${YESARG} update" | tee -a "${LOGFILENAME}"
|
||||||
if ! ssh root@"${HOST}" pkg ${YESARG} update | tee -a "${LOGFILENAME}"; then
|
if ! runSSH "${HOST}" pkg ${YESARG} update | tee -a "${LOGFILENAME}"; then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
else
|
else
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
echo "pkg upgrade ${YESARG}" | tee -a "${LOGFILENAME}"
|
echo "pkg upgrade ${YESARG}" | tee -a "${LOGFILENAME}"
|
||||||
if ! ssh root@"${HOST}" pkg upgrade ${YESARG} | tee -a "${LOGFILENAME}"; then
|
if ! runSSH "${HOST}" pkg upgrade ${YESARG} | tee -a "${LOGFILENAME}"; then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
fi
|
fi
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
echo "pkg autoremove ${YESARG}" | tee -a "${LOGFILENAME}"
|
echo "pkg autoremove ${YESARG}" | tee -a "${LOGFILENAME}"
|
||||||
ssh root@"${HOST}" pkg autoremove ${YESARG} | tee -a "${LOGFILENAME}"
|
runSSH "${HOST}" pkg autoremove ${YESARG} | tee -a "${LOGFILENAME}"
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
echo "pkg clean ${YESARG}" | tee -a "${LOGFILENAME}"
|
echo "pkg clean ${YESARG}" | tee -a "${LOGFILENAME}"
|
||||||
ssh root@"${HOST}" pkg clean ${YESARG} | tee -a "${LOGFILENAME}"
|
runSSH "${HOST}" pkg clean ${YESARG} | tee -a "${LOGFILENAME}"
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
pacman)
|
pacman)
|
||||||
if [ ${YES} == 1 ]; then
|
if [ "${YES}" -eq 1 ]; then
|
||||||
YESARG="--noconfirm"
|
YESARG="--noconfirm"
|
||||||
fi
|
fi
|
||||||
echo "pacman -Sy ${YESARG} archlinux-keyring" | tee -a "${LOGFILENAME}"
|
echo "pacman -Sy ${YESARG} archlinux-keyring" | tee -a "${LOGFILENAME}"
|
||||||
if ! ssh root@"${HOST}" pacman -Sy ${YESARG} archlinux-keyring | tee -a "${LOGFILENAME}"; then
|
if ! runSSH "${HOST}" pacman -Sy ${YESARG} archlinux-keyring | tee -a "${LOGFILENAME}"; then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
fi
|
fi
|
||||||
echo "pacman -Syu ${YESARG}" | tee -a "${LOGFILENAME}"
|
echo "pacman -Syu ${YESARG}" | tee -a "${LOGFILENAME}"
|
||||||
if ! ssh root@"${HOST}" pacman -Syu ${YESARG} | tee -a "${LOGFILENAME}"; then
|
if ! runSSH "${HOST}" pacman -Syu ${YESARG} | tee -a "${LOGFILENAME}"; then
|
||||||
|
ERROR=1
|
||||||
|
fi
|
||||||
|
echo "pacman orphan cleanup" | tee -a "${LOGFILENAME}"
|
||||||
|
if [ -n "${YESARG}" ]; then
|
||||||
|
if ! runSSH "${HOST}" sh -c 'orphans=$(pacman -Qqtd 2>/dev/null || true); if [ -n "$orphans" ]; then pacman -Rns --noconfirm $orphans; fi' | tee -a "${LOGFILENAME}"; then
|
||||||
|
ERROR=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! runSSH "${HOST}" sh -c 'orphans=$(pacman -Qqtd 2>/dev/null || true); if [ -n "$orphans" ]; then pacman -Rns $orphans; fi' | tee -a "${LOGFILENAME}"; then
|
||||||
|
ERROR=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "pacman -Sc ${YESARG}" | tee -a "${LOGFILENAME}"
|
||||||
|
if ! runSSH "${HOST}" pacman -Sc ${YESARG} | tee -a "${LOGFILENAME}"; then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
fi
|
fi
|
||||||
# shellcheck disable=SC2046
|
|
||||||
ssh root@"${HOST}" pacman -Rns $(pacman -Qqtd) ${YESARG} | tee -a "${LOGFILENAME}"
|
|
||||||
ssh root@"${HOST}" pacman -Sc ${YESARG} | tee -a "${LOGFILENAME}"
|
|
||||||
;;
|
;;
|
||||||
apk)
|
apk)
|
||||||
if [ ${YES} == 1 ]; then
|
|
||||||
YESARG="-y"
|
|
||||||
fi
|
|
||||||
echo "apk update" | tee -a "${LOGFILENAME}"
|
echo "apk update" | tee -a "${LOGFILENAME}"
|
||||||
if ! ssh root@"${HOST}" apk update | tee -a "${LOGFILENAME}"; then
|
if ! runSSH "${HOST}" apk update | tee -a "${LOGFILENAME}"; then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
fi
|
fi
|
||||||
echo "apk upgrade" | tee -a "${LOGFILENAME}"
|
echo "apk upgrade" | tee -a "${LOGFILENAME}"
|
||||||
if ! ssh root@"${HOST}" apk upgrade | tee -a "${LOGFILENAME}"; then
|
if ! runSSH "${HOST}" apk upgrade | tee -a "${LOGFILENAME}"; then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
cmd)
|
cmd)
|
||||||
echo "cmd: ${CMDVAL}" | tee -a "${LOGFILENAME}"
|
echo "cmd: ${CMDVAL}" | tee -a "${LOGFILENAME}"
|
||||||
# shellcheck disable=SC2029
|
if ! runSSH "${HOST}" sh -c "${CMDVAL}" | tee -a "${LOGFILENAME}"; then
|
||||||
if ! ssh root@"${HOST}" "${CMDVAL}" | tee -a "${LOGFILENAME}"; then
|
|
||||||
ERROR=1
|
ERROR=1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
docker-stacks)
|
docker-stacks)
|
||||||
echo "docker stacks update" | tee -a "${LOGFILENAME}"
|
echo "docker stacks update in ${CMDVAL}" | tee -a "${LOGFILENAME}"
|
||||||
echo "for each" | tee -a "${LOGFILENAME}"
|
if ! ssh "${SSH_USER}@${HOST}" "STACK_ROOT=$(printf '%q' "${CMDVAL}") bash -s" <<'EOF' | tee -a "${LOGFILENAME}"
|
||||||
echo " docker compose pull; docker compose up -d" | tee -a "${LOGFILENAME}"
|
stack_root="${STACK_ROOT}"
|
||||||
if ! ssh root@"${HOST}" 'for dir in '"${CMDVAL}"'/*; do (cd "${dir}"; docker compose pull; docker compose up -d); done; docker image prune -f' | tee -a "${LOGFILENAME}"; then
|
|
||||||
ERROR=1
|
for dir in "$stack_root"/*; do
|
||||||
fi
|
[ -d "$dir" ] || continue
|
||||||
echo "docker image prune -a -f" | tee -a "${LOGFILENAME}"
|
(
|
||||||
if ! ssh root@"${HOST}" docker image prune -f | tee -a "${LOGFILENAME}"; then
|
cd "$dir" || exit 1
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
) || exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
docker image prune -f
|
||||||
|
EOF
|
||||||
|
then
|
||||||
ERROR=1
|
ERROR=1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
*) echo "Error: Command ${CMD} unknown" | tee -a "${LOGFILENAME}";;
|
*)
|
||||||
|
echo "Error: Command ${CMD} unknown" | tee -a "${LOGFILENAME}"
|
||||||
|
ERROR=1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
echo "" | tee -a "${LOGFILENAME}"
|
echo "" | tee -a "${LOGFILENAME}"
|
||||||
if [ ${ERROR} == 1 ]; then
|
if [ "${ERROR}" -eq 1 ]; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -227,6 +344,8 @@ declare -i YES=0
|
|||||||
declare -i FULL=0
|
declare -i FULL=0
|
||||||
declare CONFIGFILENAME="${HOME}/.config/netupgrade/index.cfg"
|
declare CONFIGFILENAME="${HOME}/.config/netupgrade/index.cfg"
|
||||||
declare LOGFILENAME="${HOME}/netupgrade.log"
|
declare LOGFILENAME="${HOME}/netupgrade.log"
|
||||||
|
declare LOGVIEWER=""
|
||||||
|
declare SSH_USER="root"
|
||||||
declare -a NODES=()
|
declare -a NODES=()
|
||||||
|
|
||||||
while [[ ${#} -gt 0 ]]; do
|
while [[ ${#} -gt 0 ]]; do
|
||||||
@@ -238,6 +357,8 @@ while [[ ${#} -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
checkDependencies
|
||||||
|
resolveLogViewer
|
||||||
loadConfig
|
loadConfig
|
||||||
selectNodes
|
selectNodes
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ NODES+=("10.0.0.103;alpine-01;apk;reboot")
|
|||||||
NODES+=("10.0.0.104;redhat-01;yum;reboot")
|
NODES+=("10.0.0.104;redhat-01;yum;reboot")
|
||||||
NODES+=("10.0.0.105;freebsd-01;pkg;reboot")
|
NODES+=("10.0.0.105;freebsd-01;pkg;reboot")
|
||||||
NODES+=("10.0.0.211;docker-01;docker-stacks:/srv/stacks")
|
NODES+=("10.0.0.211;docker-01;docker-stacks:/srv/stacks")
|
||||||
NODES+=("10.0.0.105;docker-01;cmd:reboot")
|
#NODES+=("10.0.0.211;docker-01;cmd:reboot")
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Repository analysis
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
- Project name: `netupgrade`
|
||||||
|
- Primary language: Bash
|
||||||
|
- Main entrypoint: `bin/netupgrade`
|
||||||
|
- Project type: interactive CLI administration tool
|
||||||
|
- Main purpose: orchestrate remote upgrade and maintenance actions on multiple hosts over SSH
|
||||||
|
- Configuration model: Bash-based configuration files sourced from `~/.config/netupgrade/*.cfg`
|
||||||
|
|
||||||
|
## Repository structure
|
||||||
|
- `bin/netupgrade`: main executable script containing CLI parsing, node selection, remote execution, and logging
|
||||||
|
- `config/netupgrade/*.cfg`: sample configuration files defining host groups and action sequences
|
||||||
|
- `README.md`: installation and usage documentation
|
||||||
|
- `docs/`: project documentation
|
||||||
|
|
||||||
|
## Functional behavior
|
||||||
|
The tool:
|
||||||
|
1. Loads a Bash configuration file
|
||||||
|
2. Expects a `NODES` array populated with entries formatted like:
|
||||||
|
`host;display-name;action1;action2;...`
|
||||||
|
3. Displays an interactive multi-select checklist using `whiptail`
|
||||||
|
4. Executes the selected actions on each selected host through SSH, using `root@host` by default or `SSH_USER@host` when configured
|
||||||
|
5. Writes execution logs to `~/netupgrade.log`
|
||||||
|
6. Opens the log with `$EDITOR` when available, otherwise `nano`, `vi`, or `less`; if none is available, it prints the log path, then optionally removes the log file
|
||||||
|
|
||||||
|
Supported action types currently include:
|
||||||
|
- `apt`
|
||||||
|
- `yum`
|
||||||
|
- `pkg`
|
||||||
|
- `pacman`
|
||||||
|
- `apk`
|
||||||
|
- `reboot`
|
||||||
|
- `cmd:<remote command>`
|
||||||
|
- `docker-stacks:<directory>`
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
- The project is intentionally lightweight and script-based
|
||||||
|
- Configuration is code-driven rather than declarative, since config files are sourced as shell files
|
||||||
|
- The entire execution flow currently lives in a single Bash script
|
||||||
|
- Remote operations are performed sequentially, not in parallel
|
||||||
|
- Logging is file-based and coupled directly to command execution
|
||||||
|
|
||||||
|
## Strengths
|
||||||
|
- Very small and easy to deploy
|
||||||
|
- Clear practical purpose for system administration workflows
|
||||||
|
- Flexible host/action configuration model
|
||||||
|
- Supports several Linux/BSD package managers
|
||||||
|
- Suitable for use from a bastion host or admin workstation
|
||||||
|
|
||||||
|
## Main issues identified
|
||||||
|
|
||||||
|
### 1. Documentation accuracy problems
|
||||||
|
- `README.md` and CLI help were updated to better match current behavior
|
||||||
|
- The previous typo in the configuration path (`netuprade`) has been fixed
|
||||||
|
- The unsupported `-b` option was removed from the displayed help
|
||||||
|
- The configuration format and supported actions are now documented in more detail
|
||||||
|
|
||||||
|
### 2. Shell robustness concerns
|
||||||
|
- Config files are sourced directly, which is flexible but implies arbitrary code execution
|
||||||
|
- The script still has some quoting-sensitive areas and does not use a stricter shell safety baseline
|
||||||
|
- The `NODES` parsing was hardened to split on `;` with `IFS`/`read -r -a`, which now preserves spaces in action values such as `cmd:...`
|
||||||
|
|
||||||
|
### 3. Remote execution correctness and safety
|
||||||
|
- SSH execution now goes through a dedicated `runSSH` helper and the SSH user is configurable via `SSH_USER`, defaulting to `root`
|
||||||
|
- `cmd:<...>` intentionally allows arbitrary remote command execution and is now executed through a remote shell, which improves support for shell operators but remains a powerful unsafe feature
|
||||||
|
- The `pacman` orphan-removal command was corrected so orphan detection happens on the remote host instead of locally
|
||||||
|
- The `docker-stacks` remote loop was rewritten to pass the stack root as an argument to a remote shell script, improving quoting and path handling
|
||||||
|
|
||||||
|
### 4. UX and dependency issues
|
||||||
|
- Required runtime dependencies are now checked at startup (`ssh`, `whiptail`, `cat`, `tee`, `rm`, `touch`)
|
||||||
|
- Log viewing no longer depends strictly on `nano`; the script now falls back to `$EDITOR`, then `nano`, `vi`, or `less`
|
||||||
|
- If no supported log viewer is available, execution continues and the log path is shown
|
||||||
|
- The workflow is highly interactive and not well suited for automation
|
||||||
|
- `rm -i` introduces an extra prompt even when the rest of the flow is meant to be streamlined
|
||||||
|
|
||||||
|
### 5. Error handling limitations
|
||||||
|
- Error propagation is inconsistent depending on the action type
|
||||||
|
- Cleanup commands often do not affect the final failure state
|
||||||
|
- The script continues through action sequences without a documented policy
|
||||||
|
- The advertised “break on error” behavior does not exist yet
|
||||||
|
|
||||||
|
### 6. Maintainability limitations
|
||||||
|
- Most logic is concentrated in one script
|
||||||
|
- There is duplication in package-manager handling
|
||||||
|
- No tests or validation tooling are present in the repository
|
||||||
|
- Some wording, typos, and naming inconsistencies reduce clarity
|
||||||
|
|
||||||
|
## Recommended direction
|
||||||
|
|
||||||
|
### Short term
|
||||||
|
- Tackle the next hardening work as small, reviewable commits instead of one broad patch
|
||||||
|
- Define and document an explicit error-handling policy: what is fatal, what is best-effort, and whether host action sequences continue after a failure
|
||||||
|
- Review the remaining quoting-sensitive areas, especially around remote shell command construction for `cmd:<...>` and `docker-stacks:<...>`
|
||||||
|
- Align `README.md`, CLI help, and runtime behavior on dependencies and interaction details, especially the actual meaning of `-f`, current log-file behavior, and whether `sed` is still required
|
||||||
|
- Review sample configuration files for naming or targeting inconsistencies that could lead to operator mistakes
|
||||||
|
|
||||||
|
### Medium term
|
||||||
|
- Make SSH user, log path, and editor configurable
|
||||||
|
- Improve non-interactive usage options with explicit batch-friendly flags such as a true `--all`, `--no-log-view`, `--keep-log`, and custom log-path support
|
||||||
|
- Standardize error handling and exit codes with a documented policy for best-effort cleanup steps versus fatal failures
|
||||||
|
- Add lightweight validation or warnings around sourced config files, for example around trust expectations or overly permissive file permissions
|
||||||
|
- Consider adopting a clearer shell option baseline such as an explicit global `pipefail` policy
|
||||||
|
|
||||||
|
### Long term
|
||||||
|
- Refactor the script into smaller functions with less duplication
|
||||||
|
- Add shell linting guidance and automation (for example ShellCheck, optionally shfmt)
|
||||||
|
- Consider a safer declarative configuration format if the project grows
|
||||||
|
- Add test coverage for parsing, command construction, and non-regression around SSH quoting behavior
|
||||||
|
|
||||||
|
## Recent changes
|
||||||
|
- `README.md` was expanded to document installation, requirements, usage, configuration format, and supported actions
|
||||||
|
- `bin/netupgrade` help output was aligned with actual CLI behavior and now documents `--help`
|
||||||
|
- A startup dependency check was added before loading configuration or opening the interactive selector
|
||||||
|
- Log viewer selection was made more flexible: `$EDITOR` is preferred, then `nano`, `vi`, or `less`
|
||||||
|
- `nano` is no longer a strict runtime dependency
|
||||||
|
- The unsupported `-b` option remains unimplemented and is no longer shown in help output
|
||||||
|
- `NODES` parsing was hardened to preserve spaces in action values by splitting on `;` with `IFS` and `read -r -a`
|
||||||
|
- SSH calls were centralized through a `runSSH` helper and `SSH_USER` is now configurable, defaulting to `root`
|
||||||
|
- The `pacman` orphan cleanup now runs entirely on the remote host instead of evaluating orphan detection locally
|
||||||
|
- The `pacman` orphan cleanup remote command now avoids nested `bash -lc` argument-passing issues by selecting between two simple remote `sh -c` commands, one with `--noconfirm` and one without
|
||||||
|
- The `docker-stacks` action uses a remote shell script sent over SSH stdin, with the stack directory exported as a remote environment assignment before `bash -s`, to keep path handling working after recent SSH command-construction changes
|
||||||
|
- Unknown actions and reboot SSH failures now propagate error status more consistently
|
||||||
|
- The `reboot` action now triggers a detached remote reboot command (`reboot || /sbin/reboot || shutdown -r now` under `nohup`) so an expected SSH disconnect during restart is less likely to be reported as a failure
|
||||||
|
- A focused code review identified the next recommended work items and suggested splitting them into separate commits rather than combining them in one larger hardening change
|
||||||
|
- `whiptail` checklist defaults are now passed explicitly as `ON`/`OFF`, and selected items are parsed through a dedicated helper instead of relying on raw shell word splitting
|
||||||
|
- The CLI help and README now clarify that `-f` preselects all nodes in the interactive checklist
|
||||||
|
- Log summary generation no longer uses `sed -i` interpolation; the script now writes a temporary file with the summary header plus the existing log content and replaces the original log atomically
|
||||||
|
- The `apk` action no longer passes `-y` to `apk upgrade`, because current Alpine `apk` does not accept that option there; `-y` remains a best-effort flag for other supported package managers
|
||||||
|
- The `apt` action now uses `apt-get autoremove --purge` and no longer runs `apt-get purge` without arguments, which makes the cleanup step more meaningful and avoids a misleading command in the log
|
||||||
|
- The `pacman` action was further hardened by simplifying orphan cleanup command construction, reducing quoting-related regressions while still skipping removal when no orphan packages are present
|
||||||
|
- `README.md` no longer lists `sed` as a required dependency and now better reflects the local utilities actually used by the script
|
||||||
|
- Startup dependency checks now include `mv`, which is required by the log-summary rewrite
|
||||||
|
- The sample configuration in `config/netupgrade/hypervisor-01.cfg` now comments out the alternate `docker-01` reboot step so the two-step example remains visible without being active by default
|
||||||
|
|
||||||
|
## Change guidance
|
||||||
|
- Preserve backward compatibility for existing config files where possible
|
||||||
|
- Prefer incremental hardening over a full rewrite
|
||||||
|
- Keep the tool simple and admin-friendly
|
||||||
|
- Split behavioral fixes into small logical commits when possible, for example: selection handling, log generation, package-manager cleanup semantics, error-policy changes, and non-interactive CLI improvements
|
||||||
|
- Be cautious with changes to remote command construction, as quoting changes can introduce regressions
|
||||||
|
- Treat documentation and behavior alignment as part of functional quality, not as a separate cleanup task
|
||||||
|
- Avoid introducing a global `set -euo pipefail` baseline in one step without first documenting and testing the expected failure semantics
|
||||||
|
|
||||||
|
## Suggested review focus for future changes
|
||||||
|
- Correctness of package-manager cleanup commands and confirmation flags
|
||||||
|
- Correctness of remote command execution
|
||||||
|
- Safe quoting and shell expansion behavior
|
||||||
|
- Compatibility of config format with existing user setups
|
||||||
|
- Error-handling policy consistency across action types
|
||||||
|
- Package-manager command correctness and cleanup-step behavior
|
||||||
|
- Usability in both interactive and semi-automated contexts
|
||||||
|
- Documentation consistency with actual runtime behavior and dependencies
|
||||||
|
- Review of sample config accuracy to avoid mislabelled hosts or risky operator confusion
|
||||||
|
|
||||||
|
## Additional recommendations from latest review
|
||||||
|
- Highest priority should go to defining an explicit execution and failure policy, because it currently affects operator trust more than missing features do
|
||||||
|
- The next highest priority should be protecting against regressions in SSH command construction by documenting manual test cases for commands with spaces, pipes, redirections, `&&`, `||`, and quoted arguments
|
||||||
|
- A small CLI usability pass would have strong value: `-f` currently only preselects nodes in `whiptail`, so a true non-interactive selection mode would improve automation without changing the overall project model
|
||||||
|
- The dependency list was realigned: `README.md` no longer mentions `sed`, and the script dependency check now includes `mv` for the log-summary rewrite
|
||||||
|
- The sample configuration set was clarified to avoid an active duplicate reboot step for `docker-01`; the alternate `cmd:reboot` example remains commented out for illustration
|
||||||
|
- The sample configuration set should be reviewed for consistency; for example, duplicate or mismatched display names attached to different IPs increase the risk of accidental operations on the wrong host
|
||||||
|
- Shell quality improvements should favor linting, targeted helpers, and incremental refactors before any broad strict-mode changes
|
||||||
|
- Future testing should focus first on parser behavior, command construction, and result reporting rather than trying to build a large end-to-end framework immediately
|
||||||
Reference in New Issue
Block a user