ci: containerize CI pipeline with pre-built Docker image - #2529
ci: containerize CI pipeline with pre-built Docker image#2529Brendan Walsh (BrendanWalsh) wants to merge 13 commits into
Conversation
|
Hey Brendan Walsh (@BrendanWalsh) 👋! We use semantic commit messages to streamline the release process. Examples of commit messages with semantic prefixes:
To test your commit locally, please follow our guild on building from source. |
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
Pull request overview
Containerizes most Azure DevOps CI jobs to run inside a pre-built Docker image, aiming to reduce end-to-end CI time by pre-baking toolchains, caches, and datasets.
Changes:
- Add a new CI Docker image definition and a pipeline job to build/retag it using a content-hash tag.
- Move major CI jobs (style/tests/publish) to run with
container: ci, add disk cleanup, and scope compilation for unit tests. - Add dataset cache support in
build.sbtand introduce a global ScalaTest per-test timeout inTestBase.
Reviewed changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/docker/ci/Dockerfile | Defines the CI container image (JDK8, conda env, Spark, SBT warmup, datasets). |
| templates/free_disk.yml | Adds a reusable host disk cleanup step for container jobs. |
| pipeline.yaml | Introduces ci container resource, BuildCIImage job, and migrates many jobs to containers/scoped compilation. |
| build.sbt | Uses DATASET_CACHE to avoid re-downloading test datasets in CI. |
| core/src/test/.../TestBase.scala | Wraps all tests with a global failAfter timeout. |
| project/CodegenPlugin.scala | Removes redundant LocalRootProject publishLocal from installPipPackage. |
| .gitignore | Ignores pipeline.yaml.bak. |
6f18643 to
bbb23c6
Compare
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2529 +/- ##
==========================================
+ Coverage 84.66% 84.93% +0.27%
==========================================
Files 335 335
Lines 17747 17747
Branches 1595 1595
==========================================
+ Hits 15025 15073 +48
+ Misses 2722 2674 -48 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
aec4efd to
6678299
Compare
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
369043d to
be65e36
Compare
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
be65e36 to
61b4e8f
Compare
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
61b4e8f to
4d6cdc8
Compare
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
## Summary Rebase PR #2529 onto current master, preserve master pipeline updates, add the CI container image flow, and fix blocking CI image publication and timeout defects. ## Prompting Intent Engineer asked to rebase microsoft/SynapseML PR #2529 onto current master in a new local worktree without pushing or commenting, resolve pipeline.yaml conflicts while preserving master-side changes, verify and fix genuine blockers around untrusted image publication, mutable image consumption, and UnitTests timeout regression, validate the result, and prepare one local commit. ## Linked Sources - GitHub PR: #2529 - Upstream branch: brwals/containerize-ci - Rebase target: microsoft/SynapseML master at ffe123a - Local validation: yaml.safe_load plus duplicate-key detector; sbt scalastyle Test/scalastyle; sbt core/Test/compile ## Rationale Kept the Java 8 CI image because current SynapseML CI sbt logs use Java 8 and ReleaseBranchCompat is the only job that explicitly switches JDKs. Restricted image build/push to trusted non-PR master builds so fork and same-repo PRs cannot publish a poisoned shared image. Pinned downstream jobs to a content-hash tag and added a hash guard so a given run consumes the intended image instead of racing on ci-latest. Raised UnitTests to 100 minutes so its 90-minute test task plus setup can complete, while preserving current master matrix additions and timeouts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
4d6cdc8 to
5f0e7b5
Compare
## Summary Harden the container image and pipeline against UID-remapped cache failures, stale image tags, duplicate packaging, and premature E2E timeouts. ## Prompting Intent Bring GitHub PR #2529 to merge-ready confidence by verifying every inline review finding against current code, fixing only live defects, preserving deterministic containerized CI, and validating Docker, YAML, Python, and Scala behavior without pushing or changing the PR. ## Linked Sources - GitHub PR: #2529 - Inline review findings: GitHub PR #2529 review threads ## Rationale Use ownership by the hosted agent UID with non-world-writable modes rather than 777 or root-owned 755. Hash only tracked build inputs and enforce the tag in a regression test. Override the global test timeout only for E2E suites whose documented inner limits exceed ten minutes, and avoid rebuilding core twice in its own Python matrix leg. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1bf674c to
68fbdb2
Compare
|
/azp run |
|
Azure Pipelines: No pipelines were found matching this branch/path. |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
templates/databricks_e2e_steps.yml:4
templates/databricks_e2e_steps.ymlstill runssbtbut no longer restores the shared sbt bootstrap/dependency caches (and the job only references this template). This will force Databricks E2E legs to cold-bootstrap dependencies, increasing runtime and reintroducing Maven Central/429 flakiness that theBuildAndCacheSbtprewarm is meant to avoid.
steps:
- template: free_disk.yml
- template: kv.yml
- template: publish.yml
tools/docker/ci/Dockerfile:103
- The pre-downloaded datasets tarball is fetched at build time without any integrity verification. Since this blob is an external input that feeds tests, it’s safer to pin and verify its digest (similar to the libssl1.1 .deb above) to reduce supply-chain risk and make builds reproducible if the blob content ever changes.
# Pre-download test datasets (static tarball, ~50MB) to avoid downloading in every job
ENV DATASET_CACHE=/opt/datasets
RUN mkdir -p $DATASET_CACHE \
&& wget -q "https://mmlspark.blob.core.windows.net/installers/datasets-2023-04-03.tgz" \
-O "$DATASET_CACHE/datasets-2023-04-03.tgz"
- Files reviewed: 10/11 changed files
- Comments generated: 1
- Review effort level: Lite
The tag is documented as content addressed, but .dockerignore selects the docker build context and so can change the built image without changing any hashed input. This PR edits .dockerignore itself, so a later change to it would have silently reused a stale image. Hash it alongside the other build inputs in both tag computations and in the test helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/azp run |
|
Azure Pipelines: No pipelines were found matching this branch/path. |
Containerizing Publish left behind an apt-get install of graphviz and doxygen that cannot succeed in the image: the Dockerfile already installs both and then removes /var/lib/apt/lists, so apt cannot resolve them, and the job no longer runs as root. Drop the step, add a test so no containerized job reintroduces one, and record .dockerignore in the content-addressed tag comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (2)
pipeline.yaml:262
- Same locale-dependent hashing issue as in BuildAndCacheSbt: this
sort -zis not pinned to a stable collation order, so the CI image content-hash tag can vary depending on agent locale. PinsorttoLC_ALL=Cso CI_IMAGE_TAG stays deterministic.
HASH=$({
sha256sum .dockerignore environment.yml build.sbt sonatype.sbt tools/docker/ci/Dockerfile
git ls-files -z -- project | sort -z | xargs -0 sha256sum
} | sha256sum | cut -c1-12)
pipeline.yaml:126
- The CI image build hashes
project/inputs usingsort -zwithout pinning the collation locale.sortis locale-dependent, so the computed content hash (and therefore CI_IMAGE_TAG validation) can change across agents/environments ifLC_ALLdiffers, even when file contents are identical. Pin the sort locale toCto make the tag deterministic.
This issue also appears on line 259 of the same file.
HASH=$({
sha256sum .dockerignore environment.yml build.sbt sonatype.sbt tools/docker/ci/Dockerfile
git ls-files -z -- project | sort -z | xargs -0 sha256sum
} | sha256sum | cut -c1-12)
- Files reviewed: 10/11 changed files
- Comments generated: 0 new
- Review effort level: Lite
The CI image bakes in a dataset tarball that feeds tests, but fetched it without checking its contents, unlike the libssl deb a few lines above. Pin and verify its sha256 so a changed or truncated blob fails the image build loudly instead of silently altering test inputs. Digest confirmed stable across two independent downloads; the size in the comment was also wrong (117MB, not 50MB). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (2)
tools/docker/ci/Dockerfile:53
- Spark tarball is downloaded and extracted without integrity verification. Since this image is meant to be a trusted, reproducible CI baseline, it would be safer to checksum-verify (or signature-verify) the archive before extracting it.
RUN wget -q "https://mmlspark.blob.core.windows.net/installers/spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz" -O /tmp/spark.tgz \
&& tar -xzf /tmp/spark.tgz -C /opt \
&& rm /tmp/spark.tgz
tools/docker/ci/Dockerfile:103
- The pre-fetched datasets tarball is downloaded without any integrity verification. A corrupted or tampered artifact would be cached into the CI image and could cause hard-to-debug test failures (or worse). Consider adding checksum verification for the tarball after download.
RUN mkdir -p $DATASET_CACHE \
&& wget -q "https://mmlspark.blob.core.windows.net/installers/datasets-2023-04-03.tgz" \
-O "$DATASET_CACHE/datasets-2023-04-03.tgz" \
- Files reviewed: 10/11 changed files
- Comments generated: 1
- Review effort level: Lite
The image fetched Miniconda, Spark and sbt over the network and used them
unchecked; the Miniconda one is piped straight into bash as root. Pin each
to the digest its own vendor publishes, so a corrupted or substituted
artifact fails the image build instead of silently becoming the toolchain
every CI job runs on.
Each digest was confirmed against the upstream publisher, not just
computed locally:
Miniconda - matches the sha256 on repo.anaconda.com/miniconda/
Spark - matches Apache's published spark-3.5.0-bin-hadoop3.tgz.sha512,
which also confirms the blob mirror is byte-identical upstream
sbt - matches the sbt-1.10.11.tgz.sha256 asset on the GitHub release
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Automated-review adjudication + one correction to an earlier replyRecording where I landed on the remaining automated review comments on this PR, including the Correction to my earlier reply on the
There is a real gap worth naming, though: this PR's own Reviewed and dismissed, with reasons
Acted on in this round
|
|
/azp run |
|
Azure Pipelines: No pipelines were found matching this branch/path. |
There was a problem hiding this comment.
Review details
Suppressed comments (3)
tools/docker/ci/Dockerfile:41
- The Miniconda installer is downloaded and executed without any integrity verification. Since this image is used as a trusted CI base, it would be safer to pin and verify the installer with a SHA256 check (similar to the
libssl1.1download) to reduce supply-chain risk.
# Digest is the one published by Anaconda on https://repo.anaconda.com/miniconda/ ; this
# installer is executed as root, so verify before running it rather than after.
RUN wget -q https://repo.anaconda.com/miniconda/Miniconda3-py311_24.11.1-0-Linux-x86_64.sh -O /tmp/miniconda.sh \
templates/databricks_e2e_steps.yml:4
- Databricks E2E jobs run
sbt(via this template), but this template no longer restores the shared sbt/Ivy/Coursier cache (sbt_cache.yml). That defeats the existing CI hardening that avoids cold-bootstrapping sbt (and related 429/rate-limit failures) and can significantly increase E2E wall time. Add the sbt cache template back (it can run inside the container;free_disk.ymlcan remain host-targeted).
steps:
- template: free_disk.yml
- template: kv.yml
- template: publish.yml
tools/ci/tests/test_pipeline_yaml.py:692
test_every_sbt_running_job_waits_for_the_prewarm_cacheonly inspects the job's directstepsentries. Jobs like Databricks E2E runsbtinside a referenced step template (templates/databricks_e2e_steps.yml), so this check can miss real sbt executions and allow cache regressions to slip through. Consider treating that template as sbt-running and verifying it restoressbt_cache.yml(or more generally, scanning referenced templates).
runs_sbt = any(
_INVOKES_SBT.search(t) or t.strip().startswith("sbt") or "sbt_retry.sh" in t
for t in texts
)
templates = [
- Files reviewed: 10/11 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
Review details
Suppressed comments (3)
tools/docker/ci/Dockerfile:87
- The CI image overrides the torch/torchvision versions pinned in environment.yml (torch==2.1.2, torchvision==0.16.2) by installing older CPU wheels (2.1.0 / 0.16.0). This creates an implicit version downgrade that can lead to dependency mismatches and makes the image less reproducible vs the declared environment. Prefer matching the pinned versions while swapping only the build variant (+cpu).
"https://download.pytorch.org/whl/cpu/torch-2.1.0%2Bcpu-cp311-cp311-linux_x86_64.whl" \
"https://download.pytorch.org/whl/cpu/torchvision-0.16.0%2Bcpu-cp311-cp311-linux_x86_64.whl" \
templates/databricks_e2e_steps.yml:4
- This template runs sbt (
sbt "testOnly $(TEST-CLASS)") but no longer restores the shared sbt caches. The repo’s CI convention (see templates/sbt_cache.yml header) is that every sbt-running job restores these caches after BuildAndCacheSbt to avoid cold-bootstrapping and 429 rate limiting. Re-add the sbt_cache template here so Databricks E2E jobs follow the same pattern.
steps:
- template: free_disk.yml
- template: kv.yml
- template: publish.yml
tools/ci/tests/test_pipeline_yaml.py:690
- test_every_sbt_running_job_waits_for_the_prewarm_cache only scans each job’s inlined steps, so it misses sbt invocations that live inside referenced templates. In the current pipeline this means DatabricksCPUE2E/DatabricksGPUE2E can run sbt via templates/databricks_e2e_steps.yml without being validated for sbt_cache usage. Expand one level of templates when building
textsso sbt usage in templates is detected.
steps = job.get("steps", [])
texts = flatten(steps)
runs_sbt = any(
_INVOKES_SBT.search(t) or t.strip().startswith("sbt") or "sbt_retry.sh" in t
for t in texts
- Files reviewed: 10/11 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
/azp run |
|
Azure Pipelines: No pipelines were found matching this branch/path. |
⛔ Prerequisite — this PR cannot merge as-is (unrelated to code quality)
pipeline.yamlreferences an Azure DevOps Docker Registry service connection namedSynapseML MCR(top-levelresources.containers, and again in theBuildCIImagejob'sDocker@2 loginstep). This service connection does not exist in the ADO project. Resource validation runs before any job condition is evaluated, so this fails the entire pipeline instantly, before a single job starts:Confirmed on two real queued builds against this branch (
230951450,230953655) — both failed in ~0 timeline records. 9 jobs usecontainer: ci, so every PR build and every master build will fail immediately until this is fixed.Before merging, someone with ADO project-admin rights must:
SynapseML MCR, pointing atmmlsparkmcr.azurecr.io.microsoft.SynapseML.synapseml/ci:ci-cc980b65de98actually exists in that registry.Summary
Containerizes the SynapseML CI pipeline using a pre-built Docker image so jobs start from a warm environment (JDK, conda env, SBT + resolved dependencies, Spark, test datasets) instead of installing everything on every run.
SynapseML MCRblocker was discovered. No build has completed successfully against the current head — treat them as directional, not a guarantee.What changed, by file
tools/docker/ci/Dockerfile(new): Ubuntu 22.04, Temurin JDK 8, pinned Miniconda, SBT 1.10.11, Spark 3.5.0/hadoop3, pre-warmed SBT/Ivy caches, and a pre-fetched test-dataset tarball. HTTPS + SHA256-verifiedlibssl1.1. Published tommlsparkmcr.azurecr.io/synapseml/ci..dockerignore: rewritten from a denylist to an allowlist — onlyenvironment.yml,project/,build.sbt,sonatype.sbt,tools/docker/demo/init_notebook.py, anddocs/are sent to the Docker build context.pipeline.yaml(bulk of the diff):resources.containersentry (container: ci) and newBuildCIImagejob that builds/pushes the image only on trusted, non-PRmasterbuilds, with content-hash tagging and a hash-vs-CI_IMAGE_TAGguard.container: ciadded to all 9 containerizable jobs:Style,Publish,DatabricksCPUE2E,DatabricksGPUE2E,FabricE2E,PythonTests,RTests,WebsiteSamplesTests,UnitTests. (BuildDocker,ReleaseBranchCompat— needs JDK 17 — andBuildAndCacheSbtintentionally stay on the host.)BuildCIImageis skipped on PR builds by design, every dependent job's condition changed fromsucceeded()toand(not(failed()), not(canceled()), ...)— otherwise a skippedBuildCIImagewould skip all 9 downstream jobs on every PR.PROJECTvariable added to theUnitTestsmatrix so each leg compiles only its own module;PythonTests' install step now buildscorethen$(PACKAGE)explicitly.templates/sbt_cache.yml/update_cli.yml/conda.ymlreplaced bytemplates/free_disk.yml(runs on the host viatarget: hosteven inside container jobs, to reclaim real VM disk).SBT_OPTStuned (-Xmx4G,-Dscala.concurrent.context.numThreads=8/maxThreads=8) andUnitTeststimeout raised 80m → 100m.build.sbt:getDatasetsTasknow checks$DATASET_CACHE/datasets-2023-04-03.tgz(baked into the image) before downloading from blob storage.core/.../test/base/TestBase.scala: adds a global 10-minute per-test timeout (failAfter, overridable viatestTimeoutInSeconds), and declaresimplicit protected val testSignaler: Signaler = ThreadSignaler. This second part is the important one: ScalaTest's defaultDoNotSignalonly reports a timeout after the test body returns on its own, so a genuinely hung test would never actually be interrupted. Verified empirically with a probe suite (3s limit, 12s sleeping body): 12001 ms beforeThreadSignaler→ 3004 ms after. Scoped only toTestBase; does not touch the separate, pre-existingTimeLimitedFlaky/testFun _eta-expansion bug in the same file (that bug silently skips the test body inPartitionConsolidatorSuiteand is tracked as its own fix).project/CodegenPlugin.scala: drops a redundantLocalRootProject / Compile / publishLocalfrominstallPipPackage.templates/free_disk.yml(new),templates/databricks_e2e_steps.yml,.gitignore(addspipeline.yaml.bak): supporting template/hygiene changes for the above.Fixes applied since this PR was opened (rebase onto master
7be2767)BuildCIImagebuilt and pushed on every PR build, including forksand(succeeded(), isMaster, !isPR)ci-latesttagci-cc980b65de98, with a hash guard inBuildCIImageUnitTestsjob timeout equaled its step timeout (couldn't distinguish job vs. step timeout)ThreadSignaler(seeTestBase.scalaabove)Known issues / residual risk in this diff
ci-cc980b65de98is a literal in two places —resources.containers[0].imageandvariables.CI_IMAGE_TAG— with no automated link between them.BuildCIImage's hash guard only checks the variable, and that job never runs on PRs, so a PR that bumpstools/docker/ci/Dockerfileor its dependencies without manually updating both literals will pass PR CI and can silently desync on merge.resources.repositories: - repository: self / type: self:type: selfisn't a documented value (git | github | githubenterprise | bitbucket). ADO appears to tolerate it in practice, but it's non-standard and unnecessary —selfdoesn't need to be declared underresources.repositoriesat all.RTestsstill downloads Spark at runtime (wget -q .../spark-3.5.0-bin-hadoop3.tgz) even though the image pre-downloads and unpacks the identical tarball specifically "for R tests" — this job doesn't get the caching win the image was built to provide it.PythonTests"Test Python Code" step silently dropped one of three||-chained retry attempts (3 → 2), with no callout here. ~10 of the ~20PythonTests/UnitTestsmatrix legs are markedFLAKY: "true".PythonTests'corematrix leg runs"project core" installPipPackage publishM2twice back-to-back (once explicitly, once again because$(PACKAGE) == core). Harmless, just wasted time.chmod -R 777 /opt/conda/envs/synapseml/lib/R/libraryis intentionally kept for the R package library (needed for UID-1001 write access at test time).Testing
SynapseML MCRblocker above. Re-run once the service connection exists.sbt scalastyle Test/scalastyle: 0 errors. YAMLsafe_load+ duplicate-key check: pass.Dependencies
Independent, but works best alongside #2524, #2525, #2526, #2527, #2528 — all split from the original #2506 for independent review.
Open design question
Building/pushing the CI image from the same pipeline that consumes it still means image-push credentials live in the product pipeline (even though
BuildCIImageis now gated to trusted master builds). A separate trusted pipeline that only publishes the image, with this pipeline only consuming a pinned digest, would remove that credential surface entirely. Not a blocker for this PR — a follow-up worth considering.This is the core containerization PR, split from #2506.