使用 Bash 实现自动化
鉴于最近有大量文章介绍了 Bash 的基本方面(在本文末尾列出),您的新同事中必然会有人将其抛入云端。随着这些事情的发展,接下来的逻辑步骤是:
- 验证某些绝对关键的事情是否依赖于“云”脚本的正常运行。
- 验证脚本的原作者,已经完全忘记它实际上是如何工作的。
- 确认最新的管理员被赋予了在没有任何验证的情况下从根本上改变它的任务。
在本文中,我将帮助所有管理人员避免上述所有错误。反过来,这将使管理层更加快乐,并希望我们能继续工作。
如何拼写“Bash 脚本”
为了获得启发(也为了您对 $DEITY 的热爱),请将您的代码签入源代码管理 (SCM) 工具。即使在学习期间,也可以使用本地存储库作为操场。这种做法不仅可以让您记录一段时间内的努力,还可以让您轻松撤消错误。我强烈推荐许多关于入门的精彩文章。git
关于使用和学习 Bash 的简要说明,因为这种脚本语言带来了一套独特的主义和作者的风格偏好:一旦您发现某些对您来说很新的东西(语法、风格或语言结构),请立即查找。花时间从手册页(首选)或高级 Bash 脚本指南(两者都可以离线访问)中了解新项目。这样做一开始会减慢您的速度,但随着时间的推移,这种做法将帮助您积累有关在哪里找到答案的知识。
编写可重用的 Bash 块作为库
自动化脚本最好遵循 Unix 哲学来编写:许多小工具只做一件事。这意味着,编写小型、专门的脚本和库比使用一个巨大的“厨房水槽”要好得多。尽管,不可否认,我编写并维护了一些庞然大物(有时它们确实有用)。
自动化脚本通常必须能够被多位作者理解和维护。随着许多小脚本四处流传(并在版本控制中进行跟踪),您很快就会发现自己需要共享名称、版本、路径或 URL 的值的引用。将这些通用元素写入库中还为维护人员提供了额外的心理空间来欣赏内联文档。此外,这样做使得利用单元测试变得几乎微不足道(我们将在最后讨论这个主题)。
让我们从一开始就通过创建本地“播放”存储库来实践良好的代码卫生。在新的存储库中,创建一个包含我们的脚本和库文件的地方。为了简单起见,我喜欢坚持使用众所周知的FHS 标准。在存储库的根目录中创建目录./bin/
和./lib/
。在较大的自动化项目中,我仍然会使用这些名称,但它们可能被深深地埋藏起来(例如,在scripts
或tools
子目录下)。
谈论路径让我想到了第一个库的一个很棒的主题。我们需要一种方法来让未来的组件引用结构元素和高级值。使用您最喜欢的编辑器,创建文件./lib/anchors.sh
并添加以下内容:
# A Library of fundamental values
# Intended for use by other scripts, not to be executed directly.
# Set non-'false' by nearly every CI system in existence.
CI="${CI:-false}" # true: _unlikely_ human-presence at the controls.
[[ $CI == "false" ]] || CI='true' # Err on the side of automation
# Absolute realpath anchors for important directory tree roots.
LIB_PATH=$(realpath $(dirname "${BASH_SOURCE[0]}"))
REPO_PATH=$(realpath "$LIB_PATH/../") # Specific to THIS repository
SCRIPT_PATH=$(realpath "$(dirname $0)")
该文件以两个空行开头,第一个注释解释了原因。不应将库设置为可执行文件(尽管名称以 .sh 结尾,表明其类型)。如果直接执行该库,可能会导致用户的 shell 消失(或更糟)。禁用直接执行(chmod -x ./lib/anchors.sh
)是初级管理员保护的第一级。文件开头的注释是第二级。
按照惯例,注释应该始终描述后面语句的“为什么”(而不是“是什么”)。读者只需阅读语句即可了解其作用,但无法可靠地凭直觉了解作者当时的想法。然而,在我深入探讨之前,我需要详细说明一个经常让 Bash 措手不及的问题。
Bash 默认在引用未定义的变量时提供空字符串。该变量(或自动化中的类似变量)旨在指示可能没有人。不幸的是,对于机器人统治者来说,人类可能需要至少手动执行一次脚本。此时,他们很可能会忘记在按 Enter 之前CI
设置值。CI
因此,我们需要为该值设置一个默认值,并确保它始终为 true 或 false。上面的示例库代码演示了如何测试字符串是否为空,以及如何强制字符串包含一对值中的一个。我读入第一组语句的方式anchors.sh
是:
将' CI
' 定义为以下结果:
- 检查先前的值
CI
(它可能未定义,因此为空字符串)。 - '
:-
' 部分的意思是:- 如果值为空字符串,则用字符串“
false
”代替。 - 如果值不是空字符串,则使用它原来的值(包括“
DaRtH
VaDeR
”)。
- 如果值为空字符串,则用字符串“
[[
测试‘ ’和‘ ’里面的东西]]
:
- 如果 ' ' 的新值
CI
等于文字字符串“false
”,则抛出退出代码0
(表示成功或真实)。 - 否则,抛出退出代码
1
(表示失败或不真实)
如果测试以 退出0
,则继续下一行,或者(' ||
' 部分),假设新手管理员设置CI=YES!PLEASE
或完美机器设置CI=true
。立即将 ' ' 的值设置CI
为文字字符串 ' true
';因为完美机器更好,不要犯错误。
对于此库的其余部分,锚路径值在从存储库自动运行的脚本中几乎总是有用的。在较大的项目中使用时,您需要调整库目录相对于存储库根目录的相对位置。否则,我将把理解这些语句留给读者作为研究练习(现在就做,以后会对您有帮助)。
在 Bash 脚本中使用 Bash 库
要加载库,请使用source
内置命令。此命令并不复杂。给它一个文件的位置,然后它就会立即读取并执行内容,这意味着库代码的运行时上下文实际上是执行source
它的脚本。
为了防止太多的脑浆从耳朵里流出,这里有一个简单的./bin/example.sh
脚本来说明:
#!/bin/bash
LIB_PATH="$PWD/$(dirname $0)/../lib/anchors.sh"
echo "Before loading: $LIB_PATH"
set -ax
cd /var/tmp
source $LIB_PATH
echo "After loading: $(export -p | grep ' LIB_PATH=')"
您可能立即注意到,脚本在加载库之前改变了运行时上下文。它还进行了LIB_PATH
本地定义,并将其指向一个具有相对路径的文件(令人困惑的是,不是目录)(仅用于说明目的)。
继续执行此脚本并检查输出。请注意,库中的所有操作都在目录anchors.sh
中运行并自动导出其定义。中的/var/tmp/
的旧定义LIB_PATH
已被破坏并导出。此事实在命令的输出中可见。希望调试输出(中的)是可以理解的。a
set -ax
declare -x
export
x
set -ax
像这样调试时,Bash 会在解析每一行时打印所有中间值。我在此处包含此脚本是为了说明为什么您永远不想set -ax
使用库顶层的命令来更改目录。请记住,库指令是在脚本的加载时评估的。因此,更改库中的环境会导致在用于source
加载它的任何脚本中产生运行时副作用。这样的副作用肯定会让至少一个系统管理员完全发疯。你永远不知道,那个管理员可能是我,所以不要这样做。
举一个实际的例子,假设有一个假想库,它使用用户名/密码环境变量来定义一个函数,以访问远程服务。如果该库在此函数之前执行了顶层set -ax
,那么每次加载时,调试输出都会显示这些变量,将您的秘密散布到各处供所有人查看。更糟糕的是,对于初学者同事来说,如果不对着键盘大喊大叫,就很难(从调用脚本的角度来看)禁用输出。
总之,这里的关键是要始终意识到库“发生”在调用者的上下文中。这个因素也是为什么示例anchors.sh
可以使用(可执行脚本路径和文件名),但库本身的路径只能通过“魔法” (数组元素)获得。这个因素一开始可能会令人困惑,但你应该尽量保持纪律。避免在库中使用广泛、影响深远的命令。当你这样做时,所有新聘用的管理员都会坚持为你买单。 $0
'${BASH_SOURCE[0]}'
为库编写单元测试
编写单元测试可能感觉像是一项艰巨的任务,直到你意识到完美的覆盖通常是在浪费你的时间。然而,在接触库代码时始终使用和更新测试代码是一个好习惯。测试编写的目标是处理最常见和最明显的用例,然后继续。不要过多关注极端情况或不太常用的用途。我还建议最初将库测试工作重点放在单元测试级别,而不是集成测试。
让我们看另一个例子:可执行脚本./lib/test-anchors.sh
:
#!/bin/bash
# Unit-tests for library script in the current directory
# Also verifies test script is derived from library filename
TEST_FILENAME=$(basename $0) # prefix-replace needs this in a variable
SUBJ_FILENAME="${TEST_FILENAME#test-}"; unset TEST_FILENAME
TEST_DIR=$(dirname $0)/
ANY_FAILED=0
# Print text after executing command, set ANY_FAILED non-zero on failure
# usage: test_cmd "description" <command> [arg...]
test_cmd() {
local text="${1:-no test text given}"
shift
if ! "$@"; then
echo "fail - $text"; ANY_FAILED=1;
else
echo "pass - $text"
fi
}
test_paths() {
source $TEST_DIR/$SUBJ_FILENAME
test_cmd "Library $SUBJ_FILENAME is not executable" \
test ! -x "$SCRIPT_PATH/$SUBJ_FILENAME"
test_cmd "Unit-test and library in same directory" \
test "$LIB_PATH" == "$SCRIPT_PATH"
for path_var in LIB_PATH REPO_PATH SCRIPT_PATH; do
test_cmd "\$$path_var is defined and non-empty: ${!path_var}" \
test -n "${!path_var}"
test_cmd "\$$path_var referrs to existing directory" \
test -d "${!path_var}"
done
}
# CI must only/always be either 'true' or 'false'.
# Usage: test_ci <initial value> <expected value>
test_ci() {
local prev_CI="$CI" # original value restored at the end
CI="$1"
source $TEST_DIR/$SUBJ_FILENAME
test_cmd "Library $SUBJ_FILENAME loaded from $TEST_DIR" \
test "$?" -eq 0
test_cmd "\$CI='$1' becomes 'true' or 'false'" \
test "$CI" = "true" -o "$CI" = "false"
test_cmd "\$CI value '$2' was expected" \
test "$CI" = "$2"
CI="$prev_CI"
}
test_paths
test_ci "" "false"
test_ci "$RANDOM" "true"
test_ci "FoObAr" "true"
test_ci "false" "false"
test_ci "true" "true"
# Always run all tests and report, exit non-zero if any failed
test_cmd "All tests passed" \
test "$ANY_FAILED" -eq 0
[[ "$CI" == "false" ]] || exit $ANY_FAILED # useful to automation
exit(0)
./lib
我把这个脚本放在 中(而不是)的原因./bin
既是为了方便,也因为测试绝不能依赖于使用它们正在检查的代码。因为这个测试需要检查路径,所以把它放在与库相同的路径中更容易。否则,这种方法就是个人喜好的问题了。现在请随意执行测试,因为它可能有助于您理解代码。
总结
本文绝不代表在自动化中使用 Bash 的全部内容。但是,我试图灌输基本知识和建议(如果遵循)无疑会让您的生活更轻松。然后,即使事情变得困难,对运行时上下文重要性的牢固理解也会很有用。
最后,编写自动化脚本是无法容忍错误的。即使对您的库进行基本的单元测试,也可以建立信心并帮助下一个人(可能是五年后忘记的您)。您可以在此处在线找到本文中使用的所有示例代码。
有兴趣复习一下 Bash 基础知识吗?请查看:
[ 想要试用 Red Hat Enterprise Linux?立即免费下载。 ]