Команда Billing API SDK в Netcracker разрабатывает инструменты для продукта Cloud Billing. Мы занимаемся контролем версий, жизненным циклом API, конфигурацией и запуском сервера REST/gRPC, аутентификацией и авторизацией, аудитом, трассировкой и многими другими вещами на языке Go.

Давайте поговорим о файлах конфигурации!

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

Что делать? Если не менять формат конфигурации, со временем это превратится в кучу вещей, с которыми «нужно разобраться по историческим причинам». А если изменить… В этом случае всегда нужно проверять, совместимы ли конфигурационные файлы с той версией продукта, которую вы устанавливаете для заказчика. Операционной группе, клиентам и многим другим это не очень понравится.

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

Несколько слов о ступично-спицевой модели

Модель hub-spoke изначально представляла собой архитектурный образец сетевой топологии, который относился не только к компьютерной сети, но и, например, к транспортной. Однако этот шаблон также нашел хорошее применение в управлении версиями объектов. Таким образом, Kubernetes использует его для управления версиями своих API и ресурсов.

Модель ступицы предполагает, что существует «концентраторная» версия некоторого объекта, которая не может использоваться пользователями напрямую, но связана с другими версиями луча. Версия хаба — это текущая версия, используемая в нашем коде. При изменении структуры конфигурации новая «лучевая» версия отделяется от концентратора. Эта версия идентична версии концентратора. Чтобы обеспечить совместимость версий, мы должны иметь возможность конвертировать любую версию Spoke в версию Hub и наоборот.

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

· определить версию конфигурации;

· конвертировать конфиг из «спицевой» в «хабовую» версию;

· использовать «хабовую» версию конфига в нашем коде.

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

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

Добавление контроля версий в конфигурацию

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

roles:
- name: "account_ro"
rules:
- verbs: [ "Get", "List" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "customer", "account", "address" ]
- name: "account_rw"
rules:
- verbs: [ "Get", "List", "Create", "Update" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "customer", "account", "address" ]
- name: "dev"
rules:
- verbs: [ "Get", "List" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "*" ]

Для генерации функций преобразования между версиями этого конфига мы будем использовать библиотеку Kubernetes apimachinery и генераторы кода Go convert-gen, deepcopy-gen и defaulter-gen из kubernetes/code-generator.

Чтобы инструменты Kubernetes могли идентифицировать нашу конфигурацию, преобразуйте ее в вид, подобный Kubernetes:

apiVersion: config.billing.netcracker.com/v1alpha1
kind: RolesConfig
spec:
roles:
- name: "account_ro"
rules:
- verbs: [ "Get", "List" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "customer", "account", "address" ]
- name: "account_rw"
rules:
- verbs: [ "Get", "List", "Create", "Update" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "customer", "account", "address" ]
- name: "dev"
rules:
- verbs: [ "Get", "List" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "*" ]

Где

· apiVersion определяет версию конфигурации. Он состоит из группы API (`config.billing.netcracker.com`) и имени распределенной версии (`v1alpha1`);

· kind — идентификатор конфига;

· spec содержит сам контент конфигурации.

Чтобы работать с этой конфигурацией в коде, добавим структуры Go, которые будут хранить данные конфигурации. Для этого мы создадим в нашем проекте два пакета для узловых и распределенных версий. Это выглядит так:

├── концентратор

├── v1alpha1

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

Добавьте файл types.go в пакет hub. Файл должен содержать:

package hub
import meta "k8s.io/apimachinery/pkg/apis/meta/v1"
// RolesConfig contains a list of roles for authorization
type RolesConfig struct {
// TypeMeta describes an individual object in an API response or request
// with strings representing the type of the object and its API schema version.
// Structures that are versioned or persisted should inline TypeMeta.
meta.TypeMeta `json:",inline"`
// ObjectMeta is metadata that all persisted resources must have,
meta.ObjectMeta `json:"metadata,omitempty"`
// RolesSpec is a list of existing RBAC Roles.
RolesSpec RolesSpec `json:"spec,omitempty"`
)
type RolesSpec struct {
Roles []Role `json:"roles,omitempty"`
)
// Role contains rules that represent a set of permissions.
type Role struct {
// Name is unique role name.
// +kubebuilder:validation:Required
Name string `json:"name,omitempty"`
// IsAnonymous identify that role is allowed for anonymous user - user.
// +kubebuilder:validation:Optional
IsAnonymous bool `json:"anonymous"`
// Rules is set of rules available for the role.
// +kubebuilder:validation:Required
Rules RuleSet `json:"rules"`
)
// RuleSet contains set of rules for a role
type RuleSet []Rule
// Rule is the list of actions the subject is allowed to perform on resources.
type Rule struct {
// +kubebuilder:validation:Required
Verbs []string `json:"verbs,omitempty"`
// +kubebuilder:validation:Required
Groups []string `json:"apiGroups,omitempty"`
// +kubebuilder:validation:Required
Kinds []string `json:"resources,omitempty"`
)

