diff --git a/errors/enum.go b/errors/enum.go index a42b795..5d27037 100644 --- a/errors/enum.go +++ b/errors/enum.go @@ -3,13 +3,12 @@ package errors // ErrorCode — тип для кода ошибки. type ErrorCode string -// Перечисление кодов ошибок для валидации. const ( - // Общие ошибки ErrEmptyFile ErrorCode = "EMPTY_FILE" // файл пуст или отсутствует ErrYamlSyntax ErrorCode = "YAML_SYNTAX_ERROR" // ошибка синтаксического анализа YAML - // Ошибки, связанные с job - ErrJobNameBlank ErrorCode = "JOB_NAME_BLANK" // имя задачи пустое + + ErrJobNameBlank ErrorCode = "JOB_NAME_BLANK" // имя задачи пустое + ErrJobNameTooLong ErrorCode = "JOB_NAME_TOO_LONG" ErrMissingScript ErrorCode = "MISSING_SCRIPT" // отсутствует обязательное поле script (или run) ErrBothScriptAndRun ErrorCode = "BOTH_SCRIPT_RUN" // одновременно присутствуют script и run ErrJobStageNotExist ErrorCode = "JOB_STAGE_NOT_EXIST" // указан stage, которого нет в списке разрешённых @@ -58,6 +57,7 @@ const ( ErrServiceInvalid ErrorCode = "SERVICE_INVALID" ErrStageInvalid ErrorCode = "STAGE_INVALID" ErrVariableInvalid ErrorCode = "VARIABLE_INVALID" + ErrVariableNameTooLong ErrorCode = "VARIABLE_NAME_TOO_LONG" ErrInvalidStagesOrder ErrorCode = "INVALID_STAGES_ORDER" ErrVariablesInvalid ErrorCode = "VARIABLES_INVALID" ErrVariablesInvalidKey ErrorCode = "VARIABLES_INVALID_KEY" @@ -66,3 +66,53 @@ const ( ErrBooleanValue ErrorCode = "BOOLEAN_VALUE" // значение должно быть булевым ErrIncludeRulesInvalid ErrorCode = "INCLUDE_RULES_INVALID" // значение exists или changes не является строкой или массивом строк ) + +// ErrorSeverity maps each error code to a severity level. +var ErrorSeverity = map[ErrorCode]SeverityLevel{ + // Critical errors + ErrEmptyFile: Critical, + ErrYamlSyntax: Critical, + ErrMissingJobs: Critical, + ErrJobNameBlank: Critical, + ErrMissingScript: Critical, + ErrBothScriptAndRun: Critical, + ErrMissingStage: Critical, + ErrJobStageNotExist: Critical, + ErrUndefinedDependency: Critical, + ErrInvalidStageOrder: Critical, + ErrUndefinedNeed: Critical, + ErrCyclicDependency: Critical, + ErrStartInMissing: Critical, + ErrStartInInvalid: Critical, + ErrStartInTooLong: Critical, + ErrStartInMustBeBlank: Critical, + ErrVariableInvalid: Critical, + ErrVariablesInvalid: Critical, + ErrVariablesInvalidKey: Critical, + ErrServiceInvalid: Critical, + ErrBeforeScriptInvalid: Critical, + ErrChangesNotArrayOfStrings: Critical, + ErrChangesInvalidType: Critical, + ErrChangesTooManyEntries: Critical, + ErrChangesMissingPaths: Critical, + ErrPathsNotArrayOfStrings: Critical, + + // Warnings + ErrDuplicateNeeds: Warning, + ErrNoVisibleJob: Warning, + ErrUnknownRootKey: Warning, + ErrUnknownKey: Warning, + ErrInvalidWhen: Warning, + ErrInvalidOnly: Warning, + ErrInvalidRulesFormat: Warning, + ErrRulesOnlyExcept: Warning, + ErrRulesOnly: Warning, + ErrRulesExcept: Warning, + ErrInvalidExpressionSyntax: Warning, + ErrUnknownRulesKey: Warning, + + // Notes (if needed, add informational codes here) + ErrJobNameTooLong: Note, + ErrVariableNameTooLong: Note, + ErrNeedNameTooLong: Critical, +} diff --git a/errors/errors.go b/errors/errors.go index bbffac6..4ff193c 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -5,14 +5,27 @@ import ( "strings" ) -// ValidationErrorDetail хранит данные об одной ошибке. +// SeverityLevel represents the severity of an error. +type SeverityLevel string + +const ( + // Critical errors prevent the configuration from being valid. + Critical SeverityLevel = "CRITICAL" + // Warning are issues that are problematic but may not break the configuration. + Warning SeverityLevel = "WARNING" + // Note are non-critical messages or hints. + Note SeverityLevel = "NOTE" +) + +// ValidationErrorDetail stores data about one error type ValidationErrorDetail struct { - Code ErrorCode // Код ошибки - Message string // Текст сообщения - Line int // Номер строки, где возникла ошибка - Column int // Позиция в строке - EndLine int // Конечная строка - EndColumn int // Конечный столбец + Code ErrorCode // Error code + Message string // Text of error + Line int // Number of line, where error appeared + Column int // Column + EndLine int // Endline + EndColumn int // EndColumn + Severity SeverityLevel // Severity leevel of an error } type UnknownKeyError struct { @@ -23,31 +36,34 @@ type UnknownKeyError struct { EndColumn int } -// ValidationError представляет совокупность ошибок валидации. +// ValidationError represents all errors type ValidationError struct { Errors []ValidationErrorDetail } -// Error возвращает строковое представление всех ошибок. +// Error returns string of all errors func (ve *ValidationError) Error() string { var sb strings.Builder - sb.WriteString("Найдены ошибки валидации:\n") + sb.WriteString("Validation errors found:\n") for i, errDetail := range ve.Errors { - sb.WriteString(fmt.Sprintf("%d) [%s] (Line %d, Col %d): %s\n", i+1, errDetail.Code, errDetail.Line, errDetail.Column, errDetail.Message)) + sb.WriteString(fmt.Sprintf("%d) [%s] (%s) (Line %d, Col %d): %s\n", + i+1, errDetail.Code, errDetail.Severity, errDetail.Line, errDetail.Column, errDetail.Message)) } return sb.String() } -// AddDetail добавляет новую ошибку в список. +// AddDetail adds new error in a list func (ve *ValidationError) AddDetail(code ErrorCode, message string, line, column int) { ve.Errors = append(ve.Errors, ValidationErrorDetail{ - Code: code, - Message: message, - Line: line, - Column: column, + Code: code, + Message: message, + Line: line, + Column: column, + Severity: ErrorSeverity[code], }) } +// AddDetailWithSpan adds a new error detail with a span. func (ve *ValidationError) AddDetailWithSpan(code ErrorCode, message string, line, col, endLine, endCol int) { ve.Errors = append(ve.Errors, ValidationErrorDetail{ Code: code, @@ -56,10 +72,11 @@ func (ve *ValidationError) AddDetailWithSpan(code ErrorCode, message string, lin Column: col, EndLine: endLine, EndColumn: endCol, + Severity: ErrorSeverity[code], }) } -// IsEmpty проверяет, имеются ли ошибки. +// IsEmpty check if error exists func (ve *ValidationError) IsEmpty() bool { return len(ve.Errors) == 0 } diff --git a/errors/messages.go b/errors/messages.go index 925fad0..82d1185 100644 --- a/errors/messages.go +++ b/errors/messages.go @@ -4,10 +4,12 @@ package errors var ErrorMessages = map[ErrorCode]string{ ErrEmptyFile: "Please provide content of .gitflame-ci.yml", ErrYamlSyntax: "YAML syntax error: %v", - ErrUnknownRootKey: "unexpected key: '%s'", + ErrUnknownRootKey: "unexpected key: '%s'. Currently we not support it.", ErrBeforeScriptInvalid: "before_script config should be a string or a nested array of strings up to 10 levels deep", + ErrJobNameTooLong: "job name is too long, probably should reduce number of symbols", ErrServiceInvalid: "config should be a hash or a string", ErrStageInvalid: "stage at index %d is empty; stage config should be a string", + ErrVariableNameTooLong: "variable name %s is too long, probably should reduce number of symbols", ErrVariablesInvalid: "variables config should be a map", ErrVariablesInvalidKey: "variable '%s' uses invalid data keys: %s", ErrMissingJobs: "no jobs found in the configuration", @@ -27,7 +29,7 @@ var ErrorMessages = map[ErrorCode]string{ 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", + ErrArtifactsPathsBlank: "job '%s': artifacts paths can't be blank. Currently we supporting `paths' syntax only.", ErrChangesTooManyEntries: "has too many entries (maximum 50)", ErrChangesNotArrayOfStrings: "changes config should be an array of strings", ErrChangesMissingPaths: "changes config hash must contain key 'paths'", diff --git a/gitlabcivalidator/models.go b/gitlabcivalidator/models.go index 98376b6..da44170 100644 --- a/gitlabcivalidator/models.go +++ b/gitlabcivalidator/models.go @@ -44,8 +44,6 @@ type Job struct { Rules interface{} `yaml:"rules,omitempty"` // для динамических правил Artifacts *Artifacts `yaml:"artifacts,omitempty"` // блок artifacts - // ... могут быть и другие поля - When string `yaml:"when,omitempty"` StartIn string `yaml:"start_in,omitempty"` Only interface{} `yaml:"only,omitempty"` @@ -68,5 +66,6 @@ type Job struct { // MaxJobNameLength здесь зададим некоторые константы, используемые для валидации const ( - MaxJobNameLength = 63 // Примерно, как Ci::BuildNeed::MAX_JOB_NAME_LENGTH + MaxJobNameLength = 63 + MaxVariableNameLength = 63 ) diff --git a/gitlabcivalidator/parser.go b/gitlabcivalidator/parser.go index 7c682b5..e7d7d01 100644 --- a/gitlabcivalidator/parser.go +++ b/gitlabcivalidator/parser.go @@ -2,7 +2,7 @@ package gitlabcivalidator import ( "fmt" - "gitflame.ru/CI_VLDR/errors" + "gitflame.ru/Azaki/CI_VLDR/errors" "gopkg.in/yaml.v3" ) @@ -11,64 +11,74 @@ func ParseGitLabCIConfig(data []byte, ve *errors.ValidationError) (*GitLabCIConf var config GitLabCIConfig var root yaml.Node - // Parse YAML into the root node + // Parse YAML into the root node. err := yaml.Unmarshal(data, &root) if err != nil { return nil, fmt.Errorf("YAML syntax error: %w", err) } - - // If root is empty, the YAML file is empty or invalid if len(root.Content) == 0 { return nil, fmt.Errorf(".gitlab-ci.yml file is empty or does not contain valid data") } - - // Expect the root node to be a YAML document if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { return nil, fmt.Errorf("invalid YAML format: expected a root object") } - - // The main object is the first child mainNode := root.Content[0] - // Decode the root object into the configuration structure + // Decode known top-level keys into config. err = mainNode.Decode(&config) if err != nil { return nil, fmt.Errorf("failed to decode GitLab CI configuration: %w", err) } - // 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' found, parsing manually - config.Jobs = make(map[string]*Job) + // Allowed top-level keys that are NOT jobs. + allowedKeys := map[string]struct{}{ + "default": {}, + "include": {}, + "before_script": {}, + "image": {}, + "services": {}, + "after_script": {}, + "variables": {}, + "stages": {}, + "cache": {}, + "workflow": {}, + "changes": {}, + "paths": {}, + } - for j := 0; j < len(valNode.Content)-1; j += 2 { - jobKeyNode := valNode.Content[j] // job name - jobValNode := valNode.Content[j+1] // job definition (mapping or otherwise) + // Initialize config.Jobs if not already set. + if config.Jobs == nil { + config.Jobs = make(map[string]*Job) + } - // Create a new job with its name and positional info. + // Now, iterate over the top-level mapping keys. + if mainNode.Kind == yaml.MappingNode { + for i := 0; i < len(mainNode.Content)-1; i += 2 { + keyNode := mainNode.Content[i] + valNode := mainNode.Content[i+1] + // If the key is not one of the allowed top-level keys, treat it as a job. + if _, ok := allowedKeys[keyNode.Value]; !ok { + // Create a new Job. job := &Job{ - Name: jobKeyNode.Value, - Line: jobKeyNode.Line, - Column: jobKeyNode.Column, + Name: keyNode.Value, + Line: keyNode.Line, + Column: keyNode.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 { - err := jobValNode.Decode(job) + // Even if the job value is not a mapping node, add the job to config for further validation. + if valNode.Kind == yaml.MappingNode { + // Optionally, you can validate unknown keys inside the job mapping here. + // For example: + validateJobMapping(valNode, ve) + // Decode the job mapping. + err = valNode.Decode(job) if err != nil { 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] - + // Process inner fields manually (e.g. dependencies, needs, only, etc.) + for d := 0; d < len(valNode.Content)-1; d += 2 { + depKey := valNode.Content[d] + depVal := valNode.Content[d+1] if depKey.Value == "dependencies" && depVal.Kind == yaml.SequenceNode { for k := 0; k < len(depVal.Content); k++ { depNode := depVal.Content[k] @@ -79,7 +89,6 @@ func ParseGitLabCIConfig(data []byte, ve *errors.ValidationError) (*GitLabCIConf }{depNode.Line, depNode.Column} } } - if depKey.Value == "needs" && depVal.Kind == yaml.SequenceNode { job.NeedLines = make(map[string]struct { Line int @@ -94,7 +103,6 @@ func ParseGitLabCIConfig(data []byte, ve *errors.ValidationError) (*GitLabCIConf }{needNode.Line, needNode.Column} } } - if depKey.Value == "only" { if depVal.Kind == yaml.ScalarNode { job.Only = depVal.Value @@ -108,10 +116,12 @@ func ParseGitLabCIConfig(data []byte, ve *errors.ValidationError) (*GitLabCIConf job.OnlyLine = depKey.Line job.OnlyColumn = depKey.Column } + // You can add additional manual processing here. } + } else { + // If the job value is not a mapping, leave job fields as default. } - // If jobValNode is not a mapping, leave the job with default (nil) fields. - // Now add the job to the configuration regardless. + // Save the job in config. config.Jobs[job.Name] = job } } @@ -123,19 +133,19 @@ func ParseGitLabCIConfig(data []byte, ve *errors.ValidationError) (*GitLabCIConf // findUnknownRootKeysWithSpan scans the mainNode for unknown keys and returns their information. func findUnknownRootKeysWithSpan(mainNode *yaml.Node) []errors.UnknownKeyError { allowedKeys := map[string]struct{}{ - "default": {}, - "include": {}, - "before_script": {}, - "image": {}, - "services": {}, - "after_script": {}, - "variables": {}, - "stages": {}, - "cache": {}, - "workflow": {}, - "jobs": {}, - "changes": {}, - "paths": {}, + "default": {}, + "include": {}, + //"before_script": {}, + "image": {}, + "services": {}, + "after_script": {}, + "variables": {}, + "stages": {}, + "cache": {}, + "workflow": {}, + "jobs": {}, + "changes": {}, + "paths": {}, } var unknowns []errors.UnknownKeyError @@ -145,7 +155,7 @@ func findUnknownRootKeysWithSpan(mainNode *yaml.Node) []errors.UnknownKeyError { for i := 0; i < len(mainNode.Content)-1; i += 2 { keyNode := mainNode.Content[i] - if _, ok := allowedKeys[keyNode.Value]; !ok { + if _, ok := allowedKeys[keyNode.Value]; !ok && keyNode.Value == "before_script" { endLine, endCol := keyNode.Line, keyNode.Column+len(keyNode.Value) unknowns = append(unknowns, errors.UnknownKeyError{ Key: keyNode.Value, diff --git a/gitlabcivalidator/validator.go b/gitlabcivalidator/validator.go index 487de64..39e42b2 100644 --- a/gitlabcivalidator/validator.go +++ b/gitlabcivalidator/validator.go @@ -2,7 +2,7 @@ package gitlabcivalidator import ( "fmt" - "gitflame.ru/CI_VLDR/errors" + "gitflame.ru/Azaki/CI_VLDR/errors" "gopkg.in/yaml.v3" "strings" "time" @@ -50,7 +50,7 @@ func ValidateGitLabCIFile(content string, opts ValidationOptions) error { // Получаем корневой узел mainNode := root.Content[0] - // Проверяем неизвестные ключи и добавляем ошибки в объект валидации + //Проверяем неизвестные ключи и добавляем ошибки в объект валидации unknowns := findUnknownRootKeysWithSpan(mainNode) for _, unk := range unknowns { validationErr.AddDetailWithSpan(errors.ErrUnknownRootKey, @@ -170,7 +170,11 @@ func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) { } for varName, val := range varsMap { if isScalar(val) { - // ok + if len(varName) > MaxVariableNameLength { + ve.AddDetailWithSpan(errors.ErrVariableNameTooLong, fmt.Sprintf(errors.ErrorMessages[errors.ErrVariableNameTooLong], varName), + 0, 0, 0, 0, + ) + } } else if nested, ok := val.(map[string]interface{}); ok { invalidKeys := []string{} for k := range nested { @@ -220,8 +224,13 @@ func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) { ve.AddDetailWithSpan(errors.ErrJobNameBlank, errors.ErrorMessages[errors.ErrJobNameBlank], job.Line, job.Column, job.Line, job.Column+len(job.Name)) - continue } + + if len(job.Name) > MaxJobNameLength { + ve.AddDetailWithSpan(errors.ErrJobNameTooLong, errors.ErrorMessages[errors.ErrJobNameTooLong], + job.Line, job.Column, job.Line, job.Column+len(job.Name)) + } + if job.Name[0] != '.' { visibleCount++ if job.Stage == "" { @@ -580,6 +589,9 @@ func validateJobMapping(jobNode *yaml.Node, ve *errors.ValidationError) { "start_in": {}, "only": {}, "except": {}, + "image": {}, + "variables": {}, + "services": {}, // add any additional allowed keys for a job... } validateMappingKeys(jobNode, allowedKeys, "job", ve) diff --git a/go.mod b/go.mod index 9393332..254d09e 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitflame.ru/CI_VLDR +module gitflame.ru/Azaki/CI_VLDR go 1.18 diff --git a/main.go b/main.go index c52f798..4112ded 100644 --- a/main.go +++ b/main.go @@ -1,60 +1,71 @@ package main import ( + "bufio" "fmt" - "log" + "os" - "gitflame.ru/CI_VLDR/gitlabcivalidator" + "gitflame.ru/Azaki/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..." -` + // 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: - teaaa: - build_job: - validateJobMapping(jobValNode, ve): - script: - - c - needs: ["test_job2"] - test_job2: - stage: test -` + // invalidYAML := ` + //stages: + // - build + // - test + //jobs: + // teaaa: + // build_job: + // validateJobMapping(jobValNode, ve): + // script: + // - c + // needs: ["test_job2"] + // test_job2: + // stage: test + //` - fmt.Println("=== Пример 1: Корректный YAML ===") - err := gitlabcivalidator.ValidateGitLabCIFile(validYAML, gitlabcivalidator.ValidationOptions{}) - if err != nil { - log.Fatalf("Ожидали, что ошибок не будет, но возникли: %v\n", err) - } else { - fmt.Println("Валидация успешна! Ошибок не найдено.") + scanner := bufio.NewScanner(os.Stdin) + var inputYAML string + for scanner.Scan() { + inputYAML += scanner.Text() + "\n" + } + + if err := scanner.Err(); err != nil { + fmt.Println("Ошибка чтения ввода:", err) + return } - fmt.Println("\n=== Пример 2: Некорректный YAML ===") - err2 := gitlabcivalidator.ValidateGitLabCIFile(invalidYAML, gitlabcivalidator.ValidationOptions{}) - if err2 != nil { - fmt.Println("Ошибка валидации (ожидаемо для данного файла):") - fmt.Println(err2.Error()) + err := gitlabcivalidator.ValidateGitLabCIFile(inputYAML, gitlabcivalidator.ValidationOptions{}) + if err != nil { + fmt.Println(err.Error()) } else { - log.Fatal("Ожидали ошибку, но её не возникло") + fmt.Println("no errors") } + + //fmt.Println("\n=== Пример 2: Некорректный YAML ===") + //err2 := gitlabcivalidator.ValidateGitLabCIFile(invalidYAML, gitlabcivalidator.ValidationOptions{}) + //if err2 != nil { + // fmt.Println("Ошибка валидации (ожидаемо для данного файла):") + // fmt.Println(err2.Error()) + //} else { + // log.Fatal("Ожидали ошибку, но её не возникло") + //} }