Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/ciconfig/parse.go

Raw
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()
}