Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/front-matter.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ engine: copilot # Engine identifier. Defaults to copilot. Currently only 'copilo
# model: claude-opus-4.7
# timeout-minutes: 30
workspace: repo # Optional: "root", "repo" (alias: "self"), or a checked-out repository alias. If not specified, defaults to "root" when no additional repositories are listed in `repos:`, and to "repo" when one or more additional repos are checked out. See "Workspace Defaults" below.
pool: AZS-1ES-L-MMS-ubuntu-22.04 # Agent pool name (string format). Defaults to AZS-1ES-L-MMS-ubuntu-22.04.
# pool: # Alternative object format (required for 1ES if specifying os)
pool: # Optional pool configuration
vmImage: ubuntu-latest # Microsoft-hosted (default for non-1ES targets)
# pool: # Self-hosted pool
# name: MySelfHostedPool
# pool: # 1ES pool format
# name: AZS-1ES-L-MMS-ubuntu-22.04
# os: linux # Operating system: "linux" or "windows". Defaults to "linux".
repos: # compact repository declarations (replaces repositories: + checkout:)
Expand Down
2 changes: 1 addition & 1 deletion docs/targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ The output contains the same 3-job chain (Agent → Detection → Execution) as
`standalone`, with:
- Job names prefixed with the agent name for uniqueness (e.g., `DailyReview_Agent`)
- No triggers, pipeline name, or resource declarations (the parent pipeline owns those)
- Pool baked in from the front matter `pool:` field
- Pool baked in from the front matter `pool:` field (`vmImage` or `name`; defaults to `vmImage: ubuntu-latest`)

Example front matter:
```yaml
Expand Down
12 changes: 6 additions & 6 deletions docs/template-markers.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,13 @@ Should be replaced with the engine's log directory path, generated by `Engine::l

## {{ pool }}

Should be replaced with the agent pool name from the `pool` front matter field. Defaults to `AZS-1ES-L-MMS-ubuntu-22.04` if not specified.
Used by all templates under a `pool:` block and expands to:
- non-1ES targets: one line (`vmImage: <image>` or `name: <pool>`)
- 1ES target: two lines (`name: <pool>` and `os: <linux|windows>`)

The pool configuration accepts both string and object formats:
- **String format**: `pool: AZS-1ES-L-MMS-ubuntu-22.04`
- **Object format**: `pool: { name: AZS-1ES-L-MMS-ubuntu-22.04, os: linux }`

The `os` field (defaults to "linux") is primarily used for 1ES target compatibility.
Defaults:
- non-1ES: `vmImage: ubuntu-latest`
- 1ES: `name: AZS-1ES-L-MMS-ubuntu-22.04` + `os: linux`

## {{ setup_job }}

Expand Down
10 changes: 5 additions & 5 deletions src/compile/codemods/0001_repos_unified.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,14 @@ mod tests {

fn run(input: &str) -> Mapping {
let mut m: Mapping = serde_yaml::from_str(input).unwrap();
let changed = apply_codemod(&mut m, &CodemodContext {}).expect("apply");
let changed = apply_codemod(&mut m, &CodemodContext::current()).expect("apply");
assert!(changed, "expected codemod to fire on input:\n{}", input);
m
}

fn run_noop(input: &str) -> Mapping {
let mut m: Mapping = serde_yaml::from_str(input).unwrap();
let changed = apply_codemod(&mut m, &CodemodContext {}).expect("apply");
let changed = apply_codemod(&mut m, &CodemodContext::current()).expect("apply");
assert!(!changed, "expected codemod to be a no-op on input:\n{}", input);
m
}
Expand All @@ -258,7 +258,7 @@ mod tests {
let mut m: Mapping = serde_yaml::from_str(input).unwrap();
format!(
"{}",
apply_codemod(&mut m, &CodemodContext {}).unwrap_err()
apply_codemod(&mut m, &CodemodContext::current()).unwrap_err()
)
}

Expand Down Expand Up @@ -425,10 +425,10 @@ mod tests {
checkout: [a]\n",
)
.unwrap();
let first = apply_codemod(&mut m, &CodemodContext {}).expect("first");
let first = apply_codemod(&mut m, &CodemodContext::current()).expect("first");
assert!(first, "first run should fire");
let snapshot = m.clone();
let second = apply_codemod(&mut m, &CodemodContext {}).expect("second");
let second = apply_codemod(&mut m, &CodemodContext::current()).expect("second");
assert!(!second, "second run should be a no-op");
assert_eq!(m, snapshot, "second run must not mutate");
}
Expand Down
174 changes: 174 additions & 0 deletions src/compile/codemods/0002_pool_object_form.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
//! `pool: <string>` → explicit object form
//!
//! Non-1ES targets now support both self-hosted (`name`) and
//! Microsoft-hosted (`vmImage`) pool syntax. This codemod rewrites the
//! legacy scalar shorthand into an explicit object form so sources are
//! unambiguous and easier to evolve.
//!
//! When the pool field is **absent** and the compiler version is at or
//! above `INTRODUCED_IN` (the release that changed the implicit
//! default from the 1ES self-hosted pool to `vmImage: ubuntu-latest`),
//! the codemod pins the legacy default explicitly so existing
//! pipelines are not silently broken.

