added more errors

main
andrew 1 month ago
parent eeab89a253
commit de17fff9a4

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

@ -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'",
}

@ -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:"-"` // Позиция в строке

@ -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": {},

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

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

Loading…
Cancel
Save