feat(gitlab-sim): first commit

This commit is contained in:
2026-06-05 01:29:07 +02:00
commit e2334ec12d
21 changed files with 1778 additions and 0 deletions
+44
View File
@@ -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
}
+498
View File
@@ -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 (2200) 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 (02) 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
}
+139
View File
@@ -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
}
+123
View File
@@ -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
}