Как стать автором
Обновить
Флант
DevOps-as-a-Service, Kubernetes, обслуживание 24×7

Создание пакетов для Kubernetes с Helm: структура чарта и шаблонизация

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


Про Helm и работу с ним «в общем» мы рассказали в прошлой статье. Теперь подойдём к практике с другой стороны — с точки зрения создателя чартов (т.е. пакетов для Helm). И хотя эта статья пришла из мира эксплуатации, она получилась больше похожей на материалы о языках программирования — такова уж участь авторов чартов. Итак, чарт — это набор файлов…

Файлы чарта можно разделить на две группы:

  1. Файлы, необходимые для генерации манифестов Kubernetes-ресурсов. К ним относятся шаблоны из директории templates и файлы со значениями (значения по умолчанию хранятся в values.yaml). Также к данной группе относятся файл requirements.yaml и директория charts — всё это используется для организации вложенных чартов.
  2. Сопроводительные файлы, содержащие информацию, которая может быть полезна при поиске чартов, знакомстве с ними и их использовании. Большая часть файлов этой группы является необязательной.

Подробнее о файлах обеих групп:

  • Chart.yaml — файл с информацией о чарте;
  • LICENSE — необязательный текстовый файл с лицензией чарта;
  • README.md — необязательный файл с документацией;
  • requirements.yaml — необязательный файл со списком чартов-зависимостей;
  • values.yaml — файл со значениями по умолчанию для шаблонов;
  • charts/ — необязательная директория со вложенными чартами;
  • templates/ — директория с шаблонами манифестов Kubernetes-ресурсов;
  • templates/NOTES.txt — необязательный текстовый файл с примечанием, которое выводится пользователю при инсталяции и обновлении.

Чтобы лучше разобраться в содержимом этих файлов, можно обратиться к официальному руководству разработчика чарта или поискать соответствующие примеры в официальном репозитории.

Создание чарта по большому счёту сводится к организации правильно оформленного набора файлов. И главная сложность в этом «оформлении» — использование достаточно продвинутой системы шаблонов для достижения нужного результата. Для рендера манифестов Kubernetes-ресурсов используется стандартный Go-шаблонизатор, расширенный функциями Helm.

Напоминание: Разработчики Helm анонсировали, что в следующей крупной версии проекта — Helm 3 — появится поддержка Lua-скриптов, которые можно будет использовать одновременно с Go-шаблонами. Останавливаться подробнее на этом моменте не буду — об этом (и других изменениях в Helm 3) можно почитать здесь.

К примеру, вот так в Helm 2 выглядит шаблон Kubernetes-манифеста Deployment'а блога на WordPress из прошлой статьи:

deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: {{ template "fullname" . }}
  labels:
    app: {{ template "fullname" . }}
    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
    release: "{{ .Release.Name }}"
    heritage: "{{ .Release.Service }}"
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    metadata:
      labels:
        app: {{ template "fullname" . }}
        chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
        release: "{{ .Release.Name }}"
    spec:
      {{- if .Values.image.pullSecrets }}
      imagePullSecrets:
      {{- range .Values.image.pullSecrets }}
        - name: {{ . }}
      {{- end}}
      {{- end }}
      containers:
      - name: {{ template "fullname" . }}
        image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}"
        imagePullPolicy: {{ .Values.image.pullPolicy | quote }}
        env:
        - name: ALLOW_EMPTY_PASSWORD
        {{- if .Values.allowEmptyPassword }}
          value: "yes"
        {{- else }}
          value: "no"
        {{- end }}
        - name: MARIADB_HOST
        {{- if .Values.mariadb.enabled }}
          value: {{ template "mariadb.fullname" . }}
        {{- else }}
          value: {{ .Values.externalDatabase.host | quote }}
        {{- end }}
        - name: MARIADB_PORT_NUMBER
        {{- if .Values.mariadb.enabled }}
          value: "3306"
        {{- else }}
          value: {{ .Values.externalDatabase.port | quote }}
        {{- end }}
        - name: WORDPRESS_DATABASE_NAME
        {{- if .Values.mariadb.enabled }}
          value: {{ .Values.mariadb.db.name | quote }}
        {{- else }}
          value: {{ .Values.externalDatabase.database | quote }}
        {{- end }}
        - name: WORDPRESS_DATABASE_USER
        {{- if .Values.mariadb.enabled }}
          value: {{ .Values.mariadb.db.user | quote }}
        {{- else }}
          value: {{ .Values.externalDatabase.user | quote }}
        {{- end }}
        - name: WORDPRESS_DATABASE_PASSWORD
          valueFrom:
            secretKeyRef:
            {{- if .Values.mariadb.enabled }}
              name: {{ template "mariadb.fullname" . }}
              key: mariadb-password
            {{- else }}
              name: {{ printf "%s-%s" .Release.Name "externaldb" }}
              key: db-password
            {{- end }}
        - name: WORDPRESS_USERNAME
          value: {{ .Values.wordpressUsername | quote }}
        - name: WORDPRESS_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ template "fullname" . }}
              key: wordpress-password
        - name: WORDPRESS_EMAIL
          value: {{ .Values.wordpressEmail | quote }}
        - name: WORDPRESS_FIRST_NAME
          value: {{ .Values.wordpressFirstName | quote }}
        - name: WORDPRESS_LAST_NAME
          value: {{ .Values.wordpressLastName | quote }}
        - name: WORDPRESS_BLOG_NAME
          value: {{ .Values.wordpressBlogName | quote }}
        - name: WORDPRESS_TABLE_PREFIX
          value: {{ .Values.wordpressTablePrefix | quote }}
        - name: SMTP_HOST
          value: {{ .Values.smtpHost | quote }}
        - name: SMTP_PORT
          value: {{ .Values.smtpPort | quote }}
        - name: SMTP_USER
          value: {{ .Values.smtpUser | quote }}
        - name: SMTP_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ template "fullname" . }}
              key: smtp-password
        - name: SMTP_USERNAME
          value: {{ .Values.smtpUsername | quote }}
        - name: SMTP_PROTOCOL
          value: {{ .Values.smtpProtocol | quote }}
        ports:
        - name: http
          containerPort: 80
        - name: https
          containerPort: 443
        livenessProbe:
          httpGet:
            path: /wp-login.php
          {{- if not .Values.healthcheckHttps }}
            port: http
          {{- else }}
            port: https
            scheme: HTTPS
          {{- end }}
{{ toYaml .Values.livenessProbe | indent 10 }}
        readinessProbe:
          httpGet:
            path: /wp-login.php
          {{- if not .Values.healthcheckHttps }}
            port: http
          {{- else }}
            port: https
            scheme: HTTPS
          {{- end }}
{{ toYaml .Values.readinessProbe | indent 10 }}
        volumeMounts:
        - mountPath: /bitnami/apache
          name: wordpress-data
          subPath: apache
        - mountPath: /bitnami/wordpress
          name: wordpress-data
          subPath: wordpress
        - mountPath: /bitnami/php
          name: wordpress-data
          subPath: php
        resources:
{{ toYaml .Values.resources | indent 10 }}
      volumes:
      - name: wordpress-data
      {{- if .Values.persistence.enabled }}
        persistentVolumeClaim:
          claimName: {{ .Values.persistence.existingClaim | default (include "fullname" .) }}
      {{- else }}
        emptyDir: {}
      {{ end }}
    {{- if .Values.nodeSelector }}
      nodeSelector:
{{ toYaml .Values.nodeSelector | indent 8 }}
      {{- end -}}
    {{- with .Values.affinity }}
      affinity:
{{ toYaml . | indent 8 }}
    {{- end }}
    {{- with .Values.tolerations }}
      tolerations:
{{ toYaml . | indent 8 }}
    {{- end }}

Теперь — об основных принципах и особенностях шаблонизации в Helm. Большая часть приведённых ниже примеров взята из чартов официального репозитория.

Шаблонизация


Шаблоны: {{ }}


Всё, что связано с шаблонизацией, оборачивается в двойные фигурные скобки. Текст вне фигурных скобок при рендере остаётся неизменным.

