commit b5bdd85c3a415f86ed135d1860f08fc6328c4fa0 Author: andrew Date: Tue Feb 25 20:22:12 2025 +0300 Initial version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e859d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +/bin/ +/.idea/ +/modules/migration/bindata.go +/modules/options/bindata.go +/modules/templates/bindata.go +/modules/migration/bindata.go.hash +/modules/options/bindata.go.hash +/modules/templates/bindata.go.hash +/.make_evidence/tags +/gitflame +/integrations/sqlite.ini +/integrations/sqlite-log/ +/integrations/integrations/gitflame-integration-sqlite/gitflame.db +/.run +/runtime-data/ +.DS_Store +/.env +/tests/.env +.vscode/settings.json +.devdbrc diff --git a/gitlabcivalidator/errors.go b/gitlabcivalidator/errors.go new file mode 100644 index 0000000..45e3876 --- /dev/null +++ b/gitlabcivalidator/errors.go @@ -0,0 +1,34 @@ +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 +} \ No newline at end of file diff --git a/gitlabcivalidator/models.go b/gitlabcivalidator/models.go new file mode 100644 index 0000000..6eddc72 --- /dev/null +++ b/gitlabcivalidator/models.go @@ -0,0 +1,56 @@ +package gitlabcivalidator + +// ValidationOptions описывает настройки валидации. +type ValidationOptions struct { + VerifyProjectSHA bool // нужно ли проверять SHA проекта + ProjectSHA string // какой SHA нужно проверить +} + +// GitLabCIConfig описывает структуру корневого объекта .gitlab-ci.yml +type GitLabCIConfig struct { + Default interface{} `yaml:"default,omitempty"` + Include interface{} `yaml:"include,omitempty"` + BeforeScript interface{} `yaml:"before_script,omitempty"` + Image interface{} `yaml:"image,omitempty"` + Services interface{} `yaml:"services,omitempty"` + AfterScript interface{} `yaml:"after_script,omitempty"` + Variables interface{} `yaml:"variables,omitempty"` + Stages []string `yaml:"stages,omitempty"` + Cache interface{} `yaml:"cache,omitempty"` + Workflow interface{} `yaml:"workflow,omitempty"` + Jobs map[string]*Job `yaml:"jobs,omitempty"` + // В реальном GitLab CI конфигурация может позволять + // описывать job'ы не только в секции "jobs", но и на верхнем уровне. + // Для упрощения в этом примере предполагаем, что все job'ы + // расположены под ключом "jobs". +} + +// Job описывает структуру задачи (job). +type Job struct { + Name string `yaml:"-"` + Stage string `yaml:"stage,omitempty"` + Dependencies []string `yaml:"dependencies,omitempty"` + Needs []*Need `yaml:"needs,omitempty"` + Script interface{} `yaml:"script,omitempty"` // может быть строкой или списком строк + Run interface{} `yaml:"run,omitempty"` // для альтернативного описания шагов + Rules interface{} `yaml:"rules,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" и т.д. +} + +// MaxJobNameLength здесь зададим некоторые константы, используемые для валидации +const ( + MaxJobNameLength = 63 // Примерно, как Ci::BuildNeed::MAX_JOB_NAME_LENGTH +) diff --git a/gitlabcivalidator/parser.go b/gitlabcivalidator/parser.go new file mode 100644 index 0000000..657a262 --- /dev/null +++ b/gitlabcivalidator/parser.go @@ -0,0 +1,89 @@ +package gitlabcivalidator + +import ( + "fmt" + "gopkg.in/yaml.v3" +) + +// ParseGitLabCIConfig разбирает YAML и возвращает структуру GitLabCIConfig. +func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) { + var config GitLabCIConfig + var root yaml.Node + + // Парсим YAML в узел root + err := yaml.Unmarshal(data, &root) + if err != nil { + return nil, fmt.Errorf("ошибка синтаксического анализа YAML: %w", err) + } + + // Если root пустой, значит YAML-файл был пуст или некорректен + if len(root.Content) == 0 { + return nil, fmt.Errorf("файл .gitlab-ci.yml пуст или не содержит допустимых данных") + } + + // Ожидаем, что корневой узел — это YAML-документ + if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { + return nil, fmt.Errorf("некорректный формат YAML: ожидался корневой объект") + } + + // Основной объект — первый дочерний элемент + mainNode := root.Content[0] + + // Декодируем root-объект в структуру + err = mainNode.Decode(&config) + if err != nil { + return nil, fmt.Errorf("не удалось декодировать конфигурацию GitLab CI: %w", err) + } + + // Ищем узел `jobs` вручную + for i := 0; i < len(mainNode.Content)-1; i += 2 { + keyNode := mainNode.Content[i] + valNode := mainNode.Content[i+1] + + if keyNode.Value == "jobs" && valNode.Kind == yaml.MappingNode { + // `jobs` найден, разбираем его вручную + config.Jobs = make(map[string]*Job) + + for j := 0; j < len(valNode.Content)-1; j += 2 { + jobKeyNode := valNode.Content[j] // Имя job'а + jobValNode := valNode.Content[j+1] // Значение job'а (массив или объект) + + if jobValNode.Kind == yaml.MappingNode { + job := &Job{ + Name: jobKeyNode.Value, + Line: jobKeyNode.Line, + Column: jobKeyNode.Column, + DependencyLines: make(map[string]struct{ Line, Column int }), + } + + // Декодируем содержимое job + err := jobValNode.Decode(job) + if err != nil { + return nil, fmt.Errorf("не удалось декодировать job '%s': %w", job.Name, err) + } + + for d := 0; d < len(jobValNode.Content)-1; d += 2 { + depKey := jobValNode.Content[d] + depVal := jobValNode.Content[d+1] + + if depKey.Value == "dependencies" && depVal.Kind == yaml.SequenceNode { + for k := 0; k < len(depVal.Content); k++ { + depNode := depVal.Content[k] + job.Dependencies = append(job.Dependencies, depNode.Value) + job.DependencyLines[depNode.Value] = struct { + Line int + Column int + }{depNode.Line, depNode.Column} + } + } + } + + // Сохраняем job в конфигурации + config.Jobs[job.Name] = job + } + } + } + } + + return &config, nil +} diff --git a/gitlabcivalidator/test.go b/gitlabcivalidator/test.go new file mode 100644 index 0000000..59a0aa3 --- /dev/null +++ b/gitlabcivalidator/test.go @@ -0,0 +1,45 @@ +package gitlabcivalidator + +import ( + "fmt" + "testing" +) + +func TestValidateGitLabCIFile(t *testing.T) { + // Пример корректного (упрощённого) YAML + validYAML := ` +stages: + - build + - test +jobs: + build_job: + stage: build + script: + - echo "Building..." + test_job: + stage: test + dependencies: ["build_job"] + script: + - echo "Testing..." +` + err := ValidateGitLabCIFile(validYAML, ValidationOptions{}) + if err != nil { + t.Errorf("Ожидали, что ошибок не будет, но возникла: %v", err) + } + + // Пример некорректного YAML + invalidYAML := ` +jobs: + .hidden_job: + script: + - echo "I am hidden" + # Нет ни одной видимой задачи +` + err2 := ValidateGitLabCIFile(invalidYAML, ValidationOptions{}) + if err2 == nil { + t.Errorf("Ожидали ошибку, но её не было") + } else { + fmt.Println("Полученная ошибка (ожидаемо некорректная конфигурация):") + fmt.Println(err2.Error()) + } +} diff --git a/gitlabcivalidator/validator.go b/gitlabcivalidator/validator.go new file mode 100644 index 0000000..b68f4b0 --- /dev/null +++ b/gitlabcivalidator/validator.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9393332 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitflame.ru/CI_VLDR + +go 1.18 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..4421f2b --- /dev/null +++ b/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "log" + + "gitflame.ru/CI_VLDR/gitlabcivalidator" +) + +func main() { + // Корректный (упрощённый) YAML + validYAML := ` +stages: + - build + - test +jobs: + build_job: + stage: build + script: + - echo "Building..." + test_job: + stage: test + dependencies: ["build_job"] + script: + - echo "Testing..." +` + + // Некорректный YAML — отсутствует видимая задача, есть только скрытая + invalidYAML := ` +stages: + - build + - test +jobs: + build_job: + stage: build + script: + - echo "Building..." + test_job: + stage: test1 + dependencies: ["build_job2"] + script: + - echo "Testing..." +` + + fmt.Println("=== Пример 1: Корректный YAML ===") + err := gitlabcivalidator.ValidateGitLabCIFile(validYAML, gitlabcivalidator.ValidationOptions{}) + if err != nil { + log.Fatalf("Ожидали, что ошибок не будет, но возникли: %v\n", err) + } else { + fmt.Println("Валидация успешна! Ошибок не найдено.") + } + + fmt.Println("\n=== Пример 2: Некорректный YAML ===") + err2 := gitlabcivalidator.ValidateGitLabCIFile(invalidYAML, gitlabcivalidator.ValidationOptions{}) + if err2 != nil { + fmt.Println("Ошибка валидации (ожидаемо для данного файла):") + fmt.Println(err2.Error()) + } else { + log.Fatal("Ожидали ошибку, но её не возникло") + } +} diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..42657d3 --- /dev/null +++ b/readme.txt @@ -0,0 +1,2 @@ +go mod tidy +go run main.go \ No newline at end of file