Menu
AkurAI-Build
publicLatest change 834088c92ff481a8a0cc0f5825c8fb9524396e7a - Build lean Git-native AkurAI CI/CD by Ólafur Búi Ólafsson
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path};
use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
const MAX_PIPELINE_BYTES: usize = 256 * 1024;
const MAX_JOBS: usize = 64;
const MAX_VARIANTS: usize = 32;
const MAX_EXPANDED_JOBS: usize = 256;
const MAX_COMMAND_BYTES: usize = 64 * 1024;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PipelineInput {
version: u8,
jobs: Vec<JobInput>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JobInput {
name: String,
needs: Option<Vec<String>>,
#[serde(default = "docker")]
executor: String,
image: Option<String>,
shell: Option<String>,
run: Commands,
#[serde(default)]
matrix: BTreeMap<String, Vec<String>>,
#[serde(default)]
artifacts: Vec<String>,
#[serde(default)]
cache: Vec<String>,
#[serde(default)]
network: bool,
#[serde(default)]
secrets: Vec<String>,
environment: Option<String>,
#[serde(default)]
approval: bool,
#[serde(default)]
branches: Vec<String>,
#[serde(default = "default_timeout")]
timeout: u64,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Commands {
One(String),
Many(Vec<String>),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct JobSpec {
pub base_name: String,
pub name: String,
pub needs: Vec<String>,
pub executor: String,
pub image: Option<String>,
pub shell: Option<String>,
pub command: String,
pub matrix: BTreeMap<String, String>,
pub artifacts: Vec<String>,
pub cache: Vec<String>,
pub network: bool,
pub secrets: Vec<String>,
pub environment: Option<String>,
pub approval: bool,
pub branches: Vec<String>,
pub timeout: u64,
}
fn docker() -> String {
"docker".into()
}
const fn default_timeout() -> u64 {
1800
}
pub fn parse(source: &str) -> Result<Vec<JobSpec>> {
ensure!(source.len() <= MAX_PIPELINE_BYTES, "pipeline is too large");
let pipeline: PipelineInput = serde_yaml::from_str(source).context("parse .akurai.yml")?;
ensure!(pipeline.version == 1, "pipeline version must be 1");
ensure!(
!pipeline.jobs.is_empty() && pipeline.jobs.len() <= MAX_JOBS,
"pipeline must contain 1..={MAX_JOBS} jobs"
);
let mut names = BTreeSet::new();
for job in &pipeline.jobs {
validate_name("job", &job.name)?;
ensure!(
names.insert(job.name.clone()),
"duplicate job: {}",
job.name
);
}
let mut expanded = Vec::new();
let mut graph = BTreeMap::new();
let mut previous: Option<String> = None;
for job in pipeline.jobs {
validate_job(&job)?;
let needs = job
.needs
.clone()
.unwrap_or_else(|| previous.iter().cloned().collect());
for need in &needs {
ensure!(
names.contains(need),
"job {} needs unknown job {need}",
job.name
);
ensure!(need != &job.name, "job {} cannot need itself", job.name);
}
graph.insert(job.name.clone(), needs.clone());
let variants = expand_matrix(&job.matrix)?;
for matrix in variants {
let suffix = matrix
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join(",");
ensure!(
expanded.len() < MAX_EXPANDED_JOBS,
"pipeline expands beyond {MAX_EXPANDED_JOBS} jobs"
);
expanded.push(JobSpec {
base_name: job.name.clone(),
name: if suffix.is_empty() {
job.name.clone()
} else {
format!("{}[{suffix}]", job.name)
},
needs: needs.clone(),
executor: job.executor.clone(),
image: job.image.clone(),
shell: job.shell.clone(),
command: commands(&job.run)?,
matrix,
artifacts: job.artifacts.clone(),
cache: job.cache.clone(),
network: job.network,
secrets: job.secrets.clone(),
environment: job.environment.clone(),
approval: job.approval,
branches: job.branches.clone(),
timeout: job.timeout,
});
}
previous = Some(job.name);
}
validate_dag(&graph)?;
Ok(expanded)
}
fn validate_job(job: &JobInput) -> Result<()> {
ensure!(
matches!(job.executor.as_str(), "docker" | "native"),
"job {} executor must be docker or native",
job.name
);
if job.executor == "docker" {
let image = job.image.as_deref().context("docker job requires image")?;
ensure!(
!image.starts_with('-') && !image.chars().any(char::is_whitespace),
"invalid Docker image"
);
ensure!(image.len() <= 256, "Docker image is too long");
} else {
ensure!(job.image.is_none(), "native job cannot set image");
}
if let Some(environment) = &job.environment {
validate_name("environment", environment)?;
}
ensure!(
!job.approval || job.environment.is_some(),
"approval requires an environment"
);
if let Some(shell) = &job.shell {
ensure!(
matches!(shell.as_str(), "sh" | "cmd" | "powershell"),
"unsupported shell {shell}"
);
}
if let Some(platforms) = job.matrix.get("platform") {
for platform in platforms {
validate_platform(platform)?;
}
}
ensure!(
(1..=14_400).contains(&job.timeout),
"timeout must be 1..=14400 seconds"
);
ensure!(job.artifacts.len() <= 64, "too many artifact patterns");
ensure!(job.cache.len() <= 16, "too many cache paths");
for path in &job.artifacts {
validate_relative_path(path, true)?;
}
for path in &job.cache {
validate_relative_path(path, false)?;
}
for secret in &job.secrets {
ensure!(valid_env_name(secret), "invalid secret name: {secret}");
}
for branch in &job.branches {
validate_ref(branch)?;
}
commands(&job.run)?;
Ok(())
}
fn commands(value: &Commands) -> Result<String> {
let values = match value {
Commands::One(value) => vec![value.as_str()],
Commands::Many(values) => values.iter().map(String::as_str).collect(),
};
ensure!(!values.is_empty(), "run must not be empty");
ensure!(
values.iter().all(|value| !value.trim().is_empty()),
"run command must not be empty"
);
let joined = values.join("\n");
ensure!(
joined.len() <= MAX_COMMAND_BYTES,
"run command is too large"
);
Ok(joined)
}
fn expand_matrix(matrix: &BTreeMap<String, Vec<String>>) -> Result<Vec<BTreeMap<String, String>>> {
let mut variants = vec![BTreeMap::new()];
for (key, values) in matrix {
validate_name("matrix key", key)?;
ensure!(!values.is_empty(), "matrix axis {key} is empty");
ensure!(
values.len() <= MAX_VARIANTS,
"matrix axis {key} is too large"
);
let mut next = Vec::new();
for variant in &variants {
for value in values {
ensure!(
!value.is_empty() && value.len() <= 128,
"invalid matrix value"
);
let mut item = variant.clone();
item.insert(key.clone(), value.clone());
next.push(item);
ensure!(
next.len() <= MAX_VARIANTS,
"matrix expands beyond {MAX_VARIANTS} jobs"
);
}
}
variants = next;
}
Ok(variants)
}
fn validate_dag(graph: &BTreeMap<String, Vec<String>>) -> Result<()> {
let mut done = BTreeSet::new();
while done.len() < graph.len() {
let before = done.len();
for (name, needs) in graph {
if needs.iter().all(|need| done.contains(need)) {
done.insert(name.clone());
}
}
if done.len() == before {
bail!("pipeline contains a dependency cycle");
}
}
Ok(())
}
pub fn validate_ref(value: &str) -> Result<()> {
ensure!(!value.is_empty() && value.len() <= 200, "invalid Git ref");
ensure!(
!value.starts_with('-') && !value.starts_with('/') && !value.ends_with('/'),
"invalid Git ref"
);
ensure!(
!value.contains("..") && !value.contains("@{") && !value.contains('\\'),
"invalid Git ref"
);
ensure!(
value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._/-".contains(&byte)),
"invalid Git ref"
);
Ok(())
}
pub fn validate_commit(value: &str) -> Result<()> {
ensure!(
value.len() == 40 || value.len() == 64,
"commit must be a full hexadecimal object id"
);
ensure!(
value.bytes().all(|byte| byte.is_ascii_hexdigit()),
"invalid commit"
);
Ok(())
}
fn validate_relative_path(value: &str, allow_glob: bool) -> Result<()> {
ensure!(
!value.is_empty() && value.len() <= 512,
"invalid relative path"
);
ensure!(
!value.contains(['\\', ':', '\0']) && (allow_glob || !value.contains(['*', '?', '[', ']'])),
"invalid path syntax"
);
let plain = value.replace(['*', '?', '[', ']'], "x");
ensure!(
Path::new(&plain)
.components()
.all(|component| matches!(component, Component::Normal(_))),
"path must stay inside the workspace"
);
Ok(())
}
fn validate_platform(value: &str) -> Result<()> {
let mut parts = value.split('/');
let os = parts.next().unwrap_or_default();
let architecture = parts.next().unwrap_or_default();
ensure!(
os == "linux"
&& !architecture.is_empty()
&& parts.next().is_none()
&& [os, architecture].into_iter().all(|part| {
part.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| matches!(byte, b'_' | b'-')
})
}),
"invalid Docker platform: {value}"
);
Ok(())
}
fn validate_name(label: &str, value: &str) -> Result<()> {
ensure!(!value.is_empty() && value.len() <= 64, "invalid {label}");
ensure!(
value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)),
"invalid {label}: {value}"
);
Ok(())
}
fn valid_env_name(value: &str) -> bool {
let mut bytes = value.bytes();
matches!(bytes.next(), Some(b'A'..=b'Z') | Some(b'_'))
&& bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expands_matrix_and_defaults_to_linear_dependencies() -> Result<()> {
let jobs = parse(
"version: 1\njobs:\n - name: test\n image: rust:1\n run: cargo test\n matrix:\n platform: [linux/amd64, linux/arm64]\n - name: package\n image: rust:1\n run: cargo build\n",
)?;
assert_eq!(jobs.len(), 3);
assert_eq!(jobs[2].needs, ["test"]);
Ok(())
}
#[test]
fn repository_pipeline_is_valid() -> Result<()> {
let jobs = parse(include_str!("../.akurai.yml"))?;
assert_eq!(jobs.len(), 3);
Ok(())
}
#[test]
fn rejects_cycles_traversal_and_unknown_fields() {
for source in [
"version: 1\njobs:\n- name: a\n needs: [b]\n image: x\n run: x\n- name: b\n needs: [a]\n image: x\n run: x\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n artifacts: [../secret]\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n magic: true\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n approval: true\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n cache: ['cache/**']\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n matrix:\n platform: ['--privileged']\n",
] {
assert!(parse(source).is_err());
}
}
}