Аналогичным образом добавьте types.go в пакет v1alpha1. Поскольку у нас пока только одна версия конфигурации, хаб и v1alpha1 идентичны.

Добавьте doc.go с метаданными конфигурации в хаб-пакет:

// +k8s:deepcopy-gen=package,register
// +groupName=config.billing.netcracker.com
package hub
const (
Version = "hub"
Group   = "config.billing.netcracker.com"
Kind    = "RolesConfig"
)

Комментарий `+k8s:deepcopy-gen` указывает настройки подключаемого модуля deepcopy-gen.

· package — этот подключаемый модуль генерирует метод DeepCopy() для всех типов в types.go.

· register — регистрирует сгенерированные методы в схеме (подробнее об этом ниже).

Такой же файл требуется в пакете v1alpha1:

// +k8s:deepcopy-gen=package,register
// +k8s:conversion-gen=nrm.netcracker.cloud//billing-api-sdk/pkg/sdk/security/authorization/hub
// +groupName=config.billing.netcracker.com
package v1alpha1
const (
Version = "v1alpha1"
Group   = "config.billing.netcracker.com"
Kind    = "RolesConfig"
)

В `+k8s:conversion-gen` укажите путь к пакету концентратора.

Kubernetes использует концепцию схемы для создания функций преобразования между разными версиями. В нем прописаны структуры Go каждой версии, а также функции SetDefault и DeepCopy. Добавьте файл register.go в хаб и v1alpha1, чтобы зарегистрировать новые добавленные версии:

// +k8s:deepcopy-gen=package
// +groupName=config.billing.netcracker.com
package v1alpha1
import (
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// SchemeGroupVersion is group version used to register these objects
SchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme
SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion}
localSchemeBuilder = runtime.NewSchemeBuilder()
)
func init() {
SchemeBuilder.SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypeWithName(SchemeBuilder.GroupVersion.WithKind(Kind), &RolesConfig{})
meta.AddToGroupVersion(s, SchemeBuilder.GroupVersion)
return RegisterConversions(s)
})
)

После всех предыдущих шагов у вас должна получиться следующая файловая структура:

├── hub
│   ├── doc.go
│   ├── register.go
│   ├── types.go
├── v1alpha1
│   ├── doc.go
│   ├── register.go
│   ├── types.go

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

· «k8s.io/code-generator/cmd/conversion-gen»

· «k8s.io/code-generator/cmd/deepcopy-gen»

· «k8s.io/code-generator/cmd/defaulter-gen»

Вы можете установить каждый подключаемый модуль с помощью команды go install. Затем вызывать каждый плагин, передавая на вход пути к хабу и версии v1alpha1:

conversion-gen --input-dirs $(PATH_TO_SPOKE_CONFIG) --output-package $(ROOT_PKG) --output-file-base zz_generated.conversion --output-base $(CURDIR) --go-header-file header.go.txt -v 1
deepcopy-gen --input-dirs $(PATH_TO_SPOKE_CONFIGS),$(PATH_TO_HUB_CONFIG) --output-package $(ROOT_PKG) --output-file-base zz_generated.deepcopy --output-base $(CURDIR)       --go-header-file header.go.txt -v 1
defaulter-gen --input-dirs $(PATH_TO_SPOKE_CONFIGS) --output-package $(ROOT_PKG) --output-file-base zz_generated.default --output-base $(CURDIR) --go-header-file  header.go.txt -v 1

В результате работы плагина должна получиться следующая файловая структура:

├── hub
│   ├── doc.go
│   ├── register.go
│   ├── types.go
│   ├── zz_generated.deepcopy.go
│   ├── zz_generated.default.go
├── v1alpha1
│   ├── doc.go
│   ├── register.go
│   ├── types.go
│   ├── zz_generated.conversion.go
│   ├── zz_generated.deepcopy.go
│   ├── zz_generated.default.go

Большой! Теперь мы можем преобразовать нашу конфигурацию из версии v1alpha1 в версию хаба.

Предоставление API для загрузки конфигураций из файла

