From de17fff9a49d7f412e8a4b7ab98ddf60e8a379ea Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 27 Feb 2025 20:27:27 +0300 Subject: [PATCH] added more errors --- errors/enum.go | 8 + errors/messages.go | 47 ++++ gitlabcivalidator/models.go | 10 +- gitlabcivalidator/parser.go | 78 ++++--- gitlabcivalidator/validator.go | 391 ++++++++++++++++++++++++--------- main.go | 12 +- 6 files changed, 395 insertions(+), 151 deletions(-) create mode 100644 errors/messages.go diff --git a/errors/enum.go b/errors/enum.go index a1bb515..a42b795 100644 --- a/errors/enum.go +++ b/errors/enum.go @@ -22,6 +22,12 @@ const ( ErrMissingJobs ErrorCode = "MISSING_JOBS" ErrArtifactsPathsBlank ErrorCode = "ARTIFACTS_PATHS_BLANK" // отсутствует или пустой блок paths в artifacts ErrUnknownRootKey ErrorCode = "UNKNOWN_ROOT_KEY" + ErrInvalidWhen ErrorCode = "INVALID_WHEN" + ErrInvalidOnly ErrorCode = "INVALID_ONLY" + ErrUnknownKey ErrorCode = "UNKNOWN_KEY" + ErrMissingStage ErrorCode = "MISSING_STAGE" + ErrInvalidChanges ErrorCode = "INVALID_CHANGES" + ErrInvalidRulesFormat ErrorCode = "INVALID_RULES_FORMAT" // Changes ErrChangesNotArrayOfStrings ErrorCode = "CHANGES_NOT_ARRAY_OF_STRINGS" @@ -51,6 +57,8 @@ const ( ErrBeforeScriptInvalid ErrorCode = "BEFORE_SCRIPT_INVALID" ErrServiceInvalid ErrorCode = "SERVICE_INVALID" ErrStageInvalid ErrorCode = "STAGE_INVALID" + ErrVariableInvalid ErrorCode = "VARIABLE_INVALID" + ErrInvalidStagesOrder ErrorCode = "INVALID_STAGES_ORDER" ErrVariablesInvalid ErrorCode = "VARIABLES_INVALID" ErrVariablesInvalidKey ErrorCode = "VARIABLES_INVALID_KEY" diff --git a/errors/messages.go b/errors/messages.go new file mode 100644 index 0000000..925fad0 --- /dev/null +++ b/errors/messages.go @@ -0,0 +1,47 @@ +package errors + +// ErrorMessages maps error codes to their default error messages. +var ErrorMessages = map[ErrorCode]string{ + ErrEmptyFile: "Please provide content of .gitflame-ci.yml", + ErrYamlSyntax: "YAML syntax error: %v", + ErrUnknownRootKey: "unexpected key: '%s'", + ErrBeforeScriptInvalid: "before_script config should be a string or a nested array of strings up to 10 levels deep", + ErrServiceInvalid: "config should be a hash or a string", + ErrStageInvalid: "stage at index %d is empty; stage config should be a string", + ErrVariablesInvalid: "variables config should be a map", + ErrVariablesInvalidKey: "variable '%s' uses invalid data keys: %s", + ErrMissingJobs: "no jobs found in the configuration", + ErrJobNameBlank: "job name cannot be blank", + ErrMissingStage: "job '%s': stage must be specified for visible jobs", + ErrJobStageNotExist: "job '%s': selected stage '%s' does not exist; available stages: %v", + ErrMissingScript: "job '%s' does not contain script", + ErrUndefinedDependency: "job '%s' (Line %d, Col %d) has dependency '%s' (Line %d, Col %d-%d) which does not exist among jobs", + ErrInvalidStageOrder: "job '%s' has dependency '%s' with a stage occurring later than '%s'", + ErrDuplicateNeeds: "job '%s' contains duplicate need '%s'", + ErrNeedNameTooLong: "job '%s' contains need '%s', which exceeds the maximum length of %d", + ErrUndefinedNeed: "job '%s' (Line %d, Col %d-%d) references a non-existent job '%s'", + ErrInvalidWhen: "job '%s': invalid when value '%s'. Allowed values are: on_success, always, on_failure, never, delayed", + ErrInvalidChanges: "job '%s': rule 'changes' is empty", + ErrUnknownRulesKey: "job '%s': unknown key in rule: %s", + ErrInvalidRulesFormat: "job '%s': rule format is invalid", + ErrInvalidOnly: "job '%s': 'only' must be a non-empty string or an array of non-empty strings", + ErrNoVisibleJob: "no visible jobs found in the jobs section", + ErrCyclicDependency: "cycle detection: a cycle was detected among jobs: %v", + ErrArtifactsPathsBlank: "job '%s': artifacts paths can't be blank", + ErrChangesTooManyEntries: "has too many entries (maximum 50)", + ErrChangesNotArrayOfStrings: "changes config should be an array of strings", + ErrChangesMissingPaths: "changes config hash must contain key 'paths'", + ErrChangesInvalidType: "should be an array or a hash", + ErrPathsNotArrayOfStrings: "paths config should be an array of strings", + ErrStartInMissing: "job '%s': start_in should be specified for delayed jobs", + ErrStartInInvalid: "job '%s': start_in should be a valid duration", + ErrStartInTooLong: "job '%s': start_in should not exceed the limit", + ErrStartInMustBeBlank: "job '%s': start_in must be blank when job is not delayed", + + ErrDependencyNotInNeeds: "job '%s': dependency '%s' should be included in needs", + ErrUnknownKey: "unexpected key '%s' found in %s", + ErrInvalidExpressionSyntax: "job '%s': invalid expression syntax in rule 'if'", + ErrIncludeRulesInvalid: "include rule key '%s' should be a string or an array of strings", + ErrVariableInvalid: "variable '%s' must be a scalar or a map", + ErrInvalidStagesOrder: "job '%s' (Line %d, Col %d) has need '%s' (Line %d, Col %d-%d) with a stage occurring later than '%s'", +} diff --git a/gitlabcivalidator/models.go b/gitlabcivalidator/models.go index 528da9e..98376b6 100644 --- a/gitlabcivalidator/models.go +++ b/gitlabcivalidator/models.go @@ -46,10 +46,12 @@ type Job struct { // ... могут быть и другие поля - When string `yaml:"when,omitempty"` - StartIn string `yaml:"start_in,omitempty"` - Only interface{} `yaml:"only,omitempty"` - Except interface{} `yaml:"except,omitempty"` + When string `yaml:"when,omitempty"` + StartIn string `yaml:"start_in,omitempty"` + Only interface{} `yaml:"only,omitempty"` + OnlyLine int `yaml:"-"` + OnlyColumn int `yaml:"-"` + Except interface{} `yaml:"except,omitempty"` Line int `yaml:"-"` // Строка в файле Column int `yaml:"-"` // Позиция в строке diff --git a/gitlabcivalidator/parser.go b/gitlabcivalidator/parser.go index 9a28f2c..7c682b5 100644 --- a/gitlabcivalidator/parser.go +++ b/gitlabcivalidator/parser.go @@ -6,63 +6,65 @@ import ( "gopkg.in/yaml.v3" ) -// ParseGitLabCIConfig разбирает YAML и возвращает структуру GitLabCIConfig. -func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) { +// ParseGitLabCIConfig parses the YAML and returns the GitLabCIConfig structure. +func ParseGitLabCIConfig(data []byte, ve *errors.ValidationError) (*GitLabCIConfig, error) { var config GitLabCIConfig var root yaml.Node - // Парсим YAML в узел root + // Parse YAML into the root node err := yaml.Unmarshal(data, &root) if err != nil { - return nil, fmt.Errorf("ошибка синтаксического анализа YAML: %w", err) + return nil, fmt.Errorf("YAML syntax error: %w", err) } - // Если root пустой, значит YAML-файл был пуст или некорректен + // If root is empty, the YAML file is empty or invalid if len(root.Content) == 0 { - return nil, fmt.Errorf("файл .gitlab-ci.yml пуст или не содержит допустимых данных") + return nil, fmt.Errorf(".gitlab-ci.yml file is empty or does not contain valid data") } - // Ожидаем, что корневой узел — это YAML-документ + // Expect the root node to be a YAML document if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { - return nil, fmt.Errorf("некорректный формат YAML: ожидался корневой объект") + return nil, fmt.Errorf("invalid YAML format: expected a root object") } - // Основной объект — первый дочерний элемент + // The main object is the first child mainNode := root.Content[0] - // Декодируем root-объект в структуру + // Decode the root object into the configuration structure err = mainNode.Decode(&config) if err != nil { - return nil, fmt.Errorf("не удалось декодировать конфигурацию GitLab CI: %w", err) + return nil, fmt.Errorf("failed to decode GitLab CI configuration: %w", err) } - // Ищем узел `jobs` вручную + // Manually parse the 'jobs' node 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` найден, разбираем его вручную + // 'jobs' found, parsing manually 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'а (массив или объект) + jobKeyNode := valNode.Content[j] // job name + jobValNode := valNode.Content[j+1] // job definition (mapping or otherwise) + + // Create a new job with its name and positional info. + job := &Job{ + Name: jobKeyNode.Value, + Line: jobKeyNode.Line, + Column: jobKeyNode.Column, + DependencyLines: make(map[string]struct{ Line, Column int }), + } + // If the job value is a mapping node, decode its content. 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) + return nil, fmt.Errorf("failed to decode job '%s': %w", job.Name, err) } - + validateJobMapping(jobValNode, ve) + // Process inner keys (dependencies, needs, only, etc.) for d := 0; d < len(jobValNode.Content)-1; d += 2 { depKey := jobValNode.Content[d] depVal := jobValNode.Content[d+1] @@ -79,7 +81,6 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) { } if depKey.Value == "needs" && depVal.Kind == yaml.SequenceNode { - // Инициализируем NeedLines, если ещё не инициализировано. job.NeedLines = make(map[string]struct { Line int Column int @@ -90,18 +91,28 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) { job.NeedLines[needNode.Value] = struct { Line int Column int - }{ - Line: needNode.Line, - Column: needNode.Column, - } + }{needNode.Line, needNode.Column} } } + if depKey.Value == "only" { + if depVal.Kind == yaml.ScalarNode { + job.Only = depVal.Value + } else if depVal.Kind == yaml.SequenceNode { + var arr []string + for k := 0; k < len(depVal.Content); k++ { + arr = append(arr, depVal.Content[k].Value) + } + job.Only = arr + } + job.OnlyLine = depKey.Line + job.OnlyColumn = depKey.Column + } } - - // Сохраняем job в конфигурации - config.Jobs[job.Name] = job } + // If jobValNode is not a mapping, leave the job with default (nil) fields. + // Now add the job to the configuration regardless. + config.Jobs[job.Name] = job } } } @@ -109,6 +120,7 @@ func ParseGitLabCIConfig(data []byte) (*GitLabCIConfig, error) { return &config, nil } +// findUnknownRootKeysWithSpan scans the mainNode for unknown keys and returns their information. func findUnknownRootKeysWithSpan(mainNode *yaml.Node) []errors.UnknownKeyError { allowedKeys := map[string]struct{}{ "default": {}, diff --git a/gitlabcivalidator/validator.go b/gitlabcivalidator/validator.go index b74bdd7..487de64 100644 --- a/gitlabcivalidator/validator.go +++ b/gitlabcivalidator/validator.go @@ -22,7 +22,7 @@ func ValidateGitLabCIFile(content string, opts ValidationOptions) error { validationErr := &errors.ValidationError{} if len(content) == 0 { - validationErr.AddDetailWithSpan(errors.ErrEmptyFile, "Please provide content of .gitflame-ci.yml", 0, 0, 0, 0) + validationErr.AddDetailWithSpan(errors.ErrEmptyFile, errors.ErrorMessages[errors.ErrEmptyFile], 0, 0, 0, 0) return validationErr } @@ -54,15 +54,15 @@ func ValidateGitLabCIFile(content string, opts ValidationOptions) error { unknowns := findUnknownRootKeysWithSpan(mainNode) for _, unk := range unknowns { validationErr.AddDetailWithSpan(errors.ErrUnknownRootKey, - fmt.Sprintf("unexpected key: '%s'", unk.Key), + fmt.Sprintf(errors.ErrorMessages[errors.ErrUnknownRootKey], unk.Key), unk.Line, unk.Column, unk.EndLine, unk.EndColumn) } // Парсим YAML в структуру конфигурации - cfg, err := ParseGitLabCIConfig([]byte(content)) + cfg, err := ParseGitLabCIConfig([]byte(content), validationErr) if err != nil { validationErr.AddDetailWithSpan(errors.ErrYamlSyntax, - fmt.Sprintf("ошибка синтаксического анализа YAML: %v", err), + fmt.Sprintf(errors.ErrorMessages[errors.ErrYamlSyntax], err), 0, 0, 0, 0) return validationErr } @@ -101,12 +101,12 @@ func validateBeforeScript(before interface{}, ve *errors.ValidationError) { if arr, ok := before.([]interface{}); ok { if !validateNestedStringArray(arr, 1, 10) { ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid, - "before_script config should be a string or a nested array of strings up to 10 levels deep", + errors.ErrorMessages[errors.ErrBeforeScriptInvalid], 0, 0, 0, 0) } } else { ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid, - "before_script config should be a string or a nested array of strings up to 10 levels deep", + errors.ErrorMessages[errors.ErrBeforeScriptInvalid], 0, 0, 0, 0) } } @@ -138,7 +138,7 @@ func validateService(cfg *GitLabCIConfig, ve *errors.ValidationError) { case map[string]interface{}: default: ve.AddDetailWithSpan(errors.ErrServiceInvalid, - "config should be a hash or a string", + errors.ErrorMessages[errors.ErrServiceInvalid], 0, 0, 0, 0) } } @@ -147,7 +147,7 @@ func validateStages(cfg *GitLabCIConfig, ve *errors.ValidationError) { for i, st := range cfg.Stages { if strings.TrimSpace(st) == "" { ve.AddDetailWithSpan(errors.ErrStageInvalid, - fmt.Sprintf("stage at index %d is empty; stage config should be a string", i), + fmt.Sprintf(errors.ErrorMessages[errors.ErrStageInvalid], i), 0, 0, 0, 0) } } @@ -159,7 +159,7 @@ func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) { } varsMap, ok := cfg.Variables.(map[string]interface{}) if !ok { - ve.AddDetailWithSpan(errors.ErrVariablesInvalid, "variables config should be a map", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrVariablesInvalid, errors.ErrorMessages[errors.ErrVariablesInvalid], 0, 0, 0, 0) return } allowedKeys := map[string]struct{}{ @@ -180,12 +180,12 @@ func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) { } if len(invalidKeys) > 0 { ve.AddDetailWithSpan(errors.ErrVariablesInvalidKey, - fmt.Sprintf("variable '%s' uses invalid data keys: %s", varName, strings.Join(invalidKeys, ", ")), + fmt.Sprintf(errors.ErrorMessages[errors.ErrVariablesInvalidKey], varName, strings.Join(invalidKeys, ", ")), 0, 0, 0, 0) } } else { - ve.AddDetailWithSpan(errors.ErrVariablesInvalid, - fmt.Sprintf("variable '%s' must be a scalar or a map", varName), + ve.AddDetailWithSpan(errors.ErrVariableInvalid, + fmt.Sprintf(errors.ErrorMessages[errors.ErrVariableInvalid], varName), 0, 0, 0, 0) } } @@ -202,7 +202,7 @@ func isScalar(val interface{}) bool { func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) { if cfg.Jobs == nil || len(cfg.Jobs) == 0 { - ve.AddDetailWithSpan(errors.ErrMissingJobs, "отсутствуют job'ы (jobs) в конфигурации", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrMissingJobs, errors.ErrorMessages[errors.ErrMissingJobs], 0, 0, 0, 0) return } @@ -215,85 +215,90 @@ func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) { dependencyGraph := make(map[string][]string) for _, job := range cfg.Jobs { - // a) Проверка имени задачи + // (a) Check that the job name is not empty. if job.Name == "" { ve.AddDetailWithSpan(errors.ErrJobNameBlank, - "job name can't be blank", - job.Line, job.Column, job.Line, job.Column+1) + errors.ErrorMessages[errors.ErrJobNameBlank], + job.Line, job.Column, job.Line, job.Column+len(job.Name)) continue } if job.Name[0] != '.' { visibleCount++ + if job.Stage == "" { + ve.AddDetailWithSpan(errors.ErrMissingStage, + fmt.Sprintf(errors.ErrorMessages[errors.ErrMissingStage], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } } - // d) Проверяем наличие хотя бы одного из script, run или trigger + // (d) Check that at least one of script is present. if job.Script == nil && job.Run == nil && job.Trigger == nil { ve.AddDetailWithSpan(errors.ErrMissingScript, - fmt.Sprintf("задача '%s' не содержит script, run или trigger", job.Name), - job.Line, job.Column, job.Line, job.Column+1) + fmt.Sprintf(errors.ErrorMessages[errors.ErrMissingScript], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } - // e) Проверка stage + // (e) Check that the specified stage is allowed. if job.Stage != "" { if _, ok := stagesAllowed[job.Stage]; !ok { ve.AddDetailWithSpan(errors.ErrJobStageNotExist, - fmt.Sprintf("задача '%s': выбранный stage '%s' не существует; доступные stage: %v", job.Name, job.Stage, cfg.Stages), - job.Line, job.Column, job.Line, job.Column+len(job.Stage)) + fmt.Sprintf(errors.ErrorMessages[errors.ErrJobStageNotExist], job.Name, job.Stage, cfg.Stages), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } } - // f) Проверка dependencies + // (f) Validate dependencies. dependencyGraph[job.Name] = append(dependencyGraph[job.Name], job.Dependencies...) for _, dep := range job.Dependencies { depPos, _ := job.DependencyLines[dep] if _, ok := cfg.Jobs[dep]; !ok { endLine, endCol := computeEndPosition(depPos.Line, depPos.Column, dep) ve.AddDetailWithSpan(errors.ErrUndefinedDependency, - fmt.Sprintf("задача '%s' (Line %d, Col %d) имеет зависимость '%s' (Line %d, Col %d-%d), которой нет среди job'ов", + fmt.Sprintf(errors.ErrorMessages[errors.ErrUndefinedDependency], job.Name, job.Line, job.Column, dep, depPos.Line, depPos.Column, endCol), job.Line, job.Column, endLine, endCol) } else { if !checkStageOrder(cfg, dep, job.Name) { ve.AddDetailWithSpan(errors.ErrInvalidStageOrder, - fmt.Sprintf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidStageOrder], job.Name, dep, job.Name), - job.Line, job.Column, job.Line, job.Column+len(dep)) + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } } } - // g) Проверка блока needs + // (g) Validate the 'needs' block. if len(job.Needs) > 0 { needNames := make(map[string]struct{}) for _, nd := range job.Needs { - // Проверка дублирования + // Check for duplicates. if _, exist := needNames[nd]; exist { ndStartLine, ndStartCol := job.NeedLines[nd].Line, job.NeedLines[nd].Column ndEndLine, ndEndCol := computeEndPosition(ndStartLine, ndStartCol, nd) ve.AddDetailWithSpan(errors.ErrDuplicateNeeds, - fmt.Sprintf("задача '%s' содержит дублирующуюся зависимость '%s' в needs", job.Name, nd), + fmt.Sprintf(errors.ErrorMessages[errors.ErrDuplicateNeeds], job.Name, nd), ndStartLine, ndStartCol, ndEndLine, ndEndCol) } else { needNames[nd] = struct{}{} } - // Если имя пустое + // Check for empty need name. if nd == "" { ve.AddDetailWithSpan(errors.ErrJobNameBlank, - fmt.Sprintf("задача '%s' имеет needs с пустым именем задачи", job.Name), - job.Line, job.Column, job.Line, job.Column+1) + fmt.Sprintf(errors.ErrorMessages[errors.ErrJobNameBlank], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } - // Проверка максимальной длины + // Check maximum length. if len(nd) > MaxJobNameLength { ndStartLine, ndStartCol := job.NeedLines[nd].Line, job.NeedLines[nd].Column ndEndLine, ndEndCol := computeEndPosition(ndStartLine, ndStartCol, nd) ve.AddDetailWithSpan(errors.ErrNeedNameTooLong, - fmt.Sprintf("задача '%s' содержит needs '%s', длина имени задачи превышает %d", job.Name, nd, MaxJobNameLength), + fmt.Sprintf(errors.ErrorMessages[errors.ErrNeedNameTooLong], job.Name, nd, MaxJobNameLength), ndStartLine, ndStartCol, ndEndLine, ndEndCol) } - // Проверяем, что задача из needs существует + // Check that the referenced job in 'needs' exists. needPos, posExists := job.NeedLines[nd] if !posExists { needPos = struct{ Line, Column int }{Line: job.Line, Column: job.Column} @@ -301,12 +306,12 @@ func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) { if _, ok := cfg.Jobs[nd]; !ok { ndEndLine, ndEndCol := computeEndPosition(needPos.Line, needPos.Column, nd) ve.AddDetailWithSpan(errors.ErrUndefinedNeed, - fmt.Sprintf("задача '%s' (Line %d, Col %d-%d) ссылается на несуществующую задачу '%s'", job.Name, needPos.Line, needPos.Column, ndEndCol, nd), + fmt.Sprintf(errors.ErrorMessages[errors.ErrUndefinedNeed], job.Name, needPos.Line, needPos.Column, ndEndCol, nd), needPos.Line, needPos.Column, ndEndLine, ndEndCol) } else if !checkStageOrder(cfg, nd, job.Name) { ndEndLine, ndEndCol := computeEndPosition(needPos.Line, needPos.Column, nd) - ve.AddDetailWithSpan(errors.ErrInvalidStageOrder, - fmt.Sprintf("задача '%s' (Line %d, Col %d) имеет need '%s' (Line %d, Col %d-%d) со стадией, идущей позже, чем '%s'", + ve.AddDetailWithSpan(errors.ErrInvalidStagesOrder, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidStagesOrder], job.Name, job.Line, job.Column, nd, needPos.Line, needPos.Column, ndEndCol, job.Name), needPos.Line, needPos.Column, ndEndLine, ndEndCol) } @@ -319,18 +324,20 @@ func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) { validateJobDelayedParameters(job, ve) validateJobDependenciesNeedsConsistency(job, ve) validateJobRules(job, ve) + validateJobWhen(job, ve) + validateJobOnly(job, ve) } if visibleCount == 0 { ve.AddDetailWithSpan(errors.ErrNoVisibleJob, - "в разделе jobs не обнаружено ни одной «видимой» задачи", + errors.ErrorMessages[errors.ErrNoVisibleJob], 0, 0, 0, 0) } cycle := detectCycles(dependencyGraph) if len(cycle) > 0 { ve.AddDetailWithSpan(errors.ErrCyclicDependency, - fmt.Sprintf("проверка циклических зависимостей: обнаружен цикл между job: %v", cycle), + fmt.Sprintf(errors.ErrorMessages[errors.ErrCyclicDependency], cycle), 0, 0, 0, 0) } } @@ -339,8 +346,8 @@ func validateJobArtifacts(job *Job, ve *errors.ValidationError) { if job.Artifacts != nil { if len(job.Artifacts.Paths) == 0 { ve.AddDetailWithSpan(errors.ErrArtifactsPathsBlank, - fmt.Sprintf("задача '%s': artifacts paths can't be blank", job.Name), - job.Line, job.Column, job.Line, job.Column+1) + fmt.Sprintf(errors.ErrorMessages[errors.ErrArtifactsPathsBlank], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } } } @@ -374,38 +381,38 @@ func validateChanges(cfg *GitLabCIConfig, ve *errors.ValidationError) { switch v := cfg.Changes.(type) { case []interface{}: if len(v) > 50 { - ve.AddDetailWithSpan(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrChangesTooManyEntries, errors.ErrorMessages[errors.ErrChangesTooManyEntries], 0, 0, 0, 0) } for _, item := range v { if _, ok := item.(string); !ok { - ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, errors.ErrorMessages[errors.ErrChangesNotArrayOfStrings], 0, 0, 0, 0) break } } case map[string]interface{}: paths, ok := v["paths"] if !ok { - ve.AddDetailWithSpan(errors.ErrChangesMissingPaths, "changes config hash must contain key 'paths'", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrChangesMissingPaths, errors.ErrorMessages[errors.ErrChangesMissingPaths], 0, 0, 0, 0) } else { arr, ok := paths.([]interface{}) if !ok { - ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, errors.ErrorMessages[errors.ErrChangesNotArrayOfStrings], 0, 0, 0, 0) } else { if len(arr) > 50 { - ve.AddDetailWithSpan(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrChangesTooManyEntries, errors.ErrorMessages[errors.ErrChangesTooManyEntries], 0, 0, 0, 0) } for _, item := range arr { if _, ok := item.(string); !ok { - ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, errors.ErrorMessages[errors.ErrPathsNotArrayOfStrings], 0, 0, 0, 0) break } } } } case string: - ve.AddDetailWithSpan(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrChangesInvalidType, errors.ErrorMessages[errors.ErrChangesInvalidType], 0, 0, 0, 0) default: - ve.AddDetailWithSpan(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrChangesInvalidType, errors.ErrorMessages[errors.ErrChangesInvalidType], 0, 0, 0, 0) } } @@ -415,12 +422,12 @@ func validatePaths(cfg *GitLabCIConfig, ve *errors.ValidationError) { } arr, ok := cfg.Paths.([]interface{}) if !ok { - ve.AddDetailWithSpan(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrPathsNotArrayOfStrings, errors.ErrorMessages[errors.ErrPathsNotArrayOfStrings], 0, 0, 0, 0) return } for _, item := range arr { if _, ok := item.(string); !ok { - ve.AddDetailWithSpan(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0, 0, 0) + ve.AddDetailWithSpan(errors.ErrPathsNotArrayOfStrings, errors.ErrorMessages[errors.ErrPathsNotArrayOfStrings], 0, 0, 0, 0) break } } @@ -430,25 +437,25 @@ func validateJobDelayedParameters(job *Job, ve *errors.ValidationError) { if job.When == "delayed" { if job.StartIn == "" { ve.AddDetailWithSpan(errors.ErrStartInMissing, - fmt.Sprintf("задача '%s': start_in should be specified for delayed job", job.Name), - job.Line, job.Column, job.Line, job.Column+1) + fmt.Sprintf(errors.ErrorMessages[errors.ErrStartInMissing], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } else { d, err := time.ParseDuration(job.StartIn) if err != nil { ve.AddDetailWithSpan(errors.ErrStartInInvalid, - fmt.Sprintf("задача '%s': job start in should be a duration", job.Name), - job.Line, job.Column, job.Line, job.Column+1) + fmt.Sprintf(errors.ErrorMessages[errors.ErrStartInInvalid], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } else if d > 7*24*time.Hour { ve.AddDetailWithSpan(errors.ErrStartInTooLong, - fmt.Sprintf("задача '%s': job start in should not exceed the limit", job.Name), - job.Line, job.Column, job.Line, job.Column+1) + fmt.Sprintf(errors.ErrorMessages[errors.ErrStartInTooLong], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } } } else { if job.StartIn != "" { ve.AddDetailWithSpan(errors.ErrStartInMustBeBlank, - fmt.Sprintf("задача '%s': job start in must be blank when not delayed", job.Name), - job.Line, job.Column, job.Line, job.Column+1) + fmt.Sprintf(errors.ErrorMessages[errors.ErrStartInMustBeBlank], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } } } @@ -462,66 +469,240 @@ func validateJobDependenciesNeedsConsistency(job *Job, ve *errors.ValidationErro for _, dep := range job.Dependencies { if _, ok := needNames[dep]; !ok { ve.AddDetailWithSpan(errors.ErrDependencyNotInNeeds, - fmt.Sprintf("задача '%s': dependency '%s' should be part of needs", job.Name, dep), - job.Line, job.Column, job.Line, job.Column+len(dep)) + fmt.Sprintf(errors.ErrorMessages[errors.ErrDependencyNotInNeeds], job.Name, dep), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } } } } func validateJobRules(job *Job, ve *errors.ValidationError) { - if job.Rules != nil { - if job.Only != nil && job.Except != nil { - ve.AddDetailWithSpan(errors.ErrRulesOnlyExcept, - fmt.Sprintf("задача '%s': may not be used with rules: only, except", job.Name), - job.Line, job.Column, job.Line, job.Column+1) - } else if job.Only != nil { - ve.AddDetailWithSpan(errors.ErrRulesOnly, - fmt.Sprintf("задача '%s': may not be used with rules: only", job.Name), - job.Line, job.Column, job.Line, job.Column+1) - } else if job.Except != nil { - ve.AddDetailWithSpan(errors.ErrRulesExcept, - fmt.Sprintf("задача '%s': may not be used with rules: except", job.Name), - job.Line, job.Column, job.Line, job.Column+1) - } - if rulesMap, ok := job.Rules.(map[string]interface{}); ok { - if ifVal, exists := rulesMap["if"]; exists { - if ifStr, ok := ifVal.(string); ok { - if !validateExpressionSyntax(ifStr) { - ve.AddDetailWithSpan(errors.ErrInvalidExpressionSyntax, - fmt.Sprintf("задача '%s': invalid expression syntax", job.Name), - job.Line, job.Column, job.Line, job.Column+len(ifStr)) + switch r := job.Rules.(type) { + case map[string]interface{}: + validateRule(r, job, ve) + case []interface{}: + for _, item := range r { + if ruleMap, ok := item.(map[string]interface{}); ok { + validateRule(ruleMap, job, ve) + } else { + ve.AddDetailWithSpan(errors.ErrInvalidRulesFormat, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidRulesFormat], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } + } + } +} + +func validateJobOnly(job *Job, ve *errors.ValidationError) { + if job.Only == nil { + return + } + switch v := job.Only.(type) { + case string: + if strings.TrimSpace(v) == "" { + endLine, endCol := computeEndPosition(job.OnlyLine, job.OnlyColumn, v) + ve.AddDetailWithSpan(errors.ErrInvalidOnly, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidOnly], job.Name), + job.OnlyLine, job.OnlyColumn, endLine, endCol) + } + case []interface{}: + if len(v) == 0 { + ve.AddDetailWithSpan(errors.ErrInvalidOnly, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidOnly], job.Name), + job.OnlyLine, job.OnlyColumn, job.OnlyLine, job.OnlyColumn+1) + } else { + for _, item := range v { + if s, ok := item.(string); ok { + if strings.TrimSpace(s) == "" { + ve.AddDetailWithSpan(errors.ErrInvalidOnly, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidOnly], job.Name), + job.OnlyLine, job.OnlyColumn, job.OnlyLine, job.OnlyColumn+1) + break } } else { - ve.AddDetailWithSpan(errors.ErrInvalidExpressionSyntax, - fmt.Sprintf("задача '%s': 'if' in rules should be a string", job.Name), - job.Line, job.Column, job.Line, job.Column+1) + ve.AddDetailWithSpan(errors.ErrInvalidOnly, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidOnly], job.Name), + job.OnlyLine, job.OnlyColumn, job.OnlyLine, job.OnlyColumn+1) + break } } - if af, exists := rulesMap["allow_failure"]; exists { - if _, ok := af.(bool); !ok { - ve.AddDetailWithSpan(errors.ErrBooleanValue, - fmt.Sprintf("задача '%s': allow_failure in rules should be a boolean value", job.Name), - job.Line, job.Column, job.Line, job.Column+1) + } + case []string: + if len(v) == 0 { + ve.AddDetailWithSpan(errors.ErrInvalidOnly, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidOnly], job.Name), + job.OnlyLine, job.OnlyColumn, job.OnlyLine, job.OnlyColumn+1) + } else { + for _, s := range v { + if strings.TrimSpace(s) == "" { + ve.AddDetailWithSpan(errors.ErrInvalidOnly, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidOnly], job.Name), + job.OnlyLine, job.OnlyColumn, job.OnlyLine, job.OnlyColumn+1) + break } } - allowed := map[string]struct{}{ - "if": {}, - "when": {}, - "start_in": {}, - "allow_failure": {}, - "changes": {}, - "exists": {}, + } + default: + ve.AddDetailWithSpan(errors.ErrInvalidOnly, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidOnly], job.Name), + job.OnlyLine, job.OnlyColumn, job.OnlyLine, job.OnlyColumn+1) + } +} + +// validateMappingKeys validates that all keys in a YAML mapping node are within the allowed set. +// fieldName is used for error messages. +func validateMappingKeys(node *yaml.Node, allowedKeys map[string]struct{}, fieldName string, ve *errors.ValidationError) { + if node.Kind != yaml.MappingNode { + return + } + for i := 0; i < len(node.Content)-1; i += 2 { + keyNode := node.Content[i] + if _, ok := allowedKeys[keyNode.Value]; !ok { + endLine, endCol := keyNode.Line, keyNode.Column+len(keyNode.Value) + ve.AddDetailWithSpan(errors.ErrUnknownKey, + fmt.Sprintf(errors.ErrorMessages[errors.ErrUnknownKey], keyNode.Value, fieldName), + keyNode.Line, keyNode.Column, endLine, endCol) + } + } +} + +func validateJobMapping(jobNode *yaml.Node, ve *errors.ValidationError) { + allowedKeys := map[string]struct{}{ + "stage": {}, + "script": {}, + "run": {}, + "trigger": {}, + "needs": {}, + "dependencies": {}, + "rules": {}, + "artifacts": {}, + "when": {}, + "start_in": {}, + "only": {}, + "except": {}, + // add any additional allowed keys for a job... + } + validateMappingKeys(jobNode, allowedKeys, "job", ve) +} + +func validateRule(rule map[string]interface{}, job *Job, ve *errors.ValidationError) { + // Validate "if" field, if present. + if ifVal, exists := rule["if"]; exists { + if ifStr, ok := ifVal.(string); ok { + if !validateExpressionSyntax(ifStr) { + ve.AddDetailWithSpan(errors.ErrInvalidExpressionSyntax, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidExpressionSyntax], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } + } else { + ve.AddDetailWithSpan(errors.ErrInvalidExpressionSyntax, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidExpressionSyntax], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } + } + + // Validate "when" field, if present. + if whenVal, exists := rule["when"]; exists { + if whenStr, ok := whenVal.(string); ok { + allowedWhen := map[string]struct{}{ + "on_success": {}, + "always": {}, + "on_failure": {}, + "never": {}, + "delayed": {}, } - for key := range rulesMap { - if _, ok := allowed[key]; !ok { - ve.AddDetailWithSpan(errors.ErrUnknownRulesKey, - fmt.Sprintf("задача '%s': unknown key in rules: %s", job.Name, key), - job.Line, job.Column, job.Line, job.Column+len(key)) + if _, ok := allowedWhen[whenStr]; !ok { + endLine, endCol := computeEndPosition(job.Line, job.Column, whenStr) + ve.AddDetailWithSpan(errors.ErrInvalidWhen, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidWhen], job.Name, whenStr), + job.Line, job.Column, endLine, endCol) + } + } else { + ve.AddDetailWithSpan(errors.ErrInvalidWhen, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidWhen], job.Name, ""), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } + } + + // Validate "changes" field, if present. + if changesVal, exists := rule["changes"]; exists { + if changesVal == nil { + ve.AddDetailWithSpan(errors.ErrInvalidChanges, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidChanges], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } else { + switch v := changesVal.(type) { + case string: + if strings.TrimSpace(v) == "" { + ve.AddDetailWithSpan(errors.ErrInvalidChanges, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidChanges], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } + case []interface{}: + if len(v) == 0 { + ve.AddDetailWithSpan(errors.ErrInvalidChanges, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidChanges], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } else { + for _, item := range v { + if s, ok := item.(string); ok { + if strings.TrimSpace(s) == "" { + ve.AddDetailWithSpan(errors.ErrInvalidChanges, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidChanges], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + break + } + } else { + ve.AddDetailWithSpan(errors.ErrInvalidChanges, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidChanges], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + break + } + } } + default: + ve.AddDetailWithSpan(errors.ErrInvalidChanges, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidChanges], job.Name), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) } } } + + // Validate allowed keys in the rule. + allowed := map[string]struct{}{ + "if": {}, + "when": {}, + "start_in": {}, + "allow_failure": {}, + "changes": {}, + "exists": {}, + } + for key := range rule { + if _, ok := allowed[key]; !ok { + ve.AddDetailWithSpan(errors.ErrUnknownRulesKey, + fmt.Sprintf(errors.ErrorMessages[errors.ErrUnknownRulesKey], job.Name, key), + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } + } +} + +func validateJobWhen(job *Job, ve *errors.ValidationError) { + if job.When == "" { + return + } + allowed := map[string]struct{}{ + "on_success": {}, + "always": {}, + "on_failure": {}, + "never": {}, + "delayed": {}, + } + if _, ok := allowed[job.When]; !ok { + endLine, endCol := computeEndPosition(job.Line, job.Column, job.When) + ve.AddDetailWithSpan(errors.ErrInvalidWhen, + fmt.Sprintf(errors.ErrorMessages[errors.ErrInvalidWhen], job.Name, job.When), + job.Line, job.Column, endLine, endCol) + } } func validateExpressionSyntax(expr string) bool { @@ -592,14 +773,14 @@ func validateIncludeRuleMap(m map[string]interface{}, ve *errors.ValidationError for _, item := range val.([]interface{}) { if _, ok := item.(string); !ok { ve.AddDetailWithSpan(errors.ErrIncludeRulesInvalid, - fmt.Sprintf("include rule key '%s' should be an array of strings", key), + fmt.Sprintf(errors.ErrorMessages[errors.ErrIncludeRulesInvalid], key), 0, 0, 0, 0) break } } default: ve.AddDetailWithSpan(errors.ErrIncludeRulesInvalid, - fmt.Sprintf("include rule key '%s' should be a string or an array of strings", key), + fmt.Sprintf(errors.ErrorMessages[errors.ErrIncludeRulesInvalid], key), 0, 0, 0, 0) } } diff --git a/main.go b/main.go index 75136b5..c52f798 100644 --- a/main.go +++ b/main.go @@ -30,21 +30,15 @@ jobs: stages: - build - test -holy_cow: - - ass -asdasd: - - sasafafasf -asdasdd: - - asd jobs: + teaaa: build_job: - stage: build2 + validateJobMapping(jobValNode, ve): script: - - echo "Building..." + - c needs: ["test_job2"] test_job2: stage: test - needs: ["build_job", "test_job2", "test_job3", "test_job4"] ` fmt.Println("=== Пример 1: Корректный YAML ===")