Создание Terraform-подобных языков конфигурации с помощью HCL и Go

В последние полтора года я работал над Atlas, инструментом управления схемами баз данных, который мы разрабатываем в Ariga. В рамках этой работы я работал над созданием инфраструктуры для Atlas DDL, языка определения данных, который является основой для декларативного стиля работы Atlas по управлению схемами баз данных.

Язык Atlas основан на HCL, инструментарии для создания языков конфигурации с аккуратной и простой информационной моделью и синтаксисом. HCL был создан в компании HashiCorp и используется в таких популярных инструментах, как Terraform и Nomad. Мы выбрали HCL в качестве основы для нашего языка конфигурации по нескольким причинам:

  • Его базовый синтаксис ясен и краток, легко читается людьми и машинами.
  • Популярный в Terraform и других проектах в области DevOps / Infrastructure-as-Code, мы решили, что он будет хорошо знаком практикам, которые являются одной из основных аудиторий для нашего инструмента.
  • Он написан на языке Go, что делает его очень простым для интеграции с остальной частью нашей кодовой базы в Ariga.
  • Он имеет большую поддержку для расширения базового синтаксиса до полноценного DSL с использованием функций, выражений и контекстных переменных.

PCL: язык конфигурации пиццы

В оставшейся части этого поста мы продемонстрируем, как создать базовый язык конфигурации с помощью HCL и Go. Чтобы сделать обсуждение занимательным, давайте представим, что мы создаем новый продукт PaC (Pizza-as-Code), который позволяет пользователям определять свою пиццу в простых конфигурационных файлах на основе HCL и отправлять их в виде заказов в ближайшую пиццерию.

Заказы и контакты

Давайте начнем создание нашего языка конфигурации PaC с того, что позволим пользователям определить, куда они хотят доставить свою пиццу и кто является голодным контактом, ожидающим прибытия пиццы. Мы стремимся к чему-то вроде:

