March 22, 2022

Тестирование Helm Chart. Часть 2

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

Валидация входных параметров

По аналогии с kubeval хотелось бы иметь какой-то инструмент, который способен валидировать файлы на соответствие нашей собственной схеме, сам kubeval использует схемы в JSON:

{
  "description": "Binding ties one object to another; for example, a pod is bound to a node by a scheduler. Deprecated in 1.7, please use the bindings subresource of pods instead.",
  "properties": {
    "apiVersion": {
      "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
      "type": [
        "string",
        "null"
      ]
    },
    "kind": {
      "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
      "type": [
        "string",
        "null"
      ],
      "enum": [
        "Binding"
      ]
    },
    "metadata": {
      "$ref": "https://kubernetesjsonschema.dev/master/_definitions.json#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta",
      "description": "Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata"
    },
    "target": {
      "$ref": "https://kubernetesjsonschema.dev/master/_definitions.json#/definitions/io.k8s.api.core.v1.ObjectReference",
      "description": "The target object that you want to bind to the standard object."
    }
  },
  "required": [
    "target"
  ],
  "type": "object",
  "x-kubernetes-group-version-kind": [
    {
      "group": "",
      "kind": "Binding",
      "version": "v1"
    }
  ],
  "$schema": "http://json-schema.org/schema#"
}

Не самый удобный формат, другой нюанс – kubeval ориентируется по полям kind и apiVersion для получения схем:

// We haven't cached this schema yet; look for one that works
primarySchemaBaseURL := determineSchemaBaseURL(config)
primarySchemaRef := determineSchemaURL(primarySchemaBaseURL, resource.Kind, resource.APIVersion, config)
schemaRefs := []string{primarySchemaRef}

for _, additionalSchemaURLs := range config.AdditionalSchemaLocations {
  additionalSchemaRef := determineSchemaURL(additionalSchemaURLs, resource.Kind, resource.APIVersion, config)
  schemaRefs = append(schemaRefs, additionalSchemaRef)
}

Решаемо. Так может не будем придумывать велосипедов, а попробуем воспользоваться готовым инструментом?

Пробуем запустить валидацию values-файла из репозитория с кодом сервиса как есть:

% kubeval values.yaml
ERR  - values.yaml: Missing 'kind' key

Добавляем kind и apiVersion, и так знаем, что они нужны:

---
apiVersion: qlean/v1
kind: values

# Базовые настройки чарта
app-template:
  app:
    name: web-widget-navbar
    component: frontend
    project: platform

Проверяем еще раз:

% kubeval values.yaml
..https://kubernetesjsonschema.dev/master-standalone/values-qlean-v1.json: Could not read schema from HTTP, response status is 404 Not Found

Для переопределения адреса источника схем в kubeval есть параметр --schema-location (либо переменная окружения KUBEVAL_SCHEMA_LOCATION), в качестве значения можно задавать как ссылку, так и локальный каталог:

export KUBEVAL_SCHEMA_LOCATION=file://./schemas

Запускаем:

% kubeval values.yaml                            
..open ./schemas/master-standalone/values-qlean-v1.json: no such file or directory

В качестве дочернего каталога выступает значение флага --kubernetes-version (по умолчанию master) и префикс standalone. Создаем в нужном каталоге файл values-qlean-v1.json со следующим тестовым содержимым:

{
  "title": "Helm Values",
  "additionalProperties": false
}

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

% kubeval values.yaml 
..kind: Additional property kind is not allowed
..apiVersion: Additional property apiVersion is not allowed
..app-template: Additional property app-template is not allowed

Обновляем схему:

{
  "title": "Helm Values",
  "additionalProperties": false,
  "properties": {
    "apiVersion": {
      "type": "string"
    },
    "kind": {
      "type": "string"
    },
    "app-template": {
      "type": "object"
    }
  }
}

Проверяем:

% kubeval values.yaml
PASS - values.yaml contains a valid values (unknown)

Ну, а далее идем изучать https://json-schema.org и, если хочется, схемы ресурсов Kubernetes, и формируем свою собственную схему, на основе которой будут проверяться пользовательские настройки чарта. Ниже несколько примеров из реальной жизни.

1. Поле должно содержать ключ-значение, есть обязательные ключи, значения должны быть строкой:

"app": {
  "type": "object",
  "additionalProperties": {
    "type": "string"
  },
  "required": [
    "name",
    "component",
    "project"
  ]
},

2. Поле должно содержать для параметра – repository и tag, и больше никаких других:

"image": {
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "repository": {
      "type": "string"
    },
    "tag": {
      "type": "string"
    }
  }
}

3. Поле должно содержать ключ-значение, но есть ключи, которые использовать нельзя:

"annotations": {
  "type": "object",
  "properties": {
    "pltf_ttl": false,
    "ci_project_id": false
  },
  "additionalProperties": {
    "type": "string"
  }
}

4. Значение поля должно быть одним из допустимых:

"type": {
  "type": "string",
  "enum": [
    "http",
    "app"
  ]
}

5. Значение должно быть целым числом и не меньше нуля:

"сount": {
  "type": "integer",
  "minimum": 0
}

6. Поле должно содержать строку в формате duration:

"for": {
  "type": "string",
  "format": "duration"
}

JSON-формат не так удобен для чтения, по сравнению с HCL или YAML, поэтому можно задуматься о том, чтобы конвертировать из подходящего формата в JSON при сборке образа.

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

Со звездочкой

Если стойкое желание сделать свой велосипед к этому моменту сохранилось, полезным будет посмотреть в сторону имплементации JSON Schema на Go, пакет позволяет, в числе прочего, описывать собственные форматы данных, например, для крона может выглядеть так:

package main

import "github.com/gorhill/cronexpr"

type CronFormat struct{}

func init() {
	gojsonschema.FormatCheckers.Add("cron", CronFormat{})
}

func (c CronFormat) IsFormat(cron interface{}) bool {
	cronString, ok := cron.(string)
	if !ok {
		return false
	}
	_, err := cronexpr.Parse(cronString)
	return err == nil
}

С последующим применением в таком виде:

"cron": {
  "type": "string",
  "format": "cron"
}

Ещё один плюс – чтение схем с диска можно описать самому и использовать любой формат, вот, например, YAML:

var bodySchema map[string]interface{}
_ = yaml.Unmarshal(schemaBytes, &bodySchema)
schemaLoader := gojsonschema.NewGoLoader(bodySchema)
return gojsonschema.NewSchema(schemaLoader)

Детали реализации можно посмотреть в коде, он открыт.

Полезные ссылки

  1. https://www.kubeval.com
  2. https://json-schema.org
  3. https://json-schema.org/draft/2020-12/json-schema-validation.html
  4. https://github.com/instrumenta/kubernetes-json-schema
  5. https://json-schema-everywhere.github.io/yaml