Skip to content
Merged
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
277 changes: 277 additions & 0 deletions src/compile/ir/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,110 @@ pub fn cmd_line_step(script: impl Into<String>) -> TaskStep {
TaskStep::new("CmdLine@2", "Command Line Script").with_input("script", script)
}

/// Returns a [`TaskStep`] for `Docker@2` in `buildAndPush` mode.
///
/// Builds a Docker image and pushes it to a container registry in one step.
/// This is the most common Docker@2 use case; it combines `docker build`
/// and `docker push` into a single pipeline step and ensures the pushed image
/// digest matches what was built.
///
/// All inputs are optional at the Rust API level because the ADO task ships
/// sensible defaults (`Dockerfile = **/Dockerfile`, `tags = $(Build.BuildId)`).
/// Apply them with `.with_input(…)`:
///
/// | Input key | Type | Default | Description |
/// |---|---|---|---|
/// | `containerRegistry` | string | — | Docker registry service connection name. Required in practice to push to a private registry. |
/// | `repository` | string | — | Container repository name (e.g. `"myapp"` or `"username/myapp"` for Docker Hub). |
/// | `Dockerfile` | string | `**/Dockerfile` | Path or glob to the Dockerfile. |
/// | `buildContext` | string | `**` | Build context path relative to the repo root. |
/// | `tags` | string | `$(Build.BuildId)` | Newline-separated list of image tags. |
///
/// ADO task reference:
/// <https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/docker-v2>
pub fn docker_build_and_push_step() -> TaskStep {
TaskStep::new("Docker@2", "Build and Push Docker Image").with_input("command", "buildAndPush")
}

/// Returns a [`TaskStep`] for `Docker@2` in `build` mode.
///
/// Builds a Docker image without pushing it to a registry. Use
/// `docker_build_and_push_step()` when you want to build and push in one
/// step; use this when you need to run a scan or test between build and push.
///
/// Optional inputs (applied via `.with_input(…)` on the returned value):
///
/// | Input key | Type | Default | Description |
/// |---|---|---|---|
/// | `containerRegistry` | string | — | Docker registry service connection for authentication. |
/// | `repository` | string | — | Image name to tag the build as. |
/// | `Dockerfile` | string | `**/Dockerfile` | Path or glob to the Dockerfile. |
/// | `buildContext` | string | `**` | Build context path relative to the repo root. |
/// | `tags` | string | `$(Build.BuildId)` | Newline-separated list of image tags. |
/// | `arguments` | string | — | Extra arguments appended to the `docker build` command. |
///
/// ADO task reference:
/// <https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/docker-v2>
pub fn docker_build_step() -> TaskStep {
TaskStep::new("Docker@2", "Build Docker Image").with_input("command", "build")
}

/// Returns a [`TaskStep`] for `Docker@2` in `push` mode.
///
/// Pushes a previously-built Docker image to a container registry. Use after
/// `docker_build_step()` when the build and push need to be separate steps
/// (e.g. to run a security scan in between).
///
/// Optional inputs (applied via `.with_input(…)` on the returned value):
///
/// | Input key | Type | Default | Description |
/// |---|---|---|---|
/// | `containerRegistry` | string | — | Docker registry service connection name. |
/// | `repository` | string | — | Container repository name to push to. |
/// | `tags` | string | `$(Build.BuildId)` | Newline-separated list of tags to push. |
/// | `arguments` | string | — | Extra arguments appended to the `docker push` command. |
///
/// ADO task reference:
/// <https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/docker-v2>
pub fn docker_push_step() -> TaskStep {
TaskStep::new("Docker@2", "Push Docker Image").with_input("command", "push")
}

/// Returns a [`TaskStep`] for `Docker@2` in `login` mode.
///
/// Logs in to a Docker container registry. Pair this with
/// `docker_logout_step()` at the end of the job. The service connection is
/// specified via `.with_input("containerRegistry", "<service-connection>")`.
///
/// Optional inputs (applied via `.with_input(…)` on the returned value):
///
/// | Input key | Type | Default | Description |
/// |---|---|---|---|
/// | `containerRegistry` | string | — | Docker registry service connection name. When omitted the task logs in to Docker Hub. |
///
/// ADO task reference:
/// <https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/docker-v2>
pub fn docker_login_step() -> TaskStep {
TaskStep::new("Docker@2", "Docker Login").with_input("command", "login")
}

/// Returns a [`TaskStep`] for `Docker@2` in `logout` mode.
///
/// Logs out from a Docker container registry. Use after a series of Docker
/// steps to ensure credentials are not left on the agent.
///
/// Optional inputs (applied via `.with_input(…)` on the returned value):
///
/// | Input key | Type | Default | Description |
/// |---|---|---|---|
/// | `containerRegistry` | string | — | Docker registry service connection name. |
///
/// ADO task reference:
/// <https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/docker-v2>
pub fn docker_logout_step() -> TaskStep {
TaskStep::new("Docker@2", "Docker Logout").with_input("command", "logout")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1191,4 +1295,177 @@ mod tests {
);
assert_eq!(t.inputs.len(), 2);
}

// ── Docker@2 ─────────────────────────────────────────────────────────

