You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ci_validator/gitlabcivalidator/validator.go

667 lines
25 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

package gitlabcivalidator
import (
"fmt"
"gitflame.ru/CI_VLDR/errors"
"strings"
"time"
)
// computeEndPosition возвращает конечные координаты для фрагмента, предполагая, что текст находится на одной строке.
func computeEndPosition(startLine, startColumn int, text string) (endLine, endColumn int) {
// Если текст пустой, выделим хотя бы один символ
if len(text) == 0 {
return startLine, startColumn + 1
}
return startLine, startColumn + len(text)
}
// ValidateGitLabCIFile — главная точка входа в библиотеку.
// Принимает на вход содержимое GitLab CI-файла (в формате YAML) и опции валидации.
// Возвращает ошибку, если есть проблемы в конфигурации (или nil, если всё хорошо).
func ValidateGitLabCIFile(content string, opts ValidationOptions) error {
validationErr := &errors.ValidationError{}
// 1) Проверяем, что содержимое не пустое
if len(content) == 0 {
validationErr.AddDetailWithSpan(errors.ErrEmptyFile, "Please provide content of .gitflame-ci.yml", 0, 0, 0, 0)
return validationErr
}
// 2) Парсим YAML
cfg, err := ParseGitLabCIConfig([]byte(content))
if err != nil {
validationErr.AddDetailWithSpan(errors.ErrYamlSyntax,
fmt.Sprintf("ошибка синтаксического анализа YAML: %v", err),
0, 0, 0, 0)
return validationErr
}
// 3) (Опционально) Проверяем SHA проекта,
// если включена опция и "проект с заданным SHA существует, но SHA не содержится ни в одной ветке/теге"
//if opts.VerifyProjectSHA && opts.ProjectSHA != "" {
// // Заглушка: предположим, что проект существует,
// // а также предположим, что SHA нигде не встречается
// // (в реальном мире тут нужно обращаться к GitLab API).
// shaExists := checkSHAExistsInBranchesOrTags(opts.ProjectSHA)
// if !shaExists {
// validationErr.Add(fmt.Errorf("указанный SHA (%s) не содержится ни в одной ветке или теге", opts.ProjectSHA))
// }
//}
// 4) Валидируем структуру конфигурации
validateRootObject(cfg, validationErr)
validateJobs(cfg, validationErr)
validateChanges(cfg, validationErr)
validatePaths(cfg, validationErr)
validateService(cfg, validationErr)
validateStages(cfg, validationErr)
validateVariables(cfg, validationErr)
validateIncludeRules(cfg, validationErr)
if !validationErr.IsEmpty() {
return validationErr
}
return nil
}
// checkSHAExistsInBranchesOrTags - фейковая функция для проверки SHA.
// В реальном проекте здесь вы бы обращались к GitLab API / репозиторию.
func checkSHAExistsInBranchesOrTags(sha string) bool {
// Для демонстрации пусть всегда возвращает false,
// т.е. "SHA не найден".
return false
}
// validateRootObject проверяет допустимые ключи на корневом уровне.
func validateRootObject(cfg *GitLabCIConfig, ve *errors.ValidationError) {
validateBeforeScript(cfg.BeforeScript, ve)
}
// validateBeforeScript проверяет, что поле before_script представлено строкой
// или вложенным массивом строк (до 10 уровней вложенности).
func validateBeforeScript(before interface{}, ve *errors.ValidationError) {
if before == nil {
return
}
// Если это строка — всё ок.
if _, ok := before.(string); ok {
return
}
// Если это массив, проверяем вложенность.
if arr, ok := before.([]interface{}); ok {
if !validateNestedStringArray(arr, 1, 10) {
ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid,
"before_script config should be a string or a nested array of strings up to 10 levels deep",
0, 0, 0, 0)
}
} else {
ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid,
"before_script config should be a string or a nested array of strings up to 10 levels deep",
0, 0, 0, 0)
}
}
// validateNestedStringArray рекурсивно проверяет, что все элементы массива являются строками
// или вложенными массивами строк и что глубина вложенности не превышает maxLevel.
func validateNestedStringArray(arr []interface{}, currentLevel, maxLevel int) bool {
if currentLevel > maxLevel {
return false
}
for _, item := range arr {
switch v := item.(type) {
case string:
// ок
case []interface{}:
if !validateNestedStringArray(v, currentLevel+1, maxLevel) {
return false
}
default:
return false
}
}
return true
}
// validateService проверяет, что конфигурация сервиса имеет тип string или map[string]interface{}.
func validateService(cfg *GitLabCIConfig, ve *errors.ValidationError) {
if cfg.Services == nil {
return
}
switch cfg.Services.(type) {
case string:
// Корректно
case map[string]interface{}:
// Корректно
default:
ve.AddDetailWithSpan(errors.ErrServiceInvalid,
"config should be a hash or a string",
0, 0, 0, 0)
}
}
// validateStages проверяет, что каждая стадия задана как непустая строка.
func validateStages(cfg *GitLabCIConfig, ve *errors.ValidationError) {
for i, st := range cfg.Stages {
if strings.TrimSpace(st) == "" {
ve.AddDetailWithSpan(errors.ErrStageInvalid,
fmt.Sprintf("stage at index %d is empty; stage config should be a string", i),
0, 0, 0, 0)
}
}
}
// validateVariables проверяет корректность описания переменных.
// Если значение переменной — скаляр, оно должно быть строкой, числом или булевым значением.
// Если переменная задана как хэш, допустимы ключи: value, description, expand, options.
func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) {
if cfg.Variables == nil {
return
}
varsMap, ok := cfg.Variables.(map[string]interface{})
if !ok {
ve.AddDetailWithSpan(errors.ErrVariablesInvalid, "variables config should be a map", 0, 0, 0, 0)
return
}
allowedKeys := map[string]struct{}{
"value": {},
"description": {},
"expand": {},
"options": {},
}
for varName, val := range varsMap {
if isScalar(val) {
// всё ок
} else if nested, ok := val.(map[string]interface{}); ok {
invalidKeys := []string{}
for k := range nested {
if _, allowed := allowedKeys[k]; !allowed {
invalidKeys = append(invalidKeys, k)
}
}
if len(invalidKeys) > 0 {
ve.AddDetailWithSpan(errors.ErrVariablesInvalidKey,
fmt.Sprintf("variable '%s' uses invalid data keys: %s", varName, strings.Join(invalidKeys, ", ")),
0, 0, 0, 0)
}
} else {
ve.AddDetailWithSpan(errors.ErrVariablesInvalid,
fmt.Sprintf("variable '%s' must be a scalar or a map", varName),
0, 0, 0, 0)
}
}
}
// isScalar возвращает true, если значение является скаляром (string, bool, числовым типом).
func isScalar(val interface{}) bool {
switch val.(type) {
case string, bool, int, int64, float64, float32:
return true
default:
return false
}
}
// validateJobs проводит серию проверок для job'ов.
func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) {
// Если jobs отсутствуют, возвращаем ошибку с позицией 0,0 (так как позиция не определена)
if cfg.Jobs == nil || len(cfg.Jobs) == 0 {
ve.AddDetailWithSpan(errors.ErrMissingJobs, "отсутствуют job'ы (jobs) в конфигурации", 0, 0, 0, 0)
return
}
// Формируем список разрешённых стадий из cfg.Stages
stagesAllowed := make(map[string]struct{})
for _, st := range cfg.Stages {
stagesAllowed[st] = struct{}{}
}
visibleCount := 0
// Для проверки циклов зависимости можно собрать dependency graph (пока не используем для цикла)
dependencyGraph := make(map[string][]string)
// Обходим все job'ы
for _, job := range cfg.Jobs {
// a) Имя задачи не может быть пустым
if job.Name == "" {
ve.AddDetailWithSpan(errors.ErrJobNameBlank, "job name can't be blank", job.Line, job.Column, job.Line, job.Column)
continue
}
// b) Если имя не начинается с точки — задача считается видимой
if job.Name[0] != '.' {
visibleCount++
}
//// c) Проверяем обязательное наличие script или run
//scriptPresent := job.Script != nil
//if !scriptPresent && job.Run == nil {
// ve.AddDetail(errors.ErrMissingScript,
// fmt.Sprintf("задача '%s' не содержит script или run", job.Name),
// job.Line, job.Column)
//}
// d) Нельзя использовать одновременно script и run
if job.Script == nil && job.Run == nil && job.Trigger == nil {
ve.AddDetail(errors.ErrMissingScript,
fmt.Sprintf("задача '%s' не содержит script, run или trigger", job.Name),
job.Line, job.Column)
}
// e) Если указан stage, он должен присутствовать в списке разрешённых
if job.Stage != "" {
if _, ok := stagesAllowed[job.Stage]; !ok {
ve.AddDetail(errors.ErrJobStageNotExist,
fmt.Sprintf("задача '%s': выбранный stage '%s' не существует; доступные stage: %v", job.Name, job.Stage, cfg.Stages),
job.Line, job.Column)
}
}
// f) Проверяем, что все зависимости (dependencies) ссылаются на существующие job'ы
dependencyGraph[job.Name] = append(dependencyGraph[job.Name], job.Dependencies...)
for _, dep := range job.Dependencies {
depPos, _ := job.DependencyLines[dep]
if _, ok := cfg.Jobs[dep]; !ok {
ve.AddDetail(errors.ErrUndefinedDependency,
fmt.Sprintf("задача '%s' (строка %d, символ %d) имеет зависимость '%s' (строка %d, символ %d), которой нет среди job'ов",
job.Name, job.Line, job.Column, dep, depPos.Line, depPos.Column),
job.Line, job.Column)
} else {
// Проверяем порядок stage — зависимость не должна идти позже
if !checkStageOrder(cfg, dep, job.Name) {
ve.AddDetail(errors.ErrInvalidStageOrder,
fmt.Sprintf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", job.Name, dep, job.Name),
job.Line, job.Column)
}
}
}
// g) Проверяем блок needs
if len(job.Needs) > 0 {
needNames := make(map[string]struct{})
for _, nd := range job.Needs {
// Не должно быть дублирующихся записей в needs
if _, exist := needNames[nd]; exist {
// Здесь, например, выделяем весь фрагмент с нужной зависимостью.
// Предположим, что мы можем вычислить окончание как:
endCol := job.Column + len(nd)
ve.AddDetailWithSpan(errors.ErrDuplicateNeeds,
fmt.Sprintf("задача '%s' содержит дублирующуюся зависимость '%s' в needs", job.Name, nd),
job.Line, job.Column, job.Line, endCol)
} else {
needNames[nd] = struct{}{}
}
// Имя в needs не должно быть пустым
if nd == "" {
ve.AddDetailWithSpan(errors.ErrJobNameBlank,
fmt.Sprintf("задача '%s' имеет needs с пустым именем задачи", job.Name),
job.Line, job.Column, job.Line, job.Column)
}
// Проверяем максимальную длину имени
if len(nd) > MaxJobNameLength {
endCol := job.Column + len(nd)
ve.AddDetailWithSpan(errors.ErrNeedNameTooLong,
fmt.Sprintf("задача '%s' содержит needs '%s', длина имени задачи превышает %d", job.Name, nd, MaxJobNameLength),
job.Line, job.Column, job.Line, endCol)
}
// Каждая задача, указанная в needs, должна существовать
needPos, posExists := job.NeedLines[nd]
if !posExists {
needPos = struct{ Line, Column int }{Line: job.Line, Column: job.Column}
}
if _, ok := cfg.Jobs[nd]; !ok {
endCol := needPos.Column + len(nd)
ve.AddDetailWithSpan(errors.ErrUndefinedNeed,
fmt.Sprintf("задача '%s' (Line %d, Col %d-%d) ссылается на несуществующую задачу '%s'", job.Name, needPos.Line, needPos.Column, endCol, nd),
job.Line, job.Column,
needPos.Line, endCol)
} else if !checkStageOrder(cfg, nd, job.Name) {
endCol := needPos.Column + len(nd)
ve.AddDetailWithSpan(errors.ErrInvalidStageOrder,
fmt.Sprintf("задача '%s' (Line %d, Col %d) имеет need '%s' (Line %d, Col %d-%d) со стадией, идущей позже, чем '%s'",
job.Name, job.Line, job.Column, nd, needPos.Line, needPos.Column, endCol, job.Name),
job.Line, job.Column,
needPos.Line, endCol)
}
// Добавляем запись needs в граф зависимостей
dependencyGraph[job.Name] = append(dependencyGraph[job.Name], nd)
}
}
validateJobArtifacts(job, ve)
validateJobDelayedParameters(job, ve)
validateJobDependenciesNeedsConsistency(job, ve)
validateJobRules(job, ve)
}
// Проверка: должна быть хотя бы одна видимая задача
if visibleCount == 0 {
ve.AddDetail(errors.ErrNoVisibleJob,
"в разделе jobs не обнаружено ни одной «видимой» задачи", 0, 0)
}
// После обхода всех job проверяем граф зависимостей на циклы.
cycle := detectCycles(dependencyGraph)
if len(cycle) > 0 {
ve.AddDetail(errors.ErrCyclicDependency,
fmt.Sprintf("проверка циклических зависимостей: обнаружен цикл между job: %v", cycle),
0, 0)
}
}
func validateJobArtifacts(job *Job, ve *errors.ValidationError) {
if job.Artifacts != nil {
// Если paths не указаны или массив пустой, возвращаем ошибку
if len(job.Artifacts.Paths) == 0 {
ve.AddDetail(
errors.ErrArtifactsPathsBlank,
fmt.Sprintf("задача '%s': artifacts paths can't be blank", job.Name),
job.Line, job.Column,
)
}
}
}
// checkStageOrder проверяет, что stage у jobDep не позже, чем stage у jobName
func checkStageOrder(cfg *GitLabCIConfig, jobDep, jobName string) bool {
depStage := cfg.Jobs[jobDep].Stage
jobStage := cfg.Jobs[jobName].Stage
// Если stage пустые, условимся, что порядок "любопытен",
// но в реальном GitLab это тоже может вызывать ошибки.
if depStage == "" || jobStage == "" {
return true // условно пропустим
}
allStages := cfg.Stages
depIndex := -1
jobIndex := -1
for i, st := range allStages {
if st == depStage {
depIndex = i
}
if st == jobStage {
jobIndex = i
}
}
// Если не нашли stage, тоже пропустим, ибо сам факт уже поднимался выше
if depIndex == -1 || jobIndex == -1 {
return true
}
// Зависимость не должна иметь stage с индексом больше, чем у job
return depIndex <= jobIndex
}
func validateChanges(cfg *GitLabCIConfig, ve *errors.ValidationError) {
if cfg.Changes == nil {
return
}
switch v := cfg.Changes.(type) {
case []interface{}:
if len(v) > 50 {
ve.AddDetail(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0)
}
for _, item := range v {
if _, ok := item.(string); !ok {
ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0)
break
}
}
case map[string]interface{}:
paths, ok := v["paths"]
if !ok {
ve.AddDetail(errors.ErrChangesMissingPaths, "changes config hash must contain key 'paths'", 0, 0)
} else {
arr, ok := paths.([]interface{})
if !ok {
ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0)
} else {
if len(arr) > 50 {
ve.AddDetail(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0)
}
for _, item := range arr {
if _, ok := item.(string); !ok {
ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0)
break
}
}
}
}
case string:
ve.AddDetail(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0)
default:
ve.AddDetail(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0)
}
}
func validatePaths(cfg *GitLabCIConfig, ve *errors.ValidationError) {
if cfg.Paths == nil {
return
}
arr, ok := cfg.Paths.([]interface{})
if !ok {
ve.AddDetail(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0)
return
}
for _, item := range arr {
if _, ok := item.(string); !ok {
ve.AddDetail(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0)
break
}
}
}
// validateJobDelayedParameters проверяет параметры для отложенных (delayed) задач.
func validateJobDelayedParameters(job *Job, ve *errors.ValidationError) {
if job.When == "delayed" {
if job.StartIn == "" {
ve.AddDetail(errors.ErrStartInMissing,
fmt.Sprintf("задача '%s': start_in should be specified for delayed job", job.Name),
job.Line, job.Column)
} else {
d, err := time.ParseDuration(job.StartIn)
if err != nil {
ve.AddDetail(errors.ErrStartInInvalid,
fmt.Sprintf("задача '%s': job start in should be a duration", job.Name),
job.Line, job.Column)
} else if d > 7*24*time.Hour {
ve.AddDetail(errors.ErrStartInTooLong,
fmt.Sprintf("задача '%s': job start in should not exceed the limit", job.Name),
job.Line, job.Column)
}
}
} else {
// Если задача не отложенная, start_in должен быть пустым
if job.StartIn != "" {
ve.AddDetail(errors.ErrStartInMustBeBlank,
fmt.Sprintf("задача '%s': job start in must be blank when not delayed", job.Name),
job.Line, job.Column)
}
}
}
// validateJobDependenciesNeedsConsistency убеждается, что все зависимости присутствуют в needs.
func validateJobDependenciesNeedsConsistency(job *Job, ve *errors.ValidationError) {
if len(job.Dependencies) > 0 && len(job.Needs) > 0 {
needNames := make(map[string]struct{})
for _, nd := range job.Needs {
needNames[nd] = struct{}{}
}
for _, dep := range job.Dependencies {
if _, ok := needNames[dep]; !ok {
ve.AddDetail(errors.ErrDependencyNotInNeeds,
fmt.Sprintf("задача '%s': dependency '%s' should be part of needs", job.Name, dep),
job.Line, job.Column)
}
}
}
}
func validateJobRules(job *Job, ve *errors.ValidationError) {
if job.Rules != nil {
// Если rules используется, не должно быть only или except
if job.Only != nil && job.Except != nil {
ve.AddDetail(errors.ErrRulesOnlyExcept,
fmt.Sprintf("задача '%s': may not be used with rules: only, except", job.Name),
job.Line, job.Column)
} else if job.Only != nil {
ve.AddDetail(errors.ErrRulesOnly,
fmt.Sprintf("задача '%s': may not be used with rules: only", job.Name),
job.Line, job.Column)
} else if job.Except != nil {
ve.AddDetail(errors.ErrRulesExcept,
fmt.Sprintf("задача '%s': may not be used with rules: except", job.Name),
job.Line, job.Column)
}
// Если в секции rules присутствует ключ if, проверим его тип и синтаксис
if rulesMap, ok := job.Rules.(map[string]interface{}); ok {
if ifVal, exists := rulesMap["if"]; exists {
if ifStr, ok := ifVal.(string); ok {
if !validateExpressionSyntax(ifStr) {
ve.AddDetail(errors.ErrInvalidExpressionSyntax,
fmt.Sprintf("задача '%s': invalid expression syntax", job.Name),
job.Line, job.Column)
}
} else {
ve.AddDetail(errors.ErrInvalidExpressionSyntax,
fmt.Sprintf("задача '%s': 'if' in rules should be a string", job.Name),
job.Line, job.Column)
}
}
// Проверим, что ключ allow_failure, если присутствует, имеет булевое значение
if af, exists := rulesMap["allow_failure"]; exists {
if _, ok := af.(bool); !ok {
ve.AddDetail(errors.ErrBooleanValue,
fmt.Sprintf("задача '%s': allow_failure in rules should be a boolean value", job.Name),
job.Line, job.Column)
}
}
// Проверка допустимых ключей в rules
allowed := map[string]struct{}{
"if": {},
"when": {},
"start_in": {},
"allow_failure": {},
"changes": {},
"exists": {},
}
for key := range rulesMap {
if _, ok := allowed[key]; !ok {
ve.AddDetail(errors.ErrUnknownRulesKey,
fmt.Sprintf("задача '%s': unknown key in rules: %s", job.Name, key),
job.Line, job.Column)
}
}
}
}
}
// validateExpressionSyntax выполняет базовую проверку синтаксиса выражения.
// В реальной реализации здесь может быть полноценный парсер.
func validateExpressionSyntax(expr string) bool {
// Для демонстрации считаем, что пустая строка или отсутствие каких-либо специальных символов — это ошибка.
return expr != ""
}
// detectCycles принимает граф зависимостей в виде мапы: job -> список зависимых job,
// и возвращает список job, участвующих в цикле (если он обнаружен).
func detectCycles(dependencyGraph map[string][]string) []string {
visited := make(map[string]bool)
recStack := make(map[string]bool)
var cycle []string
// Рекурсивная функция для обхода графа.
var dfs func(string) bool
dfs = func(node string) bool {
if recStack[node] {
// Если node уже в рекурсивном стеке, значит обнаружен цикл.
cycle = append(cycle, node)
return true
}
if visited[node] {
return false
}
visited[node] = true
recStack[node] = true
for _, neighbor := range dependencyGraph[node] {
if dfs(neighbor) {
cycle = append(cycle, node)
return true
}
}
recStack[node] = false
return false
}
// Запускаем DFS для каждой job.
for node := range dependencyGraph {
if !visited[node] {
if dfs(node) {
break // если цикл обнаружен, прерываем обход
}
}
}
return cycle
}
// Функция для валидации include-правил
func validateIncludeRules(cfg *GitLabCIConfig, ve *errors.ValidationError) {
if cfg.Include == nil {
return
}
switch v := cfg.Include.(type) {
case map[string]interface{}:
validateIncludeRuleMap(v, ve)
case []interface{}:
for _, item := range v {
if m, ok := item.(map[string]interface{}); ok {
validateIncludeRuleMap(m, ve)
} else {
// Если элемент не является картой, можно считать его допустимым,
// либо добавить дополнительную проверку при необходимости.
}
}
default:
// Если include задан не как карта и не как массив, пропускаем проверку.
}
}
// Функция, которая проверяет отдельную карту include-правила
func validateIncludeRuleMap(m map[string]interface{}, ve *errors.ValidationError) {
// Проверяем ключи "exists" и "changes"
keysToCheck := []string{"exists", "changes"}
for _, key := range keysToCheck {
if val, exists := m[key]; exists {
switch v := val.(type) {
case string:
// Корректное значение строка.
case []interface{}:
// Проверяем, что все элементы массива строки.
for _, item := range v {
if _, ok := item.(string); !ok {
ve.AddDetail(errors.ErrIncludeRulesInvalid,
fmt.Sprintf("include rule key '%s' should be an array of strings", key),
0, 0)
break
}
}
default:
ve.AddDetail(errors.ErrIncludeRulesInvalid,
fmt.Sprintf("include rule key '%s' should be a string or an array of strings", key),
0, 0)
}
}
}
}