SaltStack-扩展指南-全-

SaltStack 扩展指南(全)

原文:zh.annas-archive.org/md5/14192f95f462eb0486790344dad3fe7b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

你手中(或你的电子阅读器中)的是第一本专门介绍编写与 SaltStack 工具框架一起使用的代码的书。

本书涵盖的内容

第一章, 从基础知识开始,从讨论本书关注的两个核心原则开始:Salt 如何使用 Python 以及 Loader 系统是如何工作的。这些构成了扩展 Salt 的基础。

第二章, 编写执行模块,解释了 Salt 中的大部分重工作是由执行模块完成的,这些模块通常被其他模块封装。对执行模块的深入了解也将是理解其他模块类型如何工作的关键。

第三章, 扩展 Salt 配置,解释了动态管理配置的能力可以使某些模块变得更有用。有些模块甚至没有动态配置就无法工作。在这里,我们探讨提供这种能力的不同方法。

第四章, 将状态封装在模块周围,支持执行模块使事物工作的事实,但状态模块使这种工作持久。在本章中,你将了解如何使用状态模块来管理执行模块。

第五章, 渲染数据,展示了渲染系统允许你添加自己的模板系统,从而增强状态的功能。Jinja 和 YAML 都很好,但有时你需要更多。

第六章, 处理返回数据,回答了当作业完成时数据会发生什么的问题。数据可以前往许多地方,你可以编写模块将其发送到那里。

第七章, 使用 Runners 进行脚本编写,展示了 SaltStack 知道系统管理员多年来一直在使用脚本语言,他们提供了一个结合 Python 和 Salt 原始力量的脚本环境。

第八章, 添加外部文件服务器,建议不要仅仅从 Salt Master 提供文件服务。你可以使用自己的外部文件服务器模块从任何你想要的地方提供文件服务。

第九章,连接到云,帮助您了解如何更新现有的云模块或添加您自己的模块。现在每个人都使用云,Salt Cloud 将其与 Salt 连接起来。

第十章,使用信标进行监控,帮助我们解决 Salt 通常不与监控相关联的问题,这确实是个遗憾。信标是将 Salt 集成到您的监控框架的一种方式。

第十一章,扩展主节点,解释了 Salt 提供了一种程序化地满足主节点管理需求的方法。如果您能将您自己的身份验证系统与 Salt 结合起来,那么这将是一个加分项。

附录 A,连接不同模块,提供了即使知道 Salt 模块旨在协同工作,如何适配不同组件的解决方案。本附录阐述了不同部分是如何连接在一起的。

附录 B,向上游贡献代码,为您提供了一些了解项目哪里出了问题或哪些功能缺失的技巧。在 Salt 中,这不必是这样的,但可以回到社区。

您需要这本书的内容

本书假设您对 Salt 和 Python 编程语言有一定的了解。虽然您可能能够在没有太多经验的情况下快速编写代码(实际上,作者就是这样开始使用这两者的),但有了这些工具,您会发现这要容易得多。

虽然本书中的示例经过测试且功能正常,但它们可能不适用于您的需求。它们旨在简单易懂,适合那些对 Python 和 Salt 都很熟悉的读者,同时仍然展示了相当数量的功能。

Salt 目前支持 Python 2.6 的基线,这意味着许多较老的 Linux 发行版仍然得到支持。截至本书的第一版,Salt 目前不支持 Python 3.x 分支。从节点端的示例预期在 Windows 上也能正常工作,除非它们依赖的依赖项不可用。

由于主节点和从节点都可以在同一台机器上运行,您只需要一台计算机来执行本书中的示例。许多示例确实会引用可能未预装在您的操作系统中的软件。在这些情况下,该软件仍然可以下载。

这本书面向的对象

这本书适合所有希望构建和编写新 Salt 模块的新手和现有 Salt 开发者。预期您有一定的 Python 开发经验。

习惯用法

在本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“如果没有__virtual__()函数,则该模块将在每个系统上始终可用。”

代码块设置如下:

'''
This module should be saved as salt/modules/mysqltest.py
'''
__virtualname__ = 'mysqltest'

def __virtual__():
    '''
    For now, just return the __virtualname__
    '''
    return __virtualname__

def ping():
    '''
    Returns True

    CLI Example:
        salt '*' mysqltest.ping
    '''
    return True

任何命令行输入或输出都按照以下方式编写:

#salt-call mymodule.test

新术语重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中如下所示:“当你再次访问 GitHub 上的分支时,你会看到一个写着新拉取请求的链接。”

注意

警告或重要注意事项如下所示。

小贴士

小技巧和技巧如下所示。

读者反馈

我们的读者反馈总是受欢迎的。告诉我们你对本书的看法——你喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大价值的标题。

要向我们发送一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。

如果你在某个领域有专业知识,并且对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南,网址为 www.packtpub.com/authors

客户支持

现在你已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助你从购买中获得最大收益。

下载示例代码

你可以从你的账户中下载本书的示例代码文件,网址为 www.packtpub.com。如果你在其他地方购买了本书,你可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书的名称。

  5. 选择你想要下载代码文件的书籍。

  6. 从你购买本书的下拉菜单中选择。

  7. 点击代码下载

文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/ExtendingSaltStack_ColorImages.pdf下载此文件。

勘误

尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。

问答

如果您对本书的任何方面有问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章:从基础知识开始

大多数 Salt 用户将其视为配置管理平台。事实上,它在这方面处理得非常好。但它的设计目标并非一开始就是如此。在其早期,Salt 是一个通信框架,旨在对那些不编写代码的人来说也很有用。但对于那些愿意的人来说,它也被设计成对那些工具箱中有些 Python 知识的使用者高度可扩展。

在我们开始编写模块之前,了解 Salt 模块系统的工作原理会有所帮助。在本章中,你将学习以下内容:

  • 加载系统的工作原理

  • Salt 如何使用 Python

使用插件

由于 Salt 最初被设计为一个其他软件可以用来通信的骨干,其最初目的是从大量物理和虚拟机器中收集信息,并将这些数据返回给用户或数据库。各种程序,如psdunetstat,被用来收集这些信息。因此,每个程序都被一个插件封装,其中包含调用这些程序和解析返回数据的各种功能。

这些插件最初被称为模块。后来,当 Salt 中添加了其他类型的模块时,原始模块开始被称为执行模块。这是因为执行模块会承担繁重的工作,而其他类型的模块通常围绕它们扩展其功能。

加载模块

就像许多数据中心一样,Salt 被创建的那个数据中心拥有各种服务器,它们使用不同的软件包来完成工作。一台服务器可能运行 Nginx,而另一台服务器可能运行 DNSMasq。在 DHCP 服务器上启用nginx模块或在 Web 服务器上启用dnsmasq模块是没有意义的。许多流行的程序通过允许用户在启动服务之前配置要加载的插件来解决这一问题。

Salt 处理插件的方式与其他不同。在一个大型基础设施中,单个服务器的配置可能非常耗时。随着配置管理被添加到 Salt 中,一个核心信念逐渐形成,即配置管理平台本身应该尽可能少地需要配置。如果一开始就需要花费大量时间来启动,那么使用这样的套件来节省时间又有什么意义呢?

这就是加载系统是如何产生的。Salt 总是附带一套完整的模块,并且 Salt 会自动检测可用的模块,并动态加载它们。

执行模块是一种插件,在 Salt 内部执行大部分繁重的工作。这些是第一个使用加载系统的,在一段时间内没有其他类型的模块。随着 Salt 功能的增加,很快就很明显需要其他类型的模块。例如,返回输出最初只是打印到控制台。然后输出被改为更容易从 shell 脚本中处理。然后添加了输出器系统,以便可以在 JSON、YAML、Python 的 pprint 以及任何可能有用的其他格式中显示输出。

标准模块

在开始时,有一些类型的模块总是会加载。其中第一个是 test 模块,它只需要 Salt 自身的依赖项;特别是,它只需要 Python。

其他模块也设计用于通用用途,不需要比 Salt 自身的依赖项更多。file 模块将执行各种基于文件的操作。useradd 模块将包装标准的 Unix useradd 程序。这很好,只要 Salt 只在 Unix-like 平台上使用。当用户开始在 Windows 上运行 Salt 时,这些实用工具并不容易获得,情况就改变了。这正是虚拟模块真正开始发光的地方。

虚拟模块

在各种平台上支持 Salt,例如 Unix-like 和 Windows,与是否使 nginx 模块可用的问题类似:如果该平台已安装且可用,则使模块可用。否则,不要。Salt 通过实现虚拟模块来处理可用性问题。

虚拟模块背后的想法是它将包含一段代码,用于检测其依赖项是否满足,如果满足,则该模块将被加载并使该系统上的 Salt 可用。我们将在第二章编写执行模块中详细介绍如何实际执行此操作。

惰性加载模块

在开始时,如果检测到某个模块可加载,那么在 Salt 服务启动时就会加载该模块。对于特定系统,可能加载多个模块,管理员可能永远不会使用它们。虽然拥有它们可能不错,但在某些情况下,仅在需要时加载它们会更好。

当 Salt 服务启动时,惰性加载器将检测特定系统上可能使用的模块,但它不会立即将它们加载到内存中。一旦调用特定模块,Salt 将按需加载它,并将其保留在内存中。在一个通常只使用少量模块的系统上,这可以比以前产生更小的内存占用。

扩展加载系统

如我们之前所说,加载系统最初是为一种类型的模块设计的:我们现在称之为执行模块。不久之后,添加了其他类型的模块,而且这个数量至今仍在增长。

本书并不包括所有类型的模块,但它确实涵盖了相当多。以下列表并不全面,但它将告诉您现在可用的许多内容,并在您完成本书后可能给您提供一个关于其他类型模块的参考:

  • 执行模块在 Salt 内部执行大部分繁重的工作。当需要调用一个程序时,将为它编写一个执行模块。当其他模块需要使用该程序时,它们将调用该模块。

  • Grain 模块用于报告关于 minion 的信息。虚拟模块通常严重依赖这些模块。配置也可以在 grains 中定义。

  • Runner 模块被设计用来给 Salt 添加脚本元素。而执行模块在 minion 上运行,runner 模块则会在 Master 上运行,并调用 minion。

  • Returner 模块为 minion 提供了一种将数据返回给 Master 以外的途径,例如配置为存储日志数据的数据库。

  • State 模块将 Salt 从远程执行框架转变为配置管理引擎。

  • Renderer 模块允许使用不同的文件格式(根据需要)定义 Salt States。

  • Pillar 模块通过提供更集中的配置定义系统来扩展 grains。

  • SDB 模块提供了一种简单的数据库查找功能。它们通常从配置区域(包括 grains 和 pillars)引用,以防止敏感数据以明文形式出现。

  • Outputter 模块影响命令行数据输出显示给用户的方式。

  • 外部文件服务器模块允许 Salt 服务的文件存储在 Master 本地之外的地方。

  • 云模块用于管理不同计算云提供商的虚拟机。

  • 信标允许来自其他 Salt 组件或第三方应用程序的各种软件向 Salt 报告数据。

  • 外部身份验证模块允许用户无需在 Master 上拥有本地账户即可访问 Master。

  • Wheel 模块提供了一种管理 Master 端配置文件的 API。

  • 代理 minion 模块允许无法运行 Salt 平台的设备能够被当作完整的 minion 来处理。

  • 引擎允许 Salt 向长时间运行的外部进程提供内部信息和服务的功能。实际上,最好将引擎视为它们自己的程序,并且与 Salt 有特殊的连接。

  • Master Tops 系统允许在不使用 top.sls 文件的情况下针对 States。

  • Roster 模块允许 Salt SSH 在不使用 /etc/salt/roster 文件的情况下针对 minion。

  • Queue 模块提供了一种组织函数调用的方式。

  • pkgdbpkgfile 模块允许 Salt 软件包管理器存储其本地数据库并将 Salt 公式安装到本地硬盘以外的位置。

这些模块通常是按照必要性创建的。它们全部是用 Python 编写的。虽然其中一些可能相当复杂,但大多数创建起来都很简单。事实上,现在与 Salt 一起提供的许多模块实际上是由没有 Python 经验的用户提供的。

使用 Python 加载模块

Python 非常适合构建加载系统。尽管它被归类为一种非常高级的语言(而不是像 C 那样的中级语言),Python 对其内部管理的控制能力很强。Python 内置的强大模块内省功能对 Salt 非常有用,因为它使得在运行时任意加载虚拟模块变得非常顺畅。

每个 Salt 模块都可以支持一个名为__virtual__()的函数。这是检测该模块是否将在该系统上提供给 Salt 的函数。

salt-minion服务加载时,它将遍历每个模块,寻找__virtual__()函数。如果没有找到,则假定该模块的所有要求已经满足,并且它可以被提供。如果找到该函数,则将使用它来检测该模块的要求是否满足。

如果一个模块类型使用懒加载器,那么可以加载的模块将被放置一旁,在需要时加载。不符合要求的模块将被丢弃。

检测颗粒

在 Minion 上,最重要的可能是加载颗粒。尽管颗粒模块很重要(并在第三章中讨论,扩展 Salt 配置),但实际上有许多核心颗粒是由 Salt 本身加载的。

许多这些颗粒描述了系统上的硬件。其他描述了 Salt 运行的操作系统。例如,osos_family颗粒被设置,并在以后用于确定哪些核心模块将被加载。

例如,如果os_family颗粒被设置为redhat,则位于salt/modules/yumpkg.py的执行模块将作为pkg模块被加载。如果os_family颗粒被设置为debian,则salt/modules/aptpkg.py将作为pkg模块被加载。

使用其他检测方法

颗粒并不是用于确定是否加载模块的唯一机制。Salt 还提供了一些可以使用的实用工具。salt.utils库包含了一些函数,它们通常比颗粒更快,或者比简单的name=value(也称为键值对)配置提供更多的功能。

一个例子是salt.utils.is_windows()函数,正如其名称所暗示的,报告 Salt 是否在 Windows 内部运行。如果检测到 Windows,则salt/modules/win_file.py将作为file模块被加载。否则,salt/modules/file.py将作为file模块被加载。

另一个非常常见的例子是salt.utils.which()函数,该函数报告是否有一个必要的 shell 命令可用。例如,这被salt/modules/nginx.py用于检测nginx命令是否对 Salt 可用。如果是的话,那么nginx模块将被提供。

我们还可以探讨其他许多例子,但在这本书中,几乎无法为所有这些例子提供足够的空间。实际上,最常见的一些例子最好通过示例来展示。从第二章《编写执行模块》开始,我们将开始编写使用我们已讨论过的示例以及其他大量示例的 Salt 模块。

摘要

盐的生成依赖于加载器系统的存在,该系统能够检测哪些模块能够被加载,然后只加载可用的部分。仅当需要时,才会加载使用懒加载的模块类型。

Python 是 Salt 的一个组成部分,使得模块的编写和维护变得容易。Salt 附带了一个函数库,它帮助支持加载器系统以及与之一起加载的模块。这些文件位于 Salt 代码库下的salt/目录下的各个目录中。例如,执行模块位于salt/modules/

本章仅仅触及了 Salt 所能实现的可能性,但已经介绍了一些必要概念。从现在开始,重点将全部放在使用 Python 编写和维持模块上。

第二章。编写执行模块

执行模块构成了 Salt 执行的工作负载的骨干。它们也易于编写,编写它们的技巧是编写其他所有类型 Salt 模块的基础。对执行模块的工作原理有扎实的理解后,其他模块类型的功能也将被打开。

在本章中,我们将讨论:

  • 编写 Salt 模块的基本知识

  • 利用 Salt 内置功能

  • 使用良好实践

  • 执行模块的故障排除

编写 Salt 模块

在所有 Salt 模块中,有一些项目是一致的。这些组件在所有模块类型中通常以相同的方式工作,尽管在少数地方你可以期望至少有一点偏差。我们将在其他章节中介绍这些偏差。现在,让我们谈谈那些通常相同的事情。

隐藏对象

很久以前,程序员就习惯于在函数、变量等前面加上下划线,如果它们只打算在同一个模块内部使用。在许多语言中,这样使用的对象被称为私有对象

一些环境通过不允许外部代码直接引用这些内容来强制执行私有行为。其他环境允许这样做,但使用是不被鼓励的。Salt 模块属于强制执行私有函数行为的列表;如果一个 Salt 模块中的函数以下划线开头,它甚至不会被暴露给尝试调用它的其他模块。

在 Python 中,存在一种特殊的对象,其名称以两个下划线开头和结尾。这些被称为“魔法方法”(magic methods)的东西被昵称为dunder(意为双下划线)。Python 通常如何处理它们超出了本书的范围,但重要的是要知道 Salt 添加了一些自己的。其中一些是内置的,通常在(几乎)所有模块类型中都可以使用,而另一些则是用户定义的对象,Salt 会对它们进行特殊处理。

virtual()函数

这是一个可以出现在任何模块中的函数。如果没有__virtual__()函数,那么该模块将始终在每个系统上可用。如果该模块存在,那么它的任务是确定该模块的要求是否得到满足。这些要求可能包括配置设置到软件包依赖的任何数量。

如果要求没有得到满足,那么__virtual__()函数将返回False。在 Salt 的较新版本中,可以返回一个包含False值和无法加载模块的原因的元组。如果它们得到满足,那么它可以返回两种类型的值。这里事情变得有点棘手。

假设我们正在开发的模块位于 salt/modules/mymodule.py。如果满足要求,并且该模块将被引用为 mymodule,那么 __virtual__() 函数将返回 True。假设该模块中还有一个名为 test() 的函数,它将使用以下命令调用:

#salt-call mymodule.test

如果满足要求,但此模块将被引用为 testmodule,那么 __virtual__() 函数将返回字符串 testmodule。然而,而不是直接返回该字符串,你应该在所有函数之前使用 __virtualname__ 变量定义它。

让我们开始编写一个模块,使用 __virtual__() 函数和 __virtualname__ 变量。我们目前不会检查任何要求:

'''
This module should be saved as salt/modules/mysqltest.py
'''
__virtualname__ = 'mysqltest'

def __virtual__():
    '''
    For now, just return the __virtualname__
    '''
    return __virtualname__

def ping():
    '''
    Returns True

    CLI Example:
        salt '*' mysqltest.ping
    '''
    return True

代码格式化

在我们继续前进之前,我想指出一些你应该现在就注意的重要事项,这样你就不会养成需要以后改正的坏习惯。

模块以一种特殊的注释开始,称为 docstring。在 Salt 中,它以三行单引号开始和结束,所有引号都在一行上,单独成行。不要使用双引号。不要将文本放在引号所在的同一行上。所有公共函数也必须包含 docstring,遵循相同的规则。这些 docstrings 由 Salt 内部使用,为 sys.doc 等函数提供帮助文本。

注意

请记住,这些指南是针对 Salt 的;Python 本身遵循不同的风格。有关更多信息,请参阅附录 B 中的 Understanding the Salt style guide

注意,ping() 函数的 docstring 包含一个 CLI Example。你应该总是包含足够的信息,以便清楚地了解函数的用途,并且至少包含一个(或更多,根据需要)命令行示例,以演示如何使用该函数。私有函数不包括 CLI Example

你应该在顶部和下面的函数之间,以及所有函数之间,始终包含两个空行,任何导入和变量声明之间也应如此。文件末尾不应有空格。

虚拟模块

__virtual__() 函数的主要动机不仅仅是重命名模块。使用此函数允许 Salt 不仅检测有关系统的某些信息,而且还可以使用这些信息适当地加载特定模块,使某些任务更加通用。

第一章,从基础开始,提到了这些例子中的一些。salt/modules/aptpkg.py 包含了多个测试,以确定它是否运行在类似 Debian 的操作系统上,该操作系统使用 apt 工具集进行软件包管理。salt/modules/yumpkg.pysalt/modules/pacman.pysalt/modules/solarispkg.py 以及其他一些模块中也有类似的测试。如果这些模块中的任何一个都通过了所有测试,那么它将被加载为 pkg 模块。

如果您正在构建这样的模块集,重要的是要记住,它们应该尽可能相似。例如,所有的 pkg 模块都包含一个名为 install() 的函数。每个 install() 函数都接受相同的参数,执行相同的任务(适用于该平台),然后以完全相同的格式返回数据。

可能存在这样的情况,某个函数适用于一个平台,但不适用于另一个平台。例如,salt/modules/aptpkg.py 包含一个名为 autoremove() 的函数,它调用 apt-get autoremove。在 yum 中没有这样的功能,因此 salt/modules/yumpkg.py 中不存在该函数。如果存在,那么这个函数在两个文件中应该以相同的方式表现。

使用 salt.utils 库

前一个模块总是会运行,因为它不会检查系统上的需求。现在让我们继续添加一些检查。

salt/utils/ 目录内有一套丰富的工具可用于导入。其中许多直接位于 salt.utils 命名空间下,包括一个非常常用的函数 salt.utils.which()。当给出命令名时,此函数将报告该命令在系统上的位置。如果不存在,则返回 False

让我们重新设计 __virtual__() 函数,以查找名为 mysql 的命令:

'''
This module should be saved as salt/modules/mysqltest.py
'''
import salt.utils

__virtualname__ = 'mysqltest'

def __virtual__():
    '''
    Check for MySQL
    '''
    if not salt.utils.which('mysql'):
        return False
    return __virtualname__

def ping():
    '''
    Returns True

    CLI Example:
        salt '*' mysqltest.ping
    '''
    return True

salt.utils 库与 Salt 一起提供,但您需要显式导入它们。对于 Python 开发者来说,只导入函数的一部分是很常见的。您可能会发现使用以下导入行很有吸引力:

from salt.utils import which

然后使用以下行:

if which('myprogram'):

虽然在 Salt 中没有明确禁止,但除非必要,否则不建议这样做。虽然这可能会需要更多的输入,尤其是如果您在特定模块中多次使用特定函数,这样做可以更容易地一眼看出特定函数来自哪个模块。

使用 salt 字典进行跨调用

有时候,能够调用另一个模块中的另一个函数是有帮助的。例如,调用外部 shell 命令是 Salt 的一个重要部分。实际上,它如此重要,以至于在cmd模块中进行了标准化。最常用的发布 shell 命令的命令是cmd.run。以下 Salt 命令演示了在 Windows Minion 上使用cmd.run

#salt winminon cmd.run 'dir C:\'

如果你需要你的执行模块从这样的命令中获取输出,你会使用以下 Python 代码:

__salt__'cmd.run'

__salt__对象是一个字典,其中包含对该 Minion 上所有可用函数的引用。如果一个模块存在,但它的__virtual__()函数返回False,那么它将不会出现在这个列表中。作为一个函数引用,它需要在末尾使用括号,并在括号内放置任何参数。

让我们创建一个函数,告诉我们sshd守护进程是否在 Linux 系统上运行,并且监听某个端口:

def check_mysqld():
    '''
    Check to see if sshd is running and listening

    CLI Example:
        salt '*' testmodule.check_mysqld
    '''
    output = __salt__'cmd.run'
    if 'tcp' not in output:
        return False
    return True

如果sshd正在运行并监听某个端口,netstat -tulpn | grep sshd命令的输出应该看起来像这样:

tcp        0      0 0.0.0.0:3306              0.0.0.0:*               LISTEN      426/mysqld
tcp6       0      0 :::3306                   :::*                    LISTEN      426/mysqld

如果mysqld正在运行,并且监听 IPv4 或 IPv6(或两者),那么这个函数将返回True

这个函数远非完美。有许多因素可能导致这个命令返回一个假阳性。例如,假设你正在寻找sshd而不是mysqld。再假设你是一位美式足球迷,并且自己编写了一个高清足球视频流服务,你称之为passhd。这可能不太可能,但绝对不是不可能的。这提出了一个重要观点:在处理从用户或计算机接收到的数据时,要信任但也要验证。实际上,你应该始终假设有人会试图做坏事,你应该寻找阻止他们这样做的方法。

获取配置参数

虽然一些软件可以在没有任何特殊配置的情况下访问,但还有很多软件需要设置一些信息。执行模块可以从四个地方获取其配置:Minion 配置文件、grain 数据、pillar 数据和主配置文件。

注意

这就是 Salt 内置函数行为不同的地方之一。Grain 和 pillar 数据对执行和状态模块可用,但对其他类型的模块不可用。这是因为 grain 和 pillar 数据是特定于运行模块的 Minion 的。例如,Runners 无法访问这些数据,因为 Runners 是在 Master 上使用的,而不是直接在 Minions 上。

我们可以查找配置的第一个地方是__opts__字典。当在 Minion 上执行模块工作时,这个字典将包含来自 Minion 配置文件的数据副本。它还可能包含一些 Salt 在运行时自己生成的一些信息。当从在 Master 上执行的模块访问时,这些数据将来自主配置文件。

还可以在 grain 或 pillar 数据中设置配置值。这些信息分别通过 __grains____pillar__ 字典访问。以下示例显示了从这些位置拉取的不同配置值:

username = __opts__['username']
hostname = __grains__['host']
password = __pillar__['password']

由于这些值可能实际上不存在,最好使用 Python 的 dict.get() 方法,并提供一个默认值:

username = __opts__.get('username', 'salt')
hostname = __grains__.get('host', 'localhost')
password = __pillar__.get('password', None)

我们可以存储配置数据的最后一个地方是在主配置文件中。Master 的所有配置都可以存储在一个名为 master 的 pillar 字典中。默认情况下,这不会对 Minions 可用。但是,可以通过在 master 配置文件中将 pillar_opts 设置为 True 来启用它。

一旦 pillar_opts 被启用,你可以使用如下命令访问 master 配置中的值:

master_interface = __pillar__['master']['interface']
master_sock_dir = __pillar__.get('master', {}).get('sock_dir', None)

最后,你可以让 Salt 依次在每个位置搜索特定的变量。当你不在乎哪个组件携带你需要的信息,只要你能从某个地方获取它时,这非常有价值。

为了搜索这些区域,需要跨调用 config.get() 函数:

username = __salt__'config.get'

这将按以下顺序搜索配置参数:

  1. __opts__(在 Minion 上)。

  2. __grains__

  3. __pillar__

  4. __opts__(在 Master 上)。

请记住,当使用 config.get() 时,将使用找到的第一个值。如果你要查找的值在 __grains____pillar__ 中都定义了,那么将使用 __grains__ 中的值。

使用 config.get() 的另一个优点是,此函数将自动解析使用 sdb:// URI 引用的数据。当直接访问这些字典时,任何 sdb:// URI 都需要手动处理。编写和使用 SDB 模块将在 第三章,扩展 Salt 配置 中介绍。

让我们继续设置一个模块,该模块获取配置数据并使用它来连接到服务:

'''
This module should be saved as salt/modules/mysqltest.py
'''
import MySQLdb

def version():
    '''
    Returns MySQL Version

    CLI Example:
        salt '*' mysqltest.version
    '''
    user = __salt__'config.get'
    passwd = __salt__'config.get'
    host = __salt__'config.get'
    port = __salt__'config.get'
    db_ = __salt__'config.get'
    dbc = MySQLdb.connect(
        connection_user=user,
        connection_pass=passwd,
        connection_host=host,
        connection_port=port,
        connection_db=db_,
    )
    cur = dbc.cursor()
    return cur.execute('SELECT VERSION()')

此执行模块将在 Minion 上运行,但它可以使用在四个配置区域中定义的任何配置连接到任何 MySQL 数据库。然而,这个功能相当有限。如果 MySQLdb 驱动未安装,那么在 Minion 启动时日志文件中会出现错误。如果你需要执行其他类型的查询,你将需要每次都获取配置值。让我们依次解决这些问题。

小贴士

你注意到我们使用了名为 db_ 的变量而不是 db 吗?在 Python 中,被认为更好的做法是使用至少三个字符长的变量名。Salt 也认为这是必需的。对于通常较短的变量,在变量名末尾附加一个或两个下划线是一个非常常见的实现方式。

处理导入

许多 Salt 模块需要安装第三方 Python 库。如果其中任何一个库没有安装,那么__virtual__()函数应该返回False。但你是如何事先知道这些库是否可以导入的呢?

在 Salt 模块中,一个非常常见的技巧是尝试导入库,并记录导入是否成功。这通常是通过一个名为HAS_LIBS的变量来完成的:

try:
    import MySQLdb
    HAS_LIBS = True
except ImportError:
    HAS_LIBS = False

def __virtual__():
    '''
    Check dependencies
    '''
    return HAS_LIBS

在这种情况下,Python 将尝试导入MySQLdb。如果成功,则将HAS_LIBS设置为True。否则,将其设置为False。由于这直接关联到__virtual__()函数需要返回的值,我们只需将其原样返回即可,只要我们不更改__virtualname__。如果我们更改了,那么函数将看起来像这样:

def __virtual__():
    '''
    Check dependencies
    '''
    if HAS_LIBS:
        return __virtualname__
    return False

代码重用

仍然存在消除同一模块中不同函数之间冗余代码的问题。在代码中使用连接对象(如数据库游标或云提供商身份验证)的模块中,通常会有特定的函数被留出以收集配置和建立连接。

这些云模块的一个非常常见的名称是_get_conn(),所以让我们在我们的例子中使用它:

def _get_conn():
    '''
    Get a database connection object
    '''
    user = __salt__'config.get'
    passwd = __salt__'config.get'
    host = __salt__'config.get'
    port = __salt__'config.get'
    db_ = __salt__'config.get'
    return MySQLdb.connect(
        connection_user=user,
        connection_pass=passwd,
        connection_host=host,
        connection_port=port,
        connection_db=db_,
    )

def version():
    '''
    Returns MySQL Version

    CLI Example:
        salt '*' mysqltest.version
    '''
    dbc = _get_conn()
    cur = dbc.cursor()
    return cur.execute('SELECT VERSION()')

这大大简化了我们的代码,通过将每个函数中的大量行转换为单行。当然,这可以进一步扩展。实际上,与 Salt 一起提供的salt/modules/mysql.py模块使用了一个名为_connect()的函数而不是_get_conn(),并且它还将cur.execute()抽象到它自己的_execute()函数中。你可以在 Salt 的 GitHub 页面上看到这些:

github.com/saltstack/salt

日志消息

非常常见,你会执行需要记录某种消息的操作。当编写新代码时,这尤其常见;能够记录调试信息是非常好的。

Salt 内置了一个基于 Python 自己的logging库的日志系统。要启用它,你需要在模块顶部添加两行:

import logging
log = logging.getLogger(__name__)

在这些设置到位后,你可以使用如下命令来记录日志:

log.debug('This is a log message')

在 Salt 中通常使用五个级别的日志记录:

  1. log.info(): 此级别的信息被认为是所有用户都认为重要的事情。这并不意味着有任何错误发生,但像所有日志消息一样,其输出将被发送到STDERR而不是STDOUT(只要 Salt 在前台运行,并且没有配置到其他地方进行日志记录)。

  2. log.warn(): 从这里记录的消息应该向用户表明某些事情没有按预期进行。然而,它并没有到足以停止代码运行的程度。

  3. log.error(): 这表示出了问题,Salt 无法继续运行直到问题得到修复。

  4. log.debug(): 这不仅是有助于确定程序思考什么的信息,而且也是为了使程序的用户(如故障排除)有用。

  5. log.trace(): 这与调试消息类似,但这里的信息更有可能只对开发者有用。

现在,我们将在 _get_conn() 函数中添加一个 log.trace(),这样我们就可以知道何时成功连接到数据库:

def _get_conn():
    '''
    Get a database connection object
    '''
    user = __salt__'config.get'
    passwd = __salt__'config.get'
    host = __salt__'config.get'
    port = __salt__'config.get'
    db_ = __salt__'config.get'
    dbc = MySQLdb.connect(
        connection_user=user,
        connection_pass=passwd,
        connection_host=host,
        connection_port=port,
        connection_db=db_,
    )
    log.trace('Connected to the database')
    return dbc

小贴士

有一些地方可能会诱使人们使用日志消息,但应该避免这样做。具体来说,日志消息可以在任何函数中使用,除了 __virtual__()。在函数外部以及 __virtual__() 函数中使用的日志消息会生成混乱的日志文件,难以阅读和导航。

使用 func_alias 字典

在 Python 中有一些保留字。不幸的是,其中一些词对于像函数名这样的用途也非常有用。例如,许多模块都有一个函数,其任务是列出与该模块相关的数据,因此将这样的函数命名为 list() 似乎是自然的。但这会与 Python 的内置 list 冲突。这会引发问题,因为函数名直接暴露在 Salt 命令中。

对于这个问题有一个解决方案。可以在模块顶部声明一个 __func_alias__ 字典,它将在命令行中使用的别名与函数的实际名称之间创建一个映射。例如:

