Продвинутая helm-шаблонизация: выжимаем максимум

Helm promotion strategies

A Helm chart (like a Docker image) should be promoted between environments. It should start with testing and staging environments and gradually move to production ones.

Single repository with multiple environments

This is the most basic deployment workflow. You have a single Helm chart (which is exactly the same across all environments).
It is deployed to multiple targets using a different set of values.

Deploy to multiple environments with Helm

Codefresh has several ways to override the values for each environment within a .

Chart promotion between environments

This is the recommended deployment workflow. Codefresh can store different Helm values per environment in the mechanism.
Then you view and manage releases from the Helm environments dashboard.

Helm Environment Dashboard

Then once you promote a Helm release either from the GUI, or the pipeline you can select exactly which configuration set of parameters you want to use:

Changing deployment values

This workflow has two big advantages:

  1. You get a visual overview on what and where each Helm release is installed on
  2. You can promote releases without running the initial CI/CD pipeline (that created the chart)

Chart promotion between repositories and environments

A more advanced workflow (useful in organizations with multi-location deployments) is the promotion of Helm releases between both repositories and environments.

Advanced Helm promotion

There are different pipelines for:

  1. Creating the Helm chart and storing it to a staging Helm repository (i.e. the Codefresh Helm repository)
  2. Deployment of the Helm chart to a staging environment. After it is tested the chart is promoted to one or more “production” Helm repositories
  3. Deployment of the promoted Helm chart happens to one of the production environments

While this workflow is very flexible, it adds complexity on the number of Helm charts available (since they exist in multiple Helm repositories). You also need to set up the parameters between the different pipelines so that Helm charts to be deployed can be indeed found in the expected Helm repository.

Use the lookup Function to Avoid Secret Regeneration

Helm functions are used to generate random data, such as passwords, keys, and certificates. Random generation creates new arbitrary values and updates the resources in the cluster with each deployment and upgrade. For example, it can replace your database password in the cluster with every version upgrade. This causes the clients to be unable to connect to the database after the password change.

To address this, it is recommended to randomly generate values and override those already in the cluster. For example:

{{- $rootPasswordValue = (randAlpha 16) | b64enc | quote }}

{{- $secret = (lookup "v1" "Secret" .Release.Namespace "db-keys") }}

{{- if $secret }}

{{- $rootPasswordValue = index $secret.data "root-password" }}

{{- end -}}

apiVersion v1

kind Secret


  name db-keys

  namespace{{ .Release.Namespace }}

type Opaque


  root-password{{ $rootPasswordValue}}

The template above first creates a 16-character randAlpha value, then checks the cluster for a secret and its corresponding field. If found, it overrides and reuses the rootPasswordValue as root-password.

Helm pipelines

With the basics out of the way, we can now see some typical Helm usage patterns. Depending on the size of your company and your level of involvement with Helm you need to decide which practice is best for you.

Deploy from an un-packaged chart

This is the simplest pipeline for Helm. The Helm chart is in the same git repository as the source code of the application.

Using Helm without a Helm repository

The steps are the following:

  1. Code/Dockerfile/Chart is checked out from Git
  2. Docker image is built (and pushed to )
  3. Chart is to a Kubernetes Cluster

Notice that in this pipeline there is no Helm repository involved.

Package/push and then deploy

This is the recommended approach when using Helm. First, you package and push the Helm chart in a repository and then you deploy it to your cluster. This way your Helm repository shows a registry of the applications that run on your cluster. You can also re-use the charts to deploy to other environments (described later in this page).

Basic Helm application pipeline

The Helm chart can be either in the same GIT repository as the source code (as shown above) or in a different one.
Note that this workflow assumes that you configuration in the pipeline.

If you use the Codefresh Helm repository you can see all your releases from the Codefresh UI.

Helm application catalog

This approach allows you also to reuse Helm charts. After you publish a Helm chart, in the Helm repository you can deploy it to another environment (with a pipeline or manually) using different values.

Separate Helm pipelines