Предоставим нашим пользователям возможность загружать конфигурации из файлов в объект RolesConfig из хаба. Назовите файл config.go и поместите его рядом с пакетами hub и v1alpha1. Не требуется делать отдельную функцию загрузки для каждого типа. Поскольку все типы конфигураций прописаны в глобальной схеме и реализуют интерфейс runtime.Object, вы можете написать только одну функцию загрузки, которая будет принимать объект конфигурации и заполнять его из файла для всех типов конфигураций.

// getConfigScheme returns a new instance of runtime.Schema with registered authorization config.
func getConfigScheme() (*runtime.Scheme, error) {
scheme := runtime.NewScheme()
err := hub.SchemeBuilder.AddToScheme(scheme)
if err != nil {
return nil, err
)
err = v1alpha1.SchemeBuilder.AddToScheme(scheme)
if err != nil {
return nil, err
)
return scheme, nil
)
// LoadFromFile reads roles configuration from provided config file and converts it to hub representation.
func LoadFromFile(filePath string) (*hub.RolesConfig, error) {
// get conversion schema
scheme, err := getConfigScheme()
if err != nil {
return nil, err
)
// read file content
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("unable to read roles configuration: %w", err)
)
// unmarshal it to meta.TypeMeta to get metadata
typeMeta := &meta.TypeMeta{}
err = yaml.Unmarshal(data, typeMeta)
if err != nil {
return nil, err
)
// validate that configuration in file has the same kind
if typeMeta.Kind != hub.Kind {
return nil, fmt.Errorf("unable to read roles configuration: invalid config kind '%s', only '%s' supported", typeMeta.Kind, hub.Kind)
)
// create new spoke with same version as in config
storedObject, err := scheme.New(typeMeta.GroupVersionKind())
if err != nil {
return nil, err
)
// unmarshall config to spoke version
err = yaml.Unmarshal(data, storedObject)
if err != nil {
return nil, err
)
// set defaults for spoke. If you added defaults.
scheme.Default(storedObject)
// convert spoke to hub
hubObj := &hub.RolesConfig{}
err = scheme.Convert(storedObject, hubObj, nil)
if err != nil {
return nil, err
)
// set default for hub, if required
scheme.Default(hubObj)
return hubObj, nil
)

Нам нужен метод getConfigScheme() для получения схемы Kubernetes, в которой зарегистрированы все версии конфигурации. Получив эту схему, мы можем определить версию из файла, переданного в LoadFromFile(). Затем создайте экземпляр объекта RolesConfig необходимой версии и разархивируйте содержимое файла в этот объект. Используя схему, преобразуйте этот объект в версию концентратора объектов, установите значения по умолчанию и верните в качестве выходных данных.

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

Добавление новой версии спиц

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

· Создать пакет для новой спиц-версии (например, v1alpha2) и скопировать в него обновленные types.go из хаба;

· Добавлены doc.go, register.go и default.go, аналогично v1alpha1;

Вызовите плагины convert-gen, deepcopy-gen и defaulter-gen для всех трех версий (hub, v1alpha1, v1alpha2).

Основные изменения: что делать?

Что, если в конфигурацию были внесены серьезные изменения, и плагин convert-gen не может автоматически преобразовывать эту конфигурацию между версиями? Вы действительно можете столкнуться с такой ситуацией… В этом случае вам нужно реализовать функцию конвертации и поместить ее в пакет спиц-версии, с которой у вас возникли проблемы.

Назовите файл zz_generated.manual.go, чтобы отличить его от остальных сгенерированных файлов. Код будет следующим:

package v1alpha1
import (
hub "billing-api-sdk.git/pkg/sdk/db/hub"
conversion "k8s.io/apimachinery/pkg/conversion"
)
func Convert_hub_RolesSpec_To_v1alpha1_RolesSpec(in *hub.RolesSpec, out *RolesSpec, s conversion.Scope) error {
// some custom conversion logic here...
)

Другие возможные улучшения

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

Добавлена ​​возможность одновременной загрузки нескольких конфигураций из каталога и объединения их в одну

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

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

Затем вы можете найти все файлы с расширением yaml/yml в каталоге, найти среди них все файлы соответствующего типа, разархивировать их в их распределенные версии, объединить их, а затем преобразовать в хаб-версию.

Добавлена ​​возможность загрузки нескольких конфигураций с условиями

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

Добавление проверки конфигурации через CRD

Поскольку структура версионных конфигураций соответствует CustomResource из Kubernetes, мы можем сгенерировать CustomResourceDefinition и с его помощью проверить содержимое конфигурации. Подробную информацию о валидации с помощью CRD смотрите здесь.

Создание централизованного сервиса для настройки

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

Выводы

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

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

Обратная и прямая совместимость конфигурации делает жизнь проще!