Значение контекста: .


При рендере файла или partial'а (подробнее о переиспользовании шаблонов рассказывается в следующих разделах статьи) прокидывается значение, которое становится доступным внутри через переменную контекста — точку. При передаче в качестве аргумента структуры точка используется для доступа к полям и методам этой структуры.

Значение переменной изменяется в процессе рендера в зависимости от контекста, в котором она используется. Большинство блочных операторов переопределяет переменную контекста внутри основного блока. Основные операторы и их особенности будут рассмотрены ниже, после знакомства с базовой структурой Helm.

Базовая структура Helm


При рендере манифестов в шаблоны прокидывается структура со следующими полями:

  • Поле .Values — для доступа к параметрам, которые определяются при инсталяции и обновлении релиза. К ним относятся значения опций --set, --set-string и --set-file, а также параметры файлов со значeниями, файл values.yaml и файлы, соответствующие значениям опций --values:

    containers:
    - name: main
      image: "{{ .Values.image }}:{{ .Values.imageTag }}"
      imagePullPolicy: {{ .Values.imagePullPolicy }}
    
  • .Release — для использования данных релиза о выкате, инсталяции или обновлении, имени релиза, namespace и значений ещё нескольких полей, которые могут пригодиться при генерации манифестов:

    metadata:
      labels:
        heritage: "{{ .Release.Service }}"
        release: "{{ .Release.Name }}"
    subjects:
    - namespace: {{ .Release.Namespace }}
    
  • .Chart — для доступа к информации о чарте. Поля соответствуют содержимому файла Chart.yaml:

    labels:
      chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
    
  • Структура .Files — для работы с хранящимися в директории чарта файлами; со структурой и доступными методами можно ознакомиться по ссылке. Примеры:

    data:
      openssl.conf: |
    {{ .Files.Get "config/openssl.conf" | indent 4 }}
    

    data:
    {{ (.Files.Glob "files/docker-entrypoint-initdb.d/*").AsConfig | indent 2 }}
    
  • .Capabilities — для доступа к информации о кластере, в котором выполняется выкат:

    {{- if .Capabilities.APIVersions.Has "apps/v1beta2" }}
    apiVersion: apps/v1beta2
    {{- else }}
    apiVersion: extensions/v1beta1
    {{- end }}
    

    {{- if semverCompare "^1.9-0" .Capabilities.KubeVersion.GitVersion }}
    apiVersion: apps/v1
    {{- else }}
    

Операторы


Начнём, конечно, с операторов if, else if и else:

{{- if .Values.agent.image.tag }}
image: "{{ .Values.agent.image.repository }}:{{ .Values.agent.image.tag }}"
{{- else }}
image: "{{ .Values.agent.image.repository }}:v{{ .Chart.AppVersion }}"
{{- end }}

Оператор range предназначен для работы с массивами и картами. Если в качестве аргумента передаётся массив и он содержит элементы, то для каждого элемента последовательно выполняется блок (при этом значение внутри блока становится доступным через переменную контекста):

{{- range .Values.ports }}
- name: {{ .name }}
  port: {{ .containerPort }}
  targetPort: {{ .containerPort}}
{{- else }}
...
{{- end}}

{{ range .Values.tolerations -}}
- {{ toYaml . | indent 8 | trim }}
{{ end }}

Для работы с картами предусмотрен синтаксис с переменными:

{{- range $key, $value := .Values.credentials.secretContents }}
  {{ $key }}: {{ $value | b64enc | quote }}
{{- end }}

Похожее поведение — у оператора with: eсли переданный аргумент существует, то выполняется блок, а переменная контекста в блоке соответствует значению аргумента. Например:

{{- with .config }}
  config:
  {{- with .region }}
    region: {{ . }}
  {{- end }}
  {{- with .s3ForcePathStyle }}
    s3ForcePathStyle: {{ . }}
  {{- end }}
  {{- with .s3Url }}
    s3Url: {{ . }}
  {{- end }}
  {{- with .kmsKeyId }}
    kmsKeyId: {{ . }}
  {{- end }}
{{- end }}

