vlambda博客
学习文章列表

py-libterraform 的使用和实现:一个 Terraform 的 Python 绑定

初衷

在某个使用 Python 开发的业务中,涉及到 Terraform 的交互,具体有两个需求:

  • 需要调用 Terraform 的各种命令,以完成对资源的部署、销毁等操作
  • 需要解析 Terraform 配置文件(HCL 语法)的内容,分析里面的组成

对于前者,有一个名为 python-terraform[1] 的开源库,它封装了 Terraform 的命令,当我们在代码中调用时,背后会新启一个进程执行 Terraform 的对应命令,并能返回命令退出码和捕获的 stdoutstderrpython-terraform 用起来虽然方便,但最大的缺点在于要求执行环境事先安装了 Terraform,而且新启进程也带来了额外的开销。

对于后者,尚未找到 Python 开源库能满足要求。

我希望能有一个库无需用户事先安装 Terraform,能在当前进程执行 Terraform 命令,而且还能解析 Terraform 配置文件,py-libterraform[2] 就这样诞生了。

使用

在说明 py-libterraform 的实现原理之前,不妨先看看是如何安装和使用的。

它的安装十分简单,执行 pip 命令即可,支持 MacLinuxWindows,并支持 Python3.6 及以上版本:

$ pip install libterraform

py-libterraform 目前提供两个功能:TerraformCommand 用于执行 Terraform CLI,TerraformConfig 用于解析 Terraform 配置文件。后文将通过示例介绍这两个功能。假定当前有一个 sleep 文件夹,里面的 main.tf 文件内容如下:

variable "time1" {
type = string
default = "1s"
}

variable "time2" {
type = string
default = "1s"
}

resource "time_sleep" "wait1" {
create_duration = var.time1
}

resource "time_sleep" "wait2" {
create_duration = var.time2
}

output "wait1_id" {
value = time_sleep.wait1.id
}

output "wait2_id" {
value = time_sleep.wait2.id
}

Terraform CLI

现在进入 sleep 目录,需要对它执行 Terraform init, applyshow,以部署资源并查看资源属性,那么可以这么做:

>>> from libterraform import TerraformCommand
>>> cli = TerraformCommand()
>>> cli.init()
<CommandResult retcode=0 json=False>
>>> _.value
'\nInitializing the backend...\n\nInitializing provider plugins...\n- Reusing previous version of hashicorp/time from the dependency lock file\n- Using previously-installed hashicorp/time v0.7.2\n\nTerraform has been successfully initialized!\n\nYou may now begin working with Terraform. Try running "terraform plan" to see\nany changes that are required for your infrastructure. All Terraform commands\nshould now work.\n\nIf you ever set or change modules or backend configuration for Terraform,\nrerun this command to reinitialize your working directory. If you forget, other\ncommands will detect it and remind you to do so if necessary.\n'
>>> cli.apply()
<CommandResult retcode=0 json=True>
>>> _.value
[{'@level''info''@message''Terraform 1.1.7''@module''terraform.ui''@timestamp''2022-04-08T19:16:59.984727+08:00''terraform''1.1.7''type''version''ui''1.0'}, ... ]
>>> cli.show()
<CommandResult retcode=0 json=True>
>>> _.value
{'format_version''1.0''terraform_version''1.1.7''values': {'outputs': {'wait1_id': {'sensitive'False'value''2022-04-08T11:17:01Z'}, 'wait2_id': {'sensitive'False'value''2022-04-08T11:17:01Z'}}, 'root_module': {'resources': [{'address''time_sleep.wait1''mode''managed''type''time_sleep''name''wait1''provider_name''registry.terraform.io/hashicorp/time''schema_version'0'values': {'create_duration''1s''destroy_duration'None'id''2022-04-08T11:17:01Z''triggers'None}, 'sensitive_values': {}}, {'address''time_sleep.wait2''mode''managed''type''time_sleep''name''wait2''provider_name''registry.terraform.io/hashicorp/time''schema_version'0'values': {'create_duration''1s''destroy_duration'None'id''2022-04-08T11:17:01Z''triggers'None}, 'sensitive_values': {}}]}}}

从上述执行过程可以看出,不论执行什么命令,都会返回一个 CommandResult 对象,用来表示命令执行结果(包含返回码、输出、错误输出、是否为 json 结构)。其中:

  • init() 返回的 value 是 Terraform init 命令的标准输出,一个字符串
  • apply() 返回的 value 默认是 Terraform apply -json 命令的标准输出被视作 json 加载后的数据,一个展示日志记录的列表。如果不希望解析标准输出,则可以使用 apply(json=False)
  • show() 返回的 value 默认是 Terraform show -jon 命令的标准输出被视作 json 加载后的数据,一个展示 Terraform state 文件数据结构的字典

所有命令的封装函数的思路是尽可能让结果方便给程序处理,因此对于支持 -json 的 Terraform 命令都会默认使用此选项并对结果进行解析。

以上是一个简单的示例,实际上 TerraformCommand 封装了所有的 Terraform 命令,具体可以调用 help(TerraformCommand) 进行查看。

Terraform 配置文件解析

如果希望拿到 Terraform 对配置文件的解析结果做进一步处理,那么 TerraformConfig 就可以满足需求,通过它可以解析指定的 Terraform 配置目录,获取其中的变量、资源、输出、行号等信息,这对分析配置组成很有帮助。可以这么做(部分输出较多使用...做了省略):

>>> from libterraform import TerraformConfig
>>> mod, _ = TerraformConfig.load_config_dir('.')
>>> mod
{'SourceDir''.''CoreVersionConstraints'None'ActiveExperiments': {}, 'Backend'None'CloudConfig'None'ProviderConfigs'None'ProviderRequirements': {'RequiredProviders': {}, 'DeclRange': ...}, 'Variables': {'time1': ..., 'time2': ...}, 'Locals': {}, 'Outputs': {'wait1_id': ..., 'wait2_id': ...}, 'ModuleCalls': {}, 'ManagedResources': {'time_sleep.wait1': ..., 'time_sleep.wait2': ...}, 'DataResources': {}, 'Moved'None}

TerraformConfig.load_config_dir 背后会调用 Terraform 源码中 internal/configs/parser_config_dir.go 中的 LoadConfigDir 方法,以加载 Terraform 配置文件目录,返回内容是原生返回结果 *Module, hcl.Diagnostics 的经序列化后分别加载为 Python 中的字典。

实现原理

由于 Terraform 是用 GoLang 编写的,Python 无法直接调用,但好在它可以编译为动态链接库,然后再被 Python 加载调用。因此,总体思路上可以这么做:

  • 使用 cgo 编写 Terraform 的 C 接口文件
  • 将它编译为动态链接库,Linux/Unix 上以 .so 结尾,在 Windows 上以 .dll 结尾
  • 在 Python 中通过 ctypes 加载此动态链接库,在此之上实现命令封装

本质上,GoLang 和 Python 之间以 C 作为媒介,完成交互。关于如何使用 cgoctypes 网上有很多文章,本文着重介绍实现过程中遇到的各种“坑”以及如何解决的。

坑 1:GoLang 的 internal packages 机制阻隔了外部调用

GoLang 从 1.4 版本开始,增加了 Internal packages 机制,只允许 internal 的父级目录及父级目录的子包导入,其它包无法导入。而 Terraform 最新版本中,几乎所有的代码都放在了 internal 中,这意味着使用 cgo 写的接口文件(本项目中叫 libterraform.go)如果作为外部包(比如包名叫 libterraform)是无法调用 Terraform 代码的,也就无法实现 Terraform 命令的封装。

一个解决方法是把 Terraform 中的 internal 改为 public,但这意味着需要修改大量的 Terraform 源码,这可不是个好主意。

那么另一个思路就是让 libterraform.go 作为整个 Terraform 项目的“一份子”,来“欺骗” Go 编译器。具体过程如下:

  • libterraform.go 的包名和 Terraform 主包保持一致,即 main
  • 构建前把 libterraform.go 移动到 Terraform 源码根目录下,作为 Terraform 项目的成员
  • 构建时,使用 go build -buildmode=c-shared -o=libterraform.so github.com/hashicorp/terraform 命令进行编译,这样编译出的动态链接库就能包含 libterraform.go 的逻辑

坑 2:注意管理 C 运行时申请的内存空间

不论是 GoLang 还是 Python,我们都不需要担心内存管理的问题,因为它们自会被语言的垃圾回收机制在合适的时机去回收。但是涉及到 C 的逻辑就需要各位注意内存管理了。使用 cgo 中定义的接口中可能会返回 *C.char,它实际是 C 层面上开辟的一段内存空间,需要被显式释放。例如,libterraform.go 中定义了加载 Terraform 配置目录的方法 ConfigLoadConfigDir,其实现如下:

//export ConfigLoadConfigDir
func ConfigLoadConfigDir(cPath *C.char) (cMod *C.char, cDiags *C.char, cError *C.char) {
 defer func() {
  recover()
 }()

 parser := configs.NewParser(nil)
 path := C.GoString(cPath)
 mod, diags := parser.LoadConfigDir(path)
 modBytes, err := json.Marshal(convertModule(mod))
 if err != nil {
  cMod = C.CString("")
  cDiags = C.CString("")
  cError = C.CString(err.Error())
  return cMod, cDiags, cError
 }
 diagsBytes, err := json.Marshal(diags)
 if err != nil {
  cMod = C.CString(string(modBytes))
  cDiags = C.CString("")
  cError = C.CString(err.Error())
  return cMod, cDiags, cError
 }
 cMod = C.CString(string(modBytes))
 cDiags = C.CString(string(diagsBytes))
 cError = C.CString("")
 return cMod, cDiags, cError
}

上述方法实现中,使用 C.CString 会在 C 层面上申请了一段内存空间,并返回结果返回给调用者,那么调用者(Python 进程)需要在使用完返回值之后显式释放内存。

在此之前,需要先通过 cgo 暴露释放内存的方法:

//export Free
func Free(cString *int) {
 C.free(unsafe.Pointer(cString))
}

然后,在 Python 中就可以实现如下封装:

import os
from ctypes import cdll, c_void_p
from libterraform.common import WINDOWS


