diff --git a/.dockerignore b/.dockerignore index b718bd108a8a..1c382038d7f3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,4 +28,5 @@ _isaac_sim docker/.isaac-lab-docker-history # ignore uv environment env_isaaclab +.venv/ tools/wheel_builder/build/ diff --git a/.github/actions/ecr-build-push-pull/action.yml b/.github/actions/ecr-build-push-pull/action.yml index d6fe0cb27987..115379c79085 100644 --- a/.github/actions/ecr-build-push-pull/action.yml +++ b/.github/actions/ecr-build-push-pull/action.yml @@ -37,6 +37,14 @@ inputs: description: Tag used for the ECR layer cache image (e.g. "cache-base", "cache-curobo"). required: false default: 'cache' + pull-on-deps-hit: + description: > + Pull the image locally after a deps-cache hit. Needed by jobs that run + the image in later steps of the same job: the ECR login lives in a + temporary docker config that is removed when this action ends, so + steps after the action cannot pull. + required: false + default: 'false' runs: using: composite steps: @@ -268,6 +276,27 @@ runs: docker tag "${{ inputs.image-tag }}" "${DEPS_ECR_IMAGE}" docker push "${DEPS_ECR_IMAGE}" + ##### 8b: Pull deps-cached image for same-job use ##### + + # On a deps-cache hit step 5 only aliases the commit tag registry-side. + # Jobs that docker-run the image in later steps opt in here, while the + # temporary docker config (and its ECR login) still exists. + + - name: Pull deps-cached image + if: > + steps.deps-cache.outputs.deps-cache-hit == 'true' && + inputs.pull-on-deps-hit == 'true' + shell: bash + run: | + if docker image inspect "${{ inputs.image-tag }}" >/dev/null 2>&1; then + echo "🟢 Image already available locally, skipping pull" + else + echo "🔵 Pulling ${ECR_IMAGE} from ECR..." + docker pull "${ECR_IMAGE}" + docker tag "${ECR_IMAGE}" "${{ inputs.image-tag }}" + echo "🟢 Image pulled and tagged as ${{ inputs.image-tag }}" + fi + ##### 9: Cleanup docker config ##### - name: Cleanup docker config diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7e704d6a6ff5..a4547c793521 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -77,112 +77,27 @@ env: jobs: changes: name: Detect Changes - runs-on: ubuntu-latest - outputs: - run_docker_tests: ${{ steps.detect.outputs.run_docker_tests }} - steps: - - id: detect - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - EVENT_NAME: ${{ github.event_name }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - # Docker test jobs run only when paths in the patterns table change. - # Otherwise they skip via `if:` and report green to branch protection, - # which is why we don't use a workflow-level `paths:` filter (a - # not-triggered required check would block the PR forever). - # config.yaml is included because it controls the base image names and - # tags consumed by the Docker build jobs. - patterns=( - $'^source/\tLibrary source code' - $'^docker/\tContainer build inputs' - $'^tools/\tBuild tooling' - $'^apps/\tStandalone apps' - $'^scripts/\tStandalone scripts' - $'^\\.github/workflows/build\\.yaml$\tThis workflow file' - $'^\\.github/workflows/config\\.yaml$\tBase image config' - $'^\\.github/actions/\tCI actions' - $'^\\.github/test-subsets/\tCI test subset config' - ) - if [ "$EVENT_NAME" = "push" ]; then - triggered_jobs="Docker base build job + rendering-correctness + rendering-correctness-kitless" - else - triggered_jobs="Docker build jobs + all test-* matrix jobs" - fi - - render_table() { - local files="$1" entry regex desc count sample shown - echo "| Pattern | What it covers | Matched files |" - echo "|---|---|---|" - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex desc <<< "$entry" - # escape | so it doesn't end the markdown table cell mid-regex - shown="${regex//|/\\|}" - count=$(grep -cE "$regex" <<< "$files" || true) - if [ "$count" -gt 0 ]; then - sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) - [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" - echo "| \`$shown\` | $desc | $sample |" - else - echo "| \`$shown\` | $desc | - |" - fi - done - } - - any_match() { - local files="$1" entry regex - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex _ <<< "$entry" - if grep -qE "$regex" <<< "$files"; then - return 0 - fi - done - return 1 - } - - decide() { - local decision="$1" reason="$2" files="${3:-}" - echo "Decision: run_docker_tests=$decision ($reason)" - echo "run_docker_tests=$decision" >> "$GITHUB_OUTPUT" - { - echo "## Docker test gating" - echo "" - if [ "$decision" = "true" ]; then - echo "Docker tests will **run**: $reason." - else - echo "Docker tests will be **skipped**: $reason." - fi - echo "" - echo "Triggered jobs: $triggered_jobs." - if [ -n "$files" ]; then - echo "" - render_table "$files" - fi - } >> "$GITHUB_STEP_SUMMARY" - } - - if [ "$EVENT_NAME" != "pull_request" ]; then - decide true "non-PR event ($EVENT_NAME)" - exit 0 - fi - - if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then - # Fail-safe: a transient API error must not block merge. Default to running. - echo "::warning::Could not list changed files; defaulting to running tests" - decide true "fail-safe (could not list changed files)" - exit 0 - fi - - printf '%s\n' "$changed_files" - - if any_match "$changed_files"; then - decide true "relevant paths changed" "$changed_files" - else - decide false "no relevant paths changed" "$changed_files" - fi + # Docker test jobs run only when paths in the patterns table change. + # config.yaml is included because it controls the base image names and + # tags consumed by the Docker build jobs. + uses: ./.github/workflows/detect-changes.yml + with: + summary-title: Docker test gating + summary-note: >- + Triggered jobs: ${{ github.event_name == 'push' + && 'Docker base build job + rendering-correctness + rendering-correctness-kitless' + || 'Docker build jobs + all test-* matrix jobs' }}. + patterns: | + ^source/ Library source code + ^docker/ Container build inputs + ^tools/ Build tooling + ^apps/ Standalone apps + ^scripts/ Standalone scripts + ^\.github/workflows/build\.yaml$ This workflow file + ^\.github/workflows/config\.yaml$ Base image config + ^\.github/workflows/detect-changes\.yml$ Path-gating workflow + ^\.github/actions/ CI actions + ^\.github/test-subsets/ CI test subset config config: name: Load Config @@ -233,7 +148,7 @@ jobs: name: Build Base Docker Image runs-on: [self-hosted, gpu] needs: [changes, config] - if: needs.changes.outputs.run_docker_tests == 'true' + if: needs.changes.outputs.run == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 @@ -256,7 +171,7 @@ jobs: needs: [changes, config] if: >- github.event_name != 'push' && - needs.changes.outputs.run_docker_tests == 'true' + needs.changes.outputs.run == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 @@ -280,7 +195,7 @@ jobs: name: arm-ci runs-on: [self-hosted, arm64] needs: [changes, config] - if: needs.changes.outputs.run_docker_tests == 'true' + if: needs.changes.outputs.run == 'true' timeout-minutes: 60 continue-on-error: true steps: diff --git a/.github/workflows/detect-changes.yml b/.github/workflows/detect-changes.yml new file mode 100644 index 000000000000..89a905c9eb9d --- /dev/null +++ b/.github/workflows/detect-changes.yml @@ -0,0 +1,144 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# Shared path-gating engine, called via workflow_call so the gate job needs +# no checkout: GitHub resolves local reusable workflows server-side from the +# caller's own commit. Pull requests run the gated jobs only when a changed +# file matches one of the given patterns; non-PR events and changed-file +# listing failures fail open (run). Consuming jobs should skip via `if:` so +# required checks report green instead of never triggering (a workflow-level +# `paths:` filter would leave a required check pending forever). + +name: Detect Changed Paths + +on: + workflow_call: + inputs: + patterns: + description: > + One entry per line: an extended regex matched against the changed + file paths, then whitespace, then a human-readable description for + the step-summary table. The regex itself must not contain + whitespace (the first whitespace run is the column separator). + required: true + type: string + summary-title: + description: 'Heading for the step-summary section.' + required: false + type: string + default: 'Path gating' + summary-note: + description: 'Optional extra line appended below the decision.' + required: false + type: string + default: '' + outputs: + run: + description: '"true" when the gated jobs should run, "false" otherwise.' + value: ${{ jobs.detect.outputs.run }} + +jobs: + detect: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + run: ${{ steps.detect.outputs.run }} + steps: + - id: detect + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + PATTERNS_INPUT: ${{ inputs.patterns }} + SUMMARY_TITLE: ${{ inputs.summary-title }} + SUMMARY_NOTE: ${{ inputs.summary-note }} + run: | + set -euo pipefail + + # Parse "regexdescription" lines into parallel arrays. + regexes=() + descriptions=() + while IFS= read -r line; do + [[ -z "${line//[[:space:]]/}" ]] && continue + regex="${line%%[[:space:]]*}" + desc="${line#"$regex"}" + desc="${desc#"${desc%%[![:space:]]*}"}" + regexes+=("$regex") + descriptions+=("${desc:-$regex}") + done <<< "$PATTERNS_INPUT" + + render_table() { + local files="$1" i regex desc count sample shown + echo "| Pattern | What it covers | Matched files |" + echo "|---|---|---|" + for i in "${!regexes[@]}"; do + regex="${regexes[$i]}" + desc="${descriptions[$i]}" + # escape | so it doesn't end the markdown table cell mid-regex + shown="${regex//|/\\|}" + count=$(grep -cE "$regex" <<< "$files" || true) + if [ "$count" -gt 0 ]; then + sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) + [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" + echo "| \`$shown\` | $desc | $sample |" + else + echo "| \`$shown\` | $desc | - |" + fi + done + } + + any_match() { + local files="$1" regex + for regex in "${regexes[@]}"; do + if grep -qE "$regex" <<< "$files"; then + return 0 + fi + done + return 1 + } + + decide() { + local decision="$1" reason="$2" files="${3:-}" + echo "Decision: run=$decision ($reason)" + echo "run=$decision" >> "$GITHUB_OUTPUT" + { + echo "## ${SUMMARY_TITLE}" + echo "" + if [ "$decision" = "true" ]; then + echo "Gated jobs will **run**: $reason." + else + echo "Gated jobs will be **skipped**: $reason." + fi + if [ -n "$SUMMARY_NOTE" ]; then + echo "" + echo "$SUMMARY_NOTE" + fi + if [ -n "$files" ]; then + echo "" + render_table "$files" + fi + } >> "$GITHUB_STEP_SUMMARY" + } + + if [ "$EVENT_NAME" != "pull_request" ]; then + decide true "non-PR event ($EVENT_NAME)" + exit 0 + fi + + if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then + # Fail-safe: a transient API error must not block merge. Default to running. + echo "::warning::Could not list changed files; defaulting to running tests" + decide true "fail-safe (could not list changed files)" + exit 0 + fi + + printf '%s\n' "$changed_files" + + if any_match "$changed_files"; then + decide true "relevant paths changed" "$changed_files" + else + decide false "no relevant paths changed" "$changed_files" + fi diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 226222d425bd..faa25b569470 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -34,100 +34,26 @@ permissions: jobs: changes: name: Detect Changes - runs-on: ubuntu-latest - outputs: - run_install_tests: ${{ steps.detect.outputs.run_install_tests }} - steps: - - id: detect - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - EVENT_NAME: ${{ github.event_name }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - # Installation tests run only when paths in the patterns table change. - # Otherwise the test job skips via `if:` and reports green to branch - # protection, which is why we don't use a workflow-level `paths:` - # filter (a not-triggered required check would block the PR forever). - patterns=( - $'^apps/\tStandalone apps' - $'^tools/\tBuild tooling' - $'^source/\tLibrary source code' - $'^\\.github/actions/run-package-tests/\tTest action' - $'^\\.github/actions/install-ci-(collect|run)/\tInstall-ci composite actions' - $'^\\.github/workflows/install-ci\\.yml$\tThis workflow file' - $'^VERSION$\tVersion file' - $'(^|/)pyproject\\.toml$\tPython project metadata' - $'(^|/)environment\\.ya?ml$\tConda environment file' - ) - - render_table() { - local files="$1" entry regex desc count sample shown - echo "| Pattern | What it covers | Matched files |" - echo "|---|---|---|" - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex desc <<< "$entry" - # escape | so it doesn't end the markdown table cell mid-regex - shown="${regex//|/\\|}" - count=$(grep -cE "$regex" <<< "$files" || true) - if [ "$count" -gt 0 ]; then - sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) - [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" - echo "| \`$shown\` | $desc | $sample |" - else - echo "| \`$shown\` | $desc | - |" - fi - done - } - - any_match() { - local files="$1" entry regex - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex _ <<< "$entry" - if grep -qE "$regex" <<< "$files"; then - return 0 - fi - done - return 1 - } - - decide() { - local decision="$1" reason="$2" files="${3:-}" - echo "Decision: run_install_tests=$decision ($reason)" - echo "run_install_tests=$decision" >> "$GITHUB_OUTPUT" - { - if [ -n "$files" ]; then - render_table "$files" - fi - } >> "$GITHUB_STEP_SUMMARY" - } - - if [ "$EVENT_NAME" != "pull_request" ]; then - decide true "non-PR event ($EVENT_NAME)" - exit 0 - fi - - if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then - # Fail-safe: a transient API error must not block merge. Default to running. - echo "::warning::Could not list changed files; defaulting to running tests" - decide true "fail-safe (could not list changed files)" - exit 0 - fi - - printf '%s\n' "$changed_files" - - if any_match "$changed_files"; then - decide true "relevant paths changed" "$changed_files" - else - decide false "no relevant paths changed" "$changed_files" - fi + # Installation tests run only when paths in the patterns table change. + uses: ./.github/workflows/detect-changes.yml + with: + summary-title: Installation test gating + patterns: | + ^apps/ Standalone apps + ^tools/ Build tooling + ^source/ Library source code + ^\.github/actions/run-package-tests/ Test action + ^\.github/actions/install-ci-(collect|run)/ Install-ci composite actions + ^\.github/workflows/detect-changes\.yml$ Path-gating workflow + ^\.github/workflows/install-ci\.yml$ This workflow file + ^VERSION$ Version file + (^|/)pyproject\.toml$ Python project metadata + (^|/)environment\.ya?ml$ Conda environment file install-tests-x86: name: Installation Tests (x86) needs: [changes] - if: needs.changes.outputs.run_install_tests == 'true' + if: needs.changes.outputs.run == 'true' runs-on: [self-hosted, gpu] timeout-minutes: 90 steps: @@ -146,7 +72,7 @@ jobs: install-tests-arm: name: Installation Tests (ARM) needs: [changes] - if: needs.changes.outputs.run_install_tests == 'true' + if: needs.changes.outputs.run == 'true' runs-on: [self-hosted, aarch64] timeout-minutes: 90 steps: diff --git a/.github/workflows/newton-docker.yml b/.github/workflows/newton-docker.yml new file mode 100644 index 000000000000..8c99c5aeedd6 --- /dev/null +++ b/.github/workflows/newton-docker.yml @@ -0,0 +1,171 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# Builds the kitless Newton training image (docker/Dockerfile.newton) and runs +# the canonical cartpole training smoke inside it. This is an install/build +# test of the shipped container artifact: library code is covered by the +# build.yaml test matrix, and the documented native install paths are covered +# by the Installation Tests workflow. + +name: Newton Docker Image + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + pull-requests: read + +jobs: + changes: + name: Detect Changes + # This job is an install/build test, so it gates only on the install + # inputs — the same set that feeds the deps-hash in the + # ecr-build-push-pull action — plus its own wiring. Source-level + # kitless regressions are accepted to surface on the next + # install-input change rather than on every source PR. + uses: ./.github/workflows/detect-changes.yml + with: + summary-title: Newton docker gating + patterns: | + ^docker/Dockerfile\.newton$ Newton image definition + ^isaaclab\.sh$ Install CLI wrapper + ^pyproject\.toml$ Root dependency manifest + ^source/[^/]+/pyproject\.toml$ Package manifests + ^source/isaaclab/isaaclab/cli/ Install CLI implementation + ^\.github/workflows/newton-docker\.yml$ This workflow file + ^\.github/workflows/detect-changes\.yml$ Path-gating workflow + ^\.github/actions/ecr-build-push-pull/ ECR build-cache action + + build-newton: + name: Build Newton Kitless Docker Image + runs-on: [self-hosted, gpu] + timeout-minutes: 120 + needs: [changes] + if: needs.changes.outputs.run == 'true' + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + lfs: true + + # Mirrors the per-commit tag scheme of build.yaml's config job so the + # ECR tags stay in the same family as the other CI images. + - name: Compute image tag + id: tag + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "pull_request" ]; then + ref_component="pr-${PR_NUMBER}" + else + ref_component="$REF_NAME" + fi + # Sanitize the ref name for use as a Docker tag suffix. + sanitized_ref=$(echo "$ref_component" | sed 's/[^a-zA-Z0-9._-]/-/g') + echo "image_tag=isaac-lab-ci:${sanitized_ref}-${SHA}-newton" >> "$GITHUB_OUTPUT" + echo "CI image tag: isaac-lab-ci:${sanitized_ref}-${SHA}-newton" + + # Same ECR-cached setup as the build.yaml image builds: an existing + # per-commit or deps- tag in ECR skips the build entirely, and full + # builds reuse the registry layer cache. Dockerfile.newton builds from the + # public ubuntu:24.04 image, so the (historically named) + # isaacsim-base-image / isaacsim-version inputs only pin the base image + # whose digest feeds the deps hash; the ISAACSIM_* build-args the action + # passes are not declared by this Dockerfile and are ignored. The kitless + # guarantee stays asserted inside the Dockerfile itself (no isaacsim + # distribution, no _isaac_sim link), so any path that produces the image + # has already proven it. + - name: Build and push to ECR + uses: ./.github/actions/ecr-build-push-pull + with: + image-tag: ${{ steps.tag.outputs.image_tag }} + isaacsim-base-image: ubuntu + isaacsim-version: "24.04" + dockerfile-path: docker/Dockerfile.newton + cache-tag: cache-newton + # The smoke below docker-runs the image in this same job, and the + # ECR login only exists while the action runs, so the action itself + # must pull on a deps-cache hit. + pull-on-deps-hit: "true" + + # Every action path (full build --load, exact-tag pull, deps-hit pull) + # leaves the image local; fail fast with a clear message if not, instead + # of letting docker run attempt a docker.io pull of the CI-only tag. + - name: Ensure image is available locally + shell: bash + run: | + set -euo pipefail + IMAGE_TAG="${{ steps.tag.outputs.image_tag }}" + if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then + echo "::error::Image ${IMAGE_TAG} is not available locally after the build action." + exit 1 + fi + + # Mirrors the verification command documented in + # docs/source/deployment/docker.rst and the canonical cartpole training + # probe used by the installation tests. source/, scripts/ and apps/ are + # mounted from the checkout so a deps-cached image still runs this PR's + # code: the isaaclab packages are installed editable from source/, so the + # baked venv resolves imports through the mounted tree. + - name: Run training smoke test + shell: bash + run: | + set -euo pipefail + log="$(mktemp)" + docker run --rm --gpus all --network host \ + -v "$PWD/source":/workspace/isaaclab/source \ + -v "$PWD/scripts":/workspace/isaaclab/scripts \ + -v "$PWD/apps":/workspace/isaaclab/apps \ + "${{ steps.tag.outputs.image_tag }}" \ + isaaclab train --rl_library rsl_rl \ + --task Isaac-Cartpole-Direct \ + --num_envs 16 \ + presets=newton_mjwarp \ + --max_iterations 5 2>&1 | tee "$log" + if grep -q "Traceback (most recent call last):" "$log"; then + echo "::error::Training smoke test raised a traceback." + exit 1 + fi + if ! grep -q "Training time:" "$log"; then + echo "::error::Training smoke test did not report 'Training time:'." + exit 1 + fi + + - name: Verify Newton runtime user is non-root + shell: bash + run: | + set -euo pipefail + runtime_identity="$(docker run --rm --entrypoint bash "${{ steps.tag.outputs.image_tag }}" \ + -lc 'printf "%s %s %s\n" "$(id -u)" "$(id -g)" "$(id -un 2>/dev/null || true)"')" + read -r runtime_uid runtime_gid runtime_user <<< "${runtime_identity}" + echo "Newton runtime identity: uid=${runtime_uid} gid=${runtime_gid} user=${runtime_user}" + if [ "${runtime_uid}" = "0" ]; then + echo "::error::Newton Docker image must not run as root by default." + exit 1 + fi + + # Keep the self-hosted runner's disk lean: drop the local tags after the + # run; the layers stay cached in ECR for the next build. + - name: Remove local image + if: always() + shell: bash + run: | + docker image rm -f "${{ steps.tag.outputs.image_tag }}" || true + if [ -n "${ECR_IMAGE:-}" ]; then + docker image rm -f "${ECR_IMAGE}" || true + fi diff --git a/docker/Dockerfile.newton b/docker/Dockerfile.newton new file mode 100644 index 000000000000..fc3b98332ad8 --- /dev/null +++ b/docker/Dockerfile.newton @@ -0,0 +1,98 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# Kitless Newton image for GPU-accelerated headless training without Isaac Sim. +ARG BASE_IMAGE_ARG=ubuntu:24.04 + +FROM ${BASE_IMAGE_ARG} AS builder + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ARG ISAACLAB_PATH_ARG=/workspace/isaaclab +ENV ISAACLAB_PATH=${ISAACLAB_PATH_ARG} +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 +ENV VIRTUAL_ENV=${ISAACLAB_PATH_ARG}/.venv +ENV PATH=${ISAACLAB_PATH_ARG}/.venv/bin:/usr/local/bin:${PATH} +ENV UV_NO_CACHE=1 + +# Keep this package set aligned with the documented Ubuntu installation path. +# Native build tools remain confined to this stage. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + git \ + libglib2.0-0 \ + python3 \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* \ + && curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh + +WORKDIR ${ISAACLAB_PATH} + +# Copy the install inputs before the rest of the runtime tree so dependency +# installation remains cached when only scripts or application code changes. +COPY isaaclab.sh pyproject.toml VERSION LICENSE LICENSE-mimic README.md ./ +COPY source/ source/ + +# The venv pins the system Python 3.12 (the version documented in the install +# docs) so the interpreter cannot silently drift from the runtime stage's +# libpython3.12 if the base image's default python3 ever changes. +RUN chmod +x isaaclab.sh \ + && uv venv --python /usr/bin/python3.12 --seed "${VIRTUAL_ENV}" \ + && ./isaaclab.sh --install 'newton,rl[rsl-rl]' \ + && python -c "import importlib.metadata as m; assert all(d.metadata['Name'].lower() != 'isaacsim' for d in m.distributions())" \ + && test ! -e "${ISAACLAB_PATH}/_isaac_sim" + +# isaaclab.utils.assets parses apps/isaaclab.python.kit at import time to +# resolve the Nucleus asset root, so apps/ is required even without Kit. +COPY apps/ apps/ +COPY scripts/ scripts/ +COPY docs/licenses/ docs/licenses/ + + +FROM ${BASE_IMAGE_ARG} AS runtime + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ARG ISAACLAB_PATH_ARG=/workspace/isaaclab +ENV ISAACLAB_PATH=${ISAACLAB_PATH_ARG} +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 +ENV HOME=/home/isaaclab +ENV VIRTUAL_ENV=${ISAACLAB_PATH_ARG}/.venv +ENV PATH=${ISAACLAB_PATH_ARG}/.venv/bin:/usr/local/bin:${PATH} +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV NVIDIA_VISIBLE_DEVICES=all +ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + libglib2.0-0 \ + libpython3.12 \ + python3 \ + && rm -rf /var/lib/apt/lists/* \ + && if getent passwd ubuntu >/dev/null; then userdel -r ubuntu; fi \ + && if getent group ubuntu >/dev/null; then groupdel ubuntu; fi \ + && groupadd --gid 1000 isaaclab \ + && useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash isaaclab + +COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv +COPY --from=builder /usr/local/bin/uvx /usr/local/bin/uvx +COPY --from=builder --chown=isaaclab:isaaclab ${ISAACLAB_PATH} ${ISAACLAB_PATH} + +RUN mkdir -p ${ISAACLAB_PATH}/logs ${HOME}/.cache/uv ${HOME}/.cache/warp \ + && chown -R isaaclab:isaaclab ${ISAACLAB_PATH}/logs ${HOME} + +USER isaaclab +WORKDIR ${ISAACLAB_PATH} + +CMD ["bash"] diff --git a/docker/test/test_dockerfile_nonroot.py b/docker/test/test_dockerfile_nonroot.py index 52959e6ffa79..5e6a0e12c8fa 100644 --- a/docker/test/test_dockerfile_nonroot.py +++ b/docker/test/test_dockerfile_nonroot.py @@ -35,12 +35,18 @@ def _load_module(name: str, path: Path): "Dockerfile.base": "isaaclab", "Dockerfile.curobo": "isaaclab", "Dockerfile.installci": "isaaclab", + "Dockerfile.newton": "isaaclab", "Dockerfile.ros2": "isaaclab", } # Dockerfiles that are expected to *create* the non-root runtime user # (i.e. contain groupadd/useradd/USER isaaclab). -DOCKERFILES_CREATING_RUNTIME_USER = {"Dockerfile.base", "Dockerfile.curobo", "Dockerfile.installci"} +DOCKERFILES_CREATING_RUNTIME_USER = { + "Dockerfile.base", + "Dockerfile.curobo", + "Dockerfile.installci", + "Dockerfile.newton", +} USER_DIRECTIVE_RE = re.compile(r"^USER\s+(\S+)\s*$") @@ -103,6 +109,15 @@ def test_ros2_dockerfile_restores_non_root_runtime_user(): assert _user_directives(dockerfile_text) == ["root", "isaaclab"] +def test_newton_dockerfile_excludes_isaac_sim(): + """The kitless image installs Newton without an Isaac Sim runtime.""" + dockerfile_text = (DOCKER_DIR / "Dockerfile.newton").read_text(encoding="utf-8") + + assert "--install 'newton,rl[rsl-rl]'" in dockerfile_text + assert "all(d.metadata['Name'].lower() != 'isaacsim'" in dockerfile_text + assert 'test ! -e "${ISAACLAB_PATH}/_isaac_sim"' in dockerfile_text + + # --------------------------------------------------------------------------- # # Volume mount-point writability # diff --git a/docs/source/deployment/docker.rst b/docs/source/deployment/docker.rst index c5eadb3c5416..6588cbf74d7f 100644 --- a/docs/source/deployment/docker.rst +++ b/docs/source/deployment/docker.rst @@ -6,8 +6,9 @@ Docker Guide .. caution:: - Due to the dependency on Isaac Sim docker image, by running this container you are implicitly - agreeing to the `NVIDIA Software License Agreement`_. If you do not agree to the EULA, do not run this container. + The standard Isaac Lab container depends on the Isaac Sim Docker image. By running that container, you are + implicitly agreeing to the `NVIDIA Software License Agreement`_. The kitless Newton container described below + does not contain Isaac Sim. Setup Instructions ------------------ @@ -45,6 +46,7 @@ needed to run Isaac Lab inside a Docker container. A subset of these are summari * **Dockerfile.base**: Defines the base Isaac Lab image by overlaying its dependencies onto the Isaac Sim Docker image. Dockerfiles which end with something else, (i.e. ``Dockerfile.ros2``) build an `image extension <#isaac-lab-image-extensions>`_. +* **Dockerfile.newton**: Defines a kitless image for headless Newton training without Isaac Sim dependencies. * **docker-compose.yaml**: Creates mounts to allow direct editing of Isaac Lab code from the host machine that runs the container. It also creates several named volumes such as ``isaac-cache-kit`` to store frequently reused resources compiled by Isaac Sim, such as shaders, and to retain logs, data, and documents. @@ -300,6 +302,32 @@ The container defaults to ``FastRTPS``, but ``CylconeDDS`` is also supported. Ea :language: bash +Building the Kitless Newton Container +------------------------------------- + +The kitless Newton container uses Ubuntu 24.04 and a Python 3.12 virtual environment instead of the Isaac Sim base +image. It includes the Newton backend and RSL-RL for GPU-accelerated headless training. Build it from the repository +root: + +.. code:: bash + + docker build --file docker/Dockerfile.newton --tag isaac-lab-newton . + +Run a short Cartpole training job to verify the image and NVIDIA Container Toolkit setup: + +.. code:: bash + + docker run --rm --gpus all --network host isaac-lab-newton \ + isaaclab train --rl_library rsl_rl \ + --task Isaac-Cartpole-Direct \ + --num_envs 16 \ + presets=newton_mjwarp \ + --max_iterations 5 + +The image runs as the non-root ``isaaclab`` user with uid/gid 1000. It is intentionally separate from the Compose +profiles, which configure Isaac Sim services and cache volumes. + + Running Pre-Built Isaac Lab Container ------------------------------------- diff --git a/source/isaaclab/changelog.d/newton-kitless-container.minor.rst b/source/isaaclab/changelog.d/newton-kitless-container.minor.rst new file mode 100644 index 000000000000..23a7461b3975 --- /dev/null +++ b/source/isaaclab/changelog.d/newton-kitless-container.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added a kitless Newton container for headless training without Isaac Sim dependencies.