diff --git a/src/compile/ir/tasks.rs b/src/compile/ir/tasks.rs index c0e75582..15730c53 100644 --- a/src/compile/ir/tasks.rs +++ b/src/compile/ir/tasks.rs @@ -403,6 +403,110 @@ pub fn cmd_line_step(script: impl Into) -> 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: +/// +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: +/// +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: +/// +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", "")`. +/// +/// 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: +/// +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: +/// +pub fn docker_logout_step() -> TaskStep { + TaskStep::new("Docker@2", "Docker Logout").with_input("command", "logout") +} + #[cfg(test)] mod tests { use super::*; @@ -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" + ); + } }