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 }