Initial version

main
andrew 1 month ago
commit b5bdd85c3a

20
.gitignore vendored

@ -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

@ -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
}

@ -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
)

@ -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
}

@ -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())
}
}

@ -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
}

@ -0,0 +1,5 @@
module gitflame.ru/CI_VLDR
go 1.18
require gopkg.in/yaml.v3 v3.0.1

@ -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=

@ -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("Ожидали ошибку, но её не возникло")
}
}

@ -0,0 +1,2 @@
go mod tidy
go run main.go
Loading…
Cancel
Save