Сразу оговорюсь, что основная цель статей - пополнить копилку знаний о том, как начать работать с описанием в формате HCL. Возможно это даст стимул для появления новых примеров работы со своим описанием в HCL.
Во-вторых, я не преследую цель показать как писать чистый код на Go.
И в третьих, дать какие-то глубокие объяснения и знания я то же не могу, так как до многих вещей дохожу методом проб и ошибок.
Начнем.
В первой части был показан упрощенный шаблон HCL. Начнем его усложнять и приближать к моим требованиям.
Для начала добавим новые поля, которые могут использоваться в моем случае при создании Issue
create "Task" {
project = "AG" # required
# required
summary = "service_A // Обновить библиотеку Library_A до актуальной версии"
# optional
description = <<DESC
Нужно обновить библиотеку Library_A до актуальной версии.
После обновления проверить сервис на regress
DESC
app_layer = "Backend" # optional
components = ["service_A"] # optional
sprint = 100 # optional
epic = "AG-6815" # optional
labels = ["need-regress"] # optional
story_point = 2 # optional
qa_story_point = 1 # optional
assignee = "user_A" # optional
developer = "user_A" # optional
team_lead = "user_B" # optional
tech_lead = "user_B" # optional
release_engineer = "user_C" # optional
tester = "user_D" # optional
# parent = "AA-1234" # optional, используется для Sub-Task
}
# и так еще штук 20, меняя service_A на названия других сервисов
Если немного доработать код из первой части, добавив новые поля в структуру и заполнение их в jira.Issue, то таким образом можно создавать по 20+ похожих задач. Но этот вариант имеет кучу неудобств. Поэтому будем улучшать.
Если сейчас запустить скрипт, то получим от парсера сообщения, что обнаружены новые поля:
Error: Unsupported argument
on example.hcl line 10, in create "Task":
10: app_layer = "Backend" # optional
An argument named "app_layer" is not expected here.
Error: Unsupported argument
on example.hcl line 11, in create "Task":
11: components = ["service_A"] # optional
An argument named "components" is not expected here.
Error: Unsupported argument
on example.hcl line 20, in create "Task":
20: team_lead = "user_B" # optional
An argument named "team_lead" is not expected here.
...
Поэтому доработаем структуру, добавив туда описание новых полей:
type config struct {
Type string `hcl:"type,label"`
Project string `hcl:"project"`
Summary string `hcl:"summary"`
Description string `hcl:"description,optional"`
AppLayer string `hcl:"app_layer,optional"`
Components []string `hcl:"components,optional"`
SprintId int `hcl:"sprint,optional"`
Epic string `hcl:"epic,optional"`
Labels []string `hcl:"labels,optional"`
StoryPoint int `hcl:"story_point,optional"`
QaStoryPoint int `hcl:"qa_story_point,optional"`
Assignee string `hcl:"assignee,optional"`
Developer string `hcl:"developer,optional"`
TeamLead string `hcl:"team_lead,optional"`
TechLead string `hcl:"tech_lead,optional"`
ReleaseEngineer string `hcl:"release_engineer,optional"`
Tester string `hcl:"tester,optional"`
Parent string `hcl:"parent,optional"`
}
Проверяем:
&main.Root{
Create: {
{
Type: "Task",
Project: "AG",
Summary: "service_A // Обновить библиотеку Library_A до актуальной версии",
Description: "Нужно обновить библиотеку Library_A до актуальной версии.\nПосле обновления проверить сервис на regress\n",
AppLayer: "Backend",
Components: {"service_A"},
SprintId: 100,
Epic: "AG-6815",
Labels: {"need-regress"},
StoryPoint: 2,
QaStoryPoint: 1,
Assignee: "user_A",
Developer: "user_A",
TeamLead: "user_B",
TechLead: "user_B",
ReleaseEngineer: "user_C",
Tester: "user_D",
Parent: "",
},
},
}
Парсер работает ????
Теперь перейдем к задачке с переменными. Чтобы каждый раз не вспоминать, как в Jira заведены сотрудники, и избежать опечаток, хочу сделать для них "алиасы", которые проще запомнить.
Способ 1
Самое первое, что приходит на ум, это захардкодить некоторые значения в коде.
Для объявления переменных в коде, в методе DecodeBody есть аргумент context, в котором можно объявить доступные для использования в шаблоне переменные и функции.
Пример из доки:
ctx := &hcl.EvalContext{
Variables: map[string]cty.Value{
"name": cty.StringVal("Ermintrude"),
"age": cty.NumberIntVal(32),
"path": cty.ObjectVal(map[string]cty.Value{
"root": cty.StringVal(rootDir),
"module": cty.StringVal(moduleDir),
"current": cty.StringVal(currentDir),
}),
},
Functions: map[string]function.Function{
"upper": stdlib.UpperFunc,
"lower": stdlib.LowerFunc,
"min": stdlib.MinFunc,
"max": stdlib.MaxFunc,
"strlen": stdlib.StrlenFunc,
"substr": stdlib.SubstrFunc,
},
}
message = "${name} is ${age} ${age == 1 ? "year" : "years"} old!"
source_file = "${path.module}/foo.txt"
message = "HELLO, ${upper(name)}!"
Забегая вперед, скажу, что это нам пригодится. Поэтому придется познакомиться с библиотекой, которая занимается преобразованием типов zclconf/go-cty
Таким образом, можно попробовать захардкодить нужные нам переменные:
...
ctx := &hcl.EvalContext{
Variables: map[string]cty.Value{
"tester": cty.StringVal("jira_user_1"),
"team_lead": cty.StringVal("jira_user_5"),
"tech_lead": cty.StringVal("jira_user_5"),
"release_engineer": cty.StringVal("jira_user_6"),
"developers": cty.ObjectVal(map[string]cty.Value{
"Alex": cty.StringVal("jira_user_2"),
"Igor": cty.StringVal("jira_user_3"),
"Denis": cty.StringVal("jira_user_4"),
}),
"services": cty.ObjectVal(map[string]cty.Value{
"service_A": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("service_A"),
}),
"service_B": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("service_B"),
}),
"service_C": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("service_C"),
}),
}),
},
}
var root Root
diags = gohcl.DecodeBody(f.Body, ctx, &root)
...
Теперь шаблон будет выглядеть так:
create "Task" {
project = "AG" # required
# required
summary = "${services.service_A.name} // Обновить библиотеку Library_A до актуальной версии"
# optional
description = <<DESC
Нужно обновить библиотеку Library_A до актуальной версии.
После обновления проверить сервис на regress
DESC
app_layer = "Backend" # optional
components = ["${services.service_A.name}"] # optional
sprint = 100 # optional
epic = "AG-6815" # optional
labels = ["need-regress"] # optional
story_point = 2 # optional
qa_story_point = 1 # optional
assignee = developers.Alex # optional
developer = developers.Alex # optional
team_lead = team_lead # optional
tech_lead = tech_lead # optional
release_engineer = release_engineer # optional
tester = tester
}
С полным описанием синтаксиса HCL можно ознакомиться здесь.
Выполняем код:
&main.Root{
Create: {
{
Type: "Task",
Project: "AG",
Summary: "service_A // Обновить библиотеку Library_A до актуальной версии",
Description: "Нужно обновить библиотеку Library_A до актуальной версии.\nПосле обновления проверить сервис на regress\n",
AppLayer: "Backend",
Components: {"service_A"},
SprintId: 100,
Epic: "AG-6815",
Labels: {"need-regress"},
StoryPoint: 2,
QaStoryPoint: 1,
Assignee: "jira_user_2",
Developer: "jira_user_2",
TeamLead: "jira_user_5",
TechLead: "jira_user_5",
ReleaseEngineer: "jira_user_6",
Tester: "jira_user_1",
Parent: "",
},
},
}
Работает, но не очень удобно.
Способ 2
Следующее, что мне пришло на ум в процессе написания программы, использовать переменные окружения. Добавить либо переменную env либо функцию env() в EvalContext.
Для простоты, пусть будет функция env. Смотрим, как реализованы функции в доке, например, upper, и делаем по аналогии.
var EnvFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "env",
Type: cty.String,
AllowDynamicType: true,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
in := args[0].AsString()
out := os.Getenv(in)
return cty.StringVal(out), nil
},
})
...
ctx := &hcl.EvalContext{
Variables: map[string]cty.Value{
"team_lead": cty.StringVal("jira_user_5"),
"tech_lead": cty.StringVal("jira_user_5"),
"release_engineer": cty.StringVal("jira_user_6"),
"services": cty.ObjectVal(map[string]cty.Value{
"service_A": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("service_A"),
}),
"service_B": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("service_B"),
}),
"service_C": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("service_C"),
}),
}),
},
Functions: map[string]function.Function{
"env": EnvFunc,
},
}
Объявляем переменные в окружении:
export DEVELOPER_ALEX="jira_user_2"
export DEVELOPER_IGOR="jira_user_3"
export DEVELOPER_DENIS="jira_user_4"
export JIRA_TESTER="jira_user_1"
Дорабатываем hcl-шаблон
create "Task" {
project = "AG" # required
# required
summary = "${services.service_A.name} // Обновить библиотеку Library_A до актуальной версии"
# optional
description = <<DESC
Нужно обновить библиотеку Library_A до актуальной версии.
После обновления проверить сервис на regress
DESC
app_layer = "Backend" # optional
components = ["${services.service_A.name}"] # optional
sprint = 100 # optional
epic = "AG-6815" # optional
labels = ["need-regress"] # optional
story_point = 2 # optional
qa_story_point = 1 # optional
assignee = env("DEVELOPER_ALEX") # optional
developer = env("DEVELOPER_ALEX") # optional
team_lead = team_lead # optional
tech_lead = tech_lead # optional
release_engineer = release_engineer # optional
tester = env("JIRA_TESTER") # optional
}
И проверяем:
&main.Root{
Create: {
{
Type: "Task",
Project: "AG",
Summary: "service_A // Обновить библиотеку Library_A до актуальной версии",
Description: "Нужно обновить библиотеку Library_A до актуальной версии.\nПосле обновления проверить сервис на regress\n",
AppLayer: "Backend",
Components: {"service_A"},
SprintId: 100,
Epic: "AG-6815",
Labels: {"need-regress"},
StoryPoint: 2,
QaStoryPoint: 1,
Assignee: "jira_user_2",
Developer: "jira_user_2",
TeamLead: "jira_user_5",
TechLead: "jira_user_5",
ReleaseEngineer: "jira_user_6",
Tester: "jira_user_1",
Parent: "",
},
},
}
С этим способом уже больше гибкости. Но, все равно не то, чего я ожидаю.
Способ 3
А что, если нам объявлять переменные в самом HCL, которые мы потом сможем использовать. Например, так:
variables {
developers = ["jira_user_2", "jira_user_3", "jira_user_4"]
tester = "jira_user_1"
team_lead = "jira_user_5"
tech_lead = "jira_user_5"
release_engineer = "jira_user_6"
services = [
{ name = "service_A"},
{ name = "service_B"},
{ name = "service_C"},
]
}
# и использовать их в шаблоне
create "Task" {
project = "AG" # required
# required
summary = "${services.0.name} // Обновить библиотеку Library_A до актуальной версии"
# optional
description = <<DESC
Нужно обновить библиотеку Library_A до актуальной версии.
После обновления проверить сервис на regress
DESC
app_layer = "Backend" # optional
components = ["${services.0.name}"] # optional
sprint = 100 # optional
epic = "AG-6815" # optional
labels = ["need-regress"] # optional
story_point = 2 # optional
qa_story_point = 1 # optional
assignee = developers.0 # optional
developer = developers.0 # optional
team_lead = team_lead # optional
tech_lead = tech_lead # optional
release_engineer = release_engineer # optional
tester = tester # optional
}
Пробуем запустить:
Error: Unsupported block type
on example.hcl line 1, in variables:
1: variables {
Blocks of type "variables" are not expected here.
Error: Invalid index
on example.hcl line 17, in create "Task":
17: summary = "${services.0.name} // Обновить библиотеку Library_A до актуальной версии"
The given key does not identify an element in this collection value. An object
only supports looking up attributes by name, not by numeric index.
...
Ок. Нам нужно объявить блок variables. Дальше у нас снова развилка вариантов: описать доступные переменные либо сделать их динамическими. Выбираю второе.
Первая мысль, сделать так:
type Root struct {
Variables variables `hcl:"variables,block"`
Create []config `hcl:"create,block"`
}
Но в этом случае мы обязаны всегда указывать блоки variables и create. Хочу по-другому, чтобы блок был необязательным, но пойдем по пути от простого к сложному.
Итак, добавляем описание структуры для variables:
type variables struct {
Variables hcl.Body `hcl:",remain"`
}
Проверяем:
Error: Invalid index
on example.hcl line 17, in create "Task":
17: summary = "${services.0.name} // Обновить библиотеку Library_A до актуальной версии"
The given key does not identify an element in this collection value. An object
only supports looking up attributes by name, not by numeric index.
Error: Unsuitable value type
on example.hcl line 17, in create "Task":
17: summary = "${services.0.name} // Обновить библиотеку Library_A до актуальной версии"
Unsuitable value: value must be known
...
Ок, нужно как-то обработать блок variables, добавить из него переменные, после чего снова парсить, но уже блоки create.
Значит такая Root-структура нам не очень подходит. Попробуем другую (пришла в голову пока писал статью):
type VariablesBlock struct {
Variables variables `hcl:"variables,block"`
Remains hcl.Body `hcl:",remain"`
}
type variables struct {
Remains hcl.Body `hcl:",remain"`
}
type CreateBlocks struct {
Create []createConfig `hcl:"create,block"`
}
type createConfig struct {
Type string `hcl:"type,label"`
Project string `hcl:"project"`
Summary string `hcl:"summary"`
Description string `hcl:"description,optional"`
AppLayer string `hcl:"app_layer,optional"`
Components []string `hcl:"components,optional"`
SprintId int `hcl:"sprint,optional"`
Epic string `hcl:"epic,optional"`
Labels []string `hcl:"labels,optional"`
StoryPoint int `hcl:"story_point,optional"`
QaStoryPoint int `hcl:"qa_story_point,optional"`
Assignee string `hcl:"assignee,optional"`
Developer string `hcl:"developer,optional"`
TeamLead string `hcl:"team_lead,optional"`
TechLead string `hcl:"tech_lead,optional"`
ReleaseEngineer string `hcl:"release_engineer,optional"`
Tester string `hcl:"tester,optional"`
Parent string `hcl:"parent,optional"`
}
И попробуем реализовать идею: парсинг variables, наполнение контекста переменными, парсинг create.
parser := hclparse.NewParser()
f, diags := parser.ParseHCLFile(filename)
if diags.HasErrors() {
renderDiags(diags, parser.Files())
return nil, diags
}
var variablesBlock VariablesBlock
diags = gohcl.DecodeBody(f.Body, nil, &variablesBlock)
if diags.HasErrors() {
renderDiags(diags, parser.Files())
return nil, diags
}
...
Смотрим в окне дебага, что получили:
Вроде то, что нужно. Пробуем дальше:
parser := hclparse.NewParser()
...
// объявляем контекст, чтобы были доступны env в блоке variables
ctx := &hcl.EvalContext{
Variables: map[string]cty.Value{},
Functions: map[string]function.Function{
"env": EnvFunc,
},
}
// парсим блок variables
var variablesBlock VariablesBlock
diags = gohcl.DecodeBody(f.Body, ctx, &variablesBlock)
if diags.HasErrors() {
renderDiags(diags, parser.Files())
return nil, diags
}
// так как variablesBlock.Variables.Remains имеет тип hcl.Body
// и в блоке только атрибуты, чтобы их достать, используем метод hcl.Body.JustAttributes()
variables, diags := variablesBlock.Variables.Remains.JustAttributes()
if diags.HasErrors() {
renderDiags(diags, parser.Files())
return nil, diags
}
// проходимся по переменным и добавляем их в контекст
for _, variable := range variables {
var value cty.Value
// методом проб и ошибок, пришел к такому варианту получения значения с типом cty.Value
diags := gohcl.DecodeExpression(variable.Expr, nil, &value)
if diags.HasErrors() {
return nil, diags
}
ctx.Variables[variable.Name] = value
}
var createBlock CreateBlocks
diags = gohcl.DecodeBody(variablesBlock.Remains, ctx, &createBlock)
if diags.HasErrors() {
renderDiags(diags, parser.Files())
return nil, diags
}
Запускаем
&main.CreateBlocks{
Create: {
{
Type: "Task",
Project: "AG",
Summary: "service_A // Обновить библиотеку Library_A до актуальной версии",
Description: "Нужно обновить библиотеку Library_A до актуальной версии.\nПосле обновления проверить сервис на regress\n",
AppLayer: "Backend",
Components: {"service_A"},
SprintId: 100,
Epic: "AG-6815",
Labels: {"need-regress"},
StoryPoint: 2,
QaStoryPoint: 1,
Assignee: "jira_user_2",
Developer: "jira_user_2",
TeamLead: "jira_user_5",
TechLead: "jira_user_5",
ReleaseEngineer: "jira_user_6",
Tester: "jira_user_1",
Parent: "",
},
},
}
Есть результат ???? и вроде да же годный.
Сделаем блок variables необязательным, для этого просто игнорируем ошибку первичного парсинга и добавляем проверку на наличие variablesBlock.Variables.Remains
var variablesBlock VariablesBlock
_ = gohcl.DecodeBody(f.Body, ctx, &variablesBlock)
if variablesBlock.Variables.Remains != nil {
variables, diags := variablesBlock.Variables.Remains.JustAttributes()
if diags.HasErrors() {
renderDiags(diags, parser.Files())
return nil, diags
}
for key, variable := range variables {
var value cty.Value
diags := gohcl.DecodeExpression(variable.Expr, nil, &value)
if diags.HasErrors() {
return nil, diags
}
ctx.Variables[key] = value
}
}
Проверяем с удаленным блоком variables и без переменных
create "Task" {
project = "AG" # required
# required
summary = "service_A // Обновить библиотеку Library_A до актуальной версии"
# optional
description = <<DESC
Нужно обновить библиотеку Library_A до актуальной версии.
После обновления проверить сервис на regress
DESC
app_layer = "Backend" # optional
components = ["service_A"] # optional
sprint = 100 # optional
epic = "AG-6815" # optional
# optional
labels = ["need-regress"]
story_point = 2 # optional
qa_story_point = 1 # optional
assignee = "user_A" # optional
developer = "user_A" # optional
team_lead = "user_B" # optional
tech_lead = "user_B" # optional
release_engineer = "user_C" # optional
tester = "user_D" # optional
# parent = "AA-1234" # optional, используется для Sub-Task
}
Результат:
&main.CreateBlocks{
Create: {
{
Type: "Task",
Project: "AG",
Summary: "service_A // Обновить библиотеку Library_A до актуальной версии",
Description: "Нужно обновить библиотеку Library_A до актуальной версии.\nПосле обновления проверить сервис на regress\n",
AppLayer: "Backend",
Components: {"service_A"},
SprintId: 100,
Epic: "AG-6815",
Labels: {"need-regress"},
StoryPoint: 2,
QaStoryPoint: 1,
Assignee: "user_A",
Developer: "user_A",
TeamLead: "user_B",
TechLead: "user_B",
ReleaseEngineer: "user_C",
Tester: "user_D",
Parent: "",
},
},
}
Способ 4
К этому способу я пришел до написания статьи, после чего и появилось желание поделиться примерами работы с HCL, которые я пробую в процессе написания программы.
Идея такая же, как и в способе 3, с частичными парсингами, но с использованием метода hcl.Body.PartialContent.
Минус, PartialContent работает со своей структурой hcl.BodySchema, поэтому описанные для hcl-файла блоки и структуры использовать не выйдет, придется делать новое описание.
var variablesSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{{Type: "variables"}},
}
И сама обработка
blocks, body, diags := body.PartialContent(variablesSchema)
if diags.HasErrors() {
renderDiags(diags, parser.Files())
return nil, diags
}
for _, block := range blocks.Blocks {
attrs, _ := block.Body.JustAttributes()
for key, attr := range attrs {
var value cty.Value
diags := gohcl.DecodeExpression(attr.Expr, nil, &hclVar.Value)
if diags.HasErrors() {
renderDiags(diags, parser.Files())
return nil, diags
}
ctx.Variables[key] = value
}
}
Вообще работать с BodySchema мне не понравилось из-за "своей" структуры, где можно объявить только блоки и атрибуты. Причем внутри блока атрибуты объявить нельзя, что исключает использование нативной валидации.
package hcl
// BlockHeaderSchema represents the shape of a block header, and is
// used for matching blocks within bodies.
type BlockHeaderSchema struct {
Type string
LabelNames []string
}
// AttributeSchema represents the requirements for an attribute, and is used
// for matching attributes within bodies.
type AttributeSchema struct {
Name string
Required bool
}
// BodySchema represents the desired shallow structure of a body.
type BodySchema struct {
Attributes []AttributeSchema
Blocks []BlockHeaderSchema
}
Таким образом на текущий момент есть возможность описывать несколько задач, через блоки create и использовать переменные.
Следующим шагом в моих хотелках нужно придумать способ использовать итерации для блока create. Практическая задача такая: у меня есть массив из названий сервисов (либо описания в виде объектов), нужно по нему запустить цикл, и, используя один шаблон (блок create) создать N issue в Jira.
Псевдо-код выглядит примерно так:
{% for service in services %}
create "Task" {
...
summary = "${service.name} // Обновить библиотеку Library_A до актуальной версии"
components = ["${services.name}"]
...
}
{% endfor %}
Пока идеи две:
в create добавить атрибут for_each, при анализе которого запускать цикл и как-то внутри цикла создавать переменную iter
create "Task" {
...
summary = "${iter.name} // Обновить библиотеку Library_A до актуальной версии"
...
for_each = [for i, service in services: service if service.skip == false]
}
либо поместить create внутрь нового блока, например, loop, и в атрибутах loop указывать настройки для цикла
loop {
list = [for i, service in services: service if service.skip == false]
create "Task" {
...
summary = "${iter.name} // Обновить библиотеку Library_A до актуальной версии"
...
}
}
funca
На какой вы стадии стадии, чтобы реализовать свой движок Terraform? :)
alexandrtumaykin Автор
На нулевой )) так как решаем разные задачи ))
funca
Насколько я понимаю ваша задача научиться писать парсеры для HCL самому, и путь в ней важнее цели. Этим интересен ваш цикл статей, показывающий разработку от простого к сложному, с разбором различных альтернатив решений.
Так-то все эти синтаксические конструкции: блоки, переменные, циклы - в терраформе, который является идейным вдохновителем этого направления в индустрии, реализованы, а для создания ишью в джире есть готовый провайдер https://github.com/fourplusone/terraform-provider-jira .
alexandrtumaykin Автор
Верно. Без практического применения смысл потерялся бы.
А так, подвернулась идея для практического применения, на чем можно было научиться парсить HCL и немного подкачать знания по Go.
За ссылку спасибо ????