Нет-нет, этот пост совсем не про боль и страдание! Даже немножко наоборот. Просто картинка напоминает о значимости первой строчки будущего кода ;).
Вначале, просто хотел описать свежую утилиту, с пылу с жару из под клавиатуры. Сама по себе она вполне ничего (хоть и до блеска бляхи новоиспечённого дембеля её ещё полировать и полировать), но описывать только её оказалось как-то скучновато. Потом решил, что на её примере можно поделиться башизмами, которыми сам раньше не пользовался. Далее подумал, что можно убить двух зайцев и описывать её совместно с жизненными примерами. Но в конце понял, что негоже мучить и без того изрядно потрёпанных зверушек, и решил просто их немножко причесать. Третий заяц (который и не заяц вовсе, а удав) мудро предпочёл воздержаться от участия в этом бардаке.
Так что если вдруг, тебе регулярно приходится искать файлы и может даже затем перемещать их куда-то; или тебе всё равно какой слой пыли лежит на файловой системе твоего сервера с аптаймом в несколько сотен лет и тебе просто интересен bash; или если ты просто мимо проходил{,а,о}, то не проходи мимо!
findNclean создан на базе великого и могучего find
, не менее великого mv
, и всё это в супе из bash
.
Конечно, можно было написать всё на python
и не заморачиваться с «тонкостями» bash
, но у меня не было такого выбора, плюс у bash получше с переносимостью между разными окружениями — просто исполняемый файл и никаких тебе библиотек, виртуальных окружений и сопутствующих проблем с запуском на винегрете из платформ. Кто не согласен — киньте в меня… чем-нибудь, только чтоб проняло :). Повозиться поэтому пришлось, включая адаптацию под старые окружения типа CentOS 5. Зато зависимостей получилось минимум, для новых осей (типа Ubuntu 16.04): find mv wc
, для старых плюс date touch
. Остальное всё чистый bash.
Основной сценарий использования
- Создаёшь конфигурационный файл, где описываешь правила для поиска определённой категории файлов/директорий/симлинков. Каждое правило — это обычно отдельный процесс
find
, который записывает найденные файлы в свой лог. - Если нужен не только список файлов сам по себе, а для дальнейшего перемещения, там же описываешь опции перемещения.
- Запускаешь процесс поиска. Он создаст csv-лог со списком найденных элементов.
- Проверяешь этот список, если вдруг туда попало что-то не то — удаляешь эти строки.
- Если в конфиге указан глобальный параметр
APPROVED_SUFFIX
(во избежание перемещения файлов из не утверждённого списка), добавляешь этот суффикс к именам логов. По-умолчаниюAPPROVED_SUFFIX
пустой, т.е. переименование не требуется. - Запускаешь процесс перемещения. Он переместит все файлы у которых логированные параметры совпадают с реальными, а у которых не совпадает — не переместит с сообщением в лог ошибок перемещения. Конечно же создаст лог когда, что, куда переместил.
До всей документации можно добраться через вызов совсем без параметров: ./findNclean
Состоит утилита из исполняемого и конфигурационного файлов. Все доступные опции конфига можно глянуть в выводе ./findNclean help -c
# 100501й способ выстрелить себе в ногу
<Rule - На твой страх и риск>
result_list = /tmp/ненужное.csv
search_path = /
type = file
move_path = /.Trash
move_log = /var/log/findNclean - Highway to hell (remix).csv
</Rule>
2017-03-13T02:04:02 10853 Start action: find
2017-03-13T02:04:02 10853 Config: ./findNclean.conf.example
2017-03-13T02:04:02 10853 Debug mode enabled
2017-03-13T02:04:02 10853 Config rules count: 2
2017-03-13T02:04:02 10853 Relative paths allowed. Current dir: /home/user/path/to/findNclean
2017-03-13T02:04:02 10853 Effective config content:
<Global>
DEBUG = true
REL_PATH_ALLOW = true
APPROVED_SUFFIX = .approved
</Global>
<Rule1 find/move old unused *.tmp files in /tmp, /var and current user home dir>
result_list = ./ancient-tmp.find-example.csv
search_path = /var/
search_path = /tmp/
search_path = ~/
type = f
name = .*.tmp
accessed < 2017
move_path = ./
move_log = ./ancient-tmp.move-example.csv
move_log_err = ./ancient-tmp.move-example.err.csv
</Rule1>
<Rule2 The same as above but for small non-zero logs>
result_list = ./ancient-logs.find-example.csv
search_path = ~/
search_path = /var/
search_path = /tmp/
type = f
name = .*.log(.gz)?
size > 0
size <= 2M
accessed < 2017
move_path = ./
move_log = ./ancient-logs.move-example.csv
move_log_err = ./ancient-logs.move-example.err.csv
</Rule2>
2017-03-13T02:04:02 10853 All non-zero size existing result_list files will be preliminary backuped:
2017-03-13T02:04:02 10853 The following commands will be evaluated (you can copy-paste them to bash terminal as is):
find -P -O3 -- /var/ /tmp/ /home/user/ -regextype posix-extended ( ! -name *$'n'* -type f -regex .*/.*\.tmp ! -newerat 2017-01-01T00:00:00 -fprintf ./ancient-tmp.find-example.csv %y;%s;%TY-%Tm-%TdT%TH:%TM:%TS;%AY-%Am-%AdT%AH:%AM:%AS;%u;%g;%p\n ) , ( ! -name *$'n'* -type f -regex .*/.*\.log(\.gz)? -size +0c ( -size -2097152c -o -size 2097152c ) ! -newerat 2017-01-01T00:00:00 -fprintf ./ancient-logs.find-example.csv %y;%s;%TY-%Tm-%TdT%TH:%TM:%TS;%AY-%Am-%AdT%AH:%AM:%AS;%u;%g;%p\n )
2017-03-13T02:04:02 10853 Wait for all background processes completion...
2017-03-13T02:04:06 10853 Completed in 4 sec
2017-03-13T02:04:06 10853 Found items count (filenames with newline was excluded from the search):
0 ./ancient-tmp.find-example.csv
7 ./ancient-logs.find-example.csv
7 total
2017-03-13T02:04:06 10853 WARNING: Some 'find' commands returned non-zero exit code, results could be incomplete or incorrect!
2017-03-13T02:04:06 10853 Exit codes list: 1
2017-03-13T02:04:06 10853 Finished action: find
grep
, кстати, в этом контексте не совсем корректен, т.к. несколько дочерних процессов пишут в один и тот же терминал, и иногда один из процессов начинает/продолжает писать ещё до того как другой успел дописать строчку, поэтому часть полезной инфы может пропасть, а часть бесполезной стать видимой.
vim ./ancient-logs.find-example.csv
mv ./ancient-logs.find-example.csv{,.approved}
mv ./ancient-tmp.find-example.csv{,.approved}
2017-03-13T02:05:32 11141 Start action: mv
2017-03-13T02:05:32 11141 Config: ./findNclean.conf.example
2017-03-13T02:05:32 11141 Debug mode enabled
2017-03-13T02:05:32 11141 Config rules count: 2
2017-03-13T02:05:32 11141 Relative paths allowed. Current dir: /home/user/path/to/findNclean
2017-03-13T02:05:32 11141 Effective config content:
<Global>
DEBUG = true
REL_PATH_ALLOW = true
APPROVED_SUFFIX = .approved
</Global>
<Rule1 find/move old unused *.tmp files in /tmp, /var and current user home dir>
result_list = ./ancient-tmp.find-example.csv
search_path = /var/
search_path = /tmp/
search_path = ~/
type = f
name = .*.tmp
accessed < 2017
move_path = ./
move_log = ./ancient-tmp.move-example.csv
move_log_err = ./ancient-tmp.move-example.err.csv
</Rule1>
<Rule2 The same as above but for small non-zero logs>
result_list = ./ancient-logs.find-example.csv
search_path = ~/
search_path = /var/
search_path = /tmp/
type = f
name = .*.log(.gz)?
size > 0
size <= 2M
accessed < 2017
move_path = ./
move_log = ./ancient-logs.move-example.csv
move_log_err = ./ancient-logs.move-example.err.csv
</Rule2>
2017-03-13T02:05:32 11141 All non-zero size existing move_log and move_log_err files will be preliminary backuped:
2017-03-13T02:05:32 11141 Start processing the following move lists in background:
0 ./ancient-tmp.find-example.csv.approved
2 ./ancient-logs.find-example.csv.approved
2 total
2017-03-13T02:05:32 11141 Wait for all background processes completion...
mv: cannot move '/var/log/openvpn/00004-test.log' to './00004-test.log': Permission denied
mv: cannot move '/var/log/openvpn/00009-tmp.log' to './00009-tmp.log': Permission denied
2017-03-13T02:05:32 11141 Completed in 0 sec
2017-03-13T02:05:32 11141 Moved items count (successful canceled failed move_log [move_log_err]):
0 0 0 ./ancient-tmp.move-example.csv ./ancient-tmp.move-example.err.csv
0 0 2 ./ancient-logs.move-example.csv ./ancient-logs.move-example.err.csv
0 0 2 total
2017-03-13T02:05:32 11141 Finished action: mv
removed '/dev/shm/findNclean.exch26957.tmp'
removed '/dev/shm/findNclean.exch24396.tmp'
2017-03-13T02:06:48 11394 Start action: mv
2017-03-13T02:06:48 11394 Config: ./findNclean.conf.example
2017-03-13T02:06:48 11394 Debug mode enabled
2017-03-13T02:06:48 11394 Config rules count: 2
2017-03-13T02:06:48 11394 Relative paths allowed. Current dir: /home/user/path/to/findNclean
2017-03-13T02:06:48 11394 Effective config content:
<Global>
DEBUG = true
REL_PATH_ALLOW = true
APPROVED_SUFFIX = .approved
</Global>
<Rule1 find/move old unused *.tmp files in /tmp, /var and current user home dir>
result_list = ./ancient-tmp.find-example.csv
search_path = /var/
search_path = /tmp/
search_path = ~/
type = f
name = .*.tmp
accessed < 2017
move_path = ./
move_log = ./ancient-tmp.move-example.csv
move_log_err = ./ancient-tmp.move-example.err.csv
</Rule1>
<Rule2 The same as above but for small non-zero logs>
result_list = ./ancient-logs.find-example.csv
search_path = ~/
search_path = /var/
search_path = /tmp/
type = f
name = .*.log(.gz)?
size > 0
size <= 2M
accessed < 2017
move_path = ./
move_log = ./ancient-logs.move-example.csv
move_log_err = ./ancient-logs.move-example.err.csv
</Rule2>
2017-03-13T02:06:48 11394 All non-zero size existing move_log and move_log_err files will be preliminary backuped:
2017-03-13T02:06:48 11394 Start processing the following move lists in background:
0 ./ancient-tmp.find-example.csv.approved
2 ./ancient-logs.find-example.csv.approved
2 total
2017-03-13T02:06:48 11394 Wait for all background processes completion...
2017-03-13T02:06:48 11394 Completed in 0 sec
2017-03-13T02:06:48 11394 Moved items count (successful canceled failed move_log [move_log_err]):
0 0 0 ./ancient-tmp.move-example.csv ./ancient-tmp.move-example.err.csv
2 0 0 ./ancient-logs.move-example.csv ./ancient-logs.move-example.err.csv
2 0 0 total
2017-03-13T02:06:48 11394 Finished action: mv
removed '/dev/shm/findNclean.exch24681.tmp'
removed '/dev/shm/findNclean.exch11002.tmp'
Можно запускать одновременно несколько экземпляров findNclean с разными конфигами.
Если вдруг что-то собирается пойти не так можно установить переменную NOTIFICATION_SCRIPT
с путём ко скрипту, тогда ему будут передаваться параметры с которыми он может что-нибудь делать, например отправлять оповещения.
В именах файлов поддерживаются все символы за исключением переноса строки, поэтому файлы с переносами по-умолчанию игнорируются, хотя, если нужно только составить список файлов (без дальнейшего перемещения), то можно и их включить опцией IGNORE_FILES_WITH_NEWLINES = false
.
В коде вся документация запихана в переменные (которые затем выводятся на экран при необходимости):
read -r -d '' VARIABLE <<'EOF'
bla-bla line
EOF
— это получше, чем периодически встречающаяся конструкция:
VARIABLE=$(cat <<'EOF'
The bla-bla line
EOF
)
ибо используется встроенная функция без создания подоболочки и вызова внешней программы.
Создание процесса достаточно медленная операция по сравнению с запуском встроенной команды, поэтому, если без особой нужды не дёргать системные бинарники, скорость исполнения slowpoke-bash-скрипта может увеличиться на порядок.
Когда переменные с документацией уже точно не понадобятся, они сбрасываются, пустячёк, а приятно:
unset DESCRIPTION USAGE CONFIG_USAGE TRICKS # No need help variables yet
Ближе к шапке проверяются версии используемых программ, чтобы проверить в каком же окружении утилита сейчас находится. Для версии bash
это выглядит как-то так:
printf -v BV '%d%03d%03d' "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}" "${BASH_VERSINFO[2]}" # Bash version in numbers like 4003046, where 4 is major version, 003 is minor, 046 is subminor.
((BV < 3002025)) && echo "WARNING: bash version ${BASH_VERSION} is too old (below 3.2.25)! This app was not tested with too ancient versions!" >&2
printf
в этом случае лучше, чем BV=$(bla-bla)
, ибо опять же встроенная утилита, и будет выполнена без запуска подоболочки. К тому же позволяет отформатировать исходные данные.
Возможно есть более элегантный способ проверки версии, чем запихивание её в int
с дальнейшим сравнением как с десятичным числом, если в у тебя в закромах такой завалялся — выкладывай!
Во второй строчке примера ((BV < 3002025))
тоже лучше, чем [[ "${BV}" -lt 3002025 ]]
, т.к. быстрее, короче и человекочитаемей.
Также можно заметить чрезмерное использование
printf %q
и соответственно eval
, но это своеобразная цена за поддержку почти всех символов, хотя не во всех местах эти конструкции так уж необходимы, часть из них были использованы по инерции.Для булевых переменных тоже удобно использовать двойные круглые скобки:
RULES_JOIN_ALLOW=1 # Allow optimize performance by join Rules with identical search_paths collection
if ((RULES_JOIN_ALLOW)); then
echo You'll see this msg
fi
Многие знают про нотации типа $'r'$'n'
, их лучше использовать вместе со встроенной командой echo
вместо часто рекомендуемых escape-последовательностей для внешней утилиты /bin/echo
, вызываемой с помощью echo -e
(встроенная их не поддерживает). Но в закавыченных выражениях это несколько неудобно, придётся часто закрывать и открывать кавычки. Благо тот самый перенос строки можно назначить переменной и тогда никаких открываний-закрываний:
readonly EOL=$'n' # Newline
...
approve|confirm)
quit 255 "This action is not implemented yet.${EOL}You have to manually overview and approve list of files by appending${EOL}corresponding APPROVED_SUFFIX to the result_list value.${EOL}By default APPROVED_SUFFIX is none, so all result_list will be treated as approved."
Этот же пример апеллирует к извечному холивару «табы vs пробелы». Чтоб не портить форматирование при использовании табов можно было реализовать его через here-doc с отступами:
read -r -d '' tmp <<-'EOF'
This action is not implemented yet.
You have to manually overview and approve list of files by appending
corresponding APPROVED_SUFFIX to the result_list value.
By default APPROVED_SUFFIX is none, so all result_list will be treated as approved.
EOF
quit 255 "${tmp}"
unset tmp
При использовании пробелов тоже можно выкрутиться:
tmp='This action is not implemented yet.'$'n'
tmp+='You have to manually overview and approve list of files by appending'$'n'
tmp+='corresponding APPROVED_SUFFIX to the result_list value.'$'n'
tmp+='By default APPROVED_SUFFIX is none, so all result_list will be treated as approved.'
quit 255 "${tmp}"
unset tmp
Но моё решение мне нравится больше, и не важно табы тут или пробелы, хоть и строка при этом становится неприлично длинной.
Кстати, одно время табы для меня были предпочтительнее именно из-за возможности использовать here-doc с истреблением отступов при парсинге. Но код с табами весьма проблематично вставлять в интерактивный терминал для тестов — всякие автодополнения всё портят. Поэтому сейчас даже не знаю как жить в этом неидеальном мире. Гугл Инк на стороне пробелов и я постепенно склоняюсь туда же.
Вставка метки времени испокон веков делалась с помощью date
, хотя в новых версиях bash
для этого можно использовать printf %(datefmt)T
, правда без поддержки долей секунды:
readonly TIMESTAMP_FORMAT='%Y-%m-%dT%H:%M:%S'
...
if ((BV > 4002000)); then # Modern bash versions
log() {
## Fast (builtin) but sec is min sample for most implementations
printf "%(${TIMESTAMP_FORMAT})T %5d %sn" '-1' $$ "$*" # %b convert escapes, %s print as is
}
else # Legacy bash versions
log() {
## Slow (subshell, date) but support nanoseconds
echo "$(exec -c date +"${TIMESTAMP_FORMAT}") $$ $*"
}
fi
Здесь же можно видеть использование $(exec -c some command)
для запуска внешней утилиты в подоболочке. Это позволяет избавиться от дополнительного процесса-прослойки между текущим шеллом и утилитой, ну и просто быстрее (на 0-2% в зависимости от ситуации, но с миру по нитке на шапочку наберётся). Так будет выглядеть вырезка дерева процессов у команды $(some command)
:
├── /bin/bash # основной процесс
└── /bin/bash # лишняя прослойка
└── some command
Вместе с exec
лишней прослойки не будет, а с опцией -c
команда будет запущена с пустым окружением, что тоже слегка ускорит работу.
Для расчёта времени исполнения части кода (с точностью до секунды) используется следующая конструкция:
if ((BV > 4002000)); then # Modern bash versions
## Set global variable with the name $1 and time format $2
set_timestamp() {
printf -v "$1" "%($2)T" '-1'
}
else # Legacy bash versions
set_timestamp() {
printf -v "$1" '%s' "$(exec -c date +"$2")"
}
fi
...
set_timestamp ts '%s'
# код время исполнения которого измеряется
set_timestamp TS '%s'
log "Completed in $((TS-ts)) sec"
Как альтернативу можно было использовать специальную переменную SECONDS
. Она равна 0 при старте оболочки и увеличивается на единицу каждую секунду. Если её обнулить, а потом обраться к ней, то получится время исполнения участка кода между этими событиями в секундах. Чтоб не терять текущее значение времени исполнения скрипта с самого начала можно также использовать промежуточные переменные:
ts=${SECONDS}
# код время исполнения которого измеряется
TS=${SECONDS}
log "Completed in $((TS-ts)) sec"
Это, думаю, даже более предпочтительный вариант, чем использование функции set_timestamp
.
Правильно читать файл построчно тоже не совсем тривиально:
while IFS= read -r line || [[ -n ${line} ]]; do
...
done </path/to/some/text/file
Зачистка IFS
для read
нужна, чтобы не убирались начальные и конечные табы или пробелы;
-r
— чтобы строка читалась как есть, без интерпретации escape-последовательностей типа tr
;
|| [[ -n ${line} ]]
— чтобы код внутри цикла исполнялся даже если последняя строка файла не содержит переноса строки, без неё read
вернёт не нулевой код и цикл тут же завершится.
Интересные вещи творятся со временем создания файла в Linux (см. опцию born
): даже если файловая система (например ext4) поддерживает его и прилежно записывает при каждом создании нового файла, stat
и, соответственно, find
не могут его прочитать:
user@host:~$stat /
Файл: '/'
Размер: 4096 Блоков: 8 Блок В/В: 4096 каталог
Устройство: fc00h/64512d Inode: 2 Ссылки: 25
Доступ: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
Доступ: 2017-03-16 15:28:06.121675004 +0700
Модифицирован: 2017-02-27 17:09:09.937545795 +0700
Изменён: 2017-02-27 17:09:09.937545795 +0700
Создан: -
но оно есть!
user@host:~$sudo debugfs -R "stat <$(stat -c %i /)>" /dev/ROOTFSDRIVE
Inode: 2 Type: directory Mode: 0755 Flags: 0x80000
Generation: 0 Version: 0x00000000:00000086
User: 0 Group: 0 Size: 4096
File ACL: 0 Directory ACL: 0
Links: 25 Blockcount: 8
Fragment: Address: 0 Number: 0 Size: 0
ctime: 0x58b3fac5:df87410c -- Mon Feb 27 17:09:09 2017
atime: 0x58ca4c96:1d0273f0 -- Thu Mar 16 15:28:06 2017
mtime: 0x58b3fac5:df87410c -- Mon Feb 27 17:09:09 2017
crtime: 0x5801149b:00000000 -- Sat Oct 15 00:23:39 2016
Size of extra inode fields: 32
EXTENTS:
(0):9249
Такая вот недоработка.
Для перемещения mv
используется немного непривычным (для интерактивного терминала) образом:
# Явное указание, что dest_name - файл
mv --backup=t -vT -- "${name}" "${dest_name}"
# Явное указание, что dest - директория, а всё, что после '--', - список источников к перемещению
mv --backup=t -vt "${dest}" -- "${name}"
# Когда ничего сохранять точно не надо,
# и даже заикаться об этом не стоит, смело перезаписывать в случае чего!
mv --backup=off -vft "${dest}" -- "${name}"
--backup[=CONTROL]
— полезная штука, в случае какого-то сбоя или непредвиденной ситуации вероятность вертать всё взад сильно возрастает.
--
тоже полезно использовать в скриптах (это касается не только mv
, но и других), т.к. в какой-то степени отвязывает от использования ограниченного набора символов в значениях переменных (например если значение начинается с дефиса). В данном случае это не так уж необходимо, ибо у всех переменных для mv
вначале будет либо /
, либо ./
— во избежание, но лучше всё равно использовать такую возможность.
Для проверки существования в системе какого-нибудь бинарника вместо часто используемого which
, лучше использовать встроенный hash
:
for util in ${DEPENDENCIES}; do
hash "${util}" &>/dev/null || quit 1 "ERROR: '${util}' not found on this system"
done; unset util
Для парсинга опций коммандной строки (кто ещё не знает), бывает удобно пользоваться встроенной коммандой getopts
:
OPTIND=2 # Ignore first argument ACTION even if it has leading hyphen
while getopts ':dvc:' OPT; do
[[ "${OPTARG:0:1}" = '-' ]] && quit 1 "ERROR: Option argument cannot start with hyphen, got: ${OPTARG}"
case "${OPT}" in
d|v) DEBUG=1; v=v; ;;
c) CONFIG=${OPTARG} ;;
:) quit 1 "ERROR: Option -${OPTARG} requires an argument"; ;;
*) quit 1 "ERROR: Unrecognized option: -${OPTARG}"
esac
done
Правда сфера его применение несколько ограничена тем, что он не поддерживает длинные опции (начинающиеся с --
).
Для преобразования значения переменных (не самих, а только вывода) или массивов есть целый набор функций следующих за именем переменной в фигурных скобках:
${var#word} # Убрать самый короткий префикс
${var##word} # Убрать самый длинный префикс
${var%word} # Убрать самый короткий суффикс
${var%%word} # Убрать самый длинный суффикс
${var/pattern/string} # Поиск и замена
${var^pattern} # К верхнему регистру первый символ совпадения
${var^^pattern} # К верхнему регистру все символы совпадения
${var,pattern} # К нижнему регистру первый символ совпадения
${var,,pattern} # К нижнему регистру все символы совпадения
Есть даже недокументированная возможность:
${var~} # Инвертировать регистр для первого символа
${var~~} # Инвертировать регистр для всех символов
В коде их много где можно встретить. Вот одна из самых занятных для массива команд для параллельного исполнения:
parallel_run "${Commands[@]/#/exec -c }" # Prepending of 'exec -c ' need to avoid additional subshell creation
Она вставит в начало каждой ячейки массива 'exec -c
'.Таким же способом можно суммировать все значения внутри массива (только целые конечно же): $((${MOVE_OK_COUNT[@]/#/+}))
В м[ио]ре bash
ещё полно подводных камней и нырять за ними можно пока не утонешь даже если ты осилил man bash
и засим случайные прохожие регулярно снимают перед тобой шляпу.
Закончу цитатой из вышеупомянутого эпоса:
BUGS
It's too big and too slow.
Поэтому, если есть возможность, выбирай правильный шебанг!#!/bin/bash
Автор: vmspike