原文:
annas-archive.org/md5/74e7dee08e6c205ecc2a82f2d11edba8译者:飞龙
第五章:Facts 和 Functions(函数)
本章将讲解 facts(事实)。我们将展示 Facter 工具如何收集它们以显示系统配置文件,如何与 Facter 交互,以及如何在 Puppet 代码中使用它们。我们还将介绍如何将自定义和外部的 facts 添加到提供的核心 facts 中,以便收集更多特定于用户的 facts。
接下来,我们将介绍函数。我们将解释函数的作用以及三种类型的函数——语句函数、前缀函数和链式函数。我们将考察一些核心函数,展示它们的功能。同时,也会展示来自 stdlib 模块的一些函数,并解释该模块的使用方法和方式。
延迟函数(Deferred functions)是在 Puppet 6 中引入的,本节将对此进行讲解。在这里,我们将向您展示延迟函数与普通函数的区别,如何使一个函数成为延迟函数,以及在使用延迟函数时应避免的陷阱。
简而言之,本章将涵盖以下主题:
-
Facts 和 Facter
-
自定义 facts 和外部 facts
-
函数
-
stdlib 模块函数
-
延迟函数
技术要求
本章中,您需要通过下载 github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch05/params.json 文件,并使用以下命令从 pecdm 目录中配置一个标准的 Puppet 服务器架构,其中包含一个 Windows 客户端和一个 Linux 客户端:
bolt --verbose plan run pecdm::provision –params @params.json
Facts 和 Facter
Facter 是 Puppet 的系统分析工具,是一组跨平台的 Ruby 库,用于收集关于客户端的信息(称为 facts)。这些工具提供了评估客户端配置文件所需的信息,并允许根据主机在 Puppet 代码中的先前状态做出配置决策。
Puppet 5 和 6 使用 Facter 3,而 Puppet 7 使用 Facter 4。Facter 4 中仅提供了一小部分功能,本文将重点介绍这些功能,并且少量事实(facts)有所变化,但大多数用户将不会发现差异。您可以通过运行 puppet facts diff 命令查看这些差异。在第八章中,我们将重点介绍如何通过模块测试确保代码在不同版本间的兼容性。
可以通过在命令行或 VSCode 终端中运行 facter -p 或 puppet facts 命令查看 Facter 的输出。运行这些命令而不添加任何额外选项时,将返回所有核心 facts。-p 标志确保收集 Puppet 特定的 facts。由于 Facter 和 Puppet 之间创建了循环依赖,之前计划废弃 -p 标志并用 puppet facts 命令代替,但随着 Facter 4 的发布,这一做法被放弃了。本书的示例将使用 facter 命令,这与文档和社区的实践一致。
注意
默认情况下,facter 命令以 Puppet 哈希格式输出,而 puppet facts 以 JSON 格式输出。这两个命令都接受选择适当格式的选项。
现在我们来看一些 Facter 输出的示例。最简单的事实类型是简单的键值对,例如 Kernel 事实,在本例中,它告诉我们内核是基于 Windows 的:
"Kernel": "windows"
还有一些称为结构化事实的哈希值,它们可以分解成嵌套级别。os 事实是常用的。以下是 Windows 10 笔记本电脑的示例,展示了可用的各种级别:
os => {
architecture => "x64",
family => "windows",
hardware => "x86_64",
name => "windows",
release => {
full => "10",
major => "10"
},
windows => {
display_version => "21H2",
edition_id => "Core",
installation_type => "Client",
product_name => "Windows 10 Home",
release_id => "21H2",
system32 => "C:\WINDOWS\system32"
}
}
核心事实的完整列表可以在 puppet.com/docs/puppet/latest/core_facts.html 中找到;建议在客户端系统上运行 facter -p 并查看输出。可以通过运行 facter 命令并指定事实名称来访问单个事实,例如运行 facter -p kernel 来返回 kernel 事实。要访问结构化事实中的特定嵌套级别值,使用点符号表示法,点号(.)分隔每个键级名称。因此,要访问 os 结构化事实中的 family 事实,可以运行 facter -p os.family 命令。
由于 Facter 经历了多个版本迭代,并且早期版本没有结构化事实,Facter 3 隐藏了多个遗留事实,如架构,它被放入 os 结构化事实中作为 os.structured。--show-legacy 标志可以使这些事实在 Facter 输出中可见;它们在核心事实文档中有记录。
当 Puppet 运行时,无论是通过代理还是在命令行上运行 puppet apply,Facter 都会运行,使用遗留事实,并且输出将分配给全局变量。
然后,这些变量可以通过两种方式在 Puppet 清单中访问——要么直接通过事实的名称作为全局变量,要么通过 facts 数组。强烈建议仅通过 facts 数组访问事实,因为这样可以明确表示正在访问事实,而不是其他潜在的全局变量。
例如,在以下代码中,notify 资源将访问 kernel 和 os family 变量,并打印包含主机 kernel 和 os 系列的日志信息:
notify { "This clients kernel is ${facts[kernel]}": }
notify { "This client is a member of the os family ${facts[os][family]": }
请注意,并非所有事实都会出现在所有客户端上。事实通常会根据某些上下文进行过滤,例如正在运行的操作系统,或者是否使用了特定的底层硬件。
注意
正如你将在下一节中看到的那样,函数使用点号来表示链式函数,因此 facter 命令的点分隔访问语法不能用于直接调用 facts 变量。然而,可以使用 getvar 函数。
Facter 可以通过配置 facter.conf 文件,在每个主机上进行自定义和调整。默认情况下,此文件不会创建,应在 Nix 系统上的 /etc/puppetlabs/facter/facter.conf 和 Windows 上的 C:\ProgramData\PuppetLabs\facter\etc\facter.conf 创建。为了测试,可以使用 -c 标志运行 facter 命令,选择要运行的配置文件。
一个示例 facter.conf 组如下所示:
facts : {
blocklist : [ "disks", "dmi.product.serial_number", "file system" ],
ttls : [
{ "processor" : 30 days },
]
}
global : {
external-dir : [ "/home/david/external1", "/home/david/external2" ],
custom-dir : [ "/home/david/customtest" ],
no-exernal-facts : false,
no-custom-facts : false,
no-ruby : false
}
cli : {
debug : false,
trace : true,
verbose : false,
log-level : "warn"
}
fact-groups : {
custom-exampleapp : ["exampleapp1", "exampleapp2"],
}
第一部分 facts 包括一个阻止列表,它允许我们列出将不会运行的事实和事实组。这在计算事实可能会非常耗费资源的情况下很有用。例如,在前面的示例中,我们阻止了 disks 和 file system 组,因为在一些传统的 UNIX 系统中,SAN 存储可能会配置有成千上万条路径。它还禁用了 dmi.product.serial_number,这可能被认为是某些安全信息,不应在 Puppet 中显示。要查看所有可阻止组的完整列表,可以运行 facter --list-block-groups 命令,它将列出组名以及其中包含的事实列表。例如,disks 组如下所示:
disks
- blockdevices
- disks
事实部分的另一部分是 ttls,它允许配置缓存。缓存的事实以 JSON 格式存储在 UNIX 系统上的 /opt/puppetlabs/facter/cache/cached_facts 和 Windows 上的 C:\ProgramData\PuppetLabs\facter\cache\cached_facts 中。在前面的示例中,processor 组将每 30 天刷新一次。要查看所有可缓存组的完整列表,可以运行 facter --list-cache-groups 命令,这将显示类似于块组的格式。
global 部分允许传递一个目录数组到 external-dir,以便定义 facter 应该在何处查找外部事实。类似地,可以传递一个目录数组到 custom-dir,定义 facter 应该在何处查找自定义事实。自定义和外部事实将在下一部分中讨论。
global 部分有三个布尔值:
-
no-external-facts:如果设置为true,则禁用外部事实。 -
no-custom-facts:如果设置为true,则禁用自定义事实。 -
no-ruby:防止通过 Ruby 加载 Facter。任何使用 Ruby 和自定义事实的事实如果设置为true,则会被禁用。
所有这些设置更可能用于调试和开发目的。
cli 部分设置日志级别,值为(none、trace、debug、info、warn、error、fatal)的字符串,并有三个选项:verbose、trace 和 debug。这三个选项的启用或禁用通过布尔值 true 或 false 来设置。trace 选项将在自定义事实发生异常时显示回溯。这个选项不应与追踪日志级别混淆;这个选项的更合适名称可能是 stacktrace。verbose 选项启用 Facter 的详细信息输出,而 debug 选项启用 Facter 的调试级别输出。
fact-group 部分是 Facter 4 中 Puppet 新增的功能,允许你为缓存和阻止定义自定义组。可以指定核心事实和自定义事实,但不能指定外部事实。
注意
由于 facter.conf 文件使用 HOCON 格式,因此可以通过 Puppet Forge 的 HOCON 模块(forge.puppet.com/modules/puppetlabs/hocon)更轻松地管理它,在此过程中可以根据需要按单个节点或节点组进行分类。
Puppet 7 中的 Facter 4 重新引入了事实基准测试功能,这一功能之前在 Facter 2 中就已存在。要对某个特定的事实进行基准测试,可以运行 facter -t <fact name> 命令。例如,运行 facter -t os 将生成类似于以下的输出:
fact 'os.name', took: (0.000007) seconds
fact 'os.family', took: (0.000006) seconds
fact 'os.hardware', took: (0.000007) seconds
如果选择了结构化事实,它将对事实的每个部分进行计时,并在执行完后将其返回到 facter 调用的正常输出中。
在了解了核心事实是什么以及如何运行和配置 Facter 来测试和管理它们之后,下一步是通过自定义和外部事实添加个性化配置。
自定义事实和外部事实
在本节中,你将学习如何通过自定义事实将其添加到核心事实提供的事实中。这些事实是用 Ruby 编写的,类似于核心事实或外部事实,它们可以是硬编码的值,也可以是客户端本地可执行的脚本。虽然收集所有数据可能很有诱惑力,但应考虑到外部事实对 Puppet 基础架构的额外负担,特别是在有大量代理的情况下,并需要平衡数据需求与系统性能。
外部事实
外部事实是可执行文件,它们可以根据脚本中的逻辑设置事实,或者根据文件的结构化数据静态设置事实。
外部事实可以存储在以下目录中,适用于基于 Unix 的操作系统:
-
/``opt/puppetlabs/facter/facts.d/ -
/``etc/puppetlabs/facter/facts.d/ -
/``etc/facter/facts.d/
对于 Windows 系统,外部事实可以存储在 C:\ProgramData\PuppetLabs\facter\facts.d\ 中。
在 第八章 中,你将学习如何通过插件同步过程将外部事实从模块分发到客户端,在此过程中,模块中 facts.d 文件夹内的事实会被添加进来。
注意
Puppet 可以在基于 UNIX 的系统上作为非 root 用户运行,而外部事实可以存储在 ~/.facter/facts.d/ 中。然而,本书不会涉及作为非 root 用户运行的相关内容。
静态外部事实
静态外部事实必须采用 JSON、YAML 或 TXT 格式。例如,我们可以将 Application 事实设置为 exampleapp,将 Use 事实设置为 production,将 Owner 事实设置为 exampleorg。在 YAML 文件中,可以像这样创建:
---
Application : exampleapp
Use : Production
Owner : exampleorg
在 JSON 文件中,可以像这样设置它们:
{ "Application": "exampleapp", "Use": "Production", "Owner": "exampleorg"}
在 TXT 文件中,同样的事实可以像这样设置:
Application=exampleapp
Use=Production
Owner=exampleorg
对于 Windows,这些文件中使用的行结束符必须是 LF(换行符,Unicode 字符 000A)或 CRLF(回车符和换行符,Unicode 字符 000D 和 000A),且文件的编码必须是 ANSI 或 UTF8 且不带 BOM。
到目前为止我们所看过的示例都被称为平面事实。然而,通过创建数组格式,可以返回结构化的事实。例如,在 YAML 中,我们可以通过添加数组和嵌套数组来允许两个所有者。在这个示例中,假设有多个应用程序,每个应用程序可以有联合所有权:
---
Application :
Exampleapp
Use : production
Owner
- Exampleorg
- anotherorg
Anotherapp
Use : Production
Owner : exampleorg
这将允许我们调用 facter application.exampleapp.owner 来检索所有者数组,或者调用 facter application.anotherapp 来接收使用者和所有者的键值对。
请注意,静态外部事实在输出中将始终返回字符串类型。
可执行外部事实
可执行外部事实在 Windows 和 UNIX 上有所不同,但它们都是可运行的脚本,输出键值对或数组以返回事实或结构化事实。
在 Windows 上,可以使用以下文件类型:
-
二进制可执行文件(
.com和.exe文件) -
批处理脚本(
.bat和.cmd文件) -
PowerShell 脚本(
.ps1文件)
在 UNIX 平台上,任何具有有效 shebang (#!) 声明的可执行文件都可以运行。如果缺少 shebang 声明,脚本执行将失败。
对于两个平台,这些脚本应该返回文本。文本将被读取为键值对,或作为 YAML 或 JSON,可以解析成结构化事实。
例如,一个返回 exampleapp 进程 PID 作为事实的 Unix bash 脚本,同时返回 exampleapp_cpu_use 和 example_memory_use 的事实,可能如下所示:
#!/bin/bash
echo "exampleapp_pid = ${pidof exampleapp}"
echo "exampleapp_cpu_use = ${ps -C exampleapp} %cpu"
echo "exampleapp_memory_use = ${ps -C exampleapp} %mem"
对于 Windows,一个 PowerShell 脚本返回相同的事实将如下所示:
Write-Output "exampleapp_pid=$((Get-Process explorer).id)"
Write-Output "exampleapp_cpu=$(Get-Process explorer).cpu)"
Write-Output "exampleapp_mem=$(Get-Process explorer).pm)"
注意
要查找外部事实的问题,可以运行 facter --debug。这将显示事实是否对 Facter 可见,以及是否有任何输出未被解析并被忽略。
自定义事实
自定义事实是可以用来设置事实并扩展核心 Facter 事实的 Ruby 代码段。使用自定义事实相较于外部事实的主要优势在于其内置的机制。在本节中,您将了解如何使用自定义事实访问其他事实的值,如何进行多个加权解析,以及如何使用 confine 确保只有特定的节点会尝试运行该事实。
使用自定义事实的主要缺点是它们需要用 Ruby 编写,而 Ruby 存在学习曲线。深入学习 Ruby 的细节超出了本书的范围,但本书将展示其基本结构以及一些在 Windows 和 UNIX 系统上运行良好的核心库,以便您能够为进一步研究打下基础。
与外部事实类似,自定义事实通常通过 Puppet 模块分发。然而,在进行本地测试时,有三种方法可以指示 Facter 查找我们存储本地事实的位置:
-
Ruby 库加载路径
-
在 Facter 命令中使用
--custom-dir选项(请注意,此选项可以多次标记) -
设置
FACTERLIB环境变量
Ruby 库加载路径可以通过运行 ruby -e 'puts $LOAD_PATH' 来检查。记得确保所使用的 Ruby 二进制文件是 Puppet 提供的版本,在 Windows 上是 C:\Program Files\Puppet Labs\Puppet\puppet\bin\ruby.exe,在 UNIX 系统上是 /opt/puppetlabs/puppet/bin。
自定义事实使用 Facter.add('<fact_name>') 声明自己,并使用 setcode 语句运行代码块来解析事实。这就是事实值确定的方式。作为一个简单的例子,可以通过将命令括起来使用反引号(`)直接运行 UNIX shell 或 Windows 终端命令:
Facter.add('exampleapp_version') do
setcode do
`exampleapp –version`
end
end
由于只有一个命令,这也可以用单个 setcode 行来编写:
Facter.add('exampleapp_version') do
setcode `exampleapp --version`
end
两者都会将 exampleapp_version 事实设置为 exampleapp --version 命令的输出结果。
如果你的事实更加复杂,需要运行多个命令或处理输出,可以通过 Ruby 类来运行命令。
在以下示例中,Facter::Core::Execution.execute Ruby 类将运行名为 exampleapp 的命令,并带有 version 标志,然后将命令的输出通过管道传递给 awk,以打印第二个返回值:
Facter::Core::Execution.execute('exampleapp –version' | awk '{print $2}' )
可以使用 powershell 命令执行 PowerShell 命令,示例如下:
Facter::Core::Execution.execute('powershell (Get-WindowsCapability -Online -Name "Microsoft.Windows.PowerShell.ISE~~~~0.0.1.0").state')
虽然出于熟悉感,可以将所有操作都当作终端命令来执行,但需要小心,并不是所有终端中可以使用的命令都能正常工作。例如,bash 风格的 if 语句无法使用,应当用 Ruby 代码来编写。
调用另一个事实的值到变量中可能会很有用。以下代码将 os arch 事实的值存入 arch 变量:
arch = Facter.value('os.arch')
限制自定义事实
自定义事实的主要优点之一是可以限制它们将运行的节点。可以通过 confine 语句实现,并选择事实和值来匹配运行的事实。confine 函数的语法如下所示:
confine <fact_name>: '<fact_value>'
在 confine 函数之后定义的事实,只有在条件满足时才会运行。例如,你可以将事实限制为仅在 Windows 内核节点上运行:
confine kernel: 'Windows'
也可以使用数组,匹配任何一个值都可以让事实运行。例如,我们可以检查内核是否来自 Linux 或 Solaris:
confine kernel: ['Linux', 'Solaris']
对于结构化事实,可以使用 Facter.value 方法来访问。例如,要测试 os.release.major 事实是否等于 10,可以使用以下代码,其中 => 被用来代替冒号(:)来匹配事实的值:
confine Facter.value(:os)['release']['major'] => '10'
除了事实之外,Ruby 命令和库命令也可以用来限制事实。例如,confine 可以与 Facter::Core::Execution.where 或 Facter::Core::Execution.which 一起使用,分别用于确认 Windows 或 Linux 的路径中是否存在某个命令。此外,Ruby 库如 File 也可以用来检查这一点。
例如,要限制一个事实,仅在 Windows 路径中找到 git 命令时运行,可以运行以下代码:
confine { Facter::Core::Execution.where('git') }
以下代码会限制事实仅在 /opt/app/exampleapp 存在作为文件或目录时运行:
confine { File.exist? '/opt/app/exampleapp' }
要编写一个可以涵盖多种实现并且具有粒度限制的单一事实,我们可以同时使用多个解析(Facter.add 语句)和多个限制块。以下示例展示了一个简单的示例,设置 whoami 的 Facter 值为 I am windows 10,如果内核事实是 Windows 并且 os.release.major 为 10,或者设置为 I am Sparc 字符串,如果内核是 sparc:
Facter.add('whoami') do
setcode do
confine kernel: 'Windows'
confine Facter.value(:os)['release']['major'] => '10'
'I am windows 10'
end
end
Facter.add('whoami') do
setcode do
confine kernel: 'Sparc'
'I am Sparc'
end
end
另一种限制事实的方法是使用特性。特性是 Ruby 代码的一部分,添加到模块的 lib/puppet/feature 目录下。例如,exampleapp 模块可以包含一个 exampleapp.rb 特性,用来检查 exampleapp 是否安装在 Windows 或 Linux 上:
require 'puppet/util/feature'
Puppet.features.add(:example_app)
do
windows= `powershell '(Get-Command exampleapp).source'`.strip
linux = `sh -c 'command -v exampleapp`.strip
windows.empty? && linux.empty? ? false : true end
然后,自定义事实可以使用 confine 语句,这样只有在 exampleapp 命令可用的节点上才会运行该事实:
Facter.add('exampleapp) do
setcode do
confine { Puppet.features.example_app? }
这消除了需要创建额外事实并收集和处理不必要的信息(除了评估限制之外)的需求。
注意
在 setcode 和 confine 块中执行所有逻辑代码非常重要;否则,在加载事实时,它会运行这些代码,而不是在查询事实进行解析时运行。这是因为事实加载的顺序是不可预测的,因此如果代码被事实要求但位于块外部,可能会导致顺序错误。
加权解析
写自定义事实的另一种方法是有多个解析,同时知道某些解析可能返回 null 值,但我们希望逐一尝试不同选项。审查解析时,Facter 会排除所有没有被限制的解析。然后,它会查看每个解析的权重。默认情况下,权重为 0,但可以通过 has_weight 函数进行设置。如果两个解析的权重相同,Facter 会使用代码中列出的第一个解析。
例如,要使用多个解析选项设置 exampleapp_version 事实,在第一个解析中,它将以 100 权重运行带有 version 标志的命令,然后尝试以 50 权重在配置文件中查找版本:
Facter.add('exampleapp_version') do
has_weight 100
setcode do
`exampleapp --version`
end
Facter.add('exampleapp_version') do
has_weight 50
setcode do
`grep version /etc/exampleapp/exampleapp.conf | awk '{print $2}'`
end
这允许命令失败,从而可以通过第二个来源进行备份。
注意
外部事实的权重为 1000。因此,为了防止外部事实覆盖自定义事实解析,可以将解析权重设置为高于 1000 的值。
异常处理块
默认情况下,如果任何解析失败并产生错误,Facter 将报告错误并无法返回任何值。使用rescue块可以在失败时返回默认值,并选择打印警告。这与加权解析配合使用,在加权解析中,通常会预期解析失败。
一个简单的rescue块,在运行exampleapp –version命令并记录失败后,返回nil,看起来像这样:
setcode do
`exampleapp --version`
rescue
nil
Facter.warn("exampleapp command failed")
end
使用Facter.warn可以确保当通过Facter命令使用时,这条消息会打印到 STDERR。当在 Puppet catalog 应用过程中使用时,它将确保该消息打印到 Puppet 的日志中。返回nil将确保其他解析可以在返回非 nil 值时被使用。
超时
作为 Puppet 7 中的 Facter 4 的一部分,现在可以为解析添加超时。可以通过在事实的名称后添加逗号,作为Facter.add解析语句的一部分,并使用{timeout: <秒数值>}语法来实现,其中秒数值可以是整数或浮动值。例如,要确保exampleapp_version事实的超时时间为 0.2 秒,代码可以像这样设置:
Facter.add('exampleapp_version', {timeout: 0.2}) do
尽管这是 Facter 4 和 Puppet 7 中的一个功能,但在 Facter 3 和 Puppet 5 及更高版本中,仍然可以通过直接在execute函数上设置options变量来对执行命令设置超时。例如,可以通过修改execute命令来将相同的 0.2 秒超时应用于exampleapp –version命令的执行,而不是整个解析:
Facter::Core::Execution.execute('exampleapp --version', options = {:timeout => 0.2})
聚合和结构化事实
聚合事实允许将事实的解析分成多个块。然后,这些块可以合并。合并数组或哈希会创建结构化的事实或执行其他功能,例如将事实的值相加。
聚合事实仍然有一个Facter.add声明,但在Facter.add内,它将类型变量设置为aggregate。然后,代替使用setcode部分,它使用chunk部分来进行解析。默认情况下,每个chunk都会被合并,除非声明了聚合块来执行其他功能。
例如,以下代码将创建一个名为exampleapp的结构化事实。它将包含exampleapp.version和exampleapp.fullpath,其中包含在块中运行的命令的输出:
Facter.add(:exampleapp, :type => :aggregate) do
Chunk(:version) do
`exampleapp –version`
end
Chunk(:fullpath) do
`which exampleapp`
end
end
要使用聚合块并将事实合并,可以使用以下代码,它创建了一个名为exampleapp_memory_usage的事实,该事实使用一个包含exampleapp使用的总内存的事实,并将其添加到exampleapp2使用的内存中,从而得出总内存使用情况:
Facter.add(: exampleapp_memory_usuage, :type => :aggregate) do
chunk(:exampleapp1_usage) do
Facter.value(:exampleapp1_usage)
end
chunk(:exampleapp2_usage) do
Facter.value(:exampleapp2_usage))
end
aggregate do |chunks|
total = 0
chunks.each_count do |value|
total += value
end
total
end
end
Puppet 7 与 Facter 4 中提供了一种新的返回结构化事实的方法。这采用了事实名称中的点表示法,允许定义将不同层次的结构化事实进行赋值。例如,要设置带有嵌套层次的exampleapp事实,包括exampleapp.version和exampleapp.pid,可以使用以下代码:
Facter.add('exampleapp.version') do
setcode do
`exampleapp --version`
end
Facter.add('exampleapp.pid') do
setcode do
`pidof exampleapp`
end
这相比使用聚合有一个核心优势。与聚合不同,声明中某一部分的失败只会影响该声明,其他部分将继续赋值。
注意
本节试图为你提供足够的信息,以便你开始使用自定义事实。在 Puppet 的自定义事实和模块代码文档中,你会发现许多我们讨论过的功能的替代语法。由于它是 Ruby 代码,声明的方式有更多变化。本书选择了它认为最好的风格和实践,以保持简洁并避免列出过多选项。
一些可以帮助你进一步跟随示例的模块可以在 GitHub 上找到:
github.com/puppetlabs/puppetlabs-pe_status_check/blob/main/lib/facter/
github.com/puppetlabs/puppetlabs-stdlib/tree/main/lib/facter
github.com/puppetlabs/puppetlabs-lvm/tree/master/lib/facter
github.com/puppetlabs/puppetlabs-java/tree/main/lib/facter
实验
对于这个实验,我们将创建一个静态外部事实和一个自定义事实,并使用bolt upload进行分发,然后运行这些事实并在控制台上查看它们是否已变得可见。
对于静态外部事实,创建一个结构,将packtlab.use设置为lab,并将packlab.student设置为你的名字。
对于自定义事实,将创建一个tmp_count事实,它将计算 Linux 中/tmp目录和 Windows 中C:\Users\admin\AppData\Local\Temp目录中的文件数量。对于 Linux,第一个权重较高的解析应为'find /tmp -type f | wc -l',而第二个权重较低的解析应为ls /tmp | wc -l。对于 Windows,第一个权重较高的解析应为(ls $env:Temp | Measure-Object -line).Lines PowerShell 命令,权重较低的解析应为(Get-ChildItem $env:Temp | Measure-Object).Count。
所有解析应在错误结果中返回undef,并且在 10 秒后超时。
请注意,在 Web 控制台查看客户端当前的事实可能很有用,这样你就能知道如何限制它们。
对于每个事实,使用以下bolt命令将其上传到正确的位置:
bolt file upload path_of_your_fact /path/to/destination --targets windows_server_fqdn linux_sever_fqdn
bolt task run facts --targets windows_server_fqdn linux_sever_fqdn
前往 Web 控制台并查看节点中的事实,以确认它们是否已出现在客户端。
你可以在 github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch05/tmp_count.rb 和 github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch05/packlab.yaml 找到示例解决方案。
注意
在测试自定义或外部事实时,可以通过设置环境变量手动设置它们,在 UNIX 环境中使用 export FACTER_exampleapp ="test",或者在 Windows 环境中使用 env FACTER_exampleapp="test" —— 这样会强制设置 exampleapp 事实。此方法仅适用于自定义或外部事实,而不适用于核心事实。
函数
函数是可以在目录编译过程中运行的 Ruby 代码段,允许你修改目录或计算并返回值。Puppet 提供了许多内建函数,更多的函数可以通过 Puppet Forge 模块提供,如 forge.puppet.com/modules/puppetlabs/stdlib,或者通过自定义编写的函数添加到模块中。本书不会涵盖编写函数的内容,但可以在 puppet.com/docs/puppet/latest/writing_custom_functions.html 找到完整的指南。
本节将介绍三种不同类型的函数:语句函数、前缀函数和链式函数。我们将展示一组核心的 Puppet 函数,并按目的分组,展示最常用和最有用的函数。
注意
许多函数已从如 stdlib 模块等源移入核心 Puppet 函数。完整列表可在 puppet.com/docs/puppet/6/release_notes_puppet.html#release_notes_puppet_x-0-0 中查看。
语句函数
语句函数是 Puppet 语言提供的函数,仅用于它们的副作用,这些函数总是返回 undef 值。语句函数可以省略括号,不像我们将在本节中介绍的其他函数那样。你不能添加自定义的或由 Forge 提供的语句函数。
目录语句
目录语句会影响目录的内容,允许类被包含,依赖关系和包含关系影响目录的顺序,并且可以应用标签。以下是目录语句的示例语法:
Include <class name>
require <class name>
contain <class name>
tag <tag name> , *<tag name>
Include 和 tag 的使用已在 第三章 中讨论,但我们没有详细探讨 tag 函数。tag 函数用于在类中标记该类,并将标签或标签列表应用于所有包含的对象。
在 第六章 中,我们将详细介绍 require 和 contain 的完整使用。
日志语句
日志语句允许将字符串消息发送到 Puppet 服务器的日志输出。在 第十章 中,将全面回顾服务器和代理的日志记录,因为日志位置取决于配置以及使用的是 Puppet 企业版还是开源版。日志语句的语法就是 <logging level>()。
可以应用以下日志级别:
-
debug -
info -
notice -
warning -
err -
fail
要记录 'code unexpected' 的警告信息,Puppet 代码如下:
warning('code unexpected')
字符串消息可以包含变量,如果它们被双引号包围以进行插值。因此,为了在 pa-risc 架构系统上产生 'pa-risc is unsupported' 错误消息,可以在错误函数的字符串中使用 Facter os.arch 的事实:
error("${facts['os']['arch']} is unsupported")
这与本书之前使用的示例不同,特别是前一章示例中使用的 notify 资源。notify 资源会返回到客户端的日志,而日志级别函数则会记录到 Puppet 服务器上。由于 notify 是资源而不是函数,每次调用 notify 资源时,报告都会显示该资源发生了变化。
fail 与其他级别不同,因为将其作为函数调用时会终止编译,并且不会将目录发送到代理。
前缀和链式函数
Puppet 函数可以通过两种方式调用,对于许多函数,两种方式都可以适用。
前缀函数通过编写函数名称然后提供一个括号中的参数列表来调用:
function_name(argument, *argument)
链式函数是通过一个参数、一个句点 (.),然后是函数名称和括号,以及括号内的任何其他参数来创建的:
argument.function_name(argument, *argument)
一些内置函数的选择
核心 Puppet 中有许多可用的函数,本节将对不同函数进行分组,展示它们如何使用,或指明本书中的哪些地方会更详细地介绍它们。本章的目的是展示函数的多样性,而不是给出每个函数的完整语法。你可以在 puppet.com/docs/puppet/latest/function.html 查阅完整的函数列表。确保选择适合你正在使用的 Puppet 环境的文档版本。
比较和大小测量
以下函数允许你比较和测量变量的大小。它们提供了比数据类型直接操作更多的功能。
length 和 size 函数实际上是相同的,都可以作为前缀或链式函数应用于数组(元素数量)、哈希(键值对数量)、字符串(字符数)或二进制数据(字节数),以确认变量的相对大小/长度。例如,以下命令将返回 4 作为字符串 “four” 的长度,返回 5 作为数组的大小:
Stringwithfour = 'four'.length()
Array_of_five = Size([8,4,5,7,0])
match 用作字符串或字符串数组上的链式函数,结合正则表达式匹配模式。它返回一个数组,包含第一个匹配的字符串,后跟匹配的模式。如果没有匹配模式,则会返回一个示例,其中字符串必须以小写字母开头,长度为 6 到 8 的数字。变量匹配 a123456 并返回一个包含 [ 'a123456', 'a' , '123456' ] 的数组:
$matches = "a123456".match(/([a-z]{1})([1-9]{6,8})/)
如果我们在一个不匹配的字符串 1a23456 上尝试相同的正则表达式,则会返回 undef:
$nomatch = "1a23456".match(/([a-z]{1})([1-9]{6,8})/)# $matches contains [abc123]
使用字符串数组('a123456'、'b1254678' 和 '1a23456')和相同的正则表达式,会导致 multi_match 变量包含一个数组的数组。如果对每个字符串单独使用 match,则输出将是:
$multi_match = ['a123456','b1254678','1a23456'].match(/([a-z]{1})([1-9]{6,8})/)
这意味着 multi_match 将包含 [['a123456','a','123456'],['b1254678','b','1254678'],undef]。
max 和 min 用作前缀函数。它们接受一个字符串或数字的数组,并返回每种情况中的最大值和最小值。在 Puppet 6.0 之前,关于如何转换和处理这些函数中使用的混合类型有一些指导意见。然而,由于它已经被弃用,现在强烈建议确保比较的类型一致。在以下示例中,highest number 变量将包含 88,而 lowest letter 变量将包含 'a':
$highest_number = max( [5,3,88,46] )
$lowest_letter = ['d','b','a'].min()
empty 用作前缀或链式函数,用来确认一个数组或哈希是否不包含元素,或者一个字符串或二进制是否为空。在以下示例中,empty_array 和 empty 字符串将包含 true,而 non_empty_string 变量将包含 false:
$empty_array = [].empty
$empty_string =empty('')
$nonempty_string='not_empty'.empty()
compare 用作前缀函数,用于比较两个值,并返回 -1、0 或 1,分别表示第一个值小于、等于或大于第二个值。两个值必须为相同类型,可以是数字、字符串、时间段、时间戳或语义版本。对于两个字符串,可以使用第三个参数(布尔值)来检查比较是否忽略大小写。
例如,numeric_compare 变量将包含 -1,而 string_compare 变量将包含 1,因为大写字母大于小写字母,A 会排在 b 前面。如果布尔值设置为 true,则返回 1:
$numeric_compare = compare(5 , 6)
$string_compare = compare('A', 'b', false)
改变大小写
以下函数用于改变字符串或字符串的数组/哈希的大小写。对于整数,它们保持不变,并可能包含其他无法计算的数据类型错误。
capitalize、camelCase、downcase 和 upcase 都用作前缀或链式函数来改变字符串或可迭代对象(如数组)中字符串的大小写。downcase 和 upcase 也可以在数组上使用。它们都可以用于数字类型,但会返回未受影响的数字。
CamelCase 会去除应用时使用的所有下划线 (_)。camelCase 和 capitalize 对数组不是递归的,而 upcase 和 downcase 是递归的。
如果 downcase 或 upcase 在递归使用时更改了数组中的键,并且因此产生了重复项,它将覆盖该键,使用最后更新的键值对替代。为了举例说明,upper_case 变量在将整个字符串转换为大写后将包含一个名为 UPANDDOWN 的字符串,而 downcase 变量在将键都转换为小写并覆盖第一个时,将包含一个哈希值 {'lower' => 'case2'}:
$upper_case = 'UpAnDdOwN'.upcase()
capitals 变量在将数组中的每个字符串首字母大写后,将包含一个名为 ['Up, Mix'] 的数组:
$capitals =capitalize(['down','miX'])
downcase 变量在将键值都转换为小写并覆盖第一个值后,将包含一个哈希值 {'lower' => 'case2'}:
$downcase = {'lower' = > 'case', 'Lower => 'Case2}.downcase()
camel 变量在去除下划线并将大写方式设置为 camelCase 后,将包含 Word1Word2Word3:
$camel = camelCase('word1_word2_word3')
如果你使用国际字符,你需要检查 Ruby 系统的区域设置是如何处理这些字符的,因为它用于处理大小写转换。
字符串操作
lstrip、rstrip 和 strip 函数可以移除字符串中的空格。它们都是前缀或链式函数,用于从字符串中移除空格。lstrip 移除前导空格,rstrip 移除尾随空格,strip 同时移除前导和尾随空格(如空格、制表符、换行符和回车符),但不包括硬空格。它们可以在字符串或可迭代对象上使用,但不能递归使用。如果用于数字类型,它们将返回未经调整的数字类型,但在任何其他不支持的类型上会产生错误。
以下示例使用了所有三个函数,最终将使 left 变量包含 'first second',right 变量包含 'first second',并且 all 变量包含 'firstsecond':
$spaces = " first second "
$left = $spaces.lstrip()
$right = rstrip($spaces)
$all = $spaces.strip()
闭包
这些函数本身不是闭包,但在与闭包一起使用时最为有用,因为它们允许对数组或哈希变量进行迭代或转换,并传递给闭包,闭包是 Puppet 代码的一个部分。以下函数用于定义变量的行为:all、any、break、each、filter、index、lest、map、next、return、reduce、reverse_each、step、then、tree_each、unique 和 with。
这些函数的语法和行为将在第六章中详细讲解,但为了举个例子,这里我们使用了 each 函数和一个包含用户名键及其对应用户 ID 数字的哈希。each 函数可以将每对键值作为一个数组,并允许为用户资源创建已分配的 ID:
$usersids = {'admin' => 1, 'operator' => 2, 'viewer' => 3}
$userids.each |$users| {
user { $users[0]:
id => $users[1]
}
}
注意
许多函数可以使用 lambda 进行错误处理,这使得您可以循环处理错误部分、消息和问题代码,并允许采取更详细的消息或动作。这将在第六章中讨论。
模板
模板允许您通过简单的输入替换创建复杂的文本。在第六章中,我们将详细讨论模板,但template和epp函数允许通过file资源的content属性使用 ERB 和 EPP 格式的模板。使用 ERB 格式并通知content属性的示例可以在exampleapp模块中找到:
file { '/etc/exampleapp.conf':
ensure => file,
content => template(exampleapp/exampleapp.conf.erb')
}
模块的结构以及如何存储模板文件将在第八章中介绍。
或者,为了使用包含模板格式的字符串并传递值,可以使用inline_template和epp_inline。例如,要使用 EPP 样式模板,假设$exampleapp_conf_template包含 EPP 模板格式的字符串,inline_epp将替换端口和调试变量值exampleapp_port和exampleapp_debugging_enabled:
file { '/etc/ntp.conf':
ensure => file,
content => inline_epp($exampleapp_conf_template, {'port' => $exampleapp_port, 'debugging' => $exampleapp_debugging_enabled}),
}
哈希/数组
以下函数用于访问和操作哈希和数组数据,超出了第四章中讨论的常规操作符,或者用于将变量转换为哈希和数组。
dig函数用于通过提供各种键或索引在复杂数据结构中进行查找。当结构不明确时,它特别有用。例如,假设我们有一个名为exampleapp_proc的数据结构,并且我们想访问 ID 为124的进程状态。如果我们尝试使用哈希索引如exampleapp_proc['exampleapp_pids']['124']['state']来访问,但哈希中没有124这个键,我们将收到错误,目录运行将失败。然而,通过使用dig函数,通知将为未定义:
$exampleapp_proc = { exmpleapp_pids => { 123 => { state => running , user => root } }
notice exampleapp_proc.dig('exampleapp_pids','124','state')
getvar函数用于使用点符号返回结构化变量的部分。如果变量不存在,它将返回undef,而不是抛出错误,这与直接访问结构化变量不同。如果没有找到值,您还可以设置默认值;否则,它将返回undef。
第一个命令使用getvar访问os.release.full事实,而第二个命令在未找到结构化事实时将返回'not_found':
getvar('facts.os.release.full')
getvar('facts.os.release.full','not_found')
join函数用于将一个数组转换为使用指定分隔符的元素字符串。例如,如果你有一个数据中心位置数组dc_locations = ['london', 'falkirk', 'portland', 'belfast'],你可以使用join函数打印一个以冒号分隔的字符串,表示这些位置;例如,notice(join(${dc_locations}, ":"))。这将在通知中生成字符串"london:falkirk:portland:belfast":
dc_locations = [ 'london','falkirk','portland','belfast']
notice ( join(${dc_locations}, ":")
然而,如果你对包含嵌套数组的数组使用join,它将展平数组,但不会影响哈希或哈希中的数组。例如,join([{London => ['bromley', 'brentford']}, 'Berlin', 'Falkirk', 'Grangemouth'], '@@')将打印[ { London => [ 'bromley', 'brentford' ] }@@Berlin@@Falkirk@@Grangemouth ],因为数组的第一个元素是哈希,它不会被展平,尽管它包含了哈希:
dr_locations = [ { London = > [ 'bromley','brentford']},Berlin,['Falkirk','Grangemouth']]
notice ( join(${dr_locations}, "@@")
keys和values函数接受一个哈希并返回哈希中键的数组,可以作为前缀或链式函数运行。例如,要打印offices变量的键列表,前两个notice函数将打印数组['Germany','Holland'],而接下来的两个将打印数组['Berlin',Amsterdam']:
$offices = {'Germany' => 'Berlin', 'Holland' => 'Amsterdam'}
notice(keys(${offices})
notice($offices.keys())
notice(values(${offices})
notice($offices.values())
这些键或值将与它们在哈希中声明时的顺序相同。如果哈希为空,则返回一个空数组。
split函数接受一个字符串,并使用一个模式来表示字段分隔符,可以将字符串拆分为一个数组元素。这个模式可以是字符串、正则表达式或正则表达式。以下示例展示了如何使用不同的模式方法进行拆分,并选择不同的分隔符或多个分隔符:
$exmple_split = north@south.east@west
$split_on_at = split($example_split, /@/)
$split_on_fullstop = split($example_split, '[.]'
$split_on_both = split($example_split, Regexp['[.@]')
split_on_at变量将包含数组['north','south.east','west'],split_on_fullstop将包含数组['north@south ','east@west'],而split_on_both将包含数组['north','south','east','west']。
sort函数接受一个数组,并按数字顺序或字典顺序对数组进行排序。无法混合这些排序方式,也无法同时进行数字和字典值的排序,否则会导致错误且没有转换。字符比较基于系统语言环境,并且区分大小写,除非使用compare和匿名函数。
在最简单的形式下,sort将按升序对数字和字符串进行排序——例如,我们可以拿一个无序的数字数组和一个无序的字符串数组,并使用sort作为前缀或链式函数。在这个示例中,代码将得到按升序排列的数字[0,1,2,3,4,5,7,8,9]和按升序排列的字符串['a','b','c','d']:
$unordered_numbers = [7,9,8,0,2,4,3,1,5]
$unordered_strings = ['d','c','b','a']
$ordered_numbers = $unordered_numbers.sort()
$ordered_strings = sort($unordered_strings)
为了明确指定顺序,你可以使用compare函数对变量进行排序,强调它们应该是升序还是降序。在以下示例中,整数将在升序变量中按[1950,1980,1984,1985]排序,而在降序变量中按[1985,1984,1980,1950]排序:
$ascending =(sort([1984,1950,1985,1980]) |$a,$b| { compare($a, $b) })
$descending = (sort([1984,1950,1985,1980]) |$a,$b| { compare($b, $a) })
正如我们在讨论 比较和大小 部分的 compare 时学到的,布尔值可以用于 compare,以确定是否按大写字母排序。
注意
可以使用 比较和大小 部分中的其他函数,如 max 或 min,来替代使用 compare 函数。
数据处理
针对 Hiera 和加密的 EYAML,提供了几个与数据相关的函数。它们将在第九章中详细讨论,但作为参考,它们是 eyaml_look_up_key,lookup 和 yaml_data。函数文档指出,几个 hiera_<type> 函数已被废弃,取而代之的是 lookup 函数。
unwrap 函数已在第三章中讨论过,该函数用于在必要时使敏感数据类型在 Puppet 代码中可见/可访问。
stdlib 模块函数
模块将在第八章中详细讨论,但 stdlib 模块(forge.puppet.com/modules/puppetlabs/stdlib)被广泛使用,值得突出一些该模块提供的函数,因为几乎每个 Puppet 安装都会使其可用。
需要注意的是,stdlib 中的函数允许一些高级行为,这些行为并不总是 Puppet 代码的最佳实践方式,例如能够将 YAML 文件的内容读取为字符串,并使用 ensure_package 函数,后者用于允许对包资源进行多次声明。在复杂的情况下或代码在多个团队的政治环境中进行管理时,它们可以提供有用的变通方法。
注意
许多函数已被文件类型转换所替代,该功能自 Puppet 5 版本开始提供,此外还有其他新特性,但这些函数为了兼容性目的被保留。
数组和字符串
以下函数通过合并、操作和以多种方式生成新数组来与字符串和数组进行交互。
intersection 函数是一个链式函数,当提供两个数组时,会生成一个包含两个数组中都存在的值的单一数组。例如,以下代码将 ['both'] 数组放入 chained_array 变量中:
$chained_array = intersection(['first','both']['second','both])
union 函数是一个链式函数,当提供两个数组时,会生成一个包含唯一值的单一数组。在以下示例中,union_array 变量将包含 ['first','second'] 数组:
$union_array = union(['first','both'],['second','both']
range 函数是一个链式函数,提供起始、结束或步长区间(如果没有提供,默认步长为 1)。起始和结束可以是字符串或数字,而可选的步长应该是整数。
例如,onetoten 变量将包含一个数组 [1,2,3,4,5,6,7,8,9,10],etog 变量将包含 ['E','F','G'],而 good_trek 变量将包含 ['StarTrek2','startrek4','startrek6','starttrek8']:
$onetoten = range(1,10)
$etog = range('E','G')
$good_trek = ('StarTrek2', 'StarTrek8', 2)
start_with和end_with函数是链式函数,允许你检查一个字符串是否以提供的字符串或字符串列表开始或结束,尝试匹配列表中的任意字符串。它将根据匹配情况返回true或false。在以下示例中,truestart将包含true,因为server匹配了server1234的开头,falseend将包含false,因为wales并没有以land结尾,而trueoptions将包含true,因为aws104以aws开头,并且可能匹配以gcp或az开头的其他字符串:
$truestart = 'server1234'.startswith('server')
$falseend = 'wales'.endswith('land')
$trueoptions = 'aws104'.startswith['gcp','az','aws']
文件信息
basename、dirname和extname函数可以作为单独的函数使用,也可以链式使用,从文件路径中提取文件名、目录或扩展名。以下是一个示例:
$full_path = 'C:\Users\david\fact.ps1'
$file_name = basename(${full_path})
$dir_name = dirname(${full_path})
$ext = ${full_path}.extname
请注意,extname仅适用于格式为filename.extension的文件名。如果字符串不包含点(.),或者点出现在字符串的开头或结尾,它将仅返回空字符串。
实验
在涵盖了各种功能之后,让我们练习使用其中的一些。我们创建一个名为example_functions的类,它接受一个作为字符串的用户前缀和若干个作为整数的用户数量。此类应接受两个参数:一个用户前缀字符串和若干个用户整数。确保前缀是小写的。应从0开始创建一个用户名数组,直到指定的用户数量。然后将该数组传递给用户资源来创建用户。
使用user字符串和数字5来定义你的类。
代码还应记录一条警告消息,其中包含os.windows.product_name事实的内容,或者如果你不使用 Windows 机器,则为linux。
最后,代码应采用fact路径,并确保每个目录都经过审计。提示:你可能需要将此路径拆分为一个数组,并将其传递给文件资源。windows和linux使用不同的路径分隔符——即;和:。以下的if语句应该能帮助你:
if $facts['os.family'] == 'windows' {
}else{
}
你应使用bolt使stdlib在我们的客户端上可用:
bolt command run "puppet module install stdlib" -t windowsclient linuxclient
然后,通过以下命令使用bolt应用puppet类:
bolt puppet apply example_functions.pp -t windowsclient linuxclient
你可以在github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch05/example_functions.pp找到一些示例解决方案。
延迟函数
Deferred函数(也称为代理端函数)是应用了Deferred类型的函数。这会导致该函数在应用目录时在客户端本地运行,而不是在 Puppet 服务器编译时运行。一个延迟函数的目录包含要在客户端运行的内容,而不是函数的输出。Deferred类型是在 Puppet 6.0 中引入的,并且在所有后续版本中可用。
当编译服务器无法访问函数中所需的源时,通常会使用此方法——例如,当从 HashiCorp Vault 服务器检索秘密时,安全设置只允许客户端访问秘密。
应用Deferred的语法如下:
Deferred( name of function, [arguments])
以下是从vault检索秘密的示例。这可以在exampleapp的用户资源中使用,从 Vault 路径exampleapp/password设置密码:
user { 'exampleapp':
password => Deferred('vault_lookup::lookup', ["exampleapp/password"])
}
该函数来自vault_lookup模块(forge.puppet.com/modules/puppet/vault_lookup),并需要根据模块中的说明以及 Hashicorp 的指南:www.hashicorp.com/resources/agent-side-lookups-with-hashicorp-vault-puppet-6)设置底层的 Vault 客户端。
理解使用带有Deferred的函数之间的差异很重要。你不能使用Deferred函数将变量传递给字符串。这样会导致目录创建该对象的字符串化版本。在以下示例中,涉及从vault查找名为exampleapp/message的键值,第一个notify将返回一个字符串,其中包含目录中函数名称的字符串化翻译,而第二个notify将返回vault lookup本身的值:
$deferred => Deferred('vault_lookup::lookup', ["exampleapp/message"])
notify {'this will return the object name':
message => "Secret message is ${deferred"
}
notify {'this will return the message':
message => $deferred
}
这反映了在编译时计算字符串值的目录编译过程。这种不匹配可能会出现在其他地方,例如模板中,但可以通过确保任何延迟的值仅在单独使用或在其他延迟函数中使用来克服。在第七章中,你将学习如何使用延迟模板。
函数只能在使用核心数据类型时延迟,因为客户端在运行时通过插件同步仅提供核心数据类型。在第十章中,你将学习插件同步如何与客户端协作。
同时需要注意,是否返回敏感值以及如何失败,取决于函数本身的实现。对于vault_lookup函数,无法优雅地失败;它将返回一个错误,导致目录运行出错。
注意
从 Puppet 7.17.0 开始,延迟函数现在可以按需调用,而不是预处理。使用此方法,目录可以为延迟函数提供输入。如果延迟函数失败,则只有受影响的资源会失败,而所有其他资源仍然会被应用。要启用此行为,设置Puppet[:preprocess_deferred] = false或使用--no-preprocess_deferred。
所有这些行为适用于本地的puppet apply run,因为puppet apply run将生成目录并在本地应用。
摘要
在本章中,你学习了 Facter 工具如何通过其事实提供系统配置文件信息,以及如何使用外部事实和自定义事实来扩展这些信息。我们提醒你,收集事实会产生基础设施成本,且应平衡其适用的规模。我们指出,外部事实可以是操作系统允许的简单静态数据的平面文件或可执行脚本。尽管自定义事实是用 Ruby 编写的,但它们相比外部事实具有若干优势。能够将自定义事实仅限于在某些系统上运行,可以让你选择不同的分辨率,并根据权重选择应被选中的分辨率,以及在 Puppet 7 中的分辨率级别或 Puppet 6 及以下版本中的执行级别设置超时。
接下来,我们回顾了函数,并强调了函数在操控目录或返回计算值方面可以完成的广泛任务。在这里,我们讨论了目录语句,它们用于在目录中包含类,以及日志记录语句,它们用于设置日志信息。我们还突出了另外两种类型的函数,即前缀函数和链式函数,并展示了它们的语法。然后,我们展示了一个核心函数的选择,并介绍了暴露可用函数的各种类别。
然后,我们讨论了stdlib模块中的一小部分函数,重点介绍了可以提供的内容。请注意,一些stdlib函数已经被弃用,仅用于向后兼容或处理极限情况,这并不是最佳实践。
最后,我们讨论了延迟函数,这些函数允许在客户端应用目录时运行。我们强调了这对某些只对客户端可用的服务(例如对安全服务进行 API 调用)或可能不希望在与其他服务共享的 Puppet 基础设施上运行的服务的优势。
在下一章中,你将学习资源和类之间的关系和依赖关系如何工作。我们将查看作用域和包含性如何影响资源、变量和类,以及如何构建代码和必要的依赖关系,而不陷入常见的陷阱和依赖地狱。
第二部分——在 Puppet 语言中结构化、排序和管理数据
本部分将介绍更高级的 Puppet 语言特性。我们将展示如何使用迭代和条件来管理代码中的依赖关系和流程。接着,我们将看到如何使用最佳实践将 Puppet 结构化为模块,采用角色和配置模式。Puppet Forge 将展示为一个有用的预构建模块源,我们将查看如何理解和审查这些模块的源代码和内容。接着,我们将查看如何使用 Hiera 管理 Puppet 中的数据,并了解何时使用独立的数据源和变量的最佳实践。
本部分包括以下章节:
-
第六章,关系、排序与作用域
-
第七章,模板化、迭代**和条件语句
-
第八章,开发与管理模块
-
第九章,使用 Puppet 处理数据
第六章:关系、排序和范围
在本章中,我们将讨论 Puppet 中的关系、顺序和范围。这些话题常被认为很复杂,因为 Puppet 的处理方式与传统编程语言大不相同。不过,我们将向你展示如何有效地管理这些方面,避免不必要的复杂性。
我们将首先讨论 Puppet 对关系和排序的处理方式。默认情况下,Puppet 将资源视为独立的,可以按任意顺序在目录中应用。然而,当排序是必需时,我们将向你展示如何使用 before、after、notify 和 subscribe 等元参数来强制执行排序并在资源之间创建关系。
接下来,我们将介绍封装的概念。我们将解释,包含类并不包含在其调用类内,因此,类之间建立的关系/依赖关系不会自动与这些类中的资源建立关系和依赖关系。为了解决这个问题,我们将介绍 contain 函数,它允许你将资源包含在类内并创建这些关系。
最后,我们将讨论作用域,以及变量和资源默认值如何根据它们在代码中的位置及其相对作用域具有不同的可见性。然后,我们将提供最佳实践和常见陷阱,以确保你采取最简单的路径并避免不必要的复杂性。
总的来说,本章将帮助你掌握在 Puppet 中有效管理关系、排序和范围的知识与技能。
在本章中,我们将介绍以下主要内容:
-
关系和排序
-
封装
-
范围
-
最佳实践与常见陷阱
技术要求
本章中的所有示例和实验可以在你自己的本地开发环境中运行。
关系和排序
默认情况下,Puppet 将所有资源视为相互独立的,这意味着它们可以按任意顺序应用。这与传统的声明式代码不同,传统代码按行执行并按照书写顺序执行。Puppet 方法的主要优势之一是,如果代码的某个部分失败,Puppet 会继续应用所有其他资源。这消除了停止或需要大规模故障处理来继续执行代码的需求。因此,即使某些资源失败,Puppet 也能将客户端服务器尽可能接近所需状态。
很明显,一些资源之间会相互依赖,例如一个配置文件只能在安装完某个包后才能存在。Puppet 提供了元参数来创建这些依赖关系:
-
before: 该资源应在指定的资源之前应用。 -
require: 该资源应在指定的资源之后应用。 -
notify: 该资源应在指定的资源之前应用。如果该资源发生变化,指定的资源将被刷新。 -
subscribe:资源应在指定的资源之后应用。如果指定的资源发生变化,资源将刷新。
before和require元参数可以用来强制执行依赖关系。然而,重要的是要注意,依赖关系只需在一个方向上应用。因此,不需要在依赖关系的两侧都使用before和require。
例如,要表示在管理文件之前应安装httpd包,可以使用before或require,如下面所示:
package { 'httpd':
ensure => latest,
before => File['/etc/httpd.conf'],
}
file { '/etc/httpd.conf':
ensure => file,
require => Package['httpd'],
}
依赖关系图,也称为puppet命令中的--graph选项,用于生成一个 dot 文件,该文件可以用来在适当的程序中创建图形。
在图 6.1中,文件资源上的require已经被移除,从而为示例代码生成了一个 DAG:
图 6.1 – 资源依赖的 DAG
如果同时存在before和require元参数,DAG 中将会看到一个额外的箭头,但它不会影响编译或应用资源。值得注意的是,示例代码中的起始和结束类Main反映了代码不包含在类中,而是处于全局范围内。这将在作用域部分进一步讨论。
在 DAG 中,通常不期望出现循环,因此依赖关系的流动应该只向下进行。如果添加了第三个资源(例如服务),并且该资源应在/etc/httpd.conf文件之后强制执行httpd包,那么 DAG 应该是这样的:
service { 'httpd':
ensure => running,
before => Package['httpd'],
require => File['/etc/httpd.conf'],
}
这将导致依赖循环,如图 6.2所示。编译时,代码将产生错误,因为无法确定资源应用的顺序。
图 6.2 – 显示依赖循环的 DAG
也可以使用数组表示多个依赖关系,数组可以包含相同类型或不同类型的名称。例如,如果一个包被exampleapp的两个文件和两个服务所需要,可以这样表示:
package { 'exampleapp':
ensure => latest,
before => [File['/opt/exampleapp.content','/var/exampleapp.variables],Service['exampleapp','exampleapp2']]
}
有时候,将所有资源依赖集中在一边比分别在每个资源上处理要更简单。
如在第三章中提到的,某些 Puppet 类型具有自动创建依赖关系的规则,这些规则可以在 Puppet 类型的文档中找到,位于Autorequires部分,可以在线查看或使用 Puppet 的describe命令。例如,用户类型会自动要求 Puppet 控制下的任何组,作为用户资源的主组或副组。
除了排序概念,Puppet 还有refresh属性,因此如果一个资源依赖于另一个资源,它将刷新自身。这在配置文件更新并且服务需要重启以重新读取配置文件的情况下非常有用。
notify和subscribe元参数创建与before和require相同的依赖关系,但将refresh属性添加到依赖资源。对于内置的 Puppet 类型,service exec和package可以被刷新。如果使用了notify或subscribe元参数与无法刷新资源类型,它只会强制执行依赖关系,并在刷新事件时不执行任何操作。
注解
notify元参数不应与notify资源类型混淆,后者用于向代理日志发送消息。
例如,一个service资源可以使用file资源的subscribe或notify,使得该服务依赖于文件的创建。如果文件被更新,它也会接收到一个刷新事件,并重启服务,前提是提供者具有此能力。如以下代码所示,我们展示了依赖关系的双方,尽管只应提供一个关系属性:
service { 'httpd':
ensure => running,
subscribe => File['/etc/httpd.conf'],
}
file { '/etc/httpd.conf':
ensure => file,
notify => Service['httpd'],
}
在 DAG 图中,这与使用before和require是相同的,并且可以使用相同的资源引用或资源引用数组。
每种类型的刷新事件的默认行为和参数如表 6.1所示。这里,我们看到默认情况下,服务将使用提供者的restart变量(如果提供)。否则,hasrestart可以定义一个init脚本,或restart可以定义一个自定义的重启脚本。如果没有提供init脚本,将在进程树中搜索服务名称,但强烈建议提供明确的服务管理脚本。
对于包类型,默认行为是忽略restart事件,但可以将参数设置为在refresh事件后重新安装包。
Exec将在刷新时重新运行其命令,但可以更改为运行不同的refresh命令或仅在refresh事件后运行。
| 类型 | 默认行为 | 参数 |
|---|---|---|
Service | 如果提供者有重启功能,则重启服务;否则,停止并重新启动 | hasrestartrestart |
Package | 忽略刷新事件 | reinstall_on_refresh |
Exec | 重新运行命令 | refreshrefresh only |
表 6.1 - Puppet 本地类型刷新选项
元参数依赖可能会产生三种类型的错误。第一种是缺少依赖,即在编译后的目录中找不到资源。这通常应该检查是否存在拼写错误或逻辑错误,意味着资源没有被包含。第二种错误是依赖失败,即资源存在问题,导致无法应用它的任何依赖。此时需要排查该资源并重新运行 Puppet,这样所有依赖的资源就能被应用。第三种错误是依赖循环,我们在 图 6.2 中讨论过并展示过,通过生成有向无环图(DAG)可以帮助识别循环的位置并修复依赖逻辑。
尽管我们之前说过资源除了依赖关系外没有顺序,但这并不完全准确,因为 Puppet 是按照所谓的 清单顺序 运行的。因此,单个清单文件将按其编写的顺序应用,除非依赖关系发生变化。尽管这允许你不使用依赖关系,但其主要目的是防止随机编译导致代码在不同的服务器上表现不同,这可能会发生在随机读取时。
注意
Puppet 在早期版本中经历了一个关于排序的奇怪哲学/纯粹性争论。开发人员习惯性地认为排序应像其他语言一样,按行逐行处理,因此 Puppet 最初选择了随机排序。这种做法混乱不堪,导致在实验室中运行的代码可能可以工作,但在生产环境中按不同的顺序运行并导致失败。
依赖元参数的一种变体是链接箭头,其中 before 和 require 通过 -> 和 <- 表示,而 notify 和 subscribe 则通过 ~> 和 <~ 表示。它们通常用来表示类之间的关系,比如表示模块模式,详情见 第八章。例如,如果我们希望 install 类在 config 类之前应用,并且在 config 类更新时应用并刷新 service 类,可以表示为:
include examplemodule::install, examplemodule::config, examplemodule::service
Class['examplemodule::install']
-> Class['examplemodule::config']
~> Class['examplemodule::service']
正如在 第三章 中讨论的那样,include 函数是必要的,以确保类被添加到目录中。
为了风格上的一致性,建议仅使用右箭头,以确保阅读时的一致性。虽然依赖参数可以在类和资源声明中使用,并且可以在其他资源类型中链接箭头,但不推荐这样做,以保持阅读的清晰性。
在简单的情况下,可以通过类内部使用所需的函数来创建对其他类的依赖。然而,没有类似 refresh 或 before 的功能,因此为了风格和一致性,通常使用排序箭头会更方便。一个简单的例子,使用 require 函数表示 install 类应该在 config 类之前应用,示例如下:
class examplemodule::config {
require examplemodule::install
}
我们刚刚讨论的类依赖方法并不像看起来那么简单,因为 Puppet 类实际上并不包含其他类。一个类默认会包含其他类,因此依赖关系不会覆盖它们。接下来我们将探讨这个包含问题的含义以及如何处理它。
包含
Puppet 中的包含意味着包含的类不会像类中的资源那样被包含;因此,当设置对一个类的依赖关系,并通过 include 函数或 class 资源来包含另一个类时,依赖关系只会覆盖资源。例如,假设我们创建了一个要求 class1 在 class2 之前应用,并且 class2 包含一个 package 资源和一个对 class3 的 include 调用,如以下代码所示:
include examplemodule::class1, examplemodule::class2
Class['examplemodule::class1'] -> Class['examplemodule::class2']
class examplemodule::class2 {
include examplemodule::class3
package{'PDS':}
}
因此,虽然可能有一种假设认为这将确保 class1 在 class3 之前,但是 class1 确实在 class2 之前,这并不会发生,正如在 图 6.3 的 DAG 图中所看到的那样。
图 6.3 – DAG 显示缺乏包含
回想一下在 第三章 中介绍的 include 函数,这种包含并非自动发生,因为我们可能希望将该类包含在不同的地方,以应对不同的情况,并且它在目录中只出现一次,没有依赖或包含问题。
要包含一个类,可以使用 contain 函数。将 include 行改为 contain examplemodule::class3,这将使 DAG 图包含 examplemodule::class3,正如我们在 图 6.4 中所看到的那样。
图 6.4 – DAG 显示使用 contain 函数
如果 class 资源与 contain 语句一起使用,它必须在 class 资源之后按清单顺序出现。如果没有这样做,class 资源将把 contain 语句解释为尝试声明一个重复的资源,从而导致错误。例如,如果使用以下代码,属性将被成功传递:
class {'examplemodule::class3':
attribute1 => 'value1''
}
contain examplemodule::class3
对于这个包含问题,直接的问题可能是为什么不使用 contain 来处理所有的内容呢?这归结于它可能产生的不必要且令人困惑的依赖关系。如果我们将原始示例更新为使用 contain 替代 class,并且我们有另一个类 anothermodule:class,它要求 examplemodule:class3 出现在目录中,那么我们可以添加如下代码:
class anothermodule::class {
contain examplemodule::class3
package{'PTOP':}
}
然后,DAG 会像 图 6.5 所示。可以立刻看出,我们仅通过少数几个类就创建了不必要的依赖关系。
图 6.5 – DAG 显示由于过度使用 contain 而导致的循环
更糟的是,很容易创建一个循环依赖。例如,如果 security::default 类被包含在所有应用程序类中,application2 类通过 require 函数引用 application1 类,就可能会创建一个循环依赖,代码如下所示:
class application1 {
contain security::default
}
class application2 {
contain security::default
require application1
}
这将生成如 图 6.6 所示的 DAG。如果仅使用 include,就能避免应用程序类与 security::default 之间的不必要关系:
图 6.6 – DAG 显示了过度使用 contain 导致循环依赖的情况
在 最佳实践与陷阱 部分,我们将进一步讨论如何通过一致的模式避免担心包含问题。
在 Puppet 3.4 版本引入 contain 函数之前,有另一种方法,您可能会在遗留代码中看到:使用 anchor 资源。这可以通过 stdlib 模块提供的特定锚点资源或类中的其他资源对来完成。为了确保当前类包含 examplemodule::class3,使用 anchor 资源的代码如下所示:
anchor {['start', 'stop']: }
include examplemodule::class3
Anchor['start'] -> Class[' examplemodule::class3'] -> Anchor['stop']
或者,如果这两个软件包资源,pdk 和 cowsay,在此类中,它们可以被借用来创建关系并包含该类:
Package['pds'] -> Class[' examplemodule::class3'] -> Package['cowsay']
这种模式的问题是,它会用额外的锚点资源或不必要的关系使 DAG 变得杂乱,可能会引起混淆。因此,如果发现正在使用锚点,建议您通过使用 contain 关键字来现代化您的方法。
讨论了依赖关系和资源及类的包含后,我们将看到变量和资源默认值在 Puppet 语言中的作用域。
作用域
在 Puppet 中,作用域反映了代码中可以直接访问变量的地方,而无需使用命名空间,并且可以影响资源默认值的地方。
作用域有三个级别:
-
顶层作用域:类、类型或节点定义之外的任何代码。顶层作用域中的任何变量或资源声明将在任何地方都能读取或使用。
-
节点作用域:在节点定义中定义的任何代码。节点作用域中的任何变量和资源默认值将对与该节点定义匹配的节点在节点和局部作用域级别可见。
-
局部作用域:在类、定义类型或 Lambda 中定义的任何代码。因此,在该特定资源内定义的任何变量和资源默认值将仅在该资源内可见。
外部节点分类器(ENCs)和节点定义将在第十一章中讨论。我们在本节中需要理解的是,ENC 是一个可执行脚本,它返回变量和类,这些变量和类将应用于主机。此脚本可以通过执行各种操作(例如,执行数据库查找或使用 AWS Lambda)注入自定义逻辑和数据。它还可以用于访问第三方源,如配置管理数据库(CMDBs)。返回的变量位于顶级作用域,而类位于节点作用域级别。这使得提供的变量可以在任何地方可见,但只有声明了访问节点作用域变量的类才能访问这些变量。相比之下,节点定义是应用于匹配节点的代码段。
类具有所谓的命名作用域,其中类的名称用于命名空间。例如,在exampleclass中创建的名为test的变量可以通过exampleclass::test从任何地方访问。在全局作用域中创建的变量(如site.pp)可以通过调用::variablename从空命名空间访问。然而,通常不推荐以这种方式访问数据。在第九章中,我们将展示如何集中管理数据。
在 lambda 和已定义类型中的节点作用域定义和本地作用域定义是匿名的,只能通过其可见的名称直接访问。从当前作用域声明一个变量(例如,类覆盖全局变量)也可以覆盖更高作用域的变量。
为了展示这一点,请考虑以下代码:
$top='toptest'
$test='testing123'
notify "Top = ${top} node = ${node} local = ${local} test = ${testing}"
notify "Access directly ${example::local}"
node default {
include example
$test='hello world'
$node='nodetest'
notify "Top = ${top} node = ${node} local = ${local} test = ${testing}"
notify "Access directly ${example::local}"
}
class example {
test='an example'
$local ='localtest'
notify "Top = ${top} node = ${node} local = ${local} test = ${testing}"
}
第一个notify无法找到本地或节点变量,因为它位于全局作用域,而testing将被设置为testing123。第二个notify将直接访问本地命名空间example,并打印localtest。第三个notify将无法访问本地变量,并打印hello world。第四个notify将再次通过命名空间访问本地作用域。最后一个notify将能够访问所有变量,并将local设置为localtest。此示例展示了变量在作用域之间的流动。
资源标题和资源引用不受作用域的影响,可以在任何作用域中声明。例如,资源可以声明对目录中任何资源的依赖关系。然而,依赖于访问外部变量的做法并不推荐。
最佳实践与陷阱
在早期版本的 Puppet 中,作用域、依赖关系和包含性是一些最具挑战性的问题,这导致了对新开发者的重大困扰。一项主要的解决方案是广泛采用角色和配置文件方法,这在第八章中将详细介绍。Hiera 数据将在第九章中进行详细讲解。
角色和配置文件方法涉及将执行独立功能的单用途组件模块进行分组。例如,一个组件模块可以安装和配置 Oracle。模块结构将包含一组具有特定目的的清单,如安装软件包或管理服务。这简化了模块的组织,并允许更容易地对类进行排序。例如,install类可以在service类之前应用。
组件模块应该相互独立运行,并且模块之间没有直接的依赖关系。配置文件层将模块组合在一起以创建技术栈,并可以在必要时对模块进行排序。角色则抽象出另一层,利用这些技术栈创建业务解决方案,并可以对配置文件进行排序。在这种结构中,任何全局或节点数据应该来自 Hiera,而不是在节点或全局作用域中设置,从而减少代码复杂性。对于开发者来说,避免在代码中设置全局变量可能会感觉不直观,但推荐遵循这一做法。
如在第三章中提到的,建议避免使用资源收集器/导出的资源。然而,值得注意的是,它们可以作为链式箭头的一部分使用。使用它们可能具有风险,因为这可能导致不可预测的结果,并且可能产生难以在运行时映射的大量依赖循环。依赖关系应始终根据需要创建,不应依赖清单的顺序来实现这一点。省略这些依赖关系可能会显著降低代码的可维护性,并在未来重构时带来复杂性。
使用链式箭头表示类依赖关系,并仅在必要时包含它们,如在角色和配置文件方法中所示。避免在全局范围内强制资源默认值,例如在site.pp或节点定义中,因为这种方法会使代码变得不可预测,特别是在与多个应用团队协作时,他们可能不了解自己代码中的这些默认值。总之,避免尝试过于复杂或从其他语言中借鉴的方式,应该遵循既定的角色/配置文件和 Hiera 模式。仔细审查角色/配置文件和 Hiera 模式,并重构任何不符合这些指导原则的代码。
实验室 – 关系、顺序和范围概述
在本实验中,我们将提供一些代码进行回顾和运行,以确保理解所讨论的概念。所有代码都可以在 github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/tree/main/ch06 中找到。
github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch06/lab6_1.pp 中的代码目前没有依赖关系。为了满足以下要求,需要相应调整代码:
-
install类及其所有资源应在config和service之前运行 -
config类及其所有资源应在service之前运行 -
如果
config类中的任何资源被更新,则应刷新service类及其所有资源 -
httpd包应在exampleapp包之前安装 -
exampleuser用户应在examplegroup组之后创建 -
应在创建
exampleuser用户和examplegroup组之后创建/etc/exampleapp/目录 -
应在创建
exampleuser用户、examplegroup组和/etc/exampleapp目录之后创建/etc/exampleapp/exampleapp.conf文件 -
httpd服务应在exampleapp服务之前启动,并且如果httpd服务重启,则应刷新exampleapp服务
建议使用 validate.puppet.com/ 检查你的 Puppet 代码,因为你不应仅仅依赖于清单顺序。此外,重要的是要记住某些资源具有自动依赖行为。一个示例可以在 github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch06/lab6_2.pp 中找到。检查代码并查看 notify 函数将打印什么。
概要
在这一章中,我们讨论了资源默认按任何顺序应用的假设,以及如何使用元参数如before、require、notify和subscribe来定义所需的顺序。我们了解到,DAG(有向无环图)可以用来可视化资源之间的依赖关系,并且应该避免依赖关系的循环,以确保目录能够成功应用。我们还讨论了某些资源如何自动应用依赖关系,例如用户需要其主组。我们解释了notify和subscribe元参数,并特别强调它们在资源如exec、package和service中的refresh用法。这允许这些资源在必要时被重启、重新安装或重新运行,例如当配置文件发生变化时。此外,我们还承认,尽管资源应被假定没有特定顺序,但实际上它们会按清单中书写的顺序被应用,以确保在不同环境中的一致性。我们还讨论了三种可能发生的错误:循环依赖、缺失的依赖关系和依赖资源失败。
随后,我们讨论了作为元参数变体的链式箭头,允许它们在类之间使用。我们强调只有右向箭头应当被使用,以遵守样式指南。虽然元参数可以在类上使用,而链式箭头可以在资源上使用以保持一致性和样式,但我们建议避免这种做法。相反,我们展示了require函数如何在一个依赖于另一个类的类中使用,以处理相对简单的类依赖关系。
然后我们讨论了封装问题,这个问题出现在将类包含在其他类中时,并未创建资源依赖关系。通过使用contain函数代替类中的include函数,达到了使该类包含其他类的资源并创建依赖关系的效果。我们讨论了这可能引发包含所有类的诱惑,但我们演示了这会创建不必要的或循环的依赖关系。我们展示了旧的锚点模式,因为遗留代码仍然可能包含这种模式。我们强调anchor函数不再是推荐的做法,且在发现时应当更新为使用contain函数。
作用域影响着变量和资源的默认值,其中全局作用域指的是类、类型或节点定义之外设置的任何内容。节点作用域是指节点定义中的任何内容,而局部作用域是指类、类型或 lambda 中的任何内容。
最后,作为最佳实践,建议遵循角色和配置文件方法,以确保依赖关系和顺序的一致性。还建议使用 Hiera 代替复杂的变量使用,并避免在全局范围内设置资源默认值,例如 site.pp 或节点定义。重要的是,绝不要依赖清单的顺序,应该使用显式依赖关系来确保一致性。
下一章将探讨 Puppet 中的模板、迭代和条件语句。它将展示如何通过利用变量、条件和文本处理函数,Puppet 能够生成文件内容。此外,还将解释如何通过迭代函数和 Lambda 代码块,Puppet 能够循环处理并操作数据集合。最后,本章将介绍如何在 Puppet 中使用条件语句,根据条件逻辑创建不同的配置。
第七章:模板化、迭代和条件语句
本章将介绍 Puppet 语言中的高级结构,包括允许在模板文件中插入变量的模板。Puppet 中有两种格式可用:嵌入式 Ruby(ERB)模板,它基于原生 Ruby 模板化,和嵌入式 Puppet(EPP)模板,它是基于现代 Puppet 语言的模板。本章将讨论这两种格式,重点介绍它们之间的区别,以及使用 EPP 而非 ERB 的核心优势。
此外,本章还将深入探讨 Puppet 中的迭代和循环,展示如何使用 Puppet 中的迭代函数和代码块(lambdas),而不是其他语言中常见的loop关键词。最后,本章将讨论 Puppet 中可用的不同类型的条件语句,包括if、case和unless语句,这些语句是任何编程语言中常见的,以及 Puppet 特有的选择器,它允许根据事实或变量选择键或变量上的值。本章还将详细探讨在条件语句中使用正则表达式的情况。
本章将涵盖以下主要内容:
-
Puppet 中的模板格式 – EPP 和 ERB
-
迭代和循环
-
条件语句
技术要求
本节中的所有代码都可以在本地开发服务器上进行测试。
Puppet 中的模板格式 – EPP 和 ERB
Puppet 中的模板化允许通过替换变量并使用条件逻辑来定制内容,从而生成标准格式的内容。Puppet 支持两种模板格式:ERB,它是一个原生 Ruby 模板格式(github.com/ruby/erb),并且在所有版本的 Puppet 中都可用;EPP 模板,它基于 Puppet 语言,在 Puppet 4 中引入,并且在启用未来解析器的 Puppet 3 版本中也可以使用。
模板提供的灵活性超过了字符串,但比使用file_line、augeas或concat等资源控制单个或一组设置的灵活性要低。因此,在决定使用模板还是资源时,需要在复杂性之间找到平衡。
对于相对较短的heredoc文件或简单字符串,使用带变量插值的模板可能已足够。然而,对于更复杂的文件,尤其是多个模块可能管理不同设置或接受手动编辑的文件,使用资源来处理每个设置或部分会更加简单且易于管理。
在旧代码中,可能会发现模板使用过度,这反映了在 Puppet 早期版本中缺乏资源类型(如 file_line)。因此,审查尝试实现的目标状态非常重要,并确保通过使用模板控制所有内容设置时,整个文件不会被不必要地强制执行,避免其中包含已变得冗余的设置,因为与配置文件相关的底层应用程序已经更新并更改了其配置设置。
虽然新代码中没有必要使用 ERB,但许多 forge 模块和遗留代码库可能包含 ERB,因此本节将涵盖这两种格式,以帮助理解。在展示两种格式的语法后,将讨论使用 EPP 的优点以及将 ERB 转换为 EPP 的理由。
模板可以通过使用模板文件中的内容或通过字符串(称为内联模板)生成。对于模板文件,ERB 使用 template 函数,EPP 使用 epp 函数。对于内联模板,EPP 使用 inline_epp 函数,ERB 使用 inline_template 函数。
EPP 模板
EPP 模板文件是一个文本文件,包含文本和 Puppet 语言表达式的混合,表达式被标签包围。这些标签指示 Puppet 表达式应如何评估,并可以修改模板中的文本,从而基于 Puppet 语言特性(如变量插值、逻辑语句和函数)创建文件。
表 7.1 显示了可以使用的标签类型:
| 标签名称 | **起始标签(**带修剪) | **结束标签(**带修剪) | 目的 |
|---|---|---|---|
| 参数 | <% | (<%- |) | |%> (| - %>) | 声明模板接受的参数 |
| 非打印表达式 | <% (<%-) | %> (<%-) | 评估 Puppet 代码但不打印 |
| 表达式打印 | <%= | %> (-%>) | 评估代码并打印其值 |
| 注释 | <%# (<%#-) | %> (-%>) | 允许仅为模板文件本身添加注释行 |
表 7.1 – EPP 模板标签
当模板被评估时,它在遇到起始标签时会切换到 Puppet 模式,遇到结束标签时返回到文本模式。在文本模式下,它将文本输出为内容,当找到标签时,起始标签和结束标签之间的 Puppet 代码会被评估,具体取决于起始标签的类型。
如表 7.1所示,一些标签可以通过使用短横线(-)来修剪适当的空格和换行。
参数标签是可选的,如果使用,必须是模板文件中的第一个内容,注释标签除外,且注释标签必须使用闭合连字符。其行为类似于 Puppet 类的参数声明方式,如在 第八章 中所示。参数遵循与类相同的模式,因此它们可以选择性地在开头包含类型。接着必须有一个美元符号($),后跟变量名,后可选择一个等号(=)和默认值,最后必须以逗号结束。
例如,若要使一个选项参数包含一个默认值为空字符串的字符串,一个 application_mode 参数(可以包含 full、partial 或 none 字符串,并且默认值为 node),以及一个 cluster_enabled 参数(布尔值),以下代码将开始我们的模板:
<%- |
String $options = '',
Enum[full,partial,none] $application_mode = 'none',
Boolean $cluster_enabled,
|-%>
当参数传递给 EPP 模板时,它们变为局部作用域,并且可以直接按名称调用,但来自调用类的变量必须使用完整的命名空间名称;这类似于定义类型。任何没有默认值的参数,例如前述示例中的 cluster_enabled,都是必填的,必须传入。
注意
推荐始终在参数前使用连字符,以避免模板开头的任何意外空格。
如果没有使用参数,可以直接使用类作用域访问类变量,例如 $example_module::example_param。
参数使得模板在多个不同位置使用时更加灵活,确保数据更为明确且与需求紧密绑定,并且一目了然地展示了消耗的是什么数据。当需要使用大量变量时,使用变量可能比使用参数更合适,因为参数无法扩展。当未在参数列表中定义的参数被传递时,将导致语法错误,尽管如果不使用参数,可以向模板传递任何参数。本节后面将展示如何在引用 epp_template 函数时传递哈希值。
Puppet 模板中的 comment 标签允许在模板文件内添加注释。这些注释在模板被评估并生成内容时不会出现在输出中。以下是 Puppet 模板中注释的示例:
<%#- An example comment. -%>
注意
<%#- 连字符修剪功能自 Puppet 6.0.0 版本起提供。在此之前,修剪行为是默认假定的。
打印表达式标签将 Puppet 表达式的返回值输出到结果中。这可以是一个变量或事实,函数的输出,或者运算符的输出。最终的输出是一个字符串,并会在必要时自动转换。在最简单的情况下,这可以用来打印事实作为值。例如,以下行将读取 application = exampleapp,如果 application_name 事实包含 exampleapp 值:
application = <%= $facts[application_name] -%>
本示例也是变量在这个上下文中首次展示,但它们的访问方式与常规 Puppet 代码中的变量相同。
非打印标签包含迭代和条件逻辑。这与其他标签不同,因为标签的效果可以跨越多行,直到另一个标签关闭迭代器或条件逻辑。例如,if 语句(将在条件语句部分中讨论)以花括号 { 开始,以花括号 } 结束。在下面的示例中,我们可以确保如果应用程序名称从 getvar 函数返回 undef,则不会输出 application =,就像我们之前的例子中所做的那样。相反,如果变量未定义,则会忽略该行:
<% if getvar(facts.application_name) { -%>
application = <%= $facts[application_name] -%>
<% } -%>
可以使用多级非打印标签来创建适当的嵌套 if 或 case 语句。
有一些语法错误需要小心处理。如果非打印表达式标签包含注释,则基本上会注释到行尾,并且需要在下一行上关闭标签,如本例所示:
<%-# I don't finish commenting here -%> but on the next line
-%>
这种错误显然可能会发生在误键入的 comment 标签中,因此必须小心,并且任何标签,而不仅仅是注释关闭标签,都会被忽略直到新行。
要在模板输出中包含文字 <% 或 %> 字符而不让它们作为 EPP 标签进行评估,您可以使用额外的 % 字符来进行转义。例如,要将 <% Puppet expression example %> 输出为文本,您应该写成 <%% Puppet expression example %%%>。请注意,转义仅适用于遇到的第一个 <% 或 %>,因此如果您需要在一行中仅转义其中一个,您可以使用一次转义,然后正常使用另一个符号。
可以使用 puppet epp validate <template_name.epp> 命令验证 EPP 模板,并且在第八章中,将看到 Puppet 开发工具包 (PDK) 将在其验证过程中运行此命令。
要测试模板的渲染,可以使用 render 命令,并根据需要使用值哈希:puppet epp render <template_name.epp> --values '{key1 => value1, key2 => `value2}'。
注意
EPP 模板的完整规范可以在线查看 github.com/puppetlabs/puppet-specifications/blob/master/language/templates.md。
在审查 EPP 模板文件的语法之后,让我们看看如何在 Puppet 资源中使用 epp 函数。可以通过将其传递给 content 属性,将 epp 函数用于诸如 file 等资源。此外,可以提供键值哈希以指定参数,正如在关于 parameter 标签的前一节中讨论的那样:
file { '/etc/exampleapp.conf':
ensure => file,
content => epp('exampleapp/exampleapp.conf.epp', {'version' => '1', 'clustered' => false}),
}
注意
如果你希望在开发环境中尝试前面的示例,请在系统上创建一个合适的模板文件,并将exampleapp模块名更改为包含模板的绝对路径,例如/var/tmp或C:\Users\David Sandilands。
epp中使用的命名空间假设它要么形成一个模块路径<modulename/templatename.epp>,该路径转换为modulepath/modulename/templates/templatename.epp,要么是磁盘上的绝对路径。在第八章中,将详细介绍模块的结构。
内联模板类似于常规模板,但它们不使用单独的模板文件,而是需要传递一个字符串或变量给它们。它们通常用于解决方法或在使用 heredoc 比使用模板文件更简单的情况下。
一个解决方法的例子是在使用第五章中讨论的 Vault 模块时,通过延迟函数来检索密钥。Vault 模块返回一个键值对,但我们可能只想访问密码的值。由于该值是延迟的,因此不能像字符串一样操作。使用inline_epp函数,如以下示例所示,可以在代理运行时解包字符串并将其应用到文件中:
$vault_keypair = { 'password' => Deferred('vault_lookup::lookup', ["secret/examleapp", 'https://vault:8200']), }
file { '/etc/exampleapp_secret.conf':
ensure => file,
content => Deferred('inline_epp', ['PASSWORD=<%= $password.unwrap %>', $vault_keypair]),
}
在介绍了用于管理遗留代码的 EPP 模板后,我们将回顾 ERB 的不同之处。
ERB 模板
ERB 模板与 EPP 模板类似,但有一些值得注意的区别。ERB 模板是包含文本和 Ruby 语言表达式的混合文本文件,表达式被标签包围。ERB 使用与 EPP 相同的标签,但它没有参数标签,并且无法传递参数。
在 ERB 中,模板具有局部作用域和父作用域,后者是评估模板的类或定义的类型。当前作用域中的变量可以使用@符号访问,这是 Ruby 通常访问变量的方式。要访问作用域外的变量,可以使用scope对象或较旧的scope.lookup函数,后者是在引入哈希格式之前在 Puppet 中使用的。
为了给出一些简单的 Ruby 示例,可以使用if语句检查exampleapp_extras变量是否不包含NONE,并在模板中输出extras <exampleapp_version>字符串。还可以使用unless语句检查exampleapp_key变量是否为nil,如果它有定义值,则输出key <exampleapp_nill>字符串:
<% if @exampleapp_extras!= "NONE" %>extras<%= @exampleapp_version%><% end %>
<% unless @exampleapp_key.nil? -%>
key <%= @exampleapp_%>
<% end -%>
Ruby 中的迭代类似,each函数也可用。以下示例显示了通过迭代将一个变量中的设置数组逐个输出到模板内容中:
<% @array_of_settings.each do |setting| -%>
<%= val %>
<% end -%>
Puppet 变量的数据将从 Puppet 类型转换为等效的 Ruby 类型(更多信息请参见官方 Puppet 文档:www.puppet.com/docs/puppet/latest/lang_template_erb.html#erb_variables-puppet-data-types-ruby)。然而,如何将这些数据转换为 Ruby 类型已经超出了本书的讨论范围。
还可以使用 <%scope.function_name(<函数名称>, <参数数组>)%> 语法在 ERB 模板中调用 Puppet 函数。
例如,要对 example_variable 变量使用 downcase 函数并将结果输出到模板,可以使用以下代码:
<%= scope.call_function('downcase', [@example_variable]) %>
验证 ERB 模板的语法可以通过运行 erb 命令来完成:erb -P -x -T '-' example.erb | ruby -c。与 EPP 一样,PDK 在运行验证时会检查两种模板。遗憾的是,无法呈现 ERB 模板。
在文件中使用 ERB 模板文件的内容看起来与 EPP 非常相似,但如前所述,它没有参数,并且使用 template() 函数。转换 EPP 示例的方式如下:
file { '/etc/exampleapp.conf':
ensure => file,
content => template('exampleapp/exampleapp.conf.erb'),
}
可以传递并评估多个模板文件,这些文件将会合并在一起。例如,更新如下内容将合并两个模板:
content => [ template('exampleapp/exampleapp.conf.erb'), template('exampleapp/exampleapp2.conf.erb')]
内联 ERB 仅使用与内联 EPP 相同系统中的inline_template函数,并且过去经常用来通过 Ruby 代码提供一种解决方案,弥补早期版本的 Puppet 缺乏迭代/循环功能,并执行数据转换。
现在既然已经讨论了 ERB,是时候突出 EPP 相较于 ERB 更受青睐的原因以及考虑转换遗留代码的理由了。
EPP 与 ERB 比较
在审查了两种语法模板后,很明显 EPP 相较于 ERB 具有多个优势。首先,EPP 的性能明显优于 ERB。每次评估模板时,ERB 会为所有事实和顶层变量创建一个作用域对象,而 EPP 仅使用与模板相关的事实和变量。在拥有大量事实的环境中,这可能会对性能产生显著影响。
此外,EPP 提供了更高的安全性,因为模板可以提供一个有限的数据范围供使用,并在使用前验证所有数据是否存在。而 ERB 则没有内建的验证,且不存在的变量会被简单地忽略。例如,如果类中的某个变量在模板使用前未被评估,ERB 将无法捕捉到这一点。
EPP 也被认为更易于使用,因为它采用 Puppet DSL 风格,并且不需要任何 Ruby 知识。这使得编码更加容易,特别是能够使用puppet epp render和validate命令。此外,EPP 正在积极开发中,最近的功能(例如,6.20 及以后版本中能够自动解开敏感变量的模板)仅在 EPP 中可用。
迭代和循环
Puppet 的迭代和循环方法受其变量不可变性的影响,这意味着一旦设置,变量就不能更改。这使得许多正常使用loop或do关键字来转换数据的方法变得不可能。在语言的早期版本中,通过传递数组给定义类型来解决这个问题,如第三章所述,或者使用带有 Ruby 代码的内联 ERB 模板来操作数组和哈希。
然而,使用定义类型方法的问题在于,执行工作的代码被抽象化了,不可见。而且,每次需要不同类型的迭代时,都需要其自定义的定义类型,这使得代码膨胀。因此,回顾旧代码并重构这些模式为接下来将讨论的方法非常重要。
在现代 Puppet 中,采取的方法是使用迭代函数将数据从数组和哈希传递给 lambda。lambda 是一个没有名字的函数,因此除非通过某个函数调用,否则无法在其他地方调用。lambda 可以附加到任何函数调用中,包括自定义函数。表 7.2提供了涉及迭代和 lambda 的完整函数列表。虽然一些函数可能不被认为是迭代器,但它们有类似的行为。还应注意,其他函数可以与这些示例组合/链接,例如unique:
| 函数名称 | 目的 | 返回类型 | 参数 |
|---|---|---|---|
all | 遍历所有元素,直到 lambda 返回false或完成并返回true。 | true或false | 1 或 2 |
any | 遍历所有元素,直到 lambda 返回true或完成并返回false。 | true或false | 1 或 2 |
break | 用于 lambda 内部,停止迭代。 | 无 | 无 |
each, reverse_each, tree_each | 依次传递哈希或数组的每个元素供 lambda 处理(逆序或递归变体)。 | 无 | 1 或 2 |
filter | 遍历所有元素并与 lambda 代码匹配,返回匹配的元素数组。 | 数组 | 1 或 2 |
index | 遍历所有元素,并在 lambda 代码中首次匹配时,返回匹配元素的索引。 | 整数 | 1 或 2 |
lest | 该函数接受一个参数;如果该值未定义,它将运行一个 lambda 并返回结果。如果参数未定义,它将返回该参数。 | 任意有效类型 | 0 |
map | 遍历所有元素并对该元素应用 lambda 代码。返回应用 lambda 后的元素数组。 | 数组 | 1 或 2 |
next | 在 lambda 中使用,用于改变迭代中下一个元素的值。 | n/a | n/a |
reduce | 遍历所有元素并应用 lambda 代码,将结果传递给每次迭代。 | 数组 | 2 |
return | 用于使 lambda 返回(不能在顶层作用域中使用)。 | n/a | n/a |
slice | 按切片大小遍历元素,例如每次迭代三个元素。 | 数组 | 1 或切片大小 |
step | 链接到另一个可迭代函数,传递一个从起始元素到结束元素按步长递增的元素序列。 | 可迭代 | n/a |
then | 接受一个参数,如果该参数不是 undefined,则调用一个 lambda 并传递该参数。否则,返回 undefined。 | 任何有效类型 | 1 |
with | 接受一个参数,并无条件将其传递给 lambda 并使用该参数运行。返回 lambda 的结果。 | 任何有效类型 | 1 |
表 7.2 – 迭代和 lambda 函数
在 Puppet 中使用 lambda 的迭代函数的基本语法结构如下:
<function acting on data> | <parameter(s)> | { lambda of Puppet code }
例如,考虑使用 each 函数对一个数组进行迭代,使用一个参数(可选类型),并打印输出:
['first', 'second', 'third'].each | String $x | { notice $x }
这将导致 notice 函数为数组中的每个字符串打印输出,类似于大多数语言中的 for 循环和 print/echo 命令。
each 函数也可以使用两个参数,第一个参数为索引,第二个参数为该索引对应的内容。以下代码将在第二次迭代时打印 index 2 contains second:
['first', 'second', 'third'].each | $index $value | { notice "index $index contains $value" }
注意
要在开发者桌面上测试这些示例,只需运行 puppet apply -e '<``example code>'。
为了明确,当在哈希上使用 each 函数并且仅使用一个参数时,每个键值对将作为一个数组传递给 lambda。例如,运行代码 [{ key1 => 'val1', key2 => 'val2' }].each | $key_pair | { notice $key_pair } 将输出两个数组,每个键值对对应一个数组:['key1', 'val1'] 和 ['``key2', 'val2']。
如果 lambda 使用两个参数,第一个参数代表键,第二个参数代表值。例如,运行代码 [{ key1 => 'val1', key2 => 'val2' }].each | $key, $value | { notice "$key contains $value" } 将输出两个字符串,每个键值对对应一个字符串:key1 contains val1 和 key2 contains val2。
还值得注意的是,其他数据类型(例如字符串)可以自动转换为数组,其中字符串中的每个字符都被视为一个元素。此外,还可以使用 Integer 类型声明数字范围;例如,运行代码 Integer[100, 150].each | Integer $number | { notice $number } 将输出从 100 到 150 的所有整数。
最后,迭代可以嵌套;例如,为了处理具有数组值的哈希,可以在 lambda 内使用迭代函数。运行以下代码将输出 key1 键值对数组中的每个值——'value1' 和 'value2':
[{ key1 => ['value1', 'value2'], key2 => 'val2' }].each | $key, $value_array | {
$value_array.each | $value | {
notice $value
}
}
总体而言,本节概述了 Puppet 中最常用的函数,但若想了解更深入的描述,用户可以参考官方文档:www.puppet.com/docs/puppet/latest/function.html。
迭代循环
到目前为止审查的主要函数是 each,还有几个函数执行元素的循环或操作循环。reverse_each 函数简单地按名称所示取元素的反向顺序。tree_each 允许返回嵌套数组/哈希中的值,并根据提供的标志采用不同的行为。它相对复杂且较为冷门。slice 函数允许我们在每次迭代中获取特定数量的元素。例如,以下代码会每次传递三个数字的数组给 lambda:
Integer[100, 151].slice(3) | Array $numbers | { notice $numbers }
在最后一次迭代时,它将提供剩余的元素;在此示例中,即一个仅包含 [151] 的数组。也可以使用多个参数,但参数的数量必须与 slice 的大小相同:
Integer[100, 151].slice(3) | Integer $first, Integer $second, Integer $third | { notice $numbers }
step 函数允许我们选择要传递的可迭代元素。在这个代码示例中,它将从第一个元素开始,然后是第四个、第七个,以此类推:
Integer[100, 150].step(3) | Integer $numbers | { notice $numbers }
这在链式调用到另一个可迭代函数时非常有用。下一个函数类型是用于匹配模式。这是一种不同风格的迭代函数,在这种方式下,迭代函数定义了 lambda 如何返回,而不是仅将元素传递给 lambda 执行某些操作。例如,all 寻找所有元素是否都满足 lambda 中的检查条件以返回 true。如果任何一个 lambda 返回 false,该函数将返回 false。例如,以下代码将输出 true,因为所有元素都大于 99:
Integer[100, 151].all | Integer $number | { $number > 99 }.notice()
any 函数与 all 相反,如果没有匹配项,则返回 false,如果在任何迭代中 lambda 返回 true,则返回 true。index 函数类似于 any,但它不是返回 true 或 false,而是返回匹配元素的索引号,若没有匹配项则返回 undef。例如,以下代码会输出 20,因为 number 会匹配到第 20 个元素:
Integer[100, 151].index | Integer $number | { $number == 120 }.notice()
所有这些函数都可以在数组或哈希上使用两个参数,如 each 函数的示例所示。
数据转换
数据转换是迭代器用于遍历元素并在返回之前进行调整的另一种方式。这也是迭代器与 lambda 模式开发的主要原因之一,因为 Puppet 无法重新赋值变量。例如,map函数会遍历每个元素,并应用一个 lambda,其结果会存储在一个数组中。例如,下面的代码会将每个元素除以1024,并返回一个数组[2, 3, 1]:
[2048, 3096, 1024].map | $size | { $size / 1024 } .notice()
filter函数在每次迭代中处理每个元素,并应用 lambda 中的代码。如果 lambda 返回true,则该元素将被添加到数组中并返回。否则,如果返回false,则会继续到下一次迭代。例如,下面的 filter 会遍历每个数组,检查其大小是否大于0,结果会输出[[1, 2, 3], ['a', 'b', 'c']],并移除第二个元素中的空数组:
[[1,2,3], [], [a,b,c]].filter | $array | { $array.size > 0 } .notice()
filter和map都可以处理一个或两个参数,如在数组和哈希上的each函数所示。
reduce函数允许在 lambda 中进行累积操作。它与其他函数不同,需要两个参数:第一个参数在迭代过程中保持其值,而第二个参数是元素。此外,可以通过传递一个值给reduce函数来选择第一个参数的初始值。在这个例子中,total参数的初始值为1,并且在每一轮中将元素加到它的总和上,最终返回并打印15:
[2, 4, 8].reduce(1) | $total, $number | { $total + $number } .notice()
在 lambda 中,也可以改变迭代的流程。next函数可以改变下一个元素是什么:如果没有传递任何值给next函数,则为undef,否则为提供给next函数的值。break函数会在代码的这一点停止迭代器,并返回到迭代函数,从而有效地结束迭代器的执行。相比之下,return函数会从迭代函数中返回,因此不会继续执行,并返回到包含类、函数或定义的类型。
为了演示这一流程的变化,第一个使用map的例子会遍历一系列数字,运行next函数,当元素等于101时,会将下一个元素102替换为1984,然后当元素大于104时会运行break函数。因此,最终会打印的输出将是一个数组[100, 1984, 102]:
Integer[100, 151].map | Integer $number | { if $number == 101 { next(1984) } if $number > 103 { break() } $number }.notice()
为了突出用return函数替换break函数后的不同行为,下面的例子将不会打印任何内容:
class example {
Integer[100, 151].map | Integer $number | { if $number == 101 { next(1984) } if $number > 103 { return() } $number }.notice()
}
这就是在这个例子中我们将函数放入类中的原因,因为return只能在类、函数或定义类型中调用,不能在顶层作用域调用。
嵌套数据
最后一类函数在处理嵌套数据或处理未定义值时非常有用。then 会在 lambda 后链接,如果它从该 lambda 输出 undef,则返回 undef;否则,它会将该值传递给另一个 lambda。所以,以下示例将使用 dig 函数来尝试访问数组中第二个哈希表中不存在的 c 元素,由于 then 将接收到 undef,它因此会返回 undef:
$example = {first => { second => [{a => 10, b => 20}, {d => 30, e => 40}]}}
$example.dig(first, second, 1, c ).then |$x| { $x / 10 }.notice()
为了澄清前面的语句,如果将 dig(first, second, 1, d) 改为 dig(first, second, 2, d),它将把 30 传递给 lambda,该 lambda 将除以 10 并打印出 3。
lest 是 then 的反义词,并返回其定义的值;否则,它会将 undef 传递给 lambda,这样就可以采取诸如设置默认值等操作。这在与 then 一起使用时非常有用。以先前的示例为例,添加 lest 将允许在值为 0 时返回 undef:
$example.dig(first, second, 1, c ).then |$x| { $x / 10 }.lest() || { 0 } .notice()
with 函数有些特殊,因为它用于通过 lambda 传递值,如果我们的 lambda 能处理 undef 或已定义的值。
因此,经过对各种函数的审查,以及数据转换和数据探索的可能性,值得再次强调,与过去使用定义类型的方式不同,当我们需要创建多个资源时,应该使用另一种方法。所以,例如,要为请求的多个实例创建目录,可以使用以下代码:
Integer[1,$instance_number].each |Integer $id | {
file {"/opt/app/exampleapp/instance${id}":
ensure => directory,
}
}
在讲解了 Puppet 如何执行循环和迭代后,接下来将回顾条件语句。
条件语句
Puppet 具有你在任何语言中都能预期的条件语句,if、unless 和 case 允许根据事实或来自外部源的数据等不同因素使代码行为有所不同。此外,Puppet 还使用选择器,这类似于 case 语句,但返回一个值,而不是在结果上执行代码。
if 和 unless 语句
if 语句遵循特定的语法,其中包括 if 关键字,后跟条件,一个左花括号({),当条件为 true 时要执行的 Puppet 代码,最后是一个右花括号(})。
以下示例是对 example_bool 中布尔值的简单检查,如果它包含 true,则打印一条通知:
if $example_bool {
notice 'It was true'
}
这可以通过在if语句的闭括号(})后可选地添加 else 关键字,并使用左花括号({)结合 Puppet 代码来执行,当条件为 false 时。然后再用右花括号(})结束。如果要在 example_bool 为 false 时也打印,则代码更新如下:
if $example_bool {
notice 'It was true'
}else{
notice 'It was false'
}
类似地,要一起执行多个if检查,可以在if语句的闭括号(})之后使用elsif关键字,允许再次使用相同的if语法。可以根据需要进行嵌套和重复。举个例子,在布尔值检查后,使用elsif,我们可以添加第二个检查,查看值变量是否大于2,如果是,则打印一条通知,并使用else语句打印两个条件都是false的情况:
if $example_bool {
notice 'It was true'
}elsif $value > 2{
notice 'It was false and value is greater than 2'
}else {
notice 'It was false and the value was 2 or less'
}
unless语句只是if语句的反义语句。它允许你避免对条件进行取反,并且它也可以与if语句结合使用。然而,它没有与elsif相对应的部分,如果使用将导致编译失败。以之前的if示例为例,可以改用unless语句来检查example_bool是否为false,如果是,则打印一条通知:
unless $example_bool {
notice 'It was false'
}else{
notice 'It was true'
}
条件中使用的任何非布尔值将根据数据类型规则转换为布尔值,如在第四章中所述。
Puppet 风格指南建议,在if和unless关键字之后的代码行应该缩进两个空格,并与示例中显示的对齐。
case语句
case语句通过匹配控制表达式输出的值来工作。通常这是事实或变量的内容,但它也可以是一个表达式或函数。该格式以case关键字开始,后跟一个控制表达式,解析为一个值,并用大括号括起来。每一行以匹配的 case 或逗号分隔的 case 列表开始,后跟冒号,然后是要应用于匹配 case 的 Puppet 代码,代码用大括号括起来。case语句最后以大括号结束。
例如,要测试hardwareisa事实的值并根据使用的处理器架构类型包含一个配置文件,可以使用以下代码。它包括sparc或powerpc值的 Unix 配置文件,i686和i386值的 Linux 32 位配置文件,任何以64结尾的值的 64 位配置文件,以及任何无法匹配到某个情况的值的默认配置文件:
case $facts[' hardwareisa'] {
'sparc', 'powerpc': { include profile::unix}
'i686', 'i386': { include profile::linux::32bit}
/(*64)/: { include profile::linux::64bit}
default: { include profile::default }
}
Puppet 风格指南建议始终使用default case,它可以是一个失败的情况,甚至只是一个空的大括号。
选择器
选择器类似于case语句,但它们不会应用 Puppet 代码,而是返回一个值。选择器可以在期望值的任何地方使用,如变量赋值、资源属性和函数参数。Puppet 风格指南建议仅在变量赋值中使用选择器以提高可读性,但在遗留代码中,它也可以在资源属性中看到,因为它曾是一个流行的模式。
选择器的语法是一个控制表达式,解析为一个值,一个问号(?)和一个左花括号({)。然后它有多个匹配案例,从一个单独的案例或default关键字开始,后跟一个哈希火箭符号(=>),返回的值,最后是一个闭合的逗号。选择器以一个右花括号(})闭合。
以下示例展示了如何根据os.family事实的输出分配apache_package_name变量,使用httpd作为 Red Hat 的名称,apache2用于 Debian 或 Ubuntu,apache-httpd用于 Windows,如果没有匹配,则默认为httpd。然后,包资源可以使用这个名称来安装相关包:
$apache_package_name = $facts['os']['family'] ? {
'RedHat' => 'httpd',
/(Debian|Ubuntu)/ => ' apache2 ',
'Windows' => 'apache-httpd',
default => 'httpd',
}
package { $apache_package_name }
和case语句一样,Puppet 风格指南建议选择器始终使用default案例,这可以是一个失败,甚至只是一个空的花括号。
捕获变量
如果使用的case语句是正则表达式,则所称的捕获变量将在相关代码中作为数字变量(如$1和$2)可用,整个匹配项则可通过$0访问。
要修改Case 语句部分中的示例,如果匹配的是amd64,这将包括profile::linux::amd64:
/(*64)/: { include profile::linux::${1} }
在回顾了模板、条件语句、迭代和循环的各个方面后,我们将通过一个实验来总结并结合这些概念。
实验 – 创建并测试包含循环和条件的模板
在这个实验中,我们将把你在本章中看到的所有内容结合起来,测试和验证一些示例模板,并创建一个包含逻辑和迭代的模板:
-
在
github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/tree/main/ch07/templates_to_check下载模板文件,并验证和解析它们以确保模板生成以下内容(<>显示需要插值的部分):-
“此模板在具有<#你机器的 cpu 数量>个 cpu 的机器上运行”
-
“自定义事实包.lab 已被<设置为事实的内容或字符串‘未设置’>”
-
提示
你会想使用getvar函数来测试事实,并通过传入哈希来测试它,在解析时设置它以进行测试。
-
“系统运行时间是<仅显示天、小时和分钟,如果它们非零>”
-
“此机器是<不是/是>虚拟的<并且运行在<$virtual>上”
- 创建一个模板,打印以下内容
“这是一个机器,运行版本” “以下目录在路径中<列出每个路径>”
提示
使用 split 函数 (www.puppet.com/docs/puppet/latest/function.html#split) 将路径事实字符串分割成可以迭代的数组,并记住,Windows 路径是通过 ; 分隔,而基于 Unix/Linux 的路径则通过 : 分隔。参见 github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch07/2_sample.epp 中的答案。
总结
本章审视了在 Puppet 中使用模板。展示了 Puppet 的两种模板类型——EPP 和 ERB——它们以类似的方式工作,通过将纯文本和代码周围的标签混合,允许 Puppet/Ruby 应用逻辑和变量,并在评估时创建更复杂的内容。文中警告,在使用模板代替诸如 file_line 等函数或单独控制资源之前,应仔细考虑复杂性的层级。此外,由于缺乏用于单行或文件设置控制的函数,模板已被过度使用,遗留代码应仔细审查,以确保模板是正确的复杂性级别。
显示了 EPP 是推荐的模板生成方式,因为它是用 Puppet 语言编写的,且对 Puppet 开发者来说更容易学习。它也更安全,因为可以通过参数限制其作用域,并且由于它仅为所需的变量和事实创建作用域,因此性能也更好;而 ERB 每次使用模板时,都会为所有事实生成一个作用域。此外,还提到所有 Puppet 的未来开发工作都将集中在 EPP 上,例如 EPP 文件中自动解包敏感变量的功能,且渲染模板的能力仅在 EPP 中可用。
epp 和 template 函数显示可以通过文件引用和评估模板,其中多个文件可以组合在一起。同时也展示了通过 inline-template 或 inline-epp 函数可以实现内联模板,这样文本可以直接传递给函数,而不需要存储在文件中。
然后展示了迭代和循环,强调了 Puppet 之前由于 Puppet 变量的不可变性,使得更传统的loop关键字变得不切实际。相反,Puppet 被展示为使用对数组和哈希的迭代函数,将值作为参数传递给 lambda 函数。这些没有命名的 lambda 函数只能通过其他函数调用,允许创建完全局部于 lambda 函数的作用域,因此允许变量名被重用。迭代函数选择如何将值传递给 lambda,例如每次传递一个值或键值对,或者使用Reduce,它允许在每个 lambda 函数中传递一个参数,连同每个值和键值对一起传递,并且可以用于执行累积转换。
然后讨论了 Puppet 的条件逻辑,显示其与大多数其他语言相似。if 检查会将检查评估为布尔值语句/比较,如果为true,则使用 Puppet 代码进行操作。else 关键字允许在布尔值为false时执行某个操作,elsif 关键字允许将多个检查链式连接在一起。unless 被显示为 if 的反义操作,当检查为负时进行操作,并允许 else 在检查为 true 时执行,尽管它没有 elsif 的等价项。
然后讨论了 case 关键字。我们展示了它是通过获取值并将其与某个匹配项进行匹配,基于匹配结果运行 Puppet 代码,或者如果没有找到匹配值,则执行默认操作。selector 关键字的行为类似于 case 语句,但它用于分配一个值,而不是运行 Puppet 代码。需要强调的是,尽管过去在资源中使用选择器是一个常见模式,但这已不再被认为是最佳实践。最后,捕获变量作为可用于条件语句的变量被展示,它们通过正则表达式显示匹配结果。
现在已经回顾了 Puppet 核心语言,是时候学习如何构建我们迄今为止使用的 manifest 文件和类了。下一章将展示模块如何提供必要的结构来容纳我们所研究的 manifest、类以及其他配置和实现文件。还将探讨角色和配置文件模式,它提供了一种额外的抽象,用于表示客户的技术堆栈和业务需求。此外,将演示 Puppet Forge 作为一个模块来源,可以通过它减少开发需求,并与 Puppet 社区协作以增强现有代码。
第八章:模块的开发与管理
在审视了 Puppet 语言的许多方面后,我们可以清楚地看到,仅使用清单文件和类是不够的,随着代码库的增长,无法提供所需的结构,特别是在需要支持多种服务器和客户需求时。在本章中,我们将回顾在大规模创建 Puppet 代码时所需的组件。我们将讨论 Puppet 模块,它们允许我们将代码和数据捆绑在一起,专注于单一的技术实现,从而使得与其他实现共享和结合变得更加容易。接着,我们将探索 角色与配置文件方法,展示如何通过配置文件将模块组合在一起,创建技术栈和角色,然后通过组合配置文件来满足业务需求。之后,我们将介绍 Puppet 开发工具包(PDK),展示如何通过它自动化创建和管理模块的过程。我们将展示 PDK 模板化的目录和文件,重点介绍其内置的验证、代码检查和单元编译检查。接下来,我们将介绍 Rspec,一种扩展方法,用于提供更为彻底的单元测试,以及用于服务器测试的 ServerSpec,并进行概述。然后,我们将讨论 Puppet Forge 目录,它作为 Puppet 官方、供应商和社区成员开发模块的来源。我们将展示如何过滤模块的各个方面,以了解它们的支持情况、与操作系统和 Puppet 版本的兼容性,以及评分/扫描评分,从而帮助你选择最适合组织需求的模块。
本章我们将讨论以下主要内容:
-
什么是模块?它包含哪些内容?
-
角色与配置文件方法
-
PDK 及如何编写和测试模块
-
使用 RSpec 与 PDK 进行测试
-
理解 Puppet Forge
技术要求
本章不需要部署任何基础设施。所有操作都可以从开发者的桌面进行。
什么是模块?它包含哪些内容?
模块为我们提供了一种将代码和数据进行分组的方式,使得共享和重用特定技术实现的代码变得更加容易。几乎所有的 Puppet 代码都将存储在各种类型的模块中。你应该明确模块的范围,以创建具有单一明确职责的专注模块。如果你正在部署 LAMP 或 WAMP 堆栈,你不会创建一个包含所有组件的单一模块;而是会将其拆分成多个独立的模块,包括操作系统设置、MySQL 和 Apache。这种方式可以更好地重用代码,并减少单个模块的复杂性。
模块是一个目录,其命名规则类似于类,因此必须以小写字母开头,并且只能包含小写字母、数字和下划线。与类不同,模块不能嵌套,也不使用 :: 符号。保留字和类名不应作为模块名使用。
模块具有一种目录结构,使 Puppet 能够知道各种类型的代码和数据将存储在何处,并按请求自动加载。如第六章所述,该模块将创建一个作用域命名空间和文件服务命名空间。核心代码和数据存储在以下目录中:
-
data:包含模块化数据,用于参数默认值,相关内容将在第九章中讲解。 -
examples:包含如何声明模块类和定义类型的示例。 -
files:包含可以由 Puppet 放置的静态文件内容。 -
manifests:包含模块的所有清单以及提供结构的目录。 -
template:包含将由 Puppet 代码使用的 EPP 和 ERB 模板文件。 -
tasks:包含程序化工作的任务。将在第十二章中讲解
模块还有一种叫做插件的功能,可以将各种自定义的 Puppet 组件分发到 Puppet 服务器或代理端,具体取决于相关情况。以下是一些插件示例:
-
lib/facter:由Ruby编写的自定义事实,在代理端使用 -
lib/puppet/functions:由 Ruby 编写的自定义函数,供服务器使用 -
lib/puppet/type:在服务器和代理端都可以使用的自定义资源类型 -
lib/puppet/provider:由 Puppet 编写的自定义资源提供者,服务器和代理端都可以使用 -
lib/augeaus/lenses:在代理端使用的自定义 Augeas 镜头 -
facts.d:在代理端使用的外部事实或静态脚本 -
functions:由 Puppet 编写的自定义函数,供服务器使用
需要注意的是,某些插件类型,例如资源类型,并没有在环境中完全隔离。环境将在第十一章中详细讨论,我们将重点讨论分类和发布管理,但现在需要注意的是,环境允许通过节点使用隔离的代码库,从而使用不同版本的代码。这是由于 Ruby 加载第一个资源类型并将其设置为全局,忽略任何发现的重复项。因此,如果使用包含无法隔离插件的模块,请务必查阅 Puppet 文档:puppet.com/docs/puppet/latest/environment_isolation.html#environment_isolation。如果需要,您可以配置环境隔离,Puppet Enterprise 默认提供环境隔离。
模块可以以不同的方式使用。尽管本章的大部分内容将集中在使用 Puppet 代码进行配置的模块上,但模块也可以用于分发一个或多个项目。例如,Puppet Forge 中的 PowerShell 模块(forge.puppet.com/modules/puppetlabs/powershell)仅用于通过提供者插件目录分发新的执行提供者。
关注manifests目录时,清单文件将与其包含的类名相同。一个主要的例外是主清单,它命名为init.pp,但其类名为模块的类名。这个主清单通常用作模块的入口点。如第六章中讨论的那样,为模块创建了一个模块命名空间,允许我们通过运行include <module name>来在代码中包含该模块。
类应该是自包含的并且简洁,专注于一个方面。识别类是否过大的一个常见建议是,当它太大,以至于无法在单个编辑器屏幕中查看时,就是过大了。考虑到这一点,开始使用模块时最常见的模式之一是使用主清单init.pp作为入口点,接受参数以供整个模块使用。然后,它调用其他类并设置它们的顺序。例如,使用install类来安装资源(如包),使用config类来添加任何配置文件或用户,以及使用service类来管理服务。以下代码展示了这一模式的主清单示例:
class exampleapp (
Boolean $package_managed = true,
Integer $package_version = 3,
Boolean $user_managed = true,
Integer $user_id= 10,
Boolean $service_enable = true,
Integer $jmx_heap_size = 1024,
Integer $thread_number = 10,
)
{
contain exampleapp::install
contain exampleapp::config
contain exampleapp::service
Class['exampleapp::install']
-> Class['exampleapp::config']
~> Class['exampleapp::service']
}
考虑到可用的参数,如模块的公共 API,可以使模块具有灵活性;此外,命名这些参数时应该保持一致性。在这里,我们使用一种方法,根据参数的作用来命名参数。因此,对于exampleapp,可以看到包和用户都采用布尔值来声明模块是否将其作为资源进行管理。布尔值用于service_enable来决定服务是否在启动时启用,而user_id和package_version则使用整数。接着,使用两个额外的整数来配置应用程序,设置 Java 内存大小和线程数。这些参数可以通过模块命名空间访问,并通过在modulename::variablename处执行数据查找。此方法被称为自动参数查找,我们将在回顾 Puppet 如何处理数据时,在第九章中详细讲解。
注意
模块的参数及其他方面可以通过代码头部的注释进行文档化,然后使用 Puppet Strings gem 生成不同格式的文档。详细信息可以在 Puppet Strings 风格指南和以下网页中找到:puppet.com/docs/puppet/latest/puppet_strings_style.html。
我们采用在模块内对类进行包含和排序的方法。这确保了在请求时,先安装软件包,添加配置,再管理或刷新服务,因为每个阶段都依赖于下一个阶段。contain 关键字不应被视为替代 include 的默认关键字;它应仅在组件模块风格下使用,并且类只会在主类中使用。在 角色和配置方法 部分,你会看到类似的包含和排序在某些情况下是不合适的。
从中可以看出,这些子类是如何使用来自主清单的参数的。例如,install 清单对 package_managed 变量使用 if 语句;如果它为 True,则安装由 package_version 变量设置的 exampleapp 包版本:
class exampleapp::install {
if $exampleapp::package_managed {
package { 'exampleapp':
ensure => $exampleapp::package_version
}
}
}
对于 config 清单,我们可以看到如何通过使用模块命名空间将 jmx_heap_size 和 thread_number 变量替换到模板中,并通过该命名空间访问存储在 exampleapp 模块中的模板:
class exampleapp::config {
file { '/etc/exampleapp/app.conf':
ensure => file,
content => epp('exampleapp/app.conf.epp', {' jmx_heap_size ' => $exampleapp::jmx_heap_size , ' thread_number' => $exampleapp::thread_number }),
}
}
service 类的风格与 install 类非常相似。它使用 if 语句;如果条件为 True,则添加 exampleapp 服务,并根据 service_enable 变量设置 enable 参数:
class exampleapp::service
if $exampleapp::service_managed {
service{'exampleapp':
ensure => 'running',
enable => $exampleapp::service_enable ,
}
}
}
注意
一个常见的模块模式是使用 params.pp 文件来管理默认的模块数据。现在,Hiera 5 能比清单文件以更结构化的方式管理模块级数据,具体内容将在 第九章 中展示。params.pp 文件仍然常见于代码中,特别是在数据结构简单且没有太大意义改变为 Hiera 的情况下。
examples 目录中可能包含一个名为 init.pp 的文件,指定如何调用类:
class { 'exampleapp':
package_managed => true,
package_version => 3,
user_managed => true,
user_id => 10,
service_enable => true,
jmx_heap_size => 1024,
thread_number => 10,
}
在 examples 目录中,文件的命名并不重要;可以有多个不同的示例,展示不同设置下常见模块属性的选择。例如,一个模块可能展示如何使用最小默认值包含它,也可能展示特定架构所需的属性,例如在集群设置中进行部署。
随着配置用例变得更加复杂,模块结构的另一种常见方法可以在 Puppet Forge 目录中的 Apache 模块中看到:forge.puppet.com/modules/puppetlabs/apache。与基于简单 package 和 config 类的资源分组不同,apache 模块将应用程序的不同组件进行了拆分。在此示例中,Apache 的主清单执行了 Apache 的默认安装,具有默认虚拟主机和文档根目录,并启动了 Apache 服务。可以通过使用相关模块参数来配置此操作。在这里,Apache 服务由单独的类管理,但通常在 package 和 config 类中管理的资源是在 Apache 主类中管理的。还有各种实现类,例如 vhosts、mod 和 mpm,用于不同的 apache 配置项。这使得主类具有执行基本安装和配置 apache 服务器的明确目的,以便实现类可以专注于特定的定制。例如,vhosts 类是定义类型,可以为 apache 服务器所需的每个虚拟主机定义。
这些示例提供了一个结构,您可以根据需要为您的模块进行调整。但是,需要记住的关键教训是,模块应专注于单一任务,仅管理其资源(无跨模块依赖),并且应该是细粒度且可移植的。
在本节中,我们查看了 Puppet 模块的目录和文件结构,以及创建模块的两种常见模式。这些模块本身配置了专注于个别技术实现。在下一节中,我们将看到如何使用模块的结构来组合模块以生成技术堆栈,并通过组合技术堆栈和配置,满足客户对服务器的业务需求。
实验室 - 审查 Apache 模块
在本书中打印 apache 模块代码的所有关键部分是不切实际的。但是,查看 forge.puppet.com/modules/puppetlabs/apache 上的代码并阅读 examples 目录,以了解如何将主类 apache 与模块内的各种类结合起来配置不同组件,将帮助您理解如何结构化此模块模式。
角色和配置文件方法
在上一节中,我们讨论的模块是所谓的组件模块,因为它们覆盖单一实现。这些模块主要引起那些直接参与技术实现的用户的兴趣,例如 Unix 或 Windows 管理员,在他们看来,理解哪些特定资源已被应用是配置中最重要的方面。但不同的用户并不关心节点如何配置;他们关心的是它的作用。例如,一个应用程序专家关心的是,Tomcat 和 MySQL 已为他们的应用程序安装,而不关心如何配置。项目经理关心的是他们获得了一台满足业务需求的服务器,而不关心使用了哪些技术栈。项目经理也可能认为每个实现都是独特的,但通常会有很多相似之处,例如在多个应用程序中使用 Apache 或 Java 的不同技术栈,并根据位置或环境的不同而有所不同的设置。
如果没有为这些逻辑层次提供某种结构,将这些模块应用于节点将需要大量的重复和复杂的if语句。
一种名为角色和配置文件的模式采用模块化结构来实现这一点。角色和配置文件不是关键字,而只是模块和类中使用的模式。一个简单的方法是有一个名为role的模块和一个名为profile的模块,模块中的每个类表示一个角色或配置文件。
这些角色代表了客户(如项目经理)所需的业务需求,而配置文件则反映了应用程序的技术栈。
在将角色和配置文件方法应用于现有应用程序的配置时,重要的是从角色开始到模块结束,避免自然地首先寻求技术解决方案而忽视业务逻辑。这个过程涉及将事物拆解成组件,并且要具体思考它是什么,而不是它看起来像什么。识别角色的一个技巧是使用主机名,主机名通常包含关于位置、环境使用和应用程序的信息。例如,主机名可能看起来像fk1ora005prd,其中fk1是数据中心的位置,005是一个编号,prd是生产环境,而ora则是 Oracle 应用程序,它与角色的名称相匹配。因此,角色应该是业务名称,例如buildserver、proxyserver或ecomwebserver,而配置文件应该是技术栈的名称,例如 Apache、Jenkins 或 nginx。
这种命名并不总是完美的,有时其中一些术语可能只是项目经理用来订购 Oracle 服务器的术语。他们可能没有意识到 Oracle 角色背后的配置文件,这些配置文件包括一个 Oracle 配置文件和其他相关的配置文件。
在这种情况下,role 类应简单地调用所需的配置文件,不带变量,如下所示:
class role::exampleapp {
include profile::core
include profile::java
include profile::apache
}
相反,profile 类应包含参数,以定制模块和类声明,进而添加所需的模块:
class profile::java(
Pattern[/present|installed|latest|^[.+_0-9a-zA-Z:~-]+$/] $java_version
String $java_distribution
) {
class { 'java':
version => $java_version,
distribution => $java_distribution,
}
}
在 第九章 中,涉及到 Puppet 和数据,你将看到 Hiera 如何对覆盖配置文件默认值的数据建模。每个服务器应该只拥有一个角色;如果需要两个角色,那么它本身就是一个新角色,但一个角色会有多个配置文件。图 8.1 展示了使用角色、配置文件和模块的简单示例,以及这些类如何相互包含。在这个设置中,正如我们稍后在 第十一章 中看到的那样,对主机进行分类就像确保正确的角色被分配到节点一样简单:
图 8.1 – 角色、配置文件和模块的示例
上图中显示的框架完全是关于抽象的,因此我们将业务逻辑、实现和资源管理解耦,并减少节点级别的复杂性。
这种模式不是强制要求,而是提供了一些如何构建代码的建议,以避免重复并提供一种模型。在这种情况下,可以考虑几种适应性调整。
使用复杂度升级允许我们在最初代码较少时不创建过多结构。如果配置文件中只有少量资源,那么保持这种方式并在变得复杂时扩展到模块可能更容易。
根据你所在组织的变更管理和交付要求,可能有多个配置文件或角色模块,以便实现更细粒度的控制和访问——例如,teama_profiles 和 teamb_profiles。
正如在 第三章 中讨论的那样,一般不建议在 Puppet 代码中使用继承,但通过在目录中分组清单(例如,在 profile 中创建 exampleapp 目录,并创建 client.pp 和 server.pp 来表示服务器和客户端版本(分别为 profile::exampleapp::server 和 profile::exampleapp::client))来扩展 profile 模块的命名空间可能是值得的。这也可以针对特定的操作系统进行。在考虑这种方法之前,请注意,这种结构是一个边缘情况,使用继承时风险较大。
如果发现配置文件在变化的技术栈中过于死板,或者采用遗留服务器意味着必须丢弃配置文件或角色的某些部分,那么使用参数使配置文件更具动态性,可以通过配置文件类的参数(无论是通过 Hiera 还是默认值)来定义类。
作为一个简单的示例,以下代码使用 include_classes 和 exampleapp 模块中列出的默认值:
class profile::exampleapp(
Array[String] $include_classes = ['exampleapp'],
) {
include $include_classes
}
这样我们就可以覆盖 Hiera 或配置文件中的 include_classes 数组。通过只允许来自某个特定模块的类,我们可以使包含更加严格:
class profile::exampleapp(
Array[String] $include_classes = ['server'],
) {
$modules = $include_classes.map | String $module | {
"exampleteam_exampleapp::${profile}"
}
include $modules
}
为了为参数增加更多结构,并在审批和代码审核过程中使其更加清晰,可以进一步细分类参数。在这里,我们可以添加默认、必需、附加和剔除数组,从而提供完全的灵活性:
class profile::exampleapp(
Array[String] $include_default = ['my_default'],
Array[String] $include_mandatory = ['my_base_profile'],
Array[String] $include_additional = ['my_test_default_profile'],
Array[String] $include_removal = ['my_default'],
){
$profiles = $include_default + $include_mandatory + $include_additional + $include_removal
include $profiles
}
通过限制多个命名空间,并为每个命名空间创建类数组的列表,可以进一步混合这一模式。这将取决于采用什么方法,能够为组织提供足够的灵活性,同时明确代码会影响什么内容以及谁应进行审核。
通过这种方法,也许可以通过参数定义一个 noop 标志,并在资源上执行 noop 操作。你也可以通过 forge.puppet.com/modules/trlinkin/noop 中的 noop 函数来实现,让模块在被接受之前处于 noop 模式。
这些模式的调整更加复杂,需要读取 Hiera 数据以理解角色和配置文件所代表的含义,但最终的决定将取决于贵组织,选择哪种方法最适合。虽然通过使用严格的角色和配置文件来减少变化是理想的,但如果没有适当的管理方法,这可能会导致采用上的阻力或遗留问题。
在审视了角色和配置文件模式所能创建的模块结构及其内容后,我们可以看到,这需要大量的内容来手动管理,通过创建文件并管理各种测试工具。下一部分将讨论如何通过使用 PDK 来自动化模块创建和测试的生命周期。
使用 PDK 编写和测试模块
PDK 的引入旨在减少一致性地创建模块目录和文件的工作量,同时将一些常用的测试和验证工具组合在一起。我们将回顾 PDK 版本 2.7.1,这是写作时可用的最新版本。PDK 安装了自己的 Ruby gems 和环境,以提供以下工具:
| Ruby Gem 名称 | Ruby Gem 功能 | 项目页面 |
|---|---|---|
metadata-json-lint | 验证语法并根据样式指南检查 metadata.json | github.com/voxpupuli/metadata-json-lint |
pdk | 生成模块及模块内容,并使用自动化测试命令 | github.com/puppetlabs/pdk |
puppet-lint | 根据 Puppet 语言样式指南检查 Puppet 清单代码 | github.com/puppetlabs/puppet-lint |
puppet-syntax | 检查 Puppet 清单、模板和 Hiera YAML 的语法是否正确 | github.com/voxpupuli/puppet-syntax |
puppetlabs_spec_helper | 提供必要的工具,以便在不同版本的 Puppet 中进行测试 | github.com/puppetlabs/puppetlabs_spec_helper |
rspec-puppet | 编译 Puppet 代码并使用 Puppet 特定实现的 Ruby RSpec 测试预期行为 | github.com/puppetlabs/rspec-puppet |
Rspec-puppet-facts | 提供一种方法,通过 facterdb 的输出为支持的操作系统提供事实数据 | github.com/voxpupuli/rspec-puppet-facts |
facterdb | 提供不同操作系统和不同 Facter 版本的事实输出示例 | github.com/voxpupuli/facterdb |
表 8.1 – PDK gem 列表
关于 PDK 的常见误解是,它在打包和安装这些工具。实际上,它正在为每个创建的模块运行 bundle install。之后,PDK 缓存被保存,看起来像是 PDK 正在打包这些工具。
使用 表 8.1 中讨论的 gem,PDK 可以生成以下内容:
-
包含完整模块骨架、元数据和 README 模板的模块
-
类、定义类型、任务、自定义事实、函数和 Ruby 提供者
-
类和定义类型的单元测试模板
PDK 会执行 lint 检查样式和最佳实践,并对以下内容进行语法验证:
-
metadata.json文件;有关详细信息,请参见 表 8.2 -
针对特定 Puppet 版本的 Puppet 清单文件(
.pp) -
针对特定 Puppet 版本的 Ruby 文件(
.rb) -
EPP 和 ERB 模板文件
-
Puppetfile和environment.conf,它提供了环境的模块列表及其环境设置,具体内容将在 第十一章 中讨论 -
YAML 文件
PDK 在模块和类上运行 RSpec 单元测试。有关详细信息,请参阅 使用 PDK 的 RSpec 测试 部分。
PDK 具有构建和发布命令,可以生成 .tar 文件,用于上传到 Puppet Forge 和 Puppet 调试控制台。
要创建一个模块,执行 pdk new module 命令(可选地在末尾加上模块名称)。回答关于模块名称的问题(如果没有提供模块名称,则指定你的 Puppet Forge 用户名,若有的话),模块的作者,代码应遵循的许可协议,以及支持的操作系统。此过程可以在以下截图中看到:
图 8.2 – pdk 新模块问题
对用户详细信息和许可协议提供的答案将作为默认值,在以后运行时提供,并可以通过运行 pdk get config 并检查 user.module_defaults 设置来查看。
注意
在引入 PDK 之前使用的 puppet module generate 命令,在 Puppet 5 中已弃用,并在 Puppet 6 中被移除。
一旦输入并确认了答案,将创建一个包含模块名称的目录。该目录将包含先前在 模块是什么及其内容 部分中讨论的以下内容目录:
-
data -
examples -
files -
Manifests -
spec -
tasks -
templates
使用默认的内建模板后,它将创建以下额外的配置文件和目录:
| 文件/目录名称 | 文件/目录用途 |
|---|---|
appveyor.yml | Appveyor CI 集成配置文件 |
CHANGELOG.md | 可维护的变更日志 |
.``devcontainer | 配置容器以测试此模块的方式 |
.``fixtures.yml | 测试模块依赖配置 |
Gemfile | Ruby gem 依赖 |
Gemfile.lock | Ruby gem 依赖 |
.``gitattributes | 将属性和行为与文件类型关联 |
.``gitignore | Git 应忽略的文件 |
.``gitlab-ci.yml | 用于 GitLab CI 的示例配置 |
metadata.json |
- 创建时填写的元数据,包括问题回答
|
.``pdkignore |
|---|
- 用于构建 Puppet Forge 包时忽略的文件
|
.``puppet-lint.rc | puppet-lint gem 的配置 |
|---|---|
Rakefile | Ruby 任务配置 |
README.md | 模块的 README 页模板 |
.``rspec | rspec 单元测试的配置默认值 |
.``rubocop.yml | Ruby 风格检查设置 |
/``spec | 包含 rspec 单元测试文件的目录 |
/``spec/default_facts.yaml | 所有测试都可用的默认事实 |
/``spec/spec_helper.rb | rspec 的入口脚本,设置各种配置 |
.``sync.yml | 自定义正在使用的 PDK 模板的文件 |
.``vscode | VSCode 配置,例如推荐的扩展 |
.``yardopts | Puppet Strings 配置文件 |
表 8.2 – PDK 默认模板文件和目录
对于已有的模块,也可以运行 pdk convert 将模块适配到模板中。它会在应用更改之前确认将要做出的修改。
PDK 内容的大小随着时间的推移而增长,默认模板可能包含许多未使用的文件。可以通过从 github.com/puppetlabs/pdk-templates 进行 fork 并按照 README 文件的指导调整模板,来创建自定义模板。然后,可以通过在新模块或转换命令中使用 --template-url 来使用该模板。此外,.sync.yml 文件可以设置为删除、不受管理或更改设置。以下的 .sync.yml 文件示例将设置 .gitlab-ci.yml 文件,使其不包含在模块中。它将确保 .vscode 目录不受 PDK 模板管理,从而避免将来的更新。它还将禁用旧版事实(事实的全局变量,详细介绍见 第五章):
common
disable_legacy_facts: true
.gitlab-ci.yml
delete: true
.vscode
Unmanaged: true
可以调整的完整设置已在 PDK 模板的 README 文件中进行了文档化:github.com/puppetlabs/pdk-templates/blob/main/README.md。
注意
如果需要在多个现有模块中进行配置更改,可以使用 modulesync 模块来管理此操作。它可以通过以下网页获得:github.com/voxpupuli/modulesync。
现在我们已经详细描述了 PDK 模块及其工具的内容,接下来我们将描述开发模块的工作流程,如 图 8.3 所示。
¶¶¶
图 8.3 – PDK 工作流程
如前所述,通过运行 pdk new 创建新模块或在未受控模块上运行 pdk convert,可以建立一个初始的 PDK 内容模块及其设置。使用 pdk update,可以更新模块的配置,因为我们可以更改其设置、提供新的模板或更改 PDK 版本。
下一步是添加任何需要的新内容文件。这可能包括 class、defined_type、fact、函数提供者、任务或传输,可以通过使用 pdk new 命令和相关内容来完成。这将使用所选内容的模板和一个 rspec 测试文件创建一个文件。对于 PDK 无法提供的内容,例如外部 facts 或计划,必须手动创建文件和测试。
一旦文件和内容测试就位,应该添加你的代码。可以通过定期运行pdk validate命令来验证和测试代码,这个命令会检查代码的代码风格和语法解析。此命令也可以与-a标志一起使用,该标志会尝试自动修正任何错误。对于代码风格错误,可以通过使用内联注释或通过在代码区域周围加注释lint:ignore:<rule name>来忽略文件某部分的特定检查。以下示例展示了如何设置某一行忽略 140 字符的代码风格规则。在这种情况下,该段代码会忽略双引号检查,双引号只应在同时使用字符串和变量进行变量赋值时使用:
$long_variable_text = "Pretend this is more than 140 characters" # lint:ignore:140chars
# lint:ignore:double_quoted_strings
$variable1 = "don't do this"
$variable2 = "this is just a simple example"
# lint:endignore
如果必须在所有代码中忽略该检查,可以通过添加类似--no-selector_inside_resource-check的标志更新.puppet-lint.rc文件,以确保puppet-lint不执行检查,确保选择器代码位于资源内部。可以在github.com/puppetlabs/puppet-lint/tree/gh-pages/checks查看puppet-lint检查的完整列表。请注意,尽可能避免禁用检查,因为这可能会影响模块评分或影响模块是否能在 Puppet Forge 获得认证。这会使你的代码远离推荐的 Puppet 实践。
注意
puppet-lint.com/checks/ 不是 Puppet 公司所有,并且已经过时。Puppet 将该模块分支到github.com/puppetlabs/puppet-lint,因此建议使用puppetlabs.github.io/puppet-lint。
一旦pdk validate成功运行,可以执行pdk test unit命令来进行单元测试。模板提供的检查是基础性的,旨在检查代码是否能正常工作;对于 Puppet 代码,它确保代码能够编译。一个强大的功能是,通过使用--puppet-version标志或--pe-version,检查可以针对特定的 Puppet 或 Puppet Enterprise 版本进行运行——例如,pdk test unit –pe-version=2019.11——这样可以在升级之前进行代码测试。在下一部分,你将学习如何进一步扩展rspec检查。
Puppet Forge 将在本章最后详细讨论。本书不涉及发布到 Puppet Forge 的内容,但如果要将代码发布到 Puppet Forge 供使用,可以运行pdk build命令来创建.tar文件进行上传,或者运行pdk release命令来自动化上传模块到 Puppet Forge 的过程。
保持 metadata.json 文件的最新状态非常重要,因为它将限制根据 Puppet 支持的版本进行的测试,并且是文档的重要部分。可以在 docs.puppet.com/puppet/latest/modules_metadata.html 查看其格式及选项。
若要查看所有可用的选项,您可以在 puppet.com/docs/pdk/2.x/pdk_reference.html 中查阅完整的 PDK 命令参考。
在回顾了如何使用 PDK 创建和管理模块,以及其验证和测试功能后,我们来学习如何使用 RSpec 进行完整的单元测试。
使用 PDK 进行 RSpec 测试
为了在单元测试层面进一步进行初步验证和编译测试,RSpec 可用于测试模块的行为和逻辑,而 ServerSpec 可用于进行系统集成测试。
RSpec 是一个用于测试 Ruby 代码的 Ruby 框架,rspec-puppet 测试是 RSpec 的一个实现,专门用于测试 Puppet 模块。
注意
需要注意的是,当前项目代码可以在 github.com/puppetlabs/rspec-puppet 中找到,它是从 github.com/rodjek/rspec-puppet 派生的,核心指南和文档可以在 rspec-puppet.com/ 找到。
当用户开始使用 RSpec 时,有些人可能会觉得它只是用不同的语言模仿 Puppet 代码。RSpect 会运行 Puppet 代码中的不同逻辑和行为,确保在各种环境和情况下会产生正确的目录和输出。这可以防止在重构或升级到新版本的 Puppet 时出现回归。如果 RSpec 代码只是简单地模仿清单中的代码,那么测试场景就没有得到正确的审查。
这种单元测试风格的优点在于,它允许你在不启动任何特定基础设施或进行任何更改的情况下测试代码。
RSpec 测试包含在 spec 目录下的 Ruby 文件中的模块里,目录中包含了针对不同类型代码的测试,例如 spec/classes 目录中的类,和 spec/defines 目录中的定义类型。
我们将忽略其他可能的测试目录(如 types、type_alias 和 functions 测试目录),因为创建它们超出了本书的范围。然而,这里讨论的大部分内容可以应用于这些类型。
RSpec 配置包含在 PDK 中,文件会通过 pdk new 命令自动创建。但是,当转换模块或使用 PDK 时,可以通过向 convert 命令添加 --add-tests 标志 (pdk convert --add-tests),以及使用 pdk new test --unit <name> 命令分别进行添加。
在我们查看 PDK 默认为定义类型和类提供的内容之前,我们必须在exampleapp模块上运行pdk new class exampleapp和pdk new define example_define命令,以创建主清单和一个名为example_define的定义类型。这样会生成一个名为spec/classes/exampleapp.rb的文件,内容如下:
require 'spec_helper'
describe 'exampleapp' do
on_supported_os.each do |os, os_facts|
context "on #{os}" do
let(:facts) { os_facts }
it { is_expected.to compile }
end
end
end
此外,spec/defined/example_define.rb可以如下创建:
require 'spec_helper'
describe 'exampleapp::example_define' do
let(:title) { 'namevar' }
let(:params) do
{}
end
on_supported_os.each do |os, os_facts|
context "on #{os}" do
let(:facts) { os_facts }
it { is_expected.to compile }
end
end
end
分解这个过程,第一步是require spec_helper,它会加载spec/spec_helper.rb文件。由于spec目录会自动加载到路径中,因此只需要声明标题;这将配置 RSpec,稍后将在本节中详细讨论。接下来的部分是describe,它是 RSpec 中的一个关键字,用于描述一组测试。对于exampleapp和example_define测试,描述的是类和定义类的名称,因为每个测试组只有一个基本的测试组。
注意
如果你以前使用过puppet-rspec,你可能在describe语句中设置了额外的类型定义,比如describe 'exampleapp', :type => :class do。由于文件夹本身充当了类型的自动标识符,这一步是多余的。
定义类型始终需要一个标题和任何参数。使用let关键字时,设置了标题以及参数,在这种情况下,参数为空。
然后,exampleapp和example_define类使用on_supported_os函数进行循环,该函数由rspec-puppet-facts宝石提供,输入来自metadata.json文件,该文件包含有关支持的操作系统的详细信息,并生成一个存储在os_facts变量中的事实数组。接着,这些事实被传递到另一个let中,将这些事实赋值给os_facts数组的内容。
it关键字是一个 RSpec 术语,可以是单行的,也可以包含在do和end块内。它是一个测试用例,包含一个叫做is_expected.to的期望语句,这是对某个条件的验证步骤。这个条件通过匹配器来表达。在这种情况下,它将编译类和定义类型的 Puppet 代码,并确认会成功生成目录。
注意
我们推荐使用www.betterspecs.org/上的样式指南,它适用于通用的 Ruby RSpec 风格。我们将在本章中引用其中的建议。
简要查看了默认的编译测试后,让我们看看每个组件以及如何进一步扩展它们。
describe和context关键字
对于许多曾尝试使用 RSpec 的 Puppet 开发者来说,一个大的困惑是理解何时使用describe关键字,何时使用context。它们看起来是可以互换的,这有充分的理由。context关键字是describe的别名,所以它们是可以互换的,你的使用仅影响代码的可读性。
Betterspecs建议使用describe来描述正在测试的方法。在 Puppet RSpec 中,这也是我们在使用 PDK 进行 RSpec 测试部分看到describe与类名exampleapp以及其定义类exampleapp::example_define的原因。
推荐将context写成何时、与或不与等情境方式,这样可以明确说明正在测试的场景。
本书的风格推荐是写一个单独的describe来匹配 Puppet 类型,比如class,然后使用context来匹配要测试的场景。
describe和context的块允许描述正在测试的情况,并设置事实、变量和参数。由于它们可以嵌套,因此可以实现继承,这将构建更详细的场景,或尝试不同的逻辑路径,但需要注意不要使这些案例过于难以阅读。
目标应该是测试所有情况。因此,应该计划测试有效、边缘和无效情况,允许正面和负面情况都能被测试。作为一个简单的示例,在没有任何代码测试或参数设置的情况下,以下exampleapp类的代码将根据install版本是否为中间值、低边缘版本或无效版本来查看每个受支持操作系统的上下文:
describe 'exampleapp' do
on_supported_os.each do |os, _os_facts|
context "on #{os}" do
context 'When install_version is 6' do
it { is_expected.to compile }
end
context 'When install_version is 1' do
it { is_expected.to compile.and_raise_error('unsupported version') }
end
context 'When install_version is invalid string' do
it { is_expected.to compile.and_raise_error('Invalid version string') }
end
end
end
end
现在我们已经有了测试场景的基本结构,下一步是使用匹配器来测试根据context在目录中生成的内容。
示例、期望和匹配器
示例it语句可以是单行的,如在使用 PDK 进行 RSpec 测试部分所示,或者当所使用的匹配器太长,无法放在一行时,可以分成多行。使用do和end,相同的编译示例可以表示如下:
it do
is_expected.to compile
end
在一般的 Ruby RSpec 实现中,期望有更广泛的选择,但在puppet-rspec中,我们的期望将仅限于使用is_expected关键字。不过,可以通过使用not_to来否定它——例如,It { is_expected.not_to }。
匹配器提供了多种测试,用于测试不同的资源类型。匹配器的语法为contain_<resource_type>('<title>').<options>。
对于编译匹配器,我们可以通过添加with_all_deps选项来更明确地进行编译测试——例如,it { is_expected.to compile.with_all_deps }。这将测试目录中所有关系是否包含资源。或者,我们可以通过使用and_raise_error('error_message')选项来查找编译错误,这将包含我们期望抛出的消息作为字符串——例如,it { is_expected.to compile.raise_error('lets cause failure' }。
主要的匹配集合是基于资源类型,使用contain_<resource_type>('<resource_title>')模式——例如,
it { is_expected.to contain_class('exampleclass::install') } 和 it { is_expected.to contain_service('httpd') }。
Rspec-puppet不会进行类名解析或查找,因此匹配器只接受没有前导冒号的限定类。因此,install在exampleclass中找不到,但exampleclass::install可以找到。如果资源类型包含::符号,则需要将其转换为__符号,这样它将变成contain_exampleapp__exampletype。
资源匹配器可以通过使用with、only_with和without方法进一步扩展。这使我们能够检查资源的参数;with确保目录中的资源具有指定的参数,only_with确保仅设置提供的参数而没有其他参数,without接受一个参数数组并确保这些参数没有被设置。在使用这些方法时,使用it do...end格式更具可读性,以下是一个示例:
it do
is_expected.to contain_package('httpd').with(
'ensure' => 'latest',
'provider' => 'solaris',
)
end
通过遵循with和only_with的方法语法,这可以简化为仅一个参数:
<with_method>_<parameter name>
it {is_expected.to contain_server('exampleserver').only_with_enable(true) }
对于without,该方法接受一个不应设置在资源上的参数数组:
it {is_expected.to contain_user('exampleuser'). .without(['managehome', 'home']) }
这些方法可以通过链式调用的方式组合在一起,无论是作为相同的方法,还是作为混合方法:
it {is_expected.to contain_server('exampleserver').with_enable(true).without_ensure }
另一种资源匹配器是使用count,它允许使用have_<resource_type>_count语法。例如,要验证资源的总数是否为5,类的总数是否为4,可以运行以下代码:
it { is_expected.to have_resource_count(5) }
it { is_expected.to have_class_count(4) }
在回顾了如何设置示例后,显然对于describe和context关键字,必须设置参数和前置条件才能形成测试场景。例如,如果上下文是安装版本为 1,则需要将安装版本参数设置为 1。
参数和前置条件
在定义类型的默认示例中,我们解释了如何使用let关键字来指定定义类型的测试实例的标题和空参数。然而,这些也可以用于其他类型,如带参数的类。
要填充参数,可以提供一个由=>符号分隔的键值对数组,在字符串中未定义的值声明为:undef,在编译测试时会转换为undef。例如,要将param1设置为yup字符串,并将param2设置为undef,可以使用以下let:
let(:params) { {'param1' => 'yup', 'param2' => :undef } }
除了参数外,还可以设置前置条件。因此,如果正在测试的清单依赖于目录中存在另一个类或变量,则可以将其添加,以便在测试类之前进行评估。例如,在模块模式中,我们展示了config类需要在install类之后但在service类之前在目录中进行评估。这可以通过以下代码完成:
let(:pre_condition) { 'include exampleapp::install' }
let(:post_condition){ 'include exampleapp::service' }
如果有多个条件,也可以使用字符串数组。如果测试针对特定的节点或环境,可以按如下方式设置:
let(:node) { puppet.packtpub.com' }
let(:environment) { 'production' }
节点应该是完全合格的域名(FQDN)。
关系
资源之间的关系可以通过that_requires、that_comes_before、that_notifies和that_subscribes_to方法进行测试。无论 Puppet 代码使用require,还是 RSpec 使用that_comes_before,或者 Puppet 代码使用方向箭头,只要这些变体在逻辑上是等价的,都不重要,因为测试是在目录上进行的。
这些方法被链式调用到示例中与需求一起,但是在 Puppet 清单中声明关系与在rspec测试中声明关系之间存在一些区别:名称不应加引号,不能在单一类型下有多个资源名称,如果引用了类,则不应使用前导::标记它为顶级作用域。举个简单的例子,一个名为exampleconfig的文件要求exampleapp包,可以通过以下方式进行检查:
it { is_expected.to contain_file('exampleconfig').that_requires('Package[exampleapp]') }
要检查exampleapp包是否在exampleapp::service和exampleapp::config类之前,可以传递一个数组。然而,请注意,它们不能在一个类下:
it { is_expected.to contain_package('exampleapp').that_comes_before('Class[exampleapp::service]','Class[exampleapp::config]') }
使用it do...end的带参数资源示例如下,其中通知了两个文件:
it do
is_expected.to contain_service('anotherapp').with(
'ensure' => 'running',
'enable' => 'true',
).that_notifies('File[config_a]', 'File[config_b]')
end
如果测试的是类似定义类的内容,并且它的定义中包含require或before,则可以在参数中设置此关系。然而,必须使用ref辅助函数来命名它所依赖的资源,使用ref('<type>','<title>')语法。对于一个需要exampleapp包的定义类型,以下代码会通过参数添加该关系:
let(:params) { 'require' => ref('Package', 'exampleapp') }
来自 Hiera 和事实的数据
来自 Hiera 和事实的数据对我们代码中的逻辑有很大的影响,因此必须能够提供并自定义,以覆盖不同的测试场景。如在使用 PDK 进行 RSpec 测试章节中的默认示例所示,rspec-puppet-facts gem 会检查metadata.json文件以查找支持的操作系统列表。然而,metadata.json并没有提供架构的方式,默认情况下,rspec-puppet-facts会根据操作系统选择一个默认架构,例如 Solaris 的 i86PC 或 Fedora 的 x86_64。如果您希望能够检查其他架构,可以通过逗号分隔的数组传递硬件模型。这样将与以下代码结合使用:
additional_archs = {
:hardwaremodels => ['i386'],
}
on_supported_os(additional_archs).each do |os, os_facts|
如果只需要测试一个子集,例如专为某个操作系统制作的类,则可以通过operatingsystem和operatingsystemreleases参数传递相关的详细信息;这将覆盖metadata.json:
ubuntu = {
supported_os: [
{
'operatingsystem' => 'Ubuntu',
'operatingsystemrelease' => ['18.04', '16.04'],
},
],
}
on_supported_os(ubuntu).each do |os, os_facts|
使用on_supported_os方法时,这只能在所有选择上进行设置。如果没有找到任何内容,例如 Windows 11 上的 i386,它将默默地失败。查看facterdb模块github.com/voxpupuli/facterdb以查看可用的内容。
使用on_supported_os并不是强制性的,但如果没有它,默认情况下将不会有任何事实。当你需要测试facterdb中不存在的数据时,可以通过let(:facts)声明事实以及你想要的值。例如,如果你要测试一个理论上的 RedHat 10 事实集,你可以使用以下代码:
Context "when OS is redhat-10-x86_64" do
let(:facts) do
{
:osfamily => 'RedHat',
:operatingsystem => 'RedHat',
:operatingsystemmajrelease => '10',
…
}
end
同样,如果要在嵌套的context中向os_facts变量添加额外的事实,可以使用merge方法与super方法:
let(:facts) do
super().merge({
:student => 'david',
})
end
注意
对于结构化事实,这些合并可能变得更加复杂。Voxpupli 在github.com/voxpupuli/voxpupuli-test中提供了一个override_facts助手,可以帮助解决这个问题。
要添加可以被 PDK 用于验证和测试代码的事实,请添加一个spec/default_module_facts.yml文件。这个文件将包含类似下面的 YAML 内容:
---,
choco_install_path: C:\ProgramData\chocolatey
chocolateyversion: 0.9.9
default_facts.yml文件不应被编辑,因为它由 PDK 管理,并提供 PDK 运行所需的最小事实。
通过.sync.yaml添加默认事实是可能的,方法是添加标准代码块或default_facts.yml,但与default_module_facts.yml相比,这种方式不必要地复杂。
在 spec 中通过let(:facts)提供的任何事实将会覆盖默认事实。
除了这些事实外,还有三个额外的变量来自分类和外部数据源:节点参数,这是从分类中分配给节点的全局变量;可信事实,这是从 Puppet 客户端证书中分配的变量;以及可信外部事实,这是由脚本从外部数据源获取的变量。这些的完整实现将在第十一章和第十四章中详细描述。
可以通过在 spec 文件中使用let语句或在spec_helper中设置为默认值来添加所有三种类型的变量。
从 Puppet 4.3 版本开始,可信事实将包含基于节点名称(通过:node设置)填充的可信事实键(certname,domain,hostname)。然而,可信外部事实和节点参数将为空。
可信事实使用trusted_facts,可信外部数据使用trusted_external_data,节点参数使用node_params。例如,要声明可信事实和可信外部数据,可以使用以下let语句:
let(:trusted_facts) { {'pp_role' => 'puppet/server', 'pp_cluster' =>
'A'} }
let(:trusted_external_data) do,
{
pds: {
puppet_classes: some_class,
example: hiera_data,
},
}
end
要设置默认值,.sync.yaml 可以通过传递一个数组给 spec_overrides 来添加额外的行;然而,添加一个包含必要行的 spec_helper_local.rb 文件会比遵循 YAML 语法更为简便。在 Rspec.config 块中,需要按照 c.<fact_type> = {<fact/parameters_keys>} 格式,并使用带有 default_ 前缀的 fact/parameter 名称。因此,要将节点参数指定为默认值,可以按如下方式更新 spec_helper_local.rb:
RSpec.configure do |c|
c.default_node_params = {
'owner' => 'oracle',
'site' => 'Falkirk1',
'state' => 'live',
}
end
同样,可信的外部数据也可以像这样设置:
Rspec.configure do |c|
c.default_trusted_external_data = {
pds: {
puppet_classes: some_class,
example: hiera_data,
},
}
end
Hiera 会在 第九章 中详细介绍,但现在知道 Hiera 提供一个 hiera.yaml 文件来帮助你学习如何查找数据和配置文件就足够了。我们在 spec/fixtures/hiera/hiera.yaml 中创建了一个 hiera.yaml 定义,通常会在 spec/fixtures/hieradata 中定义一个 datadir。
Hiera 的配置可以通过两种方式进行设置,具体文档可以参考 github.com/puppetlabs/rspec-puppet。第一种方式是使用 let 并设置必要的变量,如下所示:
let(:hiera_config) { 'spec/fixtures/hiera/hiera.yaml' }
hiera = Hiera.new(:config => 'spec/fixtures/hiera/hiera.yaml')
查找操作可以按如下方式进行:
primary_dns = hiera.lookup('primary_dns', nil, nil)
let(:params) { 'primary_dns' => primary_dns}
或者,可以将以下内容添加到 spec_helper_local.rb 中。这里,参数的自动查找将会发生:
RSpec.configure do |c|
c.hiera_config = 'spec/fixtures/hiera/hiera.yaml'
end
在了解如何为单独的模块创建测试后,你会很快发现,模块内使用了各种资源,例如函数,而这些资源依赖于其他模块的内容。在接下来的章节中,你将学习如何使用 fixtures 使这些内容可用于测试。
使用 fixtures 管理依赖关系
puppetlabs_spec_helper 可以将依赖的模块放在 spec/fixtures/modules 目录下,供运行 RSpec 测试单元时使用。.fixtures.yml 文件可以指定 GitHub 仓库源的 repositories: 和 Puppet Forge 模块的 forge_modules:。
主要的参数是 repo,它可以是 Git 仓库链接或 Puppet Forge 模块名,ref 表示 Git 提交 ID,或 Forge 模块版本号,branch 是 Git 分支名。ref 和 branch 参数可以一起使用来修改分支。
所以,包含两个 Git 仓库和两个 Forge 模块的 .fixtures.yml 示例文件如下:
fixtures:
forge_modules:
peadm: "puppetlabs/peadm"
stdlib:
repo: "puppetlabs/stdlib"
ref: "2.6.0"
repository:
pecdm: "git://github.com/puppetlabs/pecdm"
Puppet-data-service:
repo: "git://github.com/puppetlabs/puppetlabs-puppet_data_service"
Ref: "feature_branch_1"
如果除了 repo 之外没有其他参数,则可以简化为一行,如此所示。如果 fixtures 文件发生了变化,可以使用 --clean-fixtures 标志和 pdk test unit 命令来确保所有内容被删除。
更多的标志和选项可以与 fixtures 一起使用,具体文档请参考 github.com/puppetlabs/puppetlabs_spec_helper#fixtures-examples。
覆盖率报告
可以通过将以下代码添加到 spec_helper_local.rb 来生成覆盖率报告:
RSpec.configure do |c|
c.after(:suite) do
RSpec::Puppet::Coverage.report!
end
end
该工具检查 Puppet 资源是否被覆盖,并生成覆盖的资源百分比和未覆盖资源的列表。被检查的资源必须位于正在测试的模块内,并且不能包含任何由 fixtures 引入的依赖。资源覆盖的百分比也可以通过在括号中添加通过率来设定为通过或失败的标准。例如,通过将代码行更新为 RSpec::Puppet::Coverage.report! (100),可以确保每个资源(100%)都被覆盖。这有时可以作为推动 RSpec 使用和覆盖的动力,且只有在出现特定问题或例外时,资源覆盖百分比才会减少。
进一步的 RSpec 研究和工具
本节旨在为你提供足够的信息,以便你能够使用事实数据和依赖关系构建有意义的 rspec-puppet 测试。同时,请注意,可以使用普通的 Ruby 代码,如 case 或 if 语句和变量,并且在 spec_helper_local 中还有许多更多高级配置选项,相关文档请见:rspec-puppet.com/documentation/configuration/。
本书不建议使用 Augeas,但实际上可以在 RSpec 中测试 Augeas。详细信息请参阅:github.com/domcleal/rspec-puppet-augeas。
尽管这超出了本书的范围,但在使用自定义函数和类型时,必须执行存根和模拟,这可以通过 rspec-mocks 完成,相关文档可见:github.com/puppetlabs/puppetlabs_spec_helper#mock_with。
在使用 PDK 进行 RSpec 测试部分的开头提到,对于大型清单,必须为资源编写所有 RSpec 代码可能会很痛苦。然而,有几个工具可以为你完成这项工作。这些工具包括 github.com/logicminds/puppet-retrospec、github.com/enterprisemodules/puppet-catalog_rspec 和 github.com/alexharv074/create_specs.git;这些工具都可以从代码或清单生成 RSpec。
几乎所有任务也可以通过使用 rspec-puppet-yaml gem 在 YAML 中完成,相关文档可见:rubydoc.info/gems/rspec-puppet-yaml。然而,我们强烈不推荐这样做。
对于进一步的 RSpec 研究,查看核心 RSpec 文档可能会很有帮助,网址是:rspec.info/documentation/。
Serverspec
Serverspec 是一个 RSpec 实现,用于在配置管理部署完成后进行服务器级别的测试。它是一个独立于 Puppet 的工具,且不与 PDK 集成;通常,它会被添加到流水线工具中运行,并需要你从服务器远程连接到测试目标。许多我们在 RSpec 中看到的相同原理和理念仍然适用。相关的文档和教程可以在 serverspec.org/ 找到。
在本章了解了如何创建和测试模块后,我们现在可以看看如何使用 Puppet Forge 获取预编写的模块。
理解 Puppet Forge
Puppet Forge 提供了一个丰富的资源库,包括 Puppet、Puppet 社区和第三方供应商提供的模块,旨在减少你组织必须编写和维护的代码量。它还允许你为项目做出贡献或发布模块,进而让他人也能为你的项目做贡献。
理解 Puppet Forge 中不同类型的作者、认可和质量评分非常重要,这有助于你了解是谁在开发模块、可以期待什么样的模块,以及如何在 7000 多个模块中做出选择。
任何人都可以注册并发布模块。然而,Puppet 公司本身是通过 puppetlabs 用户名发布的,而 puppet 用户名则是由 Vox Pupuli 社区发布的。这种混淆源于 Puppet 最初被称为 Puppet Labs。尽管如此,这并不影响 Vox Pupuli 社区在 Puppet 的高标准开发以及与 Puppet 的紧密合作,两个组织在相互贡献。有关 Vox Pupuli 社区的详细信息可以访问 voxpupuli.org/,包括如何贡献和参与其中。
还有一些其他重要的咨询贡献者,比如 example42、enterprisemodules、camptocamp 和 betadots,他们贡献了模块并提供服务。还有一些供应商组织,如 foreman、datadog、SIMP、cyberark 和 Elastic,它们提供与自己产品相关的模块。最后,像 saz 和 ghoneycut 这样的个人贡献者也贡献了多个高质量的模块。Puppet 有一个 Champions 计划,专门突出介绍 Puppet 知名贡献者,这有助于了解模块作者的可靠性:puppet-champions.github.io/profiles.html。
注意
将模块发布到 Puppet Forge 的过程超出了本书的范围,但可以通过访问 puppet.com/docs/puppet/latest/modules_publishing.html 进行查看,并配合 pdk build 和 pdk release 命令使用,如在 使用 PDK 编写和测试模块 部分中所讨论。
在了解如何在查看如图 8.4所示的屏幕时筛选使用模块时,我们有多种选择,该屏幕允许我们搜索 Puppet Forge 中所有可用的模块:
图 8.4 – Puppet Forge 搜索屏幕
最直接的有用过滤器是metadata.json文件,用于操作系统和 Puppet 版本兼容性。发布日期、最新版本和下载次数是衡量模块是否常用以及是否保持更新的关键指标。
Puppet 实施了一种认可方案,由内容与工具团队(CAT)管理,分为三种类型:合作伙伴、批准和支持。
批准的模块通过了forge.puppet.com/about/approved/criteria中记录的具体标准,这些标准确保模块符合可用性和质量标准。这可以帮助你在选择可靠模块时,或让你的团队以标准为目标,并通过github.com/puppetlabs/puppet-approved-modules提交模块。
支持的模块遵循与批准模块相同的标准,但由 Puppet 或 Puppet 批准的第三方供应商完全支持,允许 Puppet Enterprise 客户在遇到问题时提交支持案例。请注意,只有模块的最新版本才会得到支持,且 Puppet Enterprise 操作系统版本在生命周期结束后的支持窗口有限。详细信息可以在forge.puppet.com/about/supported查看。
第三种合作伙伴类型是由非 Puppet 提供支持和测试。但为了使此支持有效,可能需要单独的合作伙伴许可方案。
除了这种认可方法,每个 Puppet 模块都会有一个评分。自从评分机制最后一次更新以来,详细信息尚未完全公开,评分的细分也不可见,但模块质量评分基于代码风格检查、兼容性测试和元数据验证。这个评分可以帮助你了解模块在运行anubis-docker评估时是否符合 Puppet 代码标准。github.com/puppetlabs/anubis-docker
恶意软件扫描于 2021 年引入,使用 VirtusTotal。puppetlabs用户模块上可以看到模块的通过或失败情况,但这将在稍后的日期扩展到已批准、合作伙伴以及所有未来模块版本。
随着新实现的推出,或者因为用例不再有效而不再得到支持,模块可能会被弃用。这些模块默认会被隐藏,但可以通过选择显示****已弃用选项使其可见。
最近发布的 Puppet Comply 产品新增了高级模块,但它们目前仅适用于cem_windows和cem_linux模块,这些模块只有在购买 Puppet Comply 后才能使用。
由于历史发展重点在 Linux 平台,Puppet 在 Windows 平台上的支持曾一度被忽视。Puppet Forge 提供了一个集合页面(forge.puppet.com/collections/windows),该页面展示了为 Windows 设计的模块,例如 Chocolatey 包提供程序:forge.puppet.com/modules/puppetlabs/chocolatey。另一个重要的发展是自动生成 PowerShell xInternetExplorerHomePage,用于设置 Internet Explorer 的首页,以及如xActiveDirectory等模块,用于部署和配置 Active Directory。xInternetExplorerHomePage非常简单,只有一个名为dsc_xinternetexplorerhomepage的资源类型,可以用来设置默认首页,如下所示:
dsc_xinternetexplorerhomepage { 'set home page':
dsc_startpage => 'https://www.packtpub.com'
}
xActiveDirectory具有多种资源类型,用于配置和部署 Active Directory 的不同方面。
由于这是完全自动化的转换,而且 Puppet 并不拥有 DSC 代码,因此存在一定限制。这使得测试受到限制,并且依赖于 DSC 代码所有者提供的代码和文档质量。你还可能会发现一些模块在 PowerShell Gallery 中已被弃用,因此值得检查。此外,由于minitar中的一个 bug,只有 Puppet Enterprise 代码管理器才能正确地从 Puppet Forge 直接解压这些模块。对于开源用户,请参阅模块文档说明,了解如何从 Puppet Forge 的 Web 链接下载模块并手动解压归档文件,确保模块已安装并且 DSC 代码完全解压。
还有一些进一步的博客和工具需要关注,虽然它们超出了本书的范围,但值得调查以获取更多信息。为了跟上 Puppet Forge 和 Puppet 管理模块的最新动态,CAT 团队运行了一个博客:puppetlabs.github.io/content-and-tooling-team/。Puppet Forge 还提供了一个 API,地址为forgeapi.puppet.com/,可以执行更多的编程查询,而 Ben Ford 开发的denmark模块提供了额外的扫描和检查,帮助审查模块:github.com/binford2k/denmark。
实验—创建并测试一个模块
在这个实验中,你将运用你所学到的关于模块结构、PDK 和测试的知识,创建并测试一个 Grafana 模块。然后,利用你对 Puppet Forge 的了解,你将探索 Forge 网站并选择模块:
-
使用你为 第四章 编写的结合了 Grafana、Windows 和 Linux 的类代码,或者参考
github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch04/all_grafana_data_types.pp的示例答案,创建一个名为packt_grafana的新模块,并按照init、service、config和install模式将 Puppet 代码拆分为适当的类(在实际应用中,对于这么多资源,单个类更为合适,但此处仅为练习)。建议使用pdk new class创建类。请遵循puppet.com/docs/puppet/latest/puppet_strings_style.html,确保类的完整文档,并通过测试。 -
扩展 PDK 提供的默认测试,并在考虑可能传递的参数和可用的操作系统选择时设计要覆盖的上下文。使用模块中的
github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch08/.sync.yml文件,该文件将包含puppet-catalog_rspec的 gem 文件,并运行pdk update。为了自动生成一些 RSpec 资源,你可以在每个类的规格文件中添加it{dump_catalog}(你需要为此定义一些参数),并在获得输出后删除该行。添加 100% 覆盖率的测试,并确保测试能够达到该目标。 -
使用
pdk validate和pdk test unit,修正模块中可以找到的错误,示例见此处:github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/tree/main/ch08/mistakemodule。 -
访问 Puppet Forge 并决定你希望为以下任务使用哪个模块:
-
在 Ubuntu 上配置 SSH
-
安装和配置 IIS
-
在 Windows 机器上使用 DSC 配置时区(提示:不是
xtimezone;参考www.powershellgallery.com/) -
安装并配置 Logstash
-
查看建议答案,链接地址为 github.com/PacktPublishing/Puppet-8-for-DevOps-Engineers/blob/main/ch08/module_choice.txt
总结
在本章中,您学习了模块如何帮助您将代码和数据进行分组,从而更容易共享和重用代码。我们讨论了模块应该专注于清晰的单一用途责任。我们检查了模块的目录结构,并重点介绍了存储特定 Puppet 代码和数据的位置。展示了一个好的入门清单结构,重点介绍了用作入口点的主清单(init.pp),并通过参数像公共 API 一样使模块灵活,能够包含其他所需的类。我们还看到了 install.pp、config.pp 和 service.pp 类,分别聚焦于安装、配置和服务。在应用程序变得比这更复杂的情况下,我们讨论了模块如何使用类和目录来处理不同的组件。
接下来,我们了解了 PDK 如何作为一种自动化模块创建的工具,并将常用的工具集成在一起,帮助我们管理和测试 Puppet 模块。我们创建了一个 Ruby 环境,并在模块目录中安装了社区最常用的开发工具和配置文件。我们审查了生成模块的默认模板,以及如何通过在 sync.yaml 上进行分支来定制它。
接着,我们了解了在使用各种 PDK 命令创建或转换模块时,开发生命周期的过程,以及添加不同的 Puppet 类型,如类或定义类型,这些都用于创建单元测试。我们还查看了 pdk validate 命令,它用于执行代码检查和语法验证,并通过 -a 标志在可能的情况下进行自动修正。模板创建了基本的 RSpec 测试,用于检查目录的编译。PDK 的 build 和 release 命令也被提及,作为将 PDK 打包到 Puppet Forge 中或将其作为一个命令打包并上传的方法——release。
接下来,你学习了如何使用 describe 和 context 扩展 RSpec,以构建测试用例和期望,并使用匹配器来定义单个测试。你了解到,可以通过 let 语句设置前置条件,从而在测试中创建类的依赖关系。你还学习了如何通过链式调用定义关系。你看到,let 语句可以用于在数据中定义事实、节点数据、受信任的事实和受信任的外部事实,并且通过使用 default_module_facts.yaml 和 spec_helper_local 文件,可以为模块设置默认值。随后,我们讨论了 Hiera,详细介绍了如何在 spec 或通过 spec_helper 设置配置文件,以及如何执行查找操作。对于外部依赖,展示了 fixtures.yml 文件,能够从 Puppet Forge 或本地仓库引入模块依赖,以支持目录编译。然后,将覆盖率报告添加到本地 spec helper,使单元测试能够显示哪些资源未被测试覆盖,并在测试中显示通过百分比。接着,我们了解了一些进一步的 RSpec 工具和资源,这些工具允许你生成 RSpec 代码,并进行一些超出本书范围的检查。然后,重点介绍了 ServerSpec,它是一个基于 RSpec 的服务器级测试框架。它独立于 Puppet,超出了本书的范围,但值得投资,理想情况下应该加入到流水线中。
在向你展示如何开发和构建模块之后,你学习了如何从 Puppet Forge 获取模块,了解了 Puppet 提供的不同类型的模块支持和背书,如何对模块进行评分和扫描,以及如何了解贡献者及其在 Puppet 社区中的地位。提到了 Windows 模块集合,以及 PowerShell DSC 集合,它为 PowerShell Gallery 中的模块提供了自动封装,使内容可以在 Puppet 代码中下载和使用。还提到 CAT 团队作为 Puppet Forge 的维护者,通过博客发布更新来支持内容。接着,介绍了丹麦模块,作为一种额外的模块评分方式。
在下一章中,你将学习 Puppet 如何处理数据,并了解 Hiera,探讨它如何将数据分层到不同的作用域。我们将讨论何时最好使用 Puppet 代码、变量和 Hiera 来存储数据,以及如何构建和将数据传递给模块参数。我们还将涵盖如何正确存储数据的安全性,无论是在静止状态还是传输过程中,以及使用 Puppet 数据时的一些常见问题和如何应对它们。
799

被折叠的 条评论
为什么被折叠?