Для переиспользования шаблонов может быть задействована связка из define [name] и template [name] [variable], где переданное значение становится доступным через переменную контекста в блоке define:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ template "kiam.serviceAccountName.agent" . }}
...
{{- define "kiam.serviceAccountName.agent" -}}
{{- if .Values.serviceAccounts.agent.create -}}
  {{ default (include "kiam.agent.fullname" .) .Values.serviceAccounts.agent.name }}
{{- else -}}
  {{ default "default" .Values.serviceAccounts.agent.name }}
{{- end -}}
{{- end -}}

Пара особенностей, которые стоит учитывать при использовании define, или, проще говоря, partial'ов:

  • Объявленные partial'ы являются глобальными и могут использоваться во всех файлах директории templates.
  • Основной чарт компилируется вместе с зависимыми чартами, поэтому при существовании двух одноимённых partial'ов будет использоваться последний загруженный. При именовании partial'а принято добавлять имя чарта для избежания подобных конфликтов: define "chart_name.partial_name".

Переменные: $


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

{{ $provider := .Values.configuration.backupStorageProvider.name }}
...
{{ if eq $provider "azure" }}
envFrom:
- secretRef:
    name: {{ template "ark.secretName" . }}
{{ end }}

При рендере файла или partial'а $ имеет такое же значение, что и точка. Но в отличие от переменной контекста (точки), значение $ не изменяется в контексте блочных операторов, что позволяет одновременно работать со значением контекста блочного оператора и базовой структурой Helm (или значением, переданным в partial, если говорить об использовании $ внутри partial'а). Иллюстрация отличия:

context: {{ . }}
dollar: {{ $ }}
with: 
{{- with .Chart }}
  context: {{ . }}
  dollar: {{ $ }}
{{- end }}

template:
{{- template "flant" .Chart -}}

{{ define "flant" }}
  context: {{ . }}
  dollar: {{ $ }}
  with: 
  {{- with .Name }}
    context: {{ . }}
    dollar: {{ $ }}
  {{- end }}
{{- end -}}

В результате обработки этого шаблона получится следующее (для наглядности в выводе структуры заменены на соответствующие псевдоимена):

context: #Базовая структура helm
dollar: #Базовая структура helm
with:
  context: #.Chart
  dollar: #Базовая структура helm

template:
  context: #.Chart
  dollar: #.Chart
  with:
    context: habr
    dollar: #.Chart

А вот реальный пример использования данной особенности:

{{- if .Values.ingress.enabled -}}
{{- range .Values.ingress.hosts }}
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: {{ template "nats.fullname" $ }}-monitoring
  labels:
    app: "{{ template "nats.name" $ }}"
    chart: "{{ template "nats.chart" $ }}"
    release: {{ $.Release.Name | quote }}
    heritage: {{ $.Release.Service | quote }}
  annotations:
    {{- if .tls }}
    ingress.kubernetes.io/secure-backends: "true"
    {{- end }}
    {{- range $key, $value := .annotations }}
    {{ $key }}: {{ $value | quote }}
    {{- end }}
spec:
  rules:
    - host: {{ .name }}
      http:
        paths:
        - path: {{ default "/" .path }}
          backend:
            serviceName: {{ template "nats.fullname" $ }}-monitoring
            servicePort: monitoring
{{- if .tls }}
  tls:
  - hosts:
    - {{ .name }}
    secretName: {{ .tlsSecret }}
{{- end }}
---
{{- end }}
{{- end }}

Отступы


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

  • {{- variable }} обрезает предшествующие пробелы;
  • {{ variable -}} обрезает последующие пробелы;
  • {{- variable -}} — оба варианта.

Пример файла, результатом обработки которого будет строка habr flant helm:

habr
{{- " flant " -}}
helm

Встроенные функции


Со всеми функциями, встроенными в шаблон, можно ознакомиться по следующей ссылке. Здесь же я расскажу только о некоторых из них.

Функция index предназначена для доступа к элементам массива или карт:

definitions.json: |
    {
      "users": [
        {
          "name": "{{ index .Values "rabbitmq-ha" "rabbitmqUsername" }}",
          "password": "{{ index .Values "rabbitmq-ha" "rabbitmqPassword" }}",
          "tags": "administrator"
        }
      ]
    }

Функция принимает произвольное количество аргументов, что позволяет работать с вложенными элементами:

$map["key1"]["key2"]["key3"] => index $map "key1" "key2" "key3"

Например:

httpGet:
{{- if (index .Values "pushgateway" "extraArgs" "web.route-prefix") }}
  path: /{{ index .Values "pushgateway" "extraArgs" "web.route-prefix" }}/#/status
{{- end }}

Булевые операции реализованы в шаблонизаторе как функции (а не как операторы). Все аргументы для них вычисляются при передаче:

{{ if and (index .Values field) (eq (len .Values.field) 10) }}
...
{{ end }}

При отсутствии поля field рендер шаблона завершится с ошибкой (error calling len: len of untyped nil): второе условие проверяется, несмотря на то, что первое не выполнилось. Стоит взять это на заметку, а подобные запросы решать за счёт разбиения на несколько проверок:

{{ if index . field }}
  {{ if eq (len .field) 10 }}
  ...
  {{ end }}
{{ end }}

Pipeline — это уникальная функция Go-шаблонов, позволяющая объявлять выражения, которые выполняются подобно конвейеру в shell. Формально конвейер представляет собой цепочку команд, разделенных символом |. Команда может быть простым значением или вызовом функции. Результат каждой команды передаётся в качестве последнего аргумента следующей команде, а результатом конечной команды в конвейере является значение всего конвейера. Примеры:

data:
  openssl.conf: |
{{ .Files.Get "config/openssl.conf" | indent 4 }}

data:
  db-password: {{ .Values.externalDatabase.password | b64enc | quote }}

Дополнительные функции


Sprig — библиотека, состоящая из 70 полезных функций для решения широкого спектра задач. Из соображений безопасности в Helm исключены функции env и expandenv, которые предоставляли бы доступ к переменным окружения Tiller.

Функция include, как и стандартная функция template, используется для переиспользования шаблонов. В отличие от template, функцию можно использовать в pipeline, т.е. передавать результат в другую функцию:

metadata:
  labels:
{{ include "labels.standard" . | indent 4 }}

{{- define "labels.standard" -}}
app: {{ include "hlf-couchdb.name" . }}
heritage: {{ .Release.Service | quote }}
release: {{ .Release.Name | quote }}
chart: {{ include "hlf-couchdb.chart" . }}
{{- end -}}

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

sftp-user: {{ required "Please specify the SFTP user name at .Values.sftp.user" .Values.sftp.user | b64enc | quote }}
sftp-password: {{ required "Please specify the SFTP user password at .Values.sftp.password" .Values.sftp.password | b64enc | quote }}
{{- end }}
{{- if .Values.svn.enabled }}
svn-user: {{ required "Please specify the SVN user name at .Values.svn.user" .Values.svn.user | b64enc | quote }}
svn-password: {{ required "Please specify the SVN user password at .Values.svn.password" .Values.svn.password | b64enc | quote }}
{{- end }}
{{- if .Values.webdav.enabled }}
webdav-user: {{ required "Please specify the WebDAV user name at .Values.webdav.user" .Values.webdav.user | b64enc | quote }}
webdav-password: {{ required "Please specify the WebDAV user password at .Values.webdav.password" .Values.webdav.password | b64enc | quote }}
{{- end }}

Функция tpl позволяет рендерить строку как шаблон. В отличие от template и include, функция позволяет выполнять шаблоны, которые передаются в переменных, а также рендерить шаблоны, хранящиеся не только в директории templates. Как это выглядит?

Выполнение шаблонов из переменных:

containers:
{{- with .Values.keycloak.extraContainers }}
{{ tpl . $ | indent 2 }}
{{- end }}

… а в values.yaml имеем следующее значение:

