Команда 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. Файл должен содержать:
packagehub
importmeta
"k8s.io/apimachinery/pkg/apis/meta/v1" // RolesConfig contains a list of roles for authorization typeRolesConfig
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"`)
typeRolesSpec
struct{
Roles []Role
`json:"roles,omitempty"`)
// Role contains rules that represent a set of permissions. typeRole
struct{
// Name is unique role name. // +kubebuilder:validation:RequiredName
string `json:"name,omitempty"` // IsAnonymous identify that role is allowed for anonymous user - user. // +kubebuilder:validation:OptionalIsAnonymous
bool `json:"anonymous"` // Rules is set of rules available for the role. // +kubebuilder:validation:RequiredRules RuleSet
`json:"rules"`)
// RuleSet contains set of rules for a role typeRuleSet []Rule
// Rule is the list of actions the subject is allowed to perform on resources. typeRule
struct{
// +kubebuilder:validation:RequiredVerbs []
string `json:"verbs,omitempty"` // +kubebuilder:validation:RequiredGroups []
string `json:"apiGroups,omitempty"` // +kubebuilder:validation:RequiredKinds []
string `json:"resources,omitempty"`)
Аналогичным образом добавьте types.go в пакет v1alpha1. Поскольку у нас пока только одна версия конфигурации, хаб и v1alpha1 идентичны.
Добавьте doc.go с метаданными конфигурации в хаб-пакет:
// +k8s:deepcopy-gen=package,register // +groupName=config.billing.netcracker.com packagehub
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 packagev1alpha1
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 packagev1alpha1
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 objectsSchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version}
// SchemeBuilder is used to add go types to the GroupVersionKind schemeSchemeBuilder = &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)
returnRegisterConversions(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
1deepcopy-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
1defaulter-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)
iferr !=
nil{
returnnil
, err
)
err = v1alpha1.SchemeBuilder.AddToScheme(scheme)
iferr !=
nil{
returnnil
, err
)
returnscheme,
nil)
// LoadFromFile reads roles configuration from provided config file and converts it to hub representation. func LoadFromFile(filePath string) (*hub.RolesConfig, error){
// get conversion schemascheme, err := getConfigScheme()
iferr !=
nil{
returnnil
, err
)
// read file contentdata, err := os.ReadFile(filePath)
iferr !=
nil{
return nil, fmt.Errorf(
"unable to read roles configuration: %w", err)
)
// unmarshal it to meta.TypeMeta to get metadatatypeMeta := &meta.TypeMeta{}
err = yaml.Unmarshal(data, typeMeta)
iferr !=
nil{
returnnil
, err
)
// validate that configuration in file has the same kind iftypeMeta.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 configstoredObject, err := scheme.New(typeMeta.GroupVersionKind())
iferr !=
nil{
returnnil
, err
)
// unmarshall config to spoke versionerr = yaml.Unmarshal(data, storedObject)
iferr !=
nil{
returnnil
, err
)
// set defaults for spoke. If you added defaults.scheme.Default(storedObject)
// convert spoke to hubhubObj := &hub.RolesConfig{}
err = scheme.Convert(storedObject, hubObj,
nil)
iferr !=
nil{
returnnil
, err
)
// set default for hub, if requiredscheme.Default(hubObj)
returnhubObj,
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, чтобы отличить его от остальных сгенерированных файлов. Код будет следующим:
packagev1alpha1
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, разумно сделать ее централизованной. Регистрируя все типы и версии конфигурации в одной схеме, вы также можете формировать документацию, а также находить дельты.
Выводы
Невозможно построить идеальную систему, не требующую улучшений. И конфиги не исключение.
Использование звездообразной модели управления версиями позволяет беспрепятственно вносить изменения в файлы конфигурации, обеспечивая обратную и прямую совместимость. Это способствует быстрой адаптации к требованиям среды, не путая разные версии и не усложняя тело конфигурации.
Обратная и прямая совместимость конфигурации делает жизнь проще!