use anyhow::Result;
use serde_yaml::{Mapping, Value};

use super::{Codemod, CodemodContext};
use crate::compile::common::DEFAULT_ONEES_POOL;

/// Version where the pool default changed from the legacy self-hosted
/// pool to `vmImage: ubuntu-latest`.
const INTRODUCED_IN: &str = "0.30.0";

pub static CODEMOD: Codemod = Codemod {
id: "pool_object_form",
summary: "pool: <string> -> pool object form (name/vmImage)",
introduced_in: INTRODUCED_IN,
apply: apply_codemod,
};

/// Simple major.minor.patch comparison. Returns true when `version`
/// is greater than or equal to `threshold`.
fn version_gte(version: &str, threshold: &str) -> bool {
let parse = |s: &str| -> (u32, u32, u32) {
let mut parts = s.split('.');
let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
(major, minor, patch)
};
parse(version) >= parse(threshold)
}

fn apply_codemod(fm: &mut Mapping, ctx: &CodemodContext) -> Result<bool> {
let key = Value::String("pool".to_string());

let Some(pool_value) = fm.get(&key).cloned() else {
// Pool absent — only inject the legacy default when the
// compiler version is at or above the release that changed
// the implicit default. Older binaries still carry the old
// default in `resolve_pool_block`, so no rewrite is needed.
if !version_gte(ctx.compiler_version, INTRODUCED_IN) {
return Ok(false);
}
let mut mapped = Mapping::new();
mapped.insert(
Value::String("name".to_string()),
Value::String(DEFAULT_ONEES_POOL.to_string()),
);
fm.insert(key, Value::Mapping(mapped));
return Ok(true);
};

let Value::String(name) = pool_value else {
// Already object-form (or invalid in another way) — no-op.
return Ok(false);
};

let mut mapped = Mapping::new();
mapped.insert(Value::String("name".to_string()), Value::String(name));
fm.insert(key, Value::Mapping(mapped));
Ok(true)
}

