main
andrew 1 month ago
parent b5bdd85c3a
commit c02d3b37a6

@ -0,0 +1,59 @@
package errors
// ErrorCode — тип для кода ошибки.
type ErrorCode string
// Перечисление кодов ошибок для валидации.
const (
// Общие ошибки
ErrEmptyFile ErrorCode = "EMPTY_FILE" // файл пуст или отсутствует
ErrYamlSyntax ErrorCode = "YAML_SYNTAX_ERROR" // ошибка синтаксического анализа YAML
// Ошибки, связанные с job
ErrJobNameBlank ErrorCode = "JOB_NAME_BLANK" // имя задачи пустое
ErrMissingScript ErrorCode = "MISSING_SCRIPT" // отсутствует обязательное поле script (или run)
ErrBothScriptAndRun ErrorCode = "BOTH_SCRIPT_RUN" // одновременно присутствуют script и run
ErrJobStageNotExist ErrorCode = "JOB_STAGE_NOT_EXIST" // указан stage, которого нет в списке разрешённых
ErrUndefinedDependency ErrorCode = "UNDEFINED_DEPENDENCY" // зависимость не определена в списке job'ов
ErrInvalidStageOrder ErrorCode = "INVALID_STAGE_ORDER" // зависимость имеет stage, который идёт позже, чем у задачи
ErrDuplicateNeeds ErrorCode = "DUPLICATE_NEEDS" // дублирующиеся записи в needs
ErrUndefinedNeed ErrorCode = "UNDEFINED_NEED" // need ссылается на несуществующую задачу
ErrNeedNameTooLong ErrorCode = "NEED_NAME_TOO_LONG" // имя need превышает допустимую длину
ErrNoVisibleJob ErrorCode = "NO_VISIBLE_JOB" // нет ни одной видимой задачи
ErrMissingJobs ErrorCode = "MISSING_JOBS"
ErrArtifactsPathsBlank ErrorCode = "ARTIFACTS_PATHS_BLANK" // отсутствует или пустой блок paths в artifacts
// Changes
ErrChangesNotArrayOfStrings ErrorCode = "CHANGES_NOT_ARRAY_OF_STRINGS"
ErrChangesInvalidType ErrorCode = "CHANGES_INVALID_TYPE"
ErrChangesTooManyEntries ErrorCode = "CHANGES_TOO_MANY_ENTRIES"
ErrChangesMissingPaths ErrorCode = "CHANGES_MISSING_PATHS"
// Paths
ErrPathsNotArrayOfStrings ErrorCode = "PATHS_NOT_ARRAY_OF_STRINGS"
// Job delayed parameters
ErrStartInMissing ErrorCode = "START_IN_MISSING"
ErrStartInInvalid ErrorCode = "START_IN_INVALID"
ErrStartInTooLong ErrorCode = "START_IN_TOO_LONG"
ErrStartInMustBeBlank ErrorCode = "START_IN_MUST_BE_BLANK"
// Dependencies / Needs consistency
ErrDependencyNotInNeeds ErrorCode = "DEPENDENCY_NOT_IN_NEEDS"
// Rules validation
ErrRulesOnlyExcept ErrorCode = "RULES_ONLY_EXCEPT"
ErrRulesOnly ErrorCode = "RULES_ONLY"
ErrRulesExcept ErrorCode = "RULES_EXCEPT"
ErrInvalidExpressionSyntax ErrorCode = "INVALID_EXPRESSION_SYNTAX"
ErrUnknownRulesKey ErrorCode = "UNKNOWN_RULES_KEY"
ErrBeforeScriptInvalid ErrorCode = "BEFORE_SCRIPT_INVALID"
ErrServiceInvalid ErrorCode = "SERVICE_INVALID"
ErrStageInvalid ErrorCode = "STAGE_INVALID"
ErrVariablesInvalid ErrorCode = "VARIABLES_INVALID"
ErrVariablesInvalidKey ErrorCode = "VARIABLES_INVALID_KEY"
ErrCyclicDependency ErrorCode = "CYCLIC_DEPENDENCY" // обнаружена циклическая зависимость
ErrBooleanValue ErrorCode = "BOOLEAN_VALUE" // значение должно быть булевым
ErrIncludeRulesInvalid ErrorCode = "INCLUDE_RULES_INVALID" // значение exists или changes не является строкой или массивом строк
)

@ -0,0 +1,57 @@
package errors
import (
"fmt"
"strings"
)
// ValidationErrorDetail хранит данные об одной ошибке.
type ValidationErrorDetail struct {
Code ErrorCode // Код ошибки
Message string // Текст сообщения
Line int // Номер строки, где возникла ошибка
Column int // Позиция в строке
EndLine int // Конечная строка
EndColumn int // Конечный столбец
}
// ValidationError представляет совокупность ошибок валидации.
type ValidationError struct {
Errors []ValidationErrorDetail
}
// Error возвращает строковое представление всех ошибок.
func (ve *ValidationError) Error() string {
var sb strings.Builder
sb.WriteString("Найдены ошибки валидации:\n")
for i, errDetail := range ve.Errors {
sb.WriteString(fmt.Sprintf("%d) [%s] (Line %d, Col %d): %s\n", i+1, errDetail.Code, errDetail.Line, errDetail.Column, errDetail.Message))
}
return sb.String()
}
// AddDetail добавляет новую ошибку в список.
func (ve *ValidationError) AddDetail(code ErrorCode, message string, line, column int) {
ve.Errors = append(ve.Errors, ValidationErrorDetail{
Code: code,
Message: message,
Line: line,
Column: column,
})
}
func (ve *ValidationError) AddDetailWithSpan(code ErrorCode, message string, line, col, endLine, endCol int) {
ve.Errors = append(ve.Errors, ValidationErrorDetail{
Code: code,
Message: message,
Line: line,
Column: col,
EndLine: endLine,
EndColumn: endCol,
})
}
// IsEmpty проверяет, имеются ли ошибки.
func (ve *ValidationError) IsEmpty() bool {
return len(ve.Errors) == 0
}

@ -1,34 +0,0 @@
package gitlabcivalidator
import (
"fmt"
"strings"
)
// ValidationError представляет собой совокупную ошибку,
// содержащую несколько сообщений об ошибках.
type ValidationError struct {
Errors []error
}
func (ve *ValidationError) Error() string {
var sb strings.Builder
sb.WriteString("Found validation errors:\n")
for i, err := range ve.Errors {
sb.WriteString(fmt.Sprintf("%d) %s\n", i+1, err.Error()))
}
return sb.String()
}
// Add добавляет новую ошибку во внутренний срез ValidationError.
func (ve *ValidationError) Add(err error) {
if err == nil {
return
}
ve.Errors = append(ve.Errors, err)
}
// IsEmpty проверяет, есть ли в ValidationError какие-либо ошибки.
func (ve *ValidationError) IsEmpty() bool {
return len(ve.Errors) == 0
}

@ -19,35 +19,49 @@ type GitLabCIConfig struct {
Cache interface{} `yaml:"cache,omitempty"`
Workflow interface{} `yaml:"workflow,omitempty"`
Jobs map[string]*Job `yaml:"jobs,omitempty"`
Changes interface{} `yaml:"changes,omitempty"`
Paths interface{} `yaml:"paths,omitempty"`
// В реальном GitLab CI конфигурация может позволять
// описывать job'ы не только в секции "jobs", но и на верхнем уровне.
// Для упрощения в этом примере предполагаем, что все job'ы
// расположены под ключом "jobs".
}
type Artifacts struct {
Paths []string `yaml:"paths"`
}
// Job описывает структуру задачи (job).
type Job struct {
Name string `yaml:"-"`
Stage string `yaml:"stage,omitempty"`
Dependencies []string `yaml:"dependencies,omitempty"`
Needs []*Need `yaml:"needs,omitempty"`
Needs []string `yaml:"-"` // отключаем автоматическое декодирование
Script interface{} `yaml:"script,omitempty"` // может быть строкой или списком строк
Trigger interface{} `yaml:"trigger,omitempty"` // для описания trigger
Run interface{} `yaml:"run,omitempty"` // для альтернативного описания шагов
Rules interface{} `yaml:"rules,omitempty"` // для динамических правил
Artifacts *Artifacts `yaml:"artifacts,omitempty"` // блок artifacts
// ... могут быть и другие поля
When string `yaml:"when,omitempty"`
StartIn string `yaml:"start_in,omitempty"`
Only interface{} `yaml:"only,omitempty"`
Except interface{} `yaml:"except,omitempty"`
Line int `yaml:"-"` // Строка в файле
Column int `yaml:"-"` // Позиция в строке
DependencyLines map[string]struct {
Line int
Column int
} `yaml:"-"` // Строки и позиции зависимостей
}
// Need описывает одну зависимость в разделе needs
type Need struct {
Name string `yaml:"name"`
// могут быть и другие поля, напр. "artifacts: true/false" и т.д.
NeedLines map[string]struct {
Line int
Column int
} `yaml:"-"`
}
// MaxJobNameLength здесь зададим некоторые константы, используемые для валидации

@ -29,6 +29,12 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) {
// Основной объект — первый дочерний элемент
mainNode := root.Content[0]
// Здесь выполняем проверку ключей
unknownKeys := findUnknownRootKeys(mainNode)
if len(unknownKeys) > 0 {
return nil, fmt.Errorf("contains unknown keys: %v", unknownKeys)
}
// Декодируем root-объект в структуру
err = mainNode.Decode(&config)
if err != nil {
@ -76,6 +82,26 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) {
}{depNode.Line, depNode.Column}
}
}
if depKey.Value == "needs" && depVal.Kind == yaml.SequenceNode {
// Инициализируем NeedLines, если ещё не инициализировано.
job.NeedLines = make(map[string]struct {
Line int
Column int
})
for k := 0; k < len(depVal.Content); k++ {
needNode := depVal.Content[k]
job.Needs = append(job.Needs, needNode.Value)
job.NeedLines[needNode.Value] = struct {
Line int
Column int
}{
Line: needNode.Line,
Column: needNode.Column,
}
}
}
}
// Сохраняем job в конфигурации
@ -87,3 +113,36 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) {
return &config, nil
}
func findUnknownRootKeys(mainNode *yaml.Node) []string {
// Разрешённые ключи корневого объекта
allowedKeys := map[string]struct{}{
"default": {},
"include": {},
"before_script": {},
"image": {},
"services": {},
"after_script": {},
"variables": {},
"stages": {},
"cache": {},
"workflow": {},
"jobs": {},
"changes": {},
"paths": {},
}
var unknownKeys []string
if mainNode.Kind != yaml.MappingNode {
return unknownKeys
}
// В YAML Mapping узле ключи находятся в нечетных индексах
for i := 0; i < len(mainNode.Content)-1; i += 2 {
keyNode := mainNode.Content[i]
if _, ok := allowedKeys[keyNode.Value]; !ok {
unknownKeys = append(unknownKeys, keyNode.Value)
}
}
return unknownKeys
}

@ -1,44 +1,63 @@
package gitlabcivalidator
import (
"errors"
"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 := &ValidationError{}
validationErr := &errors.ValidationError{}
// 1) Проверяем, что содержимое не пустое
if len(content) == 0 {
validationErr.Add(errors.New("файл .gitlab-ci.yml пуст или отсутствует"))
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.Add(fmt.Errorf("ошибка синтаксического анализа YAML: %w", err))
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))
}
}
//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
@ -56,159 +75,296 @@ func checkSHAExistsInBranchesOrTags(sha string) bool {
}
// validateRootObject проверяет допустимые ключи на корневом уровне.
func validateRootObject(cfg *GitLabCIConfig, ve *ValidationError) {
// Допустимые ключи на корне
/*allowedKeys := map[string]struct{}{
"default": {},
"include": {},
"before_script": {},
"image": {},
"services": {},
"after_script": {},
"variables": {},
"stages": {},
"cache": {},
"workflow": {},
"jobs": {},
}*/
// Поскольку мы уже распарсили в структуру GitLabCIConfig,
// у нас нет возможности напрямую посмотреть "лишние" ключи,
// так как yaml.Unmarshal их "отбрасывает".
//
// В реальном случае можно было бы воспользоваться механизмом
// "yaml.UnmarshalStrict" или собственной реализацией обхода
// узлов YAML. Ниже — упрощённый пример проверки "строгим" способом.
//
// Для демонстрации можно парсить в виде map[string]interface{},
// а затем проверить. Для краткости — опущено.
// Проверим, что cfg.Stages - это действительно срез строк
// (если он есть) — базовая проверка типа.
// В реальном коде при Unmarshal строка превращается в nil,
// так что здесь слегка условно:
for i, s := range cfg.Stages {
if s == "" {
ve.Add(fmt.Errorf("stages[%d] содержит пустую строку", i))
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 *ValidationError) {
func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) {
// Если jobs отсутствуют, возвращаем ошибку с позицией 0,0 (так как позиция не определена)
if cfg.Jobs == nil || len(cfg.Jobs) == 0 {
ve.Add(errors.New("отсутствуют job'ы (jobs) в конфигурации"))
ve.AddDetailWithSpan(errors.ErrMissingJobs, "отсутствуют job'ы (jobs) в конфигурации", 0, 0, 0, 0)
return
}
// Список допустимых стадий, определённых в cfg.Stages
// Формируем список разрешённых стадий из cfg.Stages
stagesAllowed := make(map[string]struct{})
for _, st := range cfg.Stages {
stagesAllowed[st] = struct{}{}
}
// Проверим наличие хотя бы одной "видимой" задачи
// (под "видимой" обычно понимается job без точки впереди имени,
// но деталь зависит от бизнес-логики; здесь покажем упрощённо).
visibleCount := 0
// 1. Сбор данных для проверки циклов зависимости
// (Graph: jobName -> список зависимых jobName).
// Для проверки циклов зависимости можно собрать dependency graph (пока не используем для цикла)
dependencyGraph := make(map[string][]string)
// Обходим все job'ы
for _, job := range cfg.Jobs {
// a) Имя задачи не может быть пустым
if job.Name == "" {
ve.Add(fmt.Errorf("у задачи отсутствует имя"))
ve.AddDetailWithSpan(errors.ErrJobNameBlank, "job name can't be blank", job.Line, job.Column, job.Line, job.Column)
continue
}
// b) Если имя начинается с точки, считаем job скрытым
// b) Если имя не начинается с точки — задача считается видимой
if job.Name[0] != '.' {
visibleCount++
}
// c) Обязательное поле script
scriptPresent := job.Script != nil
// Проверка на булевые значения и массивы строк можно реализовать
// дополнительно; здесь для примера выводим только общую идею.
if !scriptPresent && job.Run == nil {
ve.Add(fmt.Errorf("задача '%s' не содержит script или run", job.Name))
}
//// 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 {
ve.Add(fmt.Errorf("задача '%s' содержит одновременно script и run", job.Name))
// 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 должен быть в списке допустимых
// e) Если указан stage, он должен присутствовать в списке разрешённых
if job.Stage != "" {
if _, ok := stagesAllowed[job.Stage]; !ok {
ve.Add(fmt.Errorf("задача '%s' (строка %d, символ %d) имеет недопустимый stage '%s'",
job.Name, job.Line, job.Column, job.Stage))
ve.AddDetail(errors.ErrJobStageNotExist,
fmt.Sprintf("задача '%s': выбранный stage '%s' не существует; доступные stage: %v", job.Name, job.Stage, cfg.Stages),
job.Line, job.Column)
}
}
// f) Dependencies (если есть) должны ссылаться на определённые job
// 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.Add(fmt.Errorf("задача '%s' (строка %d, символ %d) имеет зависимость '%s' (строка %d, символ %d), которой нет среди job'ов",
job.Name, job.Line, job.Column, dep, depPos.Line, depPos.Column))
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 (зависимость не должна быть позже)
// Проверяем порядок stage — зависимость не должна идти позже
if !checkStageOrder(cfg, dep, job.Name) {
ve.Add(fmt.Errorf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", job.Name, dep, job.Name))
ve.AddDetail(errors.ErrInvalidStageOrder,
fmt.Sprintf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", job.Name, dep, job.Name),
job.Line, job.Column)
}
}
}
// g) Needs — проверки дублирования, наличия и пр.
// g) Проверяем блок needs
if len(job.Needs) > 0 {
needNames := make(map[string]struct{})
for _, nd := range job.Needs {
// Не должно быть дублирующихся name
if _, exist := needNames[nd.Name]; exist {
ve.Add(fmt.Errorf("задача '%s' содержит дублирующуюся зависимость '%s' в needs", job.Name, nd.Name))
// Не должно быть дублирующихся записей в 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.Name] = struct{}{}
needNames[nd] = struct{}{}
}
// Имя в needs не должно быть пустым
if nd.Name == "" {
ve.Add(fmt.Errorf("задача '%s' имеет needs с пустым именем задачи", job.Name))
if nd == "" {
ve.AddDetailWithSpan(errors.ErrJobNameBlank,
fmt.Sprintf("задача '%s' имеет needs с пустым именем задачи", job.Name),
job.Line, job.Column, job.Line, job.Column)
}
// Проверка максимальной длины имени
if len(nd.Name) > MaxJobNameLength {
ve.Add(fmt.Errorf("задача '%s' содержит needs '%s', длина имени задачи превышает %d",
job.Name, nd.Name, MaxJobNameLength))
// Проверяем максимальную длину имени
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)
}
// Проверка, что такая задача есть в списке job'ов
if _, ok := cfg.Jobs[nd.Name]; !ok {
ve.Add(fmt.Errorf("задача '%s' ссылается на несуществующую задачу '%s' в needs", job.Name, nd.Name))
} else {
// Проверяем порядок stage
if !checkStageOrder(cfg, nd.Name, job.Name) {
ve.Add(fmt.Errorf("задача '%s' имеет need '%s' со стадией, идущей позже, чем '%s'", job.Name, nd.Name, job.Name))
// Каждая задача, указанная в 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)
}
// Добавляем в граф для дальнейшей проверки циклов
dependencyGraph[job.Name] = append(dependencyGraph[job.Name], nd.Name)
// Добавляем запись 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.Add(errors.New("в разделе jobs не обнаружено ни одной «видимой» задачи"))
ve.AddDetail(errors.ErrNoVisibleJob,
"в разделе jobs не обнаружено ни одной «видимой» задачи", 0, 0)
}
// 2. Проверяем граф зависимостей на отсутствие циклов
if hasCycles := checkCircularDependencies(dependencyGraph); hasCycles {
ve.Add(errors.New("обнаружены циклические зависимости в заданиях"))
// После обхода всех 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,
)
}
}
}
@ -245,35 +401,266 @@ func checkStageOrder(cfg *GitLabCIConfig, jobDep, jobName string) bool {
return depIndex <= jobIndex
}
// checkCircularDependencies анализирует граф зависимостей и проверяет наличие циклов.
// Здесь применяется простой DFS.
func checkCircularDependencies(graph map[string][]string) bool {
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)
for node := range graph {
if dfsHasCycle(node, graph, visited, recStack) {
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
}
func dfsHasCycle(node string, graph map[string][]string, visited, recStack map[string]bool) bool {
if !visited[node] {
// Отмечаем текущий узел как посещённый
visited[node] = true
recStack[node] = true
// Рекурсивно для всех соседей
for _, neighbor := range graph[node] {
if !visited[neighbor] && dfsHasCycle(neighbor, graph, visited, recStack) {
return true
} else if recStack[neighbor] {
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)
}
}
}
}

@ -35,11 +35,12 @@ jobs:
stage: build
script:
- echo "Building..."
test_job:
stage: test1
dependencies: ["build_job2"]
needs: ["test_job2"]
test_job2:
stage: test
script:
- echo "Testing..."
needs: ["build_job", "test_job2", "test_job3"]
`
fmt.Println("=== Пример 1: Корректный YAML ===")

Loading…
Cancel
Save