repositories / bugabinga.net
bugabinga.net
personal infrastructure for bugabinga!
owned by admin
services/luci/internal/ciconfig/parse.go
Rawpackage 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()
}