keycloak:
  extraContainers: |
    - name: cloudsql-proxy
      image: gcr.io/cloudsql-docker/gce-proxy:1.11
      command:
        - /cloud_sql_proxy
      args:
        - -instances={{ .Values.cloudsql.project }}:{{ .Values.cloudsql.region }}:{{ .Values.cloudsql.instance }}=tcp:5432
        - -credential_file=/secrets/cloudsql/credentials.json
      volumeMounts:
        - name: cloudsql-creds
          mountPath: /secrets/cloudsql
          readOnly: true

Рендер файла, хранящегося вне директории templates:

apiVersion: batch/v1
kind: Job
metadata:
  name: {{ template "mysqldump.fullname" . }}
  labels:
    app: {{ template "mysqldump.name" . }}
    chart: {{ template "mysqldump.chart" . }}
    release: "{{ .Release.Name }}"
    heritage: "{{ .Release.Service }}"
spec:
  backoffLimit: 1
  template:
{{ $file := .Files.Get "files/job.tpl" }}
{{ tpl $file . | indent 4 }}

… в чарте, по пути files/job.tpl, имеется следующий шаблон:

spec:
  containers:
  - name: xtrabackup
    image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
    imagePullPolicy: {{ .Values.image.pullPolicy | quote }}
    command: ["/bin/bash", "/scripts/backup.sh"]
    envFrom:
    - configMapRef:
        name: "{{ template "mysqldump.fullname" . }}"
    - secretRef:
        name: "{{ template "mysqldump.fullname" . }}"
    volumeMounts:
    - name: backups
      mountPath: /backup
    - name: xtrabackup-script
      mountPath: /scripts
  restartPolicy: Never
  volumes:
  - name: backups
{{- if .Values.persistentVolumeClaim }}
    persistentVolumeClaim:
      claimName: {{ .Values.persistentVolumeClaim }}
{{- else -}}
{{- if .Values.persistence.enabled }}
    persistentVolumeClaim:
      claimName: {{ template "mysqldump.fullname" . }}
{{- else }}
    emptyDir: {}
{{- end }}
{{- end }}
  - name: xtrabackup-script
    configMap:
      name: {{ template "mysqldump.fullname" . }}-script

На этом знакомство с азами шаблонизации в Helm подошло к концу…

Заключение


В статье рассказано о структуре Helm-чартов и подробно разобрана главная сложность в их создании — шаблонизация: основные принципы, синтаксис, функции и операторы Go-шаблонизатора, дополнительные функции.

Как начать со всем этим работать? Поскольку Helm — это уже целая экосистема, всегда можно посмотреть на примеры чартов схожих пакетов. Например, если вы хотите запаковать новый message queue, взгляните на публичный чарт RabbitMQ. Конечно, никто не обещает вам идеальных реализаций в уже существующих пакетах, однако они отлично подойдут как отправная точка. Остальное же приходит с практикой, в которой вам помогут команды отладки helm template и helm lint, а также запуск инсталяции с опцией --dry-run.

Для получения более обширного представления о разработке Helm-чартов, лучших практиках и используемых технологиях предлагаю ознакомиться с материалами по следующим ссылкам (все на английском языке):


А в конце очередного материала про Helm прикрепляю опрос, который поможет лучше понять, какие ещё статьи о Helm ждут (или не ждут?) читатели Хабра. Спасибо за внимание!

P.S.


Читайте также в нашем блоге:

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Что ещё вы бы хотели почитать про Helm?
28.57% Хуки в Helm 8
35.71% Управление зависимостями, организация вложенных чартов 10
25% Разработка комплексного чарта 7
53.57% Лучшие практики c Helm 15
50% Освещение негативных сторон Helm 14
21.43% Сравнение Helm с kubectl 6
10.71% Как мы способствуем развитию Helm, какие задачи решаем и с какими проблемами сталкиваемся 3
39.29% Использование dapp для выката: какие преимущества перед обычным Helm? 11
Проголосовали 28 пользователей. Воздержались 6 пользователей.
Теги:
Хабы:
+35
Комментарии 4
Комментарии Комментарии 4

Публикации

Информация

Сайт
flant.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Тимур Тукаев