__func_alias__ = {
    'list_': 'list'
}

def list_(type_):
    '''
    List different resources in MySQL
    CLI Examples:
        salt '*' mysqltest.list tables
        salt '*' mysqltest.list databases
    '''
    dbc = _get_conn()
    cur = dbc.cursor()
    return cur.execute('SHOW {0}()'.format(type_))

这样一来,list_ 函数将被调用为 mysqltest.list(如 CLI 示例所示),而不是 mysqltest.list_

小贴士

为什么我们使用变量 type_ 而不是 type?因为 type 是 Python 的内置函数。但因为这个函数只有一个参数,所以预计用户不需要在 Salt 命令中使用 type_=<something>

验证数据

从最后一部分代码来看,此时可能有许多读者脑海中响起了警钟。它允许一种非常常见的安全漏洞,称为注入攻击。因为这个函数没有对 type_ 变量进行任何形式的验证,用户可以传递可能导致破坏或获取他们不应拥有的数据的代码。

有些人可能会认为在 Salt 中这并不是一个必然的问题,因为在许多环境中,只有受信任的用户应该有权访问。然而,由于 Salt 可以被各种用户类型使用,他们可能只被授权有限访问,因此存在许多场景,注入攻击可能会造成灾难性的后果。想象一下,一个用户正在运行以下 Salt 命令:

#salt myminion mysqltest.list 'tables; drop table users;'

这通常很容易修复,只需在用户输入中添加一些简单的检查(记住:信任但验证):

from salt.exceptions import CommandExecutionError

def list_(type_):
    '''
    List different resources in MySQL
    CLI Examples:
        salt '*' mysqltest.list tables
        salt '*' mysqltest.list databases
    '''
    dbc = _get_conn()
    cur = dbc.cursor()
    valid_types = ['tables', 'databases']
    if type_ not in valid_types:
        err_msg = 'A valid type was not specified'
        log.error(err_msg)
        raise CommandExecutionError(err_msg)
    return cur.execute('SHOW {0}()'.format(type_))

在这种情况下,我们在允许它们传递到 SQL 查询之前已经声明了哪些类型是有效的。即使是一个坏字符也会导致 Salt 拒绝完成命令。这种类型的数据验证通常更好,因为它不试图修改输入数据以使其安全运行。这样做被称为 验证用户输入

我们还添加了另一段代码:一个 Salt 异常。salt.exceptions 库中有许多这样的异常,但 CommandExecutionError 是你在验证数据时可能会经常使用的一个。

字符串格式化

关于字符串格式化的一点说明:较老的 Python 开发者可能已经注意到,我们选择使用 str.format() 而不是旧的 printf 风格的字符串处理。以下两行代码在 Python 中做的是同样的事情:

'The variable's value is {0}'.format(myvar)
'The variable's value is %s' % myvar

使用 str.format() 进行字符串格式化在 Python 中稍微快一点,但在 Salt 中除了不合适的地方外都是必需的。

不要被 Python 2.7.x 中可用的以下快捷方式所诱惑:

'The variable's value is {}'.format(myvar)

由于 Salt 仍然需要在 Python 2.6 上运行,而 Python 2.6 不支持使用 {} 代替 {0},这将为旧平台上的用户带来问题。

最后一个模块

当我们将所有前面的代码放在一起时,我们最终得到以下模块:

'''
This module should be saved as salt/modules/mysqltest.py
'''
import salt.utils

try:
    import MySQLdb
    HAS_LIBS = True
except ImportError:
    HAS_LIBS = False

import logging
log = logging.getLogger(__name__)

__func_alias__ = {
    'list_': 'list'
}

__virtualname__ = 'mysqltest'

def __virtual__():
    '''
    Check dependencies, using both methods from the chapter
    '''
    if not salt.utils.which('mysql'):
        return False

    if HAS_LIBS:
        return __virtualname__

    return False

def ping():
    '''
    Returns True

    CLI Example:
        salt '*' mysqltest.ping
    '''
    return True

def check_mysqld():
    '''
    Check to see if sshd is running and listening

    CLI Example:
        salt '*' testmodule.check_mysqld
    '''
    output = __salt__'cmd.run'
    if 'tcp' not in output:
        return False
    return True

def _get_conn():
    '''
    Get a database connection object
    '''
    user = __salt__'config.get'
    passwd = __salt__'config.get'
    host = __salt__'config.get'
    port = __salt__'config.get'
    db_ = __salt__'config.get'
    dbc = MySQLdb.connect(
        connection_user=user,
        connection_pass=passwd,
        connection_host=host,
        connection_port=port,
        connection_db=db_,
    )
    log.trace('Connected to the database')
    return dbc

def version():
    '''
    Returns MySQL Version

    CLI Example:
        salt '*' mysqltest.version
    '''
    dbc = _get_conn()
    cur = dbc.cursor()
    return cur.execute('SELECT VERSION()')

def list_(type_):
    '''
    List different resources in MySQL
    CLI Examples:
        salt '*' mysqltest.list tables
        salt '*' mysqltest.list databases
    '''
    dbc = _get_conn()
    cur = dbc.cursor()
    valid_types = ['tables', 'databases']
    if type_ not in valid_types:
        err_msg = 'A valid type was not specified'
        log.error(err_msg)
        raise CommandExecutionError(err_msg)
    return cur.execute('SHOW {0}()'.format(type_))

调试执行模块

就像任何编程一样,你花在编写执行模块上的时间越多,你遇到问题的可能性就越大。让我们花点时间来谈谈如何调试和调试你的代码。

使用 salt-call

salt-call 命令始终是测试和调试代码的有价值工具。没有它,每次你想测试新代码时,都需要重新启动 salt-minion 服务;相信我,这很快就会变得很无聊。

由于 salt-call 不会启动服务,它将始终运行 Salt 代码的最新副本。它确实做了 salt-minion 服务的大部分工作:加载粒度、连接到 Master(除非被告知不要连接)以获取 pillar 数据、通过加载器过程决定要加载哪些模块,然后执行请求的命令。几乎唯一不做的就是保持运行。

使用 salt-call 发送命令与使用 salt 命令相同,只是不需要指定目标(因为目标是 salt-call 运行的 Minion):

#salt '*' mysqltest.ping
#salt-call mysqltest.ping

你可能会注意到,尽管你正在同一台机器上发出 salt-call 命令,该机器将执行操作,但它通常会运行得慢一点。这有两个原因。首先,你仍然基本上每次都在启动 salt-minion 服务,而没有真正让它保持运行。这意味着检测粒度、加载模块等操作将不得不每次都进行。

要了解这实际上需要多少时间,尝试比较带有和不带有粒度检测的执行时间:

# time salt-call test.ping
local:
 True
real	0m3.257s
user	0m0.863s
sys	0m0.197s
# time salt-call --skip-grains test.ping
local:
 True
real	0m0.675s
user	0m0.507s
sys	0m0.080s

当然,如果你正在测试一个使用 grains 的模块,这不是一个可接受的战略。命令运行速度减慢的第二件事是必须连接到 Master。这并不像 grain 检测那样耗时,但它确实会减慢速度:

# time salt-call --local test.ping
local:
 True
real	0m2.820s
user	0m0.797s
sys	0m0.120s

--local标志不仅告诉salt-call不要与 Master 通信。实际上,它告诉salt-call使用自身作为 Master(这意味着,以本地模式运行)。如果你的模块使用了 Master 上的 pillar 或其他资源,那么你只需在本地提供服务即可。

主机配置文件中你需要配置的任何内容都可以直接复制到Minion文件中。如果你只是使用默认设置,你甚至不需要这样做:只需从 Master 复制必要的文件到 Minion:

# scp -r saltmaster:/srv/salt /srv
# scp -r saltmaster:/srv/pillar /srv

一切准备就绪后,使用--local标志启动salt-call并开始故障排除。

不可用

当我编写一个新的模块时,我遇到的第一大问题通常是让模块显示出来。这通常是因为代码明显有问题,比如打字错误。例如,如果我们把导入从salt.utils改为salt.util,我们的模块将无法加载:

$ grep 'import salt' salt/modules/mysqltest.py
import salt.util
# salt-call --local mysqltest.ping
'mysqltest.ping' is not available.

在这种情况下,我们可以通过以debug模式运行salt-call来找到问题:

# salt-call --local -l debug mysqltest.ping
...
[DEBUG   ] Failed to import module mysqltest:
Traceback (most recent call last):
 File "/usr/lib/python2.7/site-packages/salt/loader.py", line 1217, in _load_module
 ), fn_, fpath, desc)
 File "/usr/lib/python2.7/site-packages/salt/modules/mysqltest.py", line 4, in <module>
 import salt.util
ImportError: No module named util
...
'mysqltest.ping' is not available.

另一种可能是__virtual__()函数存在问题。这是我唯一一次建议向该函数添加日志消息的情况:

def __virtual__():
    '''
    Check dependencies, using both methods from the chapter
    '''
    log.debug('Checking for mysql command')
    if not salt.utils.which('mysql'):
        return False

    log.debug('Checking for libs')
    if HAS_LIBS:
        return __virtualname__

    return False

然而,确保在生产之前将它们移除,否则你迟早会有一批非常不满意的用户。

小贴士

下载示例代码

你可以从www.packtpub.com的账户下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以按照以下步骤下载代码文件:

  • 使用您的电子邮件地址和密码登录或注册我们的网站。

  • 将鼠标指针悬停在顶部的 SUPPORT 标签上。

  • 点击代码下载与勘误表。

  • 在搜索框中输入书籍名称。

  • 选择你想要下载代码文件的书籍。

  • 从下拉菜单中选择你购买这本书的地方。

  • 点击代码下载。

文件下载完成后,请确保使用最新版本的软件解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

摘要

学习如何编写执行模块为编写其他 Salt 模块奠定了良好的基础。Salt 包含了许多内置功能,其中许多功能适用于所有模块类型。一些库也随 Salt 一起打包在salt/utils/目录中。使用salt-call命令(尤其是在本地模式下)进行故障排除时,Salt 模块的调试最为容易。

接下来,我们将讨论可以用来处理配置的各种类型的 Salt 模块。

第三章:扩展 Salt 配置

到现在为止,您已经知道如何从 Salt 的各个部分访问配置变量,除了 SDB 模块,这将在本章中介绍。但在设置静态配置的同时,能够从外部源提供这些数据非常有用。在本章中,您将学习以下内容:

  • 编写动态 grains 和外部的 pillars

  • 故障排除 grains 和 pillars

  • 编写和使用 SDB 模块

  • 故障排除 SDB 模块

动态设置 grains

正如您已经知道的,grains 包含描述 Minion 某些方面的变量。这可能包括有关操作系统、硬件、网络等信息。它还可以包含静态定义的用户数据,这些数据配置在/etc/salt/minion/etc/salt/grains中。还可以使用 grains 模块动态定义 grains。

设置一些基本的 grains

Grains 模块很有趣,只要模块被加载,所有公共函数都会被执行。随着每个函数的执行,它将返回一个字典,其中包含要合并到 Minion 的 grains 中的项。

让我们继续设置一个新的 grains 模块来演示。我们将返回数据的名称前面加上一个z,以便于查找。

'''
Test module for Extending SaltStack

This module should be saved as salt/grains/testdata.py
'''

def testdata():
    '''
    Return some test data
    '''
    return {'ztest1': True}

继续将此文件保存为salt/grains/testdata.py,然后使用salt-call显示所有 grains,包括这个:

# salt-call --local grains.items
local:
 ----------
...
 virtual:
 physical
 zmqversion:
 4.1.3
 ztest1:
 True

请记住,您也可以使用 grains.item 来仅显示单个 grain:

# salt-call --local grains.item ztest
local:
 ----------
 ztest1:
 True

这个模块可能看起来并不怎么有用,因为这只是静态数据,这些数据可以在miniongrains文件中定义。但请记住,与其他模块一样,grains 模块可以使用__virtual__()函数进行控制。让我们继续设置它,以及一个决定此模块是否首先加载的某种类型的标志:

import os.path

def __virtual__():
    '''
    Only load these grains if /tmp/ztest exists
    '''
    if os.path.exists('/tmp/ztest'):
        return True
    return False

继续运行以下命令以查看此功能的作用:

# salt-call --local grains.item ztest
local:
 ----------
 ztest:
# touch /tmp/ztest
# salt-call --local grains.item ztest
local:
 ----------
 ztest:
 True

这对于控制整个模块的返回数据非常有用,无论是动态的还是,如这个模块目前所是,静态的。

您可能想知道为什么那个例子检查了文件的存在,而不是检查现有的 Minion 配置。这是为了说明检测某些系统属性可能会决定如何设置 grains。如果您只想在minion文件中设置一个标志,您可以从__opts__中提取它。让我们继续将其添加到__virtual__()函数中:

def __virtual__():
    '''
    Only load these grains if /tmp/ztest exists
    '''
    if os.path.exists('/tmp/ztest'):
        return True
    if __opts__.get('ztest', False):
        return True
    return False

继续删除旧的标志,并设置新的标志:

# rm /tmp/ztest
# echo 'ztest: True' >> /etc/salt/minion
# salt-call --local grains.item ztest
local:
 ----------
 ztest:
 True

让我们继续设置这个模块,使其也能返回动态数据。由于 YAML 在 Salt 中非常普遍,让我们设置一个函数,返回 YAML 文件的内容:

import yaml
import salt.utils

def yaml_test():
    '''
    Return sample data from /etc/salt/test.yaml
    '''
    with salt.utils.fopen('/etc/salt/yamltest.yaml', 'r') as fh_:
        return yaml.safe_load(fh_)

您可能会注意到,我们使用了salt.utils.fopen()而不是标准的 Python open()。Salt 的fopen()函数用一些额外的处理包装了 Python 的open(),以确保在 Minions 上正确关闭文件。

保存你的模块,然后输入以下命令以查看结果:

# echo 'yamltest: True' > /etc/salt/yamltest.yaml
# salt-call --local grains.item yamltest
local:
 ----------
 yamltest:
 True

(不)跨调用执行模块

你可能会尝试从谷物模块内部跨调用执行模块。不幸的是,这是不可能的。许多执行模块中的__virtual__()函数严重依赖于谷物。如果允许谷物在 Salt 决定是否首先启用执行模块之前就跨调用执行模块,将会导致循环依赖。

只需记住,谷物首先加载,然后是柱子,然后是执行模块。如果你打算使用两种或更多这类模块的代码,考虑在salt/utils/目录下为它设置一个库。

最终的谷物模块

我们已经编写了所有这些代码,生成的模块应该看起来如下:

'''
Test module for Extending SaltStack.

This module should be saved as salt/grains/testdata.py
'''
import os.path
import yaml
import salt.utils

def __virtual__():
    '''
    Only load these grains if /tmp/ztest exists
    '''
    if os.path.exists('/tmp/ztest'):
        return True
    if __opts__.get('ztest', False):
        return True
    return False

def testdata():
    '''
    Return some test data
    '''
    return {'ztest1': True}

def yaml_test():
    '''
    Return sample data from /etc/salt/test.yaml
    '''
    with salt.utils.fopen('/etc/salt/yamltest.yaml', 'r') as fh_:
        return yaml.safe_load(fh_)

创建外部柱子

正如你所知,柱子就像谷物,有一个关键的区别:谷物是在 Minion 上定义的,而柱子是为单个 Minion 定义的,从 Master 那里定义。

对于用户来说,这里没有太多区别,除了柱子必须映射到 Master 上的目标,使用pillar_roots中的top.sls文件。这样的映射可能看起来像这样:

# cat /srv/pillar/top.sls
base:
 '*':
 - test

在这个例子中,我们会定义一个名为 test 的柱子,它可能看起来像这样:

# cat /srv/pillar/test.sls
test_pillar: True

动态柱子在top.sls文件中仍然被映射,但在配置方面,相似之处到此为止。

配置外部柱子

与动态谷物不同,只要它们的__virtual__()函数允许它们这样做,就会一直运行,柱子必须在master配置文件中显式启用。或者,如果我们像我们这样在本地模式下运行,在minion配置文件中。让我们继续在/etc/salt/minion的末尾添加以下行:

ext_pillar:
  - test_pillar: True

如果我们在 Master 上测试这个,我们需要重新启动salt-master服务。然而,由于我们在 Minion 上以本地模式测试,这不会是必需的。

添加外部柱子

我们还需要创建一个简单的外部柱子来开始。创建salt/pillar/test_pillar.py并包含以下内容:

'''
This is a test external pillar
'''

def ext_pillar(minion_id, pillar, config):
    '''
    Return the pillar data
    '''
    return {'test_pillar': minion_id}

保存你的工作,然后测试以确保它工作正常:

# salt-call --local pillar.item test_pillar
local:
 ----------
 test_pillar:
 dufresne

让我们回顾一下这里发生了什么。首先,我们有一个名为ext_pillar()的函数。这个函数在所有外部柱子中都是必需的。它也是唯一必需的函数。任何其他函数,无论是否以前置下划线命名,都将仅限于这个模块。

这个函数将始终传递三份数据。第一份是请求此柱子的 Minion 的 ID。你可以在我们的例子中看到这一点:之前示例中运行的minion_iddufresne。第二份是为这个 Minion 定义的静态柱子的副本。第三份是在master(或在这种情况下,minion)配置文件中传递给这个外部柱子的额外数据。

让我们继续更新我们的支柱,以显示每个组件的外观。将你的ext_pillar()函数更改为如下所示:

def ext_pillar(minion_id, pillar, command):
    '''
    Return the pillar data
    '''
    return {'test_pillar': {
        'minion_id': minion_id,
        'pillar': pillar,
        'config': config,
    }}

保存它,然后修改你的minion(或master)文件中的ext_pillar配置:

ext_pillar:
  - test_pillar: Alas, poor Yorik. I knew him, Horatio.

再次查看你的支柱数据:

# salt-call --local pillar.item test_pillar
local:
 ----------
 test_pillar:
 ----------
 config:
 Alas, poor Yorik. I knew him, Horatio.
 minion_id:
 dufresne
 pillar:
 ----------
 test_pillar:
 True

你可以看到我们之前提到的test_pillar。当然,你也可以看到minion_id,就像之前一样。这里的重要部分是config

这个例子被选出来是为了清楚地说明config参数是从哪里来的。当一个外部支柱被添加到ext_pillar列表中时,它作为一个字典被输入,其值只有一个条目。指定的条目可以是字符串、布尔值、整数或浮点数。它不能是字典或列表。

这个参数通常用于从配置文件传递参数到支柱。例如,Salt 附带的cmd_yaml支柱使用它来定义一个预期以 YAML 格式返回数据的命令:

ext_pillar:
- cmd_yaml: cat /etc/salt/testyaml.yaml

如果你的支柱只需要被启用,那么你只需将其设置为 True,然后忽略它即可。然而,你仍然必须设置它!Salt 会期望那里有数据,如果没有,你会收到这样的错误:

[CRITICAL] The "ext_pillar" option is malformed

小贴士

虽然minion_idpillarconfig都按顺序传递到ext_pillar()函数中,但 Salt 实际上并不关心你在函数定义中如何命名变量。如果你想叫它们 Emeril、Mario 和 Alton 也行(虽然你不会这么做)。但无论你叫它们什么,它们都必须都在那里。

另一个外部支柱

让我们再组合另一个外部支柱,这样它就不会和我们的第一个支柱混淆。这个支柱的工作是检查网络服务的状态。首先,让我们编写我们的支柱代码:

'''
Get status from HTTP service in JSON format.

This file should be saved as salt/pillar/http_status.py
'''
import salt.utils.http

def ext_pillar(minion_id, pillar, config):
    '''
    Call a web service which returns status in JSON format
    '''
    comps = config.split()
    key = comps[0]
    url = comps[1]
    status = salt.utils.http.query(url, decode=True)
    return {key: status['dict']}

你可能已经注意到,我们的docstring声明说这个文件应该保存为salt/pillar/http_status.py。当你检查 Salt 代码库时,有一个名为salt/的目录包含实际的代码。这就是docstring中提到的目录。你将在本书中的代码示例中继续看到这些注释。

将此文件保存为salt/pillar/http_status.py。然后继续更新你的ext_pillar配置,使其指向它。现在,我们将使用 GitHub 的状态 URL:

ext_pillar
  - http_status: github https://status.github.com/api/status.json

继续保存配置,然后测试支柱:

# salt-call --local pillar.item github
local:
 ----------
 github:
 ----------
 last_updated:
 2015-12-02T05:22:16Z
 status:
 good

如果你需要能够检查多个服务的状态,你可以多次使用同一个外部支柱,但使用不同的配置。尝试更新你的ext_pillar定义,包含两个条目:

ext_pillar
  - http_status: github https://status.github.com/api/status.json
  - http_status: github2 https://status.github.com/api/status.json

现在,这可能会迅速成为一个问题。如果你不断地调用 GitHub 的状态 API,GitHub 不会高兴。所以,虽然获取实时状态更新很好,但你可能想做一些限制你的查询。让我们将状态保存到文件中,并从那里返回。我们将检查文件的最后修改时间戳,以确保它不会在一分钟内更新多次。

让我们继续更新整个外部 pillar:

'''
Get status from HTTP service in JSON format.

This file should be saved as salt/pillar/http_status.py
'''
import json
import time
import datetime
import os.path
import salt.utils.http

def ext_pillar(minion_id,  # pylint: disable=W0613
               pillar,  # pylint: disable=W0613
               config):
    '''
    Return the pillar data
    '''
    comps = config.split()

    key = comps[0]
    url = comps[1]

    refresh = False
    status_file = '/tmp/status-{0}.json'.format(key)
    if not os.path.exists(status_file):
        refresh = True
    else:
        stamp = os.path.getmtime(status_file)
        now = int(time.mktime(datetime.datetime.now().timetuple()))
        if now - 60 >= stamp:
            refresh = True

    if refresh:
        salt.utils.http.query(url, decode=True, decode_out=status_file)

    with salt.utils.fopen(status_file, 'r') as fp_:
        return {key: json.load(fp_)}

现在我们设置了一个名为 refresh 的标志,并且只有当该标志为 True 时才会访问 URL。我们还定义了一个将缓存从该 URL 获取的内容的文件。该文件将包含 pillar 的名称,因此最终会有一个像 /tmp/status-github.json 这样的名称。以下两行将检索文件的最后修改时间和当前时间(以秒为单位):

        stamp = os.path.getmtime(status_file)
        now = int(time.mktime(datetime.datetime.now().timetuple()))

通过比较这两个,我们可以确定文件是否超过 60 秒。如果我们想使 pillar 更具可配置性,甚至可以将那个 60 移动到 config 参数,并从 comps[2] 中获取它。

故障排除 grains 和 pillars

在编写 grains 和 pillars 时,你可能会遇到一些困难。让我们看看你可能会遇到的最常见问题。

动态 grains 未显示

你可能会发现,当你从 Master 发出 grains.items 命令时,你的动态 grains 没有显示。这可能很难追踪,因为 grains 在 Minion 上评估,任何错误都不太可能通过线路返回给你。

当你发现动态 grains 没有按照预期显示时,通常最简单的方法是直接登录到 Minion 进行故障排除。打开一个 shell 并尝试执行一个 salt-call 命令,看看是否有错误出现。如果它们没有立即出现,尝试在命令中添加 --log-level=debug 来查看是否有错误隐藏在那个级别。使用 trace 日志级别可能也是必要的。

外部 pillars 未显示

这些可能有点难以找出。使用 salt-call 在 grains 中查找错误是有效的,因为所有代码都可以在不启动或联系服务的情况下执行。但是,pillars 来自 Master,除非你在 local 模式下运行 salt-call

如果你能够在一个 Minion 上安装你的外部 pillar 代码进行测试,那么步骤与检查 grains 错误相同。但是,如果你发现自己处于 Master 的环境无法在 Minion 上复制的情形,你需要使用不同的策略。

在 Master 上停止 salt-master 服务,然后以调试日志级别重新启动它:

# salt-master --log-level debug

然后打开另一个 shell 并检查受影响的 Minion 的 pillars:

# salt <minionid> pillar.items

pillar 代码中的任何错误都应该在 salt-master 以前台运行时在窗口中显示出来。

编写 SDB 模块

SDB 是一种相对较新的模块类型,非常适合开发。它代表 Simple Database,它旨在允许数据以非常简短的 URI 查询。底层配置可以像必要的那么复杂,只要用于查询它的 URI 尽可能简单。

SDB 的另一个设计目标是 URI 可以隐藏敏感信息,使其不会直接存储在配置文件中。例如,密码通常用于其他类型的模块,如mysql模块。但是,将密码存储在随后存储在版本控制系统(如 Git)中的文件中是一种不良做法。

使用 SDB 即时查找密码允许存储密码的引用,但不是密码本身。这使得在版本控制系统中存储引用敏感数据的文件变得更加安全。

有一种假设的功能可能会让人想要使用 SDB:在 Minion 上存储加密数据,这些数据不能被 Master 读取。可以在 Minion 上运行需要本地认证的代理,例如从 Minion 的键盘输入密码,或者使用硬件加密设备。可以创建利用这些代理的 SDB 模块,由于它们的本质,认证凭证本身不能被 Master 获取。

问题在于 Master 可以访问任何订阅它的 Minion 可以访问的内容。尽管数据可能存储在 Minion 上的加密数据库中,并且尽管其传输到 Master 时肯定被加密,但一旦到达 Master,它仍然可以以明文形式读取。

获取 SDB 数据

SDB 只使用了两个公共函数:getset。实际上,其中最重要的一个是get,因为set通常可以在 Salt 之外完成。让我们先看看get函数。

对于我们的示例,我们将创建一个模块,它读取 JSON 文件,然后从中返回请求的键。首先,让我们设置我们的 JSON 文件:

{
    "user": "larry",
    "password": "123pass"
}

将该文件保存为/root/mydata.json。然后编辑minion配置文件并添加一个配置配置文件:

myjson:
    driver: json
    json_file: /root/mydata.json

准备好这两样东西后,我们就可以开始编写我们的模块了。JSON 有一个非常简单的接口,所以这里不会有太多内容:

'''
SDB module for JSON

This file should be saved as salt/sdb/json.py
'''
from __future__ import absolute_import
import salt.utils
import json

def get(key, profile=None):
    '''
    Get a value from a JSON file
    '''
    with salt.utils.fopen(profile['json_file'], 'r') as fp_:
        json_data = json.load(fp_)
    return json_data.get(key, None)

你可能已经注意到,我们在必要的 JSON 代码之外添加了一些额外的东西。首先,我们导入了一个名为absolute_import的东西。这是因为这个文件叫做json.py,它正在导入另一个名为json的库。如果没有absolute_import,该文件将尝试导入自己,并且无法从实际的json库中找到必要的函数。

get()函数接受两个参数:keyprofilekey指的是将用于访问所需数据的键。profile是我们保存到minion配置文件中的myjson配置文件数据的副本。

SDB URI 使用了这两个项目。当我们构建该 URI 时,它将被格式化为:

sdb://<profile_name>/<key>

例如,如果我们使用sdb执行模块来检索key1的值,我们的命令将如下所示:

# salt-call --local sdb.get sdb://myjson/user
local:
 larry

在此模块和配置文件就绪的情况下,我们现在可以向 minion 配置文件(或 grains 或 pillars,甚至 master 配置文件)中添加类似以下内容的行:

username: sdb://myjson/user
password: sdb://myjson/password

当一个使用 config.get 的模块遇到 SDB URI 时,它将自动即时将其转换为适当的数据。

在我们继续之前,让我们稍微更新一下这个函数,以便进行一些错误处理。如果用户在配置文件中输入了错误(例如 json_fle 而不是 json_file),或者引用的文件不存在,或者 JSON 格式不正确,那么这个模块将开始输出跟踪回执消息。让我们继续处理所有这些问题,使用 Salt 的 CommandExecutionError

from __future__ import absolute_import
from salt.exceptions import CommandExecutionError
import salt.utils
import json

def get(key, profile=None):
    '''
    Get a value from a JSON file
    '''
    try:
        with salt.utils.fopen(profile['json_file'], 'r') as fp_:
            json_data = json.load(fp_)
        return json_data.get(key, None)
    except IOError as exc:
        raise CommandExecutionError (exc)
    except KeyError as exc:
        raise CommandExecutionError ('{0} needs to be configured'.format(exc))
    except ValueError as exc:
        raise CommandExecutionError (
            'There was an error with the JSON data: {0}'.format(exc)
        )

IOError 会捕获指向非实际文件的路径的问题。KeyError 会捕获缺少配置文件(如果其中一个项拼写错误)的错误。ValueError 会捕获格式不正确的 JSON 文件的问题。这将使错误变成:

Traceback (most recent call last):
  File "/usr/bin/salt-call", line 11, in <module>
    salt_call()
  File "/usr/lib/python2.7/site-packages/salt/scripts.py", line 333, in salt_call
    client.run()
  File "/usr/lib/python2.7/site-packages/salt/cli/call.py", line 58, in run
    caller.run()
  File "/usr/lib/python2.7/site-packages/salt/cli/caller.py", line 133, in run
    ret = self.call()
  File "/usr/lib/python2.7/site-packages/salt/cli/caller.py", line 196, in call
    ret['return'] = func(*args, **kwargs)
  File "/usr/lib/python2.7/site-packages/salt/modules/sdb.py", line 28, in get
    return salt.utils.sdb.sdb_get(uri, __opts__)
  File "/usr/lib/python2.7/site-packages/salt/utils/sdb.py", line 37, in sdb_get
    return loaded_dbfun
  File "/usr/lib/python2.7/site-packages/salt/sdb/json_sdb.py", line 49, in get
    with salt.utils.fopen(profile['json_fil']) as fp_:
KeyError: 'json_fil'

...变成这样的错误:

Error running 'sdb.get': 'json_fil' needs to be configured

设置 SDB 数据

用于 set 的函数可能看起来很奇怪,因为 set 是 Python 的内置函数。这意味着该函数可能不被称为 set();它必须被命名为其他名称,然后使用 __func_alias__ 字典给出别名。让我们继续创建一个除了返回要设置的 value 之外什么都不做的函数:

__func_alias__ = {
    'set_': 'set'
}

def set_(key, value, profile=None):
    '''
    Set a key/value pair in a JSON file
    '''
    return value

对于只读数据,这对你来说已经足够了,但在这个案例中,我们将修改 JSON 文件。首先,让我们看看传递给我们的函数的参数。

你已经知道,数据的关键点是要引用的,并且配置文件包含 Minion 配置文件中的配置数据副本。你可能也能猜到,值包含要应用的数据副本。

值不会改变实际的 URI;无论你是获取还是设置数据,它始终相同。执行模块本身接受要设置的数据,然后设置它。你可以通过以下方式看到这一点:

# salt-call --local sdb.set sdb://myjson/password 321pass
local:
 321pass

考虑到这一点,让我们继续让我们的模块读取 JSON 文件,应用新值,然后再将其写回。目前,我们将跳过错误处理,以便更容易阅读:

def set_(key, value, profile=None):
    '''
    Set a key/value pair in a JSON file
    '''
    with salt.utils.fopen(profile['json_file'], 'r') as fp_:
        json_data = json.load(fp_)

    json_data[key] = value

    with salt.utils.fopen(profile['json_file'], 'w') as fp_:
        json.dump(json_data, fp_)

    return get(key, profile)

这个函数与之前一样读取 JSON 文件,然后更新特定的值(如果需要则创建它),然后写回文件。完成时,它使用 get() 函数返回数据,这样用户就知道是否设置正确。如果返回错误的数据,那么用户就会知道出了问题。它不一定能告诉他们出了什么问题,但会拉响一个红旗。

让我们继续添加一些错误处理来帮助用户了解出了什么问题。我们将继续添加来自 get() 函数的错误处理:

def set_(key, value, profile=None):
    '''
    Set a key/value pair in a JSON file
    '''
    try:
        with salt.utils.fopen(profile['json_file'], 'r') as fp_:
            json_data = json.load(fp_)
    except IOError as exc:
        raise CommandExecutionError (exc)
    except KeyError as exc:
        raise CommandExecutionError ('{0} needs to be configured'.format(exc))
    except ValueError as exc:
        raise CommandExecutionError (
            'There was an error with the JSON data: {0}'.format(exc)
        )

    json_data[key] = value

    try:
        with salt.utils.fopen(profile['json_file'], 'w') as fp_:
            json.dump(json_data, fp_)
    except IOError as exc:
        raise CommandExecutionError (exc)

    return get(key, profile)

由于我们在读取文件时进行了所有错误处理,当我们再次写入时,我们已经知道路径是有效的,JSON 是有效的,并且没有配置错误。然而,保存文件时仍然可能出错。尝试以下操作:

# chattr +i /root/mydata.json
# salt-call --local sdb.set sdb://myjson/password 456pass
Error running 'sdb.set': [Errno 13] Permission denied: '/root/mydata.json'

我们已将文件的属性更改为不可变(只读),因此我们不能再写入文件。如果没有IOError,我们就会得到一个像以前一样的难看的跟踪回信息。移除不可变属性将允许我们的函数正常运行:

# chattr -i /root/mydata.json
# salt-call --local sdb.set sdb://myjson/password 456pass
local:
 456pass

使用描述性的docstring

使用 SDB 模块时,添加一个演示如何配置和使用模块的docstring比以往任何时候都更重要。没有它,用户几乎不可能弄清楚如何使用模块,尝试修改模块的情况甚至更糟。

docstring不需要是新颖的。它应该包含足够的信息来使用模块,但不要太多,以免弄清楚事情变得令人困惑和沮丧。你应该包括配置数据的示例,以及与该模块一起使用的 SDB URI:

'''
SDB module for JSON

Like all sdb modules, the JSON module requires a configuration profile to
be configured in either the minion or master configuration file. This profile
requires very little. In the example:

.. code-block:: yaml

    myjson:
      driver: json
      json_file: /root/mydata.json

The ``driver`` refers to the json module and json_file is the path to the JSON
file that contains the data.

.. code-block:: yaml

    password: sdb://myjson/somekey
'''

使用更复杂的配置

可能会诱使人们创建使用更复杂 URI 的 SDB 模块。例如,完全有可能创建一个支持如下 URI 的模块:

sdb://mydb/user=curly&group=ops&day=monday

使用前面的 URI,传入的key将是:

user=curly&group=ops&day=monday

到那时,将取决于你解析出密钥并将其转换为代码可用的东西。然而,我强烈反对这样做!

你使 SDB URI 越复杂,它就越不像简单的数据库查找。你还可能以意想不到的方式暴露数据。再次看看前面的key。它揭示了以下关于存储敏感信息的数据库的信息:

  • 存在一个被称为用户的字段(抽象的或真实的)。由于用户往往比他们想象的要懒惰,这很可能是指向一个名为 user 的真实数据库字段。如果是这样,那么这暴露了数据库模式的一部分。

  • 有一个名为 ops 的组。这意味着还有其他组。由于ops通常指的是执行服务器操作任务的团队,那么这意味着还有一个名为dev的组吗?如果 dev 组被攻破,攻击者能窃取到哪些有价值的资料?

  • 指定了一天。这家公司是否每天轮换密码?指定monday的事实意味着最多只有七个密码:一周中每一天一个。

与其将所有这些信息放入 URL 中,通常更好的做法是将它们隐藏在配置文件中。可以假设mydb指的是一个数据库连接(如果我们把配置文件命名为mysql,我们就会暴露数据库连接的类型)。跳过任何可能存在的数据库凭证,我们可以使用如下配置文件:

mydb:
  driver: <some SDB module>
  fields:
    user: sdbkey
    group: ops
    day: monday

假设相关的模块能够将这些fields 转换成查询,并在内部将sdbkey更改为实际传入的任何key,我们可以使用如下看起来像的 URI:

sdb://mydb/curly

你仍然可以猜测curly指的是用户名,当 URI 与配置参数如一起使用时,这可能是更加明显的:

username: sdb://mydb/curly

然而,它并没有暴露数据库中的字段名称。

最终的 SDB 模块

在我们编写的所有代码中,生成的模块应该看起来像以下这样:

'''
SDB module for JSON

Like all sdb modules, the JSON module requires a configuration profile to
be configured in either the minion or master configuration file. This profile
requires very little. In the example:

.. code-block:: yaml

    myjson:
      driver: json
      json_file: /root/mydata.json

The ``driver`` refers to the json module and json_file is the path to the JSON
file that contains the data.

.. code-block:: yaml

    password: sdb://myjson/somekey
'''
from __future__ import absolute_import
from salt.exceptions import CommandExecutionError
import salt.utils
import json

__func_alias__ = {
    'set_': 'set'
}

def get(key, profile=None):
    '''
    Get a value from a JSON file
    '''
    try:
        with salt.utils.fopen(profile['json_file'], 'r') as fp_:
            json_data = json.load(fp_)
        return json_data.get(key, None)
    except IOError as exc:
        raise CommandExecutionError (exc)
    except KeyError as exc:
        raise CommandExecutionError ('{0} needs to be configured'.format(exc))
    except ValueError as exc:
        raise CommandExecutionError (
            'There was an error with the JSON data: {0}'.format(exc)
        )

def set_(key, value, profile=None):  # pylint: disable=W0613
    '''
    Set a key/value pair in a JSON file
    '''
    try:
        with salt.utils.fopen(profile['json_file'], 'r') as fp_:
            json_data = json.load(fp_)
    except IOError as exc:
        raise CommandExecutionError (exc)
    except KeyError as exc:
        raise CommandExecutionError ('{0} needs to be configured'.format(exc))
    except ValueError as exc:
        raise CommandExecutionError (
            'There was an error with the JSON data: {0}'.format(exc)
        )

    json_data[key] = value

    try:
        with salt.utils.fopen(profile['json_file'], 'w') as fp_:
            json.dump(json_data, fp_)
    except IOError as exc:
        raise CommandExecutionError (exc)

    return get(key, profile)

使用 SDB 模块

有许多地方可以使用 SDB 模块。因为 SDB 检索被集成在config执行模块中的config.get函数中,以下位置可以用来为 Minion 设置一个值:

  • Minion 配置文件

  • 粒子

  • 柱子

  • 主配置文件

SDB 也由 Salt Cloud 支持,因此你还可以在以下位置设置 SDB URI:

  • 主要云配置文件

  • 云配置文件

  • 云提供商

  • 云映射

无论你在哪里设置 SDB URI,格式都是相同的:

<setting name>: sdb://<profile name>/<key>

这对于云提供商特别有用,所有这些都需要凭证,但其中许多也使用更复杂的配置块,这些配置块应该被纳入版本控制。

openstack云提供商为例:

my-openstack-config:
  identity_url: https://keystone.example.com:35357/v2.0/
  compute_region: intermountain
  compute_name: Compute
  tenant: sdb://openstack_creds/tenant
  user: sdb://openstack_creds/username
  ssh_key_name: sdb://openstack_creds/keyname

在这个组织中,compute_regioncompute_name可能是公开的。而identity_url肯定也是(否则,你怎么进行认证呢?)。但其他信息可能应该保持隐藏。

如果你曾经在使用 Salt Cloud 设置 OpenStack,你可能已经使用了许多其他参数,其中许多可能不是敏感的。然而,一个复杂的配置文件可能应该保存在版本控制系统。使用 SDB URI,你可以这样做而不必担心暴露敏感数据。

SDB 模块故障排除

我们已经介绍了一些可以添加到我们的 SDB 模块中的错误处理,但你可能仍然会遇到问题。像粒子和柱子一样,最常见的问题是在预期中数据没有显示出来。

SDB 数据未显示

你可能会发现,当你将 SDB URI 包含在配置中时,它并没有像你想象的那样解析。如果你在早期的 SDB 代码中犯了拼写错误,你可能已经发现sdb.get在存在语法错误时会非常乐意抛出跟踪回溯。但如果使用salt-callsdb.get上没有引发你可以看到的任何错误,那么可能不是你的代码中的问题。

在开始责怪其他服务之前,最好确保你不是问题所在。开始记录关键信息,以确保它以你期望的方式显示。确保在模块顶部添加以下行:

import logging
log = logging.getLogger(__name__)

然后,你可以使用log.debug()来记录这些信息。如果你正在记录敏感信息,你可能想使用log.trace()代替,以防你忘记取出日志消息。

你可能想从记录每个函数接收到的信息开始,以确保它看起来符合你的预期。让我们先看看之前提到的 get() 示例,并添加一些日志记录:

def get(key, profile=None):
    '''
    Get a value from a JSON file
    '''
    import pprint
    log.debug(key)
    log.debug(pprint.pformat(profile))
    with salt.utils.fopen(profile['json_file'], 'r') as fp_:
        json_data = json.load(fp_)
    return json_data.get(key, None)

我们在这里只添加了几行日志,但使用了 Python 的 pprint 库来格式化其中之一。pprint.pformat() 函数用于格式化打算存储在字符串中或传递给函数的文本,而不是像 pprint.pprint() 那样直接输出到 STDOUT

如果你的 SDB 模块连接到某个服务,你可能会发现该服务本身不可用。这可能是由于未知的或意外的防火墙规则、网络错误,或者服务本身的实际停机。在代码中分散日志消息将帮助你发现它失败的地方,这样你就可以在那里解决问题。

摘要

可以通过加载系统连接到 Salt 配置的三个区域是动态 grains、外部 pillars 和 SDB。Grains 在 Minion 上生成,pillars 在 Master 上生成,SDB URI 可以在这两个地方配置。

SDB 模块允许配置存储在外部,但可以从 Salt 配置的各个部分引用。当从执行模块访问时,它们在 Minion 上解析。当从 Salt-Cloud 访问时,它们在运行 Salt Cloud 的任何系统上解析。

现在我们已经处理好了配置,是时候深入配置管理了,通过在执行模块周围包装状态模块来实现。

第四章:将状态包裹在执行模块周围

现在我们已经介绍了执行模块和配置模块,是时候讨论配置管理了。状态模块背后的想法是使用执行模块作为一个机制,将资源带到某种状态:一个软件包处于安装状态,一个服务处于运行状态,一个文件的内容与 Master 上定义的状态相匹配。在本章中,我们将讨论:

  • 基本状态模块布局背后的概念

  • 决定每个状态要推进多远

  • 故障排除状态模块

构建状态模块

状态模块比大多数其他类型的模块更有结构,但正如你很快就会看到的,这实际上使它们更容易编写。

确定状态

状态模块必须执行一系列操作以完成其工作,并且在这些操作执行过程中,会存储某些数据。让我们从一个伪代码片段开始,并依次解释每个组件:

def __virtual__():
    '''
    Only load if the necesaary modules available in __salt__
    '''
    if 'module.function' in __salt__:
        return True
    return False

def somestate(name):
    '''
    Achieve the desired state

    nane
        The name of the item to achieve statefulness
    '''
    ret = {'name': name,
           'changes': {},
           'result': None,
           'comment': ''}
    if <item is already in the desired state>:
        ret['result'] = True
        ret['comment'] = 'The item is already in the desired state'
        return ret
    if __opts__['test']:
        ret['comment'] = 'The item is not in the desired state'
        return ret
    <attempt to configure the item correctly>
    if <we are able to put the item in the correct state>:
        ret['changes'] = {'desired state': name}
        ret['result'] = True
        ret['comment'] = 'The desired state was successfully achieved'
        return ret
    else:
        ret['result'] = False
        ret['comment'] = 'The desired state failed to be achieved'
        return ret

__virtual__() 函数

到现在为止,你已经熟悉这个函数了,但我想在这里再次提到它。因为执行模块旨在执行繁重的工作,所以在尝试使用它们之前确保它们可用是至关重要的。

很有可能你需要在你的状态模块内部跨调用多个函数。通常,你会调用至少一个函数来检查相关项的状态,至少再调用一个来将项带入所需的配置。但如果它们都在同一个执行模块中,你实际上只需要检查其中一个的存在。

假设你将要编写一个使用 http.query 执行模块进行查找和更改 Web 资源的状态的函数。这个函数应该始终可用,但为了演示的目的,我们将假设我们需要检查它。编写这个函数的一种方法可以是:

def __virtual__():
    '''
    Check for http.query
    '''
    if 'http.query' in __salt__:
        return True
    return False

也有一种更简短的方式来做到这一点:

def __virtual__():
    '''
    Check for http.query
    '''
    return 'http.query' in __salt__

设置默认值

在处理完 __virtual__() 函数之后,我们可以继续讨论状态函数本身。首先,我们在字典中设置一些默认变量。在我们的示例中,以及在大多数状态模块中,这个字典被称为 ret。这仅是一种惯例,并不是实际的要求。然而,字典内部的键及其数据类型是硬性要求。这些键包括:

  • name(字符串)- 这是传递到状态中的资源的名称。这也被称为状态中的 ID。例如,在以下状态中:

    nginx:
      - pkg.installed
    
    • 传入的名称将是 nginx
  • changes(字典)- 如果状态对 Minion 应用了任何更改,这个字典将包含对已应用的每个更改的条目。例如,如果使用了 pkg.installed 来安装 nginx,则 changes 字典将如下所示:

    {'nginx': {'new': '1.8.0-2',  'old': ''}}
    
    • 对存储在changes中的数据类型没有限制,只要changes本身是一个字典。如果进行了更改,则此字典必须包含一些内容。
  • result(布尔值)- 此字段是三个值之一:TrueFalseNone。如果指定的资源已经处于它应该处于的状态,或者它已经被成功配置到该状态,则此字段将为True。如果资源不在正确的状态,但salttest=True运行,则此字段设置为None。如果资源不在正确的状态,并且 Salt 无法将其置于正确的状态,则此字段设置为False

    • 在执行状态运行,如state.highstate时,结果值将影响输出的颜色。状态为True但没有changes的状态将是绿色。状态为True且有changes的状态将是蓝色。状态为None的状态将是黄色。状态为False的状态将是红色。
  • comment(字符串)- 此字段完全自由格式:它可以包含任何你想要的注释,或者没有注释。然而,最好有一些注释,即使像“请求的资源已经处于期望状态”这样简短也行。如果结果是NoneFalse,则comment应包含尽可能有帮助的消息,说明为什么资源没有正确配置,以及如何纠正。

    • 我们在示例中使用的默认值几乎适用于任何状态:

          ret = {'name': name,
                 'changes': {},
                 'result': None,
                 'comment': ''}
      

检查真实性

在设置默认值之后,接下来的任务是检查资源,看看它是否处于期望的状态:

    if <item is already in the desired state>:
        ret['result'] = True
        ret['comment'] = 'The item is already in the desired state'
        return ret

这可能是一个使用执行模块中的单个函数进行的快速检查,或者可能包含需要跨调用几个函数的更多逻辑。不要在这里添加任何不必要的代码来检查资源的状态;记住,所有重负载都应该在执行模块中完成。

如果发现资源配置得当,则将result设置为True,添加一个有用的comment,然后函数returns。如果资源没有正确配置,则继续下一部分。

检查测试模式

如果代码通过了真实性检查,那么我们可以假设有问题。但在对系统进行任何更改之前,我们需要查看salt是否以test=True被调用。

    if __opts__['test']:
        ret['comment'] = 'The item is not in the desired state'
        return ret

如果是这样,我们为用户设置一个有用的comment,然后return``ret字典。如果一旦确定salt正在test模式下运行,还有更多的逻辑发生,那么它应该只用于在注释中为用户提供更多信息。在test模式下永远不应该进行任何更改!

尝试配置资源

如果我们通过了test模式的检查,那么我们知道我们可以尝试更改以正确配置资源:

    <attempt to configure the item correctly>
    if <we are able to put the item in the correct state>:
        ret['changes'] = {'desired state': name}
        ret['result'] = True
        ret['comment'] = 'The desired state was successfully achieved'
        return ret

同样,这段代码应该只包含足够的逻辑来正确配置相关资源,并在成功时通知用户。如果更改成功,那么我们更新changes字典,添加一个描述如何实现这些changescomment,将result设置为True,然后return

通知关于错误

如果我们通过了那段代码,我们现在可以确信出了问题,我们无法修复它:

    else:
        ret['result'] = False
        ret['comment'] = 'The desired state failed to be achieved'
        return ret

这是代码中最重要的部分,因为用户交互很可能会被用来修复问题。

可能是 SLS 文件只是写得不好,下一次状态运行会修复它。也可能是状态模块存在需要修复的 bug。或者可能存在一些 Salt 无法控制的其他情况,例如一个暂时不可用的网络服务。注释应该包含尽可能多的信息,以便追踪和修复问题,而不要过多。这也是在return之前将结果设置为False的时候。

示例:检查 HTTP 服务

已经有一个用于联系网络服务的状态:http.query状态。然而,它非常通用,直接使用它的用途有限。实际上,它并没有执行更多逻辑的真正逻辑,而只是检查 URL 是否按预期响应。为了使其更智能,我们需要添加一些自己的逻辑。

检查凭证

让我们从设置我们的docstring、库导入以及一个带有理论网络服务凭证的__virtual__()函数开始:

'''
This state connects to an imaginary web service.
The following credentials must be configured:

    webapi_username: <your username>
    webapi_password: <your password>

This module should be saved as salt/states/fake_webapi.py
'''
import salt.utils.http

def __virtual__():
    '''
    Make sure there are credentials
    '''
    username = __salt__'config.get'
    password = __salt__'config.get'
    if username and password:
        return True
    return False

在这个情况下,我们不是检查http.query函数的存在;正如我们之前所说的,它已经存在了。但是,如果没有能够连接到网络服务,这个模块将无法工作,所以我们快速检查以确保凭证已经就位。

我们没有检查服务本身是否响应,或者凭证是否正确。__virtual__()函数在 Minion 启动时进行检查,那时进行所有这些检查是不必要的,而且在停机事件中可能是不准确的。更好的做法是在我们实际调用服务时再进行检查。

第一个状态函数

接下来,我们需要设置一个状态函数。在我们的例子中,我们将允许用户确保该网络服务上的特定用户账户已被锁定。首先,我们设置默认值,然后检查该用户的账户是否已被锁定:

def locked(name):
    '''
    Ensure that the user is locked out
    '''
    username = __salt__'config.get'
    password = __salt__'config.get'

    ret = {'name': name,
           'changes': {},
           'result': None,
           'comment': ''}

    result = salt.utils.http.query(
        'https://api.example.com/v1/users/{0}'.format(name),
        username=username,
        password=password,
        decode=True,
        decode_type='json',
    )

    if result('dict', {}).get('access', '') == 'locked':
        ret['result'] = True
        ret['comment'] = 'The account is already locked'
        return ret

你可能立刻就会发现问题。进行认证的网络调用有点重,尤其是当你必须解码返回数据时,无论你如何做。我们将在这个函数中再次进行网络调用,在其他函数中还会进行更多调用。让我们将我们可以的部分拆分到另一个函数中:

def _query(action, resource='', data=None):
    '''
    Make a query against the API
    '''
    username = __salt__'config.get'
    password = __salt__'config.get'

    result = salt.utils.http.query(
        'https://api.example.com/v1/{0}/{1}'.format(action, resource),
        username=username,
        password=password,
        decode=True,
        decode_type='json',
        data=data,
    )

def locked(name):
    '''
    Ensure that the user is locked out
    '''
    ret = {'name': name,
           'changes': {},
           'result': None,
           'comment': ''}

    result = _query('users', name)
    if result('dict', {}).get('access', '') == 'locked':
        ret['result'] = True
        ret['comment'] = 'The account is already locked'
        return ret

新的_query()函数至少需要一个参数:将要执行的操作(action)的类型。这种类型的 API 通常期望在未指定特定资源的情况下列出该查询的所有项目,所以我们允许资源为空。我们还设置了一个名为data的可选参数,我们将在稍后使用它。

现在我们有一个检查账户是否被锁定,并且如果它是的话,我们可以返回True。如果我们通过了这一点,我们知道账户没有被锁定,所以让我们进行对test模式的检查:

    if __opts__['test']:
        ret['comment'] = 'The {0} account is not locked'.format(name)
        return ret

这部分很容易;我们已经有了一切需要的test模式信息,我们不需要做任何事情,除了返回它。让我们尝试将正确的设置应用到账户上。

    _query('users', name, {'access': 'locked'})

记住那个data选项吗?我们用它传递一个字典,将用户的访问值设置为locked。这也是使用 Web API 修改数据的一种非常常见的方式。

当然,我们不一定知道设置是否被正确应用,所以让我们再进行一次检查,以确保:

    result = _query('users', name)
    if result('dict', {}).get('access', '') == 'locked':
        ret['changes'] = {'locked': name}
        ret['result'] = True
        ret['comment'] = 'The {0} user account is now locked'.format(name)
        return ret
    else:
        ret['result'] = False
        ret['comment'] = 'Failed to set the {0} user account to locked'.format(name)
        return ret

如果账户现在被锁定,那么我们可以返回我们已成功。如果账户仍然没有被锁定,那么我们可以返回一个失败信息。

另一个状态函数

让我们继续添加另一个函数,以便解锁用户账户。我们也将借此机会向您展示整个模块,包括所有公共和私有函数:

'''
This state connects to an imaginary web service.
The following credentials must be configured:

    webapi_username: <your username>
    webapi_password: <your password>

This module should be saved as salt/states/fake_webapi.py
'''
import salt.utils.http

def __virtual__():
    '''
    Make sure there are credentials
    '''
    username = __salt__'config.get'
    password = __salt__'config.get'
    if username and password:
        return True
    return False

def _query(action, resource='', data=None):
    '''
    Make a query against the API
    '''
    username = __salt__'config.get'
    password = __salt__'config.get'

    result = salt.utils.http.query(
        'https://api.example.com/v1/{0}/{1}'.format(action, resource),
        username=username,
        password=password,
        decode=True,
        decode_type='json',
        data=data,
    )
return result

def locked(name):
    '''
    Ensure that the user is locked out
    '''
    ret = {'name': name,
           'changes': {},
           'result': None,
           'comment': ''}

    result = _query('users', name)
    if result('dict', {}).get('access', '') == 'locked':
        ret['result'] = True
        ret['comment'] = 'The account is already locked'
        return ret

    if __opts__['test']:
        ret['comment'] = 'The {0} account is not locked'.format(name)
        return ret

    _query('users', name, {'access': 'locked'})

    result = _query('users', name)
    if result('dict', {}).get('access', '') == 'locked':
        ret['changes'] = {'locked': name}
        ret['result'] = True
        ret['comment'] = 'The {0} user account is now locked'.format(name)
        return ret
    else:
        ret['result'] = False
        ret['comment'] = 'Failed to set the {0} user account to locked'.format(name)
        return ret

def unlocked(name):
    '''
    Ensure that the user is NOT locked out
    '''
    ret = {'name': name,
           'changes': {},
           'result': None,
           'comment': ''}

    result = _query('users', name)
    if result('dict', {}).get('access', '') == 'unlocked':
        ret['result'] = True
        ret['comment'] = 'The account is already unlocked'
        return ret

    if __opts__['test']:
        ret['comment'] = 'The {0} account is locked'.format(name)
        return ret

    _query('users', name, {'access': 'unlocked'})

    result = _query('users', name)
    if result('dict', {}).get('access', '') == 'unlocked':
        ret['changes'] = {'locked': name}
        ret['result'] = True
        ret['comment'] = 'The {0} user account is no longer locked'.format(name)
        return ret
    else:
        ret['result'] = False
        ret['comment'] = 'Failed to unlock the {0} user account'.format(name)
        return ret

你可以看到这两个函数之间没有太大的区别。实际上,它们确实做了完全相同的事情,但逻辑相反:一个锁定账户,另一个解锁账户。

状态模块通常包含同一配置的两个相反值。你经常会看到像installedremovedpresentabsentrunningdead这样的函数名。

调试状态模块

尽管代码结构更清晰,但调试状态模块可能有点棘手。这是因为你需要测试所有四种类型的返回结果:

  • 正确 – 资源已正确配置

  • 无 – 资源配置不正确,且test模式为真

  • 正确并更改 – 资源之前配置不正确,但现在已正确

  • 错误 – 资源配置不正确

使这更加棘手的是,在调试过程中,你可能会多次更改配置,从正确到错误,然后再回到正确,直到代码正确为止。我建议将其拆分。

第一步:测试真值

在设置默认值之后,你的第一步是检查资源是否正确配置。这可能需要你手动切换设置以确保它正确地检查了所需和不需要的配置。添加两个返回值:一个用于True,一个用于False

    ret = {'name': name,
           'changes': {},
           'result': None,
           'comment': ''}
    if <item is already in the desired state>:
        ret['result'] = True
        ret['comment'] = 'The item is already in the desired state'
        return ret
    ret['result'] = False
    return ret

一旦你知道代码是正确的,你可以稍后删除最后两行。你不需要设置整个 SLS 文件来测试你的状态;你可以使用state.single来执行一次性的状态命令:

# salt-run --local state.single fake_webapi.locked larry

第 2 步:测试模式

一旦你确信它能够正确地检测当前配置,手动将配置设置为不期望的值,并确保test模式工作正常:

第 3 步:应用更改

当你确信你的代码在尝试应用更改之前不会尝试检查测试模式,你可以继续应用更改。

这是最难的部分,有两个原因。首先,你将不得不频繁地设置和重置你的配置。这最多可能有些繁琐,但这是不可避免的。其次,你将同时设置正确的配置,然后测试以查看它是否被设置:

    <attempt to configure the item correctly>
    if <we are able to put the item in the correct state>:
        ret['changes'] = {'desired state': name}
        ret['result'] = True
        ret['comment'] = 'The desired state was successfully achieved'
        return ret
    else:
        ret['result'] = False
        ret['comment'] = 'The desired state failed to be achieved'
        return ret

你可能认为你可以将这部分拆分,但很快你可能会意识到,为了确保配置被正确应用,你仍然需要执行与你在自己的测试中通常执行相同的检查,所以你不妨现在就把它解决掉。

测试相反的操作

幸运的是,如果你正在编写执行相反功能的函数,第二个通常要快得多。这是因为一旦你完成了第一个,你可以继续运行它来将配置重置为第二个不期望的值。在我们的例子中,一旦你能够锁定一个账户,你就可以在测试解锁功能时轻松地锁定它。

摘要

状态模块比执行模块更有结构,但这通常使它们更容易编写。状态返回的结果可以是 True(绿色),None(黄色),True with changes(蓝色),或 False(红色)。状态模块通常包含执行相反功能的函数对。

现在你已经知道如何编写状态模块了,是时候看看我们传递给它们的那些数据了。接下来是:渲染器!

第五章:渲染数据

能够编写自己的执行和状态模块对于开发者来说是一种强大的能力,但你不能忽视为那些没有能力提供自己模块的用户提供这种能力。

渲染器允许用户使用不同类型的数据输入格式向 Salt 的各个部分提供数据。Salt 附带的一小部分渲染器涵盖了大多数用例,但如果你用户需要以专用格式应用数据怎么办?或者甚至是一个尚未支持但更常见的格式,如 XML?在本章中,我们将讨论:

  • 编写渲染器

  • 解决渲染器问题

理解文件格式

默认情况下,Salt 使用 YAML 处理其各种文件。有两个主要原因:

  • YAML 可以轻松转换为 Python 数据结构

  • YAML 易于人类阅读和修改

Salt 配置文件也必须使用 YAML(或 JSON,可以被 YAML 解析器读取),但其他文件,如状态、支柱、反应器等,可以使用其他格式。数据序列化格式是最常见的,但任何可以转换为 Python 字典的格式都可以。

例如,Salt 附带三个不同的 Python 渲染器:pypyobjectspydsl。每个都有其优点和缺点,但最终结果相同:它们执行 Python 代码,生成字典,然后传递给 Salt。

一般而言,你会在 Salt 中找到两种类型的渲染器。第一种返回 Python 数据结构中的数据。序列化和基于代码的模块都属于这一类别。第二种用于管理文本格式化和模板。让我们依次讨论每个部分,然后在章节的后面构建我们自己的渲染器。

序列化数据

数据可以存储在任何数量的格式中,但最终,这些数据必须是能够转换为指令的东西。YAML 和 JSON 等格式是明显的选择,因为它们易于修改,并且反映了使用它们的程序中的结果数据结构。二进制格式,如 Message Pack,虽然不易于人类修改,但它们仍然产生相同的数据结构。

其他格式,如 XML,更难处理,因为它们并不直接类似于 Salt 等程序的内部数据结构。它们非常适合建模大量使用类的代码,但 Salt 并不大量使用这种代码。然而,当你知道如何将这种格式转换为 Salt 可以使用的数据结构时,为其构建渲染器并不困难。

与模板一起工作

模板很重要,因为它们允许最终用户使用某些程序元素,而无需编写实际的模块。变量无疑是模板引擎中最关键元素之一,但其他结构,如循环和分支,也可以给用户带来很大的权力。

模板渲染器与数据序列化渲染器不同,因为它们不是以字典格式返回数据,然后由 Salt 摄取,而是返回至少需要使用数据序列化渲染器转换一次的数据。

在某些层面上,这可能会显得有些反直觉,但使用渲染管道将这两个元素结合起来。

使用渲染管道

渲染管道基于 Unix 管道;数据可以通过一系列管道从模块传递到模块,以便到达最终的数据结构。你可能没有意识到,但如果你曾经编写过 SLS 文件,你就已经使用了渲染管道。

要设置渲染管道,你需要在要渲染的文件顶部添加一行,其中包含经典的 Unix hashbang,后面跟着要使用的渲染器,按照使用的顺序,由管道字符分隔。默认的渲染顺序实际上是:

#!jinja|yaml

这意味着相关的文件将首先由 Jinja2 解析,然后编译成 YAML 库可以读取的格式。

通常来说,将两个以上的不同渲染器组合在一起并不合理或必要;使用的越多,结果文件对人类来说就越复杂,出错的可能性也越大。一般来说,一个添加了程序性快捷方式的模板引擎和一个数据序列化器就足够了。一个值得注意的例外是gpg渲染器,它可以用于静态加密场景。这个 hashbang 看起来会是这样:

#!jinja|yaml|gpg

构建序列化渲染器

渲染器相对容易构建,因为它们通常做的只是导入一个库,将数据通过它,然后返回结果。我们的示例渲染器将使用 Python 自己的 Pickle 格式。

基本结构

在任何必要的导入之外,渲染器只需要一个render()函数。最重要的参数是第一个。与其他模块一样,这个参数的名称对 Salt 来说并不重要,只要它被定义即可。因为我们的例子使用了pickle库,所以我们将使用pickle_data作为我们的参数名称。

其他参数也会传递给渲染器,但在这个例子中,我们只会用它们来解决问题。特别是,我们需要接受saltenvsls,稍后会显示它们的默认值。我们将在“渲染器故障排除”部分介绍它们,但现在我们只需使用kwargs来涵盖它们。

我们还需要从一种特殊的import开始,称为absolute_import,它允许我们从也称为pickle的文件中导入pickle库。

让我们先列出模块,然后讨论render()函数中的组件:

'''
Render Pickle files.

This file should be saved as salt/renderers/pickle.py
'''
from __future__ import absolute_import
import pickle
from salt.ext.six import string_types

def render(pickle_data, saltenv='base', sls='', **kwargs):
    '''
    Accepts a pickle, and renders said data back to a python dict.
    '''
    if not isinstance(pickle_data, string_types):
        pickle_data = pickle_data.read()

    if pickle_data.startswith('#!'):
        pickle_data = pickle_data[(pickle_data.find('\n') + 1):]
    if not pickle_data.strip():
        return {}
    return pickle.loads(pickle_data)

这个函数除了以下内容之外不做太多:

  • 首先,检查传入的数据是否为字符串,如果不是,则将其视为文件对象。

  • 检查是否存在#!,表示使用了显式的渲染管道。因为那个管道在其他地方处理,并且会导致与pickle库的错误,所以将其丢弃。

  • 检查结果内容是否为空。如果是,则返回一个空字典。

  • 通过pickle库运行数据,并返回结果。

如果您开始将此代码与 Salt 附带的自定义渲染器进行比较,您会发现它们几乎完全相同。这在很大程度上是因为 Python 中许多数据序列化库使用完全相同的方法。

让我们创建一个可以使用的文件。我们将使用的示例数据如下:

apache:
  pkg:
    - installed
    - refresh: True

创建此文件的最佳方式是使用 Python 本身。请打开 Python shell 并输入以下命令:

>>> import pickle
>>> data = {'apache': {'pkg': ['installed', {'refresh': True}]}}
>>> out = open('/srv/salt/pickle.sls', 'w')
>>> pickle.dump(data, out)
>>> out.close()

当您退出 Python shell 时,您应该能够用您最喜欢的文本编辑器打开此文件。当您在顶部添加一个指定pickle渲染器的 hashbang 行时,您的文件可能看起来像这样:

#!pickle
(dp0
S'apache'
p1
(dp2
S'pkg'
p3
(lp4
S'installed'
p5
a(dp6
S'refresh'
p7
I01
sass.

保存文件,并使用salt-call测试您的渲染器。这次,我们将告诉 Salt 将结果 SLS 以 Salt 看到的形式输出:

# salt-call --local state.show_sls pickle --out=yaml
local:
 apache:
 __env__: base
 __sls__: !!python/unicode pickle
 pkg:
 - installed
 - refresh: true
 - order: 10000

Salt 的状态编译器添加了一些它内部使用的额外信息,但我们可以看到我们请求的基本内容都在那里。

构建模板渲染器

构建处理模板文件的渲染器与处理序列化的渲染器没有太大区别。实际上,渲染器本身除了库特定的代码外,几乎相同。这次,我们将使用一个名为tenjin的 Python 库。您可能需要使用 pip 安装它:

# pip install tenjin

使用 Tenjin 进行模板化

此模块使用第三方库,因此将有一个__virtual__()函数来确保它已安装:

'''
Conver a file using the Tenjin templating engine

This file should be saved as salt/renderers/tenjin.py
'''
from __future__ import absolute_import
try:
    import tenjin
    from tenjin.helpers import *
    HAS_LIBS = True
except ImportError:
    HAS_LIBS = False
from salt.ext.six import string_types

def __virtual__():
    '''
    Only load if Tenjin is installed
    '''
    return HAS_LIBS

def render(tenjin_data, saltenv='base', sls='', **kwargs):
    '''
    Accepts a tenjin, and renders said data back to a python dict.
    '''
    if not isinstance(tenjin_data, string_types):
        tenjin_data = tenjin_data.read()

    if tenjin_data.startswith('#!'):
        tenjin_data = tenjin_data[(tenjin_data.find('\n') + 1):]
    if not tenjin_data.strip():
        return {}

    template = tenjin.Template(input=tenjin_data)
    return template.render(kwargs)

render()函数本身与用于pickle的函数基本相同,除了最后两行,它们对模板引擎的处理略有不同。

注意传递给此函数的kwargs。模板引擎通常具有合并外部数据结构的能力,这可以与模板引擎本身的各种数据结构一起使用。Salt 会在kwargs中提供一些数据,因此我们将传递这些数据以供 Tenjin 使用。

使用模板渲染器

当然,您需要在 SLS 文件中添加一个 hashbang 行,就像之前一样,但由于我们的 Tenjin 渲染器没有设置为直接返回数据,您需要将所需的数据序列化渲染器的名称添加到您的渲染管道中。我们将使用之前相同的实际 SLS 数据,但添加了一些 Tenjin 特定的元素:

#!tenjin|yaml
<?py pkg = 'apache'?>
<?py refresh = True?>
#{pkg}:
  pkg:
    - installed
    - refresh: #{refresh}

我们在这里没有做任何特别的事情,只是设置了一些变量,然后使用了它们。结果内容将是 YAML 格式,因此我们向我们的渲染管道添加了yaml

许多模板引擎,包括 Tenjin,都有能力处理输出字符串(如我们示例中所做)或实际数据结构(如数据序列化器返回的数据)的模板。当使用此类库时,请花点时间考虑您计划使用多少,以及是否需要为它创建两个不同的渲染器:一个用于数据,一个用于字符串。

测试与之前相同:

# salt-call --local state.show_sls tenjin --out yaml
local:
 apache:
 pkg:
 - installed
 - refresh: true
 - order: 10000
 __sls__: !!python/unicode tenjin
 __env__: base

我们可以看到我们的第一个示例和第二个示例之间有一些细微的差异,但这些差异只是显示了用于渲染数据的模块。

渲染器故障排除

由于渲染器经常被用来管理 SLS 文件,因此使用状态编译器进行故障排除通常是最简单的,正如我们在本章中已经做的那样。

首先,生成一个包含你需要测试的特定元素的 SLS 小文件。这可能是使用序列化引擎格式的数据文件,或者是一个生成数据序列化文件格式的基于文本的文件。如果你正在编写模板渲染器,通常最简单的方法就是使用 YAML。

state执行模块包含许多主要用于故障排除的函数。我们在示例中使用了state.show_sls,并带有--out yaml选项,因为它以我们在 SLS 文件中已经习惯的格式显示输出。然而,还有一些其他有用的函数:

  • state.show_low_sls:显示单个 SLS 文件在状态编译器将其转换为低数据后的数据。在编写状态模块时,低数据通常更容易可视化。

  • state.show_highstate:显示所有状态,根据top.sls文件,它们将被应用到 Minion 上。此输出的外观就像所有 SLS 文件都被堆在一起一样。这在故障排除你认为跨越多个 SLS 文件的渲染问题时可能很有用。

  • state.show_lowstate:此函数返回的数据与state.show_highstate返回的数据相同,但经过状态编译器处理。这又像是state.show_low_sls的长版本。

摘要

渲染器用于将各种文件格式转换为 Salt 内部可用的数据结构。数据序列化渲染器以字典格式返回数据,而模板渲染器返回可以由数据序列化器处理的数据。这两种类型的渲染器看起来相同,都需要一个render()函数。

现在我们已经知道如何处理进入 Salt 的数据,是时候看看从 Salt 返回的数据了。接下来:处理返回数据。

第六章:处理返回数据

当 Salt Master 向 Minion 发出命令并且任务成功完成时,总会存在返回数据。salt命令通常会监听返回数据,并且如果它及时返回,它将使用输出器显示。但无论是否发生这种情况,Minion 总会将返回数据发送回 Master,以及任何配置为 Returner 的其他目的地。

本章全部关于处理返回数据,使用 Returner 和 Outputter 模块。我们将讨论:

  • 数据如何返回给 Master

  • 编写 Returner 模块

  • 将 Returners 扩展为用作外部作业缓存

  • Returners 故障排除

  • 编写 Outputter 模块

  • Outputters 故障排除

将数据返回到外部目的地

处理返回数据最重要的模块类型称为 Returner。当 Master 向目标发布任务(称为作业)时,它会为它分配一个作业 ID(或 JID)。当 Minion 完成该作业时,它会将结果数据连同与其关联的 JID 一起发送回 Master。

将数据返回给 Master

Salt 的架构基于发布-订阅模式,俗称 pub/sub。在这个设计中,一个或多个客户端订阅一个消息队列。当消息发布到队列时,任何当前订阅者都会收到一个副本,他们通常会以某种方式处理它。

事实上,Salt 使用了两个消息队列,这两个队列都由 Master 管理。第一个队列由 Master 用于向其 Minions 发布命令。每个 Minion 都可以看到发布到这个队列的消息,但只有当 Minions 包含在目标中时,它们才会做出反应。针对'*'的目标消息将由所有连接的 Minions 处理,而使用-s命令行选项针对192.168.0.0/16的目标消息将只会被以192.168开头的 IP 地址的 Minions 处理。

第二个消息队列也由 Master 托管,但消息是由 Minions 发布的,Master 本身是订阅者。这些消息通常存储在 Master 的作业缓存中。Returners 可以被配置为将这些消息发送到其他目的地,并且一些 Returners 也可以使用这些目的地作为作业缓存本身。如果当收到这些消息时salt命令仍在监听,那么它也会将这些数据发送到输出器。

监听事件数据

每次将消息发布到队列时,Salt 的事件总线也会触发一个事件。您可以使用state.event运行器来监听事件总线并实时显示这些消息。

确保您有salt-master服务正在运行,并且至少有一台连接到它的机器上的salt-minion服务。在 Master 上运行以下命令:

# salt-run state.event

在另一个终端中,向一个或多个 Minions 发出命令:

# salt '*' test.ping

在运行事件监听器的终端中,您将看到作业发送到 Minions:

Event fired at Sun Dec 20 12:04:15 2015
*************************
Tag: 20151220120415357444
Data:
{'_stamp': '2015-12-20T19:04:15.387417',
 'minions': ['trotter',
             'achatz']}

本事件包含的信息仅限于一个时间戳,表示作业创建的时间,以及一个列表,列出了指定的目标(在我们的例子中,所有目标)应执行作业并从中返回数据的 Minions。

这是一个非常小的任务,所以你几乎可以立即看到来自 Minions 的返回数据。因为每个 Minion 都单独响应,所以你会看到每个 Minion 的条目:

Event fired at Sun Dec 20 12:04:15 2015
*************************
Tag: salt/job/20151220120415357444/ret/dufresne
Data:
{'_stamp': '2015-12-20T19:04:15.618340',
 'cmd': '_return',
 'fun': 'test.ping',
 'fun_args': [],
 'id': 'dufresne',
 'jid': '20151220120415357444',
 'retcode': 0,
 'return': True,
 'success': True}

注意每个事件使用的标签。当 Master 创建作业时创建的事件有一个只包含 JID 的标签。每个返回事件都包含一个以salt/job/<JID>/ret/<Minion ID>命名的命名空间标签。

几秒钟后,salt 命令也将返回,并通知你哪些 Minions 完成了分配给它们的作业,哪些没有完成:

# salt '*' test.ping
achatz:
 True
trotter:
 Minion did not return. [Not connected]

在我们的例子中,achatz是活跃的,并且能够按照要求返回True。不幸的是,trotter已经不再存在,所以无法完成我们需要的操作。

当返回者监听 Minions

每次 Master 从 Minion 收到响应时,它将调用返回者。如果一个作业针对的是,比如说,400 个 Minions,那么你应该预期返回者将被执行 400 次,每个 Minion 一次。

这通常不是问题。如果一个返回者连接到数据库,那么这个数据库很可能能够快速处理 400 个响应。然而,如果您创建了一个发送消息给人类的返回者,比如 Salt 附带的 SMTP 返回者,那么您可以预期会发送 400 封单独的电子邮件;每个 Minion 一封。

还有一点需要注意:返回者最初是为了在 Minions 上执行而设计的。背后的想法是将工作卸载到 Minions 上,这样在一个大型环境中,Master 就不需要处理所有必要的工作,比如每个 Minion 每次作业连接数据库。

返回者现在可以由 Master 或 Minion 运行,当编写自己的返回者时,你应该预期这两种可能性。我们将在本章后面讨论此配置,当我们谈到作业缓存时。

让我们看看这个动作的一个例子。连接到您的其中一个 Minion 并停止salt-minion服务。然后以info日志级别在前台启动它:

# salt-minion --log-level info

然后连接到 Master 并直接向其发出作业:

# salt dufresne test.ping
dufresne:
 True

切换回 Minion,你将看到一些关于作业的信息:

[INFO    ] User sudo_techhat Executing command test.ping with jid 20151220124647074029
[INFO    ] Starting a new job with PID 25016
[INFO    ] Returning information for job: 20151220124647074029

现在再次发出命令,但将--return标志设置为local。此返回者将直接在本地控制台显示返回数据:

# salt dufresne --return local test.ping
dufresne:
 True

再次切换回 Minion 以检查返回数据:

[INFO    ] User sudo_techhat Executing command test.ping with jid 20151220124658909637
[INFO    ] Starting a new job with PID 25066
[INFO    ] Returning information for job: 20151220124658909637
{'fun_args': [], 'jid': '20151220124658909637', 'return': True, 'retcode': 0, 'success': True, 'fun': 'test.ping', 'id': 'dufresne'}

您的第一个返回者

打开salt/returners/local.py。这里没有多少内容,但我们感兴趣的是returner()函数。它非常非常小:

def returner(ret):
    '''
    Print the return data to the terminal to verify functionality
    '''
    print(ret)

实际上,它所做的只是接受返回的数据作为ret,然后将其打印到控制台。它甚至不尝试进行任何形式的格式化打印;它只是原样输出。

这实际上是一个返回器所需的最基本内容:一个接受字典的 returner() 函数,然后对其进行处理。让我们创建我们自己的返回器,它以 JSON 格式将作业信息本地存储。

'''
Store return data locally in JSON format

This file should be saved as salt/returners/local_json.py
'''
import json
import salt.utils

def returner(ret):
    '''
    Open new file, and save return data to it in JSON format
    '''
    path = '/tmp/salt-{0}-{1}.json'.format(ret['jid'], ret['id'])
    with salt.utils.fopen(path, 'w') as fp_:
        json.dump(ret, fp_)

在 Minion 上保存此文件,然后向其发出作业。无论是否重新启动 salt-minion 服务,返回器模块都使用 LazyLoader。但我们将继续使用 salt-call

# salt-call --local --return local_json test.ping
local:
 True

现在请查看 /tmp/ 目录:

# ls -l /tmp/salt*
-rw-r--r-- 1 root  root  132 Dec 20 13:03 salt-20151220130309936721-dufresne.json

如果你查看该文件,你会看到看起来与从本地返回器接收到的数据非常相似,但它是 JSON 格式:

# cat /tmp/salt-20151220130309936721-dufresne.json
{"fun_args": [], "jid": "20151220130309936721", "return": true, "retcode": 0, "success": true, "fun": "test.ping", "id": "dufresne"}

使用作业缓存

从某种意义上说,我们的 JSON 返回器是一个作业缓存,因为它缓存了返回数据。不幸的是,它不包含任何处理已保存数据的代码。通过更新逻辑并添加一些函数,我们可以扩展其功能。

目前,我们的返回器表现得就像一组日志文件。让我们将其改为更像一个平面文件数据库。我们将使用 JID 作为访问密钥,并根据 JID 中的日期格式化目录结构:

import json
import os.path
import salt.utils
import salt.syspaths

def _job_path(jid):
    '''
    Return the path for the requested JID
    '''
    return os.path.join(
        salt.syspaths.CACHE_DIR,
        'master',
        'json_cache',
        jid[:4],
        jid[4:6],
        jid[6:],
    )

def returner(ret):
    '''
    Open new file, and save return data to it in JSON format
    '''
    path = os.path.join(_job_path(ret['jid']), ret['id']) + '/'
    __salt__'file.makedirs'
    ret_file = os.path.join(path, 'return.json')
    with salt.utils.fopen(ret_file, 'w') as fp_:
        json.dump(ret, fp_)

我们没有改变任何东西,除了目录结构及其处理方式。私有函数 _job_path() 将标准化目录结构,并可以被未来的函数使用。我们还使用了 salt.syspaths 来检测 Salt 在这台机器上配置的缓存文件位置。当针对名为 dufresne 的 Minion 运行时,用于存储返回数据的路径将看起来像:

/var/cache/salt/master/json_cache/2015/12/21134608721496/dufresne/return.json

我们还需要存储有关作业本身的信息。return.json 文件包含一些关于作业的信息,但不是全部。

让我们添加一个保存作业元数据的函数。这个元数据被称为负载,包含一个 jid,一个名为 clear_load 的字典,它包含大部分元数据,以及一个名为 minions 的列表,它将包含所有包含在目标中的 Minions:

def save_load(jid, clear_load, minions=None):
    '''
    Save the load to the specified JID
    '''
    path = os.path.join(_job_path(jid)) + '/'
    __salt__'file.makedirs'

    load_file = os.path.join(path, 'load.json')
    with salt.utils.fopen(load_file, 'w') as fp_:
        json.dump(clear_load, fp_)

    if 'tgt' in clear_load:
        if minions is None:
            ckminions = salt.utils.minions.CkMinions(__opts__)
            # Retrieve the minions list
            minions = ckminions.check_minions(
                    clear_load['tgt'],
                    clear_load.get('tgt_type', 'glob')
                    )
        minions_file = os.path.join(path, 'minions.json')
        with salt.utils.fopen(minions_file, 'w') as fp_:
            json.dump(minions, fp_)

再次强调,我们生成数据将被写入的路径。clear_load 字典将被写入该路径内的 load.json 文件。Minions 的列表有点棘手,因为它可能包含一个空列表。如果是这样,我们使用 salt.utils.minions 内的一个名为 CkMinions 的类来生成该列表,基于用于作业的目标。一旦我们有了这个列表,我们就将其写入为 minions.json

测试这一点也有点棘手,因为它需要一个由 Master 生成的工作来生成所需的所有元数据。我们还需要让 Master 知道我们正在使用外部作业缓存。

首先,编辑主配置文件并添加一个 ext_job_cache 行,将其设置为 local_json

ext_job_cache: local_json

注意

外部作业缓存与 Master 作业缓存

当主节点设置为使用外部工作缓存(使用 ext_job_cache 设置)时,返回代码将在从节点上执行。这将减轻主节点的负载,因为每个从节点将记录自己的工作数据,而不是请求主节点。然而,连接到工作缓存(例如,如果使用了数据库)所需的任何凭证都需要从节点可以访问。

当主节点设置为使用主节点工作缓存(使用 master_job_cache 设置)时,返回代码将在主节点上执行。这将增加主节点的负载,但可以节省您向从节点提供凭证的麻烦。

一旦您打开了工作缓存,让我们先重启主节点和从节点,然后尝试一下:

# systemctl restart salt-master
# systemctl restart salt-minion
# salt dufresne test.ping
dufresne:
 True
# find /var/cache/salt/master/json_cache/
/var/cache/salt/master/json_cache/2015/12/
/var/cache/salt/master/json_cache/2015/12/21184312454127
/var/cache/salt/master/json_cache/2015/12/21184312454127/load.json
/var/cache/salt/master/json_cache/2015/12/21184312454127/dufresne
/var/cache/salt/master/json_cache/2015/12/21184312454127/dufresne/return.json
/var/cache/salt/master/json_cache/2015/12/21184312454127/minions.json
# cat /var/cache/salt/master/json_cache/2015/12/21184312454127/load.json
{"tgt_type": "glob", "jid": "20151221184312454127", "cmd": "publish", "tgt": "dufresne", "kwargs": {"delimiter": ":", "show_timeout": true, "show_jid": false}, "ret": "local_json", "user": "sudo_larry", "arg": [], "fun": "test.ping"}
# cat /var/cache/salt/master/json_cache/2015/12/21184312454127/minions.json
["dufresne"]

现在我们有了保存的信息,但我们没有检索它的方法,除了手动查看文件之外。让我们继续完善我们的返回器,添加一些可以读取数据的函数。

首先,我们需要一个只返回工作负载信息的函数:

def get_load(jid):
    '''
    Return the load data for a specified JID
    '''
    path = os.path.join(_job_path(jid), 'load.json')
    with salt.utils.fopen(path, 'r') as fp_:
        return json.load(fp_)

我们还需要一个函数来获取每个工作的工作数据。这两个函数将由 jobs 运行者一起使用:

def get_jid(jid):
    '''
    Return the information returned when the specified JID was executed
    '''
    minions_path = os.path.join(_job_path(jid), 'minions.json')
    with salt.utils.fopen(minions_path, 'r') as fp_:
        minions = json.load(fp_)

    ret = {}
    for minion in minions:
        data_path = os.path.join(_job_path(jid), minion, 'return.json')
        with salt.utils.fopen(data_path, 'r') as fp_:
            ret[minion] = json.load(fp_)

    return ret

我们不需要重新启动主节点来测试这个功能,因为工作负载运行者不需要主节点正在运行:

# salt-run jobs.print_job 20151221184312454127
20151221184312454127:
 ----------
 Arguments:
 Function:
 test.ping
 Result:
 ----------
 dufresne:
 ----------
 fun:
 test.ping
 fun_args:
 id:
 dufresne
 jid:
 20151221184312454127
 retcode:
 0
 return:
 True
 success:
 True
 StartTime:
 2015, Dec 21 18:43:12.454127
 Target:
 dufresne
 Target-type:
 glob
 User:
 sudo_techhat

我们还需要一个函数,该函数返回一个 JIDs 列表,以及它们相关联的工作的一些基本信息。这个函数将使用另一个导入,我们将使用它来快速定位 load.json 文件:

import salt.utils.find

def get_jids():
    '''
    Return a dict mapping all JIDs to job information
    '''
    path = os.path.join(
        salt.syspaths.CACHE_DIR,
        'master',
        'json_cache'
    )

    ret = {}
    finder = salt.utils.find.Finder({'name': 'load.json'})
    for file_ in finder.find(path):
        with salt.utils.fopen(file_) as fp_:
            data = json.load(fp_)
        if 'jid' in data:
            ret[data['jid']] = {
                'Arguments': data['arg'],
                'Function': data['fun'],
                'StartTime': salt.utils.jid.jid_to_time(data['jid']),
                'Target': data['tgt'],
                'Target-type': data['tgt_type'],
                'User': data['user'],
            }

    return ret

再次使用 jobs 运行者测试这个功能:

# salt-run jobs.list_jobs
20151221184312454127:
 ----------
 Arguments:
 Function:
 test.ping
 StartTime:
 2015, Dec 21 18:43:12.454127
 Target:
 dufresne
 Target-type:
 glob
 User:
 sudo_techhat

最终模块

一旦我们将所有代码编译在一起,最终的模块将看起来像这样:

'''
Store return data locally in JSON format

This file should be saved as salt/returners/local_json.py
'''
import json
import os.path
import salt.utils
import salt.utils.find
import salt.utils.jid
import salt.syspaths

def _job_path(jid):
    '''
    Return the path for the requested JID
    '''
    return os.path.join(
        salt.syspaths.CACHE_DIR,
        'master',
        'json_cache',
        jid[:4],
        jid[4:6],
        jid[6:],
    )

def returner(ret):
    '''
    Open new file, and save return data to it in JSON format
    '''
    path = os.path.join(_job_path(ret['jid']), ret['id']) + '/'
    __salt__'file.makedirs'
    ret_file = os.path.join(path, 'return.json')
    with salt.utils.fopen(ret_file, 'w') as fp_:
        json.dump(ret, fp_)

def save_load(jid, clear_load, minions=None):
    '''
    Save the load to the specified JID
    '''
    path = os.path.join(_job_path(jid)) + '/'
    __salt__'file.makedirs'

    load_file = os.path.join(path, 'load.json')
    with salt.utils.fopen(load_file, 'w') as fp_:
        json.dump(clear_load, fp_)
            minions = ckminions.check_minions(
                    clear_load['tgt'],
                    clear_load.get('tgt_type', 'glob')
                    )
        minions_file = os.path.join(path, 'minions.json')
        with salt.utils.fopen(minions_file, 'w') as fp_:
            json.dump(minions, fp_)

def get_load(jid):
    '''
    Return the load data for a specified JID
    '''
    path = os.path.join(_job_path(jid), 'load.json')
    with salt.utils.fopen(path, 'r') as fp_:
        return json.load(fp_)

def get_jid(jid):
    '''
    Return the information returned when the specified JID was executed
    '''
    minions_path = os.path.join(_job_path(jid), 'minions.json')
    with salt.utils.fopen(minions_path, 'r') as fp_:
        minions = json.load(fp_)

    ret = {}
    for minion in minions:
        data_path = os.path.join(_job_path(jid), minion, 'return.json')
        with salt.utils.fopen(data_path, 'r') as fp_:
            ret[minion] = json.load(fp_)

    return ret

def get_jids():
    '''
    Return a dict mapping all JIDs to job information
    '''
    path = os.path.join(
        salt.syspaths.CACHE_DIR,
        'master',
        'json_cache'
    )

    ret = {}
    finder = salt.utils.find.Finder({'name': 'load.json'})
    for file_ in finder.find(path):
        with salt.utils.fopen(file_) as fp_:
            data = json.load(fp_)
        if 'jid' in data:
            ret[data['jid']] = {
                'Arguments': data['arg'],
                'Function': data['fun'],
                'StartTime': salt.utils.jid.jid_to_time(data['jid']),
                'Target': data['tgt'],
                'Target-type': data['tgt_type'],
                'User': data['user'],
            }

    return ret

返回器的故障排除

正如您所看到的,有许多不同的 Salt 组件使用不同的返回器部分。其中一些需要主节点正在运行,这使得它们稍微有点难以调试。以下是一些可以帮助的策略。

使用 salt-call 进行测试

可以使用 salt-call 命令测试 returner() 函数。在这种情况下,简单的 print 语句可以用来向您的控制台显示信息。如果有拼写错误,Python 将显示错误消息。如果问题涉及技术上有效但仍然有缺陷的代码,那么可以使用 print 语句来追踪问题。

在主节点运行时进行测试

save_load() 函数需要在主节点上生成一个工作负载,到一个或多个从节点。这当然需要主节点和至少一个从节点正在运行。您可以在不同的终端中前台运行它们,以便看到 print 语句的输出:

# salt-master --log-level debug
# salt-minion --log-level debug

如果您使用 ext_job_cache,那么您将想要监控的是从节点。如果您使用 master_job_cache,那么请监控主节点。

使用运行者进行测试

get_load()get_jid()get_jids() 函数都是由 jobs 运行器使用的。这个运行器不需要 Master 或 Minions 运行;它只要求被返回者使用的数据库可用。再次强调,这些函数内部的 print 语句会在使用 jobs 运行器时显示信息。

编写输出器模块

当使用 salt 命令时,在等待期间接收到的任何返回数据都会显示给用户。在这种情况下,输出器模块用于将数据显示到控制台(或者更准确地说,到 STDOUT),通常以某种用户友好的格式。

序列化我们的输出

因为 Salt 已经自带了一个 json 输出器,我们将利用输出数据实际上会输出到 STDOUT 的这一事实,并创建一个使用序列化器(pickle)可能输出二进制数据的 outputter

'''
Pickle outputter

This file should be saved as salt/output/pickle.py
'''
from __future__ import absolute_import
import pickle

def output(data):
    '''
    Dump out data in pickle format
    '''
    return pickle.dumps(data)

这个 outputter 的实现非常简单。唯一需要的函数叫做 output(),它接受一个字典。字典的名称无关紧要,只要函数定义了一个即可。

pickle 库是 Python 内置的,正如你在 pickle 渲染器中看到的,它非常容易使用:我们只需告诉它将数据输出到一个字符串,然后返回给 Salt。

如同往常,我们可以使用 salt-call 来测试这个 outputter

# salt-call --local test.ping --out pickle
(dp0
S'local'
p1
I01
s.

如果你查看一些 Salt 附带的其它输出器,你会发现它们同样简单。甚至 json 输出器也没有做任何额外的工作,除了格式化输出。大多数执行模块默认会使用 nested 输出器。nested 使用基于 YAML 的格式,但带有彩色数据。然而,state 函数使用的是 highstate 输出器,它基于 nested 返回数据的聚合版本,包括关于状态运行成功率的统计信息。

输出器的故障排除

输出器可能是最容易调试的模块之一。你应该能够使用 salt-call 命令测试任何输出器。

在测试时,先从简单的 test.ping 开始,以确保首先得到一些输出。一旦你满意你的 output() 函数返回的是看起来正确的基本数据,查看 grains.items,它将使用列表和字典。

你可能会发现测试你的输出与另一个已知工作良好的输出器很有用。我发现 pprint 输出器在以易于阅读的格式显示数据时通常是最简洁的,但占用的屏幕空间最少:

# salt-call --local grains.items --out pickle
# salt-call --local grains.items --out pprint

摘要

返回数据命令始终发送到主节点,即使在salt命令完成监听之后。事件总线拾取这些消息并将它们存储在外部作业缓存中。如果salt命令仍在监听,那么它将通过outputter显示。但指定返回者总会将返回数据发送到某个地方进行处理,只要主节点本身仍在运行。

可以使用--return标志指定返回者,或者可以通过ext_job_cache master配置选项在 Minion 上默认运行,或者在主节点上使用master_job_cache master配置选项来设置。

现在我们有了处理返回数据的方法,是时候创建更智能的过程来执行我们的命令了。接下来是运行者。

第七章。使用运行器进行脚本编写

Unix 背后的设计原则之一是程序应该小巧,只做一件事,但要做好。执行模块遵循这一模式,使用通常只做一件事的函数,并将相关函数组合到模块中。当函数被执行时,它执行那个任务,然后返回。

在 Unix 中,这些小程序可以通过 shell 脚本组合在一起,将它们连接成一个更强大的工具。Salt 的运行器系统将脚本元素引入 Salt,使用与 Salt 本身编写相同的语言:Python。在本章中,我们将讨论:

  • 连接到 Salt 的本地客户端

  • 向执行模块添加额外逻辑

  • 运行器的故障排除

使用 Salt 的本地客户端

运行器最初被设计在主节点上运行,以将多个作业在 Minions 之间合并成一个完整任务。为了与这些 Minions 通信,运行器需要使用local_client。与其他组件不同,这并不是直接集成到运行器中的;你需要自己初始化客户端。让我们快速设置一个示例:

import salt.client
client = salt.client.get_local_client(__opts__['conf_file'])
minions = client.cmd('*', 'test.ping', timeout=__opts__['timeout'])

这三条线构成了设置和使用本地客户端的基础。首先,我们导入salt.client库。然后,我们实例化一个客户端对象,用于与 Salt 通信。在创建那个客户端对象时,你需要告诉它在哪里可以找到 Salt 的配置文件。幸运的是,这是我们在__opts__字典中免费获得的东西,我们不太可能需要更改它,所以你代码中的那一行可能总是看起来与我们在这里做的一模一样。

最后一行使用client对象向目标发出命令。从那返回的是在指定超时内响应的 Minions 列表。让我们继续将最后一行分解成组件,并讨论每一个:

minions = client.cmd(
    '*',  # The target to use
    'test.ping',  # The command to issue
    timeout=__opts__['timeout']  # How long to wait for a response
)

到目前为止,你应该已经习惯了使用'*'作为目标,并且知道它指的是所有的 Minions。你也应该知道test.ping是一个标准命令,常用于检查并查看哪些 Minions 正在响应。超时也是必需的,但很少需要使用除配置的超时之外的其他超时,所以__opts__['timeout']几乎总是足够的。

使用本地客户端进行脚本编写

运行器,就像其他 Salt 模块一样,是基于模块内部的函数。前面的代码在技术上是对的,但它不是用作运行器的地方。让我们继续创建一个名为scan的运行器模块,我们将使用它来收集有关所有 Minions 的各种信息:

'''
Scan Minions for various pieces of information

This file should be saved as salt/runners/scan.py
'''
import salt.client

__func_alias__ = {
	'up_': 'up'
}

def up_():
    '''
    Return a list of minions which are responding
    '''
    client = salt.client.get_local_client(__opts__['conf_file'])
    minions = client.cmd('*', 'test.ping', timeout=__opts__['timeout'])
    return sorted(minions.keys())

目前我们没有什么,但它作为一个运行器是功能性的。我们的第一个函数叫做up,但由于使用少于三个字符的函数名被认为是不好的做法,所以我们将其定义为up_(),并使用__func_alias__使其可调用为up

此函数将连接到本地客户端,对所有 Minions 发出test.ping测试,然后返回一个列表,显示哪些 Minions 响应了。如果我们返回minions而不是minions.keys(),那么我们会得到一个所有响应的 Minions 及其响应内容的列表。由于我们知道test.ping总是会返回True(假设它首先返回),我们可以跳过返回这些数据。我们还对 Minions 列表进行了排序,以便于阅读。

要执行此函数,请使用salt-run命令:

# salt-run scan.up
- achatz
- dufresne

注意

为什么不在模块顶部创建客户端连接,以便每个函数都可以访问它?由于加载器以这种方式向 Salt 展示模块,__opts__字典仅在函数内部可用,因此我们无法在模块顶部使用它。你可以硬编码正确的路径,但我们都知道,硬编码的数据也是不好的做法,应该避免。

如果你只想定义一次客户端,那么考虑使用一个名为_get_conn()的私有函数,它返回连接对象。然而,由于它只包含一行代码,而这行代码不太可能改变,所以可能不值得这么做。

我们创建的scan.up函数告诉我们哪些 Minions 正在响应,但你可能更感兴趣的是哪些没有响应。这些更有可能告诉你 Minions 何时出现连接问题。让我们继续添加一个名为down()的函数:

import salt.key

def down():
    '''
    Return a list of minions which are NOT responding
    '''
    minions = up_()
    key = salt.key.Key(__opts__)
    keys = key.list_keys()
    return sorted(set(keys['minions']) – set(minions))

首先,我们需要知道哪些 Minions 已经响应,但我们已经有一个函数可以报告这一点,所以我们只需使用那个函数的响应。

我们还需要一个预期返回的 Minions 列表。我们可以通过创建一个salt.key对象,并请求它提供一个列表,其中包含其密钥已被 Master 接受的 Minions。

现在我们有了应该响应的 Minions 列表,我们就从列表中移除已经响应的 Minions,如果列表中还有剩余的 Minions,那么它们就是我们可以假设已经宕机的 Minions。和之前一样,我们在返回 Minions 列表时已经对它们进行了排序,以便于阅读:

# salt-run scan.down
- adria
- trotter

使用不同的目标

salt-run命令与salt命令区分开来的一个主要不同之处在于无法在命令行上指定目标。这是因为运行者被设计成能够自己确定自己的目标。

让我们继续更新up_()down()函数,以便用户不仅可以指定自己的目标,还可以指定目标类型:

def up_(tgt='*', tgt_type='glob'):
    '''
    Return a list of minions which are responding
    '''
    client = salt.client.get_local_client(__opts__['conf_file'])
    minions = client.cmd(
        tgt,
        'test.ping',
        expr_form=tgt_type,
        timeout=__opts__['timeout']
    )
    return sorted(minions.keys())

def down(tgt='*', tgt_type='glob'):
    '''
    Return a list of minions which are NOT responding
    '''
    minions = up_(tgt, tgt_type)

    key = salt.key.Key(__opts__)
    keys = key.list_keys()

    return sorted(set(keys['minions']) - set(minions))

在我们的函数中,tgt参数指的是目标。本地客户端无论如何都需要指定一个目标,所以我们只需在我们的函数中将'*'替换为tgttgt_type是要使用的目标类型。默认情况下,Salt 使用目标类型为glob,但用户可以根据需要指定其他类型(pcrelist等)。在本地客户端中,此参数的名称为expr_form。检查salt --help命令的输出中的“目标选择选项”,以查看您的 Salt 版本支持哪些选项。

结合作业以添加更多逻辑

运行器最强大的功能之一是能够从一个作业的输出中获取信息,并使用它来启动另一个作业。首先,让我们定义一些关于我们基础设施的内容:

  • 我们正在使用 Salt Virt 来管理一些虚拟机。

  • 一些 Minions 运行虚拟机管理程序;其他是运行在那些虚拟机管理程序内部的虚拟机。还有一些既不运行虚拟机管理程序,也不是虚拟机。

  • 正在使用多种不同的操作系统,例如 Suse、CentOS 和 Ubuntu。

考虑到这一点,我们需要运行一个报告,以确定哪些虚拟机管理程序运行在哪些操作系统上。

我们可以使用这个 Salt 命令来发现哪些 Minions 正在运行哪些操作系统:

# salt '*' grains.item os

我们可以运行以下命令来找出哪些 Minions 是虚拟化的:

# salt '*' grains.item virtual

但是,仅仅因为 Minion 的virtualgrain 设置为physical并不意味着它是一个虚拟机管理程序。我们可以运行以下命令来找出哪些 Minions 正在运行虚拟机管理程序:

# salt '*' virt.is_hyper

然而,没有东西可以聚合这些数据并告诉我们哪些虚拟机管理程序正在运行哪些操作系统;因此,让我们编写一个可以做到这一点的函数:

def hyper_os():
    '''
    Return a list of which operating system each hypervisor is running
    '''
    client = salt.client.get_local_client(__opts__['conf_file'])
    minions = client.cmd(
        '*',
        'virt.is_hyper',
        timeout=__opts__['timeout']
    )

    hypers = []
    for minion in minions:
        if minions[minion] is True:
            hypers.append(minion)

    return client.cmd(
        hypers,
        'grains.item',
        arg=('os',),
        expr_form='list',
        timeout=__opts__['timeout']
    )

在我们创建client对象之后,我们的第一个任务是查看哪些 Minions 实际上正在运行虚拟机管理程序。然后我们遍历该列表,并将它们保存在另一个名为hypers的列表中。因为我们以列表形式存储它,所以我们可以再次将expr_formlist的它传递给客户端。

我们还增加了一些新内容。grains.item函数期望一个单一参数,告诉它要查找哪个 grain。当你需要将一系列未命名的参数传递给一个函数时,请将其作为arg传递。当我们运行这个运行器时,我们的输出将类似于以下内容:

# salt-run scan.hyper_os
dufresne:
 ----------
 os:
 Arch

假设我们想要能够在显示在虚拟机管理程序列表中的任何机器上运行任意的 Salt 命令。在我们的下一部分代码中,我们将做两件事。我们将把hyper_os()拆分成两个函数,分别称为hypers()hyper_os(),然后添加一个名为hyper_cmd()的新函数,该函数将使用hypers()函数:

def hypers(client=None):
    '''
    Return a list of Minions that are running hypervisors
    '''
    if client is None:
        client = salt.client.get_local_client(__opts__['conf_file'])

    minions = client.cmd(
        '*',
        'virt.is_hyper',
        timeout=__opts__['timeout']
    )

    hypers = []
    for minion in minions:
        if minions[minion] is True:
            hypers.append(minion)

    return hypers

def hyper_os():
    '''
    Return a list of which operating system each hypervisor is running
    '''
    client = salt.client.get_local_client(__opts__['conf_file'])

    return client.cmd(
        hypers(client),
        'grains.item',
        arg=('os',),
        expr_form='list',
        timeout=__opts__['timeout']
    )

def hyper_cmd(cmd, arg=None, kwarg=None):
    '''
    Execute an arbitrary command on Minions which run hypervisors
    '''
    client = salt.client.get_local_client(__opts__['conf_file'])

    if arg is None:
        arg = []

    if not isinstance(arg, list):
        arg = [arg]

    if kwarg is None:
        kwarg = {}

    return client.cmd(
        hypers(client),
        cmd,
        arg=arg,
        kwarg=kwarg,
        expr_form='list',
        timeout=__opts__['timeout']
    )

你可能会注意到每个函数都能够创建自己的client对象,包括hypers()。这允许我们单独使用scan.hypers。然而,它还允许我们从其他函数中传递一个client对象。这可以在创建每个 Salt 命令的单独client对象上节省大量时间。

hyper_cmd() 函数允许我们以多种方式传递参数,或者如果不需要,则不传递任何参数。不传递任何参数使用它将看起来像这样:

# salt-run scan.hyper_cmd test.ping

使用未命名的参数时,它看起来像这样:

# salt-run scan.hyper_cmd test.ping

当你传递一个参数列表时,事情开始变得复杂。默认情况下,Salt 能够将命令行中传递的 YAML 转换为 Salt 内部可以使用的数据结构。这意味着你可以运行这个命令:

# salt-run scan.hyper_cmd test.arg [one,two]

Salt 将自动将 [one,two] 翻译成一个包含 one 字符串后跟 two 字符串的列表。然而,如果你运行这个命令,情况并非如此:

# salt-run scan.hyper_cmd test.arg one,two

在这种情况下,Salt 会认为你传递了一个值为 one,two 的字符串。如果你想要允许用户输入这样的列表,你需要手动检测和解析它们。

如果你想要传递命名参数,事情会变得更加复杂。以下是一个有效的例子:

salt-run scan.hyper_cmd network.interface kwarg="{'iface':'wlp3s0'}"

但要求用户输入这些内容是非常糟糕的。让我们使用 Python 自身的 *** 工具来缩小我们的函数,这些工具允许我们从命令行接受任意列表和字典:

def hyper_cmd(cmd, *arg, **kwarg):
    '''
    Execute an arbitrary command on Minions which run hypervisors
    '''
    client = salt.client.get_local_client(__opts__['conf_file'])

    return client.cmd(
        hypers(client),
        cmd,
        arg=arg,
        kwarg=kwarg,
        expr_form='list',
        timeout=__opts__['timeout']
    )

现在,我们可以运行以下命令:

# salt-run scan.hyper_cmd test.kwarg iface='wlp3s0'

最终模块

在我们的代码就绪后,最终的模块将看起来像这样:

'''
Scan Minions for various pieces of information

This file should be saved as salt/runners/scan.py
'''
import salt.client
import salt.key

__func_alias__ = {
    'up_': 'up'
}

def up_(tgt='*', tgt_type='glob'):
    '''
    Return a list of minions which are responding
    '''
    client = salt.client.get_local_client(__opts__['conf_file'])
    minions = client.cmd(
        tgt,
        'test.ping',
        expr_form=tgt_type,
        timeout=__opts__['timeout']
    '''
    Return a list of minions which are NOT responding
    '''
    minions = up_(tgt, tgt_type)

    key = salt.key.Key(__opts__)
    keys = key.list_keys()

    return sorted(set(keys['minions']) - set(minions))

def hypers(client=None):
    '''
    Return a list of Minions that are running hypervisors
    '''
    if client is None:
        client = salt.client.get_local_client(__opts__['conf_file'])

    minions = client.cmd(
        '*',
        'virt.is_hyper',
        timeout=__opts__['timeout']
    )

    hypers = []
    for minion in minions:
        if minions[minion] is True:
            hypers.append(minion)

    return hypers

def hyper_os():
    '''
    Return a list of which operating system each hypervisor is running
    '''
    client = salt.client.get_local_client(__opts__['conf_file'])

    return client.cmd(
        hypers(client),
        'grains.item',
        arg=('os',),
        expr_form='list',
        timeout=__opts__['timeout']
    )

def hyper_cmd(cmd, *arg, **kwarg):
    '''
    Execute an arbitrary command on Minions which run hypervisors
    '''
    client = salt.client.get_local_client(__opts__['conf_file'])

    return client.cmd(
        hypers(client),
        cmd,
        arg=arg,
        kwarg=kwarg,
        expr_form='list',
        timeout=__opts__['timeout']
    )

运行者的故障排除

在某种程度上,运行者比其他类型的模块更容易进行故障排除。例如,尽管它们在主服务器上运行,但它们不需要重启 salt-master 服务来获取新的更改。实际上,除非你使用本地客户端,否则你实际上不需要 salt-master 服务在运行。

与 salt-master 服务一起工作

如果你使用的是本地客户端,并且尝试在没有 salt-master 服务运行的情况下发出命令,你会得到一个看起来像这样的错误:

# salt-run scan.hyper_os
Exception occurred in runner scan.hyper_os: Traceback (most recent call last):
 File "/usr/lib/python2.7/site-packages/salt/client/mixins.py", line 340, in low
 data['return'] = self.functionsfun
 File "/usr/lib/python2.7/site-packages/salt/runners/scan.py", line 68, in hyper_os
 hypers(client),
 File "/usr/lib/python2.7/site-packages/salt/runners/scan.py", line 50, in hypers
 timeout=__opts__['timeout']
 File "/usr/lib/python2.7/site-packages/salt/client/__init__.py", line 562, in cmd
 **kwargs)
 File "/usr/lib/python2.7/site-packages/salt/client/__init__.py", line 317, in run_job
 raise SaltClientError(general_exception)
SaltClientError: Salt request timed out. The master is not responding. If this error persists after verifying the master is up, worker_threads may need to be increased.

这是因为,尽管运行者本身不依赖于 salt-master 服务,但 Minion 依赖于它来接收命令,并将响应发送回主服务器。

超时问题

如果主服务器运行正常,但你没有收到预期的响应,考虑一下你正在触发的目标。对于运行者向所有 Minion 发出命令来说,这是非常常见的,但在大型基础设施中进行测试,或者你的主服务器上有属于无法访问或不再存在的 Minion 的密钥时,运行者命令可能需要很长时间才能返回。

在编写你的模块时,你可能想要考虑将目标从 '*' 改为特定的 Minion,或者可能是一个特定的 Minion 列表(expr_form 设置为 'list',就像我们在 hyper_os()hyper_cmd() 函数中所做的那样)。只是确保在生产前将其设置回原样。

摘要

运行者向 Salt 添加了一个脚本元素,使用 Python。它们设计在 Master 上运行,但不需要salt-master服务正在运行,除非它们正在使用本地客户端向仆从发布命令。运行者设计为能够独立管理目标,但你可以添加元素以允许用户指定目标。它们特别适用于使用一个作业的输出作为另一个作业的输入,这允许你在执行模块周围包装自己的逻辑。

在下一章中,我们将允许大师使用外部资源来存储它为它的仆从(Minions)提供的服务文件。接下来:添加外部文件服务器。

第八章。添加外部文件服务器

Salt 主服务器通常将其资源存储在托管它的机器上。这包括其他许多事情,比如为从服务器提供文件。文件服务器加载器允许你使用外部资源来存储这些文件,并将它们视为主服务器本地的文件。在本章中,我们将讨论:

  • 理解 Salt 如何使用文件

  • 抽象外部源以向 Salt 提供文件

  • 使用 Salt 的缓存系统

  • 故障排除外部文件服务器

Salt 如何使用文件

Salt 内置的文件服务器在与从服务器通信时使用文件有两种方式。它们可以完整地提供服务,或者可以通过模板引擎进行处理,使用第五章中讨论的渲染模块,即渲染数据

在任何情况下,这些文件都存储在一个或多个目录集中,这些目录是通过主配置文件中的file_roots指令配置的。这些目录按环境分组。当 Salt 寻找文件时,它将按照列出的顺序搜索这些目录。默认环境base通常使用/srv/salt/来存储文件。这样的配置看起来可能像:

file_roots:
  base:
    - /srv/salt/

许多用户没有意识到,file_roots指令实际上是一个特定于名为roots的文件服务器模块的配置选项。这个模块,以及所有其他文件服务器模块,都是通过fileserver_backend指令进行配置的:

fileserver_backend:
  - roots

这是你配置 Salt 中使用的任何其他文件服务器模块的地方。再次强调,模块的配置顺序与它们的使用顺序一致。当主服务器请求一个文件给从服务器时,Salt 会检查这些模块中的每一个,直到找到匹配项。找到后,它将停止搜索,并服务找到的文件。这意味着如果你有以下配置:

fileserver_backend:
  - git
  - roots

如果 Salt 在 Git 中找到请求的文件,它将忽略在本地文件系统中找到的任何文件。

模拟文件系统

如果你以前编写过 FUSE 文件系统,你会在 Salt 文件服务器模块内部识别到一些函数。许多用于从操作系统请求文件的操作与 Salt 请求文件时使用的文件非常相似。归根结底,Salt 文件服务器模块实际上是一个虚拟文件系统,但它的 API 是专门为 Salt 设计的,而不是为操作系统设计的。

当你使用文件服务器模块进行开发时,你可能会注意到另一个趋势。虽然使用的数据可能存储在远程位置,但反复检索这些文件可能会在资源上造成成本。因此,许多文件服务器模块将从远程位置检索文件,并在主服务器上本地缓存它们,仅在必要时更新。

在这方面,当你编写文件服务器模块时,你通常只是在实现检索和缓存文件以及从缓存中提供文件的手段。这并不总是最好的做法;一个完全基于数据库查询的真正动态文件服务器可能通过始终执行查找来表现最佳。你需要从一开始就决定最合适的策略。

查看每个函数

我们将要编写的文件服务器将基于 SFTP。由于 SFTP 调用可能很昂贵,我们将使用一个依赖于流行的 Python 库 Paramiko 的缓存实现来检索文件。为了简单起见,我们只允许配置一个 SFTP 服务器,但如果你发现自己在使用这个模块,你可能想要考虑允许配置多个端点。

设置我们的模块

在我们介绍使用的函数之前,我们开始设置模块本身。我们将实现一些提供我们将在整个模块中使用的对象的函数:

'''
The backend for serving files from an SFTP account.

To enable, add ``sftp`` to the :conf_master:`fileserver_backend` option in the
Master config file.

.. code-block:: yaml

    fileserver_backend:
      - sftp

Each environment is configured as a directory inside the SFTP account. The name
of the directory must match the name of the environment.

.. code-block:: yaml

    sftpfs_host: sftp.example.com
    sftpfs_port: 22
    sftpfs_username: larry
    sftpfs_password: 123pass
    sftpfs_root: /srv/sftp/salt/
'''
import os
import os.path
import logging
import time
import salt.fileserver
import salt.utils
import salt.syspaths

try:
    import fcntl
    HAS_FCNTL = True
except ImportError:
    HAS_FCNTL = False

try:
    import paramiko
    from paramiko import AuthenticationException
    HAS_LIBS = True
except ImportError:
    HAS_LIBS = False

__virtualname__ = 'sftp'

log = logging.getLogger()

transport = None
client = None

def __virtual__():
    '''
    Only load if proper conditions are met
    '''
    if __virtualname__ not in __opts__['fileserver_backend']:
        return False

    if not HAS_LIBS:
        return False

    if __opts__.get('sftpfs_root', None) is None:
        return False

    global client
    global transport

    host = __opts__.get('sftpfs_host')
    port = __opts__.get('sftpfs_port', 22)
    username = __opts__.get('sftpfs_username')
    password = __opts__.get('sftpfs_password')
    try:
        transport = paramiko.Transport((host, port))
        transport.connect(username=username, password=password)
        client = paramiko.SFTPClient.from_transport(transport)
    except AuthenticationException:
        return False

    return True

已经有很多内容了!幸运的是,你现在应该已经认识到了大部分内容,所以这部分应该会很快过去。

我们包含了一个比平常更长的文档字符串,它解释了如何配置 Salt 使用我们的模块。当我们到达__virtual__()函数时,我们将看到这些参数的使用。

接下来,我们设置我们的导入。大多数这些导入的使用将在我们通过单个函数进行时进行说明,但有两个我们将其包裹在try/except块中。第一个是fcntl,这是一个 Unix 系统调用,用于处理文件描述符。这个库在 Unix 和 Linux 中用于锁定文件,但在 Windows 中不存在。然而,我们模块的其余部分在 Windows 中是可用的,所以我们现在设置一个标志,稍后当我们需要锁定文件时可以使用。

第二个导入是 Paramiko。这是 Python 中用于 SSH 和 SFTP 的最受欢迎的连接库之一,对于我们的目的来说简单易用。如果没有安装,我们可以在__virtual__()函数中返回False

我们添加了__virtualname__,尽管它不是严格必要的,只是为了有一个中央且易于找到的地方来命名我们的模块。我们将在__virtual__()函数中使用这个变量。我们还添加了一些日志记录,我们将利用它们。

在加载__virtual__()函数之前,我们已经定义了两个变量,用于连接到 SFTP 服务器。我们将在__virtual__()内部将连接分配给它们,并且它们将在整个模块中使用。

最后,我们有我们的 __virtual__() 函数。首先,我们检查这个模块是否已经被配置用于使用。如果没有,就没有继续下去的必要了。我们还检查确保 Paramiko 已经安装。然后我们确保已经指定了 SFTP 服务器的根目录。现在可能还不明显,但这个目录在其他地方也将是必需的。如果它不存在,那么我们甚至不会尝试去连接服务器。

如果它已被定义,那么我们可以继续尝试建立连接。如果我们的其他参数定义不正确,Paramiko 将会抛出 AuthenticationException。在这种情况下,当然我们会认为这个模块不可用,并返回 False。但如果所有这些条件都满足,那么我们就准备好开始工作了!

让我们回顾一下在任何文件服务器模块中应该找到的函数。在每个部分,我们将实现并解释那个函数。

envs()

我们首先报告哪些环境已经为这个文件服务器配置。至少,base 环境应该被支持并报告,但最好提供一个机制来支持其他环境。因为我们实际上是在抽象文件管理机制,所以通常最简单的方法就是通过将环境分离到目录中来实现:

def envs():
    '''
    Treat each directory as an environment
    '''
    ret = []
    root = __opts__.get('sftpfs_root')
    for entry in client.listdir_attr(root):
        if str(oct(entry.st_mode)).startswith('04'):
            ret.append(entry.filename)
    return ret

这个函数需要返回一个列表。因为我们已经将环境分离到它们自己的目录中,所以我们的模块只需要返回我们配置的根目录下的目录列表。

这个函数很难测试,因为在任何 Salt 模块中都没有直接接口。然而,一旦下两个函数就位,就可以对其进行测试。

file_list() 和 dir_list()

这两个函数相当直观;它们连接到远程端点,并返回该环境下的所有文件和目录列表:

def file_list(load):
    '''
    Return a list of all files on the file server in a specified environment
    '''
    root = __opts__.get('sftpfs_root')
    path = os.path.join(root, load['saltenv'], load['prefix'])
    return _recur_path(path, load['saltenv'])

def dir_list(load):
    '''
    Return a list of all directories on the master
    '''
    root = __opts__.get('sftpfs_root')
    path = os.path.join(root, load['saltenv'], load['prefix'])
    return _recur_path(path, load['saltenv'], True)

def _recur_path(path, saltenv, only_dirs=False):
    '''
    Recurse through the remote directory structure
    '''
    root = __opts__.get('sftpfs_root')
    ret = []
    try:
        for entry in client.listdir_attr(path):
            full = os.path.join(path, entry.filename)
            if str(oct(entry.st_mode)).startswith('04'):
                ret.append(full)
                ret.extend(_recur_path(full, saltenv, only_dirs))
            else:
                if only_dirs is False:
                    ret.append(full)
        return ret
    except IOError:
        return []

这两个函数所需的东西完全相同,只是是否包含文件。因为递归通常总是需要的,所以我们添加了一个名为 _recur_path() 的递归函数,它可以报告目录或文件和目录。你可能注意到了对 entry.st_mode 的检查。你可能把 Unix 文件模式看作是一组权限,这些权限可以使用 chmodchange mode)命令来更改。然而,模式还存储了文件类型:

0100755  # This is a file, with 0755 permissions
040755  # This is a directory, with 0755 permissions

我们可以使用另一个 try/except 块来检查是否可以进入一个目录。但检查模式会更省事。如果它以 04 开头,那么我们知道它是一个目录。

这些函数都需要一个 load 参数。如果你查看内部,你会找到一个看起来像这样的字典:

{'cmd': '_file_list', 'prefix': '', 'saltenv': 'base'}

cmd 字段存储了使用了哪种命令。prefix 将包含包含任何请求文件的目录路径,在环境中,saltenv 告诉你请求的环境本身的名称。你将在整个模块中看到这个参数,但它的外观大致相同。

让我们来看看几个 Salt 命令:

# salt-call --local cp.list_master
local:
 - testdir
 - testfile
# salt-call --local cp.list_master_dirs
local:
 - testdir

请记住,--local 将告诉 salt-call 假装它是它自己的 Master。在这种情况下,它将查找 minion 配置文件以获取连接参数。

find_file()

file_list()dir_list() 类似,此功能检查请求的路径。然后报告指定的文件是否存在:

'''
def find_file(path, saltenv='base', **kwargs):
    '''
    Search the environment for the relative path
    '''
    fnd = {'path': '',
           'rel': ''}

    full = os.path.join(salt.syspaths.CACHE_DIR, 'sftpfs', saltenv, path)

    if os.path.isfile(full) and not salt.fileserver.is_file_ignored(__opts__, full):
        fnd['path'] = full
        fnd['rel'] = path

    return fnd

你可能已经注意到在此函数中没有进行任何 SFTP 调用。这是因为我们正在使用缓存文件服务器,我们现在需要检查的是文件是否已被缓存。如果是,那么 Salt 将直接从缓存中提供文件。

如果你正在编写一个不保留本地缓存的文件服务器模块,那么此功能应该检查远程端点以确保文件存在。

说到缓存,此函数中更重要的一行是定义 full 变量的那一行。这设置了用于此缓存文件服务器的目录结构。它使用 salt.syspaths 确定您平台上的正确目录;通常,这将是在 /var/cache/salt/

注意,此函数中没有传递 load,但 saltenv(通常在 load 中)被传递。Salt 的早期版本将 saltenv 传递为 env,并将 **kwargs 函数作为通配符来防止 Python 在旧实现上崩溃。

再次强调,无法直接测试此功能。它将在本节后面的 update() 函数中使用。

serve_file()

使用 find_file() 找到文件后,其数据将传递给此函数以返回实际的文件内容:

def serve_file(load, fnd):
    '''
    Return a chunk from a file based on the data received
    '''
    ret = {'data': '',
           'dest': ''}

    if 'path' not in load or 'loc' not in load or 'saltenv' not in load:
        return ret

    if not fnd['path']:
        return ret

    ret['dest'] = fnd['rel']
    gzip = load.get('gzip', None)

    full = os.path.join(salt.syspaths.CACHE_DIR, 'sftpfs', fnd['path'])

    with salt.utils.fopen(fnd['path'], 'rb') as fp_:
        fp_.seek(load['loc'])
        data = fp_.read(__opts__['file_buffer_size'])
        if gzip and data:
            data = salt.utils.gzip_util.compress(data, gzip)
            ret['gzip'] = gzip
        ret['data'] = data
    return ret

此功能直接由 Salt 的内部文件服务器使用,在将文件分块传递给 Minions 之前将文件分割成块。如果在主配置文件中将 gzip 标志设置为 True,则每个这些块都将单独压缩。

由于在我们的情况下,此功能是从缓存中提供文件,因此你可能会使用这里打印的此功能,除了定义 full 变量的那一行。如果你没有使用缓存文件服务器,那么你需要有访问和提供文件每个块的方法,正如请求的那样。

你可以使用 cp.get_file 函数测试此功能。此功能需要下载的文件名和保存文件到本地的完整路径:

# salt-call --local cp.get_file salt://testfile /tmp/testfile
local:
 /tmp/testfile

update()

在固定的时间间隔内,Salt 将要求外部文件服务器对其进行维护。此功能将比较本地文件缓存(如果正在使用)与远程端点,并使用新信息更新 Salt:

def update():
    '''
    Update the cache, and reap old entries
    '''
    base_dir = os.path.join(salt.syspaths.CACHE_DIR, 'sftpfs')
    if not os.path.isdir(base_dir):
        os.makedirs(base_dir)

    try:
        salt.fileserver.reap_fileserver_cache_dir(
            os.path.join(base_dir, 'hash'),
            find_file
        )
    except (IOError, OSError):
        # Hash file won't exist if no files have yet been served up
        pass

    # Find out what the latest file is, so that we only update files more
    # recent than that, and not the entire filesystem
    if os.listdir(base_dir):
        all_files = []
        for root, subFolders, files in os.walk(base_dir):
            for fn_ in files:
                full_path = os.path.join(root, fn_)
                all_files.append([
                    os.path.getmtime(full_path),
                    full_path,
                ])

    # Pull in any files that have changed
    for env in envs():
        path = os.path.join(__opts__['sftpfs_root'], env)
        result = client.listdir_attr(path)
        for fileobj in result:
            file_name = os.path.join(base_dir, env, fileobj.filename)

            # Make sure the directory exists first
            comps = file_name.split('/')
            file_path = '/'.join(comps[:-1])
            if not os.path.exists(file_path):
                os.makedirs(file_path)

            if str(oct(fileobj.st_mode)).startswith('04'):
                # Create the directory
                if not os.path.exists(file_name):
                    os.makedirs(file_name)
            else:
                # Write out the file
                if fileobj.st_mtime > all_files[file_name]:
                    client.get(os.path.join(path, fileobj.filename), file_name)
            os.utime(file_name, (fileobj.st_atime, fileobj.st_mtime))

呼呼!这是一个很长的函数!首先,我们定义缓存目录,如果它不存在,则创建它。这对于缓存文件服务器来说很重要。然后我们要求 Salt 使用内置的salt.fileserver.reap_fileserver_cache_dir()函数清理旧条目。这传递了find_file()的引用以帮助工作。

下一节将介绍剩余的文件,以检查它们的最后修改时间戳。只有在文件尚未下载,或者远程 SFTP 服务器上有更新的副本时,才会下载文件。

最后,我们遍历每个环境,查看哪些文件已更改,并在必要时下载它们。如果本地缓存中不存在任何目录,则会创建它们。无论我们创建文件还是目录,我们都会确保更新其时间戳,以便缓存与服务器上的内容相匹配。

这个函数将由 Salt Master 定期运行,但您可以通过手动从本地缓存中删除文件并请求副本来强制它运行:

# rm /var/cache/salt/sftpfs/base/testfile
# salt-call --local cp.get_file salt://testfile /tmp/testfile
local:
 /tmp/testfile

file_hash()

Salt 知道文件已被更改的一种方式是跟踪文件的哈希签名。如果哈希值发生变化,那么 Salt 将知道是时候从缓存中提供文件的新副本了:

def file_hash(load, fnd):
    '''
    Return a file hash, the hash type is set in the master config file
    '''
    path = fnd['path']
    ret = {}

    # if the file doesn't exist, we can't get a hash
    if not path or not os.path.isfile(path):
        return ret

    # set the hash_type as it is determined by config
    ret['hash_type'] = __opts__['hash_type']

    # Check if the hash is cached
    # Cache file's contents should be 'hash:mtime'
    cache_path = os.path.join(
        salt.syspaths.CACHE_DIR,
        'sftpfs',
        'hash',
        load['saltenv'],
        '{0}.hash.{1}'.format(
            fnd['rel'],
            ret['hash_type']
        )
    )

    # If we have a cache, serve that if the mtime hasn't changed
    if os.path.exists(cache_path):
        try:
            with salt.utils.fopen(cache_path, 'rb') as fp_:
                try:
                    hsum, mtime = fp_.read().split(':')
                except ValueError:
                    log.debug(
                        'Fileserver attempted to read incomplete cache file. Retrying.'
                    )
                    file_hash(load, fnd)
                    return ret
                if os.path.getmtime(path) == mtime:
                    # check if mtime changed
                    ret['hsum'] = hsum
                    return ret
        except os.error:
            # Can't use Python select() because we need Windows support
            log.debug(
                'Fileserver encountered lock when reading cache file. Retrying.'
            )
            file_hash(load, fnd)
            return ret

    # If we don't have a cache entry-- lets make one
    ret['hsum'] = salt.utils.get_hash(path, __opts__['hash_type'])
    cache_dir = os.path.dirname(cache_path)

    # Make cache directory if it doesn't exist
    if not os.path.exists(cache_dir):
        os.makedirs(cache_dir)

    # Save the cache object 'hash:mtime'
    if HAS_FCNTL:
        with salt.utils.flopen(cache_path, 'w') as fp_:
            fp_.write('{0}:{1}'.format(ret['hsum'], os.path.getmtime(path)))
            fcntl.flock(fp_.fileno(), fcntl.LOCK_UN)
        return ret
    else:
        with salt.utils.fopen(cache_path, 'w') as fp_:
            fp_.write('{0}:{1}'.format(ret['hsum'], os.path.getmtime(path)))
        return ret

这是我们示例中最长的函数,但幸运的是,它也需要最少的修改,对于一个缓存文件服务器来说。正如本书中的其他示例一样,您可以从 Packt Publishing 的网站上下载此模块的副本。一旦下载完成,您可能只需要更改cache_path的值。然而,我们仍然会简要地介绍这个函数。

在设置了一些基本设置,包括正在散列的文件的路径,检查该路径是否存在,并定义在缓存中保存哈希副本的位置之后。在我们的例子中,我们在缓存中设置了一个额外的目录结构,与原始结构相似,但文件名后附加了.hash.<hash_type>。生成的文件将具有如下名称:

/var/cache/salt/sftpfs/hash/base/testfile.hash.md5

下一节将检查哈希文件是否已创建,以及是否与本地副本的时间戳匹配。如果现有哈希文件的时间戳太旧,则将生成新的哈希值。

如果我们通过了所有这些,那么我们就知道是时候生成新的哈希值了。在确定要使用的哈希类型并设置存放它的目录之后,我们到达了实际将哈希写入磁盘的部分。还记得模块开头对fcntl的检查吗?在繁忙的 Salt Master 上,可能同时尝试对同一文件进行多次操作。有了fcntl,我们可以在写入之前锁定该文件,以避免损坏。

最终模块

在所有函数就绪后,最终的模块将看起来像这样:

'''
The backend for serving files from an SFTP account.

To enable, add ``sftp`` to the :conf_master:`fileserver_backend` option in the
Master config file.

.. code-block:: yaml

    fileserver_backend:
      - sftp

Each environment is configured as a directory inside the SFTP account. The name
of the directory must match the name of the environment.

.. code-block:: yaml

    sftpfs_host: sftp.example.com
    sftpfs_port: 22
    sftpfs_username: larry
    sftpfs_password: 123pass
    sftpfs_root: /srv/sftp/salt/
'''
import os
import os.path
import logging
import time

try:
    import fcntl
    HAS_FCNTL = True
except ImportError:
    # fcntl is not available on windows
    HAS_FCNTL = False

import salt.fileserver
import salt.utils
import salt.syspaths

try:
    import paramiko
    from paramiko import AuthenticationException
    HAS_LIBS = True
except ImportError:
    HAS_LIBS = False

__virtualname__ = 'sftp'

log = logging.getLogger()

transport = None
client = None

def __virtual__():
    '''
    Only load if proper conditions are met
    '''
    if __virtualname__ not in __opts__['fileserver_backend']:
        return False

    if not HAS_LIBS:
        return False

    if __opts__.get('sftpfs_root', None) is None:
        return False

    global client
    global transport

    host = __opts__.get('sftpfs_host')
    port = __opts__.get('sftpfs_port', 22)
    username = __opts__.get('sftpfs_username')
    password = __opts__.get('sftpfs_password')
    try:
        transport = paramiko.Transport((host, port))
        transport.connect(username=username, password=password)
        client = paramiko.SFTPClient.from_transport(transport)
    except AuthenticationException:
        return False

    return True

def envs():
    '''
    Treat each directory as an environment
    '''
    ret = []
    root = __opts__.get('sftpfs_root')
    for entry in client.listdir_attr(root):
        if str(oct(entry.st_mode)).startswith('04'):
            ret.append(entry.filename)
    return ret

def file_list(load):
    '''
    Return a list of all files on the file server in a specified environment
    '''
    root = __opts__.get('sftpfs_root')
    path = os.path.join(root, load['saltenv'], load['prefix'])
    return _recur_path(path, load['saltenv'])

def dir_list(load):
    '''
    Return a list of all directories on the master
    '''
    root = __opts__.get('sftpfs_root')
    path = os.path.join(root, load['saltenv'], load['prefix'])
    return _recur_path(path, load['saltenv'], True)

def _recur_path(path, saltenv, only_dirs=False):
    '''
    Recurse through the remote directory structure
    '''
    root = __opts__.get('sftpfs_root')
    ret = []
    try:
        for entry in client.listdir_attr(path):
            full = os.path.join(path, entry.filename)
            if str(oct(entry.st_mode)).startswith('04'):
                ret.append(full)
                ret.extend(_recur_path(full, saltenv, only_dirs))
            else:
                if only_dirs is False:
                    ret.append(full)
        return ret
    except IOError:
        return []

def find_file(path, saltenv='base', env=None, **kwargs):
    '''
    Search the environment for the relative path
    '''
    fnd = {'path': '',
           'rel': ''}

    full = os.path.join(salt.syspaths.CACHE_DIR, 'sftpfs', saltenv, path)

    if os.path.isfile(full) and not salt.fileserver.is_file_ignored(__opts__, full):
        fnd['path'] = full
        fnd['rel'] = path

    return fnd

def serve_file(load, fnd):
    '''
    Return a chunk from a file based on the data received
    '''
    ret = {'data': '',
           'dest': ''}

    if 'path' not in load or 'loc' not in load or 'saltenv' not in load:
        return ret

    if not fnd['path']:
        return ret

    ret['dest'] = fnd['rel']
    gzip = load.get('gzip', None)

    full = os.path.join(salt.syspaths.CACHE_DIR, 'sftpfs', fnd['path'])

    with salt.utils.fopen(fnd['path'], 'rb') as fp_:
        fp_.seek(load['loc'])
        data = fp_.read(__opts__['file_buffer_size'])
        if gzip and data:
            data = salt.utils.gzip_util.compress(data, gzip)
            ret['gzip'] = gzip
        ret['data'] = data
    return ret

def update():
    '''
    Update the cache, and reap old entries
    '''
    base_dir = os.path.join(salt.syspaths.CACHE_DIR, 'sftpfs')
    if not os.path.isdir(base_dir):
        os.makedirs(base_dir)

    try:
        salt.fileserver.reap_fileserver_cache_dir(
            os.path.join(base_dir, 'hash'),
            find_file
        )
    except (IOError, OSError):
        # Hash file won't exist if no files have yet been served up
        pass

    # Find out what the latest file is, so that we only update files more
    # recent than that, and not the entire filesystem
    if os.listdir(base_dir):
        all_files = {}
        for root, subFolders, files in os.walk(base_dir):
            for fn_ in files:
                full_path = os.path.join(root, fn_)
                all_files[full_path] = os.path.getmtime(full_path)

    # Pull in any files that have changed
    for env in envs():
        path = os.path.join(__opts__['sftpfs_root'], env)
        result = client.listdir_attr(path)
        for fileobj in result:
            file_name = os.path.join(base_dir, env, fileobj.filename)

            # Make sure the directory exists first
            comps = file_name.split('/')
            file_path = '/'.join(comps[:-1])
            if not os.path.exists(file_path):
                os.makedirs(file_path)

            if str(oct(fileobj.st_mode)).startswith('04'):
                # Create the directory
                if not os.path.exists(file_name):
                    os.makedirs(file_name)
            else:
                # Write out the file
                if fileobj.st_mtime > all_files[file_name]:
                    client.get(os.path.join(path, fileobj.filename), file_name)
            os.utime(file_name, (fileobj.st_atime, fileobj.st_mtime))

def file_hash(load, fnd):
    '''
    Return a file hash, the hash type is set in the master config file
    '''
    path = fnd['path']
    ret = {}

    # if the file doesn't exist, we can't get a hash
    if not path or not os.path.isfile(path):
        return ret

    # set the hash_type as it is determined by config
    # -- so mechanism won't change that
    ret['hash_type'] = __opts__['hash_type']

    # Check if the hash is cached
    # Cache file's contents should be 'hash:mtime'
    cache_path = os.path.join(
        salt.syspaths.CACHE_DIR,
        'sftpfs',
        'hash',
        load['saltenv'],
        '{0}.hash.{1}'.format(
            fnd['rel'],
            ret['hash_type']
        )
    )

    # If we have a cache, serve that if the mtime hasn't changed
    if os.path.exists(cache_path):
        try:
            with salt.utils.fopen(cache_path, 'rb') as fp_:
                try:
                    hsum, mtime = fp_.read().split(':')
                except ValueError:
                    log.debug(
                        'Fileserver attempted to read'
                        'incomplete cache file. Retrying.'
                    )
                    file_hash(load, fnd)
                    return ret
                if os.path.getmtime(path) == mtime:
                    # check if mtime changed
                    ret['hsum'] = hsum
                    return ret
        except os.error:
            # Can't use Python select() because we need Windows support
            log.debug(
                'Fileserver encountered lock when reading cache file. Retrying.'
            )
            file_hash(load, fnd)
            return ret

    # If we don't have a cache entry-- lets make one
    ret['hsum'] = salt.utils.get_hash(path, __opts__['hash_type'])
    cache_dir = os.path.dirname(cache_path)

    # Make cache directory if it doesn't exist
    if not os.path.exists(cache_dir):
        os.makedirs(cache_dir)

    # Save the cache object 'hash:mtime'
    if HAS_FCNTL:
        with salt.utils.flopen(cache_path, 'w') as fp_:
            fp_.write('{0}:{1}'.format(ret['hsum'], os.path.getmtime(path)))
            fcntl.flock(fp_.fileno(), fcntl.LOCK_UN)
        return ret
    else:
        with salt.utils.fopen(cache_path, 'w') as fp_:
            fp_.write('{0}:{1}'.format(ret['hsum'], os.path.getmtime(path)))
        return ret

文件服务器故障排除

文件服务器模块可能难以调试,因为许多组件需要就位,其他组件才能使用。但有一些技巧你可以记住。

从小开始

我已经尝试以编写和调试最容易的顺序呈现必要的功能。虽然不能直接调用envs(),但它很容易编写,可以在处理file_list()dir_list()时进行调试。而且,可以使用cp.list_mastercp.list_master_dirs函数分别轻松调试这两个功能。

在 Minion 上测试

虽然文件服务器模块是为在主服务器上使用而设计的,但它们也可以在 Minion 上进行测试。确保在minion配置文件中而不是在master文件中定义所有适当的配置。使用salt-call --local来发布命令,并定期清除本地缓存(在/var/salt/cache/中)以及使用cp.get_file下载的任何文件。

摘要

文件服务器模块可以用来在外部端点呈现资源,就像它们是位于主服务器上的文件一样。默认的文件服务器模块,名为roots,实际上确实使用了主服务器上的本地文件。许多文件服务器模块在主服务器上本地缓存文件,以避免对外部源进行过多的调用,但这并不总是合适的。

文件服务器模块内部有许多功能,它们协同工作以呈现类似文件服务器的接口。其中一些功能不能直接测试,但它们仍然可以与其他具有直接外部接口的功能一起测试。

尽管涉及许多功能,但文件服务器模块相对容易编写。在下一章中,我们将讨论云模块,它们有更多的必需功能,但编写起来却更加容易。

第九章。连接到云

由于需要许多功能来呈现一个针对云提供商的统一工具,云模块可能看起来是最令人生畏的 Salt 模块类型。幸运的是,一旦你知道如何操作,连接到大多数云提供商都很简单。在本章中,我们将讨论:

  • 理解云组件如何协同工作

  • 学习所需的函数以及它们的使用方法

  • 比较基于 Libcloud 的模块与直接 REST 模块

  • 编写通用的云模块

  • 云模块的故障排除

理解云组件

近年来,“云”这个词遭受了过度使用和误用的不幸,所以在我们谈论组件看起来像什么之前,我们首先需要定义我们真正在谈论的是什么。

Salt Cloud 旨在与计算云提供商一起运行。这意味着它们提供计算资源,通常以虚拟机的形式。许多云提供商还提供其他资源,如存储空间、DNS 和负载均衡。虽然 Salt Cloud 并非明确设计来管理这些资源,但可以添加对这些资源的支持。

对于我们的目的,我们将讨论创建云驱动程序,重点是管理虚拟机。其中一些技术可以用于添加其他资源,所以如果你打算朝那个方向发展,本章对你仍然有用。

观察拼图碎片

Salt Cloud 的主要目标是轻松在云提供商上创建虚拟机,在该机器上安装 Salt Minion,然后自动在 Master 上接受该 Minion 的密钥。当你深入挖掘时,你会发现许多部件协同工作以实现这一目标。

连接机制

大多数云提供商都提供 API 来管理账户中的资源。此 API 包括一个身份验证方案,以及一组用于类似目的的 URL。几乎每个云提供商都支持基于GETPOST方法的 URL,但一些支持其他方法,如PATCHDELETE

很频繁地,这些 URL 将包括多达四个组件:

  • 资源名称

  • 在该资源上要执行的操作

  • 要管理的资源的 ID

  • 定义如何管理资源的参数

这些组件可以与身份验证方案结合使用,创建一个用于执行所有可用管理功能的单一工具。

列出资源

大多数资源都有一种从 API 中列出它们的方式。这包括由云提供商定义的选项以及属于你的账户且可以由你管理的资源。通常可以从 API 中列出的资源包括:

  • 操作系统镜像

  • 可以创建的虚拟机大小

  • 用户账户中的现有虚拟机

  • 特定虚拟机的详细信息

  • 由账户管理的非计算资源

Salt Cloud 模块应该提供几种列出资源的方法,无论是创建新的虚拟机还是管理现有的虚拟机

创建虚拟机

大多数云模块中最复杂的组件是create()函数,它协调请求虚拟机、等待其可用、登录并安装 Salt 以及接受该虚拟机的 Minion 密钥在 Master 上的任务。许多这些任务已经抽象成可以从云模块中调用的辅助函数,这大大简化了create()函数的开发

管理其他资源

一旦将前面的组件组合在一起,创建其他用于创建、列出、修改和删除其他资源的函数通常不会花费太多精力

Libcloud 与 SDK 与直接 REST API 的比较

Salt 附带三种类型的云模块。第一种和原始类型的模块使用名为 Libcloud 的库与云服务提供商通信。使用此类库有一些明显的优点:

  • Libcloud 支持大量的云服务提供商

  • Libcloud 在各个提供商之间提供了一个标准且相对一致的接口

  • Salt Cloud 为 Libcloud 构建了一些内置的功能

  • Libcloud 正在积极开发,并频繁发布新版本

使用 Libcloud 也有一些缺点:

  • 并非每个云中的每个功能都由 Libcloud 支持

  • 新的云服务提供商可能尚未得到支持

  • 一些旧的、不为人知的和专有的驱动程序可能永远不会得到支持

一些云服务提供商还提供了他们自己的库来连接到他们的基础设施。这可能证明是连接到他们的最快、最简单或最可靠的方式。使用提供商自己的 SDK 的一些优点是:

  • 开发者可能对 API 有最全面的知识

  • 当新功能发布时,SDK 通常是第一个支持它们的库

一些缺点是:

  • 一些 SDK 仍然不支持该云服务提供商的所有功能

  • 一些 SDK 可能难以使用

与云服务提供商通信的另一种选项是直接与他们通信 REST API。这种方法的一些优点是:

  • 您可以控制模块的维护方式

  • 您可以在不等待库的新版本的情况下添加自己的功能

但使用直接 REST API 有一些明显的缺点:

  • 您必须维护该模块

  • 您必须自己添加任何新的功能

  • 您可能没有云服务提供商那么多的资源来使用驱动程序

您将需要决定哪种选项最适合您的具体情况。幸运的是,一旦您设置了要使用的连接机制(无论您是自己编写还是使用他人的),使用这些连接的函数之间实际上并没有真正的区别

编写通用的云模块

我们将设置一个非常通用的模块,该模块使用直接 REST API 与云提供商进行通信。如果您花了很多时间与不同的 API 交互,您会发现这里使用的风格非常常见。

检查所需配置

为了使用云提供商,您需要一个 __virtual__() 函数来检查所需配置,并在必要时检查任何依赖项。您还需要一个名为 get_configured_provider() 的函数,该函数检查确保连接到您的云提供商所需的配置(至少是身份验证,有时还有其他连接参数)已被指定。我们还需要定义 __virtualname__,它包含驱动程序的名称,Salt Cloud 将知道它。让我们从这里开始我们的云模块:

'''
Generic Salt Cloud module

This module is not designed for any specific cloud provider, but is generic
enough that only minimal changes may be required for some providers.

This file should be saved as salt/cloud/clouds/generic.py

Set up the cloud configuration at ``/etc/salt/cloud.providers`` or
``/etc/salt/cloud.providers.d/generic.conf``:

.. code-block:: yaml

    my-cloud-config:
      driver: generic
      # The login user
      user: larry
      # The user's password
      password: 123pass
      # The user's API key
      api_key: 0123456789abcdef
'''
__virtualname__ = 'generic'

def __virtual__():
    '''
    Check for cloud configs
    '''
    # No special libraries required

    if get_configured_provider() is False:
        return False

    return __virtualname__

def get_configured_provider():
    '''
    Make sure configuration is correct
    '''
    return config.is_provider_configured(
        __opts__,
        __active_provider_name__ or __virtualname__,
        ('user', 'password', 'apikey')
    )

我们从一个包含有关我们驱动程序所需配置信息的 docstring 开始。我们将坚持使用简单的身份验证方案,该方案使用 API 密钥作为 URL 的一部分,以及 HTTP 用户名和密码。

__virtual__() 函数首先应该确保安装了所有必需的库。在我们的例子中,我们不需要任何特殊的东西,所以我们将跳过这一部分。然后我们调用 get_configured_provider() 来确保所有必需的配置都已就绪,如果一切顺利,我们返回 __virtualname__

get_configured_provider() 函数将不会改变,除了模块工作所必需的绝对必需的参数列表之外。如果您打算接受任何可选参数,请不要将它们包含在这个函数中。

注意

get_configured_provider() 函数提到了另一个内置变量,称为 __active_provider_name__。这个变量包含用户在他们的提供者配置中为该模块设置的名称(例如 my-cloud-config)以及实际驱动程序的名称(在我们的例子中是 generic),两者之间用冒号(:)分隔。如果您要使用我们文档字符串中的示例配置,那么 __active_provider_name__ 将被设置为 my-cloud-config:generic

使用 http.query()

Salt 自带了一个用于通过 HTTP 通信的库。这个库本身不是一个连接库;相反,它允许您使用 urllib2(Python 的一部分),Tornado(Salt 自身的依赖项),或 requests(Python 中非常流行且功能强大的 HTTP 库)。像 Libcloud 一样,Salt 的 HTTP 库力求在所有可用库之间提供一致的接口。如果您需要在该库中使用特定功能,您可以指定要使用的库,但默认情况下使用 Tornado。

这个库位于 salt.utils 中,包含了许多与 HTTP 相关的函数。其中最常用的是 query() 函数。它不仅支持所有三个后端库,还包括将返回数据从 JSON 或 XML 自动转换为 Python 字典的机制。

http.query()的调用通常看起来像这样:

import salt.utils.http
result = salt.utils.http.query(
    'https://api.example.com/v1/resource/action/id',
    'POST',
    data=post_data_dict,
    decode=True,
    decode_type='json',
    opts=__opts__
)
print(result['dict'])

常见的 REST API

在我们连接到 REST API 之前,我们需要知道它的样子。URL 的结构通常包含以下组件:

https://<hostname>/<version>/<resource>[/<action>[/<id>]]

从技术上讲,URL 方案可以是 HTTP,但如果这是你唯一的选择,我建议切换到另一个云服务提供商。

主机名通常包含一些表明它属于 API 的提示,例如api.example.com。你的云服务提供商的文档将告诉你这里应该使用哪个主机名。主机名也可能包含有关你正在与之通信的数据中心的信息,例如eu-north.api.example.com

大多数提供商还要求你指定你正在使用的 API 版本。这可能包含在 URL 中,或在POST数据中,甚至在客户端请求头中。除非你有非常充分的理由不这样做,否则你应该始终使用最新版本,但云服务提供商通常会支持旧版本,即使只是暂时性的。

资源指的是你实际监控的内容。这可能类似于虚拟机的instancenodes,用于引用磁盘的storagevolumes,或者用于引用预构建操作系统镜像或模板的images。我希望我能在这里更加具体,但这将取决于你的云服务提供商。

动作可能出现在也可能不出现在 URL 中。一些云服务提供商将包括createlistmodifydelete等动作,后面跟着要管理的资源的 ID,如果需要的话。

然而,使用 HTTP 方法来确定动作正变得越来越普遍。以下方法通常由 REST API 使用:

GET

这用于仅显示但永远不会更改资源的调用。如果没有提供 ID,则通常会提供一个资源列表。如果使用了 ID,则将返回该特定资源的详细信息。

POST

这通常用于创建数据的调用,并且经常用于修改数据的调用。如果没有声明 ID,则通常将创建一个新的资源。如果提供了 ID,则将修改现有的资源。

PATCH

此方法最近被添加用于修改现有资源。如果云服务提供商使用此方法,那么他们不太可能允许使用POST来修改现有数据。相反,POST将仅用于应用新数据,而PATCH将用于更新现有数据。

DELETE

使用DELETE方法的调用通常包括资源类型和要删除的资源的 ID。此方法永远不会用于创建或修改数据;仅用于删除。

设置一个query()函数

现在我们知道了 API 将是什么样子,让我们创建一个函数来与之通信。我们将使用http.query()来与之通信,但我们还需要将一些其他项目包裹在里面。我们从一个函数声明开始:

def _query(
    resource=None,
    action=None,
    method='GET',
    location=None,
    data=None,
):

注意,我们已经将此函数设为私有。没有理由允许此函数直接从命令行调用,因此我们需要将其隐藏。我们允许任何参数保持未指定,因为我们不一定总是需要所有这些参数。

让我们继续设置我们的 _query() 函数,然后逐一检查其中的各个组件:

import json
import salt.utils.http
import salt.config as config

def _query(
        resource=None,
        action=None,
        params=None,
        method='GET',
        data=None
    ):
    '''
    Make a web call to the cloud provider
    '''
    user = config.get_cloud_config_value(
        'user', get_configured_provider(), __opts__,
    )

    password = config.get_cloud_config_value(
        'password', get_configured_provider(), __opts__,
    )

    api_key = config.get_cloud_config_value(
        'api_key', get_configured_provider(), __opts__,
    )

    location = config.get_cloud_config_value(
        'location', get_configured_provider(), __opts__, default=None
    )

    if location is None:
        location = 'eu-north'

    url = 'https://{0}.api.example.com/v1'.format(location)

    if resource:
        url += '/{0}'.format(resource)

    if action:
        url += '/{0}'.format(action)

    if not isinstance(params, dict):
        params = {}

    params['api_key'] = api_key

    if data is not None:
        data = json.dumps(data)

    result = salt.utils.http.query(
        url,
        method,
        params=params,
        data=data,
        decode=True,
        decode_type='json',
        hide_fields=['api_key'],
        opts=__opts__,
    )

    return result['dict']

我们首先收集我们云服务提供商所需的连接参数。salt.config 库包含一个名为 get_cloud_config_value() 的函数,该函数会在云配置中搜索请求的值。它可以搜索主云配置(通常位于 /etc/salt/cloud),以及任何提供者或配置文件配置。在这种情况下,所有配置都应位于提供者配置中,正如我们在文档字符串中所指定的。

一旦收集了 userpasswordapi_key,我们就将注意力转向 location。您可能记得,许多云提供商使用主机名来区分不同的数据中心。许多也设有默认数据中心。在我们的通用驱动程序中,我们将假设 eu-north 是默认的,并使用它创建一个 URL。我们的 URL 还包含了一个版本,正如我们之前提到的。

然后,我们查看将要使用的资源以及将要对其执行的操作。如果找到,这些操作将被附加到 URL 路径上。有了这些,我们就查看将要添加到 URL 中的任何参数。

params 变量指的是将被添加到 URL 中的 <name>=<value> 对。这些将以问号(?)开头,然后通过 ampersand(&)分隔,例如:

http://example.com/form.cgi?name1=value1&name2=value2&name3=value3

我们不会自己将这些内容附加到 URL 上,而是让 http.query() 函数来处理。如果指定了数据,它将正确地编码这些数据,并将其附加到 URL 的末尾。

如果使用,params 需要指定为一个字典。我们知道 api_key 将会是其中一个 params,因此我们在类型检查之后添加它。

最后,我们需要查看将要发送到云提供商的任何数据。许多提供商要求将 POST 数据作为 JSON 字符串发送,而不是作为 URL 编码的数据,因此如果提供了任何数据,我们将在发送之前将其转换为 JSON。

一切准备就绪后,我们使用 http.query()(作为 salt.utils.http.query())来实际发起调用。您可以看到 urlmethod(在函数声明中指定)、paramsdata。我们还设置了 decodeTruedecode_typejson,这样云提供商返回的数据将自动为我们转换为字典。

我们还传递了一个字段列表,以隐藏在 http.query() 函数内部可能发生的任何日志记录。这将确保我们的 api_key 等数据在生成任何日志时保持私密。而不是记录一个 URL,例如:

https://example.com/?api_key=0123456789abcdef

将会记录一个清理过的 URL:

https://example.com/?api_key=XXXXXXXXXX

最后,我们传递一个__opts__的副本,这样http.query()就能访问从masterminion配置文件中需要的任何变量。

http.query()函数将返回一个字典,其中包含一个名为dict的项,它包含从云提供商返回的数据,已转换为字典格式。这是我们将其传递回调用我们的_query()函数的任何函数的内容。

获取配置文件详情

一旦我们能够连接到云提供商,我们就需要能够收集可用于在该提供商上创建虚拟机的信息。这几乎总是包括虚拟机镜像和虚拟机大小的列表。如果一个云提供商有多个数据中心(大多数都有),那么你还需要一个函数来返回这些数据中心的列表。

这三个函数分别称为avail_images()avail_sizes()avail_locations()。它们分别通过salt-cloud命令使用--list-images--list-sizes--list-locations选项访问。

列出镜像

镜像指的是预构建的根虚拟机卷。对于 Windows 镜像,这将是指C:\磁盘卷。在其他操作系统上,这将是指/卷。非常常见的是,云提供商将提供多种不同的操作系统和每种操作系统的多个不同版本。

例如,云提供商可能为 Ubuntu 14.04、Ubuntu 14.10、Ubuntu 15.04 等提供单个镜像,或者它们可能提供每个镜像捆绑 WordPress、MediaWiki、MariaDB 或其他流行的软件包。

在我们的通用云提供商的情况下,可以通过请求images资源简单地返回一系列镜像。

def avail_images():
    '''
    Get list of available VM images
    '''
    return _query(resource='images')

在配置文件中,使用image参数指定镜像。

列出大小

大小是云提供商特有的一个概念,实际上并非每个云提供商都支持它们。根据提供商的不同,大小通常指的是处理器数量、处理器速度、RAM 大小、磁盘空间、磁盘类型(硬盘驱动器与 SSD)等的组合。

再次强调,我们的通用云提供商将在sizes资源下返回一系列大小。

def avail_sizes():
    '''
    Get list of available VM sizes
    '''
    return _query(resource='sizes')

在配置文件中,使用size参数指定大小。

列出位置

根据云提供商的不同,位置可能指一个具体的数据中心,世界上某个地区的区域,甚至是一个包含多个数据中心的区域内的特定数据中心。

正如我们之前所说的,位置通常会被添加到与 API 通信所使用的 URL 之前。在我们的通用云提供商的情况下,位置是通过regions资源进行查询的。

def avail_locations():
    '''
    Get list of available locations
    '''
    return _query(resource='locations')

在配置文件中,使用location参数指定位置。

列出节点

下一步是显示该云提供商账户中当前存在的节点。有三个salt-cloud参数可以显示节点数据:-Q--query-F--full-query-S--select-query。每个选项都会查询每个配置的云提供商,并一次性返回所有信息。

查询标准节点数据

对于每个节点,应该始终提供六条信息。当使用salt-cloud-Q参数时,这些数据会被显示:

  • id:此虚拟机由云提供商使用的 ID。

  • image:创建此虚拟机使用的镜像。如果此数据不可用,应设置为None

  • size:创建此虚拟机使用的尺寸。如果此数据不可用,应设置为None

  • state:此虚拟机的当前运行状态。这通常是RUNNINGSTOPPEDPENDING(虚拟机仍在启动中)或TERMINATED(虚拟机已被销毁,但尚未清理)。如果此数据不可用,应设置为 None。

  • private_ips:在云提供商的内部网络上使用的任何私有 IP 地址。这些应作为列表返回。如果此数据不可用,列表应为空。

  • public_ips:此虚拟机可用的任何公网 IP 地址。应包括任何 IPv6 地址。这些 IP 应作为列表返回。如果此数据不可用,列表应为空。

用户应该能够访问所有这些变量,即使它们为空或设置为 None。这也是-Q参数应该返回的唯一数据。为了返回这些数据,我们使用一个名为list_nodes()的函数:

def list_nodes():
    '''
    List of nodes, with standard query data
    '''
    ret = {}
    nodes = _query(resource='instances')
    for node in nodes:
        ret[node] = {
            'id': nodes[node]['id'],
            'image': nodes[node].get('image', None),
            'size': nodes[node].get('size', None),
            'state': nodes[node].get('state', None),
            'private_ips': nodes[node].get('private_ips', []),
            'public_ips': nodes[node].get('public_ips', []),
        }
    return ret

查询完整节点数据

虚拟机通常包含比-Q返回的信息多得多的信息。如果你想查看云提供商愿意并且能够显示给你的所有信息,请使用-F标志。这对应于一个名为list_nodes_full()的函数:

def list_nodes_full():
    '''
    List of nodes, with full node data
    '''
    return _query(resource='instances')

有时,你可能只对一组非常具体的数据感兴趣。例如,你可能只想显示虚拟机的 ID、公网 IP 和状态。-S选项允许你执行一个查询,只返回完整查询中可用的字段的选择。这个选择本身是在主云配置文件中定义的列表(通常为/etc/salt/cloud):

query.selection:
  - id
  - public_ips
  - state

查询本身是由一个名为list_nodes_select()的函数执行的。一些提供商可能需要做一些特殊操作来分离这些数据,但大多数情况下,你可以直接使用salt.utils.cloud库中提供的list_nodes_select()函数:

import salt.utils.cloud

def list_nodes_select():
    '''
    Return a list of the VMs that are on the provider, with select fields
    '''
    return salt.utils.cloud.list_nodes_select(
        list_nodes_full('function'), __opts__['query.selection'],
    )

创建虚拟机

任何云模块最复杂的部分传统上一直是create()函数。这是因为这个函数不仅仅是启动一个虚拟机。它的任务通常可以分解为以下组件:

  • 请求云提供商创建虚拟机

  • 等待虚拟机可用

  • 登录到该虚拟机并安装 Salt

  • 接受该虚拟机的 Minion 密钥在 Master 上

一些更复杂的云服务提供商可能包括额外的步骤,例如根据配置文件请求不同类型的虚拟机,或将卷附加到虚拟机上。此外,create()函数应该在 Salt 的事件总线上触发事件,让主服务器知道创建过程的进度。

在我们进入create()函数之前,我们应该准备另一个名为request_instance()的函数。这个函数将为我们做两件事:

  • 它可以直接从create()函数中调用,这将简化create()函数

  • 它可以在create()函数外部调用,当需要非 Salt 虚拟机时

这个函数不需要做太多。正如其名称所暗示的,它只需要请求云服务提供商创建一个虚拟机。但需要收集一些信息来构建 HTTP 请求:

def request_instance(vm_):
    '''
    Request that a VM be created
    '''
    request_kwargs = {
        'name': vm_['name'],
        'image': vm_['image'],
        'size': vm_['size'],
        'location': vm_['location']
    }

    salt.utils.cloud.fire_event(
        'event',
        'requesting instance',
        'salt/cloud/{0}/requesting'.format(vm_['name']),
        {'kwargs': request_kwargs},
        transport=__opts__['transport']
    )

    return _query(
        resource='instances',
        method='POST',
        data=request_kwargs,
    )

你可能已经注意到了在这个函数中调用salt.utils.cloud.fire_event()。每次你在create()函数(或由create()调用的函数)中做重大操作时,都应该触发一个事件,提供一些关于你即将做什么的信息。这些事件将被事件反应器捕获,允许主服务器跟踪进度,并在配置为这样做的情况下,在正确的时间执行额外任务。

我们还将创建一个名为query_instance()的函数。这个函数将监视新请求的虚拟机,等待 IP 地址变得可用。这个 IP 地址将用于登录虚拟机并配置它。

def query_instance(vm_):
    '''
    Query a VM upon creation
    '''
    salt.utils.cloud.fire_event(
        'event',
        'querying instance',
        'salt/cloud/{0}/querying'.format(vm_['name']),
        transport=__opts__['transport']
    )

    def _query_ip_address():
        nodes = list_nodes_full()
        data = nodes.get(vm_['name'], None)
        if not data:
            return False

        if 'public_ips' in data:
            return data['public_ips']
        return None

    data = salt.utils.cloud.wait_for_ip(
        _query_ip_address,
        timeout=config.get_cloud_config_value(
            'wait_for_ip_timeout', vm_, __opts__, default=10 * 60),
        interval=config.get_cloud_config_value(
            'wait_for_ip_interval', vm_, __opts__, default=10),
        interval_multiplier=config.get_cloud_config_value(
            'wait_for_ip_interval_multiplier', vm_, __opts__, default=1),
    )

    return data

这个函数使用了 Salt 附带的一个名为salt.utils.cloud.wait_for_ip()的函数。该函数接受一个回调,我们将其定义为嵌套函数,称为_query_ip_address()。这个嵌套函数会检查 IP 地址是否存在。如果存在,则salt.utils.cloud.wait_for_ip()将停止等待并继续执行。如果尚未存在,它将继续等待。

我们还传递了三个其他参数。timeout定义了等待 IP 地址出现的时间长度(在我们的案例中是十分钟);interval告诉 Salt Cloud 在查询之间等待多长时间(我们的默认值是十秒)。

你可能会想使用更短的间隔,但许多云服务提供商如果账户似乎在滥用其权限,会限制请求。在此方面,interval_multiplier会在每次请求后增加interval。例如,如果interval设置为 1 且interval_multiplier设置为 2,那么请求将间隔 1 秒,然后是 2 秒、4 秒、8 秒、16 秒、32 秒,以此类推。

在这两个函数就位后,我们最终可以设置我们的create()函数。它需要一个参数,即一个包含配置文件、提供者和主要云配置数据的字典:

def create(vm_):
    '''
    Create a single VM
    '''
    salt.utils.cloud.fire_event(
        'event',
        'starting create',
        'salt/cloud/{0}/creating'.format(vm_['name']),
        {
            'name': vm_['name'],
            'profile': vm_['profile'],
            'provider': vm_['driver'],
        },
        transport=__opts__['transport']
    )

    create_data = request_instance(vm_)
    query_data = query_instance(vm_)

    vm_['key_filename'] = config.get_cloud_config_value(
        'private_key', vm_, __opts__, search_global=False, default=None
    )
    vm_['ssh_host'] = query_data['public_ips'][0]

    salt.utils.cloud.bootstrap(vm_, __opts__)

    salt.utils.cloud.fire_event(
        'event',
        'created instance',
        'salt/cloud/{0}/created'.format(vm_['name']),
        {
            'name': vm_['name'],
            'profile': vm_['profile'],
            'provider': vm_['driver'],
        },
        transport=__opts__['transport']
    )

    return query_data

我们的功能开始于触发一个事件,声明创建过程正在开始。然后我们允许request_instance()query_instance()执行它们的工作,从配置数据中提取 SSH 密钥文件名,然后从虚拟机数据中抓取用于从虚拟机登录到盒子的 IP 地址。

下一步涉及等待虚拟机变得可用,然后登录并配置它。但由于这个过程在所有云服务提供商之间都是相同的,所以它已经被整合到salt.utils.cloud中的另一个辅助函数bootstrap()中。bootstrap()函数甚至会为我们触发额外的事件,让事件反应器了解其自身状态。

最后,我们触发一个最后的事件,声明虚拟机的信息,并将虚拟机的数据返回给用户。

小贴士

你可能已经注意到,我们触发的事件都包含一个以salt/cloud/开头的标签,然后是虚拟机的名称,然后是我们当前执行步骤的简称。如果你在与更复杂的云服务提供商一起工作,并希望触发针对它们的特定事件,请保持标签看起来相同,尽可能简单。这将帮助你的用户跟踪所有你的云标签。

销毁虚拟机

能够销毁虚拟机与能够创建虚拟机一样重要,但过程幸运地要简单得多。请注意,在销毁时也应该触发事件:一次在发生之前,一次在发生之后:

def destroy(name):
    '''
    Destroy a machine by name
    '''
    salt.utils.cloud.fire_event(
        'event',
        'destroying instance',
        'salt/cloud/{0}/destroying'.format(name),
        {'name': name},
        transport=__opts__['transport']
    )

    nodes = list_nodes_full()
    ret = _query(
        resource='instances/{0}'.format(nodes[name]['id']),
        location=node['location'],
        method='DELETE'
    )

    salt.utils.cloud.fire_event(
        'event',
        'destroyed instance',
        'salt/cloud/{0}/destroyed'.format(name),
        {'name': name},
        transport=__opts__['transport']
    )

    if __opts__.get('update_cachedir', False) is True:
        salt.utils.cloud.delete_minion_cachedir(
            name, __active_provider_name__.split(':')[0], __opts__
        )

    return ret

在这个函数中,我们做了另一件重要的事情。Salt Cloud 有维护虚拟机信息缓存的能力。我们之前没有看到这一点,因为bootstrap()函数在创建虚拟机时处理填充缓存。然而,由于没有销毁机器的通用方法,我们需要手动处理这一点。

使用动作和函数

到目前为止,我们编写的所有函数都是通过特殊的命令行参数(如--query--provision)直接调用的。然而,云服务提供商可能能够执行的操作并不一定像我们之前看到的那么标准化。

例如,大多数云服务提供商都有startstoprestart的 API 方法。但有些提供商并不支持所有这些;startstop可能可用,但restart不可用。或者startrestart可用,但stop不可用。其他操作,如列出 SSH 密钥,可能在一个云服务提供商上可用,但在另一个提供商上不可用。

当涉及到对云服务提供商的操作时,主要有两种类型的操作可以进行。针对虚拟机(VM)的特定操作(如stopstartrestart等)在 Salt Cloud 中被称为动作。与云服务提供商的组件交互,但不特定于虚拟机的操作(如列出 SSH 密钥、修改用户等),在 Salt Cloud 中被称为函数

使用动作

使用--action参数通过salt-cloud命令调用操作。因为它们作用于特定的虚拟机,所以传递给它们的第一个参数是一个名称。如果从命令行传递其他参数,它们将出现在名为kwargs的字典中。还有一个额外的参数,称为call,它告诉函数是否使用--action--function调用。你可以使用这个来通知用户他们是否错误地调用了操作或函数:

def rename(name, kwargs, call=None):
    '''
    Properly rename a node. Pass in the new name as "newname".
    '''
    if call != 'action':
        raise SaltCloudSystemExit(
            'The rename action must be called with -a or --action.'
        )

    salt.utils.cloud.rename_key(
        __opts__['pki_dir'], name, kwargs['newname']
    )

    nodes = list_nodes_full()
    return _query(
        resource='instances/{0}'.format(nodes[name]['id']),
        action='rename',
        method='POST',
        data={'name': kwargs['newname']}
    )

即使你并不打算向用户发出警告,你也必须接受call参数;无论是否传递给它,它都会被传递,如果没有提供,将会引发错误。

再次提醒,我又给你带来了一个惊喜。由于这个操作将重命名虚拟机,我们需要通知 Salt。如果我们不这样做,那么 Master 将无法联系 Minion。通常,有一个辅助函数(salt.utils.cloud.rename_key())会为我们完成这项工作。

使用函数

因为函数不作用于特定的虚拟机,所以它们不需要名称参数。然而,它们确实需要kwargscall参数,即使你并不打算使用它们。

def show_image(kwargs, call=None):
    '''
    Show the details for a VM image
    '''
    if call != 'function':
        raise SaltCloudSystemExit(
            'The show_image function must be called with -f or --function.'
        )

    return _query(resource='images/{0}'.format(kwargs['image']))

如果你将call参数添加到模块中的各个函数中,你将能够直接使用--action--function参数调用它们。这对于像list_nodes()这样的函数非常有用,当你只想一次查看一个云提供商的虚拟机,而不是一次性查看所有虚拟机时。

唯一不能这样调用的公共函数是create()函数。可以使用--action参数调用destroy(),而我们迄今为止添加的几乎所有其他内容都可以使用--function参数调用。我们将继续添加这些功能到我们的最终云模块中。

最终的云模块

当我们完成时,最终的云模块将看起来像这样:

'''
Generic Salt Cloud module

This module is not designed for any specific cloud provider, but is generic
enough that only minimal changes may be required for some providers.

This file should be saved as salt/cloud/clouds/generic.py

Set up the cloud configuration at ``/etc/salt/cloud.providers`` or
``/etc/salt/cloud.providers.d/generic.conf``:

.. code-block:: yaml

    my-cloud-config:
      driver: generic
      # The login user
      user: larry
      # The user's password
      password: 123pass
'''
import json
import salt.utils.http
import salt.utils.cloud
import salt.config as config
from salt.exceptions import SaltCloudSystemExit

__virtualname__ = 'generic'

def __virtual__():
    '''
    Check for cloud configs
    '''
    if get_configured_provider() is False:
        return False

    return __virtualname__

def get_configured_provider():
    '''
    Make sure configuration is correct
    '''
    return config.is_provider_configured(
        __opts__,
        __active_provider_name__ or __virtualname__,
        ('user', 'password')
    )

def request_instance(vm_):
    '''
    Request that a VM be created
    '''
    request_kwargs = {
        'name': vm_['name'],
        'image': vm_['image'],
        'size': vm_['size'],
        'location': vm_['location']
    }

    salt.utils.cloud.fire_event(
        'event',
        'requesting instance',
        'salt/cloud/{0}/requesting'.format(vm_['name']),
        {'kwargs': request_kwargs},
        transport=__opts__['transport']
    )

    return _query(
        resource='instances',
        method='POST',
        data=request_kwargs,
    )

def query_instance(vm_):
    '''
    Query a VM upon creation
    '''
    salt.utils.cloud.fire_event(
        'event',
        'querying instance',
        'salt/cloud/{0}/querying'.format(vm_['name']),
        transport=__opts__['transport']
    )

    def _query_ip_address():
        nodes = list_nodes_full()
        data = nodes.get(vm_['name'], None)
        if not data:
            log.error('There was an empty response from the cloud provider')
            return False

        log.debug('Returned query data: {0}'.format(data))

        if 'public_ips' in data:
            return data['public_ips']
        return None

    data = salt.utils.cloud.wait_for_ip(
        _query_ip_address,
        timeout=config.get_cloud_config_value(
            'wait_for_ip_timeout', vm_, __opts__, default=10 * 60),
        interval=config.get_cloud_config_value(
            'wait_for_ip_interval', vm_, __opts__, default=10),
        interval_multiplier=config.get_cloud_config_value(
            'wait_for_ip_interval_multiplier', vm_, __opts__, default=1),
    )

    return data

def create(vm_):
    '''
    Create a single VM
    '''
    salt.utils.cloud.fire_event(
        'event',
        'starting create',
        'salt/cloud/{0}/creating'.format(vm_['name']),
        {
            'name': vm_['name'],
            'profile': vm_['profile'],
            'provider': vm_['driver'],
        },
        transport=__opts__['transport']
    )

    create_data = request_instance(vm_)
    query_data = query_instance(vm_)

    vm_['key_filename'] = config.get_cloud_config_value(
        'private_key', vm_, __opts__, search_global=False, default=None
    )
    vm_['ssh_host'] = query_data['public_ips'][0]

    salt.utils.cloud.bootstrap(vm_, __opts__)

    salt.utils.cloud.fire_event(
        'event',
        'created instance',
        'salt/cloud/{0}/created'.format(vm_['name']),
        {
            'name': vm_['name'],
            'profile': vm_['profile'],
            'provider': vm_['driver'],
        },
        transport=__opts__['transport']
    )

    return query_data

def destroy(name, call=None):
    '''
    Destroy a machine by name
    '''
    salt.utils.cloud.fire_event(
        'event',
        'destroying instance',
        'salt/cloud/{0}/destroying'.format(name),
        {'name': name},
        transport=__opts__['transport']
    )

    nodes = list_nodes_full()
    ret = _query(
        resource='instances/{0}'.format(nodes[name]['id']),
        location=node['location'],
        method='DELETE'
    )

    salt.utils.cloud.fire_event(
        'event',
        'destroyed instance',
        'salt/cloud/{0}/destroyed'.format(name),
        {'name': name},
        transport=__opts__['transport']
    )

    if __opts__.get('update_cachedir', False) is True:
        salt.utils.cloud.delete_minion_cachedir(
            name, __active_provider_name__.split(':')[0], __opts__
        )

    return ret

def rename(name, kwargs, call=None):
    '''
    Properly rename a node. Pass in the new name as "newname".
    '''
    if call != 'action':
        raise SaltCloudSystemExit(
            'The rename action must be called with -a or --action.'
        )

    salt.utils.cloud.rename_key(
        __opts__['pki_dir'], name, kwargs['newname']
    )

    nodes = list_nodes_full()
    return _query(
        resource='instances/{0}'.format(nodes[name]['id']),
        action='rename',
        method='POST',
        data={'name': kwargs['newname']}
    )

def show_image(kwargs, call=None):
    '''
    Show the details for a VM image
    '''
    if call != 'function':
        raise SaltCloudSystemExit(
            'The show_image function must be called with -f or --function.'
        )

    return _query(resource='images/{0}'.format(kwargs['image']))

def list_nodes(call=None):
    '''
    List of nodes, with standard query data
    '''
    ret = {}
    nodes = _query(resource='instances')
    for node in nodes:
        ret[node] = {
            'id': nodes[node]['id'],
            'image': nodes[node].get('image', None),
            'size': nodes[node].get('size', None),
            'state': nodes[node].get('state', None),
            'private_ips': nodes[node].get('private_ips', []),
            'public_ips': nodes[node].get('public_ips', []),
        }
    return ret

def list_nodes_full(call=None):
    '''
    List of nodes, with full node data
    '''
    return _query(resource='instances')

def list_nodes_select(call=None):
    '''
    Return a list of the VMs that are on the provider, with select fields
    '''
    return salt.utils.cloud.list_nodes_select(
        list_nodes_full('function'), __opts__['query.selection'], call,
    )

def avail_images(call=None):
    '''
    Get list of available VM images
    '''
    return _query(resource='images')

def avail_sizes(call=None):
    '''
    Get list of available VM sizes
    '''
    return _query(resource='sizes')

def avail_locations(call=None):
    '''
    Get list of available locations
    '''
    return _query(resource='locations')

def _query(
        resource=None,
        action=None,
        params=None,
        method='GET',
        location=None,
        data=None
    ):
    '''
    Make a web call to the cloud provider
    '''
    user = config.get_cloud_config_value(
        'user', get_configured_provider(), __opts__, search_global=False
    )
    password = config.get_cloud_config_value(
        'password', get_configured_provider(), __opts__,
    )
    api_key = config.get_cloud_config_value(
        'api_key', get_configured_provider(), __opts__,
    )
    location = config.get_cloud_config_value(
        'location', get_configured_provider(), __opts__, default=None
    )

    if location is None:
        location = 'eu-north'

    url = 'https://{0}.api.example.com/v1'.format(location)

    if resource:
        url += '/{0}'.format(resource)

    if action:
        url += '/{0}'.format(action)

    if not isinstance(params, dict):
        params = {}

    params['api_key'] = api_key

    if data is not None:
        data = json.dumps(data)

    result = salt.utils.http.query(
        url,
        method,
        params=params,
        data=data,
        decode=True,
        decode_type='json',
        hide_fields=['api_key'],
        opts=__opts__,
    )

    return result['dict']

云模块故障排除

云模块可能看起来令人畏惧,因为要制作一个连贯的代码块需要许多组件。但是,如果你以小块的方式处理模块,它将更容易处理。

首先编写avail_sizes()avail_images()

每次我编写一个新的云模块时,我首先会尝试让一些示例代码运行起来,以执行一个小查询。因为图像和大小对于创建虚拟机至关重要,而且这些调用通常非常简单,所以它们通常是实现起来最简单的。

一旦这些函数中的一个开始工作,将其拆分为一个_query()函数(如果你不是从那里开始的)和一个调用它的函数。然后编写另一个调用它的函数。你可能需要为前几个函数调整_query(),但之后它将稳定下来,几乎不需要任何更改。

使用快捷方式

我无法告诉你我花了多少小时等待虚拟机启动,只是为了测试一小段代码。如果你将create()函数分解成许多更小的函数,那么你可以根据需要临时硬编码虚拟机数据,并跳过那些会浪费太多时间的操作。只是确保在完成时移除这些捷径!

摘要

Salt Cloud 旨在处理计算资源,尽管可以根据需要添加额外的云功能。可以使用 Libcloud、SDK 或直接 REST API 编写云模块;每种方法都有其优缺点。现代 REST API 通常非常相似且易于使用。一个连贯的云模块需要几个功能,但大多数都不复杂。操作是在单个虚拟机上执行的,而功能是在云提供商本身上执行的。

现在我们已经了解了云模块,是时候开始监控我们的资源了。接下来是:信标。

第十章:监控信标

信标是 Salt 中的一种新型模块,旨在监视 Minion 上的资源,并在这些资源与您期望它们看起来不一致时向主节点报告。在本章中,我们将讨论:

  • 使用 Salt 监控外部系统

  • 信标故障排除

监视数据

监控服务有两种基本类型:那些记录数据的,以及基于那些数据触发警报的。表面上,信标可能看起来像是第二种类型。它们以常规间隔运行(默认情况下每秒运行一次)并且当它们发现重要的数据时,会将这些数据发送到主节点。

然而,由于信标可以访问它们运行的 Minion 上的执行模块,它们可以与 Minion 上任何执行模块可以交互的程序进行交互。

关注事物

让我们继续构建一个监控nspawn容器的信标。它不需要非常复杂;实际上,信标应该尽可能简单,因为它们预计会频繁运行。我们的信标只需要关注应该运行和应该不存在的容器。

注意

容器在现代数据中心中变得非常流行,这很大程度上归功于 Docker 和 LXC。systemd 有自己的容器系统,称为nspawn,它本身就是一个非常强大的系统。现在许多 Linux 发行版都预装了 systemd,这意味着您可能已经安装了nspawn。您可以在 Lennart Pottering 的博客上找到关于nspawn的更完整讨论:

0pointer.net/blog/systemd-for-administrators-part-xxi.html

首先,我们需要设置我们的__virtual__()函数。由于nspawnsystemd的一部分,并不是每个 Minion 都安装了systemd,因此我们需要对其进行检查。然而,由于我们将使用随 Salt 一起提供的nspawn执行模块,并且它已经包含了一个__virtual__()函数,我们真正需要做的只是确保它存在:

'''
Send events covering nspawn containers

This beacon accepts a list of containers and whether they should be
running or absent:

beacons:
  nspawn:
    vsftpd: absent
    httpd: running

This file should be saved as salt/beacons/nspawn.py
'''
__virtualname__ = 'nspawn'

def __virtual__():
    '''
    Ensure that systemd-nspawn is available
    '''
    if 'nspawn.list_running' in __salt__:
        return __virtualname__
    return False

有针对性地检查nspawn.list_running是有意义的,因为这是我们在这里唯一会使用的函数。

验证配置

信标不知道要监视哪些数据时不会运行。您可能在前面的文档字符串中看到了配置示例。validate()函数检查传递给此信标的配置,以确保其格式正确。

如果我们要对此进行极简处理,那么我们只需检查确保已经传递了正确的数据类型。在我们的例子中,我们期望的是一个字典,所以我们只需检查这一点即可:

def validate(config):
    '''
    Validate the beacon configuration
    '''
    if not isinstance(config, dict):
        return False
    return True

但我们将添加一点更多,以确保至少容器列表被设置为所需的值之一:runningabsent

def validate(config):
    '''
    Validate the beacon configuration
    '''
    if not isinstance(config, dict):
        return False
    for key in config:
        if config[key] not in ('running', 'absent'):
            return False
    return True

如果你不需要这个函数,可以跳过它;如果没有它,Salt 会跳过它。然而,保留它是一个好主意,以帮助防止不良配置导致信标崩溃并带有堆栈跟踪。

beacon() 函数

与其他一些类型的模块一样,信标有一个必需的函数,因为 Salt 在尝试使用模块时会查找它。不出所料,这个函数叫做 beacon()。它传递与 validate() 函数相同的 config 数据。

我们信标的唯一任务是使用 machinectl 报告当前在 Minion 上运行的容器。它的输出看起来像以下这样:

# machinectl list
MACHINE       CLASS     SERVICE 
vsftpd         container systemd-nspawn

1 machines listed.

我们可以手动调用它并解析输出,但正如我之前说的,Salt 已经附带了一个 nspawn 执行模块,它有一个 list_running() 函数,可以为我们做所有这些事情。

我们接下来需要做的就是获取报告为正在运行的节点列表,然后将其与 config 字典中的节点列表进行匹配:

def beacon(config):
    '''
    Scan for nspawn containers and fire events
    '''
    nodes = __salt__['nspawn.list_running']()
    ret = []
    for name in config:
        if config[name] == 'running':
            if name not in nodes:
                ret.append({name: 'Absent'})
        elif config[name] == 'absent':
            if name in nodes:
                ret.append({name: 'Running'})
        else:
            if name not in nodes:
                ret.append({name: False})

    return ret

我们不是逐个检查正在运行的节点列表,而是遍历已配置的节点列表。如果一个本应不存在的节点出现在运行列表中,我们就将其标记为正在运行。如果一个节点应该运行但未出现,我们就将其标记为不存在。

最后的 else 语句会通知我们如果列表中出现了一些未被标记为正在运行或不存在的东西。由于我们已经在 validate() 函数中进行了这个检查,所以这不应该需要。但保留这种检查并不是一个坏主意,以防你的 validate() 函数错过了什么。如果你开始看到这个模块的事件,节点被设置为 False,那么你就知道你需要回去检查 validate() 函数。

如果你一直在跟进并已经开始了这个模块的测试,你可能注意到了一些,嗯,令人讨厌的事情。默认情况下,信标每秒执行一次。你可以根据每个模块来更改这个间隔:

beacons:
  nspawn:
    vsftpd: present
    httpd: absent
    interval: 30

使用这种配置,nspawn 信标将每五秒执行一次,而不是每秒执行一次。这将减少噪音,但也意味着你的信标不一定能像你希望的那样频繁地监视。

让我们添加一些代码,这将允许信标以你想要的频率运行,但以不那么规律的频率发送更新。假设你的信标已经与监控服务(通过事件反应器)绑定,并且你想要实时的监控,但不需要每五分钟被告知一次,“哦,顺便说一下,容器仍然处于关闭状态”:

import time
def beacon(config):
    '''
    Scan for nspawn containers and fire events
    '''
    interval = __salt__'config.get'
    now = int(time.time())

    nodes = __salt__['nspawn.list_running']()
    ret = []
    for name in config:
        lasttime = __grains__.get('nspawn_last_notify', {}).get(name, 0)
        if config[name] == 'running':
            if name not in nodes:
                if now - lasttime >= interval:
                    ret.append({name: 'Absent'})
                    __salt__'grains.setval'
        elif config[name] == 'absent':
            if name in nodes:
                if now - lasttime >= interval:
                    ret.append({name: 'Running'})
                    __salt__'grains.setval'
        else:
            if name not in nodes:
                if now - lasttime >= interval:
                    ret.append({name: False})
                        __salt__'grains.setval'

    return ret

首先,我们设置了一个名为 nspawn_alert_interval 的警报间隔,并将其默认设置为 360 秒(或者说,每五分钟一次)。因为我们使用了 config.get 来查找它,所以我们可以在这 masterminion 配置文件中,或者在 Minion 的一个 grain 或 pillar 中进行配置。

然后,我们使用 Python 自带的time.time()函数记录当前时间。这个函数报告自纪元以来的秒数,这对于我们的目的来说非常完美,因为我们的警报间隔也是以秒为单位的。

当我们遍历配置的节点列表时,我们检查最后一次发送通知的时间。这存储在一个名为nspawn_last_notify的 grain 中。这不是用户会更新的 grain;这是信标会跟踪的。

事实上,你会在if语句的每个分支中看到这种情况发生。每当信标检测到应该发送警报时,它首先检查在指定的时间间隔内是否已经发送了警报。如果没有,那么它设置一个要返回的事件。

监视信标

信标使用 Salt 的事件总线向 Master 发送通知。您可以使用state运行器中的event函数来监视事件总线上的信标。这个特定信标模块的返回值将如下所示:

salt/beacon/alton/nspawn/	{
    "_stamp": "2016-01-17T17:48:48.986662",
    "data": {
        "vsftpd": "Present",
        "id": "alton"
    },
    "tag": "salt/beacon/alton/nspawn/"
}

注意标签,它包含salt/beacon/,然后是触发信标的 Minion(alton)的 ID,然后是信标本身的名称(nspawn)。

最后一个信标模块

一切结束后,我们的最终信标模块将如下所示:

'''
Send events covering nspawn containers

This beacon accepts a list of containers and whether they should be
running or absent:

    .. code-block:: yaml

        beacons:
          nspawn:
            vsftpd: running
            httpd: absent

This file should be saved as salt/beacons/nspawn.py
'''
import time

__virtualname__ = 'nspawn'

def __virtual__():
    '''
    Ensure that systemd-nspawn is available
    '''
    if 'nspawn.list_running' in __salt__:
        return __virtualname__
    return False

def validate(config):
    '''
    Validate the beacon configuration
    '''
    if not isinstance(config, dict):
        return False
    for key in config:
        if config[key] not in ('running', 'absent'):
            return False
    return True

def beacon(config):
    '''
    Scan for nspawn containers and fire events
    '''
    interval = __salt__'config.get'
    now = int(time.time())

    nodes = __salt__['nspawn.list_running']()
    ret = []
    for name in config:
        lasttime = __grains__.get('nspawn_last_notify', {}).get(name, 0)
        if config[name] == 'running':
            if name not in nodes:
                if now - lasttime >= interval:
                    ret.append({name: 'Absent'})
                    __salt__'grains.setval'
        elif config[name] == 'absent':
            if name in nodes:
                if now - lasttime >= interval:
                    ret.append({name: 'Running'})
                    __salt__'grains.setval'
        else:
            if name not in nodes:
                if now - lasttime >= interval:
                    ret.append({name: False})
                    __salt__'grains.setval'

    return ret

信标故障排除

信标是一种需要运行中的 Master 和运行中的 Minion 的模块。在前景运行salt-master服务不会给你太多洞察力,因为代码将在 Minion 上运行,但在前景运行salt-minion服务将非常有帮助:

# salt-minion -l debug

预留一个只配置了信标而没有其他配置的 Minion。默认情况下,这些信标每秒运行一次,这确实会生成非常嘈杂的日志:

[INFO    ] Executing command 'machinectl --no-legend --no-pager list' in directory '/root'
[DEBUG   ] stdout: vsftpd container systemd-nspawn
[INFO    ] Executing command 'machinectl --no-legend --no-pager list' in directory '/root'
[DEBUG   ] stdout: vsftpd container systemd-nspawn
[INFO    ] Executing command 'machinectl --no-legend --no-pager list' in directory '/root'
[DEBUG   ] stdout: vsftpd container systemd-nspawn

想象一下同时运行多个信标,每个信标都记录它当前正在做什么的数据。这会很快变得无聊。

你还希望在 Master 上保持一个事件监听器打开:

# salt-r
un state.event pretty=True

salt/beacon/alton/nspawn/	{
    "_stamp": "2016-01-17T17:48:48.986662",
    "data": {
        "ftp-container": "Present",
        "id": "alton"
    },
    "tag": "salt/beacon/alton/nspawn/"
}

幸运的是,信标不是那种你需要等待的东西;只需让机器表现出你想要的行为,然后启动salt-minion进程。只需确保测试你期望找到的任何行为的变化,无论它是否预期返回一个事件。

摘要

信标赋予 Minion 根据监控条件触发事件的能力。一个validate()函数有助于确保配置正确,但不是必需的。一个beacon()函数是必需的,因为它是执行实际监控的函数。尽可能使用执行模块来执行繁重的工作。信标可以在非常短的时间间隔内运行,但通过让它们在 grains 中存储数据,您可以设置更长的时间间隔的通知。

现在我们已经将书中所有的 Minion 端模块处理完毕,让我们回到 Master 端模块,完成剩余的工作。接下来:扩展 Master。

第十一章。扩展 Master

尽管我们迄今为止编写的一些模块可以在 Master 上使用,但焦点仍然完全集中在基于 Minion 的操作管理上。即使是只在 Master 上运行的 runners,最初也是为了在 Minion 之间脚本化任务而设计的。

有两种模块完全是为 Master 端工作而设计的:外部认证模块和轮模块。在本章中,我们将介绍:

  • 向 Master 添加外部认证

  • 外部认证模块故障排除

  • 使用轮模块管理 Master 配置

  • 轮模块故障排除

使用外部认证

在其默认设置中,用户通常只使用一个用户与 Salt 通信:通常是rootsalt。任何能够以该用户身份登录的用户都将能够发出 Salt 命令。对于较小的设置,这可能没问题,但它根本无法扩展。较大的组织希望每个用户都能使用自己的登录来管理 Salt,并且能够根据用户设置访问控制。还有其他程序,包括 Salt API,需要使用外部认证模块。

外部认证(或autheauth)模块允许单个用户对 Salt 的各个组件拥有自己的权限。最简单的大概是pam模块,部分原因是因为其他现有的访问控制机制可以在 PAM 内部进行配置。不幸的是,PAM 很少在 Linux 之外使用,因此在其他平台上需要其他模块。

认证凭证

表面上看,认证模块不需要做太多。它只需要接受一个用户名和一个密码,并检查适当的服务以确保其有效性。如果是,则返回True。否则,它将返回False

让我们继续为一家虚构的、接受用户名和密码的 Web 服务设置一个认证模块,如果它们正确,则返回状态200OK),如果不正确,则返回403FORBIDDEN)。与其他一些模块类型一样,认证模块中有一个必需的函数。这个函数叫做auth()。让我们一次性查看整个认证模块:

'''
Provide authentication using an authentication web service. This service
must be configured with an API ID and API key in the master configuration.

webauth:
  apiid: 0123456789
  apikey: abcdef0123456789abcdef0123456789

This file should be saved as salt/auth/webauth.py
'''
import json
import base64
import urllib
import salt.utils.http

def auth(username, password):
    '''
    Authenticate using an external web authentication service
    '''
    apiid = __opts__.get('webauth', {}).get('apiid', None)
    apikey = __opts__.get('webauth', {}).get('apikey', None)
    url = 'https://api.example.com/v1/checkauth'

    username = urllib.quote(username)
    password = urllib.quote(password)
    data = {
        'type': 'basic',
        'value': base64.b64encode('{0}:{1}'.format(username, password))
    }

    result = salt.utils.http.query(
        path,
        method='POST',
        username=apiid,
        password=apikey,
        data=json.dumps(data),
        status=True,
        opts=__opts__,
    )
    if result.get('status', 403) == 200:
        return True

    return False

我们的功能声明有两个必需的参数:用户名和密码。这些信息将被发送到认证服务以检查其有效性。我们的服务不仅仅接受任意的凭证;它要求首先设置一个账户,并拥有自己的认证,该认证存储用户名和密码。因此,我们的首要任务是抓取该服务的凭证(apiidapikey)从主配置中。然后添加用于认证检查的 URL:

    apiid = __opts__.get('webauth', {}).get('apiid', None)
    apikey = __opts__.get('webauth', {}).get('apikey', None)
    url = 'https://api.example.com/v1/checkauth'

我们希望能够在用户名或密码中接受特殊字符,但由于它们在网络上无法正确翻译,我们使用 Python 的urllib库为它们添加引号。然后我们以外部 Web 服务期望的方式格式化凭证:

    username = urllib.quote(username)
    password = urllib.quote(password)
    data = {
        'type': 'basic',
        'value': base64.b64encode('{0}:{1}'.format(username, password))
    }

现在我们已经设置了所有要传递给 Web 服务的数据,我们使用 http.query() 函数进行调用。apiidapikey 用作服务本身的用户名和密码,用户的用户名和密码也作为 JSON 字符串设置。我们还确保告诉 http.query() 返回状态码,因为这是我们唯一关心的结果部分:

    result = salt.utils.http.query(
        path,
        method='POST',
        username=apiid,
        password=apikey,
        data=json.dumps(data),
        status=True,
        opts=__opts__,
    )

一旦我们有了认证代码,我们会检查它是否为 200。如果出现问题且没有代码,则默认值为 403,但归根结底,除了 200 以外的任何代码都意味着凭证将被视为无效:

   if result.get('status', 403) == 200:
        return True

    return False

故障排除外部身份验证

故障排除 auth 模块与其他类型的模块略有不同,因为你所测试的是访问命令的能力,而不是结果命令的功能。这意味着你选择的要执行的命令应该是已知可以正常工作的,例如 test.ping

设置认证参数

在你可以使用 auth 模块之前,你需要在主配置文件中启用它。可以使用 external_auth 指令配置多个认证模块:

external_auth:
  pam:
    moe:
      - .*
      - '@runner'
      - '@wheel'
    larry:
      - test.*
      - disk.*
      - network.*
      - '@runner'
      - '@wheel'
  webauth:
    shemp:
      - test.*
      - network.*
      - '@runner'
      - '@wheel'

在这个例子中,我们设置了三个用户,分布在两个不同的 auth 模块之间。moelarry 用户被设置为使用 pam 模块,而 shemp 用户被设置为使用我们刚刚创建的 webauth 模块。moe 用户可以访问所有执行模块,以及 runner 和 wheel 系统,而 larry 的执行模块访问权限仅限于 testdisknetwork 模块。shemp 用户与 larry 相同,只是没有访问 disk 模块的权限。

请记住,Salt API 需要 @runner@wheel 被设置。如果你不打算使用 Salt API 向用户授予资源访问权限,那么你可以跳过这两行。

一旦你配置了 external_auth,就有两种测试 auth 模块的方法:在 Master 上使用 salt 命令,以及使用 Salt API。

使用 salt 命令进行测试

测试 auth 模块最快的方法是登录到以 salt-master 服务运行的 Master 账户,并发出一个 salt 命令,使用适当的参数设置要使用的 auth 模块和要使用的凭证:

  • --auth-a:此参数设置要使用的认证模块。此参数的默认值是 pam

  • --username:用于认证的用户名。

  • --password:用于认证的密码。

假设你正在使用我们刚刚创建的 webauth 模块进行测试,一个基本的 salt 命令看起来像这样:

salt --auth=webauth --username=larry --password=123pass '*' test.ping

使用 Salt API 进行测试

你也可以使用 Salt API 测试 auth 模块。这可以通过在 Linux 中常见的 curl 命令轻松实现。在你可以使用此方法测试之前,你需要配置 master 配置文件中的 Salt API。

请注意,以下配置块是不安全的,因为它没有使用 SSL。在生产环境中,切勿将 disable_ssl 设置为 True!作为一个安全措施,此配置块还将 Salt API 设置为仅监听来自本地主机的请求:

rest_cherrypy:
  port: 8080
  host: 127.0.0.1
  debug: True
  disable_ssl: True

一旦配置好 Salt API,请继续在两个不同的窗口中启动前台运行的 salt-mastersalt-api 服务:

# salt-master -l debug
# salt-api -l debug

使用以下 curl 命令运行 test.ping 函数:

# curl localhost:8080/run \
 -H 'Accept: application/json' \
 -d username=larry \
 -d password=123pass \
 -d eauth=pam \
 -d client=local \
 -d tgt='*' \
 -d fun='test.ping'

这里最重要的参数是 eauth,它等同于 salt 命令中的 --auth 参数,以及 client,它指定要访问的模块类型。在这里,我们使用 local,它指的是执行模块。其他一些可用的参数是 runnerwheel,分别用于运行者和 wheel 模块。

当您使用正确的凭据执行前面的命令时,您将收到一个包含结果的 JSON 字符串:

{"return": [{"dufresne": true}]}

如果您使用错误的凭据执行它,您将收到一个包含以下文本的错误页面:

<h2>401 Unauthorized</h2>
<p>No permission -- see authorization schemes</p>

如果您查看前台运行的 salt-master 窗口,您将看到如下错误信息:

[WARNING ] Authentication failure of type "eauth" occurred for user larry.

如果您查看运行 salt-api 的窗口,您将看到如下信息:

127.0.0.1 - - [26/Jan/2016:08:25:32] "POST /run HTTP/1.1" 401 1214 "" "curl/7.46.0"
[INFO    ] 127.0.0.1 - - [26/Jan/2016:08:25:32] "POST /run HTTP/1.1" 401 1214 "" "curl/7.46.0"

使用 wheel 模块管理 Master

wheel 系统旨在为主机提供一个 API,该 API 可通过提供对外部访问主机的程序(如 Salt API)进行访问。

当您编写 wheel 模块时,您会发现没有可用的命令行程序可以直接测试 wheel 模块。通常,wheel 模块包括一些功能,如果您直接登录到 Master,这些功能将通过其他方式可用,但即使没有手动访问选项,这些功能仍然很有用。

例如,最常用的 wheel 模块是 key,它允许以编程方式管理 Minion 密钥,而无需使用密钥命令。由于 wheel 模块对 reactor 系统可用,您可以编写 reactor 模块,这些模块可以根据预定义的条件自动接受或删除 Minion 的密钥。

将 wheel 包裹在运行者周围

对于我们的示例模块,我们将组合一个 wheel 模块,该模块返回有关运行者模块的一小部分数据。此模块是 sys 执行模块内运行者函数的简化版本。这些函数可能作为 wheel 模块有用,因为运行者被设计在 Master 上运行,而不是在 Minion 上。如果您不在 Master 上运行 salt-minion 服务,那么您就没有办法以编程方式列出 Master 上可用的运行者模块。

首先,我们将创建一个函数,该函数除了列出运行者系统中可用的所有函数外,不做任何其他操作:

'''
Show information about runners on the Master

This file should be saved as salt/wheel/runners.py
'''
import salt.runner

def list_functions():
    '''
    List the functions for all runner modules.
    '''
    run_ = salt.runner.Runner(__opts__)
    return sorted(run_.functions)

此函数并没有做什么。它设置与运行者系统的连接,并将其分配给 run_ 对象。然后它返回 Master 上所有可用运行者函数的排序列表。

为了测试这一点,我们需要配置 Salt API,就像我们在故障排除外部身份验证部分所做的那样。然后我们发出一个命令,将client设置为使用wheel系统:

# curl localhost:8080/run \
 -H 'Accept: application/json' \
 -d username=larry \
 -d password=123pass \
 -d eauth=pam \
 -d client=wheel \
 -d fun='runners.list_functions'

在只有一个管理模块可用的 Master 上,我们会得到一个看起来像这样的 JSON 字符串:

"return": [{"tag": "salt/wheel/20160126084725920013", "data": {"jid": "20160126084725920013", "return": ["manage.alived", "manage.allowed", "manage.bootstrap", "manage.bootstrap_psexec", "manage.down", "manage.get_stats", "manage.joined", "manage.key_regen", "manage.lane_stats", "manage.list_not_state", "manage.list_state", "manage.not_alived", "manage.not_allowed", "manage.not_joined", "manage.not_present", "manage.not_reaped", "manage.present", "manage.reaped", "manage.road_stats", "manage.safe_accept", "manage.status", "manage.tagify", "manage.up", "manage.versions"], "success": true, "_stamp": "2016-01-26T15:47:25.974625", "tag": "salt/wheel/20160126084725920013", "user": "larry", "fun": "wheel.runners.list_functions"}}]}

让我们在此基础上进一步扩展,并在执行模块中添加一个特定于 runner 的sys.doc函数版本:

from salt.utils.doc import strip_rst as _strip_rst

def doc():
    '''
    Return the docstrings for all runners.
    '''
    run_ = salt.runner.Runner(__opts__)
    docs = {}
    for fun in run_.functions:
        docs[fun] = run_.functions[fun].__doc__
    return _strip_rst(docs)

再次强调,此函数设置与 Runner 系统的连接,并将其分配给run_对象。然后它遍历run_对象内的函数,提取存在于__doc__属性中的 docstrings。每个 docstring 都被添加到一个名为docs的字典中,该字典通过 Salt 中的一个函数_strip_rst()传递,该函数稍微清理了一下这些内容。

让我们用一个函数来结束这一切,该函数只列出可用的运行模块,而不包含其他信息,例如 docstrings 或甚至函数名称:

__func_alias__ = {
    'list_': 'list'
}

def list_():
    '''
    List the runners loaded on the minion
    '''
    run_ = salt.runner.Runner(__opts__)
    runners = set()
    for func in run_.functions:
        comps = func.split('.')
        if len(comps) < 2:
            continue
        runners.add(comps[0])
    return sorted(runners)

此函数通过删除函数名称并添加结果模块名称到名为runners的集合中来扩展list_runners()函数。与之前一样,返回该集合的排序副本。

最后一个 wheel 模块

将我们所有的函数组合在一起,最终我们会得到一个看起来像这样的模块:

'''
Show information about runners on the Master

This file should be saved as salt/wheel/runners.py
'''
import salt.runner
from salt.utils.doc import strip_rst as _strip_rst

__func_alias__ = {
    'list_': 'list'
}

def doc():
    '''
    Return the docstrings for all runners.
    '''
    run_ = salt.runner.Runner(__opts__)
    docs = {}
    for fun in run_.functions:
        docs[fun] = run_.functions[fun].__doc__
    return _strip_rst(docs)

def list_():
    '''
    List the runners loaded on the minion
    '''
    run_ = salt.runner.Runner(__opts__)
    runners = set()
    for func in run_.functions:
        comps = func.split('.')
        if len(comps) < 2:
            continue
        runners.add(comps[0])
    return sorted(runners)

def list_functions():
    '''
    List the functions for all runner modules.
    '''
    run_ = salt.runner.Runner(__opts__)
    return sorted(run_.functions)

轮模块故障排除

再次强调,轮模块在故障排除方面有些特殊,因为 Salt 中没有特定的命令行程序可以直接执行它们。与auth模块不同,它们甚至不能使用salt命令进行测试。

然而,正如您刚才看到的,它们可以使用 Salt API 和curl进行测试:

# curl localhost:8080/run \
 -H 'Accept: application/json' \
 -d username=larry \
 -d password=123pass \
 -d eauth=pam \
 -d client=wheel \
 -d fun='runners.list'

您还可以使用 Salt 的事件系统测试 wheel 模块。习惯于这种方式进行测试是很好的,因为 wheel 模块在 reactor 模块中非常有用。

让我们设置一个反应器,从 Master 中删除 Minion 的密钥:

# This reactor should be saved as /srv/reactor/test_delete.sls
test_delete_minion:
  wheel.key.delete:
    - match: data['bad_minion']

然后将该反应器添加到master配置文件中:

reactor:
  - 'user/minon/delete/*':
    - '/srv/reactor/test_delete.sls'

在 Master 上创建一个坏 Minion 密钥:

# touch /etc/salt/pki/master/minions/ronald

在重启 Master 后,发出一个命令以触发反应器:

# salt myminion event.fire_master '{"bad_minion":"ronald"}' 'user/minion/delete/ronald'

一旦您发出此命令,您可以使用salt-key命令确保 Minion 的坏密钥不再存在:

# salt-key -L

或者为了加分,为什么不使用 Salt API 来确保 Minion 的密钥已经删除?:

# curl localhost:8080/run \
 -H 'Accept: application/json' \
 -d username=larry \
 -d password=123pass \
 -d eauth=pam \
 -d client=wheel \
 -d fun='key.list' \
 -d match='ronald'
{"return": [{"tag": "salt/wheel/20160126091522567932", "data": {"jid": "20160126091522567932", "return": {}, "success": true, "_stamp": "2016-01-26T16:15:22.576966", "tag": "salt/wheel/20160126091522567932", "user": "larry", "fun": "wheel.key.list"}}]}

不要被success设置为true的事实所迷惑;这里的重要值是return,它是一个空字典。

摘要

外部身份验证(或 auth)模块允许外部身份验证系统在 Master 上验证用户凭据。这可以用于本地验证用户,但使用连接到 Salt 的外部系统时是必需的。

轮模块允许对 Master 端功能的 API 访问。轮模块中包含的函数通常允许管理在 Master 端通过其他方式本地可用,但不是通过 Master 外部端点的功能。然而,轮模块可以包含你认为必要的任何 Master 端管理功能。

恭喜!你已经完成了 Extending SaltStack 的所有内容!我们包括了一些附录,为你提供一些一般性的开发指南以及一些关于贡献社区的信息。

正如你所见,Salt 开发的世界有待探索。经常会有更多模块被添加,偶尔也会出现新的模块类型。虽然我们还没有涵盖所有现有或将来会有的内容,但你现在已经拥有了一个坚实的基础,可以用来解决你遇到的新的 Salt 代码。祝你好运;希望你在那里取得成功!

附录 A. 连接不同模块

在构建基础设施时,了解每种模块类型如何相互配合是有帮助的。这包括它们如何在 Salt 内部配合,以及你如何能够使用这些连接来构建自己的解决方案。

分离 Master 和 Minion 功能

很容易将 Salt 理解为以下方式:Master 向 Minion 发送命令,Minion 执行工作,然后 Minion 将结果发送回 Master。然而,Master 和 Minion 是两个不同的组件,它们和谐地协同工作以完成各自的任务。

需要记住的是,当 Minion 以无 Master 模式运行(使用salt-call --local)时,它表现得像自己的 Master 一样,除了少数特定功能(如salt-key和使用local_client的 runners)之外,Master 上可用的任何功能在 Minion 上也是可用的,使用与master文件中相同的配置选项,但位于minion文件中。

但当与一个 Master 和一个或多个 Minion 一起运行时,它们是两个不同的实体。一些模块类型只对 Master 或 Minion 可用;而许多其他模块类型仅对该特定服务可用。

让我们看看 Salt Master 拓扑的图示表示:

分离 Master 和 Minion 功能

现在将展示 Salt Minion 拓扑的图示表示:

分离 Master 和 Minion 功能

与 Master 和 Minion 一样,每种模块类型都是特定和独特的。然而,与 Master 和 Minion 一样,模块相互连接并协同工作以完成更大的工作流程。无论模块类型如何,Master 和 Minion 都将始终直接通信(使用传输模块,这超出了本书的范围)。除此之外,不同的模块能够以不同程度的相互通信。

Master 通常直接使用其自己的模块。其中一些模块可能被用来为 Minion 提供资源(例如文件服务器模块),但许多模块完全用于为 Master 本身提供资源。Master 返回者与在 Minion 上执行的返回者在功能上相同,唯一的区别是它们获取数据的方式。

Minion 模块之间进行广泛的通信。执行模块可以从 Grain 和 SDB 模块(以及通过 Master 从柱子上)拉取数据,并相互调用。状态模块本身由执行模块调用,但也可以访问跨回调到执行模块。渲染器被多种不同的模块类型使用,最终,返回者将返回数据传输到正确的目的地。

Salt Cloud 是异类,因为它可以通过 runner 或执行模块访问,也可以直接访问,甚至可以在不安装 Salt 的情况下独立使用。实际上,它可以用来管理节点,甚至不需要在这些节点上安装 Salt。

与双下划线操作符一起工作

对于那些不知道的人来说,双下划线指的是由两个下划线前后夹着的变量。例如,Salt 中最常见的双下划线之一是__opts__,它包含 Master 或 Minion 的配置,具体取决于上下文。有许多双下划线一起工作,形成将所有 Salt 连接在一起的粘合剂。让我们依次看看它们:

  • __opts__:在 Master 上,__opts__字典包含位于 Master 配置文件中的信息(通常是/etc/salt/master以及位于/etc/salt/master.d/目录中的文件),还包括未指定配置参数的默认值,以及 Salt 在运行时为其自身生成的任何内部配置。

    在 Minion 上,当连接到 Master 时,__opts__包含类似的信息(但来自/etc/salt/minion文件和/etc/salt/minion.d/目录),然而,当 Minion 以无 Master 模式(例如从salt-call --local调用时)使用,任何默认值都填充为如果它是一个 Master,而不是 Minion。这是因为需要从某种 Master 提供查找,如 pillar 和文件,在这个角色中,Minion 需要扮演这个角色。

  • __salt__:在运行在 Minion 上的模块(最显著的是执行和状态模块)中,__salt__包含对系统上所有可用执行模块的函数调用的列表。这些项可以直接调用,就像它们是调用模块内部的函数一样。例如:

    __salt__['disk.usage']()
    __salt__'cmd.run'
    __salt__'cmd.run'
    

    以这种方式使用函数被称为跨调用。因为它调用执行模块,而这些模块仅作为 Minion 可用,所以 Master 不使用跨调用。

  • __grains__: 另一个仅适用于 Minion 的内置字典是__grains__,它包含为 Minion 计算的所有 grains 的副本。Salt 广泛使用它来帮助 Minion 自动检测可用的资源类型。可以通过传递--skip-grains标志来启动salt-call而不检测 grains,如下所示:

    # salt-call --local --skip-grains test.ping
    
    

    你会注意到,如果你这样做,Minion 的响应速度会更快。但如果你尝试使用比test更高级的任何模块,你很快就会了解到 grains 对于 Minion 功能的重要性。

  • __pillar__: Pillar 也有自己的内置字典,其名称奇怪地是单数形式(__pillar__而不是__pillars__)。与由 Minion 生成的 grains 不同,pillar 是由 Master 生成的。然而,如果你像这样以--local模式运行salt-call,你会发现__opts__现在包含 Master 端配置,因此通常位于 Master 上的 pillar 配置现在将由 Minion 接受:

    # salt-call --local test.ping
    
    
  • 这对于编写和调试 pillar 模块非常有用,因为你不会冒着用不良 pillar 数据污染其他 Minion 的风险。

  • __context__: 这个字典对状态模块和执行模块都可用。当 Salt 启动第一个执行模块(在状态运行中将是state模块),它将创建__context__字典。所有输入到这个字典的信息都将跨后续模块持久化,这样不同的模块就有了一种存储信息以供其他模块后续使用的方法。一旦最终模块完成,__context__字典将被销毁。

    确保如果你决定使用__context__,在尝试设置或使用它之前检查其键的存在。这是因为你真的没有事先知道某人将使用模块的顺序,所以你不应该假设某些事情已经被填充或没有被填充。

注意

有关 Salt 内置函数的更多信息,请参阅:

docs.saltstack.com/en/latest/topics/development/dunder_dictionaries.html

使用事件总线

事件总线没有出现在拓扑图中,因为它在 Salt 内部任何地方都可用,只需导入salt.event库即可。它还具有使用反应器系统调用其他模块类型的能力。反应器可以访问执行、状态和运行模块。

小贴士

你可能想知道为什么这本书没有涵盖反应器模块。实际上,并没有所谓的反应器模块。反应器是用标准的 SLS 文件编写的,可以使用渲染器系统添加额外的功能。关于编写和使用反应器的更深入讨论,请务必查看Mastering SaltStackJoseph HallPackt Publishing

由于事件总线无处不在,它可以是一个非常强大的工具,用于将其他模块类型绑定成一个连贯的工作流程。

例如,让我们看看 Salt Cloud。它可以独立于 Salt 的其他部分运行,但在使用主节点+从节点设置时,它将在创建和删除过程中向主节点触发事件,这些事件可以被反应器捕获。

Salt Cloud 事件使用标签,这些标签以命名空间的方式命名,可以很容易地由反应器确定:

salt/cloud/<minion_id>/<operation>

可用的事件取决于云提供商,以及该提供商已配置的工作,但一个编写良好的云驱动程序在创建节点时始终会触发至少这两个事件:

salt/cloud/<minion_id>/creating
salt/cloud/<minion_id>/created

删除节点时,它还会触发这两个事件:

salt/cloud/<minion_id>/deleting
salt/cloud/<minion_id>/deleted

使用这些事件可以启动对从节点及其资源进行维护的操作。例如,如果你想从创建从节点的那一刻起同步从节点的资源,你可以使用一个看起来像这样的反应器:

sync_minion:
  cmd.saltutil.sync_all:
    - tgt: data['id']

由于从节点将在 Salt Cloud 发送salt/cloud/<minion_id>/created标签时可用,你可以设置一个反应器来确保从节点一上线就同步,而无需配置任何startup_states

触发事件

你可以从从节点模块(如执行和状态模块)和主节点模块(如运行器)触发事件。从一个从节点模块,你不需要做更多的事情,只需调用事件执行模块,如下所示:

__salt__'event.fire_master'

但在主模块中,你需要做更多的工作,因为__salt__不可用。你需要导入salt.utils.event,然后使用它来触发事件。这并不是很多工作,但你确实需要进行一些设置。看起来是这样的:

import os.path
import salt.utils.event
import salt.syspaths
sock_dir = os.path.join(salt.syspaths.SOCK_DIR, 'master')
transport = __opts__.get('transport', 'zeromq')
event = salt.utils.event.get_event(
    'master',
    sock_dir,
    transport,
    listen=False,
)
event.fire_event(data_dict, some_tag)

让我们回顾一下这里发生了什么。首先,我们设置我们的导入。salt.syspaths库包含有关在这个系统上标准文件和目录将位于何处的信息。在我们的情况下,我们需要连接到一个名为master的套接字。我们使用这些信息来设置一个名为sock_dir的变量,它告诉 Salt 如何找到要连接的事件总线。

我们还找出为这个系统配置了哪种传输机制。这通常会是zeromq,但也可能是其他协议,如raettcp。然后我们使用get_event()函数设置一个对象。第一个参数说明我们正在处理哪个总线,然后是sock_dir、传输,最后我们说明我们不会监听事件,我们将发送它们。

注意

我们所说的我们正在处理哪个总线是什么意思?主节点和从节点都有自己的事件总线。从节点可以使用minion总线向自己发送消息,或者使用master总线向主节点发送。从节点的事件总线很少被使用,除非是 Salt 的内部代码,但主节点总线被广泛使用。

一旦我们设置了事件对象,我们就可以触发事件。首先指定数据(可以是列表或字典),然后是事件标签。如果你愿意,你可以在 Master 上设置一个监听器来查看这些事件:

# salt-run state.event pretty=True

事件被用于许多最有用的事情之一是 reactors。如前所述,有关编写 reactors 的更多信息,请参阅Mastering SaltStackJoseph Hal lPackt Publishing

附录 B. 向上游贡献代码

多年来,许多用户都评论说 Salt 对新开发者的入门门槛很低。这可以部分归因于友好且专业的社区,以及用于管理 Salt 代码库的工具。

社区如何运作

Salt 社区由来自全球的用户和开发者组成。这些人中的绝大多数都是在商业环境中使用 Salt 的专业人士,尽管也有一些爱好者在其中找到了自己的位置。

当大多数人进入社区时,他们正在寻找关于他们正在处理的特定情况的帮助和信息。这可能是寻找示例或文档这样的小事,也可能是更严重的事情,比如报告软件中似乎存在的错误。

人们在社区中度过了一些时间后,他们通常会留下来帮助其他用户。记住,尽管他们中的一些人可能是 Salt 及其管理的各种技术的专家,但他们仍然只是像你一样的用户,他们贡献自己的时间来帮助像你这样的人。

提问和报告问题

Salt 社区有三个主要的地方聚集在一起讨论软件并互相帮助:邮件列表、IRC 聊天室和 GitHub 上的问题跟踪器。

在这些地方,你通常会找到三种类型的信息:关于软件的问题、错误报告和功能请求。一般来说,关于软件的问题应该在邮件列表或 IRC 上提出。错误报告和功能请求更适合在问题跟踪器中提出。

使用邮件列表

salt-users 邮件列表是一个非常活跃的讨论环境,托管在 Google Groups 上。邮件列表可以在以下链接找到:

groups.google.com/d/forum/salt-users

你可以通过前面的链接浏览邮件列表,或者设置电子邮件订阅,将消息发送到你的收件箱,你可以在那里回复它们。通常,每天有大约一二十封电子邮件,所以如果你觉得太多,那么可能只是在线查看是更好的选择。

如果你打算发帖提问,有一些指南可以帮助你:

  • 当你提问时,尽量提供足够关于你问题的信息,以便人们能够帮助你。在过去,人们曾询问如何修复特定的问题,但没有说明问题的实际情况,或者在某些情况下,甚至没有提到与 Salt 相关的问题的部分。正如你可以想象的那样,这对任何人都没有帮助。

  • 描述你想要尝试的事情以及你期望发生的结果。如果某些事情没有按照你预期的样子工作,请确保说明实际上发生了什么。

  • 你可能需要发布命令的输出以解释正在发生的事情。如果这种情况发生,请确保发布你正在运行的实际命令和相关的输出部分。如果你发出的命令产生了数十行日志输出,但实际错误只占五行,那么最初只发布这五行。如果有人要求更多,那么你可以继续发布更多。

    小贴士

    在发布日志和配置文件时要小心!人们往往会无意中发布 API 密钥、密码或私人网络信息。在将任何信息粘贴到任何可能被他人看到的地方之前,请确保删除任何敏感信息。确保不发布过长的日志消息会使这个过程容易得多。

  • 了解你正在运行的 Salt 版本也很有帮助。你的特定经验可能只与 Salt 的特定版本相关。与其只说 Salt 的哪个版本,通常更有帮助的是给出以下命令的输出:

    # salt --versions-report
    
    
  • 如果你正在使用 Salt Cloud,那么请确保使用以下方式获取报告:

    # salt-cloud --versions-report
    
    
  • 因为 Salt Cloud 使用不同的库集,使用它的versions报告将提供更多可能有用的信息,除了 Salt 本身的版本信息之外。

  • 如果你从邮件列表之外找到了解决你情况的方案,回复你自己的帖子并提供解决方案的副本也是一个好主意。邮件列表存档在谷歌的服务器上,如果其他人遇到同样的问题并搜索它,他们会很感激看到解决方案。相信我,没有什么比在十几个不同的邮件列表上找到十几个不同的人问同样的问题,要么没有解决方案,要么是原始发件人留言说“嘿,我想通了”,然后就此结束更令人沮丧的了。

使用 IRC

IRC,或互联网中继聊天,是一种存在了很长时间的聊天室类型。如果你已经有了 IRC 客户端,你可以连接到 Freenode 服务器:

irc.freenode.com

然后加入 Salt 聊天室:

#salt

如果你还没有 IRC 客户端,你可能想要考虑使用 Pidgin,这是一个支持多种聊天协议的聊天客户端。它并不是最受欢迎的 IRC 客户端,但它易于使用,并且适用于 Windows、Mac 和 Linux。你可以在以下地址下载它:

www.pidgin.im/

如果你不想使用 IRC 客户端,Freenode 确实有一个基于 Web 的 IRC 客户端,你可以用它来连接到 Salt 的聊天室。你可以在以下位置找到这个客户端:

webchat.freenode.net/

当你连接到 Salt 的聊天室时,有一些事情是很有用的:

  • 耐心等待。在任何给定时间,Salt 聊天室中都有数百人登录,但并非所有人都在积极参与。人们在工作时登录到 IRC 房间,并在一天中定期检查是很常见的。当你提问时,不要期望立即得到回答。可能有人正在观察并试图帮助你,但这可能需要一个小时才能有合适的人看到你的问题并跳出来尝试回答。

  • 准备好提供必要的信息。愿意帮助你的人可能会要求查看日志消息或代码片段,或者可能会要求你尝试几个不同的命令,并发布响应。

    你可能想要考虑在文本分享服务上注册一个账户。这样一个流行的服务是 PasteBin:

    pastebin.com/

    然而,你也可能想要考虑使用 GitHub 的 gist 服务:

    gist.github.com/

    这已经成为一种越来越流行的分享日志和代码片段的方式,就像 PasteBin 一样,但具有 Git 所知的版本管理功能。

  • 发布解决方案。与邮件列表一样,Salt 聊天室中的对话都会被存档。你可以在以下位置找到它们:

    irclog.perlgeek.de/salt/

    如果你在工作过程中找到了解决方案,但通过查看对话并不明显,确保将其发布在聊天室中,以便其他人以后可以找到。

使用问题跟踪器

当你遇到你知道是错误的情况,或者你有功能请求时,GitHub 上的 Salt 问题跟踪器是正确的选择。你可以在以下位置找到它:

github.com/saltstack/salt/issues

你可能会遇到一种情况,你不知道你的问题是不是由于缺乏经验,或者是一个真正的错误。如果你不确定,请将问题发布在邮件列表上。如果是错误,那么你可能会被要求在问题跟踪器中提交问题,前提是其他人还没有提交相同的问题。

在问题跟踪器中提交问题的优点之一是,你将自动订阅该问题的更新。这意味着当其他人发布有关问题的提问和评论时,你将收到一封包含他们响应的电子邮件。如果其他人已经发布了该问题,那么你仍然可以订阅它。只需在问题页面右侧查找订阅按钮:

使用问题跟踪器

一旦点击那个按钮,它将变为“取消订阅”。如果你对收到该问题的更新感到厌倦(即使是你创建的),那么你可以取消订阅。但如果你在上面留下了评论,我鼓励你保持订阅,以防将来有人想要进一步询问你。

再次强调,确保发布任何相关信息,就像你在邮件列表上做的那样。关于问题的详细信息、版本报告和代码片段都是有帮助的。最近添加到 Salt 问题跟踪器的一个新功能是使用模板,它提供了关于需要提供哪些信息的提醒。

使用 GitHub Markdown

GitHub 中一个极其有用的功能是能够使用 Markdown。你可以在以下位置找到一个有用的 Markdown 指南:

GitHub Markdown 指南

到目前为止,最实用的 Markdown 语法是知道如何标记代码块。用于标记代码的字符通常被称为反引号,也称为重音符号。在美国 QWERTY 键盘上,这个键位于左上角的位置:

使用 GitHub Markdown

当你在一段文本的前面和后面放置一个反引号时,该文本将被格式化为代码。如果你需要格式化多行,则在第一行开始时使用三个连续的反引号,在最后一行结束时也使用三个连续的反引号。以这种方式标记代码块极大地提高了可读性。

使用 GitHub Markdown

理解 Salt 风格指南

如果你已经在 Python 中花费了足够的时间,那么你已经熟悉了 Python 代码风格指南,也称为 PEP 8。对于那些尚未看到它或需要复习的人,你可以在以下位置查看它:

Python PEP 8 指南

还有一个关于 Salt 编码风格的指南,可在以下位置找到:

Salt 编码风格指南

通常,Salt 的编码约定遵循 PEP 8,但有一些关键的区别:

  • 引用: 新开发者遇到的第一项约定之一是 Salt 使用单引号(')而不是双引号(")。这适用于从字符串格式化到文档字符串的任何地方。

  • 行长度: 代码通常限制每行不超过 80 个字符,这在 Python 中尤其被严格遵守。这种做法基于一个较老的约定,即计算机屏幕的宽度正好是 80 个字符。由于现在的情况已经不再是这样,因此在 Salt 中扩展到 120 个字符被认为是可接受的,尤其是如果这有助于提高可读性。

  • 制表符与空格: Salt 使用四个空格进行缩进。不使用制表符。没有例外。

使用 Pylint

Salt 广泛使用一个名为 Pylint 的程序来确保其代码遵循其风格指南。你可以在以下位置找到有关安装 Pylint 的信息:

pylint 网站

请记住,Salt 目前使用 Python 2(最低版本为 2.6),所以如果你在一个同时提供 Python 2 和 3 版本的 Pylint 的发行版中工作,请确保你使用 Python 2 版本。

Salt 代码库附带了一个 .pylintrc 文件,用于与 Pylint 一起使用。默认情况下它不会被使用,所以你需要确保将其指向 Pylint:

$ cd /path/to/salt
$ pylint --rcfile=.pylintrc

不仅这个文件允许你检查你的代码是否符合 Salt 风格指南,还可以一次性检查整个代码库。这很重要,因为加载器会将变量插入到其他情况下 Pylint 无法检测到的模块中。

在 GitHub 上创建 pull request

与许多项目社区只通过邮件列表或复杂的网站接受代码不同,Salt 选择坚持使用 pull request 来接受代码贡献。活跃的 pull request 列表可以在以下位置找到:

saltstack/salt 的 pull request

Git 的完整使用细节超出了本书的范围,但了解克隆 Salt 仓库和提交新的 pull request 的步骤是值得的。

首先,你需要在 GitHub 上有自己的 Salt fork。如果你还没有,那么请使用 Salt 自己的 GitHub 页面上的 Fork 按钮:

saltstack/salt

假设你的 GitHub 用户名是 mygithubuser,你的新 fork 将出现在:

https://github.com/mygithubuser/salt

一旦你设置了 fork,你需要在你的电脑上克隆一个副本。以下步骤假设你在命令行环境中工作,例如 Linux:

  1. 如果你已经设置了 SSH 密钥,你可以使用 SSH 克隆:

    $ git clone git@github.com:mygithubuser/salt.git
    
    

    否则,你需要通过 HTTPS 克隆:

    $ git clone https://github.com/mygithubuser/salt.git
    
    
  2. 你还需要将原始 SaltStack 仓库添加到你的本地克隆中,以便能够创建 pull request:

    $ git remote add upstream https://github.com/saltstack/salt.git
    
    
  3. 默认的 Git 分支是 develop。如果你正在为 Salt 添加新功能,则应在基于 develop 的分支上执行工作。要创建一个名为 newfeature 的新分支并切换到它,请使用:

    $ git checkout -b newfeature
    
    
  4. 当你准备好提交 pull request 时,最好重新基于你的分支进行合并,以确保它不会与自上次更新以来已合并的任何其他 pull request 冲突:

    $ git checkout develop
    $ git fetch upstream
    $ git pull upstream develop
    $ git checkout newfeature
    $ git rebase develop
    
    

    注意

    关于使用 rebase 的更多信息,请参阅:

    使用 Git rebase

  5. 一旦你完成了 rebase,就可以将你的分支推送到 GitHub:

    $ git push origin newfeature
    
    
  6. 当你再次访问你的 GitHub fork 时,你会看到一个链接,上面写着 New Pull Request。从那里,你可以查看你的分支和 GitHub 上 develop 分支当前版本的差异,并在满意后创建你的 pull request。

与问题提交一样,pull request 现在也有一个模板可供使用,作为描述 pull request 包含的更改的指南,提供有用的信息。

使用其他分支

如果你正在提交错误修复,那么可能更合适的是针对与 Salt 的特定版本匹配的分支提交。如果你知道错误首次出现在哪个版本的 Salt 中,那么请使用那个分支。例外情况是,如果相关的分支已经太旧,不再维护。如果是这种情况,那么请选择正在维护的最旧分支。例如,如果最旧的维护版本是 2015.8.x,那么检出 2015.8 分支:

$ git checkout 2015.8

理解 pull request 中的测试错误

当你提交一个新的 pull request 时,GitHub 将触发测试套件对其运行。这需要几分钟,因为它需要创建一个新的虚拟机,并使用 Pylint 启动一个 lint 测试,以及针对 CentOS 和 Ubuntu 等流行平台的测试:

理解 pull request 中的测试错误

当测试正在运行时,你可以通过点击右侧的 详情 按钮来检查进度:

理解 pull request 中的测试错误

点击其中一个测试以查看更多信息。你会看到错误消息、堆栈跟踪以及标准输出和标准错误输出。

有可能你 pull request 中出现的测试失败并非你的责任。可能是另一个 pull request 被合并,导致构建服务器出现了未预见的错误。如果出现的错误看起来与你的代码无关,请留下评论询问。SaltStack 的核心开发者会看到并帮助你。

Lint 错误看起来略有不同。当你查看 lint 测试的详细信息时,你会看到一个受影响的文件列表。点击其中一个,你会看到每个错误都被标记出来。悬停在它上面以找出出了什么问题:

理解 pull request 中的测试错误

如果你需要更多关于 lint 测试的信息,你可以点击左侧的 控制台输出,以查看 lint 测试的完整日志。

一旦你在本地 Git 克隆的代码中进行了修正,按照常规提交它们,并将它们推回 GitHub:

$ git push origin newfeature

将安排新的测试运行,任何剩余的错误将如之前一样显示。一旦所有错误都得到解决,核心开发者将能够合并你的代码。

posted @ 2025-09-22 13:21  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报