本文同步发表于字节话云公众号。
初衷
在某个应用 Python 开发的业务中,波及到 Terraform 的交互,具体有两个需要:
- 须要调用 Terraform 的各种命令,以实现对资源的部署、销毁等操作
- 须要解析 Terraform 配置文件(HCL 语法)的内容,剖析外面的组成
对于前者,有一个名为 python-terraform 的开源库,它封装了 Terraform 的命令,当咱们在代码中调用时,背地会新启一个过程执行 Terraform 的对应命令,并能返回命令退出码和捕捉的 stdout
和 stderr
。python-terraform
用起来尽管不便,但最大的毛病在于要求执行环境当时装置了 Terraform,而且新启过程也带来了额定的开销。
对于后者,尚未找到 Python 开源库能满足要求。
我心愿能有一个库无需用户当时装置 Terraform,能在以后过程执行 Terraform 命令,而且还能解析 Terraform 配置文件,py-libterraform 就这样诞生了。
应用
在阐明 py-libterraform
的实现原理之前,无妨先看看是如何装置和应用的。
它的装置非常简略,执行 pip
命令即可,反对 Mac
、Linux
和 Windows
,并反对 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
, apply
和 show
,以部署资源并查看资源属性,那么能够这么做:
>>> 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 作为媒介,实现交互。对于如何应用 cgo
和 ctypes
网上有很多文章,本文着重介绍实现过程中遇到的各种“坑”以及如何解决的。
坑 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 调用时,默认状况下就间接输入到 stdout
和 stderr
上了。
这会有什么问题呢?如果同时执行两个命令,输入后果会交织,没法辨别这些后果是哪个命令的后果。
解决思路就是应用管道:
- 在 Python 过程中应用
os.pipe
别离创立用于规范输入和规范谬误输入的管道(会生成文件描述符) - 将两个文件描述符传入到
libterraform.go
的RunCli
办法中,在外部应用os.NewFile
关上两个文件描述符,并别离替换os.Stdout
和os.Stderr
- 在
RunCli
办法完结时敞开这两个文件,并复原原始的os.Stdout
和os.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/… 求赞😄~