KubeVela 插件的安装机制
作者:赵慧慧,中国移动云能力中心软件研发工程师,专注于云原生、微服务、算力网络等领域。
一、前提
Kubevela是一个微内核高扩展的平台,而插件正是实现高扩展能力的机制。Kubevela内置了组件、运维特征、策略和工作流步骤四种模块定义,插件通过对这四种定义的扩展来实现能力的提供。
这篇文章介绍一下插件是如何对四个模块定义进行扩展分发的。
介绍插件的工作机制之前,我们先简单了解一下插件本身的文件结构,插件由基本信息文件(metadata.yaml/README.md/NOTES.cue)、OAM模型文件(definitions/schemas/views/config-templates)和应用描述文件(template/resources)三类构成。其中,基本信息文件描述了插件的元数据信息和基本介绍;OAM模型文件即插件扩展的模型定义的能力;应用描述文件用于支撑插件OAM模型定义中提供的能力落地,即能力的提供者。
插件目录结构
安装插件的过程就是从代码仓库获取插件目录文件,将插件内的文件OAM模型文件渲染到控制面集群,对Kubevela的四种模型定义进行扩展;同时将应用描述文件组装为一个Kubevela应用并下发到管控集群以便为管控集群提供能力支撑。具体的下发逻辑如下图所示:
那么vela addon enable命令的具体实现逻辑是怎样的呢?下面我们来一起解析一下。
二、插件的工作流程
vela addon enable主要执行逻辑为:读取插件源文件和安装插件两个部分。
(一)、插件源文件读取
Kubevela支持本地源和Helm、Github、Gitee、Gitlab、阿里云OSS 5种远端源安装。其中本地安装是指直接读取addon enable命令执行的当前目录中存放的插件,远端安装指从远程插件仓库中拉取插件源代码进行安装。
1 选择源
vela addon enable 命令允许用户在安装插件时指定插件仓库,指定的方式如下:
vela addon enable
命令执行时中会优先从本地寻找插件,如果找到则从本地读取插件源文件;如果没有找到,则根据registryName查找对应的源进行源文件的获取;如果没有指定registryName则会遍历注册到平台的所有插件仓库,从可以找到的第一个插件仓库中获取插件源文件。
func enableAddon(ctx context.Context, k8sClient client.Client, dc *discovery.DiscoveryClient, config *rest.Config, name string, version string, args map[string]interface{}) (string, error) {
registryDS := pkgaddon.NewRegistryDataStore(k8sClient)
registries, err := registryDS.ListRegistries(ctx)
registryName, addonName, err := splitSpecifyRegistry(name)
if len(registryName) != 0 {
foundRegistry := false
for _, registry := range registries {
if registry.Name == registryName {
foundRegistry = true
}
}
if !foundRegistry {
return "", fmt.Errorf("specified registry %s not exist", registryName)
}
}
for i, registry := range registries {
opts := addonOptions()
if len(registryName) != 0 && registryName != registry.Name {
continue
}
additionalInfo, err = pkgaddon.EnableAddon(ctx, addonName, version, k8sClient, dc, apply.NewAPIApplicator(k8sClient), config, registry, args, nil, pkgaddon.FilterDependencyRegistries(i, registries), opts...)
...
...
return additionalInfo, nil
}
...
}
2 从源仓库拉取源代码并加载
选择完对应的源之后,Kubevela会从该源拉取插件的源代码。Kubevela支持包括从本地源在内的6种插件源中获取插件,由于每种源读取文件的方法不一样,但读取源文件的逻辑和对源文件的处理方法又是一致。因此Kubevela抽象了一个AsyncReader接口,对上层逻辑该接口声明一套统一的方法用于读取和处理插件源文件,对下层逻辑不同的插件源对接口中声明的方法进行自定义实现。
// AsyncReader helps async read files of addon
type AsyncReader interface {
// ListAddonMeta will return directory tree contain addon metadata only
ListAddonMeta() (addonCandidates map[string]SourceMeta, err error)
// ReadFile should accept relative path to github repo/path or OSS bucket, and report the file content
ReadFile(path string) (content string, err error)
// RelativePath return a relative path to GitHub repo/path or OSS bucket/path
RelativePath(item Item) string
}
AsyncReader求同存异保证了整个插件源文件代码拉取逻辑的简洁性,通过AsyncReader承上启下的抽象,插件源代码的逻辑简化为获取插件元数据ListAddonMeta()、获取UI渲染数据GetUIDataFromReader()及获取安装数据GetInstallPackageFromReader()三个步骤。
func loadInstallPackage(r AsyncReader) (*InstallPackage, error) {
metas, err := r.ListAddonMeta()
if err != nil {
return "", err
}
meta := metas[r.name]
UIData, err := GetUIDataFromReader(r, &meta, UIMetaOptions)
if err != nil {
return "", err
}
pkg, err := GetInstallPackageFromReader(r, &meta, UIData)
if err != nil {
return "", err
}
return pkg, nil
}
1) 获取插件元数据ListAddonMeta():用于获得插件的文件名及文件对应的全路径等基础元数据信息,以备后续处理各种源文件使用,这个方法的实现逻辑较为简单,就是遍历目录并将文件名、文件路径保存到数组中,不再展开介绍。
2) 获取插件UI渲染数据GetUIDataFromReader():主要用来读取插件部分与界面展示有关的源文件,具体有README、metadata、definitions、config-templates、parameter 5种类型文件。具体的读取逻辑是:
l首先,根据第一步读的元数据对文件列表进行归类,插件源文件归类类别主要有包括README、metadata、template在内的13种,对应到本方法界面展示的5类文件可以归类为README.md、readme.md、metadata.yaml、definitions、config-templates、resources/parameter.cue、parameter.cue 7种类别。对于definitions和config-templates目录,该目录下的文件都属于此类别。
// 插件源数据文件类别
var Patterns = []Pattern{
// config-templates pattern
{IsDir: true, Value: ConfigTemplateDirName},
// single file reader pattern
{Value: ReadmeFileName}, {Value: MetadataFileName}, {Value: TemplateFileName},
// parameter in resource directory
{Value: ParameterFileName},
// directory files
{IsDir: true, Value: ResourcesDirName}, {IsDir: true, Value: DefinitionsDirName}, {IsDir: true, Value: DefSchemaName}, {IsDir: true, Value: ViewDirName},
// CUE app template, parameter and notes
{Value: AppTemplateCueFileName}, {Value: GlobalParameterFileName}, {Value: NotesCUEFileName},
{Value: LegacyReadmeFileName}
}
l其次,针对已经归类的文件,每个类别编写一种处理逻辑,将不同类别的源文件挂载到不同的属性上,并将处理逻辑映射到一个函数中。如README.md和readme.md的处理逻辑对应的函数为readReadme(),该函数将两种格式的文件挂载到对象的Detail属性上;config-templates类别的处理逻辑对应的函数为readConfigTemplateFile(),将该目录下的所有文件挂载到ConfigTemplates属性上。
addonContentsReader := map[string]struct {
skip bool
read func(a *UIData, reader AsyncReader, readPath string) error
}{
ReadmeFileName: {!opt.GetDetail, readReadme},
LegacyReadmeFileName: {!opt.GetDetail, readReadme},
MetadataFileName: {false, readMetadata},
DefinitionsDirName: {!opt.GetDefinition, readDefFile},
ConfigTemplateDirName: {!opt.GetConfigTemplate, readConfigTemplateFile},
ParameterFileName: {!opt.GetParameter, readParamFile},
GlobalParameterFileName: {!opt.GetParameter, readGlobalParamFile},
}
3) 获取安装数据GetInstallPackageFromReader():用于读取插件本身安装需要的其他源文件。具体对应的文件有template、resources、schemas、views、notes 5种类型的文件。具体的读取逻辑与GetUIDataFromReader()一致,均是先对这些文件归类(类别数据见GetUIDataFromReader读取逻辑下方的Patterns数组),针对每个类别编写不同的挂载逻辑,将所有类别的文件挂载到对象的不同属性上。最后,GetInstallPackageFromReader()将前两步骤读取的数据拼接在一起,返回一个InstallPackage实例,为后续插件安装提供数据支持。
func GetInstallPackageFromReader(r AsyncReader, meta *SourceMeta, uiData *UIData) (*InstallPackage, error) {
addonContentsReader := map[string]func(a *InstallPackage, reader AsyncReader, readPath string) error{
TemplateFileName: readTemplate,
ResourcesDirName: readResFile,
DefSchemaName: readDefSchemaFile,
ViewDirName: readViewFile,
AppTemplateCueFileName: readAppCueTemplate,
NotesCUEFileName: readNotesFile,
}
ptItems := ClassifyItemByPattern(meta, r)
// Read the installed data from UI metadata object to reduce network payload
var addon = &InstallPackage{
Meta: uiData.Meta,
Definitions: uiData.Definitions,
CUEDefinitions: uiData.CUEDefinitions,
Parameters: uiData.Parameters,
ConfigTemplates: uiData.ConfigTemplates,
}
for contentType, method := range addonContentsReader {
items := ptItems[contentType]
for _, it := range items {
err := method(addon, r, r.RelativePath(it))
if err != nil {
return nil, fmt.Errorf("fail to read addon %s file %s: %w", meta.Name, r.RelativePath(it), err)
}
}
}
return addon, nil
}
需要说明的是,由于helm类型的源支持指定版本的插件安装,因此在进行上述拉取过程之前,helm源会先执行一个查找指定版本插件的操作逻辑(如果没有指定版本则获取仓库中最新的版本),根据用户指定的版本获取到对应的源文件之后,再进行源文件的处理转换操作。
(二)、安装插件
插件获取并加载完后,在对插件的基本信息进行了验证(如插件名、插件版本、插件元数据)后,执行插件的安装过程。插件安装中主要有插件依赖的安装、插件资源的分发及插件notes的渲染三个过程。
func (h *Installer) enableAddon(addon *InstallPackage) (string, error) {
...
if err = h.installDependency(addon); err != nil {
return "", err
}
if err = h.dispatchAddonResource(addon); err != nil {
return "", err
}
...
additionalInfo, err := h.renderNotes(addon)
...
}
1 安装插件依赖
插件之间具有依赖关系,插件成功安装的先决条件是所有的依赖被成功安装,因此插件安装的第一步是安装插件的所有依赖插件。
安装依赖分为两个步骤,首先判断当前平台是否具备安装所有依赖的条件,只有平台满足这些条件,才会继续执行。条件是针对插件的所有依赖,如果插件对其有版本要求,其必须满足:
l如果依赖已经被安装,其安装的版本需要符合插件要求的版本
l如果依赖没有被安装,需要至少一个插件仓库中存在插件要求的版本
func calculateDependencyVersionToInstall(dependency Dependency, installedAddons itemInfoMap, availableAddons itemInfoMap) (string, error) {
if installedAddons != nil {
installedAddon, ok := installedAddons[dependency.Name]
if ok {
...
installedVersion := installedAddon.AvailableVersions[0]
...
match, _ := checkSemVer(installedVersion, dependency.Version)
if match {
return installedVersion, nil
}
...
}
}
availableAddon, ok := availableAddons[dependency.Name]
...
sortedVersions := sortVersionsDescending(availableAddon.AvailableVersions)
...
// check if the dependency version is satisfied
var match bool
for _, version := range sortedVersions {
match, _ = checkSemVer(version, dependency.Version)
if match {
return version, nil
}
}
...
}
第二个步骤是逐个判断依赖是否需要安装,并对需要安装的依赖进行安装。判断依赖是否需要安装的逻辑较为复杂,后续另外展开介绍。判断因素主要有以下几个:
l依赖是否安装
l依赖是否本地安装
l依赖是否支持管控集群安装
l依赖安装的集群是否可以覆盖插件安装的集群
for _, dep := range addon.Dependencies {
needInstallAddonDep, err := checkDependencyNeedInstall(h.ctx, h.cli, dep.Name, addonClusters)
...
if !needInstallAddonDep {
continue
}
dependencies = append(dependencies, dep.Name)
...
// reset dependency addon clusters parameter
depArgs, depArgsErr := getDependencyArgs(h.ctx, h.cli, dep.Name, addonClusters)
...
depHandler.args = depArgs
var depAddon *InstallPackage
depVersion, err := calculateDependencyVersionToInstall(*dep, installedAddons, availableAddons)
...
depAddon, err = h.loadInstallPackage(dep.Name, depVersion)
if err == nil {
additionalInfo, err := depHandler.enableAddon(depAddon)
...
}
...
}
同时在对依赖进行安装时,会根据插件的clusters参数和插件中对依赖的版本要求,重置依赖安装的集群和版本,根据新的集群参数和版本对依赖进行安装。依赖的安装逻辑与插件的逻辑一致,此处不再赘述。
2 分发插件资源
插件安装中最核心的一步就是分发插件资源,这也是插件实现安装的真正逻辑。第一个章节所讲的插件将OAM模型文件渲染到控制面集群、将应用描述文件下发到管控集群的逻辑就是在这个步骤中实现的。分发资源即是根据前面构造的InstallPackage实例将不同类型的文件渲染为Kubernetes不同类型资源的过程,下面详细介绍下实现过程。
1) 应用描述文件分发
分发的第一步是应用描述文件的下发,应用描述文件下发的过程就是依据插件template.cue/template.yaml、resources目录和插件的安装参数构造Kubevela应用的过程。构造应用的步骤为:渲染template、渲染resources和添加部署策略三个步骤。
func RenderApp(ctx context.Context, addon *InstallPackage, k8sClient client.Client, args map[string]interface{}) (*v1beta1.Application, []*unstructured.Unstructured, error) {
...
app, auxiliaryObjects, err := generateAppFramework(addon, args)
...
app.Spec.Components = append(app.Spec.Components, renderNeededNamespaceAsComps(addon)...)
resources, err := renderResources(addon, args)
...
app.Spec.Components = append(app.Spec.Components, resources...)
if checkNeedAttachTopologyPolicy(app, addon) {
if err := attachPolicyForLegacyAddon(ctx, app, addon, args, k8sClient); err != nil {
return nil, nil, err
}
}
return app, auxiliaryObjects, nil
}
a) 渲染template
构造应用第一步是根据template.cue或template.yaml先初始化一个应用。template.yaml的内容就是一个完整的应用yaml,且yaml类型不支持参数自定义渲染逻辑,初始化的过程即为直接将template.yaml读取的过程。我们详细介绍下template.cue的初始化过程。
初始化应用时首先将根据用户输入的插件的参数值对template.cue进行组装渲染,即读取resources目录下声明的与template.cue有关的变量,并根据output变量的声明逻辑结合读取的resources变量进行渲染,将渲染的结果作为应用的初始值,该应用固定渲染在vela-system命名空间下,在label中设置了插件名称、版本和插件安装的插件仓库名称以标志该应用所属的插件及插件来源。同时,对于有其他输出物(在outputs变量声明的资源)进行组装渲染并将其填充到对象数组中,后续与shema、definitions等资源一并作为附件资源在Kubernetes中创建。
func (a addonCueTemplateRender) renderApp() (*v1beta1.Application, []*unstructured.Unstructured, error) {
...
contextFile, err := a.formatContext()
...
contextCue, err := parser.ParseFile("parameter.cue", contextFile, parser.ParseComments)
...
var files = []string{contextFile}
for _, cuef := range a.addon.CUETemplates {
files = append(files, cuef.Data)
}
v, err := newValueWithMainAndFiles(a.addon.AppCueTemplate.Data, files, nil, "")
...
outputContent, err := v.LookupValue(renderOutputCuePath)
...
err = outputContent.UnmarshalTo(&app)
...
auxiliaryContent, err := v.LookupValue(renderAuxiliaryOutputsPath)
...
err = auxiliaryContent.UnmarshalTo(&outputs)
...
for k, o := range outputs {
if ao, ok := o.(map[string]interface{}); ok {
auxO := &unstructured.Unstructured{Object: ao}
auxO.SetLabels(util.MergeMapOverrideWithDst(auxO.GetLabels(), map[string]string{oam.LabelAddonAuxiliaryName: k}))
res = append(res, auxO)
}
}
return &app, res, nil
}
b) 渲染resources目录
resources目录中,与template有紧密关系的cue类型的资源(声明在main包中)与template一并渲染。对于yaml类型的文件和不属于main包的cue资源在初始化完应用后,进行独立渲染。对于yaml类型的文件,直接将所有的yaml文件映射为一个k8s-objects组件;对于cue类型的文件,将每个cue文件中的output映射为一个组件,组件的类型与cue文件中的声明保持一致。最终,将所有这些组件挂载到应用的Components属性中,待分发应用时一并分发。
func renderResources(addon *InstallPackage, args map[string]interface{}) ([]common2.ApplicationComponent, error) {
var resources []common2.ApplicationComponent
if len(addon.YAMLTemplates) != 0 {
comp, err := renderK8sObjectsComponent(addon.YAMLTemplates, addon.Name)
..
resources = append(resources, *comp)
}
for _, tmpl := range addon.CUETemplates {
isMainCueTemplate, err := checkCueFileHasPackageHeader(tmpl)
...
if isMainCueTemplate {
continue
}
comp, err := renderCompAccordingCUETemplate(tmpl, addon, args)
...
resources = append(resources, *comp)
}
return resources, nil
}
c) 添加部署策略
目前推荐的插件定义template类型使用cue,因为cue拥有丰富的语法可以根据插件接收的参数动态渲染。考虑到一些历史插件仍然在使用yaml类型的template进行应用定义,同时这些插件想要支持自定义集群安装的功能,受限于yaml固定的不可变性,针对这种情况,在代码层面进行自动添加部署策略的功能。由于自动添加部署策略是对插件部署模式的修改,为了遵循部署行为与定义同步的最佳实践,仅针对某种特殊类型的插件提供自动添加部署的能力,当且仅当插件满足如下条件该功能才开启:
l插件template文件是yaml类型
l插件支持部署到管控集群
l插件源文件中没有定义topology策略
func checkNeedAttachTopologyPolicy(app *v1beta1.Application, addon *InstallPackage) bool {
if len(addon.AppCueTemplate.Data) != 0 {
return false
}
if !isDeployToRuntime(addon) {
return false
}
for _, policy := range app.Spec.Policies {
if policy.Type == v1alpha1.TopologyPolicyType {
return false
}
}
return true
}
对于满足条件的插件,会根据用户输入的插件集群参数(即clusters)自动添加部署策略。如果clusters参数为空,则添加ClusterLabelSelector为空的topology策略(表示插件将运行在包括控制面集群在内的所有集群中);如果clusters不为空,则添加clusters为用户输入的插件集群列表的topology策略,另外由于部分插件在管控集群的运行依赖控制面集群中插件的crd,会在clusters中添加local集群保证插件安装在控制面集群中。
func attachPolicyForLegacyAddon(ctx context.Context, app *v1beta1.Application, addon *InstallPackage, args map[string]interface{}, k8sClient client.Client) error {
...
if len(deployClusters) == 0 {
clusterSelector := map[string]interface{}{
ClusterLabelSelector: map[string]string{},
}
properties, err := json.Marshal(clusterSelector)
...
policy := v1beta1.AppPolicy{
Name: addonAllClusterPolicy,
Type: v1alpha1.TopologyPolicyType,
Properties: &runtime.RawExtension{Raw: properties},
}
app.Spec.Policies = append(app.Spec.Policies, policy)
} else {
var found bool
for _, c := range deployClusters {
if c == multicluster.ClusterLocalName {
found = true
break
}
}
if !found {
deployClusters = append(deployClusters, multicluster.ClusterLocalName)
}
...
app.Spec.Policies = append(app.Spec.Policies, v1beta1.AppPolicy{
Name: specifyAddonClustersTopologyPolicy,
Type: v1alpha1.TopologyPolicyType,
Properties: &runtime.RawExtension{Raw: body},
})
}
return nil
}
2) OAM模型文件渲染
OAM模型文件包括模块化能力定义、UI扩展、资源拓扑规则等,在插件目录中对应的目录主要有definitions、config-templates、schemas、views。这些模型文件不需要分发到管控集群,只需要在控制面集群渲染即可。该部分的渲染逻辑较简单,即将每个目录中的每个资源作为一个独立的资源在Kubernetes中直接创建,资源的namespace是vela-system。
// Step1: Render the definitions
defs, err := RenderDefinitions(addon, h.config)...// Step2: Render the config templates
templates, err := RenderConfigTemplates(addon, h.cli)...// Step3: Render the definition schemas
schemas, err := RenderDefinitionSchema(addon)...// Step4: Render the velaQL views
views, err := RenderViews(addon)
auxiliaryOutputs = append(auxiliaryOutputs, defs...)
auxiliaryOutputs = append(auxiliaryOutputs, templates...)
auxiliaryOutputs = append(auxiliaryOutputs, schemas...)
auxiliaryOutputs = append(auxiliaryOutputs, views...)for _, o := range auxiliaryOutputs {
...
err = h.apply.Apply(h.ctx, o, apply.DisableUpdateAnnotation())
...
}
每个目录的渲染逻辑均不相同,具体的渲染逻辑封装在各自对应的Render中。RenderDefinitions()将definitions中的文件序列化后生成对象数组,按照目录中各个文件中的声明将资源渲染为对应的Kubernetes资源,按照插件开发规范definitions中存放四种模型的定义扩展,所以这个目录渲染出来的不外乎ComponentDefinitions、TraitDefinitions、WorkflowStepDefinitions和PolicyDefinition四种类型的资源;RenderConfigTemplates()、RenderDefinitionSchema()和RenderViews()则分别将config-templates、schemas和views目录中的文件渲染为ConfigMap。
3)保存插件参数
安装插件时用户填写的插件参数保存在vela-system命名空间下的以addon-secret-插件名称为名字的secret中。在插件每次安装时,为了保证插件参数的实时性,会同步更新这个secret。更新逻辑为:如果输入参数存在,则会创建或覆盖原有的同名secret;如果输入参数不存在且同名的secret资源存在,则会删除该secret。
if h.args != nil && len(h.args) > 0 {
sec := RenderArgsSecret(addon, h.args)
addOwner(sec, app)
err = h.apply.Apply(h.ctx, sec, apply.DisableUpdateAnnotation())
if err != nil {
return err
}} else {
// delete addon args secret file
deleteErr := deleteArgsSecret(h.ctx, h.cli, addon.Name)
if deleteErr != nil {
return deleteErr
}
}
3 渲染插件NOTES文件
插件中的NOTES.cue文件允许用户在启用插件后根据指定参数显示动态提示信息。NOTES.cue也支持根据插件参数动态渲染。渲染NOTES.cue是在插件安装的最后一步,当插件启动安装程序后,系统根据用户输入的插件参数对文件内容进行逻辑判断与转换(转换逻辑同其他template.cue文件),将文件中的notes变量提取出来并转为字符串提示信息,并将提示信息打印到控制台。
func (h *Installer) renderNotes(addon *InstallPackage) (string, error) {
...
r := addonCueTemplateRender{
addon: addon,
inputArgs: h.args,
contextInfo: map[string]interface{}{
"installer": h.installerRuntime,
},
}
contextFile, err := r.formatContext()
...
notesFile := contextFile + "\n" + addon.Notes.Data
val, err := value.NewValue(notesFile, nil, "")
...
notes, err := val.LookupValue(KeyWordNotes)
...
notesStr, err := notes.CueValue().String()
...
return notesStr, nil
}
至此,一个完整的插件启动安装流程结束。
三、参考文献
1.http://kubevela.net/docs/platform-engineers/addon/intro
2.http://kubevela.net/docs/platform-engineers/system-operation/velaql