|
|
|
@ -0,0 +1,279 @@
|
|
|
|
|
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
|
|
|
|
|
}
|