feat(gitlab-sim): ✨ first commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.k3nny.fr/gitlab-sim/internal/model"
|
||||
)
|
||||
|
||||
func checkDependencies(p *model.Pipeline) []Finding {
|
||||
stageIndex := make(map[string]int, len(p.Stages))
|
||||
for i, s := range p.Stages {
|
||||
stageIndex[s] = i
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
for name, job := range p.Jobs {
|
||||
if len(job.Dependencies) == 0 {
|
||||
continue
|
||||
}
|
||||
jobStageIdx, jobHasStage := stageIndex[job.Stage]
|
||||
for _, dep := range job.Dependencies {
|
||||
depJob, exists := p.Jobs[dep]
|
||||
if !exists {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'dependencies' references unknown job %q", dep),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if len(p.Stages) > 0 && jobHasStage && depJob.Stage != "" {
|
||||
depIdx, depHasStage := stageIndex[depJob.Stage]
|
||||
if depHasStage && depIdx >= jobStageIdx {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/gitlab-sim/internal/model"
|
||||
)
|
||||
|
||||
var validJobWhen = map[string]bool{
|
||||
"on_success": true,
|
||||
"on_failure": true,
|
||||
"always": true,
|
||||
"manual": true,
|
||||
"delayed": true,
|
||||
"never": true,
|
||||
}
|
||||
|
||||
var validRuleWhen = map[string]bool{
|
||||
"on_success": true,
|
||||
"on_failure": true,
|
||||
"always": true,
|
||||
"manual": true,
|
||||
"delayed": true,
|
||||
"never": true,
|
||||
}
|
||||
|
||||
var validWorkflowRuleWhen = map[string]bool{
|
||||
"always": true,
|
||||
"never": true,
|
||||
}
|
||||
|
||||
var validArtifactsWhen = map[string]bool{
|
||||
"on_success": true,
|
||||
"on_failure": true,
|
||||
"always": true,
|
||||
}
|
||||
|
||||
var validCacheWhen = map[string]bool{
|
||||
"on_success": true,
|
||||
"on_failure": true,
|
||||
"always": true,
|
||||
}
|
||||
|
||||
var validCachePolicy = map[string]bool{
|
||||
"pull": true,
|
||||
"push": true,
|
||||
"pull-push": true,
|
||||
}
|
||||
|
||||
var validRetryWhen = map[string]bool{
|
||||
"always": true,
|
||||
"unknown_failure": true,
|
||||
"script_failure": true,
|
||||
"api_failure": true,
|
||||
"stuck_or_timeout_failure": true,
|
||||
"runner_system_failure": true,
|
||||
"runner_unsupported": true,
|
||||
"stale_schedule": true,
|
||||
"job_execution_timeout": true,
|
||||
"archived_failure": true,
|
||||
"unmet_prerequisites": true,
|
||||
"scheduler_failure": true,
|
||||
"data_integrity_failure": true,
|
||||
"forward_deployment_failure": true,
|
||||
"insufficient_bridge_permissions": true,
|
||||
"downstream_bridge_project_not_found": true,
|
||||
"invalid_bridge_trigger": true,
|
||||
"upstream_bridge_project_not_found": true,
|
||||
"protected_environment_failure": true,
|
||||
"pipeline_loop_detected": true,
|
||||
"excluded_by_rules": true,
|
||||
}
|
||||
|
||||
var validEnvironmentAction = map[string]bool{
|
||||
"start": true,
|
||||
"stop": true,
|
||||
"prepare": true,
|
||||
"verify": true,
|
||||
"access": true,
|
||||
}
|
||||
|
||||
func checkJobKeywords(name string, job model.Job) []Finding {
|
||||
var findings []Finding
|
||||
findings = append(findings, checkWhen(name, job)...)
|
||||
findings = append(findings, checkParallel(name, job)...)
|
||||
findings = append(findings, checkRetry(name, job)...)
|
||||
findings = append(findings, checkAllowFailure(name, job)...)
|
||||
findings = append(findings, checkInterruptible(name, job)...)
|
||||
findings = append(findings, checkTrigger(name, job)...)
|
||||
findings = append(findings, checkCoverage(name, job)...)
|
||||
findings = append(findings, checkRelease(name, job)...)
|
||||
findings = append(findings, checkEnvironment(name, job)...)
|
||||
findings = append(findings, checkArtifacts(name, job)...)
|
||||
findings = append(findings, checkCache(name, job)...)
|
||||
findings = append(findings, checkRules(name, job)...)
|
||||
findings = append(findings, checkImage(name, job)...)
|
||||
findings = append(findings, checkInherit(name, job)...)
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkWhen(name string, job model.Job) []Finding {
|
||||
var findings []Finding
|
||||
if job.When != "" && !validJobWhen[job.When] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", job.When),
|
||||
})
|
||||
}
|
||||
if job.When == "delayed" && job.StartIn == "" {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')",
|
||||
})
|
||||
}
|
||||
if job.When != "delayed" && job.StartIn != "" {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'start_in' is only valid when 'when: delayed'",
|
||||
})
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkParallel(name string, job model.Job) []Finding {
|
||||
if job.Parallel == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := job.Parallel.(type) {
|
||||
case int:
|
||||
if v < 2 || v > 200 {
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'parallel' must be between 2 and 200, got %d", v),
|
||||
}}
|
||||
}
|
||||
case map[string]any:
|
||||
if _, ok := v["matrix"]; !ok {
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'parallel' map form must have a 'matrix' key",
|
||||
}}
|
||||
}
|
||||
default:
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'parallel' must be an integer (2–200) or a map with 'matrix'",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkRetry(name string, job model.Job) []Finding {
|
||||
if job.Retry == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := job.Retry.(type) {
|
||||
case int:
|
||||
if v < 0 || v > 2 {
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'retry' must be 0, 1, or 2; got %d", v),
|
||||
}}
|
||||
}
|
||||
case map[string]any:
|
||||
var findings []Finding
|
||||
if maxVal, ok := v["max"]; ok {
|
||||
if n, ok := maxVal.(int); ok && (n < 0 || n > 2) {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'retry.max' must be 0, 1, or 2; got %d", n),
|
||||
})
|
||||
}
|
||||
}
|
||||
if whenVal, ok := v["when"]; ok {
|
||||
findings = append(findings, validateRetryWhen(name, whenVal)...)
|
||||
}
|
||||
return findings
|
||||
default:
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'retry' must be an integer (0–2) or a map with 'max'/'when'",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRetryWhen(name string, val any) []Finding {
|
||||
var findings []Finding
|
||||
check := func(s string) {
|
||||
if !validRetryWhen[s] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'retry.when' has invalid value %q", s),
|
||||
})
|
||||
}
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
check(v)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
check(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkAllowFailure(name string, job model.Job) []Finding {
|
||||
if job.Allow == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := job.Allow.(type) {
|
||||
case bool:
|
||||
// valid
|
||||
case map[string]any:
|
||||
if _, ok := v["exit_codes"]; !ok {
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'allow_failure' map form must contain 'exit_codes'",
|
||||
}}
|
||||
}
|
||||
default:
|
||||
_ = v
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'allow_failure' must be a boolean or a map with 'exit_codes'",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkInterruptible(name string, job model.Job) []Finding {
|
||||
if job.Interruptible == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := job.Interruptible.(bool); !ok {
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'interruptible' must be a boolean",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkTrigger(name string, job model.Job) []Finding {
|
||||
if job.Trigger == nil {
|
||||
return nil
|
||||
}
|
||||
var findings []Finding
|
||||
if len(job.Script) > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "jobs with 'trigger' cannot use 'script'",
|
||||
})
|
||||
}
|
||||
if m, ok := job.Trigger.(map[string]any); ok {
|
||||
_, hasProject := m["project"]
|
||||
_, hasInclude := m["include"]
|
||||
if !hasProject && !hasInclude {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'trigger' map must specify 'project' or 'include'",
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
var coveragePattern = regexp.MustCompile(`^/.*/$`)
|
||||
|
||||
func checkCoverage(name string, job model.Job) []Finding {
|
||||
if job.Coverage == "" {
|
||||
return nil
|
||||
}
|
||||
if !coveragePattern.MatchString(job.Coverage) {
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q", job.Coverage),
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkRelease(name string, job model.Job) []Finding {
|
||||
if job.Release == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := job.Release.(map[string]any)
|
||||
if !ok {
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'release' must be a map",
|
||||
}}
|
||||
}
|
||||
tagName, exists := m["tag_name"]
|
||||
if !exists || tagName == "" || tagName == nil {
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'release' requires 'tag_name'",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkEnvironment(name string, job model.Job) []Finding {
|
||||
if job.Environment == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := job.Environment.(map[string]any)
|
||||
if !ok {
|
||||
// String form: just the environment name — valid.
|
||||
return nil
|
||||
}
|
||||
var findings []Finding
|
||||
envName, _ := m["name"]
|
||||
_, hasURL := m["url"]
|
||||
if (envName == nil || envName == "") && hasURL {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'environment.url' requires 'environment.name' to be set",
|
||||
})
|
||||
}
|
||||
if action, ok := m["action"].(string); ok && !validEnvironmentAction[action] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access", action),
|
||||
})
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkArtifacts(name string, job model.Job) []Finding {
|
||||
if job.Artifacts == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := job.Artifacts.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var findings []Finding
|
||||
if w, ok := m["when"].(string); ok && !validArtifactsWhen[w] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'artifacts.when' has invalid value %q; valid: on_success, on_failure, always", w),
|
||||
})
|
||||
}
|
||||
if _, hasExposeAs := m["expose_as"]; hasExposeAs {
|
||||
paths, _ := m["paths"]
|
||||
if paths == nil {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'artifacts.expose_as' requires 'artifacts.paths'",
|
||||
})
|
||||
}
|
||||
}
|
||||
// Pages job should publish to public/
|
||||
if name == "pages" {
|
||||
if paths, ok := m["paths"].([]any); ok {
|
||||
found := false
|
||||
for _, p := range paths {
|
||||
if s, ok := p.(string); ok && (s == "public" || strings.HasPrefix(s, "public/")) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Warning,
|
||||
Job: name,
|
||||
Message: "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkCache(name string, job model.Job) []Finding {
|
||||
if job.Cache == nil {
|
||||
return nil
|
||||
}
|
||||
var maps []map[string]any
|
||||
switch v := job.Cache.(type) {
|
||||
case map[string]any:
|
||||
maps = []map[string]any{v}
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
maps = append(maps, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
var findings []Finding
|
||||
for _, m := range maps {
|
||||
if w, ok := m["when"].(string); ok && !validCacheWhen[w] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'cache.when' has invalid value %q; valid: on_success, on_failure, always", w),
|
||||
})
|
||||
}
|
||||
if p, ok := m["policy"].(string); ok && !validCachePolicy[p] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'cache.policy' has invalid value %q; valid: pull, push, pull-push", p),
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkRules(name string, job model.Job) []Finding {
|
||||
var findings []Finding
|
||||
for i, rule := range job.Rules {
|
||||
if rule.When != "" && !validRuleWhen[rule.When] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", i, rule.When),
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkImage(name string, job model.Job) []Finding {
|
||||
if job.Image == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := job.Image.(map[string]any)
|
||||
if !ok {
|
||||
return nil // String form is valid.
|
||||
}
|
||||
imgName, _ := m["name"]
|
||||
if imgName == nil || imgName == "" {
|
||||
return []Finding{{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'image' map form requires a 'name' key",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkInherit(name string, job model.Job) []Finding {
|
||||
if job.Inherit == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := job.Inherit.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var findings []Finding
|
||||
for _, key := range []string{"default", "variables"} {
|
||||
val, exists := m[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
switch val.(type) {
|
||||
case bool, []any:
|
||||
// valid: false/true or list of names
|
||||
default:
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key),
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/gitlab-sim/internal/model"
|
||||
)
|
||||
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
Error Severity = "ERROR"
|
||||
Warning Severity = "WARNING"
|
||||
)
|
||||
|
||||
type Finding struct {
|
||||
Severity Severity
|
||||
Job string // empty for pipeline-level findings
|
||||
Message string
|
||||
}
|
||||
|
||||
func (f Finding) String() string {
|
||||
if f.Job != "" {
|
||||
return fmt.Sprintf("[%s] job %q: %s", f.Severity, f.Job, f.Message)
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s", f.Severity, f.Message)
|
||||
}
|
||||
|
||||
// Lint runs all rules against p and returns findings sorted by job name.
|
||||
func Lint(p *model.Pipeline) []Finding {
|
||||
var findings []Finding
|
||||
findings = append(findings, checkStages(p)...)
|
||||
findings = append(findings, checkWorkflow(p)...)
|
||||
findings = append(findings, checkJobs(p)...)
|
||||
findings = append(findings, checkNeeds(p)...)
|
||||
findings = append(findings, checkDependencies(p)...)
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkStages(p *model.Pipeline) []Finding {
|
||||
var findings []Finding
|
||||
if len(p.Stages) == 0 {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Warning,
|
||||
Message: "no stages defined; GitLab will use default stages (build, test, deploy)",
|
||||
})
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkWorkflow(p *model.Pipeline) []Finding {
|
||||
if p.Workflow == nil {
|
||||
return nil
|
||||
}
|
||||
var findings []Finding
|
||||
for i, rule := range p.Workflow.Rules {
|
||||
if rule.When != "" && !validWorkflowRuleWhen[rule.When] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When),
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkJobs(p *model.Pipeline) []Finding {
|
||||
var findings []Finding
|
||||
|
||||
stageSet := make(map[string]bool, len(p.Stages))
|
||||
for _, s := range p.Stages {
|
||||
stageSet[s] = true
|
||||
}
|
||||
|
||||
for name, job := range p.Jobs {
|
||||
findings = append(findings, checkJob(name, job, stageSet)...)
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
||||
var findings []Finding
|
||||
|
||||
// Hidden jobs (names starting with ".") are reusable templates; skip most checks.
|
||||
isTemplate := strings.HasPrefix(name, ".")
|
||||
isTrigger := job.Trigger != nil
|
||||
|
||||
// After extends resolution, a job with no script/run is an error.
|
||||
// Exceptions: trigger jobs, pages jobs (use pages: keyword), and template jobs.
|
||||
hasScript := len(job.Script) > 0 || job.Run != nil
|
||||
if !isTemplate && !isTrigger && job.Pages == nil && !hasScript {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "missing required field 'script' (or 'run')",
|
||||
})
|
||||
}
|
||||
|
||||
// stage must exist in declared stages (if stages were declared)
|
||||
if job.Stage != "" && len(stageSet) > 0 && !stageSet[job.Stage] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("stage %q is not defined in 'stages'", job.Stage),
|
||||
})
|
||||
}
|
||||
|
||||
// only and rules are mutually exclusive
|
||||
if job.Only != nil && len(job.Rules) > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'only' and 'rules' cannot be used together",
|
||||
})
|
||||
}
|
||||
|
||||
// except and rules are mutually exclusive
|
||||
if job.Except != nil && len(job.Rules) > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: "'except' and 'rules' cannot be used together",
|
||||
})
|
||||
}
|
||||
|
||||
// warn about deprecated only/except
|
||||
if job.Only != nil || job.Except != nil {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Warning,
|
||||
Job: name,
|
||||
Message: "'only'/'except' are deprecated; prefer 'rules'",
|
||||
})
|
||||
}
|
||||
|
||||
findings = append(findings, checkJobKeywords(name, job)...)
|
||||
|
||||
return findings
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.k3nny.fr/gitlab-sim/internal/model"
|
||||
)
|
||||
|
||||
func checkNeeds(p *model.Pipeline) []Finding {
|
||||
var findings []Finding
|
||||
|
||||
// Build a stage-index map for ordering checks.
|
||||
stageIndex := make(map[string]int, len(p.Stages))
|
||||
for i, s := range p.Stages {
|
||||
stageIndex[s] = i
|
||||
}
|
||||
|
||||
// needsGraph maps each job to the list of jobs it depends on.
|
||||
// Used for cycle detection after individual checks.
|
||||
needsGraph := make(map[string][]string)
|
||||
|
||||
for name, job := range p.Jobs {
|
||||
if len(job.Needs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
neededNames := parseNeedJobNames(job.Needs)
|
||||
needsGraph[name] = neededNames
|
||||
|
||||
jobStageIdx, jobHasStage := stageIndex[job.Stage]
|
||||
|
||||
for _, needed := range neededNames {
|
||||
neededJob, exists := p.Jobs[needed]
|
||||
if !exists {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("needs unknown job %q", needed),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// A job cannot need a job in a later stage.
|
||||
if len(p.Stages) > 0 && jobHasStage && neededJob.Stage != "" {
|
||||
neededStageIdx, neededHasStage := stageIndex[neededJob.Stage]
|
||||
if neededHasStage && neededStageIdx > jobStageIdx {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf(
|
||||
"needs %q which is in a later stage (%q after %q)",
|
||||
needed, neededJob.Stage, job.Stage,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findings = append(findings, detectNeedsCycles(needsGraph)...)
|
||||
return findings
|
||||
}
|
||||
|
||||
// parseNeedJobNames extracts job names from a needs: list.
|
||||
// Each element is either a plain string or a map with a "job" key.
|
||||
// Cross-pipeline needs (maps with a "pipeline" key) are skipped.
|
||||
func parseNeedJobNames(needs []any) []string {
|
||||
var names []string
|
||||
for _, n := range needs {
|
||||
switch v := n.(type) {
|
||||
case string:
|
||||
names = append(names, v)
|
||||
case map[string]any:
|
||||
if _, crossPipeline := v["pipeline"]; crossPipeline {
|
||||
continue
|
||||
}
|
||||
if job, ok := v["job"].(string); ok {
|
||||
names = append(names, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func detectNeedsCycles(graph map[string][]string) []Finding {
|
||||
const (
|
||||
unvisited = 0
|
||||
visiting = 1
|
||||
visited = 2
|
||||
)
|
||||
|
||||
state := make(map[string]int, len(graph))
|
||||
var findings []Finding
|
||||
reported := make(map[string]bool)
|
||||
|
||||
var visit func(name string, path []string)
|
||||
visit = func(name string, path []string) {
|
||||
switch state[name] {
|
||||
case visited:
|
||||
return
|
||||
case visiting:
|
||||
if !reported[name] {
|
||||
reported[name] = true
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
state[name] = visiting
|
||||
for _, dep := range graph[name] {
|
||||
visit(dep, append(path, name))
|
||||
}
|
||||
state[name] = visited
|
||||
}
|
||||
|
||||
for name := range graph {
|
||||
visit(name, nil)
|
||||
}
|
||||
return findings
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Parse reads a .gitlab-ci.yml file and returns a Pipeline.
|
||||
func Parse(path string) (*Pipeline, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading file: %w", err)
|
||||
}
|
||||
|
||||
// First pass: decode into a raw map to extract job keys.
|
||||
var raw map[string]yaml.Node
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing YAML: %w", err)
|
||||
}
|
||||
|
||||
// Second pass: decode known top-level fields into Pipeline.
|
||||
p := &Pipeline{}
|
||||
if err := yaml.Unmarshal(data, p); err != nil {
|
||||
return nil, fmt.Errorf("parsing pipeline structure: %w", err)
|
||||
}
|
||||
|
||||
p.Jobs = make(map[string]Job)
|
||||
p.RawJobs = make(map[string]map[string]any)
|
||||
for key, node := range raw {
|
||||
if ReservedKeys[key] {
|
||||
continue
|
||||
}
|
||||
var rawMap map[string]any
|
||||
if err := node.Decode(&rawMap); err != nil {
|
||||
return nil, fmt.Errorf("parsing raw job %q: %w", key, err)
|
||||
}
|
||||
p.RawJobs[key] = rawMap
|
||||
|
||||
var j Job
|
||||
if err := node.Decode(&j); err != nil {
|
||||
return nil, fmt.Errorf("parsing job %q: %w", key, err)
|
||||
}
|
||||
j.Name = key
|
||||
p.Jobs[key] = j
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package model
|
||||
|
||||
// Pipeline represents the top-level structure of a .gitlab-ci.yml file.
|
||||
// Unknown top-level keys are collected into Jobs.
|
||||
type Pipeline struct {
|
||||
Stages []string `yaml:"stages"`
|
||||
Variables map[string]string `yaml:"variables"`
|
||||
Default *DefaultConfig `yaml:"default"`
|
||||
Include []any `yaml:"include"`
|
||||
Workflow *Workflow `yaml:"workflow"`
|
||||
// Jobs holds every non-reserved top-level key (i.e. job definitions).
|
||||
Jobs map[string]Job `yaml:"-"`
|
||||
RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver
|
||||
}
|
||||
|
||||
type DefaultConfig struct {
|
||||
Image string `yaml:"image"`
|
||||
BeforeScript []string `yaml:"before_script"`
|
||||
AfterScript []string `yaml:"after_script"`
|
||||
Cache any `yaml:"cache"`
|
||||
Artifacts any `yaml:"artifacts"`
|
||||
Retry any `yaml:"retry"`
|
||||
Timeout string `yaml:"timeout"`
|
||||
Tags []string `yaml:"tags"`
|
||||
}
|
||||
|
||||
type Workflow struct {
|
||||
Rules []Rule `yaml:"rules"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
Name string // set by parser, not from YAML
|
||||
Stage string `yaml:"stage"`
|
||||
Script []string `yaml:"script"`
|
||||
Run any `yaml:"run"` // alternative to script (CI steps)
|
||||
BeforeScript []string `yaml:"before_script"`
|
||||
AfterScript []string `yaml:"after_script"`
|
||||
Image any `yaml:"image"`
|
||||
Services []any `yaml:"services"`
|
||||
Variables map[string]string `yaml:"variables"`
|
||||
Rules []Rule `yaml:"rules"`
|
||||
Only any `yaml:"only"`
|
||||
Except any `yaml:"except"`
|
||||
Needs []any `yaml:"needs"`
|
||||
Dependencies []string `yaml:"dependencies"`
|
||||
Artifacts any `yaml:"artifacts"`
|
||||
Cache any `yaml:"cache"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Allow any `yaml:"allow_failure"`
|
||||
When string `yaml:"when"`
|
||||
StartIn string `yaml:"start_in"`
|
||||
Timeout string `yaml:"timeout"`
|
||||
Retry any `yaml:"retry"`
|
||||
Parallel any `yaml:"parallel"`
|
||||
Extends any `yaml:"extends"`
|
||||
Trigger any `yaml:"trigger"`
|
||||
Inherit any `yaml:"inherit"`
|
||||
Environment any `yaml:"environment"`
|
||||
Release any `yaml:"release"`
|
||||
Coverage string `yaml:"coverage"`
|
||||
Secrets any `yaml:"secrets"`
|
||||
IDTokens any `yaml:"id_tokens"`
|
||||
Pages any `yaml:"pages"`
|
||||
Interruptible any `yaml:"interruptible"`
|
||||
ResourceGroup string `yaml:"resource_group"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
If string `yaml:"if"`
|
||||
When string `yaml:"when"`
|
||||
Changes []string `yaml:"changes"`
|
||||
Exists []string `yaml:"exists"`
|
||||
}
|
||||
|
||||
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
|
||||
var ReservedKeys = map[string]bool{
|
||||
"stages": true,
|
||||
"variables": true,
|
||||
"default": true,
|
||||
"include": true,
|
||||
"workflow": true,
|
||||
"spec": true, // CI component spec header
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.k3nny.fr/gitlab-sim/internal/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Resolve resolves all extends: references in p.Jobs in place.
|
||||
// Jobs are merged depth-first so that base definitions are resolved before
|
||||
// derived ones. Mutates p.Jobs with the fully merged Job structs.
|
||||
func Resolve(p *model.Pipeline) error {
|
||||
// Build extends graph: jobName -> ordered list of base job names.
|
||||
extendsGraph := make(map[string][]string, len(p.Jobs))
|
||||
for name, job := range p.Jobs {
|
||||
bases, err := parseExtends(job.Extends)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %q: invalid extends: %w", name, err)
|
||||
}
|
||||
if len(bases) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, base := range bases {
|
||||
if _, ok := p.RawJobs[base]; !ok {
|
||||
return fmt.Errorf("job %q extends unknown job %q", name, base)
|
||||
}
|
||||
}
|
||||
extendsGraph[name] = bases
|
||||
}
|
||||
|
||||
if len(extendsGraph) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Topological sort — bases must be resolved before derived jobs.
|
||||
order, err := topoSort(extendsGraph)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// resolved holds the final merged raw map for each processed job.
|
||||
resolved := make(map[string]map[string]any, len(order))
|
||||
|
||||
for _, name := range order {
|
||||
bases := extendsGraph[name]
|
||||
|
||||
// Merge bases left-to-right (later bases override earlier ones),
|
||||
// then overlay the job's own definition on top.
|
||||
merged := make(map[string]any)
|
||||
for _, baseName := range bases {
|
||||
baseMap := resolved[baseName]
|
||||
if baseMap == nil {
|
||||
baseMap = p.RawJobs[baseName]
|
||||
}
|
||||
merged = deepMerge(merged, baseMap)
|
||||
}
|
||||
merged = deepMerge(merged, p.RawJobs[name])
|
||||
resolved[name] = merged
|
||||
|
||||
// Re-decode the merged map into a Job struct.
|
||||
data, err := yaml.Marshal(merged)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %q: re-encoding merged definition: %w", name, err)
|
||||
}
|
||||
var j model.Job
|
||||
if err := yaml.Unmarshal(data, &j); err != nil {
|
||||
return fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
|
||||
}
|
||||
j.Name = name
|
||||
p.Jobs[name] = j
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseExtends normalises the extends field (string or []any) into []string.
|
||||
func parseExtends(extends any) ([]string, error) {
|
||||
if extends == nil {
|
||||
return nil, nil
|
||||
}
|
||||
switch v := extends.(type) {
|
||||
case string:
|
||||
return []string{v}, nil
|
||||
case []any:
|
||||
names := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected string item, got %T", item)
|
||||
}
|
||||
names = append(names, s)
|
||||
}
|
||||
return names, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected type %T", extends)
|
||||
}
|
||||
}
|
||||
|
||||
// topoSort returns the keys of graph in dependency order (bases first).
|
||||
// Returns an error if a cycle is detected.
|
||||
func topoSort(graph map[string][]string) ([]string, error) {
|
||||
const (
|
||||
unvisited = 0
|
||||
visiting = 1
|
||||
visited = 2
|
||||
)
|
||||
|
||||
state := make(map[string]int, len(graph))
|
||||
order := make([]string, 0, len(graph))
|
||||
|
||||
var visit func(name string) error
|
||||
visit = func(name string) error {
|
||||
switch state[name] {
|
||||
case visited:
|
||||
return nil
|
||||
case visiting:
|
||||
return fmt.Errorf("cycle in extends involving job %q", name)
|
||||
}
|
||||
state[name] = visiting
|
||||
for _, dep := range graph[name] {
|
||||
if _, inGraph := graph[dep]; inGraph {
|
||||
if err := visit(dep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
state[name] = visited
|
||||
order = append(order, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
for name := range graph {
|
||||
if err := visit(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// deepMerge returns a new map that is override merged onto base.
|
||||
// Maps are merged recursively; all other values are replaced by override.
|
||||
func deepMerge(base, override map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(base)+len(override))
|
||||
for k, v := range base {
|
||||
result[k] = v
|
||||
}
|
||||
for k, v := range override {
|
||||
if baseVal, exists := result[k]; exists {
|
||||
if baseMap, ok := baseVal.(map[string]any); ok {
|
||||
if overMap, ok := v.(map[string]any); ok {
|
||||
result[k] = deepMerge(baseMap, overMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user