contact {
  name  = "Sherlock Holmes"
  phone = "+44 20 7224 3688"
}
address {
  street  = "221B Baker St"
  city    = "London"
  country = "England"
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Для захвата этой конфигурации мы определим структуру Go Order с подструктурами для захвата Contact и Address:

type (
    Order struct {
        Contact *Contact `hcl:"contact,block"`
        Address *Address `hcl:"address,block"`
    }
    Contact struct {
        Name  string `hcl:"name"`
        Phone string `hcl:"phone"`
    }
    Address struct {
        Street  string `hcl:"street"`
        City    string `hcl:"city"`
        Country string `hcl:"country"`
    }
)
Вход в полноэкранный режим Выход из полноэкранного режима

Кодовая база Go HCL содержит два пакета с достаточно высокоуровневым API для декодирования документов HCL в структуры Go: hclsimple (GoDoc)
и gohcl (GoDoc). Оба пакета полагаются на то, что пользователь предоставит теги полей Go struct для отображения полей из конфигурационного файла в поля struct.

Мы начнем пример с использования более простого из них, с удивительным
названием hclsimple:

func TestOrder(t *testing.T) {
    var o Order
    if err := hclsimple.DecodeFile("testdata/order.hcl", nil, &o); err != nil {
        t.Fatalf("failed: %s", err)
    }
    require.EqualValues(t, Order{
        Contact: &Contact{
            Name:  "Sherlock Holmes",
            Phone: "+44 20 7224 3688",
        },
        Address: &Address{
            Street:  "221B Baker St",
            City:    "London",
            Country: "England",
        },
    }, o)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Размеры пиццы и начинки (использование статических значений)

Далее, давайте добавим возможность заказа реальной пиццы в наше PaC-приложение. Чтобы описать пиццу на нашем языке конфигурации, пользователи должны иметь возможность сделать
что-то вроде

pizza {
  size = XL
  count = 1
  toppings = [
    olives,
    feta_cheese,
    onions,
  ]
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, что для того, чтобы сделать наш API более явным, пользователи не передают строковые значения в поле size или toppings, а вместо этого они используют заранее определенные, статические идентификаторы (называемые «переменными» во внутреннем API HCL), такие как XL или feta_cheese.

Для поддержки такого поведения мы можем передать hcl.EvalContext (GoDoc),
который предоставляет переменные и функции, которые должны быть использованы для оценки выражения.

Для создания этого контекста мы создадим вспомогательную функцию ctx():

func ctx() *hcl.EvalContext {
    vars := make(map[string]cty.Value)
    for _, size := range []string{"S", "M", "L", "XL"} {
        vars[size] = cty.StringVal(size)
    }
    for _, topping := range []string{"olives", "onion", "feta_cheese", "garlic", "tomatoe"} {
        vars[topping] = cty.StringVal(topping)
    }
    return &hcl.EvalContext{
        Variables: vars,
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Чтобы использовать ее, нам нужно добавить блок pizza в нашу структуру верхнего уровня Order:

type (
    Order struct {
        Contact *Contact `hcl:"contact,block"`
        Address *Address `hcl:"address,block"`
        Pizzas  []*Pizza `hcl:"pizza,block"`
    }
    Pizza struct {
        Size     string   `hcl:"size"`
        Count    int      `hcl:"count,optional"`
        Toppings []string `hcl:"toppings,optional"`
    }
    // ... More types ...
)
Вход в полноэкранный режим Выход из полноэкранного режима

Вот наш блок pizza, считанный с помощью ctx() в действии:

func TestPizza(t *testing.T) {
    var o Order
    if err := hclsimple.DecodeFile("testdata/pizza.hcl", ctx(), &o); err != nil {
        t.Fatalf("failed: %s", err)
    }
    require.EqualValues(t, Order{
        Pizzas: []*Pizza{
            {
                Size: "XL",
                Toppings: []string{
                    "olives",
                    "feta_cheese",
                    "onion",
                },
            },
        },
    }, o)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Сколько пицц заказать? (Использование функций в HCL)

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

pizza {
  size     = XL
  count    = for_diners(3)
  toppings = [
    tomato
  ]
}
Вход в полноэкранный режим Выход из полноэкранного режима

Основываясь на общепринятой эвристике, согласно которой следует заказывать по 3 ломтика на каждого посетителя и округлять в большую сторону, мы можем зарегистрировать следующую функцию в нашем EvalContext:

func ctx() *hcl.EvalContext {
    // .. Variables ..

    // Define a the "for_diners" function.
    spec := &function.Spec{
        // Return a number.
        Type: function.StaticReturnType(cty.Number),
        // Accept a single input parameter, "diners", that is not-null number.
        Params: []function.Parameter{
            {Name: "diners", Type: cty.Number, AllowNull: false},
        },
        // The function implementation.
        Impl: func (args []cty.Value, _ cty.Type) (cty.Value, error) {
            d := args[0].AsBigFloat()
            if !d.IsInt() {
                return cty.NilVal, fmt.Errorf("expected int got %q", d)
            }
            di, _ := d.Int64()
            neededSlices := di * 3
            return cty.NumberFloatVal(math.Ceil(float64(neededSlices) / 8)), nil
        },
    }
    return &hcl.EvalContext{
        Variables: vars,
        Functions: map[string]function.Function{
          "for_diners": function.New(spec),
        },
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Тестирование функции for_diners:

func TestDiners(t *testing.T) {
    var o Order
    if err := hclsimple.DecodeFile("testdata/diners.hcl", ctx(), &o); err != nil {
      t.Fatalf("failed: %s", err)
    }
    // For 3 diners, we expect 2 pizzas to be ordered.
    require.EqualValues(t, 2, o.Pizzas[0].Count)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Подведение итогов

С этими функциями, я думаю, мы можем закончить работу над этим прототипом первого в мире продукта Pizza-as-Code. Поскольку исходный код этих примеров доступен на GitHub под лицензией Apache 2.0
я искренне надеюсь, что кто-нибудь возьмет и построит эту штуку!

В этом посте мы рассмотрели некоторые базовые вещи, которые можно сделать для создания языка конфигурации для ваших пользователей с помощью HCL. Есть много других классных возможностей, которые мы встроили в язык Atlas (например, входные переменные, ссылки на блоки и полиморфизм блоков), так что если вам интересно почитать об этом подробнее, не стесняйтесь, пингуйте меня в Twitter.

Оцените статью
devanswers.ru
Добавить комментарий