Even though packaging and deploying a release in a single pipeline is the recommended approach, several companies have two different processes for packaging and releasing.

In this case, you can create two pipelines. One that packages the Helm chart and uploads it to a Helm repository and another one that deploys to a cluster from the Helm chart.

Push and deploy in different pipelines

While this approach offers flexible releases (as one can choose exactly what is released and what is not), it also raises the complexity of deployments. You need to pass parameters on the deployment pipeline to decide which chart version will be deployed.

In Codefresh you can also have the two pipelines automatically .

Using Helm rollbacks

Helm has the native capability of a release to any previous revision. This can be done
manually or via the .

A more advanced usage would be to automatically rollback a release if it “fails”.

Automatic Helm rollback

In the example pipeline above, after deployment, we run some smoke tests/health checks. If they fail,
then the rollback step is executed using pipeline conditionals.

Alternatively, you can run any other freestyle step after a deployment such as health checks, metric collection, load testing, etc. that decides if a deployment if a Helm rollback is needed or not.

Integrating automatic Helm rollbacks can be used in all kinds of Helm workflows that were described in this section.

Notes on index.yaml

The repository index (index.yaml) is dynamically generated based on packages found in storage. If you store your own version of index.yaml, it will be completely ignored.

occurs when you run or .

If you manually add/remove a .tgz package from storage, it will be immediately reflected in .

You are no longer required to maintain your own version of index.yaml using .

The CLI option (described above) can be used to generate and print index.yaml to stdout.

Upon index regeneration, ChartMuseum will, however, save a statefile in storage called used for cache optimization. This file is only meant for internal use, but may be able to be used for migration to simple storage.

блок инструкции блок

Определите «блок», используя {% block xxx%} на материнской плате. На подстранице замените соответствующее содержимое на материнской плате, указав имя блока на материнской плате.

Меры предосторожности:

  1. {% extends’XX.html ‘%} Напишите его в первой строке, без содержания перед ним, и он будет отображаться
  2. {% extends’XX.html ‘%}’ XX.html ‘в кавычках, иначе как переменная для поиска
  3. Напишите контент, который будет отображаться в блоке блока
  4. Определите несколько блоков блоков, определите блоки css js

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


Начнем с установки Node.js handlebars.

И сразу рассмотрим пример.




С помощью метода задается настройка Node.js handlebars, конкретно в примере указывается шаблон по умолчанию, в который будут подгружаться шаблоны страниц.

Генерация и отдача представления осуществляется с помощью метода , который принимает два параметра:

  • шаблон;
  • данные для шаблона в виде объекта (если необходимо).

Если директория с шаблонами не задана явно, то поиск представлений по умолчанию будет осуществляться в директории , а макеты — в .

Шаблоны Node.js handlebars представляют собой обычные файлы HTML в формате handlebars, в которых с помощью специального синтаксиса выводятся передаваемые данные. Для отображения значения свойства переданного объекта используется запись .

В макете запись определяет место, куда при запросе определенной страницы будет вставлено соответствующее ей представление.

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

Node.js handlebars гибкий шаблонизатор с обширным функционалом.


В handlebars предусмотрен механизм кэширования представлений в режиме . Шаблонизатор самостоятельно следит за режимом запуска приложения и управляет кэшированием. Но для этого сперва необходимо активировать кэширование с помощью Express.


В представлениях Node.js handlebars предусмотрен механизм отображения той или иной части шаблона в зависимости от определенного условия.


Для вывода данных переданного массива в Node.js шаблонизаторе handlebars предусмотрена конструкция, аналогичная работе обычного цикла.

Частичные представления

Для переиспользования повторяющейся части шаблона без ее дублирования при каждом использовании имеются частичные представления.



Вспомогательные функции

Под вспомогательными функциями в Node.js handlebars подразумеваются функции, которые могут быть вызваны прямо в представлении для обработки отображаемых данных. Такие функции могут быть определены глобально для всех шаблонов или только для одного конкретного и задаются в свойстве .

