andrew 1 month ago
parent c02d3b37a6
commit eeab89a253

@ -21,6 +21,7 @@ const (
ErrNoVisibleJob ErrorCode = "NO_VISIBLE_JOB" // нет ни одной видимой задачи ErrNoVisibleJob ErrorCode = "NO_VISIBLE_JOB" // нет ни одной видимой задачи
ErrMissingJobs ErrorCode = "MISSING_JOBS" ErrMissingJobs ErrorCode = "MISSING_JOBS"
ErrArtifactsPathsBlank ErrorCode = "ARTIFACTS_PATHS_BLANK" // отсутствует или пустой блок paths в artifacts ErrArtifactsPathsBlank ErrorCode = "ARTIFACTS_PATHS_BLANK" // отсутствует или пустой блок paths в artifacts
ErrUnknownRootKey ErrorCode = "UNKNOWN_ROOT_KEY"
// Changes // Changes
ErrChangesNotArrayOfStrings ErrorCode = "CHANGES_NOT_ARRAY_OF_STRINGS" ErrChangesNotArrayOfStrings ErrorCode = "CHANGES_NOT_ARRAY_OF_STRINGS"

@ -15,6 +15,14 @@ type ValidationErrorDetail struct {
EndColumn int // Конечный столбец EndColumn int // Конечный столбец
} }
type UnknownKeyError struct {
Key string
Line int
Column int
EndLine int
EndColumn int
}
// ValidationError представляет совокупность ошибок валидации. // ValidationError представляет совокупность ошибок валидации.
type ValidationError struct { type ValidationError struct {
Errors []ValidationErrorDetail Errors []ValidationErrorDetail

@ -2,6 +2,7 @@ package gitlabcivalidator
import ( import (
"fmt" "fmt"
"gitflame.ru/CI_VLDR/errors"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -29,12 +30,6 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) {
// Основной объект — первый дочерний элемент // Основной объект — первый дочерний элемент
mainNode := root.Content[0] mainNode := root.Content[0]
// Здесь выполняем проверку ключей
unknownKeys := findUnknownRootKeys(mainNode)
if len(unknownKeys) > 0 {
return nil, fmt.Errorf("contains unknown keys: %v", unknownKeys)
}
// Декодируем root-объект в структуру // Декодируем root-объект в структуру
err = mainNode.Decode(&config) err = mainNode.Decode(&config)
if err != nil { if err != nil {
@ -114,8 +109,7 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) {
return &config, nil return &config, nil
} }
func findUnknownRootKeys(mainNode *yaml.Node) []string { func findUnknownRootKeysWithSpan(mainNode *yaml.Node) []errors.UnknownKeyError {
// Разрешённые ключи корневого объекта
allowedKeys := map[string]struct{}{ allowedKeys := map[string]struct{}{
"default": {}, "default": {},
"include": {}, "include": {},
@ -132,17 +126,23 @@ func findUnknownRootKeys(mainNode *yaml.Node) []string {
"paths": {}, "paths": {},
} }
var unknownKeys []string var unknowns []errors.UnknownKeyError
if mainNode.Kind != yaml.MappingNode { if mainNode.Kind != yaml.MappingNode {
return unknownKeys return unknowns
} }
// В YAML Mapping узле ключи находятся в нечетных индексах
for i := 0; i < len(mainNode.Content)-1; i += 2 { for i := 0; i < len(mainNode.Content)-1; i += 2 {
keyNode := mainNode.Content[i] keyNode := mainNode.Content[i]
if _, ok := allowedKeys[keyNode.Value]; !ok { if _, ok := allowedKeys[keyNode.Value]; !ok {
unknownKeys = append(unknownKeys, keyNode.Value) endLine, endCol := keyNode.Line, keyNode.Column+len(keyNode.Value)
unknowns = append(unknowns, errors.UnknownKeyError{
Key: keyNode.Value,
Line: keyNode.Line,
Column: keyNode.Column,
EndLine: endLine,
EndColumn: endCol,
})
} }
} }
return unknownKeys return unknowns
} }

@ -3,6 +3,7 @@ package gitlabcivalidator
import ( import (
"fmt" "fmt"
"gitflame.ru/CI_VLDR/errors" "gitflame.ru/CI_VLDR/errors"
"gopkg.in/yaml.v3"
"strings" "strings"
"time" "time"
) )
@ -16,40 +17,56 @@ func computeEndPosition(startLine, startColumn int, text string) (endLine, endCo
return startLine, startColumn + len(text) return startLine, startColumn + len(text)
} }
// ValidateGitLabCIFile — главная точка входа в библиотеку. // ValidateGitLabCIFile главная точка входа в библиотеку.
// Принимает на вход содержимое GitLab CI-файла (в формате YAML) и опции валидации.
// Возвращает ошибку, если есть проблемы в конфигурации (или nil, если всё хорошо).
func ValidateGitLabCIFile(content string, opts ValidationOptions) error { func ValidateGitLabCIFile(content string, opts ValidationOptions) error {
validationErr := &errors.ValidationError{} validationErr := &errors.ValidationError{}
// 1) Проверяем, что содержимое не пустое
if len(content) == 0 { if len(content) == 0 {
validationErr.AddDetailWithSpan(errors.ErrEmptyFile, "Please provide content of .gitflame-ci.yml", 0, 0, 0, 0) validationErr.AddDetailWithSpan(errors.ErrEmptyFile, "Please provide content of .gitflame-ci.yml", 0, 0, 0, 0)
return validationErr return validationErr
} }
// 2) Парсим YAML // Повторно распарсить YAML, чтобы получить узел и позиционную информацию
cfg, err := ParseGitLabCIConfig([]byte(content)) var root yaml.Node
err := yaml.Unmarshal([]byte(content), &root)
if err != nil { if err != nil {
validationErr.AddDetailWithSpan(errors.ErrYamlSyntax, validationErr.AddDetailWithSpan(errors.ErrYamlSyntax,
fmt.Sprintf("ошибка синтаксического анализа YAML: %v", err), fmt.Sprintf("ошибка синтаксического анализа YAML: %v", err),
0, 0, 0, 0) 0, 0, 0, 0)
return validationErr return validationErr
} }
if len(root.Content) == 0 {
validationErr.AddDetailWithSpan(errors.ErrYamlSyntax,
"файл .gitlab-ci.yml пуст или не содержит допустимых данных",
0, 0, 0, 0)
return validationErr
}
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
validationErr.AddDetailWithSpan(errors.ErrYamlSyntax,
"некорректный формат YAML: ожидался корневой объект",
0, 0, 0, 0)
return validationErr
}
// 3) (Опционально) Проверяем SHA проекта, // Получаем корневой узел
// если включена опция и "проект с заданным SHA существует, но SHA не содержится ни в одной ветке/теге" mainNode := root.Content[0]
//if opts.VerifyProjectSHA && opts.ProjectSHA != "" { // Проверяем неизвестные ключи и добавляем ошибки в объект валидации
// // Заглушка: предположим, что проект существует, unknowns := findUnknownRootKeysWithSpan(mainNode)
// // а также предположим, что SHA нигде не встречается for _, unk := range unknowns {
// // (в реальном мире тут нужно обращаться к GitLab API). validationErr.AddDetailWithSpan(errors.ErrUnknownRootKey,
// shaExists := checkSHAExistsInBranchesOrTags(opts.ProjectSHA) fmt.Sprintf("unexpected key: '%s'", unk.Key),
// if !shaExists { unk.Line, unk.Column, unk.EndLine, unk.EndColumn)
// validationErr.Add(fmt.Errorf("указанный SHA (%s) не содержится ни в одной ветке или теге", opts.ProjectSHA)) }
// }
//} // Парсим YAML в структуру конфигурации
cfg, err := ParseGitLabCIConfig([]byte(content))
if err != nil {
validationErr.AddDetailWithSpan(errors.ErrYamlSyntax,
fmt.Sprintf("ошибка синтаксического анализа YAML: %v", err),
0, 0, 0, 0)
return validationErr
}
// 4) Валидируем структуру конфигурации
validateRootObject(cfg, validationErr) validateRootObject(cfg, validationErr)
validateJobs(cfg, validationErr) validateJobs(cfg, validationErr)
validateChanges(cfg, validationErr) validateChanges(cfg, validationErr)
@ -66,30 +83,21 @@ func ValidateGitLabCIFile(content string, opts ValidationOptions) error {
return nil return nil
} }
// checkSHAExistsInBranchesOrTags - фейковая функция для проверки SHA.
// В реальном проекте здесь вы бы обращались к GitLab API / репозиторию.
func checkSHAExistsInBranchesOrTags(sha string) bool { func checkSHAExistsInBranchesOrTags(sha string) bool {
// Для демонстрации пусть всегда возвращает false,
// т.е. "SHA не найден".
return false return false
} }
// validateRootObject проверяет допустимые ключи на корневом уровне.
func validateRootObject(cfg *GitLabCIConfig, ve *errors.ValidationError) { func validateRootObject(cfg *GitLabCIConfig, ve *errors.ValidationError) {
validateBeforeScript(cfg.BeforeScript, ve) validateBeforeScript(cfg.BeforeScript, ve)
} }
// validateBeforeScript проверяет, что поле before_script представлено строкой
// или вложенным массивом строк (до 10 уровней вложенности).
func validateBeforeScript(before interface{}, ve *errors.ValidationError) { func validateBeforeScript(before interface{}, ve *errors.ValidationError) {
if before == nil { if before == nil {
return return
} }
// Если это строка — всё ок.
if _, ok := before.(string); ok { if _, ok := before.(string); ok {
return return
} }
// Если это массив, проверяем вложенность.
if arr, ok := before.([]interface{}); ok { if arr, ok := before.([]interface{}); ok {
if !validateNestedStringArray(arr, 1, 10) { if !validateNestedStringArray(arr, 1, 10) {
ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid, ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid,
@ -103,18 +111,15 @@ func validateBeforeScript(before interface{}, ve *errors.ValidationError) {
} }
} }
// validateNestedStringArray рекурсивно проверяет, что все элементы массива являются строками
// или вложенными массивами строк и что глубина вложенности не превышает maxLevel.
func validateNestedStringArray(arr []interface{}, currentLevel, maxLevel int) bool { func validateNestedStringArray(arr []interface{}, currentLevel, maxLevel int) bool {
if currentLevel > maxLevel { if currentLevel > maxLevel {
return false return false
} }
for _, item := range arr { for _, item := range arr {
switch v := item.(type) { switch item.(type) {
case string: case string:
// ок
case []interface{}: case []interface{}:
if !validateNestedStringArray(v, currentLevel+1, maxLevel) { if !validateNestedStringArray(item.([]interface{}), currentLevel+1, maxLevel) {
return false return false
} }
default: default:
@ -124,16 +129,13 @@ func validateNestedStringArray(arr []interface{}, currentLevel, maxLevel int) bo
return true return true
} }
// validateService проверяет, что конфигурация сервиса имеет тип string или map[string]interface{}.
func validateService(cfg *GitLabCIConfig, ve *errors.ValidationError) { func validateService(cfg *GitLabCIConfig, ve *errors.ValidationError) {
if cfg.Services == nil { if cfg.Services == nil {
return return
} }
switch cfg.Services.(type) { switch cfg.Services.(type) {
case string: case string:
// Корректно
case map[string]interface{}: case map[string]interface{}:
// Корректно
default: default:
ve.AddDetailWithSpan(errors.ErrServiceInvalid, ve.AddDetailWithSpan(errors.ErrServiceInvalid,
"config should be a hash or a string", "config should be a hash or a string",
@ -141,7 +143,6 @@ func validateService(cfg *GitLabCIConfig, ve *errors.ValidationError) {
} }
} }
// validateStages проверяет, что каждая стадия задана как непустая строка.
func validateStages(cfg *GitLabCIConfig, ve *errors.ValidationError) { func validateStages(cfg *GitLabCIConfig, ve *errors.ValidationError) {
for i, st := range cfg.Stages { for i, st := range cfg.Stages {
if strings.TrimSpace(st) == "" { if strings.TrimSpace(st) == "" {
@ -152,9 +153,6 @@ func validateStages(cfg *GitLabCIConfig, ve *errors.ValidationError) {
} }
} }
// validateVariables проверяет корректность описания переменных.
// Если значение переменной — скаляр, оно должно быть строкой, числом или булевым значением.
// Если переменная задана как хэш, допустимы ключи: value, description, expand, options.
func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) { func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) {
if cfg.Variables == nil { if cfg.Variables == nil {
return return
@ -172,7 +170,7 @@ func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) {
} }
for varName, val := range varsMap { for varName, val := range varsMap {
if isScalar(val) { if isScalar(val) {
// всё ок // ok
} else if nested, ok := val.(map[string]interface{}); ok { } else if nested, ok := val.(map[string]interface{}); ok {
invalidKeys := []string{} invalidKeys := []string{}
for k := range nested { for k := range nested {
@ -193,7 +191,6 @@ func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) {
} }
} }
// isScalar возвращает true, если значение является скаляром (string, bool, числовым типом).
func isScalar(val interface{}) bool { func isScalar(val interface{}) bool {
switch val.(type) { switch val.(type) {
case string, bool, int, int64, float64, float32: case string, bool, int, int64, float64, float32:
@ -203,132 +200,117 @@ func isScalar(val interface{}) bool {
} }
} }
// validateJobs проводит серию проверок для job'ов.
func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) { func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) {
// Если jobs отсутствуют, возвращаем ошибку с позицией 0,0 (так как позиция не определена)
if cfg.Jobs == nil || len(cfg.Jobs) == 0 { if cfg.Jobs == nil || len(cfg.Jobs) == 0 {
ve.AddDetailWithSpan(errors.ErrMissingJobs, "отсутствуют job'ы (jobs) в конфигурации", 0, 0, 0, 0) ve.AddDetailWithSpan(errors.ErrMissingJobs, "отсутствуют job'ы (jobs) в конфигурации", 0, 0, 0, 0)
return return
} }
// Формируем список разрешённых стадий из cfg.Stages
stagesAllowed := make(map[string]struct{}) stagesAllowed := make(map[string]struct{})
for _, st := range cfg.Stages { for _, st := range cfg.Stages {
stagesAllowed[st] = struct{}{} stagesAllowed[st] = struct{}{}
} }
visibleCount := 0 visibleCount := 0
// Для проверки циклов зависимости можно собрать dependency graph (пока не используем для цикла)
dependencyGraph := make(map[string][]string) dependencyGraph := make(map[string][]string)
// Обходим все job'ы
for _, job := range cfg.Jobs { for _, job := range cfg.Jobs {
// a) Имя задачи не может быть пустым // a) Проверка имени задачи
if job.Name == "" { if job.Name == "" {
ve.AddDetailWithSpan(errors.ErrJobNameBlank, "job name can't be blank", job.Line, job.Column, job.Line, job.Column) ve.AddDetailWithSpan(errors.ErrJobNameBlank,
"job name can't be blank",
job.Line, job.Column, job.Line, job.Column+1)
continue continue
} }
// b) Если имя не начинается с точки — задача считается видимой
if job.Name[0] != '.' { if job.Name[0] != '.' {
visibleCount++ visibleCount++
} }
//// c) Проверяем обязательное наличие script или run // d) Проверяем наличие хотя бы одного из script, run или trigger
//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 { if job.Script == nil && job.Run == nil && job.Trigger == nil {
ve.AddDetail(errors.ErrMissingScript, ve.AddDetailWithSpan(errors.ErrMissingScript,
fmt.Sprintf("задача '%s' не содержит script, run или trigger", job.Name), fmt.Sprintf("задача '%s' не содержит script, run или trigger", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} }
// e) Если указан stage, он должен присутствовать в списке разрешённых // e) Проверка stage
if job.Stage != "" { if job.Stage != "" {
if _, ok := stagesAllowed[job.Stage]; !ok { if _, ok := stagesAllowed[job.Stage]; !ok {
ve.AddDetail(errors.ErrJobStageNotExist, ve.AddDetailWithSpan(errors.ErrJobStageNotExist,
fmt.Sprintf("задача '%s': выбранный stage '%s' не существует; доступные stage: %v", job.Name, job.Stage, cfg.Stages), fmt.Sprintf("задача '%s': выбранный stage '%s' не существует; доступные stage: %v", job.Name, job.Stage, cfg.Stages),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+len(job.Stage))
} }
} }
// f) Проверяем, что все зависимости (dependencies) ссылаются на существующие job'ы // f) Проверка dependencies
dependencyGraph[job.Name] = append(dependencyGraph[job.Name], job.Dependencies...) dependencyGraph[job.Name] = append(dependencyGraph[job.Name], job.Dependencies...)
for _, dep := range job.Dependencies { for _, dep := range job.Dependencies {
depPos, _ := job.DependencyLines[dep] depPos, _ := job.DependencyLines[dep]
if _, ok := cfg.Jobs[dep]; !ok { if _, ok := cfg.Jobs[dep]; !ok {
ve.AddDetail(errors.ErrUndefinedDependency, endLine, endCol := computeEndPosition(depPos.Line, depPos.Column, dep)
fmt.Sprintf("задача '%s' (строка %d, символ %d) имеет зависимость '%s' (строка %d, символ %d), которой нет среди job'ов", ve.AddDetailWithSpan(errors.ErrUndefinedDependency,
job.Name, job.Line, job.Column, dep, depPos.Line, depPos.Column), fmt.Sprintf("задача '%s' (Line %d, Col %d) имеет зависимость '%s' (Line %d, Col %d-%d), которой нет среди job'ов",
job.Line, job.Column) job.Name, job.Line, job.Column, dep, depPos.Line, depPos.Column, endCol),
job.Line, job.Column, endLine, endCol)
} else { } else {
// Проверяем порядок stage — зависимость не должна идти позже
if !checkStageOrder(cfg, dep, job.Name) { if !checkStageOrder(cfg, dep, job.Name) {
ve.AddDetail(errors.ErrInvalidStageOrder, ve.AddDetailWithSpan(errors.ErrInvalidStageOrder,
fmt.Sprintf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", job.Name, dep, job.Name), fmt.Sprintf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'",
job.Line, job.Column) job.Name, dep, job.Name),
job.Line, job.Column, job.Line, job.Column+len(dep))
} }
} }
} }
// g) Проверяем блок needs // g) Проверка блока needs
if len(job.Needs) > 0 { if len(job.Needs) > 0 {
needNames := make(map[string]struct{}) needNames := make(map[string]struct{})
for _, nd := range job.Needs { for _, nd := range job.Needs {
// Не должно быть дублирующихся записей в needs // Проверка дублирования
if _, exist := needNames[nd]; exist { if _, exist := needNames[nd]; exist {
// Здесь, например, выделяем весь фрагмент с нужной зависимостью. ndStartLine, ndStartCol := job.NeedLines[nd].Line, job.NeedLines[nd].Column
// Предположим, что мы можем вычислить окончание как: ndEndLine, ndEndCol := computeEndPosition(ndStartLine, ndStartCol, nd)
endCol := job.Column + len(nd)
ve.AddDetailWithSpan(errors.ErrDuplicateNeeds, ve.AddDetailWithSpan(errors.ErrDuplicateNeeds,
fmt.Sprintf("задача '%s' содержит дублирующуюся зависимость '%s' в needs", job.Name, nd), fmt.Sprintf("задача '%s' содержит дублирующуюся зависимость '%s' в needs", job.Name, nd),
job.Line, job.Column, job.Line, endCol) ndStartLine, ndStartCol, ndEndLine, ndEndCol)
} else { } else {
needNames[nd] = struct{}{} needNames[nd] = struct{}{}
} }
// Имя в needs не должно быть пустым // Если имя пустое
if nd == "" { if nd == "" {
ve.AddDetailWithSpan(errors.ErrJobNameBlank, ve.AddDetailWithSpan(errors.ErrJobNameBlank,
fmt.Sprintf("задача '%s' имеет needs с пустым именем задачи", job.Name), fmt.Sprintf("задача '%s' имеет needs с пустым именем задачи", job.Name),
job.Line, job.Column, job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} }
// Проверяем максимальную длину имени // Проверка максимальной длины
if len(nd) > MaxJobNameLength { if len(nd) > MaxJobNameLength {
endCol := job.Column + len(nd) ndStartLine, ndStartCol := job.NeedLines[nd].Line, job.NeedLines[nd].Column
ndEndLine, ndEndCol := computeEndPosition(ndStartLine, ndStartCol, nd)
ve.AddDetailWithSpan(errors.ErrNeedNameTooLong, ve.AddDetailWithSpan(errors.ErrNeedNameTooLong,
fmt.Sprintf("задача '%s' содержит needs '%s', длина имени задачи превышает %d", job.Name, nd, MaxJobNameLength), fmt.Sprintf("задача '%s' содержит needs '%s', длина имени задачи превышает %d", job.Name, nd, MaxJobNameLength),
job.Line, job.Column, job.Line, endCol) ndStartLine, ndStartCol, ndEndLine, ndEndCol)
} }
// Каждая задача, указанная в needs, должна существовать // Проверяем, что задача из needs существует
needPos, posExists := job.NeedLines[nd] needPos, posExists := job.NeedLines[nd]
if !posExists { if !posExists {
needPos = struct{ Line, Column int }{Line: job.Line, Column: job.Column} needPos = struct{ Line, Column int }{Line: job.Line, Column: job.Column}
} }
if _, ok := cfg.Jobs[nd]; !ok { if _, ok := cfg.Jobs[nd]; !ok {
endCol := needPos.Column + len(nd) ndEndLine, ndEndCol := computeEndPosition(needPos.Line, needPos.Column, nd)
ve.AddDetailWithSpan(errors.ErrUndefinedNeed, ve.AddDetailWithSpan(errors.ErrUndefinedNeed,
fmt.Sprintf("задача '%s' (Line %d, Col %d-%d) ссылается на несуществующую задачу '%s'", job.Name, needPos.Line, needPos.Column, endCol, nd), fmt.Sprintf("задача '%s' (Line %d, Col %d-%d) ссылается на несуществующую задачу '%s'", job.Name, needPos.Line, needPos.Column, ndEndCol, nd),
job.Line, job.Column, needPos.Line, needPos.Column, ndEndLine, ndEndCol)
needPos.Line, endCol)
} else if !checkStageOrder(cfg, nd, job.Name) { } else if !checkStageOrder(cfg, nd, job.Name) {
endCol := needPos.Column + len(nd) ndEndLine, ndEndCol := computeEndPosition(needPos.Line, needPos.Column, nd)
ve.AddDetailWithSpan(errors.ErrInvalidStageOrder, ve.AddDetailWithSpan(errors.ErrInvalidStageOrder,
fmt.Sprintf("задача '%s' (Line %d, Col %d) имеет need '%s' (Line %d, Col %d-%d) со стадией, идущей позже, чем '%s'", 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.Name, job.Line, job.Column, nd, needPos.Line, needPos.Column, ndEndCol, job.Name),
job.Line, job.Column, needPos.Line, needPos.Column, ndEndLine, ndEndCol)
needPos.Line, endCol)
} }
// Добавляем запись needs в граф зависимостей
dependencyGraph[job.Name] = append(dependencyGraph[job.Name], nd) dependencyGraph[job.Name] = append(dependencyGraph[job.Name], nd)
} }
} }
@ -337,52 +319,40 @@ func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) {
validateJobDelayedParameters(job, ve) validateJobDelayedParameters(job, ve)
validateJobDependenciesNeedsConsistency(job, ve) validateJobDependenciesNeedsConsistency(job, ve)
validateJobRules(job, ve) validateJobRules(job, ve)
} }
// Проверка: должна быть хотя бы одна видимая задача
if visibleCount == 0 { if visibleCount == 0 {
ve.AddDetail(errors.ErrNoVisibleJob, ve.AddDetailWithSpan(errors.ErrNoVisibleJob,
"в разделе jobs не обнаружено ни одной «видимой» задачи", 0, 0) "в разделе jobs не обнаружено ни одной «видимой» задачи",
0, 0, 0, 0)
} }
// После обхода всех job проверяем граф зависимостей на циклы.
cycle := detectCycles(dependencyGraph) cycle := detectCycles(dependencyGraph)
if len(cycle) > 0 { if len(cycle) > 0 {
ve.AddDetail(errors.ErrCyclicDependency, ve.AddDetailWithSpan(errors.ErrCyclicDependency,
fmt.Sprintf("проверка циклических зависимостей: обнаружен цикл между job: %v", cycle), fmt.Sprintf("проверка циклических зависимостей: обнаружен цикл между job: %v", cycle),
0, 0) 0, 0, 0, 0)
} }
} }
func validateJobArtifacts(job *Job, ve *errors.ValidationError) { func validateJobArtifacts(job *Job, ve *errors.ValidationError) {
if job.Artifacts != nil { if job.Artifacts != nil {
// Если paths не указаны или массив пустой, возвращаем ошибку
if len(job.Artifacts.Paths) == 0 { if len(job.Artifacts.Paths) == 0 {
ve.AddDetail( ve.AddDetailWithSpan(errors.ErrArtifactsPathsBlank,
errors.ErrArtifactsPathsBlank,
fmt.Sprintf("задача '%s': artifacts paths can't be blank", job.Name), fmt.Sprintf("задача '%s': artifacts paths can't be blank", job.Name),
job.Line, job.Column, job.Line, job.Column, job.Line, job.Column+1)
)
} }
} }
} }
// checkStageOrder проверяет, что stage у jobDep не позже, чем stage у jobName
func checkStageOrder(cfg *GitLabCIConfig, jobDep, jobName string) bool { func checkStageOrder(cfg *GitLabCIConfig, jobDep, jobName string) bool {
depStage := cfg.Jobs[jobDep].Stage depStage := cfg.Jobs[jobDep].Stage
jobStage := cfg.Jobs[jobName].Stage jobStage := cfg.Jobs[jobName].Stage
// Если stage пустые, условимся, что порядок "любопытен",
// но в реальном GitLab это тоже может вызывать ошибки.
if depStage == "" || jobStage == "" { if depStage == "" || jobStage == "" {
return true // условно пропустим return true
} }
allStages := cfg.Stages allStages := cfg.Stages
depIndex, jobIndex := -1, -1
depIndex := -1
jobIndex := -1
for i, st := range allStages { for i, st := range allStages {
if st == depStage { if st == depStage {
depIndex = i depIndex = i
@ -391,13 +361,9 @@ func checkStageOrder(cfg *GitLabCIConfig, jobDep, jobName string) bool {
jobIndex = i jobIndex = i
} }
} }
// Если не нашли stage, тоже пропустим, ибо сам факт уже поднимался выше
if depIndex == -1 || jobIndex == -1 { if depIndex == -1 || jobIndex == -1 {
return true return true
} }
// Зависимость не должна иметь stage с индексом больше, чем у job
return depIndex <= jobIndex return depIndex <= jobIndex
} }
@ -408,38 +374,38 @@ func validateChanges(cfg *GitLabCIConfig, ve *errors.ValidationError) {
switch v := cfg.Changes.(type) { switch v := cfg.Changes.(type) {
case []interface{}: case []interface{}:
if len(v) > 50 { if len(v) > 50 {
ve.AddDetail(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0) ve.AddDetailWithSpan(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0, 0, 0)
} }
for _, item := range v { for _, item := range v {
if _, ok := item.(string); !ok { if _, ok := item.(string); !ok {
ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0) ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0, 0, 0)
break break
} }
} }
case map[string]interface{}: case map[string]interface{}:
paths, ok := v["paths"] paths, ok := v["paths"]
if !ok { if !ok {
ve.AddDetail(errors.ErrChangesMissingPaths, "changes config hash must contain key 'paths'", 0, 0) ve.AddDetailWithSpan(errors.ErrChangesMissingPaths, "changes config hash must contain key 'paths'", 0, 0, 0, 0)
} else { } else {
arr, ok := paths.([]interface{}) arr, ok := paths.([]interface{})
if !ok { if !ok {
ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0) ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0, 0, 0)
} else { } else {
if len(arr) > 50 { if len(arr) > 50 {
ve.AddDetail(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0) ve.AddDetailWithSpan(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0, 0, 0)
} }
for _, item := range arr { for _, item := range arr {
if _, ok := item.(string); !ok { if _, ok := item.(string); !ok {
ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0) ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0, 0, 0)
break break
} }
} }
} }
} }
case string: case string:
ve.AddDetail(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0) ve.AddDetailWithSpan(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0, 0, 0)
default: default:
ve.AddDetail(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0) ve.AddDetailWithSpan(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0, 0, 0)
} }
} }
@ -449,47 +415,44 @@ func validatePaths(cfg *GitLabCIConfig, ve *errors.ValidationError) {
} }
arr, ok := cfg.Paths.([]interface{}) arr, ok := cfg.Paths.([]interface{})
if !ok { if !ok {
ve.AddDetail(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0) ve.AddDetailWithSpan(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0, 0, 0)
return return
} }
for _, item := range arr { for _, item := range arr {
if _, ok := item.(string); !ok { if _, ok := item.(string); !ok {
ve.AddDetail(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0) ve.AddDetailWithSpan(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0, 0, 0)
break break
} }
} }
} }
// validateJobDelayedParameters проверяет параметры для отложенных (delayed) задач.
func validateJobDelayedParameters(job *Job, ve *errors.ValidationError) { func validateJobDelayedParameters(job *Job, ve *errors.ValidationError) {
if job.When == "delayed" { if job.When == "delayed" {
if job.StartIn == "" { if job.StartIn == "" {
ve.AddDetail(errors.ErrStartInMissing, ve.AddDetailWithSpan(errors.ErrStartInMissing,
fmt.Sprintf("задача '%s': start_in should be specified for delayed job", job.Name), fmt.Sprintf("задача '%s': start_in should be specified for delayed job", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} else { } else {
d, err := time.ParseDuration(job.StartIn) d, err := time.ParseDuration(job.StartIn)
if err != nil { if err != nil {
ve.AddDetail(errors.ErrStartInInvalid, ve.AddDetailWithSpan(errors.ErrStartInInvalid,
fmt.Sprintf("задача '%s': job start in should be a duration", job.Name), fmt.Sprintf("задача '%s': job start in should be a duration", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} else if d > 7*24*time.Hour { } else if d > 7*24*time.Hour {
ve.AddDetail(errors.ErrStartInTooLong, ve.AddDetailWithSpan(errors.ErrStartInTooLong,
fmt.Sprintf("задача '%s': job start in should not exceed the limit", job.Name), fmt.Sprintf("задача '%s': job start in should not exceed the limit", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} }
} }
} else { } else {
// Если задача не отложенная, start_in должен быть пустым
if job.StartIn != "" { if job.StartIn != "" {
ve.AddDetail(errors.ErrStartInMustBeBlank, ve.AddDetailWithSpan(errors.ErrStartInMustBeBlank,
fmt.Sprintf("задача '%s': job start in must be blank when not delayed", job.Name), fmt.Sprintf("задача '%s': job start in must be blank when not delayed", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} }
} }
} }
// validateJobDependenciesNeedsConsistency убеждается, что все зависимости присутствуют в needs.
func validateJobDependenciesNeedsConsistency(job *Job, ve *errors.ValidationError) { func validateJobDependenciesNeedsConsistency(job *Job, ve *errors.ValidationError) {
if len(job.Dependencies) > 0 && len(job.Needs) > 0 { if len(job.Dependencies) > 0 && len(job.Needs) > 0 {
needNames := make(map[string]struct{}) needNames := make(map[string]struct{})
@ -498,9 +461,9 @@ func validateJobDependenciesNeedsConsistency(job *Job, ve *errors.ValidationErro
} }
for _, dep := range job.Dependencies { for _, dep := range job.Dependencies {
if _, ok := needNames[dep]; !ok { if _, ok := needNames[dep]; !ok {
ve.AddDetail(errors.ErrDependencyNotInNeeds, ve.AddDetailWithSpan(errors.ErrDependencyNotInNeeds,
fmt.Sprintf("задача '%s': dependency '%s' should be part of needs", job.Name, dep), fmt.Sprintf("задача '%s': dependency '%s' should be part of needs", job.Name, dep),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+len(dep))
} }
} }
} }
@ -508,44 +471,40 @@ func validateJobDependenciesNeedsConsistency(job *Job, ve *errors.ValidationErro
func validateJobRules(job *Job, ve *errors.ValidationError) { func validateJobRules(job *Job, ve *errors.ValidationError) {
if job.Rules != nil { if job.Rules != nil {
// Если rules используется, не должно быть only или except
if job.Only != nil && job.Except != nil { if job.Only != nil && job.Except != nil {
ve.AddDetail(errors.ErrRulesOnlyExcept, ve.AddDetailWithSpan(errors.ErrRulesOnlyExcept,
fmt.Sprintf("задача '%s': may not be used with rules: only, except", job.Name), fmt.Sprintf("задача '%s': may not be used with rules: only, except", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} else if job.Only != nil { } else if job.Only != nil {
ve.AddDetail(errors.ErrRulesOnly, ve.AddDetailWithSpan(errors.ErrRulesOnly,
fmt.Sprintf("задача '%s': may not be used with rules: only", job.Name), fmt.Sprintf("задача '%s': may not be used with rules: only", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} else if job.Except != nil { } else if job.Except != nil {
ve.AddDetail(errors.ErrRulesExcept, ve.AddDetailWithSpan(errors.ErrRulesExcept,
fmt.Sprintf("задача '%s': may not be used with rules: except", job.Name), fmt.Sprintf("задача '%s': may not be used with rules: except", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} }
// Если в секции rules присутствует ключ if, проверим его тип и синтаксис
if rulesMap, ok := job.Rules.(map[string]interface{}); ok { if rulesMap, ok := job.Rules.(map[string]interface{}); ok {
if ifVal, exists := rulesMap["if"]; exists { if ifVal, exists := rulesMap["if"]; exists {
if ifStr, ok := ifVal.(string); ok { if ifStr, ok := ifVal.(string); ok {
if !validateExpressionSyntax(ifStr) { if !validateExpressionSyntax(ifStr) {
ve.AddDetail(errors.ErrInvalidExpressionSyntax, ve.AddDetailWithSpan(errors.ErrInvalidExpressionSyntax,
fmt.Sprintf("задача '%s': invalid expression syntax", job.Name), fmt.Sprintf("задача '%s': invalid expression syntax", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+len(ifStr))
} }
} else { } else {
ve.AddDetail(errors.ErrInvalidExpressionSyntax, ve.AddDetailWithSpan(errors.ErrInvalidExpressionSyntax,
fmt.Sprintf("задача '%s': 'if' in rules should be a string", job.Name), fmt.Sprintf("задача '%s': 'if' in rules should be a string", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} }
} }
// Проверим, что ключ allow_failure, если присутствует, имеет булевое значение
if af, exists := rulesMap["allow_failure"]; exists { if af, exists := rulesMap["allow_failure"]; exists {
if _, ok := af.(bool); !ok { if _, ok := af.(bool); !ok {
ve.AddDetail(errors.ErrBooleanValue, ve.AddDetailWithSpan(errors.ErrBooleanValue,
fmt.Sprintf("задача '%s': allow_failure in rules should be a boolean value", job.Name), fmt.Sprintf("задача '%s': allow_failure in rules should be a boolean value", job.Name),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+1)
} }
} }
// Проверка допустимых ключей в rules
allowed := map[string]struct{}{ allowed := map[string]struct{}{
"if": {}, "if": {},
"when": {}, "when": {},
@ -556,34 +515,27 @@ func validateJobRules(job *Job, ve *errors.ValidationError) {
} }
for key := range rulesMap { for key := range rulesMap {
if _, ok := allowed[key]; !ok { if _, ok := allowed[key]; !ok {
ve.AddDetail(errors.ErrUnknownRulesKey, ve.AddDetailWithSpan(errors.ErrUnknownRulesKey,
fmt.Sprintf("задача '%s': unknown key in rules: %s", job.Name, key), fmt.Sprintf("задача '%s': unknown key in rules: %s", job.Name, key),
job.Line, job.Column) job.Line, job.Column, job.Line, job.Column+len(key))
} }
} }
} }
} }
} }
// validateExpressionSyntax выполняет базовую проверку синтаксиса выражения.
// В реальной реализации здесь может быть полноценный парсер.
func validateExpressionSyntax(expr string) bool { func validateExpressionSyntax(expr string) bool {
// Для демонстрации считаем, что пустая строка или отсутствие каких-либо специальных символов — это ошибка.
return expr != "" return expr != ""
} }
// detectCycles принимает граф зависимостей в виде мапы: job -> список зависимых job,
// и возвращает список job, участвующих в цикле (если он обнаружен).
func detectCycles(dependencyGraph map[string][]string) []string { func detectCycles(dependencyGraph map[string][]string) []string {
visited := make(map[string]bool) visited := make(map[string]bool)
recStack := make(map[string]bool) recStack := make(map[string]bool)
var cycle []string var cycle []string
// Рекурсивная функция для обхода графа.
var dfs func(string) bool var dfs func(string) bool
dfs = func(node string) bool { dfs = func(node string) bool {
if recStack[node] { if recStack[node] {
// Если node уже в рекурсивном стеке, значит обнаружен цикл.
cycle = append(cycle, node) cycle = append(cycle, node)
return true return true
} }
@ -599,23 +551,20 @@ func detectCycles(dependencyGraph map[string][]string) []string {
return true return true
} }
} }
recStack[node] = false recStack[node] = false
return false return false
} }
// Запускаем DFS для каждой job.
for node := range dependencyGraph { for node := range dependencyGraph {
if !visited[node] { if !visited[node] {
if dfs(node) { if dfs(node) {
break // если цикл обнаружен, прерываем обход break
} }
} }
} }
return cycle return cycle
} }
// Функция для валидации include-правил
func validateIncludeRules(cfg *GitLabCIConfig, ve *errors.ValidationError) { func validateIncludeRules(cfg *GitLabCIConfig, ve *errors.ValidationError) {
if cfg.Include == nil { if cfg.Include == nil {
return return
@ -627,39 +576,31 @@ func validateIncludeRules(cfg *GitLabCIConfig, ve *errors.ValidationError) {
for _, item := range v { for _, item := range v {
if m, ok := item.(map[string]interface{}); ok { if m, ok := item.(map[string]interface{}); ok {
validateIncludeRuleMap(m, ve) validateIncludeRuleMap(m, ve)
} else {
// Если элемент не является картой, можно считать его допустимым,
// либо добавить дополнительную проверку при необходимости.
} }
} }
default:
// Если include задан не как карта и не как массив, пропускаем проверку.
} }
} }
// Функция, которая проверяет отдельную карту include-правила
func validateIncludeRuleMap(m map[string]interface{}, ve *errors.ValidationError) { func validateIncludeRuleMap(m map[string]interface{}, ve *errors.ValidationError) {
// Проверяем ключи "exists" и "changes"
keysToCheck := []string{"exists", "changes"} keysToCheck := []string{"exists", "changes"}
for _, key := range keysToCheck { for _, key := range keysToCheck {
if val, exists := m[key]; exists { if val, exists := m[key]; exists {
switch v := val.(type) { switch val.(type) {
case string: case string:
// Корректное значение строка. // ok
case []interface{}: case []interface{}:
// Проверяем, что все элементы массива строки. for _, item := range val.([]interface{}) {
for _, item := range v {
if _, ok := item.(string); !ok { if _, ok := item.(string); !ok {
ve.AddDetail(errors.ErrIncludeRulesInvalid, ve.AddDetailWithSpan(errors.ErrIncludeRulesInvalid,
fmt.Sprintf("include rule key '%s' should be an array of strings", key), fmt.Sprintf("include rule key '%s' should be an array of strings", key),
0, 0) 0, 0, 0, 0)
break break
} }
} }
default: default:
ve.AddDetail(errors.ErrIncludeRulesInvalid, ve.AddDetailWithSpan(errors.ErrIncludeRulesInvalid,
fmt.Sprintf("include rule key '%s' should be a string or an array of strings", key), fmt.Sprintf("include rule key '%s' should be a string or an array of strings", key),
0, 0) 0, 0, 0, 0)
} }
} }
} }

@ -24,23 +24,27 @@ jobs:
script: script:
- echo "Testing..." - echo "Testing..."
` `
// Тип, ссылка и текст ошибки, показатели где нахоидтся ошибка...
// Некорректный YAML — отсутствует видимая задача, есть только скрытая // Некорректный YAML — отсутствует видимая задача, есть только скрытая
invalidYAML := ` invalidYAML := `
stages: stages:
- build - build
- test - test
holy_cow:
- ass
asdasd:
- sasafafasf
asdasdd:
- asd
jobs: jobs:
build_job: build_job:
stage: build stage: build2
script: script:
- echo "Building..." - echo "Building..."
needs: ["test_job2"] needs: ["test_job2"]
test_job2: test_job2:
stage: test stage: test
script: needs: ["build_job", "test_job2", "test_job3", "test_job4"]
- echo "Testing..."
needs: ["build_job", "test_job2", "test_job3"]
` `
fmt.Println("=== Пример 1: Корректный YAML ===") fmt.Println("=== Пример 1: Корректный YAML ===")

Loading…
Cancel
Save