#[cfg(test)]
mod tests {
use super::*;

/// Build a context with an explicit version for testing.
fn ctx(version: &'static str) -> CodemodContext {
CodemodContext {
compiler_version: version,
}
}

#[test]
fn rewrites_scalar_pool_to_name_object() {
let mut fm: Mapping = serde_yaml::from_str("name: x\ndescription: y\npool: MyPool").unwrap();
let changed = apply_codemod(&mut fm, &ctx("0.30.0")).expect("apply");
assert!(changed);
assert_eq!(
fm.get(Value::String("pool".into())).cloned(),
Some(serde_yaml::from_str::<Value>("name: MyPool").unwrap())
);
}

#[test]
fn noops_when_pool_is_already_mapping() {
let mut fm: Mapping =
serde_yaml::from_str("name: x\ndescription: y\npool:\n vmImage: ubuntu-latest")
.unwrap();
let changed = apply_codemod(&mut fm, &ctx("0.30.0")).expect("apply");
assert!(!changed);
assert_eq!(
fm.get(Value::String("pool".into())).cloned(),
Some(serde_yaml::from_str::<Value>("vmImage: ubuntu-latest").unwrap())
);
}

#[test]
fn inserts_legacy_default_when_pool_absent_and_version_gte() {
let mut fm: Mapping = serde_yaml::from_str("name: x\ndescription: y").unwrap();
let changed = apply_codemod(&mut fm, &ctx("0.30.0")).expect("apply");
assert!(changed);
assert_eq!(
fm.get(Value::String("pool".into())).cloned(),
Some(serde_yaml::from_str::<Value>("name: AZS-1ES-L-MMS-ubuntu-22.04").unwrap())
);
}

#[test]
fn noops_when_pool_absent_and_version_below() {
let mut fm: Mapping = serde_yaml::from_str("name: x\ndescription: y").unwrap();
let changed = apply_codemod(&mut fm, &ctx("0.29.0")).expect("apply");
assert!(!changed);
assert!(!fm.contains_key(Value::String("pool".into())));
}

#[test]
fn idempotent_after_inserting_legacy_default() {
let mut fm: Mapping = serde_yaml::from_str("name: x\ndescription: y").unwrap();
let changed1 = apply_codemod(&mut fm, &ctx("0.30.0")).expect("first apply");
assert!(changed1);
let changed2 = apply_codemod(&mut fm, &ctx("0.30.0")).expect("second apply");
assert!(!changed2, "second run must be a no-op");
}

#[test]
fn rewrites_legacy_default_pool_to_name_object() {
let mut fm: Mapping =
serde_yaml::from_str("name: x\ndescription: y\npool: AZS-1ES-L-MMS-ubuntu-22.04")
.unwrap();
let changed = apply_codemod(&mut fm, &ctx("0.30.0")).expect("apply");
assert!(changed);
assert_eq!(
fm.get(Value::String("pool".into())).cloned(),
Some(serde_yaml::from_str::<Value>("name: AZS-1ES-L-MMS-ubuntu-22.04").unwrap())
);
}

#[test]
fn scalar_rewrite_applies_regardless_of_version() {
// Scalar → object rewrite is unconditional; only the
// absent-pool injection is version-gated.
let mut fm: Mapping =
serde_yaml::from_str("name: x\ndescription: y\npool: MyPool").unwrap();
let changed = apply_codemod(&mut fm, &ctx("0.28.0")).expect("apply");
assert!(changed);
assert_eq!(
fm.get(Value::String("pool".into())).cloned(),
Some(serde_yaml::from_str::<Value>("name: MyPool").unwrap())
);
}

#[test]
fn version_gte_comparisons() {
assert!(version_gte("0.30.0", "0.30.0"));
assert!(version_gte("0.31.0", "0.30.0"));
assert!(version_gte("1.0.0", "0.30.0"));
assert!(version_gte("0.30.1", "0.30.0"));
assert!(!version_gte("0.29.0", "0.30.0"));
assert!(!version_gte("0.29.99", "0.30.0"));
}
}
32 changes: 25 additions & 7 deletions src/compile/codemods/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,32 @@ use serde_yaml::Mapping;
mod helpers;
#[path = "0001_repos_unified.rs"]
mod m0001_repos_unified;
#[path = "0002_pool_object_form.rs"]
mod m0002_pool_object_form;

#[allow(unused_imports)] // Re-exported for future codemods; only `take_key` is in-tree use.
pub use helpers::{insert_no_overwrite, rename_key, take_key, ConflictPolicy};

/// Forward-compatible context passed to every codemod. Currently
/// empty; we keep it in the signature so future codemods can be
/// given (e.g.) the source path without breaking the function
/// pointer type.
/// Forward-compatible context passed to every codemod.
///
/// Carries ambient information (e.g. compiler version) so codemods
/// can condition their behaviour without hard-coding assumptions.
#[non_exhaustive]
pub struct CodemodContext {}
pub struct CodemodContext {
/// Semantic version of the running `ado-aw` binary
/// (e.g. `"0.30.0"`). Codemods can compare this against their
/// `introduced_in` to decide when a default has changed.
pub compiler_version: &'static str,
}

impl CodemodContext {
/// Build a context using the compile-time package version.
pub fn current() -> Self {
Self {
compiler_version: env!("CARGO_PKG_VERSION"),
}
}
}

/// A single front-matter codemod.
///
Expand Down Expand Up @@ -85,6 +101,7 @@ pub struct Codemod {
/// without harm.
pub static CODEMODS: &[&'static Codemod] = &[
&m0001_repos_unified::CODEMOD,
&m0002_pool_object_form::CODEMOD,
];

/// Result of running the codemod registry on a single front-matter
Expand Down Expand Up @@ -138,10 +155,11 @@ pub(crate) fn apply_codemods_with(
fm: &mut Mapping,
registry: &[&'static Codemod],
) -> Result<CodemodReport> {
let ctx = CodemodContext::current();
let mut applied: Vec<AppliedCodemod> = Vec::new();
for c in registry {
let changed = (c.apply)(fm, &CodemodContext {})
.with_context(|| format!("codemod {} failed", c.id))?;
let changed =
(c.apply)(fm, &ctx).with_context(|| format!("codemod {} failed", c.id))?;
if changed {
applied.push(AppliedCodemod {
id: c.id,
Expand Down
Loading
Loading