Вспомогательные функции, определенные локально в методе конкретного запроса, могут использоваться только в шаблоне, обрабатываемом этим запросом.

Если имя локально определенной вспомогательной функции совпадает с глобальной, то в представлении, где описана локальная, будет использоваться локальная.

Update Your Deployments When ConfigMaps or Secrets Change

It is common to have ConfigMaps or secrets mounted to containers. Although the deployments and container images change with new releases, the ConfigMaps or secrets do not change frequently. The following annotation makes it possible to roll out new deployments when the ConfigMap changes:

kind Deployment





        checksum/config{{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}


Any change in the ConfigMap will calculate a new and create new versions of deployment. This ensures the containers in the deployments will restart using the new ConfigMap.

Вложенное наследование шаблонов

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

организовать так:

  • файл
    base.tpl – такой же как
    и ex_main.htm:

  • файл
    child1.htm: {% extends ‘base.tpl’ %} …

  • файл
    child2.htm: {% extends ‘child1.htm’ %} …

И их дальнейшая
обработка происходит аналогичным образом.

На этих занятиях
мы с вами рассмотрели основные, базовые возможности пакета Jinja. Конечно, это
не все возможности, которыми он обладает. Например, мы совершенно не касались
темы расширения базовых классов пакета для создания более тонкого рендеринга
шаблонов, или реализации своего собственного загрузчика. Но это уже тонкости,
которые требуются далеко не в каждом проекте. В большинстве случаев
рассмотренных базовых операций Jinja вполне достаточно для использования
шаблонов разного уровня сложности в своих проектах. Кроме того, этот материал
поможет лучше понимать принцип работы таких популярных фреймворков как Flask или Django, которые
используют ту же концепцию шаблонов для страниц сайтов. Ну а для тех, кто хочет
углубиться в эту тему, могу посоветовать посмотреть официальную документацию по
пакету Jinja:

Видео по теме

Jinja2 #1: О шаблонизаторе, использование ` ` в шаблонах

Jinja2 #2: Экранирование и блоки raw, for, if

Jinja2 #3: Фильтры и макросы macro, call

Jinja2 #4: Загрузчики шаблонов — FileSystemLoader, PackageLoader, DictLoader, FunctionLoader

Jinja2 #5: Конструкции include и import

Jinja2 #6: Наследование расширение шаблонов

Конфигурация зависимостей

Опишем формат зависимостей в файле .

  • — имя чарта, которое должно совпадать с именем (параметр ) в файле Chart.yaml соответствующего чарта — зависимости.
  • — версия чарта согласно схеме семантического версионирования, либо диапазон версий.
  • — URL репозитория чартов. Helm ожидает, что добавив к URL, он получит список чартов репозитория. Значение может быть псевдонимом, который в этом случае должен начинаться с префикса или .

Файл содержит точные версии прямых зависимостей, версии зависимостей прямых зависимостей и т.д.

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

  • werf helm dependency list — проверка зависимостей и их статуса.
  • werf helm dependency update — обновление папки согласно содержимому файла .
  • werf helm dependency build — обновление согласно содержимому файла .

Все репозитории чартов, используемые в , должны быть настроены в системе. Для работы с репозиториями чартов можно использовать команды :

  • werf helm repo add — добавление репозитория чартов.
  • werf helm repo index.
  • werf helm repo list — вывод списка существующих репозиториев чартов.
  • werf helm repo remove — удаление репозитория чартов.
  • werf helm repo update — обновление локального индекса репозиториев чартов.

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

Атомарное развертывание релиза

Развертывание подготовленного релиза системы производится одной командой:

В переменных окружения передаются:

  • ENV_NAME — имя среды для развертывания

  • CHART_NAME — полное имя собранного чарта системы (включая версию)

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

Как видно, каждая среда имеет свое пространство имен, таким образом даже одноименные объекты из разных сред в рамках одного кластера не конфликтуют по именам.

Флаг —atomic говорит о том, что helm будет ожидать в течение —timeout 3m успешного развертывания всех приложений. В случае ошибки или истечения таймаута произойдет автоматический откат на предыдущий релиз.

Передача динамических values из родительского чарта в сабчарты

Если вы хотите передать values, доступные только в родительском чарте, в сабчарты, то вам поможет директива , которая имитирует (с небольшими отличиями) поведение , только вместо передачи values из сабчарта в родительский чарт она делает обратное: передает values в сабчарт из родительского чарта. Пример использования:

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

В отличие от YAML-якорей будет работать с динамически выставляемыми values (сервисные данные werf), с переданными через командную строку values ( и пр.) и с секретными values.

Также доступна альтернативная укороченная форма , которая работает только для словарей (maps):

Это будет эквивалентно следующей полной форме :

Так в корень values сабчарта будут экспортированы все ключи, найденные в словаре .


Большое внимание в шаблонах Helm должно быть уделено меткам и наименованию компонентов. Каждый созданный объект Kubernetes должен иметь уникальное имя, а связи между объектами использовать уникальные метки

Это нужно для того, чтобы при установке нескольких чартов в одном неймспейсе не возникало конфиликтов имен. Также, как следствие, это позволяет развернуть один и тот же чарт несколько раз в одном неймспейсе. Хороший подход — дать пользователю возможность настроить имена компонентов вручную, а если он не пожелает, то сгенерировать их автоматически. Для этого можно использовать уникальное в рамках неймспейса имя инсталляции .


Уникальные метки для установления связей между объектами:

вернет имя чарта (‘msvc-chart’ в нашем случае). Наименования меток могут быть произвольными, но все же следует их называть согласно рекомендациям Helm.

Имя чарта с версией:

Здесь мы складываем имя чарта с его версией, заменяем недопустимые символы, ограничиваем длину 63 символами (максимальная длина меток в Kubernetes) и обрезаем возможный суффикс. Это довольно стандартная последовательность действий с литералами в Helm. Мы будем поступать аналогично и при генерации имен Kubernetes-объектов (в этом случае ограничения на длину связаны с требованиями DNS системы).

Метки, общие для всех объектов-Kubernetes:

Итого, при имени инсталляции ‘msvc-project’, итоговый набор меток наших объектов:

Имена объектов Kubernetes

В прошлой статье мы не имели дела с объектом Kubernetes под названием ServiceAccount. Эта сущность позволяет создать специального пользователя кластера с определенными полномочиями и доступами. Причем этот пользователь не имеет ни логина, ни пароля, а только токен. Таким образом ServiceAccount в основном используется внешними системами. В нашем случае ServiceAccount будет нужен для того, чтобы в следующей части дать Jenkins возможность обновлять кластер.

Имя сервисного аккаунта будет определяться по следующим правилам:

  • Если свойство serviceAccount.create=true, то создадим сервисный аккаунт под именем serviceAccount.name. Иначе сгенерируем уникальное имя.
  • Если serviceAccount.create=false, то имя должно быть равно свойству serviceAccount.name или же ‘default’, если оно не указано.

Для реализации этой логики мы используем и полезную функцию , которая возвращает первый аргумент, если второй равен .

Имена других объектов Kubernetes будут либо заданы явно, либо задаваться с помощью префикса и имени инсталляции.

Пример для имени деплоймента шлюза:

Обновление подов при изменении конфигурационных свойств

Напомню, что секреты и другие конфигурационные параметры мы передаем в контейнеры через переменные среды. Если мы обновим инсталляцию, изменив значение секрета, то Helm пересоздаст только Kubernetes-объект этого секрета. А так как контейнерам переменные среды устанавливаются на старте, то это изменение будет ими проигнорировано. Для устранения этой проблемы все зависимые поды должны быть перезапущены. В Helm это достигается с помощью использования специальной аннотации checksum/config, содержащей контрольную сумму всех файлов, от которых зависит под. И если Helm видит, что эта сумма после обновления поменялась, то он запускает механизм обновления деплоймента.

Фрагмент определения контрольной суммы конфигурационных файлов:

Функция просто складывает произвольное количество аргументов, а возвращает путь к текущему файлу (_helpers.tpl). Операцией мы получаем исходный текст файла с секретом, затем вычисляем его хеш-сумму и записываем ее в переменную . Аналогично поступаем с ConfigMap, а затем возвращаем контрольную сумму от сложения этих двух переменных.

Внимательный читатель заметит, что в операции вторым аргументом мы передаем ‘. ‘, а при получении базового пути используем ‘\$’ перед объектом Template. В Helm под ‘. ‘ подразумевается текущий контекст (scope), а ‘\$’ — объект, ссылающийся на глобальный контекст. В нашем случае это не принципиально, так как мы не манипулируем контекстами. Дальнейшее обсуждение этой функциональности оставим за рамками данной статьи. Для интересующихся ссылка.

Helm variables and range

Extract of first and last 3 lines of ingress.yaml

All the content of ingress.yaml is wrapped in a big if … starting at line 1 and ending at very last line. If ingress enabled is false NO yaml content gets generated — as we want it to be.

Line 2 and 3 demonstrates how to declare Helm template variables.

Note the hyphens at ` and `

Those hyphens/dashes eat whitespace characters. {{- eats all whitespaces to the left

-}} means whitespace to the right should be consumed — including the newline — the line is entirely removed.

Extract of values.yaml

Extract of deployment.yaml :

Gets rendered as:

Note how the range loop generates the list of hosts. The quote surrounds each host with quotes.

There is also a range .Values.ingress.tls loop that loops only once. Giving this loop 3 values will demonstrate how it will range over the values.

Gets rendered as:

Варианты расширения функционала

Для разных сред, например, промышленной и тестовых, можно создать разные variables.yaml, resources.yaml и подключать в скрипте сборки соответствующий среде файл. Например так:

На самом деле, у нас как раз сделанно именно так, при этом используются разные параметры для промышленной и тестовых сред.

Можно отказаться от соответствия имен сабчартов и разворачиваемых приложений. Это может быть полезно при необходимости развернуть одно и то же приложение с разными настройками в рамках одного экземпляра системы. 

Если будут заметны проблемы с производительнсотью сборки (а пока их нет), всю логику build_chart.sh можно реализовать другими седствами не изменяя подхода и форматов шаблонов, например на python.

Все что описано в этой статье можно применить и при другом подходе к управлению версиями, когда допустимо автоматическое развертывание при выходе новой версии каждого приложения. В этом случае можно запускать CI пайплайн сборки чарта и развертывания по триггеру. А в скрипте сборки чарта поменять источник актуальных версий приложений.

Собранный чарт можно использовать для развертывания в совершенно другой среде, например, создавать отдельную среду под клиента/заказчика, которому нужна изоляция данных, либо для развертывания в инфраструктуре заказчика. Минимально достаточно для этого переопределить при деплое DNS-имена для ingress (например с помощью helm install -f …). Данные для подключения в БД и т.п. приложения получают из объектов Secret, которые, как я упоминал выше, управляются отдельно от чарта.


As mentioned earlier, a Helm chart consists of metadata that is used to help describe what the application is, define constraints on the minimum required Kubernetes and/or Helm version and manage the version of your chart. All of this metadata lives in the Chart.yaml file. The Helm documentation describes the different fields for this file.

Step 2: Deploy your first chart

The chart you generated in the previous step is set up to run an NGINX server exposed via a Kubernetes Service. By default, the chart will create a ClusterIP type Service, so NGINX will only be exposed internally in the cluster. To access it externally, we’ll use the NodePort type instead. We can also set the name of the Helm release so we can easily refer back to it. Let’s go ahead and deploy our NGINX chart using the helm install command:

The output of helm install displays a handy summary of the state of the release, what objects were created, and the rendered NOTES.txt file to explain what to do next. Run the commands in the output to get a URL to access the NGINX service and pull it up in your browser.

If all went well, you should see the NGINX welcome page as shown above. Congratulations! You’ve just deployed your very first service packaged as a Helm chart!

Step 3: Modify chart to deploy a custom service

The generated chart creates a Deployment object designed to run an image provided by the default values. This means all we need to do to run a different service is to change the referenced image in values.yaml.

We are going to update the chart to run a todo list application available on Docker Hub. In values.yaml, update the image keys to reference the todo list image:

As you develop your chart, it’s a good idea to run it through the linter to ensure you’re following best practices and that your templates are well-formed. Run the helm lint command to see the linter in action:

The linter didn’t complain about any major issues with the chart, so we’re good to go. However, as an example, here is what the linter might output if you managed to get something wrong:

This time, the linter tells us that it was unable to parse my values.yaml file correctly. With the line number hint, we can easily find the fix the bug we introduced.

Now that the chart is once again valid, run helm install again to deploy the todo list application:

Once again, we can run the commands in the NOTES to get a URL to access our application.

If you have already built containers for your applications, you can run them with your chart by updating the default values or the Deployment template. Check out the Bitnami Docs for an introduction to containerizing your applications.

Step 4: Package it all up to share

So far in this tutorial, we’ve been using the helm install command to install a local, unpacked chart. However, if you are looking to share your charts with your team or the community, your consumers will typically install the charts from a tar package. We can use helm package to create the tar package:

Helm will create a mychart-0.1.0.tgz package in our working directory, using the name and version from the metadata defined in the Chart.yaml file. A user can install from this package instead of a local directory by passing the package as the parameter to helm install.

Трёхуровневое наследование¶

Один из способов использовать наследование — трёхуровневый подход. Этот
метод замечательно работает с тремя различными типами шаблонов, которые
мы уже рассмотрели:

  • Создайте файл , который содержит
    базовую разметку приложения (как в предыдущем примере). Внутри приложения
    такой шаблон называется ;

  • Создайте файл для каждой секции сайта. Например будет содержать
    шаблон , который включает только элементы
    специфичные для блога;

  • Создайте шаблоны для каждой страницы и унаследуйте из от шаблона соответствующей
    секции (пакета). Например, страница “index” будет вызывать что-то типа
    и отображать записи блога:

Обратите внимание, что этот шаблон наследуется от шаблона секции
, который, в свою очередь, наследуется
от базового шаблона приложения (). Это и есть типичное
трёхуровневое наследование

Работа с Helm

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

Если менять кластер через kubectl, то Helm не увидит изменений, и это чревато ошибками.

Установка чарта

В процессе разработки чарта бывает полезно проверить его на валидность, а также посмотреть на получившиеся Kubernetes-объекты:

После того, как чарт написан, можно развернуть наше приложение, создав инсталляцию под именем :

Чтобы настроить инсталляцию и заменить значения из values.yaml, можно использовать опции . Этот параметр может быть указан несколько раз. Если переопределяемых свойств много, то часто более удобно описать все свойства в yaml-файле и применить его опцией . Параметр позволяет задать неймспейс, в который будет установлен чарт.

Обновление инсталляции

Обновить инсталляцию:

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

Чтобы это обойти, можно устанавливать чарт, определив какое-нибудь фиктивное свойство, например, так:

Получение информации об инсталляциях

— выведет все установленные Helm’ом приложения. — информация о состоянии конкретной инсталляции. — история изменений инсталляции. При каждом изменении параметров или модификации самого чарта создается новая ревизия. В случае чего всегда существует возможность откатиться до определенной ревизии. — текущие переопределенные свойства инсталляции. Задав параметр , можно посмотреть эти свойства для n-ной ревизии. — выведет все доступные опции для чарта. По факту напечатает файл values.yaml. — отобразит созданные Helm’ом объекты для определенной инсталляции.

( Пока оценок нет )
Понравилась статья? Поделиться с друзьями:
Мой редактор ОС
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: