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

280 lines
12 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 (
"errors"
"fmt"
)
// ValidateGitLabCIFile — главная точка входа в библиотеку.
// Принимает на вход содержимое GitLab CI-файла (в формате YAML) и опции валидации.
// Возвращает ошибку, если есть проблемы в конфигурации (или nil, если всё хорошо).
func ValidateGitLabCIFile(content string, opts ValidationOptions) error {
validationErr := &ValidationError{}
// 1) Проверяем, что содержимое не пустое
if len(content) == 0 {
validationErr.Add(errors.New("файл .gitlab-ci.yml пуст или отсутствует"))
return validationErr
}
// 2) Парсим YAML
cfg, err := ParseGitLabCIConfig([]byte(content))
if err != nil {
validationErr.Add(fmt.Errorf("ошибка синтаксического анализа YAML: %w", err))
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)
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 *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))
}
}
}
// validateJobs проводит серию проверок для job'ов.
func validateJobs(cfg *GitLabCIConfig, ve *ValidationError) {
if cfg.Jobs == nil || len(cfg.Jobs) == 0 {
ve.Add(errors.New("отсутствуют job'ы (jobs) в конфигурации"))
return
}
// Список допустимых стадий, определённых в cfg.Stages
stagesAllowed := make(map[string]struct{})
for _, st := range cfg.Stages {
stagesAllowed[st] = struct{}{}
}
// Проверим наличие хотя бы одной "видимой" задачи
// (под "видимой" обычно понимается job без точки впереди имени,
// но деталь зависит от бизнес-логики; здесь покажем упрощённо).
visibleCount := 0
// 1. Сбор данных для проверки циклов зависимости
// (Graph: jobName -> список зависимых jobName).
dependencyGraph := make(map[string][]string)
for _, job := range cfg.Jobs {
// a) Имя задачи не может быть пустым
if job.Name == "" {
ve.Add(fmt.Errorf("у задачи отсутствует имя"))
continue
}
// b) Если имя начинается с точки, считаем job скрытым
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))
}
// d) Проверка взаимоисключения ключей script и run
if job.Script != nil && job.Run != nil {
ve.Add(fmt.Errorf("задача '%s' содержит одновременно script и run", job.Name))
}
// 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))
}
}
// 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))
} else {
// Проверяем порядок stage (зависимость не должна быть позже)
if !checkStageOrder(cfg, dep, job.Name) {
ve.Add(fmt.Errorf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", job.Name, dep, job.Name))
}
}
}
// 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))
} else {
needNames[nd.Name] = struct{}{}
}
// Имя в needs не должно быть пустым
if nd.Name == "" {
ve.Add(fmt.Errorf("задача '%s' имеет needs с пустым именем задачи", job.Name))
}
// Проверка максимальной длины имени
if len(nd.Name) > MaxJobNameLength {
ve.Add(fmt.Errorf("задача '%s' содержит needs '%s', длина имени задачи превышает %d",
job.Name, nd.Name, MaxJobNameLength))
}
// Проверка, что такая задача есть в списке 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))
}
}
// Добавляем в граф для дальнейшей проверки циклов
dependencyGraph[job.Name] = append(dependencyGraph[job.Name], nd.Name)
}
}
}
if visibleCount == 0 {
ve.Add(errors.New("в разделе jobs не обнаружено ни одной «видимой» задачи"))
}
// 2. Проверяем граф зависимостей на отсутствие циклов
if hasCycles := checkCircularDependencies(dependencyGraph); hasCycles {
ve.Add(errors.New("обнаружены циклические зависимости в заданиях"))
}
}
// 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
}
// checkCircularDependencies анализирует граф зависимостей и проверяет наличие циклов.
// Здесь применяется простой DFS.
func checkCircularDependencies(graph map[string][]string) bool {
visited := make(map[string]bool)
recStack := make(map[string]bool)
for node := range graph {
if dfsHasCycle(node, graph, visited, recStack) {
return true
}
}
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] {
return true
}
}
}
recStack[node] = false
return false
}