Добавляем в Go-проект конфигурацию на языке Terraform

от автора

Конфигурирование приложений — это интересная тема. Мало того, что форматов конфигурации в сообществе инженеров много, ситуация осложняется тем, что выбор того или иного языка определяет, как вашим приложением будут пользоваться люди. Инженеры, которые будут выкладывать ваш бэкенд в абстрактную dev- или prod-среду, будут смотреть на ваше приложение как на чёрный ящик с одной лишь ручкой: механизмом настроек.

Я, как инженер, встречал удобные и не очень текстовые конфигурации: conf в Nginx, ini в systemd, JSON в VSCode… А также YAML. Он не стал новым словом в языках, но показал, какой красивой может быть конфигурация. Впрочем, сам по себе язык тупой как пробка: если вы попробуете писать на YAML что-то сложное, с переменными или циклами, то получится химера вроде Ansible. Или вроде манифестов Kubernetes, у которого диалект настолько переусложнён, что его приходится шаблонизировать с помощью Helm.

Да, как понятно из заголовка, я хочу поговорить про язык Terraform, но сначала…

Существующие решения

Какие вообще есть языки в мире open source:

  • ini, пришедший из Windows.

  • А также его духовный наследник TOML, отличающийся большей структурированностью и типами.

  • Java properties. Просто набор из строк ключ-значение.

  • dotenv. Тоже в каком-то смысле популярный язык конфигурации из ключ-значение.

  • JSON, который ещё можно встретить как язык конфигурации…

  • …и YAML, наследующий структуру и типы JSON в более читаемом формате, и предоставляющий несколько расширений для строк и блоков.

  • HOCON, интересный язык, который можно периодически встретить в мире Java.

  • XML. Боже упаси. Нет, нет и нет. Не используйте XML как язык конфигурации в 2022 году. Пожалуйста.

  • Starlark, который можно встретить в Bazel. Очень похож на Python.

  • conf, антиформат, под которым вообще может скрываться что угодно: от конфигов того же Nginx или какой-нибудь Icinga2 до чёрт знает чего. Грубо говоря, это универсальное расширение для DSL.

  • Groovy, который, вообще-то, полноценный язык программирования, но его можно применять как встраиваемый язык конфигурации, чем пользуется Jenkins и Gradle.

  • KotlinScript, который дальше Gradle и TeamCity я нигде не встречал.

  • И многие, многие другие.

А ещё есть HCL, язык, знакомый многим по Terraform.

Hashicorp Configuration Language

Так де-юре расшифровывается HCL, но на деле это больше, чем язык конфигурации: согласно официальному описанию, это инструментарий для создания своего языка, который одинаково хорошо парсится человеком и машиной. По синтаксису он больше похоже на DSL, поскольку структура конфигурации очень гибкая, может содержать блоки, переменные и вызовы функций.

Грамматика языка описана в этом документе, а, попросту говоря, код вдохновлён конфигурацией Nginx и выглядит так:

variable = “value” block {   type = list(string)   attribute = [123, 456]   innerblock {     key = “val”   } }  # more real example document “markdown” “example” {   name = “example”   content = file(“example.md”) }

Здесь видно следующее:

  • У языка есть все необходимые для жизни типы: числа, строки, списки и map-ы ключ-значение.

  • Данные структурированы в блоки, которые могут быть вложенными. В объявлении блоков может быть два дополнительных поля (type и id).

  • Можно вызывать функции.

Хорошо, с языком, в целом, разобрались. А как с ним работать из Go? На сцену приглашается фреймворк hashicorp/hcl! Он состоит из следующих библиотек:

  • Одноимённая библиотека hcl/v2 содержит примитивы и интерфейсы, общие для других библиотек: Body (ветвь в дереве конфигурации), Diagnostic (структура сообщений от парсера языка) и другие.

  • gohcl используется для преобразования hcl.Body в структуры Go.

  • hcldec — это высокоуровневый API для валидации и работы с интерфейсом hcl.Body.

  • hclparse содержит инструменты для разбора как «нативного» синтаксиса HCL, так и JSON HCL. Да, у любого HCL-кода есть и JSON-эквивалент.

  • Пакет hclsimple хорош для начала работы с фреймворком, в нём есть функции высокого порядка для парсинга файлов и байтов в те же структуры.

  • hclsyntax — это пакет с парсером, AST и другим низкоуровневым кодом.

  • hclwrite позволяет сгенерировать HCL-конфиг по спецификации и данным.

  • json — JSON-парсер HCL, о JSON-формате упоминалось чуть выше.

Пакет hclsimple

