package ciconfig import ( "fmt" "os" "path/filepath" "sort" "strings" "github.com/sblinch/kdl-go" "github.com/sblinch/kdl-go/document" ) func Load(repoRoot string) (Config, error) { paths, err := filepath.Glob(filepath.Join(repoRoot, ".ci", "*.kdl")) if err != nil { return Config{}, err } files := make(map[string]string, len(paths)) for _, path := range paths { content, err := os.ReadFile(path) if err != nil { return Config{}, err } files[filepath.Base(path)] = string(content) } return ParseFiles(files) } // ParseFiles parses direct .ci KDL sources in lexicographic filename order. // Job source fields retain the parsed job node and the source filename. func ParseFiles(files map[string]string) (Config, error) { paths := make([]string, 0, len(files)) for path := range files { paths = append(paths, path) } sort.Strings(paths) var cfg Config seen := map[string]struct{}{} for _, path := range paths { doc, err := kdl.Parse(strings.NewReader(files[path])) if err != nil { return Config{}, err } for _, node := range doc.Nodes { if nodeName(node) != "job" { return Config{}, fmt.Errorf("unsupported top-level node %q", nodeName(node)) } job, err := parseJob(node) if err != nil { return Config{}, err } if _, ok := seen[job.Name]; ok { return Config{}, fmt.Errorf("duplicate job %q", job.Name) } seen[job.Name] = struct{}{} job.SourceFile, job.Source = path, node.String() cfg.Jobs = append(cfg.Jobs, job) } } return cfg, nil } func parseJob(node *document.Node) (Job, error) { name, err := oneStringArg(node, "job") if err != nil { return Job{}, err } if node.Properties.Len() != 0 { return Job{}, fmt.Errorf("job %q does not accept properties", name) } job := Job{Name: name} triggerSeen, imageSeen, matrixSeen := false, false, false for _, child := range node.Children { switch nodeName(child) { case "image": if imageSeen { err = fmt.Errorf("job %q has duplicate image", job.Name) break } imageSeen = true job.Image, err = oneStringArg(child, "image") if err == nil { err = leafNode(child, "image") } case "run": var command string command, err = oneStringArg(child, "run") if err == nil { err = leafNode(child, "run") } if err == nil && strings.TrimSpace(command) == "" { err = fmt.Errorf("run requires nonblank command") } if err == nil { job.Run = append(job.Run, command) } case "secret": var secret Secret secret, err = parseSecret(child) job.Secrets = append(job.Secrets, secret) case "cache": var cache Cache cache, err = parseCache(child) job.Caches = append(job.Caches, cache) case "mount": var mount Mount mount, err = parseMount(child) job.Mounts = append(job.Mounts, mount) case "artifact": var artifact Artifact artifact, err = parseArtifact(child) job.Artifacts = append(job.Artifacts, artifact) case "publish": var publish Publish publish, err = parsePublish(child) job.Publishes = append(job.Publishes, publish) case "trigger": if triggerSeen { err = fmt.Errorf("job %q has duplicate trigger", job.Name) break } triggerSeen = true job.Trigger, err = parseTrigger(child) case "matrix": if matrixSeen { err = fmt.Errorf("job %q has duplicate matrix", job.Name) break } matrixSeen = true job.Matrix, err = parseMatrix(child) default: err = fmt.Errorf("unsupported job node %q", nodeName(child)) } if err != nil { return Job{}, err } } if strings.TrimSpace(job.Image) == "" { return Job{}, fmt.Errorf("job %q missing image", job.Name) } if len(job.Run) == 0 { return Job{}, fmt.Errorf("job %q missing run", job.Name) } if job.MatrixSize() > MaxMatrixChildren { return Job{}, fmt.Errorf("job %q matrix exceeds %d children", job.Name, MaxMatrixChildren) } if err := validateJobEnvironment(job); err != nil { return Job{}, err } if err := validateMounts(job); err != nil { return Job{}, err } if err := validateArtifacts(job); err != nil { return Job{}, err } return job, nil } func validateJobEnvironment(job Job) error { seen := map[string]bool{} secretNames, cacheNames := map[string]bool{}, map[string]bool{} for _, secret := range job.Secrets { if secretNames[secret.Name] { return fmt.Errorf("job %q has duplicate secret %q", job.Name, secret.Name) } secretNames[secret.Name] = true } for _, cache := range job.Caches { if cacheNames[cache.Name] { return fmt.Errorf("job %q has duplicate cache %q", job.Name, cache.Name) } cacheNames[cache.Name] = true } for _, secret := range job.Secrets { if secret.Env == "" { continue } if !environmentName(secret.Env) { return fmt.Errorf("secret %q has invalid env name %q", secret.Name, secret.Env) } if strings.HasPrefix(secret.Env, "CI_") { return fmt.Errorf("secret %q cannot set reserved env %q", secret.Name, secret.Env) } if seen[secret.Env] { return fmt.Errorf("job %q has duplicate secret env %q", job.Name, secret.Env) } seen[secret.Env] = true } for _, axis := range job.Matrix.Axes { name := "CI_MATRIX_" + strings.ToUpper(axis.Name) if !environmentName(name) { return fmt.Errorf("matrix axis %q produces invalid env name %q", axis.Name, name) } if seen[name] { return fmt.Errorf("matrix axis %q collides on env %q", axis.Name, name) } seen[name] = true } return nil } func environmentName(value string) bool { for i, char := range value { if char == '_' || char >= 'A' && char <= 'Z' || char >= 'a' && char <= 'z' || i > 0 && char >= '0' && char <= '9' { continue } return false } return value != "" } func parseSecret(node *document.Node) (Secret, error) { name, err := oneStringArg(node, "secret") if err != nil { return Secret{}, err } if len(node.Arguments) != 1 || len(node.Children) != 0 { return Secret{}, fmt.Errorf("secret %q does not accept extra arguments or children", name) } for key := range node.Properties.Unordered() { if key != "env" && key != "file" { return Secret{}, fmt.Errorf("secret %q has unsupported property %q", name, key) } } secret := Secret{Name: name} if value, ok := node.Properties.Get("env"); ok { secret.Env, err = stringValueStrict(value, "secret env") if err != nil { return Secret{}, err } } if value, ok := node.Properties.Get("file"); ok { secret.File, err = stringValueStrict(value, "secret file") if err != nil { return Secret{}, err } } if (secret.Env == "") == (secret.File == "") { return Secret{}, fmt.Errorf("secret %q requires exactly one of env or file", name) } if secret.File != "" && !safeRelativePath(secret.File) { return Secret{}, fmt.Errorf("secret %q has unsafe file path %q", name, secret.File) } return secret, nil } func parseCache(node *document.Node) (Cache, error) { name, err := oneStringArg(node, "cache") if err != nil { return Cache{}, err } if len(node.Arguments) != 1 || node.Properties.Len() != 0 { return Cache{}, fmt.Errorf("cache %q does not accept properties", name) } cache := Cache{Name: name} pathSeen, keySeen := false, false for _, child := range node.Children { switch nodeName(child) { case "path": if pathSeen { return Cache{}, fmt.Errorf("cache %q has duplicate path", name) } pathSeen = true cache.Path, err = oneStringArg(child, "path") if err == nil { err = leafNode(child, "path") } case "key": if len(child.Arguments) != 0 || child.Properties.Len() != 0 { return Cache{}, fmt.Errorf("cache key accepts only file children") } if keySeen { return Cache{}, fmt.Errorf("cache %q has duplicate key", name) } keySeen = true for _, key := range child.Children { if nodeName(key) != "file" { return Cache{}, fmt.Errorf("unsupported cache key node %q", nodeName(key)) } var value string value, err = oneStringArg(key, "file") if err == nil { err = leafNode(key, "file") } if err != nil { break } cache.KeyFiles = append(cache.KeyFiles, value) } default: return Cache{}, fmt.Errorf("unsupported cache node %q", nodeName(child)) } if err != nil { return Cache{}, err } } if !safeRelativePath(cache.Path) { return Cache{}, fmt.Errorf("cache %q requires a safe path", name) } if len(cache.KeyFiles) == 0 { return Cache{}, fmt.Errorf("cache %q requires key files", name) } for _, path := range cache.KeyFiles { if !safeRelativePath(path) { return Cache{}, fmt.Errorf("cache %q has unsafe key file %q", name, path) } } return cache, nil } func parseMount(node *document.Node) (Mount, error) { if len(node.Arguments) != 3 || node.Properties.Len() != 0 || len(node.Children) != 0 { return Mount{}, fmt.Errorf("mount requires type, name, and target") } values := make([]string, 3) for i, arg := range node.Arguments { value, ok := arg.ResolvedValue().(string) if !ok { return Mount{}, fmt.Errorf("mount arguments must be strings") } values[i] = value } mount := Mount{Type: values[0], Name: values[1], Target: values[2]} if mount.Type != "auth" && mount.Type != "cache" { return Mount{}, fmt.Errorf("mount %q has unsupported type %q", mount.Name, mount.Type) } if !safeSegment(mount.Name) { return Mount{}, fmt.Errorf("mount %q has unsafe name", mount.Name) } if !safeMountTarget(mount.Target) { return Mount{}, fmt.Errorf("mount %q has unsafe target %q", mount.Name, mount.Target) } return mount, nil } func parseArtifact(node *document.Node) (Artifact, error) { name, err := oneStringArg(node, "artifact") if err != nil { return Artifact{}, err } if !safeSegment(name) { return Artifact{}, fmt.Errorf("artifact %q has unsafe name", name) } if node.Properties.Len() != 0 { return Artifact{}, fmt.Errorf("artifact %q does not accept properties", name) } artifact := Artifact{Name: name} seenFlags := map[string]bool{} for _, child := range node.Children { switch nodeName(child) { case "path": path, parseErr := oneStringArg(child, "path") if parseErr == nil { parseErr = leafNode(child, "path") } if parseErr != nil { return Artifact{}, parseErr } if !safeRelativePath(path) { return Artifact{}, fmt.Errorf("artifact %q has unsafe path %q", name, path) } artifact.Paths = append(artifact.Paths, path) case "required": if seenFlags["required"] { return Artifact{}, fmt.Errorf("artifact %q has duplicate required", name) } seenFlags["required"] = true value, boolErr := oneBoolArg(child, "required") if boolErr != nil || child.Properties.Len() != 0 || len(child.Children) != 0 || !value { return Artifact{}, fmt.Errorf("artifact %q required accepts only true", name) } artifact.Required = true case "after-failure": if seenFlags["after-failure"] { return Artifact{}, fmt.Errorf("artifact %q has duplicate after-failure", name) } seenFlags["after-failure"] = true value, boolErr := oneBoolArg(child, "after-failure") if boolErr != nil || child.Properties.Len() != 0 || len(child.Children) != 0 || !value { return Artifact{}, fmt.Errorf("artifact %q after-failure accepts only true", name) } artifact.AfterFailure = true default: return Artifact{}, fmt.Errorf("unsupported artifact node %q", nodeName(child)) } } if len(artifact.Paths) == 0 { return Artifact{}, fmt.Errorf("artifact %q requires path", name) } return artifact, nil } func validateArtifacts(job Job) error { seen := map[string]bool{} for _, artifact := range job.Artifacts { if seen[artifact.Name] { return fmt.Errorf("job %q has duplicate artifact %q", job.Name, artifact.Name) } seen[artifact.Name] = true for _, path := range artifact.Paths { for _, secret := range job.Secrets { if secret.File != "" && (path == secret.File || strings.HasPrefix(path, secret.File+"/") || strings.HasPrefix(secret.File, path+"/")) { return fmt.Errorf("artifact %q overlaps secret file %q", artifact.Name, secret.File) } } } } return nil } func validateMounts(job Job) error { seen := map[string]bool{} for i, mount := range job.Mounts { if seen[mount.Name] { return fmt.Errorf("job %q has duplicate mount %q", job.Name, mount.Name) } seen[mount.Name] = true for _, other := range job.Mounts[:i] { if mount.Target == other.Target || strings.HasPrefix(mount.Target, other.Target+"/") || strings.HasPrefix(other.Target, mount.Target+"/") { return fmt.Errorf("job %q has overlapping mount targets %q and %q", job.Name, mount.Target, other.Target) } } } return nil } func safeSegment(value string) bool { if value == "" || value == "." || value == ".." { return false } for i, char := range value { if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || (i > 0 && (char == '.' || char == '_' || char == '-')) { continue } return false } return true } func safeMountTarget(value string) bool { clean := filepath.Clean(value) return filepath.IsAbs(value) && value == clean && value != "/" && value != "/work" && !strings.HasPrefix(value, "/work/") } func parsePublish(node *document.Node) (Publish, error) { adapter, err := oneStringArg(node, "publish") if err != nil { return Publish{}, err } publish := Publish{Adapter: adapter} if len(node.Arguments) != 1 || node.Properties.Len() != 0 { return Publish{}, fmt.Errorf("publish %q does not accept properties", adapter) } seen := map[string]bool{} for _, child := range node.Children { var target *string switch nodeName(child) { case "image": target = &publish.Image case "from": target = &publish.From case "to": target = &publish.To default: return Publish{}, fmt.Errorf("unsupported publish node %q", nodeName(child)) } if seen[nodeName(child)] { return Publish{}, fmt.Errorf("publish %q has duplicate %s", adapter, nodeName(child)) } seen[nodeName(child)] = true *target, err = oneStringArg(child, nodeName(child)) if err == nil { err = leafNode(child, nodeName(child)) } if err != nil { return Publish{}, err } } if publish.To == "" { return Publish{}, fmt.Errorf("publish %q requires to", adapter) } switch adapter { case "registry": if publish.Image != "" { return Publish{}, fmt.Errorf("publish registry image is no longer supported; write an OCI archive in the job and use from") } if publish.From == "" { return Publish{}, fmt.Errorf("publish registry requires from") } case "pkg", "site": if publish.Image != "" || publish.From == "" { return Publish{}, fmt.Errorf("publish %q requires from", adapter) } default: if publish.Image != "" || publish.From == "" { return Publish{}, fmt.Errorf("publish %q requires from", adapter) } } if !safeRelativePath(publish.From) { return Publish{}, fmt.Errorf("publish %q has unsafe from path %q", adapter, publish.From) } return publish, nil } func safeRelativePath(value string) bool { clean := filepath.Clean(value) return strings.TrimSpace(value) != "" && clean != "." && !filepath.IsAbs(value) && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) } func parseTrigger(node *document.Node) (Trigger, error) { if len(node.Arguments) != 0 || node.Properties.Len() != 0 { return Trigger{}, fmt.Errorf("trigger does not accept arguments or properties") } var trigger Trigger for _, child := range node.Children { switch nodeName(child) { case "push": if trigger.Push != nil { return Trigger{}, fmt.Errorf("duplicate push trigger") } push, err := parsePush(child) if err != nil { return Trigger{}, err } trigger.Push = &push case "schedule": if trigger.Schedule != nil { return Trigger{}, fmt.Errorf("duplicate schedule trigger") } expression, err := oneStringArg(child, "schedule") if err != nil { return Trigger{}, err } if len(child.Children) != 0 || child.Properties.Len() != 0 { return Trigger{}, fmt.Errorf("schedule does not accept children or properties") } if _, err := ParseSchedule(expression); err != nil { return Trigger{}, err } trigger.Schedule = &Schedule{Expression: expression} default: return Trigger{}, fmt.Errorf("unsupported trigger node %q", nodeName(child)) } } return trigger, nil } func parsePush(node *document.Node) (Push, error) { if len(node.Arguments) != 0 || node.Properties.Len() != 0 { return Push{}, fmt.Errorf("push does not accept arguments or properties") } var push Push for _, child := range node.Children { if child.Properties.Len() != 0 || len(child.Children) != 0 { return Push{}, fmt.Errorf("push filter %q does not accept properties or children", nodeName(child)) } value, err := oneStringArg(child, nodeName(child)) if err != nil { return Push{}, err } switch nodeName(child) { case "branch": push.Branches = append(push.Branches, value) case "tag": push.Tags = append(push.Tags, value) case "include": push.Include = append(push.Include, value) case "exclude": push.Exclude = append(push.Exclude, value) default: return Push{}, fmt.Errorf("unsupported push node %q", nodeName(child)) } } if err := validateGlobs(append(append(append(append([]string{}, push.Branches...), push.Tags...), push.Include...), push.Exclude...)); err != nil { return Push{}, err } return push, nil } func parseMatrix(node *document.Node) (Matrix, error) { if len(node.Arguments) != 0 || node.Properties.Len() != 0 { return Matrix{}, fmt.Errorf("matrix does not accept arguments or properties") } matrix := Matrix{} seen := map[string]struct{}{} for _, child := range node.Children { if child.Properties.Len() != 0 || len(child.Children) != 0 { return Matrix{}, fmt.Errorf("matrix axis %q does not accept properties or children", nodeName(child)) } axis := Axis{Name: nodeName(child)} if _, ok := seen[axis.Name]; ok { return Matrix{}, fmt.Errorf("duplicate matrix axis %q", axis.Name) } seen[axis.Name] = struct{}{} for _, arg := range child.Arguments { axis.Values = append(axis.Values, stringValue(arg)) } if len(axis.Values) == 0 { return Matrix{}, fmt.Errorf("matrix axis %q has no values", axis.Name) } matrix.Axes = append(matrix.Axes, axis) } return matrix, nil } func nodeName(node *document.Node) string { if node == nil || node.Name == nil { return "" } return stringValue(node.Name) } func oneStringArg(node *document.Node, name string) (string, error) { if len(node.Arguments) != 1 { return "", fmt.Errorf("%s requires one argument", name) } return stringValueStrict(node.Arguments[0], name) } func stringValueStrict(value *document.Value, name string) (string, error) { if resolved, ok := value.ResolvedValue().(string); ok { return resolved, nil } return "", fmt.Errorf("%s requires string argument", name) } func leafNode(node *document.Node, name string) error { if node.Properties.Len() != 0 || len(node.Children) != 0 { return fmt.Errorf("%s does not accept properties or children", name) } return nil } func oneBoolArg(node *document.Node, name string) (bool, error) { if len(node.Arguments) != 1 { return false, fmt.Errorf("%s requires one argument", name) } value, ok := node.Arguments[0].ResolvedValue().(bool) if !ok { return false, fmt.Errorf("%s requires bool argument", name) } return value, nil } func stringValue(value *document.Value) string { if value == nil { return "" } if resolved, ok := value.ResolvedValue().(string); ok { return resolved } return value.ValueString() }