Как стать автором
Обновить

Использование HCL-конфигурации на примере создания задач в Jira. #2

Время на прочтение13 мин
Количество просмотров1.1K

Часть 1

Сразу оговорюсь, что основная цель статей - пополнить копилку знаний о том, как начать работать с описанием в формате 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 до актуальной версии"
    ...
  }
}

Продолжение

Теги:
Хабы:
Всего голосов 4: ↑1 и ↓3-2
Комментарии4

Публикации

Истории

Работа

Go разработчик
118 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань