py-libterraform 的使用和實現:一個 Terraform 的 Python 繫結

微軟技術棧發表於2022-04-09

在某個使用 Python 開發的業務中,涉及到 Terraform 的互動,具體有兩個需求:

  • 需要呼叫 Terraform 的各種命令,以完成對資源的部署、銷燬等操作
  • 需要解析 Terraform 配置檔案(HCL 語法)的內容,分析裡面的組成

對於前者,有一個名為 python-terraform 的開源庫,它封裝了 Terraform 的命令,當我們在程式碼中呼叫時,背後會新啟一個程式執行 Terraform 的對應命令,並能返回命令退出碼和捕獲的stdout和 stderr。python-terraform用起來雖然方便,但最大的缺點在於要求執行環境事先安裝了 Terraform,而且新啟程式也帶來了額外的開銷。

對於後者,尚未找到 Python 開源庫能滿足要求。

我希望能有一個庫無需使用者事先安裝 Terraform,能在當前程式執行 Terraform 命令,而且還能解析 Terraform 配置檔案,py-libterraform 就這樣誕生了。

1be3e321e2aca70f31e2391bb32b9e1.png

使用

在說明 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 之間跳轉,但好在一個個解決了,記錄此過程若能讓大家少“踩坑”也算值啦!

參考資料
python-terraform:

https://github.com/beelit94/p...

py-libterraform:

https://github.com/Prodesire/...


微軟最有價值專家(MVP)

微軟最有價值專家是微軟公司授予第三方技術專業人士的一個全球獎項。29年來,世界各地的技術社群領導者,因其線上上和線下的技術社群中分享專業知識和經驗而獲得此獎項。

MVP是經過嚴格挑選的專家團隊,他們代表著技術最精湛且最具智慧的人,是對社群投入極大的熱情並樂於助人的專家。MVP致力於通過演講、論壇問答、建立網站、撰寫部落格、分享視訊、開源專案、組織會議等方式來幫助他人,並最大程度地幫助微軟技術社群使用者使用 Microsoft 技術。

更多詳情請登入官方網站


掃碼關注微軟中國MSDN,獲取更多前沿技術資訊和學習內容!

相關文章