class LoadConfigDirResult(Structure):
    _fields_ = [("r0", c_void_p),
                ("r1", c_void_p),
                ("r2", c_void_p)]


_load_config_dir = _lib_tf.ConfigLoadConfigDir
_load_config_dir.argtypes = [c_char_p]
_load_config_dir.restype = LoadConfigDirResult

root = os.path.dirname(os.path.abspath(__file__))
_lib_filename = 'libterraform.dll' if WINDOWS else 'libterraform.so'
_lib_tf = cdll.LoadLibrary(os.path.join(root, _lib_filename))

_free = _lib_tf.Free
_free.argtypes = [c_void_p]

def load_config_dir(path: str) -> (dict, dict):
    ret = _load_config_dir(path.encode('utf-8'))
    r_mod = cast(ret.r0, c_char_p).value
    _free(ret.r0)
    r_diags = cast(ret.r1, c_char_p).value
    _free(ret.r1)
    err = cast(ret.r2, c_char_p).value
    _free(ret.r2)
    ...

这里,在获取到返回结果后,调用 _free (也就是 libterraform.go 中的 Free)来显式释放内存,从而避免内存泄露。

坑 3:捕获输出

在 Terraform 的源码中,执行命令的输出会打印到标准输出 stdout 和标准错误输出 stderr 上,那么使用 cgo 封装出 RunCli 的接口,并被 Python 调用时,默认情况下就直接输出到 stdoutstderr 上了。

这会有什么问题呢?如果同时执行两个命令,输出结果会交错,没法区分这些结果是哪个命令的结果。

解决思路就是使用管道:

  • 在 Python 进程中使用 os.pipe 分别创建用于标准输出和标准错误输出的管道(会生成文件描述符)
  • 将两个文件描述符传入到 libterraform.goRunCli 方法中,在内部使用 os.NewFile 打开两个文件描述符,并分别替换 os.Stdoutos.Stderr
  • RunCli 方法结束时关闭这两个文件,并恢复原始的 os.Stdoutos.Stderr

此外,使用 os.pipe 获取到的文件描述符给 libterraform.go 使用时要注意操作系统的不同:

  • 对于 Linux/Unix 来说,直接传进去使用即可
  • 对于 Windows 来说,需要额外将文件描述符转换成文件句柄,这是因为在 Windows 上 GoLang 的 os.NewFile 接收的是文件句柄

Python 中相关代码如下:

if WINDOWS:
    import msvcrt
    w_stdout_handle = msvcrt.get_osfhandle(w_stdout_fd)
    w_stderr_handle = msvcrt.get_osfhandle(w_stderr_fd)
    retcode = _run_cli(argc, c_argv, w_stdout_handle, w_stderr_handle)
else:
    retcode = _run_cli(argc, c_argv, w_stdout_fd, w_stderr_fd)

坑 4:管道 Hang

由于管道的大小有限制,如果写入超过了限制就会导致写 Hang。因此不能在调用 RunCli (即会把命令输出写入管道)之后去管道中读取输出,否则会发现在执行简单命令(如version)时正常,在执行复杂命令(如apply,因为有大量输出)时会 Hang 住。

解决思路就是在调用 RunCli 前就启动两个线程分别读取标准输出和标准错误输出的文件描述符内容,在调用 RunCli 命令之后去 join 这两个线程。Python 中相关代码如下:

r_stdout_fd, w_stdout_fd = os.pipe()
r_stderr_fd, w_stderr_fd = os.pipe()

stdout_buffer = []
stderr_buffer = []
stdout_thread = Thread(target=cls._fdread, args=(r_stdout_fd, stdout_buffer))
stdout_thread.daemon = True
stdout_thread.start()
stderr_thread = Thread(target=cls._fdread, args=(r_stderr_fd, stderr_buffer))
stderr_thread.daemon = True
stderr_thread.start()

if WINDOWS:
    import msvcrt
    w_stdout_handle = msvcrt.get_osfhandle(w_stdout_fd)
    w_stderr_handle = msvcrt.get_osfhandle(w_stderr_fd)
    retcode = _run_cli(argc, c_argv, w_stdout_handle, w_stderr_handle)
else:
    retcode = _run_cli(argc, c_argv, w_stdout_fd, w_stderr_fd)

stdout_thread.join()
stderr_thread.join()
if not stdout_buffer:
    raise TerraformFdReadError(fd=r_stdout_fd)
if not stderr_buffer:
    raise TerraformFdReadError(fd=r_stderr_fd)
stdout = stdout_buffer[0]
stderr = stderr_buffer[0]

最后

当发现现有的开源库满足不了需求时,手撸了 py-libterraform,基本实现了在单进程中调用 Terraform 命令的要求。尽管在开发过程中遇到了各种问题,并需要不断在 Python、GoLang、C 之间跳转,但好在一个个解决了,记录此过程若能让大家少“踩坑”也算值啦!

最后,https://github.com/Prodesire/py-libterraform 求星😄~

参考资料

[1]

python-terraform: https://github.com/beelit94/python-terraform

[2]

py-libterraform: https://github.com/Prodesire/py-libterraform