#[test]
fn docker_build_and_push_step_sets_task_and_command() {
let t = docker_build_and_push_step();
assert_eq!(t.task, "Docker@2");
assert_eq!(t.display_name, "Build and Push Docker Image");
assert_eq!(
t.inputs.get("command").map(|s| s.as_str()),
Some("buildAndPush")
);
// only the command input is set by default
assert_eq!(t.inputs.len(), 1);
}

#[test]
fn docker_build_and_push_step_accepts_registry_and_repository() {
let t = docker_build_and_push_step()
.with_input("containerRegistry", "myRegistryServiceConnection")
.with_input("repository", "myapp");
assert_eq!(t.task, "Docker@2");
assert_eq!(
t.inputs.get("containerRegistry").map(|s| s.as_str()),
Some("myRegistryServiceConnection")
);
assert_eq!(
t.inputs.get("repository").map(|s| s.as_str()),
Some("myapp")
);
assert_eq!(t.inputs.len(), 3);
}

#[test]
fn docker_build_and_push_step_accepts_dockerfile_and_tags() {
let t = docker_build_and_push_step()
.with_input("Dockerfile", "src/Dockerfile")
.with_input("buildContext", "src/")
.with_input("tags", "latest\n$(Build.BuildId)");
assert_eq!(t.task, "Docker@2");
assert_eq!(
t.inputs.get("Dockerfile").map(|s| s.as_str()),
Some("src/Dockerfile")
);
assert_eq!(
t.inputs.get("buildContext").map(|s| s.as_str()),
Some("src/")
);
assert_eq!(
t.inputs.get("tags").map(|s| s.as_str()),
Some("latest\n$(Build.BuildId)")
);
assert_eq!(t.inputs.len(), 4);
}

#[test]
fn docker_build_step_sets_task_and_command() {
let t = docker_build_step();
assert_eq!(t.task, "Docker@2");
assert_eq!(t.display_name, "Build Docker Image");
assert_eq!(
t.inputs.get("command").map(|s| s.as_str()),
Some("build")
);
assert_eq!(t.inputs.len(), 1);
}

#[test]
fn docker_build_step_accepts_optional_inputs() {
let t = docker_build_step()
.with_input("repository", "myapp")
.with_input("Dockerfile", "Dockerfile.prod")
.with_input("arguments", "--no-cache --build-arg ENV=prod");
assert_eq!(t.task, "Docker@2");
assert_eq!(
t.inputs.get("repository").map(|s| s.as_str()),
Some("myapp")
);
assert_eq!(
t.inputs.get("Dockerfile").map(|s| s.as_str()),
Some("Dockerfile.prod")
);
assert_eq!(
t.inputs.get("arguments").map(|s| s.as_str()),
Some("--no-cache --build-arg ENV=prod")
);
assert_eq!(t.inputs.len(), 4);
}

#[test]
fn docker_push_step_sets_task_and_command() {
let t = docker_push_step();
assert_eq!(t.task, "Docker@2");
assert_eq!(t.display_name, "Push Docker Image");
assert_eq!(
t.inputs.get("command").map(|s| s.as_str()),
Some("push")
);
assert_eq!(t.inputs.len(), 1);
}

#[test]
fn docker_push_step_accepts_registry_repository_and_tags() {
let t = docker_push_step()
.with_input("containerRegistry", "myRegistry")
.with_input("repository", "myapp")
.with_input("tags", "$(Build.BuildId)");
assert_eq!(t.task, "Docker@2");
assert_eq!(
t.inputs.get("containerRegistry").map(|s| s.as_str()),
Some("myRegistry")
);
assert_eq!(t.inputs.len(), 4);
}

#[test]
fn docker_login_step_sets_task_and_command() {
let t = docker_login_step();
assert_eq!(t.task, "Docker@2");
assert_eq!(t.display_name, "Docker Login");
assert_eq!(
t.inputs.get("command").map(|s| s.as_str()),
Some("login")
);
assert_eq!(t.inputs.len(), 1);
}

#[test]
fn docker_login_step_accepts_container_registry() {
let t = docker_login_step().with_input("containerRegistry", "myPrivateRegistry");
assert_eq!(t.task, "Docker@2");
assert_eq!(
t.inputs.get("containerRegistry").map(|s| s.as_str()),
Some("myPrivateRegistry")
);
assert_eq!(t.inputs.len(), 2);
}

#[test]
fn docker_logout_step_sets_task_and_command() {
let t = docker_logout_step();
assert_eq!(t.task, "Docker@2");
assert_eq!(t.display_name, "Docker Logout");
assert_eq!(
t.inputs.get("command").map(|s| s.as_str()),
Some("logout")
);
assert_eq!(t.inputs.len(), 1);
}

#[test]
fn docker_logout_step_accepts_container_registry() {
let t = docker_logout_step().with_input("containerRegistry", "myPrivateRegistry");
assert_eq!(t.task, "Docker@2");
assert_eq!(
t.inputs.get("containerRegistry").map(|s| s.as_str()),
Some("myPrivateRegistry")
);
assert_eq!(t.inputs.len(), 2);
}

#[test]
fn docker_login_and_logout_use_same_task_name() {
let login = docker_login_step();
let logout = docker_logout_step();
assert_eq!(login.task, logout.task);
assert_eq!(login.task, "Docker@2");
assert_ne!(
login.inputs.get("command"),
logout.inputs.get("command"),
"login and logout must use different command values"
);
}
}