4 # Copyright 2025 xs <xs@inda.re>
6 # Permission to use, copy, modify, and/or distribute this software for any
7 # purpose with or without fee is hereby granted, provided that the above
8 # copyright notice and this permission notice appear in all copies.
10 # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
11 # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
12 # FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
13 # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
14 # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
15 # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
16 # PERFORMANCE OF THIS SOFTWARE.
18 # shellcheck disable=SC2046,SC2031
20 # Global configuration
21 # ====================
28 # shellcheck disable=SC2031
31 cd "$_init_pwd" || _fail "Unable to cd to '$_init_pwd'"
33 # Here is the default configuration
36 : "${R7_GROUP_ALLPREFIX:=no}"
37 : "${R7_GROUP_SUMMARY:=no}"
38 : "${R7_GROUP_EXITONERROR:=no}"
39 : "${R7_PARALLEL:=no}"
40 : "${R7_PREFIX:=install state doc}"
41 : "${R7_SHOWOUTPUT:=no}"
42 : "${R7_WORKDIR:=$_init_pwd}"
43 : "${R7_SITE_CONFIG:=$R7_WORKDIR/site-config}"
44 : "${R7_OUTPUTDIR:=$R7_WORKDIR/_output}"
45 : "${SSH_CONFIG_DIR:=$R7_WORKDIR/.ssh}"
46 : "${SSH_CONFIG_FILE:=$R7_WORKDIR/.ssh/config}"
47 : "${SSH_CONTROL_DIR:=$SSH_CONFIG_DIR/control}"
48 : "${SSH_IDENTITY_FILE:=$R7_WORKDIR/.ssh/id_ed25519_r7}"
49 : "${SSH_KNOWN_HOSTS:=$R7_WORKDIR/.ssh/known_hosts}"
50 : "${SSH_CONNECT_TIMEOUT:=3}"
54 # Validate environment
55 if test "$R7_DEBUG" = yes; then
56 set | grep -e '^R7_' -e '^SSH' 1>&2
59 for param in "$R7_DEBUG" "$R7_DRYRUN" "$R7_PARALLEL"; do
62 *) _fail "Invalid parameter value: '$param'" ;;
66 R7_WORKDIR_tmp=$(_resolv_dir "$R7_WORKDIR")
67 test -d "$R7_WORKDIR_tmp" || _error "Working directory not found: '$R7_WORKDIR'"
68 R7_WORKDIR=$R7_WORKDIR_tmp
69 R7_RUNDIR=$R7_WORKDIR/_run
70 R7_OUTPUTDIR=$R7_WORKDIR/_output
72 R7_SITE_CONFIG_tmp=$(_resolv_file "$R7_SITE_CONFIG")
73 test -f "$R7_SITE_CONFIG_tmp" || _error "Site configuration not found: '$R7_SITE_CONFIG'"
74 R7_SITE_CONFIG=$R7_SITE_CONFIG_tmp
76 SSH_CONFIG_FILE_tmp=$(_resolv_file "$SSH_CONFIG_FILE")
77 test -f "$SSH_CONFIG_FILE_tmp" || _error "Unable to read SSH configuration: '$SSH_CONFIG_FILE'"
78 SSH_CONFIG_FILE=$SSH_CONFIG_FILE_tmp
80 SSH_IDENTITY_FILE_tmp=$(_resolv_file "$SSH_IDENTITY_FILE")
81 test -f "$SSH_IDENTITY_FILE_tmp" || _error "Unable to read SSH identity file: '$SSH_IDENTITY_FILE'"
82 SSH_IDENTITY_FILE=$SSH_IDENTITY_FILE_tmp
85 unset R7_WORKDIR_tmp R7_SITE_CONFIG_tmp SSH_CONFIG_FILE_tmp SSH_IDENTITY_FILE_tmp
92 printf "FAILURE: %s\n" "$*" >&2
97 printf "ERROR: %s\n" "$*" >&2
101 test "$R7_DEBUG" = yes && printf %s\\n "${*:-$(cat -)}" >&2
105 if test "$R7_ENV_READY" = yes; then
106 cd "$R7_WORKDIR" || return 2
107 _trace_id_last=$(trace_id_last)
108 if test -n "$_trace_id_last"; then
109 printf -- "- (%s) - in %s the %s\n" \
110 "$_trace_id_last" "$(basename "$R7_WORKDIR")" "$(_epoch "$_trace_id_last")"
111 nodes_active | sed 's/^/ - (active) /'
124 usage: $0 [options] [command [arguments...]]
126 -h Show usage and exit
129 -F SSH client configuration file
130 -w Work directory where to find ./nodes and ./groups
131 -r Run directory, default to ./_run
132 -o Output directory, default to ./_output
133 -c Site configuration file, default to ./site-config
134 -n Dry run, prevent any host modification
135 -e Exit the group execution on the error function
136 -p Run each host in parallel per group call
137 -O Show output interactively
138 -P Prefix, can contain whole function names
139 -a Select all available prefixes for a group command
140 -s Output-dump and summary are applied after a group command
143 run-config Run the site configuration
144 group Deploy specified group to hosts
145 output-dump Dump the run directory to the output directory
146 output Consult the output
147 summary Summary of errors and changes
148 digraph-host Produce digraph host
149 digraph-ssh Produce SSH IDs digraph
150 import Import TARGET host in current nodes directory
151 info Default command showing active nodes and summary
152 init Initialize a new work directory
155 prefix [prefix...] # Define the prefix to be executed in order
156 group groupname ['.*'|hostname...] # Deploy specified group to hosts
161 # shellcheck disable=SC2086
163 test -n "$ZSH_VERSION" && {
164 echo >&2 "ZSH is unsupported. Exiting."
167 while getopts 'P:F:r:o:i:w:c:npdseaOh' arg; do
175 _debug "R7 DEBUG $VERSION"
177 i) SSH_IDENTITY_FILE=$OPTARG ;;
178 F) SSH_CONFIG_FILE=$OPTARG ;;
179 w) R7_WORKDIR=$OPTARG ;;
180 r) R7_RUNDIR=$OPTARG ;;
181 o) R7_OUTPUTDIR=$OPTARG ;;
182 s) R7_GROUP_SUMMARY=yes ;;
183 a) R7_GROUP_ALLPREFIX=yes ;;
184 e) R7_GROUP_EXITONERROR=yes ;;
185 c) R7_SITE_CONFIG=$OPTARG ;;
190 p) R7_PARALLEL=yes ;;
195 O) R7_SHOWOUTPUT=yes ;;
198 echo 1>&2 "Unknown option '$arg'"
203 shift $((OPTIND - 1))
216 rc | run-config) run_config ;;
219 trace_id=$(_trace_id_new)
220 if test "$R7_GROUP_ALLPREFIX" = yes; then
221 prefix $(group_prefixes "$1")
226 if test "$R7_GROUP_SUMMARY" = yes; then
240 outdump | output-dump) output_dump ;;
250 '') cd "$R7_WORKDIR" && info ;;
255 echo 1>&2 "Unknown command '$1'"
259 *) prefix $R7_PREFIX ;;
266 if test -d "$1"; then
273 if test -f "$1"; then
274 cd "${1%/*}" || return 2
275 printf %s "$PWD/${1##*/}"
279 # shellcheck disable=SC2030
281 _debug _WORKDIR_INIT "$PWD" "$R7_WORKDIR"
282 if test -n "$R7_WORKDIR"; then
283 mkdir -p "$R7_WORKDIR" || _fail "Unable to create directory: '$R7_WORKDIR'"
284 cd "$R7_WORKDIR" || _fail "Unable to cd to directory: '$R7_WORKDIR'"
290 mkdir -p .ssh/control
291 : "${SSH_CONFIG_FILE:=$R7_WORKDIR/.ssh/config}"
292 : "${SSH_IDENTITY_FILE:=$R7_WORKDIR/.ssh/id_ed25519_r7}"
293 R7_SITE_CONFIG=./site-config
294 if ! test -f "$R7_SITE_CONFIG"; then
295 cat >"$R7_SITE_CONFIG" <<-eof
296 # Example r7 site configuration
300 if ! test -f "$SSH_CONFIG_FILE"; then
301 cat >"$SSH_CONFIG_FILE" <<eof
302 # Example SSH client configuration
309 #ControlPath $R7_WORKDIR/.ssh/control/%C
310 ControlPath $R7_WORKDIR/.ssh/control/%r@%h:%p
312 IdentityFile $SSH_IDENTITY_FILE
313 StrictHostKeyChecking yes
315 UserKnownHostsFile $R7_WORKDIR/.ssh/known_hosts
318 touch "${SSH_IDENTITY_FILE}" "${SSH_IDENTITY_FILE}.pub"
319 touch .ssh/known_hosts
321 find "$PWD" -exec printf -- "\t- %s\n" {} +
329 /usr/bin/ssh "-oConnectTimeout=$SSH_CONNECT_TIMEOUT" -F "$SSH_CONFIG_FILE" "$@"
332 alias ssh-debug=ssh_debug
338 alias ssh-known-hosts=ssh_known_hosts
340 /usr/bin/ssh-keygen -lv -f "${SSH_KNOWN_HOSTS:-$R7_WORKDIR/.ssh/known_hosts}"
343 alias ssh-authorized-keys-reset=ssh_authorized_keys_reset
344 alias ssh-authreset=ssh_authorized_keys_reset
345 ssh_authorized_keys_reset() {
346 ssh -o BatchMode=no "$@" "
349 touch ~/.ssh/authorized_keys
350 chmod 600 ~/.ssh/authorized_keys
351 echo '$(cat "$SSH_IDENTITY_FILE.pub")' >~/.ssh/authorized_keys
355 alias ssh-accept-new=ssh_accept_new
357 ssh -F "$SSH_CONFIG_FILE" -o StrictHostKeyChecking=accept-new "$@" :
360 alias ssh-control-master-clean=ssh_control_master_clean
361 ssh_control_master_clean() {
362 find "$SSH_CONTROL_DIR" -type f -exec rm {} \;
365 alias ssh-users=ssh_users
367 cd "$R7_WORKDIR/nodes" || _fail "Unable to cd to '$R7_WORKDIR/nodes'."
368 find ./* -type f -name "authorized_keys_*" |
370 sort | while read -r keyfile; do
372 grep -v "^#" "$R7_WORKDIR/nodes/$keyfile"
376 alias ssh-ids=ssh_ids
378 ssh_users | grep -v ^- | sort -u
381 alias ssh-authorized-keys=ssh_authorized_keys
382 alias ssh-auth=ssh_authorized_keys
383 ssh_authorized_keys() {
384 ssh_users | grep -e ^- | sed -e 's/^- /nodes\//' -e 's/:$//'
387 alias ssh-ids-hosts=ssh_ids_hosts
388 # shellcheck disable=SC2046
390 ssh_ids | awk '{ print $3 }' |
392 grep -e "$id" $(ssh_authorized_keys)
393 done | while IFS="$IFS/:" read -r _ hostname _ keyfile _ _ comment; do
394 printf '"%s" -> "%s" -> "%s"\n' "${comment#* }" "${keyfile#authorized_keys_}" "$hostname"
400 ssh-add "$SSH_IDENTITY_FILE"
404 test "$R7_DRYRUN" = yes && return 0
405 _debug "COPY $1 $2 BEGIN"
408 if r=$(ssh "$node" command -v openrsync); then
409 if openrsync -V 2>/dev/null; then
410 _debug "COPY $1 $2 USE OPENRSYNC"
411 openrsync -e "/usr/bin/ssh -oConnectTimeout=$SSH_CONNECT_TIMEOUT -F $SSH_CONFIG_FILE" \
414 -a "$1" "$2" && return 0
417 if r=$(ssh "$node" command -v rsync); then
418 if rsync -V 1>/dev/null; then
419 _debug "COPY $1 $2 USE RSYNC"
420 rsync -e "/usr/bin/ssh -oConnectTimeout=$SSH_CONNECT_TIMEOUT -F $SSH_CONFIG_FILE" \
423 -aq "$1" "$2" && return 0
426 _debug "COPY $1 $2 USE SCP"
428 ssh "$node" rm -fr "$dir/$1" 2>/dev/null
429 scp "-oConnectTimeout=$SSH_CONNECT_TIMEOUT" -qF "$SSH_CONFIG_FILE" -r "$1" "$2" && return 0
430 _fail "Unable to copy '$1' to '$2'."
433 # Deployment functions
434 # ====================
436 _trace_id_new() { date +%s; }
439 cd "$R7_WORKDIR" || return 2
440 test -d ./nodes || return 0
441 find nodes -type d -maxdepth 1 | tail -n +2 | awk -F/ '{ print $2 }'
444 alias nodes-active=nodes_active
445 alias nodesact=nodes_active
447 test "$R7_DRYRUN" = yes && {
451 if test -s "$R7_WORKDIR/.active_nodes"; then
452 cat "$R7_WORKDIR/.active_nodes"
454 touch "$R7_WORKDIR/.active_nodes"
455 for node in $(nodes); do
456 _debug "ACTIVE $node"
457 if ssh "$node" :; then
458 printf "%s\\n" "$node" >>"$R7_WORKDIR/.active_nodes"
461 cat "$R7_WORKDIR/.active_nodes"
465 alias run-config=run_config
467 # shellcheck disable=SC1090,SC2031
469 trace_id=$(_trace_id_new)
470 # shellcheck disable=SC1091
471 _debug LOADING "$R7_SITE_CONFIG"
472 sh -n "$R7_SITE_CONFIG" && . "$R7_SITE_CONFIG"
475 # Inherits $target from group()
476 # shellcheck disable=SC2031
478 _debug "DEPLOY $new_name $target"
479 cd "$R7_WORKDIR" || exit 2
480 stem="groups/$new_name"
481 nodedir="nodes/$target"
482 test "$R7_DRYRUN" = yes || {
483 if test -d "$stem.dir"; then
484 ssh "$target" /bin/sh -c 'umask 027; mkdir -p /tmp/r7'
485 copy "$stem.dir" "$target:/tmp/r7" || return 3
487 if test -d "$nodedir"; then
488 test -f /tmp/r7/"$trace_id.$target" || {
489 ssh "$target" /bin/sh -c 'umask 027; mkdir -p /tmp/r7'
490 copy "$nodedir" "$target:/tmp/r7" || return 3
494 if test -f "$stem"; then
495 if _payload "$stem" | sh -n; then
496 _debug SYNTAX OK "$stem"
497 test "$R7_DRYRUN" = yes || {
498 _payload "$stem" | ssh 2>&1 "$target" /bin/sh - |
499 _collect "$R7_RUNDIR/$trace_id.$target.$new_name"
502 _debug SYNTAX KO "$stem"
503 echo 1>&2 "syntax error in '$stem', skipping..."
510 set -f -- $(printf -- "-e %s " "$@")
511 nodes_active | grep "$@"
514 # shellcheck disable=SC2030
516 test $# -eq 0 && return 0
517 cd "$R7_WORKDIR" || return 2
519 test "$R7_DRYRUN" = yes || {
520 mkdir -p "$R7_RUNDIR" || return 2
521 cd "$R7_RUNDIR" || return 2
523 _debug "CREATED $R7_RUNDIR"
525 _debug "PROCESSING GROUP $new_name"
527 # shellcheck disable=SC2046
529 for target in $(members "$@"); do
543 grep 2>/dev/null -e "^.*()" "$R7_WORKDIR/groups/$1" | cut -d\( -f1 | cut -d_ -f1 | uniq
546 alias group-functions=group_functions
547 alias groupfuncs=group_functions
549 if test "$R7_GROUP_ALLPREFIX" = yes; then
550 grep 2>/dev/null -e "^[^#].*()" "$R7_WORKDIR/groups/$1" | cut -d\( -f1
553 for pre in $prefix; do
554 grep 2>/dev/null -e "^${pre}.*()" "$R7_WORKDIR/groups/$1" | cut -d\( -f1
558 alias group-functions-all=group_functions_all
559 alias groupfuncsall=group_functions_all
560 group_functions_all() (
561 set -- $(cd "$R7_WORKDIR/groups" && find ./* -type f -maxdepth 0)
563 group_functions "${file##./}" | while read -r func; do
564 printf %s::%s\\n "${file##./}" "$func"
572 export prefix R7_PREFIX
576 cd "$R7_WORKDIR" || return 2
577 grep -e "^$2.*()" "$1" |
578 cut -d\( -f1 | while read -r function; do
579 _debug "CALL $1 $function"
580 echo trace "${1#*/}" "$function" START
581 echo "_ret=; $function || { _ret=\$?; : ;}"
582 echo trace "${1#*/}" "$function" END "\${_ret:-0}"
583 test "$R7_DRYRUN" = yes && {
584 echo 1>&2 "- (dryrun) $target:$new_name::$function"
590 echo "hostname=$target"
591 echo "trace_id=$trace_id"
592 echo "groupname=$new_name"
593 echo "exitonerror=$R7_GROUP_EXITONERROR"
596 echo ":: $trace_id $(nodename) $*" 1>&2
600 trace ERROR "$*" 1>&2
601 test "$exitonerror" = yes && {
602 case $_ret in (0) _ret=1 ;; esac
608 hostname=${hostname:-$(uname -n)}
609 echo "${hostname%%.*}"
612 atom=/tmp/r7/$(nodename)/$1
613 test -f "$atom" && return 0
614 test -d "$atom" && return 0
618 echo "/tmp/r7/$(nodename)"
621 echo "/tmp/r7/$groupname.dir"
628 trace SOURCE FAILED "$1"
634 error missing source argument
637 _source "$(test -f "$1" && printf %s "$PWD/${1##"$PWD"}")"
640 node "$1" && cd "$(nodedir)" && fsource "$1"
643 cd "$(groupdir)" && _fsource "$1"
645 install() ( cd /tmp/r7 && r7_install "$@" ; )
646 groupinstall() ( cd "$(groupdir)" && r7_install "$@" ; )
647 nodeinstall() ( cd "$(nodedir)" && r7_install "$@" ; )
650 ls -l "$1" | grep -v total | awk '{ print $1 " " $3 " " $4 }'
654 before=$(_getperm "$1")
655 if test -n "$owner"; then
656 chown "$owner" "$1" || {
661 if test -n "$mode"; then
662 chmod "$mode" "$1" || {
667 after=$(_getperm "$1")
668 if test "$before" != "$after"; then
669 trace PERMISSIONS CHANGE "$before" TO "$after" ON "$1"
678 { error copying "$source" to "$2"; return 2 ; }
683 while getopts "o:m:" arg; do
690 shift $((OPTIND - 1))
693 source_dirname=${1%/*}
694 if ! test -e "$source"; then
695 error "$source" not found
699 */) test -d "$target" || {
700 error "$target" directory not found
704 if test -e "$target"; then
705 cmp -s "$source" "$target"
709 if test -d "$target"; then
710 new_filename="$target/${source##$source_dirname/}"
711 cmp -s "$source" "$new_filename"
713 destination_is=directory
715 if test -e "$source"; then
716 if test -d "$target"; then
717 destination_is=directory
725 case $destination_is in
727 new_filename="$target/${source##$source_dirname/}"
728 _install "$source" "$new_filename"
731 _install "$source" "$target"
737 installdir() { r7_installdir "$@" ; }
738 groupinstalldir() ( cd "$(groupdir)" && r7_installdir "$@" ; )
739 nodeinstalldir() ( cd "$(nodedir)" && r7_installdir "$@" ; )
741 while getopts "o:m:O:M:" arg; do
743 o) fileowner=$OPTARG ;;
744 m) filenode=$OPTARG ;;
745 O) dirowner=$OPTARG ;;
746 M) dirmode=$OPTARG ;;
750 shift $((OPTIND - 1))
753 source_basename=${source##*/}
754 source_dirname=$PWD/${source%%/*}
755 target_basename=${target##*/}
756 test -d "$source" || {
757 error "$source" directory not found
760 for dir in $(find "$source" -type d); do
763 cd "$target" || return 2
764 mkdir -p "$newdir" || { error unable to create "$newdir"; return 2 ; }
765 owner=$dirowner mode=$dirmode _setperm "$1"
768 for file in $(find "$source" -type f); do
770 r7_install -o "$fileowner" -m "$filemode" "$file" "$target/$newfile"
774 while getopts "s:" arg; do
780 shift $((OPTIND - 1))
782 test -n "$source" && sh -n "$source" && set -a && . "./$source" && set +a
784 sh > "${file%%.tpl}" <<-.
791 grouptemplate() ( cd "$(groupdir)" && template "$@" ; )
792 nodetemplate() ( cd "$(nodedir)" && template "$@" ; )
794 cd /tmp/r7 && umask 027
799 trace CONNECTED $(date +%s)
803 _debug "PREFIX $R7_PREFIX"
804 for run in $R7_PREFIX; do
808 trace DISCONNECT $(date +%s)
812 # Interactive output function
813 # ===========================
815 # shellcheck disable=SC2086,SC2030
818 tee -a "$1" | while read -r line; do
822 # Traces from payload
832 save_group=$group save_function=$function
834 if test "$R7_SHOWOUTPUT" = yes; then
835 echo "- (output) $host:$group::$function"
842 0 | 30 | 31) echo "- (ok) $host:$group::$function" ;;
843 *) echo "! (error $endcode) $host:$group::$function" ;;
847 # Traces from functions
851 SOURCE) echo "+ (source) $host:$save_group::$save_function" ;;
852 DIFF) echo "+ (diff) $host:$save_group::$save_function" ;;
853 PERMISSIONS) echo "+ (permissions) $host:$save_group::$save_function" ;;
854 ERROR) echo "! (error) $host:$save_group::$save_function" ;;
855 EXIT) echo "! (exit) $host:$save_group::$save_function" ;;
862 if test "$R7_SHOWOUTPUT" = yes; then
863 test "$in_function" -eq 1 &&
864 printf "%s\\n" "$line"
869 # Text output functions
870 # =====================
872 alias output-dump=output_dump
873 alias outdump=output_dump
875 set -e -- $(output_latest)
876 test $# -eq 0 && return 0
877 cd "$R7_WORKDIR" || _fail "Unable to cd to workdir: $R7_WORKDIR"
878 test "$R7_DRYRUN" = yes || {
879 mkdir -p "$R7_OUTPUTDIR"
880 awk -voutput_dir="$R7_OUTPUTDIR" '
889 if ($4 == "CONNECTED") {
893 system("mkdir -p " output_dir "/" hostname)
896 $2 == trace_id && $6 == "START" {
900 output_node = output_dir "/" hostname "/" groupname "/" funcname
901 output_status = output_node "/status"
902 output_output = output_node "/output"
903 system("rm -fr " output_node)
904 system("mkdir -p " output_node)
907 $2 == trace_id && $4 == "EXIT" {
909 system("echo " $5 " >" output_status)
911 $2 == trace_id && $6 == "END" {
913 system("echo " $7 " >" output_status)
915 in_function_block == 1 {
916 print > output_output
918 $2 == trace_id && $4 == "DISCONNECT" {
919 timestamp_disconnect=$5
920 difference=timestamp_disconnect - timestamp_connect
921 output_duration = output_dir "/" hostname "/" groupname "/duration"
922 system("echo " difference " >" output_duration)
930 OpenBSD | FreeBSD | NetBSD | Darwin)
937 perl -e "print scalar localtime($1)"
943 alias output-latest=output_latest
944 alias outlatest=output_latest
948 # shellcheck disable=SC2012
949 ls 2>/dev/null -lt1 "$R7_RUNDIR/" | while read -r file; do
954 test -z "$latest" && return 0
955 latest_date=$(_epoch "$latest")
956 if test -n "$latest_date"; then
957 find "$R7_RUNDIR" -type f -name "$latest.*"
962 cd "$R7_OUTPUTDIR" 2>/dev/null || return 2
963 find ./* -type f -name 'output'
967 cd "$R7_OUTPUTDIR" 2>/dev/null || return 2
968 find ./* -type f -name 'status'
972 cd "$R7_OUTPUTDIR" 2>/dev/null || return 2
973 find ./* -type f -name 'duration'
977 column -t 2>/dev/null ||
981 alias output-status=output_status
982 alias outstat=output_status
984 cd "$R7_OUTPUTDIR" || return 2
985 echo HOST GROUP FUNCTION STATUS
987 while read -r status; do
988 echo "$status" | while IFS=/ read -r _ host groupname funcname _; do
989 echo "$host" "$groupname" "$funcname" "$(cat "$status")"
995 alias output-duration=output_duration
996 alias outdur=output_duration
998 cd "$R7_OUTPUTDIR" || return 2
999 echo HOST GROUP DURATION
1001 while read -r duration; do
1002 echo "$duration" | while IFS=/ read -r _ host groupname _; do
1003 echo "$host" "$groupname" "$(cat "$duration")"
1010 # shellcheck disable=SC2254
1012 cd "$R7_OUTPUTDIR" || return 2
1013 _output_files | while read -r output; do
1014 echo "$output" | while IFS=/ read -r _ host groupname funcname _; do
1015 case $host in ${1:-*})
1016 case $groupname in ${2:-*})
1017 case $funcname in ${3:-*})
1018 echo "- $host" "$groupname" "$funcname"
1020 sed 's/^/ /' "$R7_OUTPUTDIR/$output"
1032 alias trace-id-last=trace_id_last
1033 alias lastid=trace_id_last
1035 output_latest | while read -r output; do
1036 output=${output##*/}
1037 output=${output%%.*}
1043 # _output/tools0/r7test/r7test_diff/output::: 1748408055 tools0 DIFF /tmp/difftest r7test.dir/difftest
1044 alias output-diffs=output_diffs
1045 alias outdiffs=output_diffs
1046 # shellcheck disable=SC2048,SC2086
1048 trace_id=$(trace_id_last)
1049 cd "$R7_OUTPUTDIR" || return 0
1050 grep -r "^:: ${trace_id:-} .* DIFF" | uniq | while read -r line; do
1052 output_file=${1%%:::}
1056 host=$1 groupname=$2 funcname=$3
1058 echo "- (diff) -" "in" "$host" "$groupname" "$funcname" "($trace_id)"
1060 sed 's/^/ /' "$R7_OUTPUTDIR/$host/$groupname/$funcname/output" | grep -v "^ :: $trace_id $host"
1066 # :: 1748415578 tools0 PERMISSIONS CHANGE -rw-r-xr-x root wheel -> -rw-r--r-- root wheel on /tmp/difftest
1067 alias output-permissions=output_permissions
1068 alias outperm=output_permissions
1069 # shellcheck disable=SC2048,SC2086
1070 output_permissions() (
1071 trace_id=$(trace_id_last)
1072 cd "$R7_OUTPUTDIR" || return 0
1073 grep -r "^:: ${trace_id:-} .* PERMISSIONS" | while read -r line; do
1075 output_file=${1%%:::}
1079 host=$1 groupname=$2 funcname=$3
1081 echo "- (permissions) -" "in" "$host" "$groupname" "$funcname" "($trace_id)"
1083 grep -e "^:: ${trace_id:-} .* PERMISSIONS CHANGE" "$R7_OUTPUTDIR/$host/$groupname/$funcname/output" |
1084 while read -r _ _ _ _ _ mode owner group _ mode2 owner2 group2 _ filename; do
1085 echo " - $filename from $mode $owner:$group to $mode2 $owner2:$group2"
1092 alias output-errors=output_errors
1093 alias outerr=output_errors
1095 trace_id=$(trace_id_last)
1096 output_status | while read -r host groupname funcname status; do
1098 STATUS | 0 | 30 | 31) continue ;;
1100 echo "- (error $status) -" "in" "$host" "$groupname" "$funcname" "($trace_id)"
1101 if test -f "$R7_OUTPUTDIR/$host/$groupname/$funcname/output"; then
1103 sed 's/^/ /' "$R7_OUTPUTDIR/$host/$groupname/$funcname/output"
1111 test -d "$R7_OUTPUTDIR" || return 0
1117 test -n "$summary_out" &&
1118 printf "%s\n" "$summary_out"
1125 # Used in the digraph generation because inside a heredoc
1126 # context, memebers don't work as expected (OKSH).
1127 # shellcheck disable=SC2048,SC2086
1131 nodes | grep -e '.*'
1135 set -f -- $(printf -- "-e %s " "$@")
1139 alias digraph-host=digraph_host
1140 # shellcheck disable=SC2086
1142 _debug "DIGRAPH HOST"
1147 graph [overlap="false", rankdir=LR, mode=KK]
1150 for node in $(nodes); do echo "\"$node\" [peripheries=2 color=blue]"; done
1151 grep 2>/dev/null "^group" "$R7_SITE_CONFIG" | while read -r _ groupname nodes; do
1152 echo "\"$groupname\" [color=orange]"
1153 for node in $(_members2 $nodes); do
1154 echo "\"$node\" -> \"$groupname\""
1156 for funct in $(group_functions "$groupname"); do
1157 echo "\"$groupname::$funct\" [color=red]"
1158 echo "\"$groupname\" -> \"$groupname::$funct\""
1166 alias digraph-ssh-id-hosts=digraph_ssh_id_hosts
1167 digraph_ssh_id_hosts() {
1169 digraph ssh_id_hosts {
1172 graph [overlap="false"]
1182 # shellcheck disable=SC2120
1184 pandoc -t html -f markdown+smart "$@"
1188 output \* \* doc\* | grep ^- | while read -r _ host group function; do
1189 cat "$R7_OUTPUTDIR/$host/$group/$function/output"
1195 summary | while read -r line; do
1199 printf %s\\n "$line"
1205 cat "$R7_OUTPUTDIR/$6/$7/$8/output"
1216 <meta charset="UTF-8">
1217 <meta name="viewport" content="width=device-width,initial-scale=1">
1218 <title>${PWD##*/}</title>
1220 body{margin:1rem;background:#f5f7fa;color:#333;font:14px/1.5 Consolas,monospace}
1221 .container{max-width:800px;margin:0 auto;padding:1.5rem;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,0.05);border-radius:3px}
1222 header{border-bottom:1px solid #ddd;margin-bottom:1rem;padding-bottom:.5rem}
1223 header h1{margin:0;font-size:1.4rem;font-weight:400}
1224 header time{font-size:.85rem;color:#666}
1225 .summary{border-bottom:1px dashed #eee;padding:.5rem 0}
1226 .summary:last-child{border:none;padding:0}
1227 .timestamp{display:block;font-size:.8rem;color:#555;margin-bottom:.25rem}
1231 <div class="container">
1235 <section class="summary">
1238 <section class="summary">
1252 cd "$R7_WORKDIR" || _fail "Unable to cd to '$R7_WORKDIR'"
1254 if test -n "$target"; then
1255 echo >&2 "-> Importing target host: $target"
1256 case $(ssh "$target" uname) in
1257 Linux) _Linux_import ;;
1258 SunOS) _SunOS_import ;;
1259 OpenBSD) _OpenBSD_import ;;
1260 FreeBSD) _FreeBSD_import ;;
1261 NetBSD) _NetBSD_import ;;
1262 DragonFly) _DragonFly_import ;;
1263 Haiku) _Haiku_import ;;
1265 Minix) _Minix_import ;;
1267 _error "Platform unsupported or host unavailable."
1271 echo >&2 "-> done: $target"
1273 echo >&2 "usage: $0 target-host"
1280 _debug "COPY $src $dst"
1281 ssh "$target" command -v rsync >/dev/null && {
1282 _debug "IMPORT $1 $2 USE RSYNC"
1283 rsync -e "/usr/bin/ssh -oConnectTimeout=$SSH_CONNECT_TIMEOUT -F $SSH_CONFIG_FILE" \
1284 -a "$src" "$dst" && return 0
1286 _openrsync=$(ssh "$target" command -v openrsync) && {
1287 _debug "IMPORT $1 $2 USE OPENRSYNC"
1288 openrsync -e "/usr/bin/ssh -oConnectTimeout=$SSH_CONNECT_TIMEOUT -F $SSH_CONFIG_FILE" \
1289 --rsync-path="$_openrsync" \
1290 -a "$src" "$dst" && return 0
1292 _debug "IMPORT $1 $2 USE SCP"
1293 scp "-oConnectTimeout=$SSH_CONNECT_TIMEOUT" -qF "$SSH_CONFIG_FILE" -r "$src" "$dst" && return 0
1294 _fail "IMPORT $1 $2 FAILED"
1297 _import_ssh_authorized_keys_old() (
1300 ssh "$target" getent passwd | awk -F: '$3 >= 1000 { print $1 }' |
1304 for user in root $other_users; do
1305 # shellcheck disable=SC2029
1306 home=$(ssh "$target" getent passwd "$user" | awk -F: '{ print $6 }')
1307 authorized_keys="$home/.ssh/authorized_keys"
1308 ssh "$target" test -f "$authorized_keys" || continue
1309 mkdir -p "nodes/$target/ssh/"
1310 echo >&2 "--> Importing $authorized_keys"
1311 _import_copy "$target:$authorized_keys" "nodes/$target/ssh/authorized_keys_$user"
1315 _import_ssh_authorized_keys() (
1318 ssh "$target" cat /etc/passwd | awk -F: '$3 >= 1000 { print $1 }' | grep -v nobody
1320 for user in root $other_users; do
1322 ssh "$target" cat /etc/passwd | awk -F: -v user="$user" '$1 == user { print $6 }'
1324 authorized_keys="$home/.ssh/authorized_keys"
1325 if ! ssh -n "$target" test -f "$authorized_keys"; then
1328 mkdir -p "nodes/$target/ssh/"
1329 echo >&2 "--> Importing $authorized_keys for user='$user'"
1330 _import_copy "$target:$authorized_keys" "nodes/$target/ssh/authorized_keys_$user"
1334 _import_etc_if_exists() {
1335 mkdir -p nodes/"$target"
1336 echo >&2 "--> Importing /etc"
1337 for file in $etc_files_to_import; do
1338 ssh "$target" test -f "'/etc/$file'" &&
1339 _import_copy "$target:/etc/$file" "nodes/$target"
1343 _import_etc_ssh_if_exists() {
1344 etc_ssh_files_to_import='
1348 mkdir -p nodes/"$target/ssh"
1349 echo >&2 "--> Importing /etc/ssh"
1350 for file in $etc_ssh_files_to_import; do
1351 ssh "$target" test -f "'/etc/$file'" &&
1352 _import_copy "$target:/etc/$file" "nodes/$target/ssh"
1356 _import_modprobe() {
1357 if ssh "$target" test -d /etc/modprobe.d; then
1358 _content=$(ssh "$target" ls -A /etc/modprobe.d/)
1359 test -z "$_content" && return 0
1360 echo >&2 "--> Importing /etc/modprobe.d"
1361 mkdir -p nodes/"$target/modprobe.d"
1362 _import_copy "$target:/etc/modprobe.d/*" "nodes/$target/modprobe.d"
1367 etc_files_to_import='
1370 _import_etc_if_exists
1371 echo >&2 "--> Importing /etc/settings"
1372 mkdir -p "nodes/$target/settings"
1373 _import_copy "$target:/etc/settings" "nodes/$target/settings"
1374 echo >&2 "--> Importing /system/etc/ssh"
1375 mkdir -p "nodes/$target/ssh"
1376 _import_copy "$target:/system/etc/ssh/sshd_config" "nodes/$target/ssh"
1377 _import_ssh_authorized_keys
1381 etc_files_to_import='
1389 _import_etc_if_exists
1390 echo >&2 "--> Importing /usr/pkg/etc/ssh"
1391 mkdir -p nodes/"$target/ssh"
1392 _import_copy "$target:/usr/pkg/etc/ssh/sshd_config" "nodes/$target/ssh"
1393 _import_copy "$target:/usr/pkg/etc/ssh/ssh_config" "nodes/$target/ssh"
1394 echo >&2 "--> Importing /usr/pkg/etc/pkgin"
1395 mkdir -p nodes/"$target/pkgin"
1396 _import_copy "$target:/usr/pkg/etc/pkgin/repositories.conf" "nodes/$target/pkgin"
1400 etc_files_to_import='
1404 _import_etc_if_exists
1405 _import_copy "$target:/boot/system/settings/network/hosts" "nodes/$target/"
1406 _import_copy "$target:/boot/system/settings/network/resolv.conf" "nodes/$target/"
1407 _import_copy "$target:/boot/system/settings/package-repositories" "nodes/$target/"
1408 echo >&2 "--> Importing /boot/system/settings/ssh"
1409 mkdir -p "nodes/$target/ssh"
1410 _import_copy "$target:/boot/system/settings/ssh/sshd_config" "nodes/$target/ssh"
1411 _import_copy "$target:/boot/system/settings/ssh/ssh_config" "nodes/$target/ssh"
1412 _import_copy "$target:/boot/home/config/settings/ssh/authorized_keys" \
1413 "nodes/$target/ssh/authorized_keys_user"
1417 if _Linux_is_debian_based; then
1418 _Linux_import_debian
1419 elif _Linux_is_rhel_based; then
1421 elif _Linux_is_suse_based; then
1423 elif _Linux_is_gentoo_based; then
1424 _Linux_import_gentoo
1425 elif _Linux_is_arch_based; then
1427 elif _Linux_is_artix; then
1429 elif ssh "$target" "grep -q 'ID=alpine' /etc/os-release"; then
1430 _Linux_import_alpine
1431 elif ssh "$target" "grep -q 'ID=crux' /etc/os-release"; then
1433 elif ssh "$target" "grep -q 'ID=\"void\"' /etc/os-release"; then
1435 elif ssh "$target" "grep -q 'ID=guix' /etc/os-release"; then
1438 echo >&2 "Unsupported Linux distribution."
1442 _Linux_is_debian_based() (
1443 ssh "$target" "grep -q 'ID=debian' /etc/os-release || \
1444 grep -q 'ID_LIKE=.*debian' /etc/os-release || \
1445 grep -qiE 'Debian|Ubuntu|Linux Mint' /etc/issue || \
1446 test -f /etc/debian_version || test -d /etc/apt || command -v apt-get >/dev/null 2>&1" 2>/dev/null
1449 _Linux_is_rhel_based() (
1450 ssh "$target" "grep -qiE 'ID=\"?(rhel|fedora|centos|ol|scientific|rocky|eurolinux)\"?' /etc/os-release || \
1451 grep -qiE 'ID_LIKE=\"?.*(rhel|fedora).*\"?' /etc/os-release || \
1452 grep -qiE 'Red Hat|CentOS|Fedora|Scientific Linux|Oracle Linux' /etc/issue || \
1453 test -f /etc/redhat-release || test -f /etc/system-release || test -f /etc/centos-release || \
1454 test -f /etc/fedora-release || test -f /etc/oracle-release || \
1455 (test -f /etc/os-release && grep -qi 'rhel' /etc/os-release) || test -d /etc/yum.repos.d" 2>/dev/null
1458 _Linux_is_gentoo_based() (
1459 ssh "$target" "test -f /etc/gentoo-release || \
1460 grep -q 'ID=gentoo' /etc/os-release || \
1461 grep -q 'ID=funtoo' /etc/os-release || \
1462 grep -qi 'Gentoo' /etc/issue || \
1463 command -v emerge >/dev/null 2>&1" 2>/dev/null
1466 _Linux_is_suse_based() (
1467 ssh "$target" "test -f /etc/os-release && grep -Eqi \
1468 'suse|opensuse' /etc/os-release || \
1469 command -v zypper >/dev/null || \
1470 command -v yast2 >/dev/null" 2>/dev/null
1473 _Linux_is_arch_based() (
1474 ssh "$target" "test -f /etc/os-release && grep -Eqi \
1475 'arch|manjaro|endeavouros|arcolinux|parabola' /etc/os-release || \
1476 command -v pacman >/dev/null || \
1477 command -v makepkg >/dev/null" 2>/dev/null
1481 ssh "$target" "test -f /etc/os-release && grep -Eqi \
1482 'artix|hyperbola' /etc/os-release || \
1483 command -v pacman >/dev/null || \
1484 command -v makepkg >/dev/null" 2>/dev/null
1487 _Linux_import_gentoo() {
1488 etc_files_to_import='
1508 _import_etc_if_exists
1509 _import_etc_ssh_if_exists
1510 echo >&2 "--> Importing /etc/portage"
1511 mkdir -p "nodes/$target/portage"
1512 _import_copy "$target:/etc/portage/" "nodes/$target/portage/"
1513 _import_ssh_authorized_keys
1517 _Linux_import_alpine() {
1518 etc_files_to_import='
1536 _import_etc_if_exists
1537 _import_etc_ssh_if_exists
1538 echo >&2 "--> Importing /etc/apk"
1539 mkdir -p "nodes/$target/apk"
1540 _import_copy "$target:/etc/apk/repositories" "nodes/$target/apk/"
1541 _import_ssh_authorized_keys
1545 _Linux_import_rhel() {
1546 etc_files_to_import='
1565 _import_etc_if_exists
1566 _import_etc_ssh_if_exists
1567 echo >&2 "--> Importing /etc/yum.repos.d"
1568 mkdir -p "nodes/$target/yum.repos.d"
1569 _import_copy "$target:/etc/yum.repos.d/" "nodes/$target/yum.repos.d/"
1570 _import_ssh_authorized_keys
1574 _Linux_import_debian() {
1575 etc_files_to_import='
1593 _import_etc_if_exists
1594 echo >&2 "--> Importing /etc/apt"
1595 mkdir -p "nodes/$target/apt"
1596 _import_copy "$target:/etc/apt/sources.list" "nodes/$target/apt"
1597 _import_copy "$target:/etc/apt/sources.list.d" "nodes/$target/apt"
1598 echo >&2 "--> Importing /etc/network"
1599 mkdir -p "nodes/$target/network/"
1600 _import_copy "$target:/etc/network/interfaces" "nodes/$target/network/"
1601 _import_copy "$target:/etc/network/interfaces.d" "nodes/$target/network/"
1602 _import_etc_ssh_if_exists
1603 _import_ssh_authorized_keys
1608 _Linux_import_suse() {
1609 etc_files_to_import='
1620 _import_etc_if_exists
1621 _import_etc_ssh_if_exists
1622 _import_ssh_authorized_keys
1626 _Linux_import_arch() {
1627 etc_files_to_import='
1639 _import_etc_if_exists
1640 _import_etc_ssh_if_exists
1641 _import_ssh_authorized_keys
1645 _Linux_import_artix() {
1649 _Linux_import_guix() {
1650 etc_files_to_import='
1663 _import_etc_if_exists
1664 _import_etc_ssh_if_exists
1665 _import_ssh_authorized_keys
1668 _Linux_import_void() {
1669 etc_files_to_import='\
1683 _import_etc_if_exists
1684 _import_etc_ssh_if_exists
1685 _import_ssh_authorized_keys
1689 _Linux_import_crux() {
1690 etc_files_to_import='
1704 _import_etc_if_exists
1705 _import_etc_ssh_if_exists
1706 _import_ssh_authorized_keys
1711 etc_files_to_import='
1732 _import_etc_if_exists
1733 _import_etc_ssh_if_exists
1734 _import_ssh_authorized_keys
1738 etc_files_to_import='
1753 _import_etc_if_exists
1754 echo >&2 "--> Importing /etc/pkg"
1755 _import_copy "$target:/etc/pkg" "nodes/$target/"
1756 _import_etc_ssh_if_exists
1757 _import_ssh_authorized_keys
1760 _DragonFly_import() {
1761 etc_files_to_import='
1772 _import_etc_if_exists
1773 _import_etc_ssh_if_exists
1774 _import_ssh_authorized_keys
1778 etc_files_to_import='
1791 _import_etc_if_exists
1792 _import_etc_ssh_if_exists
1793 _import_ssh_authorized_keys
1797 etc_files_to_import='
1823 etc_ssh_files_to_import='
1834 var_postgresql_files='
1836 data/postgresql.conf
1838 _import_ssh_authorized_keys
1839 _OpenBSD_import_etc_if_exists
1840 _import_etc_ssh_if_exists
1841 _OpenBSD_import_etc_mail_if_exists
1842 _OpenBSD_import_unbound_if_enabled
1843 _OpenBSD_import_nsd_if_enabled
1844 _OpenBSD_import_postgresql_if_installed
1847 _OpenBSD_import_etc_if_exists() {
1848 mkdir -p nodes/"$target"
1849 interfaces=$(ssh "$target" 'cd /etc && ls -1 hostname.*')
1850 echo >&2 "--> Importing /etc"
1851 for file in $etc_files_to_import $interfaces; do
1852 ssh "$target" test -f "'/etc/$file'" &&
1853 _import_copy "$target:/etc/$file" "nodes/$target/"
1857 _OpenBSD_import_etc_mail_if_exists() {
1858 etc_mail_files_to_import='
1863 mkdir -p nodes/"$target"
1864 echo >&2 "--> Importing /etc/mail"
1865 for file in $etc_mail_files_to_import; do
1866 ssh "$target" test -f "'/etc/$file'" &&
1867 _import_copy "$target:/etc/$file" "nodes/$target/mail"
1871 _OpenBSD_import_unbound_if_enabled() {
1872 ssh "$target" rcctl check unbound >/dev/null && {
1873 echo >&2 "--> Importing /var/unbound"
1874 for file in $var_unbound_files; do
1876 mkdir -p "nodes/$target/unbound/$prefix"
1877 _import_copy "$target:/var/unbound/$file" "nodes/$target/unbound/$prefix/"
1883 _OpenBSD_import_nsd_if_enabled() {
1884 ssh "$target" rcctl check nsd >/dev/null && {
1885 echo >&2 "--> Importing /var/nsd"
1886 for file in $var_nsd_files; do
1888 mkdir -p "nodes/$target/nsd/$prefix"
1889 _import_copy "$target:/var/nsd/$file" "nodes/$target/nsd/$prefix/"
1895 _OpenBSD_import_postgresql_if_installed() {
1896 ssh "$target" postgres -V 2>/dev/null | grep -q PostgreSQL && {
1897 echo >&2 "--> Importing /var/postgresql"
1898 for file in $var_postgresql_files; do
1900 mkdir -p "nodes/$target/postgresql/$prefix"
1901 _import_copy "$target:/var/postgresql/$file" "nodes/$target/postgresql/$prefix/"
1903 echo >&2 "--> Importing /etc/login.conf.d/postgresql"
1904 mkdir -p "nodes/$target/login.conf.d"
1905 _import_copy "$target:/etc/login.conf.d/postgresql" "nodes/$target/login.conf.d/"