Итак. Есть hclsimple, идеальный для знакомства с системой. Попробуем написать тест?

import (     "os"     "path"     "testing"      "github.com/hashicorp/hcl/v2/hclsimple" ) type Config1 struct {     Name  string `hcl:"name"`     Count int64  `hcl:"count"` }  func TestConfig1(t *testing.T) {     content := []byte(` name = "Дороу" count = 64     `)     var cfg Config1     err := hclsimple.Decode("test.hcl", content, nil, &cfg)     if err != nil {         t.Error(err)     }     if cfg.Name != "Дороу" {         t.Errorf("cfg.name: expected 'Дороу', got %v", cfg.Name)     }     if cfg.Count != 64 {         t.Errorf("cfg.count: expected 64, got %v", cfg.Count)     } }

Что можно сказать об этом коде? Здесь есть hclsimple.Decode(), который принимает на вход имя файла (можно несуществующее) и байты, которые затем парсит в структуру. У функции есть ещё компаньон DecodeFile(), принимающий имя файла.

Что произойдёт, если Decode получит некорректное содержимое?

count = "Пашов нафих"  === RUN   TestConfig1     /home/igor/…/config_test.go:52: test.hcl:3,10-21: Unsuitable value type; Unsuitable value: a number is required

Конечно, мы получили сообщение об ошибке, но вот что интересно: HCL вывел позицию ошибки в файле, что крайне удобно при последующей диагностике неполадок.

Также в синтаксисе HCL допускается добавлять в объявления блока двух специальных полей (label): id и type. Пробуем:

type Document struct {     Format   string `hcl:"type,label"`     Name     string `hcl:"id,label"`     Content  string `hcl:"content"` }  type Config2 struct {     Docs    []Document `hcl:"document,block"` }  func TestConfig2(t *testing.T) {     content := []byte(` document "markdown" "readme" {     content = "this is readme" } document "rst" "development" {     content = "dev process" }     `)     // …

Остальная часть теста остаётся той же.

Также можно описать в структуре только id:

type Folder struct {     Name  string   `hcl:"id,label"`     Items []string `hcl:"items"` }  type Config3 struct {     Docs    []Document `hcl:"document,block"`     Folders []Folder   `hcl:"folder,block"` }  func TestConfig3(t *testing.T) {     content := []byte(` document "markdown" "readme.md" {     content = "this is readme" } document "rst" "development.rst" {     content = "dev process" } folder "project" {     items = ["readme.md", "development.rst"] }                       `

Отлично! С блоками и примитивными типами разобрались

Выполнение выражений

Но бывает так, что примитивных типов мало, и хочется, например, обращаться к другим атрибутам других блоков, как в Terraform. Для этого есть тип поля hcl.Expression. Но для работы с ним придётся спуститься немного поглубже в фреймворк HCL, точнее, в его систему типов и их преобразования. Этими задачами занимается библиотека zclconf/go-cty. В общем, довольно лирики:

Код
import (     "testing"      "github.com/hashicorp/hcl/v2"     "github.com/hashicorp/hcl/v2/hclsimple"     "github.com/zclconf/go-cty/cty"     "github.com/zclconf/go-cty/cty/gocty" )  type Document struct {     Format   string `hcl:"type,label" cty:"format"`     Name     string `hcl:"id,label"   cty:"name"`     Filename string `hcl:"filename"   cty:"filename"`     Content  string `hcl:"content"    cty:"content"` }  type Folder struct {     Name        string         `hcl:"id,label"`     Items       hcl.Expression `hcl:"items,attr"`     ParsedItems []string }  type Config3 struct {     Docs    []Document `hcl:"document,block"`     Folders []Folder   `hcl:"folder,block"` }  func TestConfig3(t *testing.T) {     content := []byte(` document "markdown" "readme" {     filename = "readme.md"     content = "this is readme" } document "rst" "development" {     filename = "development.rst"     content = "dev process" } folder "project" {     items = [doc.readme.name, doc.development.name] }     `)     docType := cty.Object(map[string]cty.Type{         "format":   cty.String,         "filename": cty.String,         "name":     cty.String,         "content":  cty.String,     })     ctx := hcl.EvalContext{         Variables: map[string]cty.Value{             "doc": cty.EmptyObjectVal,         },     }     var cfg Config3     err := hclsimple.Decode("docs.hcl", content, &ctx, &cfg)     if err != nil {         t.Error(err)     }     docs := make(map[string]Document)     docMapType := make(map[string]cty.Type)     for _, doc := range cfg.Docs {         docs[doc.Name] = doc         docMapType[doc.Name] = docType     }     ctx.Variables["doc"], err = gocty.ToCtyValue(docs, cty.Object(docMapType))     if err != nil {         t.Error(err)     }     for _, folder := range cfg.Folders {         val, diags := folder.Items.Value(&ctx)         if diags.HasErrors() {             t.Error(diags)         }         items := val.AsValueSlice()         folder.ParsedItems = make([]string, 0, len(items))         for _, v := range items {             folder.ParsedItems = append(folder.ParsedItems, v.AsString())         }     } }

Что в коде нового:

  • В структуре Document появились теги cty. Они нужны для функции ToCtyValue().

  • Переменная docType, в которой явно описываем поля и типы структуры Document.

  • В Folder поле Items теперь типа hcl.Expression. Почему не []hcl.Expression? Просто потому, что, во-первых, массив с переменными — это тоже выражение; во-вторых, десериализатор не сможет привести массив выражений к нужной структуре.

  • В EvalContext появилась пустая переменная doc типа объекта.

  • Проходимся по десериализованным документам, создаём map-ы с именем и структурой документа, а также с именем и описанием типов структуры. Для чего перечислять тип документа в каждом элементе? Если мы хотим обращаться к данным через точку, то надо во всех местах ставить тип cty.Object(). А так как у объектов атрибуты могут быть разных типов, то придётся указывать тип каждого документа.

  • Записываем в переменную doc EvalContext-а результат преобразования структуры в cty.Value (функция gocty.ToCtyValue()).

  • Для выполнения выражения используется метод Expression.Value(ctx). Контекст при желании может быть нулевым, но сейчас мы его заполнили переменной для того, чтобы в выражениях можно было обращаться к ним.

  • Кастуем значение выражения к слайсу, каждый его элемент кастуем к строке, и только тогда добавляем в каталог.

Если в двух словах, то да, работа с переменными и выражениями в HCL трудна: надо проходить по конфигурации в несколько этапов, а если появляются графы зависимостей, как в Terraform, то становится вообще понуро! С другой стороны, легко это всё равно нельзя сделать, и вообще здорово, что фреймворк позволяет отложенно обрабатывать выражения, а не в один прогон, когда парсится файл.

Анализ выражений

К слову, если хочется не обрабатывать все переменные, а просто определить по выражению, к каким переменным идёт обращение, то есть метод Expression.Variables(). Он возвращает []hcl.Traversal, то есть массив из обращений к переменным. Traversal, в свою очередь, это путь поиска значения: в пути могут встречаться обращения к индексам, к ключам map-ы, к атрибутам структуры, и так далее.

Попробуем реализовать разбор []hcl.Traversal для частного случая с переменной doc:

Код
func TestConfig4(t *testing.T) {     var cfg Config3     // …     docRefs := make(map[string]int, 0)     for _, f := range cfg.Folders {         varRefs := f.Items.Variables()         for _, varref := range varRefs {             if varref.RootName() != "doc" {                 t.Errorf("%s is not a doc variable", varref.RootName())             }             for lvl, it := range varref[1:] {                 switch value := it.(type) {                 case hcl.TraverseAttr:                     if lvl == 0 {                         if _, ok := docRefs[value.Name]; !ok {                             docRefs[value.Name] = 1                         }                     }                 case hcl.TraverseIndex:                     t.Error("indexing operations not supported")                 }             }         }     }     docType := cty.Object(map[string]cty.Type{         "format":   cty.String,         "name":     cty.String,         "filename": cty.String,         "content":  cty.String,     })     docVars := make(map[string]Document)     docTypes := make(map[string]cty.Type)     for _, doc := range cfg.Docs {         for ref, _ := range docRefs {             if ref == doc.Name {                 docVars[doc.Name] = doc                 docTypes[doc.Name] = docType                 break             }         }     }     var err error     ctx.Variables["doc"], err = gocty.ToCtyValue(docVars, cty.Object(docTypes))     // дальше парсим как раньше }

Из интересного:

  • У Config.Items (тип hcl.Expression) вызываем метод Variables(), который возвращает перечень обращений к переменным ([]hcl.Traversal, о котором говорилось ранее).

  • Поскольку Traversal — это псевдоним для []Traverser, то по нему тоже можно итерироваться.

  • Traverser — это общий интерфейс, поэтому в обходе цикла понадобится кастовать к конкретным структурам, то есть делать switch value := it.(type).

  • У Traverse три реализации — это TraverseAttr, TraverseIndex, а также TraverseSplat.

  • Если переменная типа doc.<name>.<attrs>, то записываем name в docRefs (это наше а-ля set) с исключением дубликатов.

  • Проходимся по cfg.Docs, если имя документа есть в docRefs, то добавляем его в объект переменной.

Частичный парсинг

Потенциально можно пойти ещё дальше в сторону не просто разбора обращения к переменным, а ещё и в сторону частичного разбора конфига в целом: 

func TestConfig4(t *testing.T) {     // ...     hclfile, diags := hclsyntax.ParseConfig(content, "docs.hcl", hcl.InitialPos)     if diags.HasErrors() {         t.Error(diags)     }     bc, _, _ := hclfile.Body.PartialContent(folderSchema)     folder := bc.Blocks[0]     if folder.Labels[0] != "project" {         t.Errorf("folder name: expected %v, got %v", "project", folder.Labels[0])     }     attrs, diags := folder.Body.JustAttributes()     if diags.HasErrors() {         t.Error(diags)     }     for key, _ := range attrs {         if key != "items" {             t.Errorf("folder attr: expected %v, got %v", "items", key)         }     } }  var folderSchema = &hcl.BodySchema{     Blocks: []hcl.BlockHeaderSchema{         {             Type:       "folder",             LabelNames: []string{"name"},         },     },     Attributes: []hcl.AttributeSchema{         {Name: "items", Required: true},     }, }
  • Здесь через низкоуровневый hclsyntax.ParseConfig() конфиг парсится в hcl.File.

  • Это нужно для того, чтобы потом обратиться к его полю Body (тип hcl.Body) и получить частичный результат (hcl.BodyContent).

  • Также PartialContent() возвращает оставшуюся часть hcl.Body и «диагностику». Диагностика при частичном парсинге будет содержать ошибки, поэтому её мы игнорируем, как и тело, которое нам сейчас не нужно.

  • PartialContent() принимает только один аргумент — hcl.BodySchema, схему документа для валидации.

  • В конце берём первый попавшийся блок, смотрим на его первую метку, а также на атрибуты.

Функции

HCL, как уже упоминалось, поддерживает функции, а как их объявлять в контексте? Примерно следующим образом:

Код
func TestConfig5(t *testing.T) {     content := []byte(` document "markdown" "readme" {     filename = "readme.md"     content = file("readme.md") } document "rst" "development" {     filename = "development.rst"     content = file("development.rst") } folder "project" {     items = [doc.readme.name, doc.development.name] }     `)     ctx := hcl.EvalContext{         Functions: map[string]function.Function{             "file": FileFunc,         },     }     var cfg Config3     err := hclsimple.Decode("docs.hcl", content, &ctx, &cfg)     if err != nil {         t.Error(err)     }     for _, v := range cfg.Docs {         if v.Name == "readme" && v.Content != "<readme.md content>" {             t.Error("readme has content:", v.Content)         }         if v.Name == "development" && v.Content != "<development.rst content>" {             t.Error("development has content:", v.Content)         }     } }  var FileFunc = function.New(&function.Spec{     VarParam: nil,     Params: []function.Parameter{         {Type: cty.String},     },     Type: func(args []cty.Value) (cty.Type, error) {         return cty.String, nil     },     Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {         filename := args[0].AsString()         var err error         // content, err := os.ReadFile(filename)         content, err := []byte(fmt.Sprintf("<%s content>", filename)), nil         if err != nil {             return cty.NilVal, err         }         return cty.StringVal(string(content)), nil     }, })

  • VarParam — это тип для varargs-аргумента функции. Если таковой у функции есть.

  • Params — набор аргументов, которые принимает функция.

  • Type — тип возвращаемого значения, можно вычислить на основе полученных аргументов.

  • Impl — собственно, тело функции. os.ReadFile подменён фейковыми данными, но принцип работы понятен.

Также в библиотеке cty есть пакет functions.stdlib с уже готовыми функциями для работы с числами, массивами, строками и их форматированием, и другие.

В заключение

Hashicorp Configuration Language выделяется из других языков хорошим синтаксисом и одноимённым фреймворком. По функциональности язык можно сравнить можно со Starlark, Groovy и KotlinScript, однако в отличие от первого hashicorp/hcl умеет парсить код в несколько этапов и с разными подходами, а Groovy/Kotlin требуют свой компилятор в runtime. На мой взгляд, HCL — это отличное решение для сложной и декларативной конфигурации, или если вы пишете инструмент для DevOps, которые тоже любят описывать инфраструктуру в декларативной конфигурации.


ссылка на оригинал статьи https://habr.com/ru/company/domclick/blog/692814/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *