diff --git a/.devcontainer.json b/.devcontainer.json deleted file mode 100644 index d5d850ca5..000000000 --- a/.devcontainer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18", - "remoteUser": "node", - "customizations": { - "vscode": { - "extensions": [ - "mads-hartmann.bash-ide-vscode" - ] - } - }, - "features": { - "ghcr.io/devcontainers/features/docker-in-docker:1": {} - }, - "postCreateCommand": "npm install -g @devcontainers/cli" -} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..db939da5b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "image": "mcr.microsoft.com/devcontainers/javascript-node:3-22", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, + "postCreateCommand": "npm install -g @devcontainers/cli", + "hostRequirements": { + "cpus": 4 + }, + "customizations": { + "vscode": { + "extensions": [ + "mads-hartmann.bash-ide-vscode" + ] + } + } +} diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 000000000..144149689 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/.devcontainer" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/docker-in-docker-daemon-startup-bulk-test.yaml b/.github/workflows/docker-in-docker-daemon-startup-bulk-test.yaml new file mode 100644 index 000000000..668879730 --- /dev/null +++ b/.github/workflows/docker-in-docker-daemon-startup-bulk-test.yaml @@ -0,0 +1,37 @@ +name: "Test Docker daemon startup in bulk - Docker in Docker" +on: + pull_request: + paths: + - 'src/docker-in-docker/**' + workflow_dispatch: + +jobs: + test: + strategy: + matrix: + test-pass: [ 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50 ] + fail-fast: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: "Install latest devcontainer CLI" + run: npm install -g @devcontainers/cli + + - name: "Generating tests for 'docker-in-docker' which validates if docker daemon is running (with iptablesSwitchAtRuntime=true)" + run: devcontainer features test -f docker-in-docker --skip-autogenerated --filter "docker_iptables_switch_at_runtime" . + + test-onCreate: + strategy: + matrix: + test-pass: [ 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50 ] + fail-fast: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: "Install latest devcontainer CLI" + run: npm install -g @devcontainers/cli + + - name: "Generating tests for 'docker-in-docker' which validates if docker daemon is available within 'onCreateCommand'" + run: devcontainer features test -f docker-in-docker --skip-autogenerated --filter "docker_with_on_create_command" -i mcr.microsoft.com/devcontainers/base:noble \ No newline at end of file diff --git a/.github/workflows/linter-automated.yaml b/.github/workflows/linter-automated.yaml index 0c3420a62..db57db29d 100644 --- a/.github/workflows/linter-automated.yaml +++ b/.github/workflows/linter-automated.yaml @@ -1,5 +1,5 @@ name: "CI - Shell Script Linter" -on: +on: push: branches: - main @@ -9,10 +9,10 @@ jobs: shellchecker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Shell Linter - uses: azohra/shell-linter@v0.6.0 + uses: azohra/shell-linter@v0.8.0 with: path: "src/**/*.sh" severity: "error" # [style, info, warning, error] diff --git a/.github/workflows/linter-manual.yaml b/.github/workflows/linter-manual.yaml index 1a808939a..ba72f8eaf 100644 --- a/.github/workflows/linter-manual.yaml +++ b/.github/workflows/linter-manual.yaml @@ -15,10 +15,10 @@ jobs: shellchecker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Shell Linter - uses: azohra/shell-linter@v0.6.0 + uses: azohra/shell-linter@v0.8.0 with: path: ${{ github.event.inputs.path }} severity: ${{ github.event.inputs.severity }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f8dfc7210..96c95398b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,13 +9,16 @@ jobs: deploy: if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest + permissions: + packages: write + contents: write steps: - - uses: actions/checkout@v2 - + - uses: actions/checkout@v7 + - name: "Publish" uses: devcontainers/action@v1 with: publish-features: "true" base-path-to-features: "./src" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-all.yaml b/.github/workflows/test-all.yaml index 6d2ba0721..dd1d216a0 100644 --- a/.github/workflows/test-all.yaml +++ b/.github/workflows/test-all.yaml @@ -17,8 +17,9 @@ jobs: "azure-cli", "common-utils", "conda", + "copilot-cli", "desktop-lite", - "docker-from-docker", + "docker-outside-of-docker", "docker-in-docker", "dotnet", "git", @@ -38,17 +39,19 @@ jobs: "rust", "sshd", "terraform", + "nix", ] baseImage: [ - "ubuntu:focal", "ubuntu:jammy", "debian:11", + "debian:12", "mcr.microsoft.com/devcontainers/base:ubuntu", "mcr.microsoft.com/devcontainers/base:debian", + "mcr.microsoft.com/devcontainers/base:noble" ] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: "Install latest devcontainer CLI" run: npm install -g @devcontainers/cli @@ -67,8 +70,9 @@ jobs: "azure-cli", "common-utils", "conda", + "copilot-cli", "desktop-lite", - "docker-from-docker", + "docker-outside-of-docker", "docker-in-docker", "dotnet", "git", @@ -88,9 +92,10 @@ jobs: "rust", "sshd", "terraform", + "nix", ] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: "Install latest devcontainer CLI" run: npm install -g @devcontainers/cli @@ -102,10 +107,10 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: "Install latest devcontainer CLI" run: npm install -g @devcontainers/cli - name: "Testing global scenarios" - run: devcontainer features test --global-scenarios-only . \ No newline at end of file + run: devcontainer features test --global-scenarios-only . diff --git a/.github/workflows/test-manual.yaml b/.github/workflows/test-manual.yaml index 333dbb801..18dffd1b9 100644 --- a/.github/workflows/test-manual.yaml +++ b/.github/workflows/test-manual.yaml @@ -9,7 +9,7 @@ on: baseImage: description: "Base image" required: true - default: "ubuntu:focal" + default: "ubuntu:noble" logLevel: description: "Log Level (info/debug/trace)" required: true @@ -19,7 +19,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: "Install latest devcontainer CLI" run: npm install -g @devcontainers/cli diff --git a/.github/workflows/test-pr-arm64.yaml b/.github/workflows/test-pr-arm64.yaml new file mode 100644 index 000000000..0de1f066b --- /dev/null +++ b/.github/workflows/test-pr-arm64.yaml @@ -0,0 +1,88 @@ +name: "PR - Test Updated Features (arm64)" +on: + pull_request: + # NOTE: To extend this workflow to other features, add path entries below + # following the same pattern, e.g.: + # - "src//**" + # - "test//**" + paths: + - "src/powershell/**" + - "test/powershell/**" + - "src/docker-in-docker/**" + - "test/docker-in-docker/**" + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + features: ${{ steps.filter.outputs.changes }} + steps: + - uses: dorny/paths-filter@v4 + id: filter + with: + # NOTE: To extend this workflow to other features, add filter entries below + # following the same pattern, e.g.: + # : ./**//** + filters: | + powershell: ./**/powershell/** + docker-in-docker: ./**/docker-in-docker/** + + test: + needs: [detect-changes] + runs-on: ubuntu-24.04-arm + continue-on-error: true + strategy: + matrix: + features: ${{ fromJSON(needs.detect-changes.outputs.features) }} + # NOTE: ubuntu:focal and debian:11 are excluded because they ship + # GLIBC 2.31, but PowerShell >= 7.6.0 requires GLIBC 2.33+ on arm64. + # ubuntu:focal reached EOL Apr 2025; debian:11 reaches EOL Aug 2026. + baseImage: + [ + "ubuntu:jammy", + "debian:12", + "mcr.microsoft.com/devcontainers/base:ubuntu", + "mcr.microsoft.com/devcontainers/base:debian", + "mcr.microsoft.com/devcontainers/base:noble" + ] + exclude: + - features: docker-in-docker + baseImage: mcr.microsoft.com/devcontainers/base:debian + - features: docker-in-docker + baseImage: mcr.microsoft.com/devcontainers/base:ubuntu + steps: + - uses: actions/checkout@v7 + + - name: "Load erofs module and verify" + run: sudo modprobe erofs && grep erofs /proc/filesystems + + - name: "Install latest devcontainer CLI" + run: npm install -g @devcontainers/cli + + - name: "Generating tests for '${{ matrix.features }}' against '${{ matrix.baseImage }}'" + run: devcontainer features test --skip-scenarios -f ${{ matrix.features }} -i ${{ matrix.baseImage }} . + + test-scenarios: + needs: [detect-changes] + runs-on: ubuntu-24.04-arm + continue-on-error: true + strategy: + matrix: + features: ${{ fromJSON(needs.detect-changes.outputs.features) }} + steps: + - uses: actions/checkout@v7 + + - name: "Install latest devcontainer CLI" + run: npm install -g @devcontainers/cli + + - name: "Exclude iptables-isolation scenarios from docker-in-docker" + if: matrix.features == 'docker-in-docker' + run: | + sudo apt-get update && sudo apt-get install -y jq + sed 's://.*$::' test/docker-in-docker/scenarios.json \ + | jq 'del(.docker_with_default_iptables, .docker_with_default_iptables_ubuntu)' \ + > test/docker-in-docker/scenarios.json.tmp + mv test/docker-in-docker/scenarios.json.tmp test/docker-in-docker/scenarios.json + + - name: "Testing '${{ matrix.features }}' scenarios" + run: devcontainer features test -f ${{ matrix.features }} --skip-autogenerated . diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 8d21b0364..e18291f09 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -8,7 +8,7 @@ jobs: outputs: features: ${{ steps.filter.outputs.changes }} steps: - - uses: dorny/paths-filter@v2 + - uses: dorny/paths-filter@v4 id: filter with: filters: | @@ -17,8 +17,9 @@ jobs: azure-cli: ./**/azure-cli/** common-utils: ./**/common-utils/** conda: ./**/conda/** - desktop-lite: ./**/desktop-lite/** - docker-from-docker: ./**/docker-from-docker/** + copilot-cli: ./**/copilot-cli/** + desktop-lite: ./**/desktop-lite/** + docker-outside-of-docker: ./**/docker-outside-of-docker/** docker-in-docker: ./**/docker-in-docker/** dotnet: ./**/dotnet/** git: ./**/git/** @@ -38,6 +39,7 @@ jobs: rust: ./**/rust/** sshd: ./**/sshd/** terraform: ./**/terraform/** + nix: ./**/nix/** test: needs: [detect-changes] @@ -48,14 +50,28 @@ jobs: features: ${{ fromJSON(needs.detect-changes.outputs.features) }} baseImage: [ - "ubuntu:focal", "ubuntu:jammy", "debian:11", + "debian:12", "mcr.microsoft.com/devcontainers/base:ubuntu", "mcr.microsoft.com/devcontainers/base:debian", + "mcr.microsoft.com/devcontainers/base:noble" ] + exclude: + - features: oryx + baseImage: ubuntu:jammy + - features: oryx + baseImage: mcr.microsoft.com/devcontainers/base:ubuntu + - features: docker-in-docker + baseImage: mcr.microsoft.com/devcontainers/base:debian + - features: docker-outside-of-docker + baseImage: mcr.microsoft.com/devcontainers/base:debian + - features: docker-in-docker + baseImage: mcr.microsoft.com/devcontainers/base:ubuntu + - features: docker-outside-of-docker + baseImage: mcr.microsoft.com/devcontainers/base:ubuntu steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: "Install latest devcontainer CLI" run: npm install -g @devcontainers/cli @@ -65,16 +81,53 @@ jobs: test-scenarios: needs: [detect-changes] - runs-on: ubuntu-latest + runs-on: devcontainer-image-builder-ubuntu continue-on-error: true strategy: matrix: features: ${{ fromJSON(needs.detect-changes.outputs.features) }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: "Install latest devcontainer CLI" run: npm install -g @devcontainers/cli + - name: "Exclude iptables-isolation scenarios from docker-in-docker (run in separate 'iptables-isolation' job)" + if: matrix.features == 'docker-in-docker' + run: | + sudo apt-get update && sudo apt-get install -y jq + sed 's://.*$::' test/docker-in-docker/scenarios.json \ + | jq 'del(.docker_with_default_iptables, .docker_with_default_iptables_ubuntu)' \ + > test/docker-in-docker/scenarios.json.tmp + mv test/docker-in-docker/scenarios.json.tmp test/docker-in-docker/scenarios.json + - name: "Testing '${{ matrix.features }}' scenarios" - run: devcontainer features test -f ${{ matrix.features }} --skip-autogenerated . \ No newline at end of file + run: devcontainer features test -f ${{ matrix.features }} --skip-autogenerated . + + iptables-isolation: + needs: [detect-changes] + if: contains(fromJSON(needs.detect-changes.outputs.features), 'docker-in-docker') + runs-on: ubuntu-latest + continue-on-error: true + strategy: + fail-fast: false + matrix: + scenario: + - docker_with_default_iptables + - docker_with_default_iptables_ubuntu + steps: + - uses: actions/checkout@v7 + + - name: "Install latest devcontainer CLI" + run: npm install -g @devcontainers/cli + + - name: "Isolate scenario '${{ matrix.scenario }}'" + run: | + sudo apt-get update && sudo apt-get install -y jq + sed 's://.*$::' test/docker-in-docker/scenarios.json \ + | jq '{ "${{ matrix.scenario }}": .["${{ matrix.scenario }}"] }' \ + > test/docker-in-docker/scenarios.json.tmp + mv test/docker-in-docker/scenarios.json.tmp test/docker-in-docker/scenarios.json + + - name: "Testing docker-in-docker scenario '${{ matrix.scenario }}'" + run: devcontainer features test --features docker-in-docker --filter ${{ matrix.scenario }} --skip-autogenerated . diff --git a/.github/workflows/update-aws-cli-completer-scripts.yml b/.github/workflows/update-aws-cli-completer-scripts.yml new file mode 100644 index 000000000..ea6090fe9 --- /dev/null +++ b/.github/workflows/update-aws-cli-completer-scripts.yml @@ -0,0 +1,68 @@ +name: "Updates vendor 'aws_bash_completer' and 'aws_zsh_completer.sh' scripts" +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' # Runs every Sunday at midnight UTC (adjust as needed) + +jobs: + fetch-latest-aws-completer-scripts: + runs-on: ubuntu-latest + environment: documentation # grants access to secrets.PAT, for creating pull requests + permissions: + contents: read + steps: + - name: Generate a token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.DEVCONTAINERS_REPO_AUTOMATION_ID }} + private-key: ${{ secrets.DEVCONTAINERS_REPO_AUTOMATION_PRIVATE_KEY }} + + - name: Checkout + id: checkout + uses: actions/checkout@v7 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Run fetch-latest-completer-scripts.sh + run: src/aws-cli/scripts/fetch-latest-completer-scripts.sh + + - name: Create a PR for completer scripts + id: push_image_info + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -e + echo "Start." + + # Configure git and Push updates + git config --global user.email github-actions@github.com + git config --global user.name github-actions + git config pull.rebase false + + branch=automated-script-update-$GITHUB_RUN_ID + git checkout -b $branch + message='[Updates] Automated vendor 'aws-cli' completer scripts' + + # Add / update and commit + git add src/aws-cli/scripts/vendor/aws_bash_completer + git add src/aws-cli/scripts/vendor/aws_zsh_completer.sh + + git commit -m 'Automated completer scripts update' || export NO_UPDATES=true + + # Bump version and push + if [ "$NO_UPDATES" != "true" ] ; then + echo "$(jq --indent 4 '.version = (.version | split(".") | map(tonumber) | .[2] += 1 | join("."))' src/aws-cli/devcontainer-feature.json)" > src/aws-cli/devcontainer-feature.json + git add src/aws-cli/devcontainer-feature.json + + git commit -m 'Bump version' + git push origin "$branch" + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${GITHUB_REPOSITORY}/pulls \ + -f title="$message" \ + -f body="$message" \ + -f head="$branch" \ + -f base="$GITHUB_REF_NAME" + fi diff --git a/.github/workflows/update-documentation.yml b/.github/workflows/update-documentation.yml index 711c49256..c766a6871 100644 --- a/.github/workflows/update-documentation.yml +++ b/.github/workflows/update-documentation.yml @@ -8,9 +8,23 @@ on: jobs: generate: runs-on: ubuntu-latest + environment: documentation + permissions: + contents: read if: "github.ref == 'refs/heads/main'" steps: - - uses: actions/checkout@v2 + - name: Generate a token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.DEVCONTAINERS_REPO_AUTOMATION_ID }} + private-key: ${{ secrets.DEVCONTAINERS_REPO_AUTOMATION_PRIVATE_KEY }} + + - name: Checkout + id: checkout + uses: actions/checkout@v7 + with: + token: ${{ steps.app-token.outputs.token }} - name: Generate Documentation uses: devcontainers/action@v1 @@ -18,10 +32,10 @@ jobs: generate-docs: "true" base-path-to-features: "./src" - - name: Add and Commit Documentation + - name: Create a PR for Documentation id: push_image_info env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -e echo "Start." @@ -42,5 +56,12 @@ jobs: # Push if [ "$NO_UPDATES" != "true" ] ; then git push origin "$branch" - gh pr create --title "$message" --body "$message" + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${GITHUB_REPOSITORY}/pulls \ + -f title="$message" \ + -f body="$message" \ + -f head="$branch" \ + -f base='main' fi diff --git a/.github/workflows/update-dotnet-install-script.yml b/.github/workflows/update-dotnet-install-script.yml new file mode 100644 index 000000000..fd2161d27 --- /dev/null +++ b/.github/workflows/update-dotnet-install-script.yml @@ -0,0 +1,74 @@ +name: "Updates vendor 'dotnet-install' script" +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' # Runs every Sunday at midnight UTC (adjust as needed) + +jobs: + fetch-latest-dotnet-install: + runs-on: ubuntu-latest + environment: documentation # grants access to secrets.PAT, for creating pull requests + permissions: + contents: read + steps: + - name: Generate a token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.DEVCONTAINERS_REPO_AUTOMATION_ID }} + private-key: ${{ secrets.DEVCONTAINERS_REPO_AUTOMATION_PRIVATE_KEY }} + + - name: Checkout + id: checkout + uses: actions/checkout@v7 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Run fetch-latest-dotnet-install.sh + run: src/dotnet/scripts/fetch-latest-dotnet-install.sh + + - name: Create a PR for dotnet-install.sh + id: push_image_info + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -e + echo "Start." + + # Update dotnet-install for Oryx Feature as well + cp src/dotnet/scripts/vendor/dotnet-install.sh src/oryx/scripts/vendor/dotnet-install.sh + + # Configure git and Push updates + git config --global user.email github-actions@github.com + git config --global user.name github-actions + git config pull.rebase false + + branch=automated-script-update-$GITHUB_RUN_ID + git checkout -b $branch + message='[Updates] Automated vendor 'dotnet-install' script' + + # Add / update and commit + git add src/dotnet/scripts/vendor/dotnet-install.sh + git add src/oryx/scripts/vendor/dotnet-install.sh + + git commit -m 'Automated dotnet-install script update' || export NO_UPDATES=true + + # Bump version and push + if [ "$NO_UPDATES" != "true" ] ; then + echo "$(jq --indent 4 '.version = (.version | split(".") | map(tonumber) | .[2] += 1 | join("."))' src/dotnet/devcontainer-feature.json)" > src/dotnet/devcontainer-feature.json + git add src/dotnet/devcontainer-feature.json + + echo "$(jq --indent 4 '.version = (.version | split(".") | map(tonumber) | .[2] += 1 | join("."))' src/oryx/devcontainer-feature.json)" > src/oryx/devcontainer-feature.json + git add src/oryx/devcontainer-feature.json + + git commit -m 'Bump version' + git push origin "$branch" + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${GITHUB_REPOSITORY}/pulls \ + -f title="$message" \ + -f body="$message" \ + -f head="$branch" \ + -f base="$GITHUB_REF_NAME" + fi diff --git a/.github/workflows/validate-metadata-files.yml b/.github/workflows/validate-metadata-files.yml new file mode 100644 index 000000000..5b24dfbee --- /dev/null +++ b/.github/workflows/validate-metadata-files.yml @@ -0,0 +1,16 @@ +name: "Validate devcontainer-feature.json files" +on: + workflow_dispatch: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: "Validate devcontainer-feature.json files" + uses: devcontainers/action@v1 + with: + validate-only: "true" + base-path-to-features: "./src" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..f6c7e3ab0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +**Thanks for taking the time to contribute! ❤️** + +All types of contributions are encouraged and valued, no matter if it's a bug +report 🐛, a feature request 💡, or a Pull Request 🚀. + +- **❓ I have a question:** Ask in [our dev container community Slack channel] +- **🐛 I found a bug:** [Open an Issue] +- **💡 I have an idea:** [Open an Issue] +- **💻 I want to code:** See below + +If you like the project, but just don't have time to contribute, that's OK too! +You can also star the project ⭐, rave about it online 💬, or add a link to us +🔗 in your project's readme. + +⚠️ You must never report security 🔒 related issues, vulnerabilities or bugs +including sensitive information to the issue tracker, or elsewhere in public. +Instead, please [report them to the Microsoft Security Response Center]. You can +read more about our security policy in [`SECURITY.md`]. + +## Contributing code + +👨‍💼 This repository is a collection of dev container Features managed by dev +container spec maintainers. This repository will **only accept improvements and +bug fixes** for the [current set of maintained Features]. + +🚀 If you're looking to create a new Feature, then we encourage adding it to a +separate repository of your control. The [devcontainers/feature-starter] +repository has more guidance on self authoring Features. + +💡 Once you've self authored the Features and if you'd like to share them with +the community, then we'd recommend [adding it to the index]. + +👷‍ If you've identified an issue and you want to fix it, here's how you can get +started: + +1. 🔀 Fork the repo +2. 💻 Open the repo in your editor +3. 👨‍💻 Add your changes to your workspace +4. ✨ [Test your changes using `devcontainer features test`] to make sure + everything still works +5. 🆚 Bump the version of the feature you changed according to [semver] +6. 🔖 Commit & push your changes +7. 🔁 Open a PR to get your changes merged +8. 🚀 Profit! + +👩‍⚖️ When contributing code to this project, you may be asked to agree to our +[Contributor License Agreement]. + + +[our dev container community Slack channel]: https://aka.ms/devcontainer_community +[open an issue]: https://github.com/devcontainers/features/issues/new +[current set of maintained Features]: https://github.com/devcontainers/features/tree/main/src +[devcontainers/feature-starter]: https://github.com/devcontainers/feature-starter#readme +[adding it to the index]: https://github.com/devcontainers/feature-starter#adding-features-to-the-index +[report them to the Microsoft Security Response Center]: https://msrc.microsoft.com/create-report +[`SECURITY.md`]: https://github.com/devcontainers/spec/blob/main/SECURITY.md +[contributor license agreement]: https://opensource.microsoft.com/cla/ +[Test your changes using `devcontainer features test`]: https://github.com/devcontainers/cli/blob/main/docs/features/test.md +[semver]: https://semver.org/ + diff --git a/README.md b/README.md index 831fc633d..bfe35baaa 100644 --- a/README.md +++ b/README.md @@ -4,36 +4,28 @@ devcontainers organization logo Development Container 'Features'
-A set of simple and reusable 'features'. Quickly add a language/tool/CLI to a development container. +A set of simple and reusable Features. Quickly add a language/tool/CLI to a development container. 'Features' are self-contained units of installation code and development container configuration. Features are designed -to install atop a wide-range of base container images (**this repo focuses on `debian` based images**). +to install atop a wide-range of base container images. -Missing a CLI or language in your otherwise _perfect_ container image? Add the relevant 'feature' to the `features` +Missing a CLI or language in your otherwise _perfect_ container image? Add the relevant Feature to the `features` property of a [`devcontainer.json`](https://containers.dev/implementors/json_reference/#general-properties). A [tool supporting the dev container specification](https://containers.dev/supporting) is required to build a development container. -⚠️ Development container 'features' are a -[**proposed**](https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-features.md) addition to the -[development container specification](https://containers.dev/implementors/spec/). **Please note that 'features' are in -preview and subject to breaking changes**. - -Once the [**proposed**](https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-features.md) -specification is accepted, implementation details will be published at -[https://containers.dev](https://containers.dev/). - +You may learn about Features at [containers.dev](https://containers.dev/implementors/features/), which is the website for the dev container specification. ## Usage -To reference a feature from this repository, add the desired features to a `devcontainer.json`. Each feature has a `README.md` that shows how to reference the feature and which options are available for that feature. +To reference a Feature from this repository, add the desired Features to a `devcontainer.json`. Each Feature has a `README.md` that shows how to reference the Feature and which options are available for that Feature. The example below installs the `go` and `docker-in-docker` declared in the [`./src`](./src) directory of this repository. -See the relevant feature's README for supported options. +See the relevant Feature's README for supported options. ```jsonc "name": "my-project-devcontainer", @@ -51,7 +43,7 @@ See the relevant feature's README for supported options. The `:latest` version annotation is added implicitly if omitted. To pin to a specific package version ([example](https://github.com/devcontainers/features/pkgs/container/features/go/versions)), append it to the end of the -feature. Features follow semantic versioning conventions, so you can pin to a major version `:1`, minor version `:1.0`, or patch version `:1.0.0` by specifying the appropriate label. +Feature. Features follow semantic versioning conventions, so you can pin to a major version `:1`, minor version `:1.0`, or patch version `:1.0.0` by specifying the appropriate label. ```jsonc "features": { @@ -63,7 +55,7 @@ feature. Features follow semantic versioning conventions, so you can pin to a ma The [devcontainer CLI reference implementation](https://github.com/devcontainers/cli) (or a [supporting tool](https://containers.dev/supporting)) can be used to build a project's dev container declaring -'features'. +Features. ```bash git clone @@ -97,7 +89,7 @@ devcontainer build --workspace-folder ... ``` -- [`src`](src) - A collection of subfolders, each declaring a feature. Each subfolder contains at least a +- [`src`](src) - A collection of subfolders, each declaring a Feature. Each subfolder contains at least a `devcontainer-feature.json` and an `install.sh` script. - [`test`](test) - Mirroring `src`, a folder-per-feature with at least a `test.sh` script. The [`devcontainer` CLI](https://github.com/devcontainers/cli) will execute @@ -105,15 +97,19 @@ devcontainer build --workspace-folder ## Contributions -### Creating your own collection of features +### Creating your own collection of Features -The [feature distribution specification](https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-features-distribution.md) outlines a pattern for community members and organizations to self-author features in repositories they control. +The [Feature distribution specification](https://containers.dev/implementors/features-distribution/) outlines a pattern for community members and organizations to self-author Features in repositories they control. -A template repo [`devcontainers/feature-template`](https://github.com/devcontainers/feature-template) and [GitHub Action](https://github.com/devcontainers/action) are available to help bootstrap self-authored features. +A template repo [`devcontainers/feature-template`](https://github.com/devcontainers/feature-template) and [GitHub Action](https://github.com/devcontainers/action) are available to help bootstrap self-authored Features. We are eager to hear your feedback on self-authoring! Please provide comments and feedback on [spec issue #70](https://github.com/devcontainers/spec/issues/70). ### Contributing to this repository This repository will accept improvement and bug fix contributions related to the -[current set of maintained features](./src). +[current set of maintained Features](./src). + +🤝 You can read more about how to contribute in [`CONTRIBUTING.md`]. ❤️ + +[`CONTRIBUTING.md`]: CONTRIBUTING.md diff --git a/src/anaconda/NOTES.md b/src/anaconda/NOTES.md index f2ecdc102..4a372cade 100644 --- a/src/anaconda/NOTES.md +++ b/src/anaconda/NOTES.md @@ -11,3 +11,13 @@ As covered in the [user FAQ](https://docs.anaconda.com/anaconda/user-guide/faq) ```bash conda install python=3.7 ``` + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +Also RHEL based linux distributions such as almalinux, rockylinux, fedora are supported now. +Please do note that Alpine and cbl-mariner aren't supported due system level restrictions with the anaconda installer. + +`bash` is required to execute the `install.sh` script. diff --git a/src/anaconda/README.md b/src/anaconda/README.md index f60ec2ba6..f093ce618 100644 --- a/src/anaconda/README.md +++ b/src/anaconda/README.md @@ -7,9 +7,7 @@ ```json "features": { - "ghcr.io/devcontainers/features/anaconda:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/anaconda:1": {} } ``` @@ -34,6 +32,13 @@ conda install python=3.7 ``` +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. +Also RHEL based linux distributions such as almalinux, rockylinux, fedora are supported now. +`bash` is required to execute the `install.sh` script. + + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/anaconda/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/anaconda/devcontainer-feature.json b/src/anaconda/devcontainer-feature.json index e4b753da4..ac76a9b99 100644 --- a/src/anaconda/devcontainer-feature.json +++ b/src/anaconda/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "anaconda", - "version": "1.0.7", + "version": "1.1.0", "name": "Anaconda", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/anaconda", "options": { @@ -16,5 +16,19 @@ "containerEnv": { "CONDA_DIR": "/usr/local/conda", "PATH": "/usr/local/conda/bin:${PATH}" - } + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes Anaconda and the conda package manager pre-installed and available on the `PATH` for data science and Python development. Additional packages installed using Conda will be downloaded from Anaconda or another repository configured by the user. A user can install different versions of Python than the one in this dev container by running a command like: conda install python=3.7" + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/anaconda/install.sh b/src/anaconda/install.sh index 468fd01d7..6f57a3144 100755 --- a/src/anaconda/install.sh +++ b/src/anaconda/install.sh @@ -8,14 +8,69 @@ # Maintainer: The VS Code and Codespaces Teams -VERSION=${VERSION:-"latest"} -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} -CONDA_DIR=${CONDA_DIR:-"/usr/local/conda"} +VERSION="${VERSION:-"latest"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +UPDATE_RC="${UPDATE_RC:-"true"}" +CONDA_DIR="${CONDA_DIR:-"/usr/local/conda"}" -set -eux +set -euo pipefail export DEBIAN_FRONTEND=noninteractive +# Detect package manager and set install command +detect_package_manager() { + if command -v apt-get > /dev/null; then + PKG_MANAGER="apt-get" + PKG_UPDATE="apt-get update -y" + PKG_INSTALL="apt-get -y install --no-install-recommends" + PKG_CLEAN="apt-get -y clean" + PKG_LISTS="/var/lib/apt/lists/*" + PKG_QUERY="dpkg -s" + elif command -v apk > /dev/null; then + PKG_MANAGER="apk" + PKG_UPDATE="apk update" + PKG_INSTALL="apk add --no-cache" + PKG_CLEAN="rm -rf /var/cache/apk/*" + PKG_LISTS="/var/cache/apk/*" + PKG_QUERY="apk info -e" + elif command -v dnf > /dev/null; then + PKG_MANAGER="dnf" + PKG_UPDATE="dnf -y makecache" + PKG_INSTALL="dnf -y install" + PKG_CLEAN="dnf clean all" + PKG_LISTS="/var/cache/dnf/*" + PKG_QUERY="rpm -q" + elif command -v microdnf > /dev/null; then + PKG_MANAGER="microdnf" + PKG_UPDATE="microdnf update" + PKG_INSTALL="microdnf install -y" + PKG_CLEAN="microdnf clean all" + PKG_LISTS="/var/cache/yum/*" + PKG_QUERY="rpm -q" + elif command -v tdnf > /dev/null; then + PKG_MANAGER="tdnf" + PKG_UPDATE="tdnf makecache" + PKG_INSTALL="tdnf install -y" + PKG_CLEAN="tdnf clean all" + PKG_LISTS="/var/cache/tdnf/*" + PKG_QUERY="rpm -q" + elif command -v yum > /dev/null; then + PKG_MANAGER="yum" + PKG_UPDATE="yum -y makecache" + PKG_INSTALL="yum -y install" + PKG_CLEAN="yum clean all" + PKG_LISTS="/var/cache/yum/*" + PKG_QUERY="rpm -q" + else + echo "No supported package manager found (apt-get, apk, dnf, microdnf, tdnf, yum)." + exit 1 + fi +} + +detect_package_manager + +# Clean up +eval "$PKG_CLEAN" + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -44,7 +99,12 @@ elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then fi architecture="$(uname -m)" -if [ "${architecture}" != "x86_64" ]; then +# Normalize arm64 to aarch64 for consistency +if [ "${architecture}" = "arm64" ]; then + architecture="aarch64" +fi + +if [ "${architecture}" != "x86_64" ] && [ "${architecture}" != "aarch64" ]; then echo "(!) Architecture $architecture unsupported" exit 1 fi @@ -63,9 +123,76 @@ updaterc() { # Checks if packages are installed and installs them if not check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt-get update -y - apt-get -y install --no-install-recommends "$@" + for pkg in "$@"; do + # Use PKG_QUERY variable to check if package is installed + if ! eval "$PKG_QUERY $pkg" > /dev/null 2>&1; then + # Package not installed, check if we need to update package lists + if [ "$PKG_MANAGER" = "apt-get" ]; then + # For apt-get, check if package lists are empty + if [ "$(find "$PKG_LISTS" | wc -l)" = "0" ]; then + echo "Running $PKG_UPDATE..." + eval "$PKG_UPDATE" + fi + else + # For other package managers, always update before installing + echo "Running $PKG_UPDATE..." + eval "$PKG_UPDATE" + fi + + # Install the package + echo "Installing package: $pkg" + eval "$PKG_INSTALL $pkg" + else + echo "Package $pkg is already installed" + fi + done +} + +sudo_if() { + COMMAND="$*" + if [ "$(id -u)" -eq 0 ] && [ "$USERNAME" != "root" ]; then + if command -v runuser > /dev/null; then + runuser -l "$USERNAME" -c "$COMMAND" + elif command -v su > /dev/null; then + su - "$USERNAME" -c "$COMMAND" + elif command -v sudo > /dev/null; then + sudo -u "$USERNAME" -i bash -c "$COMMAND" + else + # Fallback: execute as root (not ideal but works in containers) + echo "Warning: No user switching command available, running as root" + eval "$COMMAND" + fi + else + eval "$COMMAND" + fi +} + +run_as_user() { + local user="$1" + shift + local cmd="$*" + + if command -v runuser > /dev/null; then + if [ "$PKG_MANAGER" = "apk" ]; then + runuser "$user" -c "$cmd" + else + runuser -l "$user" -c "$cmd" + fi + elif command -v su > /dev/null; then + if [ "$PKG_MANAGER" = "apk" ]; then + su "$user" -c "$cmd" + else + su --login -c "$cmd" "$user" + fi + elif command -v sudo > /dev/null; then + if [ "$PKG_MANAGER" = "apk" ]; then + sudo -u "$user" sh -c "$cmd" + else + sudo -u "$user" -i bash -c "$cmd" + fi + else + echo "Warning: No user switching command available, running as root" + eval "$cmd" fi } @@ -77,29 +204,46 @@ if ! conda --version &> /dev/null ; then usermod -a -G conda "${USERNAME}" # Install dependencies - check_packages wget ca-certificates + if [ "$PKG_MANAGER" = "apt-get" ]; then + check_packages wget ca-certificates libgtk-3-0 + elif [ "$PKG_MANAGER" = "apk" ]; then + check_packages wget ca-certificates gtk+3.0 + else + check_packages wget ca-certificates gtk3 + fi mkdir -p $CONDA_DIR + chown -R "${USERNAME}:conda" "${CONDA_DIR}" - chmod -R g+r+w "${CONDA_DIR}" - - find "${CONDA_DIR}" -type d -print0 | xargs -n 1 -0 chmod g+s + chmod -R g+r+w "${CONDA_DIR}" + echo "Installing Anaconda..." CONDA_VERSION=$VERSION if [ "${VERSION}" = "latest" ] || [ "${VERSION}" = "lts" ]; then - CONDA_VERSION="2021.11" + CONDA_VERSION="2024.10-1" fi - su --login -c "wget -q https://repo.anaconda.com/archive/Anaconda3-${CONDA_VERSION}-Linux-x86_64.sh -O /tmp/anaconda-install.sh \ - && /bin/bash /tmp/anaconda-install.sh -u -b -p ${CONDA_DIR}" ${USERNAME} 2>&1 + if [ "${architecture}" = "x86_64" ]; then + run_as_user "${USERNAME}" "export http_proxy=${http_proxy:-} && export https_proxy=${https_proxy:-} \ + && wget -q https://repo.anaconda.com/archive/Anaconda3-${CONDA_VERSION}-Linux-x86_64.sh -O /tmp/anaconda-install.sh \ + && /bin/bash /tmp/anaconda-install.sh -u -b -p ${CONDA_DIR}" + elif [ "${architecture}" = "aarch64" ]; then + run_as_user "${USERNAME}" "export http_proxy=${http_proxy:-} && export https_proxy=${https_proxy:-} \ + && wget -q https://repo.anaconda.com/archive/Anaconda3-${CONDA_VERSION}-Linux-aarch64.sh -O /tmp/anaconda-install.sh \ + && /bin/bash /tmp/anaconda-install.sh -u -b -p ${CONDA_DIR}" + fi if [ "${VERSION}" = "latest" ] || [ "${VERSION}" = "lts" ]; then PATH=$PATH:${CONDA_DIR}/bin conda update -y conda fi - rm /tmp/anaconda-install.sh + chown -R "${USERNAME}:conda" "${CONDA_DIR}" + chmod -R g+r+w "${CONDA_DIR}" + + + rm /tmp/anaconda-install.sh updaterc "export CONDA_DIR=${CONDA_DIR}/bin" fi @@ -128,4 +272,6 @@ if [ -f "/etc/bash.bashrc" ]; then echo "${notice_script}" | tee -a /etc/bash.bashrc fi -echo "Done!" \ No newline at end of file +# Final clean up +eval "$PKG_CLEAN" +echo "Done!" diff --git a/src/aws-cli/NOTES.md b/src/aws-cli/NOTES.md new file mode 100644 index 000000000..b8a279717 --- /dev/null +++ b/src/aws-cli/NOTES.md @@ -0,0 +1,7 @@ +Available versions of the AWS CLI can be found here: https://github.com/aws/aws-cli/blob/v2/CHANGELOG.rst. + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/aws-cli/README.md b/src/aws-cli/README.md index e91bf092b..e45cb3f4c 100644 --- a/src/aws-cli/README.md +++ b/src/aws-cli/README.md @@ -7,9 +7,7 @@ Installs the AWS CLI along with needed dependencies. Useful for base Dockerfiles ```json "features": { - "ghcr.io/devcontainers/features/aws-cli:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/aws-cli:1": {} } ``` @@ -17,8 +15,21 @@ Installs the AWS CLI along with needed dependencies. Useful for base Dockerfiles | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| -| version | Select or enter an AWS CLI version. (Available versions here: https://github.com/aws/aws-cli/blob/v2/CHANGELOG.rst) | string | latest | +| version | Select or enter an AWS CLI version. | string | latest | +## Customizations + +### VS Code Extensions + +- `AmazonWebServices.aws-toolkit-vscode` + +Available versions of the AWS CLI can be found here: https://github.com/aws/aws-cli/blob/v2/CHANGELOG.rst. + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/aws-cli/devcontainer-feature.json b/src/aws-cli/devcontainer-feature.json index 11b0bdab0..75d24ca88 100644 --- a/src/aws-cli/devcontainer-feature.json +++ b/src/aws-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "aws-cli", - "version": "1.0.3", + "version": "1.1.4", "name": "AWS CLI", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/aws-cli", "description": "Installs the AWS CLI along with needed dependencies. Useful for base Dockerfiles that often are missing required install dependencies like gpg.", @@ -11,14 +11,29 @@ "latest" ], "default": "latest", - "description": "Select or enter an AWS CLI version. (Available versions here: https://github.com/aws/aws-cli/blob/v2/CHANGELOG.rst)" + "description": "Select or enter an AWS CLI version." + }, + "verbose": { + "type": "boolean", + "default": true, + "description": "Suppress verbose output." } }, "customizations": { "vscode": { "extensions": [ "AmazonWebServices.aws-toolkit-vscode" - ] + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the AWS CLI along with needed dependencies pre-installed and available on the `PATH`, along with the AWS Toolkit extensions for AWS development." + } + ] + } } - } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/aws-cli/install.sh b/src/aws-cli/install.sh index 4ab223421..1f6af54bd 100755 --- a/src/aws-cli/install.sh +++ b/src/aws-cli/install.sh @@ -9,7 +9,11 @@ set -e +# Clean up +rm -rf /var/lib/apt/lists/* + VERSION=${VERSION:-"latest"} +VERBOSE=${VERBOSE:-"true"} AWSCLI_GPG_KEY=FB5DB77FD5C118B80511ADA8A6310ACC4672475C AWSCLI_GPG_KEY_MATERIAL="-----BEGIN PGP PUBLIC KEY BLOCK----- @@ -47,25 +51,12 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} - apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not @@ -78,14 +69,11 @@ check_packages() { export DEBIAN_FRONTEND=noninteractive -check_packages curl ca-certificates gnupg2 dirmngr unzip +check_packages curl ca-certificates gpg dirmngr unzip bash-completion less verify_aws_cli_gpg_signature() { local filePath=$1 local sigFilePath=$2 - - get_common_setting AWSCLI_GPG_KEY - get_common_setting AWSCLI_GPG_KEY_MATERIAL true local awsGpgKeyring=aws-cli-public-key.gpg echo "${AWSCLI_GPG_KEY_MATERIAL}" | gpg --dearmor > "./${awsGpgKeyring}" @@ -123,9 +111,23 @@ install() { exit 1 fi - unzip "${scriptZipFile}" + if [ "${VERBOSE}" = "false" ]; then + unzip -q "${scriptZipFile}" + else + unzip "${scriptZipFile}" + fi + ./aws/install + # AWS bash completion + mkdir -p /etc/bash_completion.d + cp ./scripts/vendor/aws_bash_completer /etc/bash_completion.d/aws + + # AWS zsh completion + mkdir -p /usr/local/share/zsh/site-functions/ + cp ./scripts/vendor/aws_zsh_completer.sh /usr/local/share/zsh/site-functions/_aws + sed -i '1s/^/#compdef aws\n/' /usr/local/share/zsh/site-functions/_aws + rm -rf ./aws } @@ -133,4 +135,7 @@ echo "(*) Installing AWS CLI..." install +# Clean up +rm -rf /var/lib/apt/lists/* + echo "Done!" diff --git a/src/aws-cli/scripts/fetch-latest-completer-scripts.sh b/src/aws-cli/scripts/fetch-latest-completer-scripts.sh new file mode 100755 index 000000000..47dc6ee1d --- /dev/null +++ b/src/aws-cli/scripts/fetch-latest-completer-scripts.sh @@ -0,0 +1,20 @@ +#!/bin/bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/devcontainers/features/tree/main/src/aws-cli +# Maintainer: The Dev Container spec maintainers +# +# Run this script to replace aws_bash_completer and aws_zsh_completer.sh with the latest and greatest available version +# +COMPLETER_SCRIPTS=$(dirname "${BASH_SOURCE[0]}") +BASH_COMPLETER_SCRIPT="$COMPLETER_SCRIPTS/vendor/aws_bash_completer" +ZSH_COMPLETER_SCRIPT="$COMPLETER_SCRIPTS/vendor/aws_zsh_completer.sh" + +wget https://raw.githubusercontent.com/aws/aws-cli/v2/bin/aws_bash_completer -O "$BASH_COMPLETER_SCRIPT" +chmod +x "$BASH_COMPLETER_SCRIPT" + +wget https://raw.githubusercontent.com/aws/aws-cli/v2/bin/aws_zsh_completer.sh -O "$ZSH_COMPLETER_SCRIPT" +chmod +x "$ZSH_COMPLETER_SCRIPT" diff --git a/src/aws-cli/scripts/vendor/README.md b/src/aws-cli/scripts/vendor/README.md new file mode 100644 index 000000000..1f3838d46 --- /dev/null +++ b/src/aws-cli/scripts/vendor/README.md @@ -0,0 +1,12 @@ +### **IMPORTANT NOTE** + +Scripts in this directory are sourced externally and not maintained by the Dev Container spec maintainers. Do not make changes directly as they might be overwritten at any moment. + +## aws_bash_completer + +`aws_bash_completer` is a copy of . + +## aws_zsh_completer.sh + +`aws_zsh_completer.sh` is a copy of . + diff --git a/src/aws-cli/scripts/vendor/aws_bash_completer b/src/aws-cli/scripts/vendor/aws_bash_completer new file mode 100755 index 000000000..32cd1dd8f --- /dev/null +++ b/src/aws-cli/scripts/vendor/aws_bash_completer @@ -0,0 +1,6 @@ +# Typically that would be added under one of the following paths: +# - /etc/bash_completion.d +# - /usr/local/etc/bash_completion.d +# - /usr/share/bash-completion/completions + +complete -C aws_completer aws diff --git a/src/aws-cli/scripts/vendor/aws_zsh_completer.sh b/src/aws-cli/scripts/vendor/aws_zsh_completer.sh new file mode 100755 index 000000000..c1b2c1244 --- /dev/null +++ b/src/aws-cli/scripts/vendor/aws_zsh_completer.sh @@ -0,0 +1,60 @@ +# Source this file to activate auto completion for zsh using the bash +# compatibility helper. Make sure to run `compinit` before, which should be +# given usually. +# +# % source /path/to/zsh_complete.sh +# +# Typically that would be called somewhere in your .zshrc. +# +# Note, the overwrite of _bash_complete() is to export COMP_LINE and COMP_POINT +# That is only required for zsh <= edab1d3dbe61da7efe5f1ac0e40444b2ec9b9570 +# +# https://github.com/zsh-users/zsh/commit/edab1d3dbe61da7efe5f1ac0e40444b2ec9b9570 +# +# zsh releases prior to that version do not export the required env variables! + +autoload -Uz bashcompinit +bashcompinit -i + +_bash_complete() { + local ret=1 + local -a suf matches + local -x COMP_POINT COMP_CWORD + local -a COMP_WORDS COMPREPLY BASH_VERSINFO + local -x COMP_LINE="$words" + local -A savejobstates savejobtexts + + (( COMP_POINT = 1 + ${#${(j. .)words[1,CURRENT]}} + $#QIPREFIX + $#IPREFIX + $#PREFIX )) + (( COMP_CWORD = CURRENT - 1)) + COMP_WORDS=( $words ) + BASH_VERSINFO=( 2 05b 0 1 release ) + + savejobstates=( ${(kv)jobstates} ) + savejobtexts=( ${(kv)jobtexts} ) + + [[ ${argv[${argv[(I)nospace]:-0}-1]} = -o ]] && suf=( -S '' ) + + matches=( ${(f)"$(compgen $@ -- ${words[CURRENT]})"} ) + + if [[ -n $matches ]]; then + if [[ ${argv[${argv[(I)filenames]:-0}-1]} = -o ]]; then + compset -P '*/' && matches=( ${matches##*/} ) + compset -S '/*' && matches=( ${matches%%/*} ) + compadd -Q -f "${suf[@]}" -a matches && ret=0 + else + compadd -Q "${suf[@]}" -a matches && ret=0 + fi + fi + + if (( ret )); then + if [[ ${argv[${argv[(I)default]:-0}-1]} = -o ]]; then + _default "${suf[@]}" && ret=0 + elif [[ ${argv[${argv[(I)dirnames]:-0}-1]} = -o ]]; then + _directories "${suf[@]}" && ret=0 + fi + fi + + return ret +} + +complete -C aws_completer aws diff --git a/src/azure-cli/NOTES.md b/src/azure-cli/NOTES.md new file mode 100644 index 000000000..19fe92f31 --- /dev/null +++ b/src/azure-cli/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/azure-cli/README.md b/src/azure-cli/README.md index 784da7bad..217019c51 100644 --- a/src/azure-cli/README.md +++ b/src/azure-cli/README.md @@ -7,9 +7,7 @@ Installs the Azure CLI along with needed dependencies. Useful for base Dockerfil ```json "features": { - "ghcr.io/devcontainers/features/azure-cli:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/azure-cli:1": {} } ``` @@ -18,7 +16,24 @@ Installs the Azure CLI along with needed dependencies. Useful for base Dockerfil | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select or enter an Azure CLI version. (Available versions may vary by Linux distribution.) | string | latest | +| extensions | Optional comma separated list of Azure CLI extensions to install in profile. | string | - | +| installBicep | Optionally install Azure Bicep | boolean | false | +| bicepVersion | Select or enter a Bicep version. ('latest' or a specic version such as 'v0.31.92') | string | latest | +| installUsingPython | Install Azure CLI using Python instead of pipx | boolean | false | +## Customizations + +### VS Code Extensions + +- `ms-vscode.azurecli` + + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/azure-cli/devcontainer-feature.json b/src/azure-cli/devcontainer-feature.json index b3e6288f6..7280ad2ad 100644 --- a/src/azure-cli/devcontainer-feature.json +++ b/src/azure-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "azure-cli", - "version": "1.0.3", + "version": "1.3.0", "name": "Azure CLI", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/azure-cli", "description": "Installs the Azure CLI along with needed dependencies. Useful for base Dockerfiles that often are missing required install dependencies like gpg.", @@ -12,13 +12,46 @@ ], "default": "latest", "description": "Select or enter an Azure CLI version. (Available versions may vary by Linux distribution.)" + }, + "extensions": { + "type": "string", + "default": "", + "description": "Optional comma separated list of Azure CLI extensions to install in profile." + }, + "installBicep": { + "type": "boolean", + "description": "Optionally install Azure Bicep", + "default": false + }, + "bicepVersion": { + "type": "string", + "proposals": [ + "latest" + ], + "default": "latest", + "description": "Select or enter a Bicep version. ('latest' or a specic version such as 'v0.31.92')" + }, + "installUsingPython": { + "type": "boolean", + "description": "Install Azure CLI using Python instead of pipx", + "default": false } }, "customizations": { - "vscode": { - "extensions": [ - "ms-vscode.azurecli" - ] + "vscode": { + "extensions": [ + "ms-vscode.azurecli" + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the Azure CLI along with needed dependencies pre-installed and available on the `PATH`, along with the Azure CLI extension for Azure development." + } + ] } - } -} + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} \ No newline at end of file diff --git a/src/azure-cli/install.sh b/src/azure-cli/install.sh index 6518f6c3e..425deafe9 100755 --- a/src/azure-cli/install.sh +++ b/src/azure-cli/install.sh @@ -9,31 +9,29 @@ set -e -AZ_VERSION=${VERSION:-"latest"} +# Clean up +rm -rf /var/lib/apt/lists/* +AZ_VERSION=${VERSION:-"latest"} +AZ_EXTENSIONS=${EXTENSIONS} +AZ_INSTALLBICEP=${INSTALLBICEP:-false} +AZ_BICEPVERSION=${BICEPVERSION:-latest} +INSTALL_USING_PYTHON=${INSTALLUSINGPYTHON:-false} MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" -AZCLI_ARCHIVE_ARCHITECTURES="amd64" -AZCLI_ARCHIVE_VERSION_CODENAMES="stretch buster bullseye bionic focal jammy" +AZCLI_ARCHIVE_ARCHITECTURES="amd64 arm64" +AZCLI_ARCHIVE_VERSION_CODENAMES="stretch bookworm buster bullseye bionic focal jammy noble trixie resolute" if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 fi -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} +if [ -z "${_REMOTE_USER}" ]; then + echo -e 'Feature script must be executed by a tool that implements the dev container specification. See https://containers.dev/ for more information.' + exit 1 +fi + +echo "Effective REMOTE_USER: ${_REMOTE_USER}" apt_get_update() { @@ -44,13 +42,14 @@ apt_get_update() # Checks if packages are installed and installs them if not check_packages() { if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi apt-get -y install --no-install-recommends "$@" fi } -export DEBIAN_FRONTEND=noninteractive - # Soft version matching that resolves a version for a given package in the *current apt-cache* # Return value is stored in first argument (the unprocessed version) apt_cache_version_soft_match() { @@ -97,10 +96,17 @@ install_using_apt() { # Install dependencies check_packages apt-transport-https curl ca-certificates gnupg2 dirmngr # Import key safely (new 'signed-by' method rather than deprecated apt-key approach) and install - get_common_setting MICROSOFT_GPG_KEYS_URI curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg echo "deb [arch=${architecture} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/azure-cli/ ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/azure-cli.list - apt-get update + + # This is a workaround for the fact that Azure CLI is not yet a supported package for debian trixie. Once Azure CLI is supported we should revert to avoid script failure for non Azure CLI packages + if ! (apt-get update); then + echo "(!) Failed to update apt cache, removing repository file" + rm -f /etc/apt/sources.list.d/azure-cli.list + else + echo "WARNING: apt-get update succeeded. The workaround for broken Azure CLI install on Debian Trixie may no longer be needed." + echo "TODO: Consider reverting to a simple `apt-get update` if the Azure CLI repo works reliably for debian trixie" + fi if [ "${AZ_VERSION}" = "latest" ] || [ "${AZ_VERSION}" = "lts" ] || [ "${AZ_VERSION}" = "stable" ]; then # Empty, meaning grab the "latest" in the apt repo @@ -119,7 +125,43 @@ install_using_apt() { fi } -install_using_pip() { +install_using_pip_strategy() { + local ver="" + if [ "${AZ_VERSION}" = "latest" ] || [ "${AZ_VERSION}" = "lts" ] || [ "${AZ_VERSION}" = "stable" ]; then + # Empty, meaning grab the "latest" in the apt repo + ver="" + else + ver="==${AZ_VERSION}" + fi + + if [ "${INSTALL_USING_PYTHON}" = "true" ]; then + install_with_complete_python_installation "${ver}" || install_with_pipx "${ver}" || return 1 + else + install_with_pipx "${ver}" || install_with_complete_python_installation "${ver}" || return 1 + fi +} + +install_with_pipx() { + echo "(*) Attempting to install globally with pipx..." + local ver="$1" + export + local + + if ! type pipx > /dev/null 2>&1; then + echo "(*) Installing pipx..." + check_packages pipx + pipx ensurepath # Ensures PIPX_BIN_DIR is on the PATH + fi + + PIPX_HOME="/usr/local/pipx" \ + PIPX_BIN_DIR=/usr/local/bin \ + pipx install azure-cli${ver} + + echo "(*) Finished installing globally with pipx." +} + +install_with_complete_python_installation() { + local ver="$1" echo "(*) No pre-built binaries available in apt-cache. Installing via pip3." if ! dpkg -s python3-minimal python3-pip libffi-dev python3-venv > /dev/null 2>&1; then apt_get_update @@ -136,39 +178,39 @@ install_using_pip() { pipx_bin=/tmp/pip-tmp/bin/pipx fi - if [ "${AZ_VERSION}" = "latest" ] || [ "${AZ_VERSION}" = "lts" ] || [ "${AZ_VERSION}" = "stable" ]; then - # Empty, meaning grab the "latest" in the apt repo - ver="" - else - ver="==${AZ_VERSION}" - fi - set +e ${pipx_bin} install --pip-args '--no-cache-dir --force-reinstall' -f azure-cli${ver} # Fail gracefully if [ "$?" != 0 ]; then - echo "Could not install azure-cli${ver} via pip" + echo "Could not install azure-cli${ver} via pip3" rm -rf /tmp/pip-tmp return 1 fi set -e } -# See if we're on x86_64 and if so, install via apt-get, otherwise use pip3 +export DEBIAN_FRONTEND=noninteractive + +# See if we're on x86_64 or AARCH64 and if so, install via apt-get, otherwise use pip3 echo "(*) Installing Azure CLI..." . /etc/os-release architecture="$(dpkg --print-architecture)" CACHED_AZURE_VERSION="${AZ_VERSION}" # In case we need to fallback to pip and the apt path has modified the AZ_VERSION variable. -if [[ "${AZCLI_ARCHIVE_ARCHITECTURES}" = *"${architecture}"* ]] && [[ "${AZCLI_ARCHIVE_VERSION_CODENAMES}" = *"${VERSION_CODENAME}"* ]]; then - install_using_apt || use_pip="true" +if [ "${INSTALL_USING_PYTHON}" != "true" ]; then + if [[ "${AZCLI_ARCHIVE_ARCHITECTURES}" = *"${architecture}"* ]] && [[ "${AZCLI_ARCHIVE_VERSION_CODENAMES}" = *"${VERSION_CODENAME}"* ]]; then + install_using_apt || use_pip="true" + else + echo "(*) Codename '${VERSION_CODENAME}' or architecture '${architecture}' not in apt archive list, falling back to pip installation." + use_pip="true" + fi else use_pip="true" fi -if [ "${use_pip}" = "true" ]; then +if [ "${use_pip}" = "true" ]; then AZ_VERSION=${CACHED_AZURE_VERSION} - install_using_pip + install_using_pip_strategy if [ "$?" != 0 ]; then echo "Please provide a valid version for your distribution ${ID} ${VERSION_CODENAME} (${architecture})." @@ -179,4 +221,48 @@ if [ "${use_pip}" = "true" ]; then fi fi -echo "Done!" \ No newline at end of file +# If Azure CLI extensions are requested, loop through and install +if [ ${#AZ_EXTENSIONS[@]} -gt 0 ]; then + echo "Installing Azure CLI extensions: ${AZ_EXTENSIONS}" + extensions=(`echo ${AZ_EXTENSIONS} | tr ',' ' '`) + for i in "${extensions[@]}" + do + echo "Installing ${i}" + su ${_REMOTE_USER} -c "az extension add --name ${i} -y" || continue + done +fi + +if [ "${AZ_INSTALLBICEP}" = "true" ]; then + # Install dependencies + check_packages apt-transport-https curl + + # Properly install Azure Bicep based on current architecture + # The `az bicep install` command installs the linux-x64 binary even on arm64 devcontainers + # The `az bicep install --target-platform` could be a solution; however, linux-arm64 is not an allowed value for this argument yet + # Manually installing Bicep and moving to the appropriate directory where az expects it to be + + if [ "${AZ_BICEPVERSION}" = "latest" ]; then + bicep_download_path="https://github.com/Azure/bicep/releases/latest/download" + else + bicep_download_path="https://github.com/Azure/bicep/releases/download/${AZ_BICEPVERSION}" + fi + + if [ "${architecture}" = "arm64" ]; then + curl -Lo bicep ${bicep_download_path}/bicep-linux-arm64 + else + curl -Lo bicep ${bicep_download_path}/bicep-linux-x64 + fi + + chmod +x ./bicep + mv ./bicep /usr/local/bin/bicep + + # Add a symlink so bicep can be accessed as a standalone executable or as part of az + mkdir -p ${_REMOTE_USER_HOME}/.azure/bin + chown -hR ${_REMOTE_USER}:${_REMOTE_USER} ${_REMOTE_USER_HOME}/.azure + ln -s /usr/local/bin/bicep ${_REMOTE_USER_HOME}/.azure/bin/bicep +fi + +# Clean up +rm -rf /var/lib/apt/lists/* + +echo "Done!" diff --git a/src/common-utils/NOTES.md b/src/common-utils/NOTES.md index 2c0724589..9faa2eedd 100644 --- a/src/common-utils/NOTES.md +++ b/src/common-utils/NOTES.md @@ -1,12 +1,33 @@ -## Speeding up the command prompt in large repositories +## OS Support -This script provides a custom command prompt that includes information about the git repository for the current folder. However, with certain large repositories, this can result in a slow command prompt since the required git status command can be slow. To resolve this, you can update a git setting to remove the git portion of the command prompt. +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, RockyLinux, and Alpine Linux. -To disable the prompt for the current folder's repository, enter the following in a terminal or add it to your `postCreateCommand` or dotfiles: +## Included utilities + +In addition to the common CLI tools (curl, wget, git, jq, nano, vim, etc.), this Feature installs: + +- **bubblewrap** (`bwrap`) — a lightweight sandboxing tool used as a dependency by some desktop and container tooling. +- **socat** — a multipurpose relay for bidirectional data transfer between two independent data channels (e.g., sockets, files, pipes). + +## Using with dev container images + +This Feature is used in many of the [dev container images](https://github.com/search?q=repo%3Adevcontainers%2Fimages+%22ghcr.io%2Fdevcontainers%2Ffeatures%2Fcommon-utils%22&type=code), as a result +these images have already allocated UID & GID 1000. Attempting to add this Feature with UID 1000 and/or GID 1000 on top of such a dev container image will result in an error when building the dev container. + +## Customizing the command prompt + +By default, this script provides a custom command prompt that includes information about the git repository for the current folder. However, with certain large repositories, this can result in a slow command prompt due to the performance of needed git operations. + +For performance reasons, a "dirty" indicator that tells you whether or not there are uncommitted changes is disabled by default. You can opt to turn this on for smaller repositories by entering the following in a terminal or adding it to your `postCreateCommand`: ```bash -git config codespaces-theme.hide-status 1 +git config devcontainers-theme.show-dirty 1 ``` -This setting will survive a rebuild since it is applied to the repository rather than the container. +To completely disable the git portion of the prompt for the current folder's repository, you can use this configuration setting instead: + +```bash +git config devcontainers-theme.hide-status 1 +``` +For `zsh`, the default theme is a [standard Oh My Zsh! theme](https://ohmyz.sh/). You may pick a different one by modifying the `ZSH_THEME` variable in `~/.zshrc`. diff --git a/src/common-utils/README.md b/src/common-utils/README.md index e98b9e4d7..3d21e8b3f 100644 --- a/src/common-utils/README.md +++ b/src/common-utils/README.md @@ -1,5 +1,5 @@ -# Common Debian Utilities (common-utils) +# Common Utilities (common-utils) Installs a set of common command line utilities, Oh My Zsh!, and sets up a non-root user. @@ -7,9 +7,7 @@ Installs a set of common command line utilities, Oh My Zsh!, and sets up a non-r ```json "features": { - "ghcr.io/devcontainers/features/common-utils:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/common-utils:2": {} } ``` @@ -18,25 +16,41 @@ Installs a set of common command line utilities, Oh My Zsh!, and sets up a non-r | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | installZsh | Install ZSH? | boolean | true | +| configureZshAsDefaultShell | Change default shell to ZSH? | boolean | false | | installOhMyZsh | Install Oh My Zsh!? | boolean | true | +| installOhMyZshConfig | Allow installing the default dev container .zshrc templates? | boolean | true | | upgradePackages | Upgrade OS packages? | boolean | true | -| username | Enter name of non-root user to configure or none to skip | string | automatic | -| uid | Enter uid for non-root user | string | automatic | -| gid | Enter gid for non-root user | string | automatic | -| nonFreePackages | Add packages from non-free Debian repository? | boolean | - | +| username | Enter name of a non-root user to configure or none to skip | string | automatic | +| userUid | Enter UID for non-root user | string | automatic | +| userGid | Enter GID for non-root user | string | automatic | +| nonFreePackages | Add packages from non-free Debian repository? (Debian only) | boolean | false | -## Speeding up the command prompt in large repositories +## OS Support -This script provides a custom command prompt that includes information about the git repository for the current folder. However, with certain large repositories, this can result in a slow command prompt since the required git status command can be slow. To resolve this, you can update a git setting to remove the git portion of the command prompt. +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, RockyLinux, and Alpine Linux. -To disable the prompt for the current folder's repository, enter the following in a terminal or add it to your `postCreateCommand` or dotfiles: +## Using with dev container images + +This Feature is used in many of the [dev container images](https://github.com/search?q=repo%3Adevcontainers%2Fimages+%22ghcr.io%2Fdevcontainers%2Ffeatures%2Fcommon-utils%22&type=code), as a result +these images have already allocated UID & GID 1000. Attempting to add this Feature with UID 1000 and/or GID 1000 on top of such a dev container image will result in an error when building the dev container. + +## Customizing the command prompt + +By default, this script provides a custom command prompt that includes information about the git repository for the current folder. However, with certain large repositories, this can result in a slow command prompt due to the performance of needed git operations. + +For performance reasons, a "dirty" indicator that tells you whether or not there are uncommitted changes is disabled by default. You can opt to turn this on for smaller repositories by entering the following in a terminal or adding it to your `postStartCommand`: ```bash -git config codespaces-theme.hide-status 1 +git config devcontainers-theme.show-dirty 1 ``` -This setting will survive a rebuild since it is applied to the repository rather than the container. +To completely disable the git portion of the prompt for the current folder's repository, you can use this configuration setting instead: + +```bash +git config devcontainers-theme.hide-status 1 +``` +For `zsh`, the default theme is a [standard Oh My Zsh! theme](https://ohmyz.sh/). You may pick a different one by modifying the `ZSH_THEME` variable in `~/.zshrc`. --- diff --git a/src/common-utils/bin/code b/src/common-utils/bin/code new file mode 100755 index 000000000..b0d517f84 --- /dev/null +++ b/src/common-utils/bin/code @@ -0,0 +1,16 @@ +#!/bin/sh + +get_in_path_except_current() { + which -a "$1" | grep -A1 "$0" | grep -v "$0" +} + +code="$(get_in_path_except_current code)" + +if [ -n "$code" ]; then + exec "$code" "$@" +elif [ "$(command -v code-insiders)" ]; then + exec code-insiders "$@" +else + echo "code or code-insiders is not installed" >&2 + exit 127 +fi diff --git a/src/common-utils/bin/devcontainer-info b/src/common-utils/bin/devcontainer-info new file mode 100755 index 000000000..abbb682eb --- /dev/null +++ b/src/common-utils/bin/devcontainer-info @@ -0,0 +1,35 @@ +#!/bin/sh + +# Load meta.env +if [ -f "/usr/local/etc/vscode-dev-containers/meta.env" ]; then + . /usr/local/etc/vscode-dev-containers/meta.env +fi +if [ -f "/usr/local/etc/dev-containers/meta.env" ]; then + . /usr/local/etc/dev-containers/meta.env +fi + +# Minimal output +if [ "$1" = "version" ] || [ "$1" = "image-version" ]; then + echo "${VERSION}" + exit 0 +elif [ "$1" = "release" ]; then + echo "${GIT_REPOSITORY_RELEASE}" + exit 0 +elif [ "$1" = "content" ] || [ "$1" = "content-url" ] || [ "$1" = "contents" ] || [ "$1" = "contents-url" ]; then + echo "${CONTENTS_URL}" + exit 0 +fi + +#Full output +echo +echo "Development container image information" +echo +if [ ! -z "${VERSION}" ]; then echo "- Image version: ${VERSION}"; fi +if [ ! -z "${DEFINITION_ID}" ]; then echo "- Definition ID: ${DEFINITION_ID}"; fi +if [ ! -z "${VARIANT}" ]; then echo "- Variant: ${VARIANT}"; fi +if [ ! -z "${GIT_REPOSITORY}" ]; then echo "- Source code repository: ${GIT_REPOSITORY}"; fi +if [ ! -z "${GIT_REPOSITORY_RELEASE}" ]; then echo "- Source code release/branch: ${GIT_REPOSITORY_RELEASE}"; fi +if [ ! -z "${GIT_REPOSITORY_REVISION}" ]; then echo "- Source code revision: ${GIT_REPOSITORY_REVISION}"; fi +if [ ! -z "${BUILD_TIMESTAMP}" ]; then echo "- Timestamp: ${BUILD_TIMESTAMP}"; fi +if [ ! -z "${CONTENTS_URL}" ]; then echo && echo "More info: ${CONTENTS_URL}"; fi +echo diff --git a/src/common-utils/bin/systemctl b/src/common-utils/bin/systemctl new file mode 100755 index 000000000..4ead9853d --- /dev/null +++ b/src/common-utils/bin/systemctl @@ -0,0 +1,7 @@ +#!/bin/sh +set -e +if [ -d "/run/systemd/system" ]; then + exec /bin/systemctl "$@" +else + echo '\n"systemd" is not running in this container due to its overhead.\nUse the "service" command to start services instead. e.g.: \n\nservice --status-all' +fi diff --git a/src/common-utils/devcontainer-feature.json b/src/common-utils/devcontainer-feature.json index e91613534..5b56f6184 100644 --- a/src/common-utils/devcontainer-feature.json +++ b/src/common-utils/devcontainer-feature.json @@ -1,7 +1,7 @@ { "id": "common-utils", - "version": "1.1.2", - "name": "Common Debian Utilities", + "version": "2.5.9", + "name": "Common Utilities", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/common-utils", "description": "Installs a set of common command line utilities, Oh My Zsh!, and sets up a non-root user.", "options": { @@ -10,11 +10,21 @@ "default": true, "description": "Install ZSH?" }, + "configureZshAsDefaultShell": { + "type": "boolean", + "default": false, + "description": "Change default shell to ZSH?" + }, "installOhMyZsh": { "type": "boolean", "default": true, "description": "Install Oh My Zsh!?" }, + "installOhMyZshConfig": { + "type": "boolean", + "default": true, + "description": "Allow installing the default dev container .zshrc templates?" + }, "upgradePackages": { "type": "boolean", "default": true, @@ -23,36 +33,42 @@ "username": { "type": "string", "proposals": [ + "devcontainer", "vscode", "codespace", "none", "automatic" ], "default": "automatic", - "description": "Enter name of non-root user to configure or none to skip" + "description": "Enter name of a non-root user to configure or none to skip" }, - "uid": { + "userUid": { "type": "string", "proposals": [ - "1000", + "1001", "automatic" ], "default": "automatic", - "description": "Enter uid for non-root user" + "description": "Enter UID for non-root user" }, - "gid": { + "userGid": { "type": "string", "proposals": [ - "1000", + "1001", "automatic" ], "default": "automatic", - "description": "Enter gid for non-root user" + "description": "Enter GID for non-root user" }, "nonFreePackages": { "type": "boolean", "default": false, - "description": "Add packages from non-free Debian repository?" + "description": "Add packages from non-free Debian repository? (Debian only)" + }, + "installSsl": { + "type": "boolean", + "default": true, + "description": "Install SSL?" } } } diff --git a/src/common-utils/install.sh b/src/common-utils/install.sh index 11702008d..313271d0a 100755 --- a/src/common-utils/install.sh +++ b/src/common-utils/install.sh @@ -1,21 +1,24 @@ -#!/usr/bin/env bash -#------------------------------------------------------------------------------------------------------------- +#!/bin/sh +#------------------------------------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. -#------------------------------------------------------------------------------------------------------------- +# Licensed under the MIT License. See https://github.com/devcontainers/features/blob/main/LICENSE for license information. +#------------------------------------------------------------------------------------------------------------------------- # -# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/common.md -# Maintainer: The VS Code and Codespaces Teams +# Docs: https://github.com/devcontainers/features/tree/main/src/common-utils +# Maintainer: The Dev Container spec maintainers set -e -INSTALL_ZSH=${INSTALLZSH:-"true"} -INSTALL_OH_MY_ZSH=${INSTALLOHMYZSH:-"true"} -UPGRADE_PACKAGES=${UPGRADEPACKAGES:-"true"} -USERNAME=${USERNAME:-"automatic"} -USER_UID=${UID:-"automatic"} -USER_GID=${GID:-"automatic"} -ADD_NON_FREE_PACKAGES=${NONFREEPACKAGES:-"false"} +INSTALL_ZSH="${INSTALLZSH:-"true"}" +CONFIGURE_ZSH_AS_DEFAULT_SHELL="${CONFIGUREZSHASDEFAULTSHELL:-"false"}" +INSTALL_OH_MY_ZSH="${INSTALLOHMYZSH:-"true"}" +INSTALL_OH_MY_ZSH_CONFIG="${INSTALLOHMYZSHCONFIG:-"true"}" +UPGRADE_PACKAGES="${UPGRADEPACKAGES:-"true"}" +USERNAME="${USERNAME:-"automatic"}" +USER_UID="${UID:-"automatic"}" +USER_GID="${GID:-"automatic"}" +ADD_NON_FREE_PACKAGES="${NONFREEPACKAGES:-"false"}" +INSTALL_SSL="${INSTALLSSL:-"true"}" MARKER_FILE="/usr/local/etc/vscode-dev-containers/common" @@ -24,427 +27,13 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -# Ensure that login shells get the correct path if the user updated the PATH using ENV. -rm -f /etc/profile.d/00-restore-env.sh -echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh -chmod +x /etc/profile.d/00-restore-env.sh - -# If in automatic mode, determine if a user already exists, if not use vscode -if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u ${CURRENT_USER} > /dev/null 2>&1; then - USERNAME=${CURRENT_USER} - break - fi - done - if [ "${USERNAME}" = "" ]; then - USERNAME=vscode - fi -elif [ "${USERNAME}" = "none" ]; then - USERNAME=root - USER_UID=0 - USER_GID=0 -fi - -# Load markers to see which steps have already run -if [ -f "${MARKER_FILE}" ]; then - echo "Marker file found:" - cat "${MARKER_FILE}" - source "${MARKER_FILE}" -fi - -# Ensure apt is in non-interactive to avoid prompts -export DEBIAN_FRONTEND=noninteractive - -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y -} - -# Run install apt-utils to avoid debconf warning then verify presence of other common developer tools and dependencies -if [ "${PACKAGES_ALREADY_INSTALLED}" != "true" ]; then - - package_list="apt-utils \ - openssh-client \ - gnupg2 \ - dirmngr \ - iproute2 \ - procps \ - lsof \ - htop \ - net-tools \ - psmisc \ - curl \ - tree \ - wget \ - rsync \ - ca-certificates \ - unzip \ - bzip2 \ - zip \ - nano \ - vim-tiny \ - less \ - jq \ - lsb-release \ - apt-transport-https \ - dialog \ - libc6 \ - libgcc1 \ - libkrb5-3 \ - libgssapi-krb5-2 \ - libicu[0-9][0-9] \ - liblttng-ust[0-9] \ - libstdc++6 \ - zlib1g \ - locales \ - sudo \ - ncdu \ - man-db \ - strace \ - manpages \ - manpages-dev \ - init-system-helpers" - - # Needed for adding manpages-posix and manpages-posix-dev which are non-free packages in Debian - if [ "${ADD_NON_FREE_PACKAGES}" = "true" ]; then - # Bring in variables from /etc/os-release like VERSION_CODENAME - . /etc/os-release - sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list - sed -i -E "s/deb-src http:\/\/(deb|httredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list - sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list - sed -i -E "s/deb-src http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list - sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list - sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list - sed -i "s/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list - sed -i "s/deb-src http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list - # Handle bullseye location for security https://www.debian.org/releases/bullseye/amd64/release-notes/ch-information.en.html - sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main contrib non-free/" /etc/apt/sources.list - sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main contrib non-free/" /etc/apt/sources.list - echo "Running apt-get update..." - apt-get update - package_list="${package_list} manpages-posix manpages-posix-dev" - else - apt_get_update - fi - - # Install libssl1.1 if available - if [[ ! -z $(apt-cache --names-only search ^libssl1.1$) ]]; then - package_list="${package_list} libssl1.1" - fi - - # Install appropriate version of libssl1.0.x if available - libssl_package=$(dpkg-query -f '${db:Status-Abbrev}\t${binary:Package}\n' -W 'libssl1\.0\.?' 2>&1 || echo '') - if [ "$(echo "$LIlibssl_packageBSSL" | grep -o 'libssl1\.0\.[0-9]:' | uniq | sort | wc -l)" -eq 0 ]; then - if [[ ! -z $(apt-cache --names-only search ^libssl1.0.2$) ]]; then - # Debian 9 - package_list="${package_list} libssl1.0.2" - elif [[ ! -z $(apt-cache --names-only search ^libssl1.0.0$) ]]; then - # Ubuntu 18.04, 16.04, earlier - package_list="${package_list} libssl1.0.0" - fi - fi - - echo "Packages to verify are installed: ${package_list}" - apt-get -y install --no-install-recommends ${package_list} 2> >( grep -v 'debconf: delaying package configuration, since apt-utils is not installed' >&2 ) - - # Install git if not already installed (may be more recent than distro version) - if ! type git > /dev/null 2>&1; then - apt-get -y install --no-install-recommends git - fi - - PACKAGES_ALREADY_INSTALLED="true" -fi - -# Get to latest versions of all packages -if [ "${UPGRADE_PACKAGES}" = "true" ]; then - apt_get_update - apt-get -y upgrade --no-install-recommends - apt-get autoremove -y -fi - -# Ensure at least the en_US.UTF-8 UTF-8 locale is available. -# Common need for both applications and things like the agnoster ZSH theme. -if [ "${LOCALE_ALREADY_SET}" != "true" ] && ! grep -o -E '^\s*en_US.UTF-8\s+UTF-8' /etc/locale.gen > /dev/null; then - echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen - locale-gen - LOCALE_ALREADY_SET="true" +# If we're using Alpine, install bash before executing +. /etc/os-release +if [ "${ID}" = "alpine" ]; then + apk add --no-cache bash fi - -# Create or update a non-root user to match UID/GID. -group_name="${USERNAME}" -if id -u ${USERNAME} > /dev/null 2>&1; then - # User exists, update if needed - if [ "${USER_GID}" != "automatic" ] && [ "$USER_GID" != "$(id -g $USERNAME)" ]; then - group_name="$(id -gn $USERNAME)" - groupmod --gid $USER_GID ${group_name} - usermod --gid $USER_GID $USERNAME - fi - if [ "${USER_UID}" != "automatic" ] && [ "$USER_UID" != "$(id -u $USERNAME)" ]; then - usermod --uid $USER_UID $USERNAME - fi -else - # Create user - if [ "${USER_GID}" = "automatic" ]; then - groupadd $USERNAME - else - groupadd --gid $USER_GID $USERNAME - fi - if [ "${USER_UID}" = "automatic" ]; then - useradd -s /bin/bash --gid $USERNAME -m $USERNAME - else - useradd -s /bin/bash --uid $USER_UID --gid $USERNAME -m $USERNAME - fi -fi - -# Add add sudo support for non-root user -if [ "${USERNAME}" != "root" ] && [ "${EXISTING_NON_ROOT_USER}" != "${USERNAME}" ]; then - echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME - chmod 0440 /etc/sudoers.d/$USERNAME - EXISTING_NON_ROOT_USER="${USERNAME}" -fi - -# ** Shell customization section ** -if [ "${USERNAME}" = "root" ]; then - user_rc_path="/root" -else - user_rc_path="/home/${USERNAME}" -fi - -# Restore user .bashrc defaults from skeleton file if it doesn't exist or is empty -if [ ! -f "${user_rc_path}/.bashrc" ] || [ ! -s "${user_rc_path}/.bashrc" ] ; then - cp /etc/skel/.bashrc "${user_rc_path}/.bashrc" -fi - -# Restore user .profile defaults from skeleton file if it doesn't exist or is empty -if [ ! -f "${user_rc_path}/.profile" ] || [ ! -s "${user_rc_path}/.profile" ] ; then - cp /etc/skel/.profile "${user_rc_path}/.profile" -fi - -# .bashrc/.zshrc snippet -rc_snippet="$(cat << 'EOF' - -if [ -z "${USER}" ]; then export USER=$(whoami); fi -if [[ "${PATH}" != *"$HOME/.local/bin"* ]]; then export PATH="${PATH}:$HOME/.local/bin"; fi - -# Display optional first run image specific notice if configured and terminal is interactive -if [ -t 1 ] && [[ "${TERM_PROGRAM}" = "vscode" || "${TERM_PROGRAM}" = "codespaces" ]] && [ ! -f "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed" ]; then - if [ -f "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" ]; then - cat "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" - elif [ -f "/workspaces/.codespaces/shared/first-run-notice.txt" ]; then - cat "/workspaces/.codespaces/shared/first-run-notice.txt" - fi - mkdir -p "$HOME/.config/vscode-dev-containers" - # Mark first run notice as displayed after 10s to avoid problems with fast terminal refreshes hiding it - ((sleep 10s; touch "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed") &) -fi - -# Set the default git editor if not already set -if [ -z "$(git config --get core.editor)" ] && [ -z "${GIT_EDITOR}" ]; then - if [ "${TERM_PROGRAM}" = "vscode" ]; then - if [[ -n $(command -v code-insiders) && -z $(command -v code) ]]; then - export GIT_EDITOR="code-insiders --wait" - else - export GIT_EDITOR="code --wait" - fi - fi +if [ "${ID}" = "azurelinux" ]; then + tdnf install -y curl git fi - -EOF -)" - -# code shim, it fallbacks to code-insiders if code is not available -cat << 'EOF' > /usr/local/bin/code -#!/bin/sh - -get_in_path_except_current() { - which -a "$1" | grep -A1 "$0" | grep -v "$0" -} - -code="$(get_in_path_except_current code)" - -if [ -n "$code" ]; then - exec "$code" "$@" -elif [ "$(command -v code-insiders)" ]; then - exec code-insiders "$@" -else - echo "code or code-insiders is not installed" >&2 - exit 127 -fi -EOF -chmod +x /usr/local/bin/code - -# systemctl shim - tells people to use 'service' if systemd is not running -cat << 'EOF' > /usr/local/bin/systemctl -#!/bin/sh -set -e -if [ -d "/run/systemd/system" ]; then - exec /bin/systemctl/systemctl "$@" -else - echo '\n"systemd" is not running in this container due to its overhead.\nUse the "service" command to start services instead. e.g.: \n\nservice --status-all' -fi -EOF -chmod +x /usr/local/bin/systemctl - -# Codespaces bash and OMZ themes - partly inspired by https://github.com/ohmyzsh/ohmyzsh/blob/master/themes/robbyrussell.zsh-theme -codespaces_bash="$(cat \ -<<'EOF' - -# Codespaces bash prompt theme -__bash_prompt() { - local userpart='`export XIT=$? \ - && [ ! -z "${GITHUB_USER}" ] && echo -n "\[\033[0;32m\]@${GITHUB_USER} " || echo -n "\[\033[0;32m\]\u " \ - && [ "$XIT" -ne "0" ] && echo -n "\[\033[1;31m\]➜" || echo -n "\[\033[0m\]➜"`' - local gitbranch='`\ - if [ "$(git config --get codespaces-theme.hide-status 2>/dev/null)" != 1 ]; then \ - export BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null); \ - if [ "${BRANCH}" != "" ]; then \ - echo -n "\[\033[0;36m\](\[\033[1;31m\]${BRANCH}" \ - && if git ls-files --error-unmatch -m --directory --no-empty-directory -o --exclude-standard ":/*" > /dev/null 2>&1; then \ - echo -n " \[\033[1;33m\]✗"; \ - fi \ - && echo -n "\[\033[0;36m\]) "; \ - fi; \ - fi`' - local lightblue='\[\033[1;34m\]' - local removecolor='\[\033[0m\]' - PS1="${userpart} ${lightblue}\w ${gitbranch}${removecolor}\$ " - unset -f __bash_prompt -} -__bash_prompt - -EOF -)" - -codespaces_zsh="$(cat \ -<<'EOF' -# Codespaces zsh prompt theme -__zsh_prompt() { - local prompt_username - if [ ! -z "${GITHUB_USER}" ]; then - prompt_username="@${GITHUB_USER}" - else - prompt_username="%n" - fi - PROMPT="%{$fg[green]%}${prompt_username} %(?:%{$reset_color%}➜ :%{$fg_bold[red]%}➜ )" # User/exit code arrow - PROMPT+='%{$fg_bold[blue]%}%(5~|%-1~/…/%3~|%4~)%{$reset_color%} ' # cwd - PROMPT+='$([ "$(git config --get codespaces-theme.hide-status 2>/dev/null)" != 1 ] && git_prompt_info)' # Git status - PROMPT+='%{$fg[white]%}$ %{$reset_color%}' - unset -f __zsh_prompt -} -ZSH_THEME_GIT_PROMPT_PREFIX="%{$fg_bold[cyan]%}(%{$fg_bold[red]%}" -ZSH_THEME_GIT_PROMPT_SUFFIX="%{$reset_color%} " -ZSH_THEME_GIT_PROMPT_DIRTY=" %{$fg_bold[yellow]%}✗%{$fg_bold[cyan]%})" -ZSH_THEME_GIT_PROMPT_CLEAN="%{$fg_bold[cyan]%})" -__zsh_prompt - -EOF -)" - -# Add RC snippet and custom bash prompt -if [ "${RC_SNIPPET_ALREADY_ADDED}" != "true" ]; then - echo "${rc_snippet}" >> /etc/bash.bashrc - echo "${codespaces_bash}" >> "${user_rc_path}/.bashrc" - echo 'export PROMPT_DIRTRIM=4' >> "${user_rc_path}/.bashrc" - if [ "${USERNAME}" != "root" ]; then - echo "${codespaces_bash}" >> "/root/.bashrc" - echo 'export PROMPT_DIRTRIM=4' >> "/root/.bashrc" - fi - chown ${USERNAME}:${group_name} "${user_rc_path}/.bashrc" - RC_SNIPPET_ALREADY_ADDED="true" -fi - -# Optionally install and configure zsh and Oh My Zsh! -if [ "${INSTALL_ZSH}" = "true" ]; then - if ! type zsh > /dev/null 2>&1; then - apt_get_update - apt-get install -y zsh - fi - if [ "${ZSH_ALREADY_INSTALLED}" != "true" ]; then - echo "${rc_snippet}" >> /etc/zsh/zshrc - ZSH_ALREADY_INSTALLED="true" - fi - - # Adapted, simplified inline Oh My Zsh! install steps that adds, defaults to a codespaces theme. - # See https://github.com/ohmyzsh/ohmyzsh/blob/master/tools/install.sh for official script. - oh_my_install_dir="${user_rc_path}/.oh-my-zsh" - if [ ! -d "${oh_my_install_dir}" ] && [ "${INSTALL_OH_MY_ZSH}" = "true" ]; then - template_path="${oh_my_install_dir}/templates/zshrc.zsh-template" - user_rc_file="${user_rc_path}/.zshrc" - umask g-w,o-w - mkdir -p ${oh_my_install_dir} - git clone --depth=1 \ - -c core.eol=lf \ - -c core.autocrlf=false \ - -c fsck.zeroPaddedFilemode=ignore \ - -c fetch.fsck.zeroPaddedFilemode=ignore \ - -c receive.fsck.zeroPaddedFilemode=ignore \ - "https://github.com/ohmyzsh/ohmyzsh" "${oh_my_install_dir}" 2>&1 - echo -e "$(cat "${template_path}")\nDISABLE_AUTO_UPDATE=true\nDISABLE_UPDATE_PROMPT=true" > ${user_rc_file} - sed -i -e 's/ZSH_THEME=.*/ZSH_THEME="codespaces"/g' ${user_rc_file} - - mkdir -p ${oh_my_install_dir}/custom/themes - echo "${codespaces_zsh}" > "${oh_my_install_dir}/custom/themes/codespaces.zsh-theme" - # Shrink git while still enabling updates - cd "${oh_my_install_dir}" - git repack -a -d -f --depth=1 --window=1 - # Copy to non-root user if one is specified - if [ "${USERNAME}" != "root" ]; then - cp -rf "${user_rc_file}" "${oh_my_install_dir}" /root - chown -R ${USERNAME}:${group_name} "${user_rc_path}" - fi - fi -fi - -# Persist image metadata info, script if meta.env found in same directory -meta_info_script="$(cat << 'EOF' -#!/bin/sh -. /usr/local/etc/vscode-dev-containers/meta.env - -# Minimal output -if [ "$1" = "version" ] || [ "$1" = "image-version" ]; then - echo "${VERSION}" - exit 0 -elif [ "$1" = "release" ]; then - echo "${GIT_REPOSITORY_RELEASE}" - exit 0 -elif [ "$1" = "content" ] || [ "$1" = "content-url" ] || [ "$1" = "contents" ] || [ "$1" = "contents-url" ]; then - echo "${CONTENTS_URL}" - exit 0 -fi - -#Full output -echo -echo "Development container image information" -echo -if [ ! -z "${VERSION}" ]; then echo "- Image version: ${VERSION}"; fi -if [ ! -z "${DEFINITION_ID}" ]; then echo "- Definition ID: ${DEFINITION_ID}"; fi -if [ ! -z "${VARIANT}" ]; then echo "- Variant: ${VARIANT}"; fi -if [ ! -z "${GIT_REPOSITORY}" ]; then echo "- Source code repository: ${GIT_REPOSITORY}"; fi -if [ ! -z "${GIT_REPOSITORY_RELEASE}" ]; then echo "- Source code release/branch: ${GIT_REPOSITORY_RELEASE}"; fi -if [ ! -z "${BUILD_TIMESTAMP}" ]; then echo "- Timestamp: ${BUILD_TIMESTAMP}"; fi -if [ ! -z "${CONTENTS_URL}" ]; then echo && echo "More info: ${CONTENTS_URL}"; fi -echo -EOF -)" -if [ -f "/usr/local/etc/vscode-dev-containers/meta.env" ]; then - echo "${meta_info_script}" > /usr/local/bin/devcontainer-info - chmod +x /usr/local/bin/devcontainer-info -fi - -if [ ! -d "/usr/local/etc/vscode-dev-containers" ]; then - mkdir -p "$(dirname "${MARKER_FILE}")" -fi - -# Write marker file -echo -e "\ - PACKAGES_ALREADY_INSTALLED=${PACKAGES_ALREADY_INSTALLED}\n\ - LOCALE_ALREADY_SET=${LOCALE_ALREADY_SET}\n\ - EXISTING_NON_ROOT_USER=${EXISTING_NON_ROOT_USER}\n\ - RC_SNIPPET_ALREADY_ADDED=${RC_SNIPPET_ALREADY_ADDED}\n\ - ZSH_ALREADY_INSTALLED=${ZSH_ALREADY_INSTALLED}" > "${MARKER_FILE}" - -echo "Done!" +exec /bin/bash "$(dirname $0)/main.sh" "$@" +exit $? diff --git a/src/common-utils/main.sh b/src/common-utils/main.sh new file mode 100644 index 000000000..5e5487aa2 --- /dev/null +++ b/src/common-utils/main.sh @@ -0,0 +1,648 @@ +#!/bin/bash +#------------------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://github.com/devcontainers/features/blob/main/LICENSE for license information. +#------------------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/devcontainers/features/tree/main/src/common-utils +# Maintainer: The Dev Container spec maintainers + +set -e + +INSTALL_ZSH="${INSTALLZSH:-"true"}" +CONFIGURE_ZSH_AS_DEFAULT_SHELL="${CONFIGUREZSHASDEFAULTSHELL:-"false"}" +INSTALL_OH_MY_ZSH="${INSTALLOHMYZSH:-"true"}" +INSTALL_OH_MY_ZSH_CONFIG="${INSTALLOHMYZSHCONFIG:-"true"}" +UPGRADE_PACKAGES="${UPGRADEPACKAGES:-"true"}" +USERNAME="${USERNAME:-"automatic"}" +USER_UID="${USERUID:-"automatic"}" +USER_GID="${USERGID:-"automatic"}" +ADD_NON_FREE_PACKAGES="${NONFREEPACKAGES:-"false"}" +INSTALL_SSL="${INSTALLSSL:-"true"}" + +MARKER_FILE="/usr/local/etc/vscode-dev-containers/common" + +FEATURE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Debian / Ubuntu packages +install_debian_packages() { + # Ensure apt is in non-interactive to avoid prompts + export DEBIAN_FRONTEND=noninteractive + + local package_list="" + package_list="${package_list} \ + apt-utils \ + bash-completion \ + openssh-client \ + gnupg2 \ + dirmngr \ + iproute2 \ + procps \ + lsof \ + htop \ + net-tools \ + psmisc \ + curl \ + tree \ + wget \ + rsync \ + ca-certificates \ + unzip \ + bzip2 \ + xz-utils \ + zip \ + nano \ + vim-tiny \ + less \ + jq \ + lsb-release \ + apt-transport-https \ + dialog \ + libc6 \ + libgcc1 \ + libkrb5-3 \ + libgssapi-krb5-2 \ + libicu[0-9][0-9] \ + liblttng-ust[0-9] \ + libstdc++6 \ + zlib1g \ + locales \ + sudo \ + ncdu \ + man-db \ + strace \ + manpages \ + manpages-dev \ + init-system-helpers \ + bubblewrap \ + socat" + + if [ "${INSTALL_SSL}" = "true" ]; then + # Include libssl1.1 if available + if [[ ! -z $(apt-cache --names-only search ^libssl1.1$) ]]; then + package_list="${package_list} libssl1.1" + fi + + # Include libssl3 if available + if [[ ! -z $(apt-cache --names-only search ^libssl3$) ]]; then + package_list="${package_list} libssl3" + fi + + # Include appropriate version of libssl1.0.x if available + local libssl_package=$(dpkg-query -f '${db:Status-Abbrev}\t${binary:Package}\n' -W 'libssl1\.0\.?' 2>&1 || echo '') + if [ "$(echo "$libssl_package" | grep -o 'libssl1\.0\.[0-9]:' | uniq | sort | wc -l)" -eq 0 ]; then + if [[ ! -z $(apt-cache --names-only search ^libssl1.0.2$) ]]; then + # Debian 9 + package_list="${package_list} libssl1.0.2" + elif [[ ! -z $(apt-cache --names-only search ^libssl1.0.0$) ]]; then + # Ubuntu 18.04 + package_list="${package_list} libssl1.0.0" + fi + fi + fi + + # Include git if not already installed (may be more recent than distro version) + if ! type git > /dev/null 2>&1; then + package_list="${package_list} git" + fi + + # Needed for adding manpages-posix and manpages-posix-dev which are non-free packages in Debian + if [ "${ADD_NON_FREE_PACKAGES}" = "true" ]; then + if [[ ! -e "/etc/apt/sources.list" ]] && [[ -e "/etc/apt/sources.list.d/debian.sources" ]]; then + sed -i '/^URIs: http:\/\/deb.debian.org\/debian$/ { N; N; s/Components: main/Components: main non-free non-free-firmware/ }' /etc/apt/sources.list.d/debian.sources + else + # Bring in variables from /etc/os-release like VERSION_CODENAME + sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list + sed -i -E "s/deb-src http:\/\/(deb|httredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list + sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list + sed -i -E "s/deb-src http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb-src http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list + # Handle bullseye location for security https://www.debian.org/releases/bullseye/amd64/release-notes/ch-information.en.html + sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}-security main contrib non-free/" /etc/apt/sources.list + fi; + echo "Running apt-get update..." + package_list="${package_list} manpages-posix manpages-posix-dev" + fi + + local missing_package_list="" + local packages=() + read -r -a packages <<< "${package_list}" + for package in "${packages[@]}"; do + if ! dpkg-query -W -f='${db:Status-Abbrev}\n' "${package}" 2>/dev/null | grep -q '^ii'; then + missing_package_list="${missing_package_list} ${package}" + fi + done + + # Install the list of missing packages + if [ -n "${missing_package_list}" ]; then + echo "Packages to verify are installed: ${missing_package_list}" + rm -rf /var/lib/apt/lists/* + apt-get update -y + apt-get -y install --no-install-recommends ${missing_package_list} 2> >( grep -v 'debconf: delaying package configuration, since apt-utils is not installed' >&2 ) + fi + + # Install zsh (and recommended packages) if needed + if [ "${INSTALL_ZSH}" = "true" ] && ! type zsh > /dev/null 2>&1; then + apt-get install -y zsh + fi + + # Get to latest versions of all packages + if [ "${UPGRADE_PACKAGES}" = "true" ]; then + apt-get -y upgrade --no-install-recommends + apt-get autoremove -y + fi + + # Ensure at least the en_US.UTF-8 UTF-8 locale is available = common need for both applications and things like the agnoster ZSH theme. + if [ "${LOCALE_ALREADY_SET}" != "true" ] && ! grep -o -E '^\s*en_US.UTF-8\s+UTF-8' /etc/locale.gen > /dev/null; then + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen + locale-gen + LOCALE_ALREADY_SET="true" + fi + + # Clean up + apt-get -y clean + rm -rf /var/lib/apt/lists/* +} + +# RedHat / RockyLinux / CentOS / Fedora packages +install_redhat_packages() { + local package_list="" + local remove_epel="false" + local install_cmd=microdnf + if type microdnf > /dev/null 2>&1; then + install_cmd=microdnf + elif type tdnf > /dev/null 2>&1; then + install_cmd=tdnf + elif type dnf > /dev/null 2>&1; then + install_cmd=dnf + elif type yum > /dev/null 2>&1; then + install_cmd=yum + else + echo "Unable to find 'tdnf', 'dnf', or 'yum' package manager. Exiting." + exit 1 + fi + + package_list="${package_list} \ + gawk \ + bash-completion \ + openssh-clients \ + gnupg2 \ + iproute \ + procps \ + lsof \ + net-tools \ + psmisc \ + wget \ + ca-certificates \ + rsync \ + unzip \ + xz \ + zip \ + nano \ + vim-minimal \ + less \ + jq \ + openssl-libs \ + krb5-libs \ + libicu \ + zlib \ + sudo \ + sed \ + grep \ + which \ + man-db \ + strace \ + socat" + + # Install bubblewrap if available (not present in UBI repositories) + if ${install_cmd} -q list bubblewrap >/dev/null 2>&1; then + package_list="${package_list} bubblewrap" + fi + + # rockylinux:9 installs 'curl-minimal' which clashes with 'curl' + # Install 'curl' for every OS except this rockylinux:9 + if [[ "${ID}" = "rocky" ]] && [[ "${VERSION}" != *"9."* ]]; then + package_list="${package_list} curl" + fi + + # Install OpenSSL 1.0 compat if needed + if ${install_cmd} -q list compat-openssl10 >/dev/null 2>&1; then + package_list="${package_list} compat-openssl10" + fi + + # Install lsb_release if available + if ${install_cmd} -q list redhat-lsb-core >/dev/null 2>&1; then + package_list="${package_list} redhat-lsb-core" + fi + + # Install git if not already installed (may be more recent than distro version) + if ! type git > /dev/null 2>&1; then + package_list="${package_list} git" + fi + + # Install EPEL repository if needed (required to install 'jq' for CentOS) + if [[ "${ID}" = "centos" ]] && ! rpm -q jq >/dev/null 2>&1; then + ${install_cmd} -y install epel-release + remove_epel="true" + fi + + # Install zsh if needed + if [ "${INSTALL_ZSH}" = "true" ] && ! type zsh > /dev/null 2>&1; then + package_list="${package_list} zsh" + fi + + local missing_package_list="" + local packages=() + read -r -a packages <<< "${package_list}" + for package in "${packages[@]}"; do + if ! rpm -q "${package}" >/dev/null 2>&1; then + missing_package_list="${missing_package_list} ${package}" + fi + done + + if [ -n "${missing_package_list}" ]; then + echo "Packages to verify are installed: ${missing_package_list}" + echo "Running ${install_cmd} install..." + if [ "${install_cmd}" = "dnf" ]; then + ${install_cmd} -y install --allowerasing ${missing_package_list} + else + ${install_cmd} -y install ${missing_package_list} + fi + fi + + # Get to latest versions of all packages + if [ "${UPGRADE_PACKAGES}" = "true" ]; then + echo "Running ${install_cmd} upgrade..." + ${install_cmd} upgrade -y + fi + + if [[ "${remove_epel}" = "true" ]]; then + ${install_cmd} -y remove epel-release + fi +} + +# Alpine Linux packages +install_alpine_packages() { + apk update + local package_list="" + + package_list="${package_list} \ + openssh-client \ + bash-completion \ + gnupg \ + procps \ + lsof \ + htop \ + net-tools \ + psmisc \ + curl \ + wget \ + rsync \ + ca-certificates \ + unzip \ + xz \ + zip \ + nano \ + vim \ + less \ + jq \ + libgcc \ + libstdc++ \ + krb5-libs \ + libintl \ + lttng-ust \ + tzdata \ + userspace-rcu \ + zlib \ + sudo \ + coreutils \ + sed \ + grep \ + which \ + ncdu \ + shadow \ + strace \ + bubblewrap \ + socat" + + # # Include libssl1.1 if available (not available for 3.19 and newer) + LIBSSL1_PKG=libssl1.1 + if [[ $(apk search --no-cache -a $LIBSSL1_PKG | grep $LIBSSL1_PKG) ]]; then + package_list="${package_list} $LIBSSL1_PKG" + fi + + # Install man pages - package name varies between 3.12 and earlier versions + if apk info man > /dev/null 2>&1; then + package_list="${package_list} man man-pages" + else + package_list="${package_list} mandoc man-pages" + fi + + # Install git if not already installed (may be more recent than distro version) + if ! type git > /dev/null 2>&1; then + package_list="${package_list} git" + fi + + # Install zsh if needed + if [ "${INSTALL_ZSH}" = "true" ] && ! type zsh > /dev/null 2>&1; then + package_list="${package_list} zsh" + fi + + local missing_package_list="" + local packages=() + read -r -a packages <<< "${package_list}" + for package in "${packages[@]}"; do + if ! apk info -e "${package}" >/dev/null 2>&1; then + missing_package_list="${missing_package_list} ${package}" + fi + done + if [ -n "${missing_package_list}" ]; then + apk add --no-cache ${missing_package_list} + fi +} + +# ****************** +# ** Main section ** +# ****************** + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +# Load markers to see which steps have already run +if [ -f "${MARKER_FILE}" ]; then + echo "Marker file found:" + cat "${MARKER_FILE}" + source "${MARKER_FILE}" +fi + +# Ensure that login shells get the correct path if the user updated the PATH using ENV. +rm -f /etc/profile.d/00-restore-env.sh +echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh +chmod +x /etc/profile.d/00-restore-env.sh + +# Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME +. /etc/os-release +# Get an adjusted ID independent of distro variants +if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" +elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "azurelinux" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + VERSION_CODENAME="${ID}${VERSION_ID}" +elif [ "${ID}" = "alpine" ]; then + ADJUSTED_ID="alpine" +else + echo "Linux distro ${ID} not supported." + exit 1 +fi + +if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${VERSION_CODENAME-}" = "centos7" ]; then + # As of 1 July 2024, mirrorlist.centos.org no longer exists. + # Update the repo files to reference vault.centos.org. + sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo + sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo + sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo +fi + +# Install packages for appropriate OS +case "${ADJUSTED_ID}" in + "debian") + install_debian_packages + ;; + "rhel") + install_redhat_packages + ;; + "alpine") + install_alpine_packages + ;; +esac + +# If in automatic mode, determine if a user already exists, if not use vscode +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + if [ "${_REMOTE_USER}" != "root" ]; then + USERNAME="${_REMOTE_USER}" + else + USERNAME="" + POSSIBLE_USERS=("devcontainer" "vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=vscode + fi + fi +elif [ "${USERNAME}" = "none" ]; then + USERNAME=root + USER_UID=0 + USER_GID=0 +fi +# Create or update a non-root user to match UID/GID. +group_name="${USERNAME}" +if id -u ${USERNAME} > /dev/null 2>&1; then + # User exists, update if needed + if [ "${USER_GID}" != "automatic" ] && [ "$USER_GID" != "$(id -g $USERNAME)" ]; then + group_name="$(id -gn $USERNAME)" + groupmod --gid $USER_GID ${group_name} + usermod --gid $USER_GID $USERNAME + fi + if [ "${USER_UID}" != "automatic" ] && [ "$USER_UID" != "$(id -u $USERNAME)" ]; then + usermod --uid $USER_UID $USERNAME + fi +else + # Create user + if [ "${USER_GID}" = "automatic" ]; then + groupadd $USERNAME + else + groupadd --gid $USER_GID $USERNAME + fi + if [ "${USER_UID}" = "automatic" ]; then + useradd -s /bin/bash --gid $USERNAME -m $USERNAME + else + useradd -s /bin/bash --uid $USER_UID --gid $USERNAME -m $USERNAME + fi +fi + +# Add add sudo support for non-root user +if [ "${USERNAME}" != "root" ] && [ "${EXISTING_NON_ROOT_USER}" != "${USERNAME}" ]; then + echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME + chmod 0440 /etc/sudoers.d/$USERNAME + EXISTING_NON_ROOT_USER="${USERNAME}" +fi + +# ********************************* +# ** Shell customization section ** +# ********************************* + +if [ "${USERNAME}" = "root" ]; then + user_home="/root" +# Check if user already has a home directory other than /home/${USERNAME} +elif [ "/home/${USERNAME}" != $( getent passwd $USERNAME | cut -d: -f6 ) ]; then + user_home=$( getent passwd $USERNAME | cut -d: -f6 ) +else + user_home="/home/${USERNAME}" + if [ ! -d "${user_home}" ]; then + mkdir -p "${user_home}" + chown ${USERNAME}:${group_name} "${user_home}" + fi +fi + +# Restore user .bashrc / .profile / .zshrc defaults from skeleton file if it doesn't exist or is empty +possible_rc_files=( ".bashrc" ".profile" ) +[ "$INSTALL_OH_MY_ZSH_CONFIG" == "true" ] && possible_rc_files+=('.zshrc') +[ "$INSTALL_ZSH" == "true" ] && possible_rc_files+=('.zprofile') +for rc_file in "${possible_rc_files[@]}"; do + if [ -f "/etc/skel/${rc_file}" ]; then + if [ ! -e "${user_home}/${rc_file}" ] || [ ! -s "${user_home}/${rc_file}" ]; then + cp "/etc/skel/${rc_file}" "${user_home}/${rc_file}" + chown ${USERNAME}:${group_name} "${user_home}/${rc_file}" + fi + fi +done + +# Add RC snippet and custom bash prompt +if [ "${RC_SNIPPET_ALREADY_ADDED}" != "true" ]; then + case "${ADJUSTED_ID}" in + "debian") + global_rc_path="/etc/bash.bashrc" + ;; + "rhel") + global_rc_path="/etc/bashrc" + ;; + "alpine") + global_rc_path="/etc/bash/bashrc" + # /etc/bash/bashrc does not exist in alpine 3.14 & 3.15 + mkdir -p /etc/bash + ;; + esac + cat "${FEATURE_DIR}/scripts/rc_snippet.sh" >> ${global_rc_path} + cat "${FEATURE_DIR}/scripts/bash_theme_snippet.sh" >> "${user_home}/.bashrc" + if [ "${USERNAME}" != "root" ]; then + cat "${FEATURE_DIR}/scripts/bash_theme_snippet.sh" >> "/root/.bashrc" + chown ${USERNAME}:${group_name} "${user_home}/.bashrc" + fi + RC_SNIPPET_ALREADY_ADDED="true" +fi + +# Optionally configure zsh and Oh My Zsh! +if [ "${INSTALL_ZSH}" = "true" ]; then + if [ ! -f "${user_home}/.zprofile" ]; then + touch "${user_home}/.zprofile" + echo 'source $HOME/.profile' >> "${user_home}/.zprofile" # TODO: Reconsider adding '.profile' to '.zprofile' + chown ${USERNAME}:${group_name} "${user_home}/.zprofile" + fi + + if [ "${ZSH_ALREADY_INSTALLED}" != "true" ]; then + if [ "${ADJUSTED_ID}" = "rhel" ]; then + global_rc_path="/etc/zshrc" + else + global_rc_path="/etc/zsh/zshrc" + fi + cat "${FEATURE_DIR}/scripts/rc_snippet.sh" >> ${global_rc_path} + ZSH_ALREADY_INSTALLED="true" + fi + + if [ "${CONFIGURE_ZSH_AS_DEFAULT_SHELL}" == "true" ]; then + # Fixing chsh always asking for a password on alpine linux + # ref: https://askubuntu.com/questions/812420/chsh-always-asking-a-password-and-get-pam-authentication-failure. + if [ ! -f "/etc/pam.d/chsh" ] || ! grep -Eq '^auth(.*)pam_rootok\.so$' /etc/pam.d/chsh; then + echo "auth sufficient pam_rootok.so" >> /etc/pam.d/chsh + elif [[ -n "$(awk '/^auth(.*)pam_rootok\.so$/ && !/^auth[[:blank:]]+sufficient[[:blank:]]+pam_rootok\.so$/' /etc/pam.d/chsh)" ]]; then + awk '/^auth(.*)pam_rootok\.so$/ { $2 = "sufficient" } { print }' /etc/pam.d/chsh > /tmp/chsh.tmp && mv /tmp/chsh.tmp /etc/pam.d/chsh + fi + + chsh --shell /bin/zsh ${USERNAME} + fi + + # Adapted, simplified inline Oh My Zsh! install steps that adds, defaults to a codespaces theme. + # See https://github.com/ohmyzsh/ohmyzsh/blob/master/tools/install.sh for official script. + if [ "${INSTALL_OH_MY_ZSH}" = "true" ]; then + user_rc_file="${user_home}/.zshrc" + oh_my_install_dir="${user_home}/.oh-my-zsh" + template_path="${oh_my_install_dir}/templates/zshrc.zsh-template" + if [ ! -d "${oh_my_install_dir}" ]; then + umask g-w,o-w + mkdir -p ${oh_my_install_dir} + git clone --depth=1 \ + -c core.eol=lf \ + -c core.autocrlf=false \ + -c fsck.zeroPaddedFilemode=ignore \ + -c fetch.fsck.zeroPaddedFilemode=ignore \ + -c receive.fsck.zeroPaddedFilemode=ignore \ + "https://github.com/ohmyzsh/ohmyzsh" "${oh_my_install_dir}" 2>&1 + + # Shrink git while still enabling updates + cd "${oh_my_install_dir}" + git repack -a -d -f --depth=1 --window=1 + fi + + # Add Dev Containers theme + mkdir -p ${oh_my_install_dir}/custom/themes + cp -f "${FEATURE_DIR}/scripts/devcontainers.zsh-theme" "${oh_my_install_dir}/custom/themes/devcontainers.zsh-theme" + ln -sf "${oh_my_install_dir}/custom/themes/devcontainers.zsh-theme" "${oh_my_install_dir}/custom/themes/codespaces.zsh-theme" + + # Add devcontainer .zshrc template + if [ "$INSTALL_OH_MY_ZSH_CONFIG" = "true" ]; then + if ! [ -f "${template_path}" ] || ! grep -qF "$(head -n 1 "${template_path}")" "${user_rc_file}"; then + echo -e "$(cat "${template_path}")\nzstyle ':omz:update' mode disabled" > ${user_rc_file} + fi + sed -i -e 's/ZSH_THEME=.*/ZSH_THEME="devcontainers"/g' ${user_rc_file} + fi + + # Copy to non-root user if one is specified + if [ "${USERNAME}" != "root" ]; then + copy_to_user_files=("${oh_my_install_dir}") + [ -f "$user_rc_file" ] && copy_to_user_files+=("$user_rc_file") + cp -rf "${copy_to_user_files[@]}" /root + chown -R ${USERNAME}:${group_name} "${copy_to_user_files[@]}" + fi + fi +fi + +# ********************************* +# ** Ensure config directory ** +# ********************************* +user_config_dir="${user_home}/.config" +if [ ! -d "${user_config_dir}" ]; then + mkdir -p "${user_config_dir}" + chown ${USERNAME}:${group_name} "${user_config_dir}" +fi + +# **************************** +# ** Utilities and commands ** +# **************************** + +# code shim, it fallbacks to code-insiders if code is not available +cp -f "${FEATURE_DIR}/bin/code" /usr/local/bin/ +chmod +rx /usr/local/bin/code + +# systemctl shim for Debian/Ubuntu - tells people to use 'service' if systemd is not running +if [ "${ADJUSTED_ID}" = "debian" ]; then + cp -fL "${FEATURE_DIR}/bin/systemctl" /usr/local/bin/systemctl + chmod +rx /usr/local/bin/systemctl +fi + +# Persist image metadata info, script if meta.env found in same directory +if [ -f "/usr/local/etc/vscode-dev-containers/meta.env" ] || [ -f "/usr/local/etc/dev-containers/meta.env" ]; then + cp -f "${FEATURE_DIR}/bin/devcontainer-info" /usr/local/bin/devcontainer-info + chmod +rx /usr/local/bin/devcontainer-info +fi + +# Write marker file +if [ ! -d "/usr/local/etc/vscode-dev-containers" ]; then + mkdir -p "$(dirname "${MARKER_FILE}")" +fi +echo -e "\ + LOCALE_ALREADY_SET=${LOCALE_ALREADY_SET}\n\ + EXISTING_NON_ROOT_USER=${EXISTING_NON_ROOT_USER}\n\ + RC_SNIPPET_ALREADY_ADDED=${RC_SNIPPET_ALREADY_ADDED}\n\ + ZSH_ALREADY_INSTALLED=${ZSH_ALREADY_INSTALLED}" > "${MARKER_FILE}" + +echo "Done!" diff --git a/src/common-utils/scripts/bash_theme_snippet.sh b/src/common-utils/scripts/bash_theme_snippet.sh new file mode 100644 index 000000000..ab76ada33 --- /dev/null +++ b/src/common-utils/scripts/bash_theme_snippet.sh @@ -0,0 +1,44 @@ +# bash theme - partly inspired by https://github.com/ohmyzsh/ohmyzsh/blob/master/themes/robbyrussell.zsh-theme +__bash_prompt() { + local userpart='`export XIT=$? \ + && [ ! -z "${GITHUB_USER:-}" ] && echo -n "\[\033[0;32m\]@${GITHUB_USER:-} " || echo -n "\[\033[0;32m\]\u " \ + && [ "$XIT" -ne "0" ] && echo -n "\[\033[1;31m\]➜" || echo -n "\[\033[0m\]➜"`' + local gitbranch='`\ + if [ "$(git config --get devcontainers-theme.hide-status 2>/dev/null)" != 1 ] && [ "$(git config --get codespaces-theme.hide-status 2>/dev/null)" != 1 ]; then \ + export BRANCH="$(git --no-optional-locks symbolic-ref --short HEAD 2>/dev/null || git --no-optional-locks rev-parse --short HEAD 2>/dev/null)"; \ + if [ "${BRANCH:-}" != "" ]; then \ + echo -n "\[\033[0;36m\](\[\033[1;31m\]${BRANCH:-}" \ + && if [ "$(git config --get devcontainers-theme.show-dirty 2>/dev/null)" = 1 ] && \ + git --no-optional-locks ls-files --error-unmatch -m --directory --no-empty-directory -o --exclude-standard ":/*" > /dev/null 2>&1; then \ + echo -n " \[\033[1;33m\]✗"; \ + fi \ + && echo -n "\[\033[0;36m\]) "; \ + fi; \ + fi`' + local lightblue='\[\033[1;34m\]' + local removecolor='\[\033[0m\]' + PS1="${userpart} ${lightblue}\w ${gitbranch}${removecolor}\$ " + unset -f __bash_prompt +} +__bash_prompt +export PROMPT_DIRTRIM=4 + +# Check if the terminal is xterm +if [[ "$TERM" == "xterm" ]]; then + # Function to set the terminal title to the current command + preexec() { + local cmd="${BASH_COMMAND}" + echo -ne "\033]0;${USER}@${HOSTNAME}: ${cmd}\007" + } + + # Function to reset the terminal title to the shell type after the command is executed + precmd() { + echo -ne "\033]0;${USER}@${HOSTNAME}: ${SHELL}\007" + } + + # Trap DEBUG signal to call preexec before each command + trap 'preexec' DEBUG + + # Append to PROMPT_COMMAND to call precmd before displaying the prompt + PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }precmd" +fi diff --git a/src/common-utils/scripts/devcontainers.zsh-theme b/src/common-utils/scripts/devcontainers.zsh-theme new file mode 100644 index 000000000..0cfd70e65 --- /dev/null +++ b/src/common-utils/scripts/devcontainers.zsh-theme @@ -0,0 +1,45 @@ +# Oh My Zsh! theme - partly inspired by https://github.com/ohmyzsh/ohmyzsh/blob/master/themes/robbyrussell.zsh-theme +__zsh_prompt() { + local prompt_username + if [ ! -z "${GITHUB_USER}" ]; then + prompt_username="@${GITHUB_USER}" + else + prompt_username="%n" + fi + PROMPT="%{$fg[green]%}${prompt_username} %(?:%{$reset_color%}➜ :%{$fg_bold[red]%}➜ )" # User/exit code arrow + PROMPT+='%{$fg_bold[blue]%}%(5~|%-1~/…/%3~|%4~)%{$reset_color%} ' # cwd + PROMPT+='`\ + if [ "$(git config --get devcontainers-theme.hide-status 2>/dev/null)" != 1 ] && [ "$(git config --get codespaces-theme.hide-status 2>/dev/null)" != 1 ]; then \ + export BRANCH=$(git --no-optional-locks symbolic-ref --short HEAD 2>/dev/null || git --no-optional-locks rev-parse --short HEAD 2>/dev/null); \ + if [ "${BRANCH}" != "" ]; then \ + echo -n "%{$fg_bold[cyan]%}(%{$fg_bold[red]%}${BRANCH}" \ + && if [ "$(git config --get devcontainers-theme.show-dirty 2>/dev/null)" = 1 ] && \ + git --no-optional-locks ls-files --error-unmatch -m --directory --no-empty-directory -o --exclude-standard ":/*" > /dev/null 2>&1; then \ + echo -n " %{$fg_bold[yellow]%}✗"; \ + fi \ + && echo -n "%{$fg_bold[cyan]%})%{$reset_color%} "; \ + fi; \ + fi`' + PROMPT+='%{$fg[white]%}$ %{$reset_color%}' + unset -f __zsh_prompt +} +__zsh_prompt + +# Check if the terminal is xterm +if [[ "$TERM" == "xterm" ]]; then + # Function to set the terminal title to the current command + preexec() { + local cmd=${1} + echo -ne "\033]0;${USER}@${HOSTNAME}: ${cmd}\007" + } + + # Function to reset the terminal title to the shell type after the command is executed + precmd() { + echo -ne "\033]0;${USER}@${HOSTNAME}: ${SHELL}\007" + } + + # Add the preexec and precmd functions to the corresponding hooks + autoload -Uz add-zsh-hook + add-zsh-hook preexec preexec + add-zsh-hook precmd precmd +fi diff --git a/src/common-utils/scripts/rc_snippet.sh b/src/common-utils/scripts/rc_snippet.sh new file mode 100644 index 000000000..f3f36a4b8 --- /dev/null +++ b/src/common-utils/scripts/rc_snippet.sh @@ -0,0 +1,25 @@ +if [ -z "${USER}" ]; then export USER=$(whoami); fi +if [[ "${PATH}" != *"$HOME/.local/bin"* ]]; then export PATH="${PATH}:$HOME/.local/bin"; fi + +# Display optional first run image specific notice if configured and terminal is interactive +if [ -t 1 ] && [[ "${TERM_PROGRAM}" = "vscode" || "${TERM_PROGRAM}" = "codespaces" ]] && [ ! -f "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed" ]; then + if [ -f "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" ]; then + cat "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" + elif [ -f "/workspaces/.codespaces/shared/first-run-notice.txt" ]; then + cat "/workspaces/.codespaces/shared/first-run-notice.txt" + fi + mkdir -p "$HOME/.config/vscode-dev-containers" + # Mark first run notice as displayed after 10s to avoid problems with fast terminal refreshes hiding it + ((sleep 10s; touch "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed") &) +fi + +# Set the default git editor if not already set +if [ -z "$(git config --get core.editor)" ] && [ -z "${GIT_EDITOR}" ]; then + if [ "${TERM_PROGRAM}" = "vscode" ]; then + if [[ -n $(command -v code-insiders) && -z $(command -v code) ]]; then + export GIT_EDITOR="code-insiders --wait" + else + export GIT_EDITOR="code --wait" + fi + fi +fi diff --git a/src/conda/NOTES.md b/src/conda/NOTES.md index 58450c6a0..98189ccc4 100644 --- a/src/conda/NOTES.md +++ b/src/conda/NOTES.md @@ -11,3 +11,10 @@ As covered in the [user FAQ](https://docs.anaconda.com/anaconda/user-guide/faq) ```bash conda install python=3.7 ``` + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. Both `x86_64` and `aarch64` architectures are supported. + +`bash` is required to execute the `install.sh` script. diff --git a/src/conda/README.md b/src/conda/README.md index c16013a88..eaee45b2d 100644 --- a/src/conda/README.md +++ b/src/conda/README.md @@ -7,9 +7,7 @@ A cross-platform, language-agnostic binary package manager ```json "features": { - "ghcr.io/devcontainers/features/conda:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/conda:2": {} } ``` @@ -18,7 +16,7 @@ A cross-platform, language-agnostic binary package manager | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select or enter a conda version. | string | latest | -| addCondaForge | Add conda-forge channel to the config? | boolean | - | +| addCondaForge | Add conda-forge channel to the config? | boolean | false | ## Using Conda @@ -35,6 +33,13 @@ conda install python=3.7 ``` +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/conda/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/conda/devcontainer-feature.json b/src/conda/devcontainer-feature.json index 1aa69a27c..8c66d4ef1 100644 --- a/src/conda/devcontainer-feature.json +++ b/src/conda/devcontainer-feature.json @@ -1,29 +1,43 @@ { - "id": "conda", - "version": "1.0.3", - "name": "Conda", - "description": "A cross-platform, language-agnostic binary package manager", - "documentationURL": "https://github.com/devcontainers/features/tree/main/src/conda", - "options": { - "version": { - "type": "string", - "proposals": [ - "latest", - "4.11.0", - "4.12.0" - ], - "default": "latest", - "description": "Select or enter a conda version." - }, - "addCondaForge": { - "type": "boolean", - "default": false, - "description": "Add conda-forge channel to the config?" - } + "id": "conda", + "version": "2.0.1", + "name": "Conda", + "description": "A cross-platform, language-agnostic binary package manager", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/conda", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "24.11.3", + "24.7.1" + ], + "default": "latest", + "description": "Select or enter a conda version." }, - "containerEnv": { - "CONDA_DIR": "/opt/conda", - "CONDA_SCRIPT":"/opt/conda/etc/profile.d/conda.sh", - "PATH": "/opt/conda/bin:${PATH}" + "addCondaForge": { + "type": "boolean", + "default": false, + "description": "Add conda-forge channel to the config?" } + }, + "containerEnv": { + "CONDA_DIR": "/opt/conda", + "CONDA_SCRIPT": "/opt/conda/etc/profile.d/conda.sh", + "PATH": "/opt/conda/bin:${PATH}" + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the conda package manager pre-installed and available on the `PATH` for data science and Python development. Additional packages installed using Conda will be downloaded from Anaconda or another repository configured by the user. A user can install different versions of Python than the one in this dev container by running a command like: conda install python=3.7" + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/conda/install.sh b/src/conda/install.sh index 89d2fcb82..fdc050c7a 100644 --- a/src/conda/install.sh +++ b/src/conda/install.sh @@ -7,13 +7,16 @@ VERSION=${VERSION:-"latest"} ADD_CONDA_FORGE=$ADDCONDAFORGE -USERNAME="automatic" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" UPDATE_RC="true" CONDA_DIR="/opt/conda" set -eux export DEBIAN_FRONTEND=noninteractive +# Clean up +rm -rf /var/lib/apt/lists/* + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -42,20 +45,61 @@ elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then fi architecture="$(uname -m)" -if [ "${architecture}" != "x86_64" ]; then - echo "(!) Architecture $architecture unsupported" - exit 1 -fi +case "${architecture}" in + x86_64) MINICONDA_ARCH="x86_64" ;; + aarch64|arm64) MINICONDA_ARCH="aarch64" ;; + *) + echo "(!) Architecture $architecture unsupported" + exit 1 + ;; +esac # Checks if packages are installed and installs them if not check_packages() { if ! dpkg -s "$@" > /dev/null 2>&1; then - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi apt-get -y install --no-install-recommends "$@" + fi +} + +sudo_if() { + COMMAND="$*" + if [ "$(id -u)" -eq 0 ] && [ "$USERNAME" != "root" ]; then + su - "$USERNAME" -c "$COMMAND" + else + $COMMAND + fi +} + +install_user_package() { + PACKAGE="$1" + sudo_if "${CONDA_DIR}/bin/python3" -m pip install --user --upgrade "$PACKAGE" +} - # Clean up - apt-get clean -y - rm -rf /var/lib/apt/lists/* +accept_anaconda_tos_if_needed() { + if ! "${CONDA_DIR}/bin/conda" tos --help > /dev/null 2>&1; then + return 0 + fi + + for channel in "https://repo.anaconda.com/pkgs/main" "https://repo.anaconda.com/pkgs/r"; do + echo "Accepting Conda Terms of Service for ${channel}..." + # Accept as root (for install-time commands) + "${CONDA_DIR}/bin/conda" tos accept --override-channels --channel "${channel}" + # Accept as the target user (for runtime usage) + sudo_if "${CONDA_DIR}/bin/conda" tos accept --override-channels --channel "${channel}" + done +} + +clean_conda_cache() { + "${CONDA_DIR}/bin/conda" clean --all --yes + find "${CONDA_DIR}" -type f -name '*.pyc' -delete + find "${CONDA_DIR}" -type d -name '__pycache__' -exec rm -rf {} + + rm -rf "${CONDA_DIR}/pkgs/cache" /root/.cache/pip + if [ "${USERNAME}" != "root" ]; then + rm -rf "/home/${USERNAME}/.cache/pip" fi } @@ -67,21 +111,38 @@ if ! conda --version &> /dev/null ; then usermod -a -G conda "${USERNAME}" # Install dependencies - check_packages curl ca-certificates gnupg2 + check_packages curl ca-certificates - echo "Installing Conda..." + echo "Installing Conda via Miniconda installer..." - curl -sS https://repo.anaconda.com/pkgs/misc/gpgkeys/anaconda.asc | gpg --dearmor > /usr/share/keyrings/conda-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/conda-archive-keyring.gpg] https://repo.anaconda.com/pkgs/misc/debrepo/conda stable main" > /etc/apt/sources.list.d/conda.list + # Download and run the official Miniconda installer + MINICONDA_INSTALLER="$(mktemp -t miniconda_XXXXXX.sh)" + MINICONDA_URL="https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-${MINICONDA_ARCH}.sh" - CONDA_PKG="conda=${VERSION}-0" - if [ "${VERSION}" = "latest" ]; then - CONDA_PKG="conda" + echo "Downloading Miniconda installer from ${MINICONDA_URL}..." + if ! curl -fsSL --connect-timeout 10 --max-time 120 "${MINICONDA_URL}" -o "${MINICONDA_INSTALLER}"; then + echo "ERROR: Failed to download Miniconda installer from ${MINICONDA_URL}" + rm -f "${MINICONDA_INSTALLER}" + exit 1 fi - check_packages $CONDA_PKG + # Run installer in batch mode (no prompts) and install to CONDA_DIR + bash "${MINICONDA_INSTALLER}" -b -p "${CONDA_DIR}" + rm -f "${MINICONDA_INSTALLER}" + + # Conda defaults channels now require a non-interactive ToS acknowledgement. + accept_anaconda_tos_if_needed - CONDA_SCRIPT="/opt/conda/etc/profile.d/conda.sh" + # Install specific conda version if requested (latest Miniconda already bundles a recent conda) + if [ "${VERSION}" != "latest" ]; then + echo "Installing conda version ${VERSION}..." + if ! "${CONDA_DIR}/bin/conda" install -y "conda=${VERSION}"; then + echo "ERROR: Failed to install conda version ${VERSION}. Please verify the version is valid and available." + exit 1 + fi + fi + + CONDA_SCRIPT="${CONDA_DIR}/etc/profile.d/conda.sh" . $CONDA_SCRIPT if [ "${ADD_CONDA_FORGE}" = "true" ]; then @@ -96,6 +157,8 @@ if ! conda --version &> /dev/null ; then chmod -R g+r+w "${CONDA_DIR}" find "${CONDA_DIR}" -type d -print0 | xargs -n 1 -0 chmod g+s + + clean_conda_cache fi # Display a notice on conda when not running in GitHub Codespaces @@ -123,4 +186,7 @@ if [ -f "/etc/bash.bashrc" ]; then echo "${notice_script}" | tee -a /etc/bash.bashrc fi +# Clean up +rm -rf /var/lib/apt/lists/* + echo "Done!" diff --git a/src/copilot-cli/NOTES.md b/src/copilot-cli/NOTES.md new file mode 100644 index 000000000..c5393ab5a --- /dev/null +++ b/src/copilot-cli/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions. + +`bash` is required to execute the `install.sh` script. diff --git a/src/copilot-cli/README.md b/src/copilot-cli/README.md new file mode 100644 index 000000000..3b7fc3be5 --- /dev/null +++ b/src/copilot-cli/README.md @@ -0,0 +1,31 @@ + +# GitHub Copilot CLI (copilot-cli) + +Installs the GitHub Copilot CLI. Auto-detects latest version and installs needed dependencies. + +## Example Usage + +```json +"features": { + "ghcr.io/devcontainers/features/copilot-cli:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| version | Select version of the GitHub Copilot CLI, if not latest. | string | latest | + + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions. + +`bash` is required to execute the `install.sh` script. + + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/copilot-cli/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ \ No newline at end of file diff --git a/src/copilot-cli/devcontainer-feature.json b/src/copilot-cli/devcontainer-feature.json new file mode 100644 index 000000000..f4db33ced --- /dev/null +++ b/src/copilot-cli/devcontainer-feature.json @@ -0,0 +1,34 @@ +{ + "id": "copilot-cli", + "version": "1.1.3", + "name": "GitHub Copilot CLI", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/copilot-cli", + "description": "Installs the GitHub Copilot CLI.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "prerelease" + ], + "default": "latest", + "description": "Select version of the GitHub Copilot CLI, if not latest." + } + }, + "postStartCommand": "[ -f /etc/devcontainer-copilot-cli/auto-update ] && copilot update || true", + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the GitHub Copilot CLI (`copilot`), which is pre-installed and available on the `PATH`." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} + diff --git a/src/copilot-cli/install.sh b/src/copilot-cli/install.sh new file mode 100755 index 000000000..ad4a19701 --- /dev/null +++ b/src/copilot-cli/install.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/devcontainers/features/blob/main/src/copilot-cli/README.md +# Maintainer: The VS Code and Codespaces Teams + +CLI_VERSION=${VERSION:-"latest"} + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +apt_get_update() { + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi +} + +# Checks if packages are installed and installs them if not +check_packages() { + if ! dpkg -s "$@" > /dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi +} + +resolve_prerelease_version() { + local repo_versions="${1:?resolve_prerelease_version requires the copilot-cli repo tags as input}" + printf '%s\n' "${repo_versions}" \ + | awk '{print $2}' | sed 's|refs/tags/||' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$' \ + | sort -V | tail -n1 +} + +download_from_github() { + local release_url=$1 + echo "Downloading GitHub Copilot CLI from ${release_url}..." + + mkdir -p /tmp/copilotcli + pushd /tmp/copilotcli + wget --show-progress --progress=dot:giga ${release_url} + # curl -fL# -O ${release_url} + tar -xzf /tmp/copilotcli/${cli_filename} + mv copilot /usr/local/bin/copilot + popd + rm -rf /tmp/copilotcli +} + +install_using_github() { + check_packages wget tar ca-certificates git + echo "Finished setting up dependencies" + arch=$(dpkg --print-architecture) + if [ "${arch}" = "amd64" ]; then + arch="x64" + fi + if [ "${arch}" != "x64" ] && [ "${arch}" != "arm64" ]; then + echo "Unsupported architecture: ${arch}" >&2 + exit 1 + fi + cli_filename="copilot-linux-${arch}.tar.gz" + echo "Installing GitHub Copilot CLI for ${arch} architecture: ${cli_filename}" + + # Install latest + if [ "${CLI_VERSION}" = "latest" ]; then + download_from_github "https://github.com/github/copilot-cli/releases/latest/download/${cli_filename}" + elif [ "${CLI_VERSION}" = "prerelease" ]; then + + prerelease_version="$(resolve_prerelease_version "$(git ls-remote --tags https://github.com/github/copilot-cli)")" + download_from_github "https://github.com/github/copilot-cli/releases/download/${prerelease_version}/${cli_filename}" + + else + # Install specific version + # Add leading v to version if it doesn't start with v + if [[ ! "${CLI_VERSION}" =~ ^v[0-9] ]]; then + CLI_VERSION="v${CLI_VERSION}" + fi + download_from_github "https://github.com/github/copilot-cli/releases/download/${CLI_VERSION}/${cli_filename}" + fi +} + +# Install the GitHub Copilot CLI +echo "Downloading GitHub Copilot CLI..." + +install_using_github + +# Create a flag file if using "latest" or "prerelease" so the postStartCommand knows to auto-update +if [ "${CLI_VERSION}" = "latest" ] || [ "${CLI_VERSION}" = "prerelease" ]; then + mkdir -p /etc/devcontainer-copilot-cli + touch /etc/devcontainer-copilot-cli/auto-update +fi + diff --git a/src/desktop-lite/NOTES.md b/src/desktop-lite/NOTES.md index ccc4e2e1b..ec9ec71d4 100644 --- a/src/desktop-lite/NOTES.md +++ b/src/desktop-lite/NOTES.md @@ -6,16 +6,26 @@ This feature provides two ways of connecting to the desktop environment it adds. 1. Open the ports view in your tool, select the noVNC port, and click the Globe icon. 1. In the browser that appears, click the **Connect** button and enter the desktop password (`vscode` by default). +To set up the `6080` port from your `devcontainer.json` file, include the following: +```json + "forwardPorts": [6080], + "portsAttributes": { + "6080": { + "label": "desktop" + } + } +``` + You can also connect to the desktop using a [VNC viewer](https://www.realvnc.com/en/connect/download/viewer/). To do so: 1. Connect to the environment from a desktop tool that supports the dev container spec (e.g., VS Code client). -1. Forward the VNC server port (`5901` by default) to your local machine using either the `forwardPorts` property in `devcontainer.json` or the user interface in your tool (e.g., you can press F1 or Ctrl/Cmd+Shift+P and select **Ports: Focus on Ports View** in VS Code to bring it into focus). +1. Forward the VNC server port (`5901` by default) to your local machine using either the `forwardPorts` property in `devcontainer.json` or the user interface in your tool (e.g., you can press F1 or Ctrl/Cmd+Shift+P and select **Ports: Focus on Ports View** in VS Code to bring it into focus). If you are using the [Dev Container CLI](https://github.com/devcontainers/cli), you should instead use the `appPort` property in `devcontainer.json`. 1. Start your VNC Viewer and connect to localhost:5901. Note that you may need to bump up the color depth to 24 bits to see full color. 1. Enter the desktop password (`vscode` by default). ## Customizing Fluxbox -The window manager is installed is [Fluxbox](http://fluxbox.org/). **Right-click** to see the application menu. In addition, any UI-based commands you execute inside the dev container will automatically appear on the desktop. +The window manager installed is [Fluxbox](http://fluxbox.org/). **Right-click** to see the application menu. In addition, any UI-based commands you execute inside the dev container will automatically appear on the desktop. You can customize the desktop using Fluxbox configuration files. The configuration files are located in the `.fluxbox` folder of the home directory of the user you using to connect to the dev container (`$HOME/.fluxbox`). @@ -27,19 +37,19 @@ See the [Fluxbox menu documentation](http://www.fluxbox.org/help/man-fluxbox-men If you run into applications crashing, you may need to increase the size of the shared memory space allocated to your container. For example, this will bump it up to 1 GB in `devcontainer.json`: - ```json - "runArgs": ["--shm-size=1g"] - ``` +```json +"runArgs": ["--shm-size=1g"] +``` - Or using Docker Compose: +Or using Docker Compose: - ```yaml - services: - your-service-here: - # ... - shm_size: '1gb' - # ... - ``` +```yaml +services: + your-service-here: + # ... + shm_size: '1gb' + # ... +``` ## Installing a browser @@ -62,3 +72,10 @@ If you want the full version of **Google Chrome** in the desktop: 2. Chrome sandbox support requires you set up and run as a non-root user. The [`common-utils`](https://github.com/devcontainers/features/tree/main/src/common-utils) script can do this for you, or you [set one up yourself](https://aka.ms/vscode-remote/containers/non-root). Alternatively, you can start Chrome using `google-chrome --no-sandbox` That's it! + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/desktop-lite/README.md b/src/desktop-lite/README.md index 249723a67..a70997405 100644 --- a/src/desktop-lite/README.md +++ b/src/desktop-lite/README.md @@ -7,9 +7,7 @@ Adds a lightweight Fluxbox based desktop to the container that can be accessed u ```json "features": { - "ghcr.io/devcontainers/features/desktop-lite:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/desktop-lite:1": {} } ``` @@ -18,10 +16,10 @@ Adds a lightweight Fluxbox based desktop to the container that can be accessed u | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Currently Unused! | string | latest | -| noVncVersion | NoVnc Version | string | 1.2.0 | -| password | Enter a password for desktop connections | string | vscode | -| webPort | Enter a port for the VNC web client | string | 6080 | -| vncPort | Enter a port for the desktop VNC server | string | 5901 | +| noVncVersion | The noVNC version to use | string | 1.2.0 | +| password | Enter a password for desktop connections. If "noPassword", connections from the local host can be established without entering a password | string | vscode | +| webPort | Enter a port for the VNC web client (noVNC) | string | 6080 | +| vncPort | Enter a port for the desktop VNC server (TigerVNC) | string | 5901 | ## Connecting to the desktop @@ -31,16 +29,26 @@ This feature provides two ways of connecting to the desktop environment it adds. 1. Open the ports view in your tool, select the noVNC port, and click the Globe icon. 1. In the browser that appears, click the **Connect** button and enter the desktop password (`vscode` by default). +To set up the `6080` port from your `devcontainer.json` file, include the following: +```json + "forwardPorts": [6080], + "portsAttributes": { + "6080": { + "label": "desktop" + } + } +``` + You can also connect to the desktop using a [VNC viewer](https://www.realvnc.com/en/connect/download/viewer/). To do so: 1. Connect to the environment from a desktop tool that supports the dev container spec (e.g., VS Code client). -1. Forward the VNC server port (`5901` by default) to your local machine using either the `forwardPorts` property in `devcontainer.json` or the user interface in your tool (e.g., you can press F1 or Ctrl/Cmd+Shift+P and select **Ports: Focus on Ports View** in VS Code to bring it into focus). +1. Forward the VNC server port (`5901` by default) to your local machine using either the `forwardPorts` property in `devcontainer.json` or the user interface in your tool (e.g., you can press F1 or Ctrl/Cmd+Shift+P and select **Ports: Focus on Ports View** in VS Code to bring it into focus). If you are using the [Dev Container CLI](https://github.com/devcontainers/cli), you should instead use the `appPort` property in `devcontainer.json`. 1. Start your VNC Viewer and connect to localhost:5901. Note that you may need to bump up the color depth to 24 bits to see full color. 1. Enter the desktop password (`vscode` by default). ## Customizing Fluxbox -The window manager is installed is [Fluxbox](http://fluxbox.org/). **Right-click** to see the application menu. In addition, any UI-based commands you execute inside the dev container will automatically appear on the desktop. +The window manager installed is [Fluxbox](http://fluxbox.org/). **Right-click** to see the application menu. In addition, any UI-based commands you execute inside the dev container will automatically appear on the desktop. You can customize the desktop using Fluxbox configuration files. The configuration files are located in the `.fluxbox` folder of the home directory of the user you using to connect to the dev container (`$HOME/.fluxbox`). @@ -52,19 +60,19 @@ See the [Fluxbox menu documentation](http://www.fluxbox.org/help/man-fluxbox-men If you run into applications crashing, you may need to increase the size of the shared memory space allocated to your container. For example, this will bump it up to 1 GB in `devcontainer.json`: - ```json - "runArgs": ["--shm-size=1g"] - ``` +```json +"runArgs": ["--shm-size=1g"] +``` - Or using Docker Compose: +Or using Docker Compose: - ```yaml - services: - your-service-here: - # ... - shm_size: '1gb' - # ... - ``` +```yaml +services: + your-service-here: + # ... + shm_size: '1gb' + # ... +``` ## Installing a browser @@ -89,6 +97,13 @@ If you want the full version of **Google Chrome** in the desktop: That's it! +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/desktop-lite/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/desktop-lite/devcontainer-feature.json b/src/desktop-lite/devcontainer-feature.json index a6382a061..891ac6d73 100644 --- a/src/desktop-lite/devcontainer-feature.json +++ b/src/desktop-lite/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "desktop-lite", - "version": "1.0.3", + "version": "1.2.10", "name": "Light-weight Desktop", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/desktop-lite", "description": "Adds a lightweight Fluxbox based desktop to the container that can be accessed using a VNC viewer or the web. GUI-based commands executed from the built-in VS code terminal will open on the desktop automatically.", @@ -16,20 +16,21 @@ "noVncVersion": { "type": "string", "proposals": [ - "1.2.0" + "1.6.0" ], - "default": "1.2.0", - "description": "NoVnc Version" + "default": "1.6.0", + "description": "The noVNC version to use" }, "password": { "type": "string", "proposals": [ "vscode", "codespaces", - "password" + "password", + "noPassword" ], "default": "vscode", - "description": "Enter a password for desktop connections" + "description": "Enter a password for desktop connections. If \"noPassword\", connections from the local host can be established without entering a password" }, "webPort": { "type": "string", @@ -37,7 +38,7 @@ "6080" ], "default": "6080", - "description": "Enter a port for the VNC web client" + "description": "Enter a port for the VNC web client (noVNC)" }, "vncPort": { "type": "string", @@ -45,12 +46,26 @@ "5901" ], "default": "5901", - "description": "Enter a port for the desktop VNC server" + "description": "Enter a port for the desktop VNC server (TigerVNC)" } }, "init": true, "entrypoint": "/usr/local/share/desktop-init.sh", "containerEnv": { "DISPLAY": ":1" - } + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes a lightweight Fluxbox based desktop that can be accessed using a VNC viewer or the web. GUI-based commands executed from the built-in VS Code terminal will open on the desktop automatically." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/desktop-lite/install.sh b/src/desktop-lite/install.sh index b05896c82..0d6d6e31d 100755 --- a/src/desktop-lite/install.sh +++ b/src/desktop-lite/install.sh @@ -7,14 +7,16 @@ # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/desktop-lite.md # Maintainer: The VS Code and Codespaces Teams -NOVNC_VERSION=${NOVNCVERSION:-"1.2.0"} # TODO: Add in a 'latest' auto-detect and swap name to 'version' +NOVNC_VERSION="${NOVNCVERSION:-"1.6.0"}" # TODO: Add in a 'latest' auto-detect and swap name to 'version' VNC_PASSWORD=${PASSWORD:-"vscode"} +if [ "$VNC_PASSWORD" = "noPassword" ]; then + unset VNC_PASSWORD +fi NOVNC_PORT="${WEBPORT:-6080}" VNC_PORT="${VNCPORT:-5901}" -INSTALL_NOVNC=${INSTALL_NOVNC:-"true"} -USERNAME=${USERNAME:-"automatic"} - +INSTALL_NOVNC="${INSTALL_NOVNC:-"true"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" WEBSOCKETIFY_VERSION=0.10.0 @@ -29,7 +31,6 @@ package_list=" fbautostart \ at-spi2-core \ xterm \ - eterm \ nautilus\ mousepad \ seahorse \ @@ -42,7 +43,6 @@ package_list=" libnotify4 \ libnss3 \ libxss1 \ - libasound2 \ xfonts-base \ xfonts-terminus \ fonts-noto \ @@ -63,6 +63,9 @@ package_list_additional=" set -e +# Clean up +rm -rf /var/lib/apt/lists/* + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -151,8 +154,10 @@ copy_fluxbox_config() { apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not @@ -163,6 +168,19 @@ check_packages() { fi } +find_available_package() { + local candidate + local package_name + for package_name in "$@"; do + candidate="$(apt-cache policy "${package_name}" | awk '/Candidate:/ {print $2}')" + if [ -n "${candidate}" ] && [ "${candidate}" != "(none)" ]; then + echo "${package_name}" + return 0 + fi + done + return 1 +} + ########################## # Install starts here # ########################## @@ -172,11 +190,11 @@ export DEBIAN_FRONTEND=noninteractive apt_get_update -# On older Ubuntu, Tilix is in a PPA. on Debian strech its in backports. +# On older Ubuntu, Tilix is in a PPA. on Debian stretch its in backports. if [[ -z $(apt-cache --names-only search ^tilix$) ]]; then . /etc/os-release if [ "${ID}" = "ubuntu" ]; then - apt-get install -y --no-install-recommends apt-transport-https software-properties-common + check_packages apt-transport-https software-properties-common add-apt-repository -y ppa:webupd8team/terminix elif [ "${VERSION_CODENAME}" = "stretch" ]; then echo "deb http://deb.debian.org/debian stretch-backports main" > /etc/apt/sources.list.d/stretch-backports.list @@ -194,6 +212,13 @@ fi # Install X11, fluxbox and VS Code dependencies check_packages ${package_list} +if ! alsa_package="$(find_available_package libasound2t64 libasound2 libasound2-dev)"; then + echo "(!) No supported ALSA package found. Tried: libasound2, libasound2t64, libasound2-dev." >&2 + exit 1 +fi +echo "Installing ${alsa_package} package..." +check_packages "${alsa_package}" + # On newer versions of Ubuntu (22.04), # we need an additional package that isn't provided in earlier versions if ! type vncpasswd > /dev/null 2>&1; then @@ -232,9 +257,7 @@ if [ "${INSTALL_NOVNC}" = "true" ] && [ ! -d "/usr/local/novnc" ]; then rm -f /tmp/websockify-install.zip /tmp/novnc-install.zip # Install noVNC dependencies and use them. - if ! dpkg -s python3-minimal python3-numpy > /dev/null 2>&1; then - apt-get -y install --no-install-recommends python3-minimal python3-numpy - fi + check_packages python3-minimal python3-numpy sed -i -E 's/^python /python3 /' /usr/local/novnc/websockify-${WEBSOCKETIFY_VERSION}/run fi @@ -286,20 +309,20 @@ user_name="${USERNAME}" group_name="$(id -gn ${USERNAME})" LOG=/tmp/container-init.log -export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-"autolaunch:"}" -export DISPLAY="${DISPLAY:-:1}" -export VNC_RESOLUTION="${VNC_RESOLUTION:-1440x768x16}" -export LANG="${LANG:-"en_US.UTF-8"}" -export LANGUAGE="${LANGUAGE:-"en_US.UTF-8"}" +export DBUS_SESSION_BUS_ADDRESS="\${DBUS_SESSION_BUS_ADDRESS:-"autolaunch:"}" +export DISPLAY="\${DISPLAY:-:1}" +export VNC_RESOLUTION="\${VNC_RESOLUTION:-1440x768x16}" +export LANG="\${LANG:-"en_US.UTF-8"}" +export LANGUAGE="\${LANGUAGE:-"en_US.UTF-8"}" # Execute the command it not already running startInBackgroundIfNotRunning() { log "Starting \$1." echo -e "\n** \$(date) **" | sudoIf tee -a /tmp/\$1.log > /dev/null - if ! pidof \$1 > /dev/null; then + if ! pgrep -x \$1 > /dev/null; then keepRunningInBackground "\$@" - while ! pidof \$1 > /dev/null; do + while ! pgrep -x \$1 > /dev/null; do sleep 1 done log "\$1 started." @@ -341,43 +364,73 @@ log() echo -e "[\$(date)] \$@" | sudoIf tee -a \$LOG > /dev/null } +# Function to compare versions +version_gt() { + # returns 0 if \$1 > \$2 + [ "\$(printf '%s\n' "\$2" "\$1" | sort -V | head -n1)" != "\$1" ] +} + + log "** SCRIPT START **" # Start dbus. log 'Running "/etc/init.d/dbus start".' -if [ -f "/var/run/dbus/pid" ] && ! pidof dbus-daemon > /dev/null; then +if [ -f "/var/run/dbus/pid" ] && ! pgrep -x dbus-daemon > /dev/null; then sudoIf rm -f /var/run/dbus/pid fi sudoIf /etc/init.d/dbus start 2>&1 | sudoIf tee -a /tmp/dbus-daemon-system.log > /dev/null -while ! pidof dbus-daemon > /dev/null; do +while ! pgrep -x dbus-daemon > /dev/null; do sleep 1 done # Startup tigervnc server and fluxbox -sudo rm -rf /tmp/.X11-unix /tmp/.X*-lock +sudoIf rm -rf /tmp/.X11-unix /tmp/.X*-lock mkdir -p /tmp/.X11-unix sudoIf chmod 1777 /tmp/.X11-unix sudoIf chown root:\${group_name} /tmp/.X11-unix if [ "\$(echo "\${VNC_RESOLUTION}" | tr -cd 'x' | wc -c)" = "1" ]; then VNC_RESOLUTION=\${VNC_RESOLUTION}x16; fi screen_geometry="\${VNC_RESOLUTION%*x*}" screen_depth="\${VNC_RESOLUTION##*x}" -startInBackgroundIfNotRunning "Xtigervnc" sudoUserIf "tigervncserver \${DISPLAY} -geometry \${screen_geometry} -depth \${screen_depth} -rfbport ${VNC_PORT} -dpi \${VNC_DPI:-96} -localhost -desktop fluxbox -fg -passwd /usr/local/etc/vscode-dev-containers/vnc-passwd" -# Spin up noVNC if installed and not runnning. -if [ -d "/usr/local/novnc" ] && [ "\$(ps -ef | grep /usr/local/novnc/noVNC*/utils/launch.sh | grep -v grep)" = "" ]; then - keepRunningInBackground "noVNC" sudoIf "/usr/local/novnc/noVNC*/utils/launch.sh --listen ${NOVNC_PORT} --vnc localhost:${VNC_PORT}" - log "noVNC started." +# Check if VNC_PASSWORD is set and use the appropriate command +common_options="tigervncserver \${DISPLAY} -geometry \${screen_geometry} -depth \${screen_depth} -rfbport ${VNC_PORT} -dpi \${VNC_DPI:-96} -localhost -desktop fluxbox -fg" + +if [ -n "\${VNC_PASSWORD+x}" ]; then + startInBackgroundIfNotRunning "Xtigervnc" sudoUserIf "\${common_options} -passwd /usr/local/etc/vscode-dev-containers/vnc-passwd" +else + startInBackgroundIfNotRunning "Xtigervnc" sudoUserIf "\${common_options} -SecurityTypes None" +fi + +# Spin up noVNC if installed and not running. +if [ -d "/usr/local/novnc" ]; then + if [ "\$(ps -ef | grep /usr/local/novnc/noVNC*/utils/launch.sh | grep -v grep)" = "" ] && [ "\$(ps -ef | grep /usr/local/novnc/noVNC*/utils/novnc_proxy | grep -v grep)" = "" ]; then + if version_gt "${NOVNC_VERSION}" "1.2.0"; then + keepRunningInBackground "noVNC" sudoIf "/usr/local/novnc/noVNC*/utils/novnc_proxy --listen ${NOVNC_PORT} --vnc localhost:${VNC_PORT}" + log "noVNC started with novnc_proxy." + else + keepRunningInBackground "noVNC" sudoIf "/usr/local/novnc/noVNC*/utils/launch.sh --listen ${NOVNC_PORT} --vnc localhost:${VNC_PORT}" + log "noVNC started with launch.sh." + fi + else + log "noVNC is already running." + fi else - log "noVNC is already running or not installed." + log "noVNC is not installed." fi # Run whatever was passed in -log "Executing \"\$@\"." -exec "\$@" +if [ -n "\$1" ]; then + log "Executing \"\$@\"." + exec "\$@" +else + log "No command provided to execute." +fi log "** SCRIPT EXIT **" EOF -echo "${VNC_PASSWORD}" | vncpasswd -f > /usr/local/etc/vscode-dev-containers/vnc-passwd +if [ -n "${VNC_PASSWORD+x}" ]; then + echo "${VNC_PASSWORD}" | vncpasswd -f > /usr/local/etc/vscode-dev-containers/vnc-passwd +fi chmod +x /usr/local/share/desktop-init.sh /usr/local/bin/set-resolution # Set up fluxbox config @@ -387,15 +440,26 @@ if [ "${USERNAME}" != "root" ]; then chown -R ${USERNAME} /home/${USERNAME}/.Xmodmap /home/${USERNAME}/.fluxbox fi +# Clean up +rm -rf /var/lib/apt/lists/* + +# Determine the message based on whether VNC_PASSWORD is set +if [ -n "${VNC_PASSWORD+x}" ]; then + PASSWORD_MESSAGE="In both cases, use the password \"${VNC_PASSWORD}\" when connecting" +else + PASSWORD_MESSAGE="In both cases, no password is required." +fi + +# Display the message cat << EOF You now have a working desktop! Connect to in one of the following ways: -- Forward port ${NOVNC_PORT} and use a web browser start the noVNC client (recommended) +- Forward port ${NOVNC_PORT} and use a web browser to start the noVNC client (recommended) - Forward port ${VNC_PORT} using VS Code client and connect using a VNC Viewer -In both cases, use the password "${VNC_PASSWORD}" when connecting +${PASSWORD_MESSAGE} (*) Done! diff --git a/src/docker-from-docker/NOTES.md b/src/docker-from-docker/NOTES.md deleted file mode 100644 index 86cdd40df..000000000 --- a/src/docker-from-docker/NOTES.md +++ /dev/null @@ -1,27 +0,0 @@ -## Supporting bind mounts from the workspace folder - -A common question that comes up is how you can use `bind` mounts from the Docker CLI from within the a dev container using this Feature (e.g. via `-v`). The trick is that, since you're actually using the Docker engine sitting outside of the container, the filesystem paths will be different than those in the container. You need to use the **host**'s paths instead. - -> **Note:** The docker-from-docker approach does not currently enable bind mounting locations outside of the workspace folder. - -### GitHub Codespaces - -In GitHub Codespaces, the workspace folder should work with bind mounts by default, so no further action is required. - -### Remote - Containers - -A simple way to do this is to put `${localWorkspaceFolder}` in an environment variable that you then use when doing bind mounts inside the container. - -Add the following to `devcontainer.json`: - -```json -"remoteEnv": { "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" } -``` - -Then reference the env var when running Docker commands from the terminal inside the container. - -```bash -docker run -it --rm -v ${LOCAL_WORKSPACE_FOLDER}:/workspace debian bash -``` - -> **Note:** There is no `${localWorkspaceFolder}` when using the **Clone Repository in Container Volume** command ([info](https://github.com/microsoft/vscode-remote-release/issues/6160#issuecomment-1014701007)). \ No newline at end of file diff --git a/src/docker-from-docker/README.md b/src/docker-from-docker/README.md deleted file mode 100644 index b29af3c02..000000000 --- a/src/docker-from-docker/README.md +++ /dev/null @@ -1,54 +0,0 @@ - -# Docker (Docker-from-Docker) (docker-from-docker) - - - -## Example Usage - -```json -"features": { - "ghcr.io/devcontainers/features/docker-from-docker:1": { - "version": "latest" - } -} -``` - -## Options - -| Options Id | Description | Type | Default Value | -|-----|-----|-----|-----| -| version | Select or enter a Docker/Moby CLI version. (Availability can vary by OS version.) | string | latest | -| moby | Install OSS Moby build instead of Docker CE | boolean | true | -| dockerDashComposeVersion | Compose version to use for docker-compose (v1 or v2) | string | v1 | - -## Supporting bind mounts from the workspace folder - -A common question that comes up is how you can use `bind` mounts from the Docker CLI from within the a dev container using this Feature (e.g. via `-v`). The trick is that, since you're actually using the Docker engine sitting outside of the container, the filesystem paths will be different than those in the container. You need to use the **host**'s paths instead. - -> **Note:** The docker-from-docker approach does not currently enable bind mounting locations outside of the workspace folder. - -### GitHub Codespaces - -In GitHub Codespaces, the workspace folder should work with bind mounts by default, so no further action is required. - -### Remote - Containers - -A simple way to do this is to put `${localWorkspaceFolder}` in an environment variable that you then use when doing bind mounts inside the container. - -Add the following to `devcontainer.json`: - -```json -"remoteEnv": { "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" } -``` - -Then reference the env var when running Docker commands from the terminal inside the container. - -```bash -docker run -it --rm -v ${LOCAL_WORKSPACE_FOLDER}:/workspace debian bash -``` - -> **Note:** There is no `${localWorkspaceFolder}` when using the **Clone Repository in Container Volume** command ([info](https://github.com/microsoft/vscode-remote-release/issues/6160#issuecomment-1014701007)). - ---- - -_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/docker-from-docker/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/docker-from-docker/devcontainer-feature.json b/src/docker-from-docker/devcontainer-feature.json deleted file mode 100644 index 119ef3a8d..000000000 --- a/src/docker-from-docker/devcontainer-feature.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "id": "docker-from-docker", - "version": "1.0.4", - "name": "Docker (Docker-from-Docker)", - "documentationURL": "https://github.com/devcontainers/features/tree/main/src/docker-from-docker", - "descripton": "Re-use the host docker socket, adding the Docker CLI to a container. Feature invokes a script to enable using a forwarded Docker socket within a container to run Docker commands.", - "options": { - "version": { - "type": "string", - "proposals": [ - "latest", - "none", - "20.10" - ], - "default": "latest", - "description": "Select or enter a Docker/Moby CLI version. (Availability can vary by OS version.)" - }, - "moby": { - "type": "boolean", - "default": true, - "description": "Install OSS Moby build instead of Docker CE" - }, - "dockerDashComposeVersion": { - "type": "string", - "enum": [ - "v1", - "v2" - ], - "default": "v1", - "description": "Compose version to use for docker-compose (v1 or v2)" - } - }, - "entrypoint": "/usr/local/share/docker-init.sh", - "containerEnv": { - "DOCKER_BUILDKIT": "1" - }, - "customizations": { - "vscode": { - "extensions": [ - "ms-azuretools.vscode-docker" - ] - } - }, - "mounts": [ - { - "source": "/var/run/docker.sock", - "target": "/var/run/docker-host.sock", - "type": "bind" - } - ] -} diff --git a/src/docker-from-docker/install.sh b/src/docker-from-docker/install.sh deleted file mode 100755 index 312e283ba..000000000 --- a/src/docker-from-docker/install.sh +++ /dev/null @@ -1,349 +0,0 @@ -#!/usr/bin/env bash -#------------------------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. -#------------------------------------------------------------------------------------------------------------- -# -# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker.md -# Maintainer: The VS Code and Codespaces Teams - -DOCKER_VERSION=${VERSION:-"latest"} -USE_MOBY=${MOBY:-"true"} -DOCKER_DASH_COMPOSE_VERSION=${DOCKERDASHCOMPOSEVERSION:-"v1"} # v1 or v2 - -ENABLE_NONROOT_DOCKER=${ENABLE_NONROOT_DOCKER:-"true"} -SOURCE_SOCKET=${SOURCE_SOCKET:-"/var/run/docker-host.sock"} -TARGET_SOCKET=${TARGET_SOCKET:-"/var/run/docker.sock"} -USERNAME=${USERNAME:-"automatic"} - -MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" -DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES="buster bullseye bionic focal jammy" -DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES="buster bullseye bionic focal hirsute impish jammy" - -set -e - -if [ "$(id -u)" -ne 0 ]; then - echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' - exit 1 -fi - -# Determine the appropriate non-root user -if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u ${CURRENT_USER} > /dev/null 2>&1; then - USERNAME=${CURRENT_USER} - break - fi - done - if [ "${USERNAME}" = "" ]; then - USERNAME=root - fi -elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then - USERNAME=root -fi - -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} - -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y -} - -# Checks if packages are installed and installs them if not -check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" - fi -} - -# Figure out correct version of a three part version number is not passed -find_version_from_git_tags() { - local variable_name=$1 - local requested_version=${!variable_name} - if [ "${requested_version}" = "none" ]; then return; fi - local repository=$2 - local prefix=${3:-"tags/v"} - local separator=${4:-"."} - local last_part_optional=${5:-"false"} - if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then - local escaped_separator=${separator//./\\.} - local last_part - if [ "${last_part_optional}" = "true" ]; then - last_part="(${escaped_separator}[0-9]+)?" - else - last_part="${escaped_separator}[0-9]+" - fi - local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" - local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" - if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then - declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" - else - set +e - declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" - set -e - fi - fi - if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then - echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 - exit 1 - fi - echo "${variable_name}=${!variable_name}" -} - -# Ensure apt is in non-interactive to avoid prompts -export DEBIAN_FRONTEND=noninteractive - -# Install dependencies -check_packages apt-transport-https curl ca-certificates gnupg2 dirmngr -if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install git -fi - -# Source /etc/os-release to get OS info -. /etc/os-release -# Fetch host/container arch. -architecture="$(dpkg --print-architecture)" - -# Check if distro is suppported -if [ "${USE_MOBY}" = "true" ]; then - # 'get_common_setting' allows attribute to be updated remotely - get_common_setting DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES - if [[ "${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then - err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS distribution" - err "Support distributions include: ${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" - exit 1 - fi - echo "Distro codename '${VERSION_CODENAME}' matched filter '${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}'" -else - get_common_setting DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES - if [[ "${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then - err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, please choose a compatible OS distribution" - err "Support distributions include: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" - exit 1 - fi - echo "Distro codename '${VERSION_CODENAME}' matched filter '${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}'" -fi - -# Set up the necessary apt repos (either Microsoft's or Docker's) -if [ "${USE_MOBY}" = "true" ]; then - - cli_package_name="moby-cli" - - # Import key safely and import Microsoft apt repo - get_common_setting MICROSOFT_GPG_KEYS_URI - curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg - echo "deb [arch=${architecture} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list -else - # Name of proprietary engine package - cli_package_name="docker-ce-cli" - - # Import key safely and import Docker apt repo - curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor > /usr/share/keyrings/docker-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list -fi - -# Refresh apt lists -apt-get update - -# Soft version matching for CLI -if [ "${DOCKER_VERSION}" = "latest" ] || [ "${DOCKER_VERSION}" = "lts" ] || [ "${DOCKER_VERSION}" = "stable" ]; then - # Empty, meaning grab whatever "latest" is in apt repo - cli_version_suffix="" -else - # Fetch a valid version from the apt-cache (eg: the Microsoft repo appends +azure, breakfix, etc...) - docker_version_dot_escaped="${DOCKER_VERSION//./\\.}" - docker_version_dot_plus_escaped="${docker_version_dot_escaped//+/\\+}" - # Regex needs to handle debian package version number format: https://www.systutorials.com/docs/linux/man/5-deb-version/ - docker_version_regex="^(.+:)?${docker_version_dot_plus_escaped}([\\.\\+ ~:-]|$)" - set +e # Don't exit if finding version fails - will handle gracefully - cli_version_suffix="=$(apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${docker_version_regex}")" - set -e - if [ -z "${cli_version_suffix}" ] || [ "${cli_version_suffix}" = "=" ]; then - echo "(!) No full or partial Docker / Moby version match found for \"${DOCKER_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:" - apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' - exit 1 - fi - echo "cli_version_suffix ${cli_version_suffix}" -fi - -# Install Docker / Moby CLI if not already installed -if type docker > /dev/null 2>&1; then - echo "Docker / Moby CLI already installed." -else - if [ "${USE_MOBY}" = "true" ]; then - apt-get -y install --no-install-recommends moby-cli${cli_version_suffix} moby-buildx - apt-get -y install --no-install-recommends moby-compose || echo "(*) Package moby-compose (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." - else - apt-get -y install --no-install-recommends docker-ce-cli${cli_version_suffix} - fi -fi - -# Install Docker Compose if not already installed and is on a supported architecture -if type docker-compose > /dev/null 2>&1; then - echo "Docker Compose already installed." -else - TARGET_COMPOSE_ARCH="$(uname -m)" - if [ "${TARGET_COMPOSE_ARCH}" = "amd64" ]; then - TARGET_COMPOSE_ARCH="x86_64" - fi - if [ "${TARGET_COMPOSE_ARCH}" != "x86_64" ]; then - # Use pip to get a version that runns on this architecture - if ! dpkg -s python3-minimal python3-pip libffi-dev python3-venv > /dev/null 2>&1; then - apt_get_update - apt-get -y install python3-minimal python3-pip libffi-dev python3-venv - fi - export PIPX_HOME=/usr/local/pipx - mkdir -p ${PIPX_HOME} - export PIPX_BIN_DIR=/usr/local/bin - export PYTHONUSERBASE=/tmp/pip-tmp - export PIP_CACHE_DIR=/tmp/pip-tmp/cache - pipx_bin=pipx - if ! type pipx > /dev/null 2>&1; then - pip3 install --disable-pip-version-check --no-cache-dir --user pipx - pipx_bin=/tmp/pip-tmp/bin/pipx - fi - ${pipx_bin} install --pip-args '--no-cache-dir --force-reinstall' docker-compose - rm -rf /tmp/pip-tmp - else - compose_v1_version="1" - find_version_from_git_tags compose_v1_version "https://github.com/docker/compose" "tags/" - echo "(*) Installing docker-compose ${compose_v1_version}..." - curl -fsSL "https://github.com/docker/compose/releases/download/${compose_v1_version}/docker-compose-Linux-x86_64" -o /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose - fi -fi - -# Install docker-compose switch if not already installed - https://github.com/docker/compose-switch#manual-installation -current_v1_compose_path="$(which docker-compose)" -target_v1_compose_path="$(dirname "${current_v1_compose_path}")/docker-compose-v1" -if ! type compose-switch > /dev/null 2>&1; then - echo "(*) Installing compose-switch..." - compose_switch_version="latest" - find_version_from_git_tags compose_switch_version "https://github.com/docker/compose-switch" - curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${architecture}" -o /usr/local/bin/compose-switch - chmod +x /usr/local/bin/compose-switch - # TODO: Verify checksum once available: https://github.com/docker/compose-switch/issues/11 - - # Setup v1 CLI as alternative in addition to compose-switch (which maps to v2) - mv "${current_v1_compose_path}" "${target_v1_compose_path}" - update-alternatives --install /usr/local/bin/docker-compose docker-compose /usr/local/bin/compose-switch 99 - update-alternatives --install /usr/local/bin/docker-compose docker-compose "${target_v1_compose_path}" 1 -fi -if [ "${DOCKER_DASH_COMPOSE_VERSION}" = "v1" ]; then - update-alternatives --set docker-compose "${target_v1_compose_path}" -else - update-alternatives --set docker-compose /usr/local/bin/compose-switch -fi - -# If init file already exists, exit -if [ -f "/usr/local/share/docker-init.sh" ]; then - exit 0 -fi -echo "docker-init doesnt exist, adding..." - -# By default, make the source and target sockets the same -if [ "${SOURCE_SOCKET}" != "${TARGET_SOCKET}" ]; then - touch "${SOURCE_SOCKET}" - ln -s "${SOURCE_SOCKET}" "${TARGET_SOCKET}" -fi - -# Add a stub if not adding non-root user access, user is root -if [ "${ENABLE_NONROOT_DOCKER}" = "false" ] || [ "${USERNAME}" = "root" ]; then - echo -e '#!/usr/bin/env bash\nexec "$@"' > /usr/local/share/docker-init.sh - chmod +x /usr/local/share/docker-init.sh - exit 0 -fi - -# Setup a docker group in the event the docker socket's group is not root -if ! grep -qE '^docker:' /etc/group; then - groupadd --system docker -fi -usermod -aG docker "${USERNAME}" -DOCKER_GID="$(grep -oP '^docker:x:\K[^:]+' /etc/group)" - -# If enabling non-root access and specified user is found, setup socat and add script -chown -h "${USERNAME}":root "${TARGET_SOCKET}" -if ! dpkg -s socat > /dev/null 2>&1; then - apt_get_update - apt-get -y install socat -fi -tee /usr/local/share/docker-init.sh > /dev/null \ -<< EOF -#!/usr/bin/env bash -#------------------------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. -#------------------------------------------------------------------------------------------------------------- - -set -e - -SOCAT_PATH_BASE=/tmp/vscr-docker-from-docker -SOCAT_LOG=\${SOCAT_PATH_BASE}.log -SOCAT_PID=\${SOCAT_PATH_BASE}.pid - -# Wrapper function to only use sudo if not already root -sudoIf() -{ - if [ "\$(id -u)" -ne 0 ]; then - sudo "\$@" - else - "\$@" - fi -} - -# Log messages -log() -{ - echo -e "[\$(date)] \$@" | sudoIf tee -a \${SOCAT_LOG} > /dev/null -} - -echo -e "\n** \$(date) **" | sudoIf tee -a \${SOCAT_LOG} > /dev/null -log "Ensuring ${USERNAME} has access to ${SOURCE_SOCKET} via ${TARGET_SOCKET}" - -# If enabled, try to update the docker group with the right GID. If the group is root, -# fall back on using socat to forward the docker socket to another unix socket so -# that we can set permissions on it without affecting the host. -if [ "${ENABLE_NONROOT_DOCKER}" = "true" ] && [ "${SOURCE_SOCKET}" != "${TARGET_SOCKET}" ] && [ "${USERNAME}" != "root" ] && [ "${USERNAME}" != "0" ]; then - SOCKET_GID=\$(stat -c '%g' ${SOURCE_SOCKET}) - if [ "\${SOCKET_GID}" != "0" ] && [ "\${SOCKET_GID}" != "${DOCKER_GID}" ] && ! grep -E ".+:x:\${SOCKET_GID}" /etc/group; then - sudoIf groupmod --gid "\${SOCKET_GID}" docker - else - # Enable proxy if not already running - if [ ! -f "\${SOCAT_PID}" ] || ! ps -p \$(cat \${SOCAT_PID}) > /dev/null; then - log "Enabling socket proxy." - log "Proxying ${SOURCE_SOCKET} to ${TARGET_SOCKET} for vscode" - sudoIf rm -rf ${TARGET_SOCKET} - (sudoIf socat UNIX-LISTEN:${TARGET_SOCKET},fork,mode=660,user=${USERNAME} UNIX-CONNECT:${SOURCE_SOCKET} 2>&1 | sudoIf tee -a \${SOCAT_LOG} > /dev/null & echo "\$!" | sudoIf tee \${SOCAT_PID} > /dev/null) - else - log "Socket proxy already running." - fi - fi - log "Success" -fi - -# Execute whatever commands were passed in (if any). This allows us -# to set this script to ENTRYPOINT while still executing the default CMD. -set +e -exec "\$@" -EOF -chmod +x /usr/local/share/docker-init.sh -chown ${USERNAME}:root /usr/local/share/docker-init.sh -echo "Done!" diff --git a/src/docker-in-docker/NOTES.md b/src/docker-in-docker/NOTES.md new file mode 100644 index 000000000..693afd41f --- /dev/null +++ b/src/docker-in-docker/NOTES.md @@ -0,0 +1,31 @@ +## Limitations + +This docker-in-docker Dev Container Feature is roughly based on the [official docker-in-docker wrapper script](https://github.com/moby/moby/blob/master/hack/dind) that is part of the [Moby project](https://mobyproject.org/). With this in mind: +* As the name implies, the Feature is expected to work when the host is running Docker (or the OSS Moby container engine it is built on). It may be possible to get running in other container engines, but it has not been tested with them. +* The host and the container must be running on the same chip architecture. You will not be able to use it with an emulated x86 image with Docker Desktop on an Apple Silicon Mac, like in this example: + ``` + FROM --platform=linux/amd64 mcr.microsoft.com/devcontainers/typescript-node:24 + ``` + See [Issue #219](https://github.com/devcontainers/features/issues/219) for more details. + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +Debian Trixie (13) does not include moby-cli and related system packages, so the feature cannot install with "moby": "true". To use this feature on Trixie, please set "moby": "false" or choose a different base image (for example, Ubuntu 24.04). + +Ubuntu 26.04 (Resolute) does not currently have moby packages available, so the feature cannot install with "moby": "true". To use this feature on Resolute, please set "moby": "false". Additionally, the kernel on Ubuntu 26.04 no longer supports legacy iptables NAT tables, so the feature automatically falls back to `iptables-nft` when `iptables-legacy` is not functional. + +`bash` is required to execute the `install.sh` script. + +## Persisted state + +This Feature mounts two named Docker volumes into the dev container so that the daemons have writable, non-overlay storage for their state: + +* `dind-var-lib-docker-${devcontainerId}` → `/var/lib/docker` +* `dind-var-lib-containerd-${devcontainerId}` → `/var/lib/containerd` + +The `/var/lib/containerd` mount is required when the dev container's root filesystem is itself an overlayfs mount (the default in Kubernetes / containerd-backed hosts, GitHub Codespaces, and Docker with the containerd image store enabled). Without it, the standalone `containerd` started by this Feature would place its overlayfs snapshotter data on an overlay rootfs, causing overlay-on-overlay mounts to fail with `invalid argument`. See [issue #1639](https://github.com/devcontainers/features/issues/1639) for background. + +Because both volumes are scoped to `${devcontainerId}`, each dev container gets its own state and rebuilds preserve images and snapshots. Removing the dev container does not automatically remove these volumes; clean them up with `docker volume rm` if you want to reclaim space. diff --git a/src/docker-in-docker/README.md b/src/docker-in-docker/README.md index 925b8166b..6bba801a8 100644 --- a/src/docker-in-docker/README.md +++ b/src/docker-in-docker/README.md @@ -7,9 +7,7 @@ Create child containers *inside* a container, independent from the host's docker ```json "features": { - "ghcr.io/devcontainers/features/docker-in-docker:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/docker-in-docker:4": {} } ``` @@ -19,9 +17,37 @@ Create child containers *inside* a container, independent from the host's docker |-----|-----|-----|-----| | version | Select or enter a Docker/Moby Engine version. (Availability can vary by OS version.) | string | latest | | moby | Install OSS Moby build instead of Docker CE | boolean | true | -| dockerDashComposeVersion | Default version of Docker Compose (v1 or v2) | string | v1 | +| mobyBuildxVersion | Install a specific version of moby-buildx when using Moby | string | latest | +| dockerDashComposeVersion | Default version of Docker Compose (v1, v2, latest or none) | string | latest | | azureDnsAutoDetection | Allow automatically setting the dockerd DNS server when the installation script detects it is running in Azure | boolean | true | +| dockerDefaultAddressPool | Define default address pools for Docker networks. e.g. base=192.168.0.0/16,size=24 | string | - | +| installDockerBuildx | Install Docker Buildx | boolean | true | +| installDockerComposeSwitch | Install Compose Switch (provided docker compose is available) which is a replacement to the Compose V1 docker-compose (python) executable. It translates the command line into Compose V2 docker compose then runs the latter. | boolean | false | +| disableIp6tables | Disable ip6tables (this option is only applicable for Docker versions 27 and greater) | boolean | false | +| iptablesSwitchAtRuntime | If true, the iptables alternative is selected at container start (inside docker-init.sh) instead of at image build time. Useful when the desired iptables backend depends on the host kernel at runtime rather than at build time. | boolean | true | +## Customizations + +### VS Code Extensions + +- `ms-azuretools.vscode-containers` + +## Limitations + +This docker-in-docker Dev Container Feature is roughly based on the [official docker-in-docker wrapper script](https://github.com/moby/moby/blob/master/hack/dind) that is part of the [Moby project](https://mobyproject.org/). With this in mind: +* As the name implies, the Feature is expected to work when the host is running Docker (or the OSS Moby container engine it is built on). It may be possible to get running in other container engines, but it has not been tested with them. +* The host and the container must be running on the same chip architecture. You will not be able to use it with an emulated x86 image with Docker Desktop on an Apple Silicon Mac, like in this example: + ``` + FROM --platform=linux/amd64 mcr.microsoft.com/devcontainers/typescript-node:16 + ``` + See [Issue #219](https://github.com/devcontainers/features/issues/219) for more details. + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/docker-in-docker/devcontainer-feature.json b/src/docker-in-docker/devcontainer-feature.json index 8538b46d5..2710a2e32 100644 --- a/src/docker-in-docker/devcontainer-feature.json +++ b/src/docker-in-docker/devcontainer-feature.json @@ -1,57 +1,105 @@ { - "id": "docker-in-docker", - "version": "1.0.5", - "name": "Docker (Docker-in-Docker)", - "documentationURL": "https://github.com/devcontainers/features/tree/main/src/docker-in-docker", - "description": "Create child containers *inside* a container, independent from the host's docker instance. Installs Docker extension in the container along with needed CLIs.", - "options": { - "version": { - "type": "string", - "proposals": [ - "latest", - "none", - "20.10" - ], - "default": "latest", - "description": "Select or enter a Docker/Moby Engine version. (Availability can vary by OS version.)" - }, - "moby": { - "type": "boolean", - "default": true, - "description": "Install OSS Moby build instead of Docker CE" - }, - "dockerDashComposeVersion": { - "type": "string", - "enum": [ - "v1", - "v2" - ], - "default": "v1", - "description": "Default version of Docker Compose (v1 or v2)" - }, - "azureDnsAutoDetection": { - "type": "boolean", - "default": true, - "description": "Allow automatically setting the dockerd DNS server when the installation script detects it is running in Azure" - } - }, - "entrypoint": "/usr/local/share/docker-init.sh", - "privileged": true, - "containerEnv": { - "DOCKER_BUILDKIT": "1" - }, - "customizations": { - "vscode": { - "extensions": [ - "ms-azuretools.vscode-docker" - ] - } - }, - "mounts": [ - { - "source": "dind-var-lib-docker", - "target": "/var/lib/docker", - "type": "volume" - } - ] + "id": "docker-in-docker", + "version": "4.0.0", + "name": "Docker (Docker-in-Docker)", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/docker-in-docker", + "description": "Create child containers *inside* a container, independent from the host's docker instance. Installs Docker extension in the container along with needed CLIs.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "none", + "20.10" + ], + "default": "latest", + "description": "Select or enter a Docker/Moby Engine version. (Availability can vary by OS version.)" + }, + "moby": { + "type": "boolean", + "default": true, + "description": "Install OSS Moby build instead of Docker CE" + }, + "mobyBuildxVersion": { + "type": "string", + "default": "latest", + "description": "Install a specific version of moby-buildx when using Moby" + }, + "dockerDashComposeVersion": { + "type": "string", + "enum": [ + "none", + "latest", + "v1", + "v2" + ], + "default": "latest", + "description": "Default version of Docker Compose (v1, v2, latest or none)" + }, + "azureDnsAutoDetection": { + "type": "boolean", + "default": true, + "description": "Allow automatically setting the dockerd DNS server when the installation script detects it is running in Azure" + }, + "dockerDefaultAddressPool": { + "type": "string", + "default": "", + "proposals": [], + "description": "Define default address pools for Docker networks. e.g. base=192.168.0.0/16,size=24" + }, + "installDockerBuildx": { + "type": "boolean", + "default": true, + "description": "Install Docker Buildx" + }, + "installDockerComposeSwitch": { + "type": "boolean", + "default": false, + "description": "Install Compose Switch (provided docker compose is available) which is a replacement to the Compose V1 docker-compose (python) executable. It translates the command line into Compose V2 docker compose then runs the latter." + }, + "disableIp6tables": { + "type": "boolean", + "default": false, + "description": "Disable ip6tables (this option is only applicable for Docker versions 27 and greater)" + }, + "iptablesSwitchAtRuntime": { + "type": "boolean", + "default": true, + "description": "If true, the iptables alternative is selected at container start (inside docker-init.sh) instead of at image build time. Useful when the desired iptables backend depends on the host kernel at runtime rather than at build time." + } + }, + "entrypoint": "/usr/local/share/docker-init.sh", + "privileged": true, + "containerEnv": { + "DOCKER_BUILDKIT": "1" + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.vscode-containers" + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the Docker CLI (`docker`) pre-installed and available on the `PATH` for running and managing containers using a dedicated Docker daemon running inside the dev container." + } + ] + } + } + }, + "mounts": [ + { + "source": "dind-var-lib-docker-${devcontainerId}", + "target": "/var/lib/docker", + "type": "volume" + }, + { + "source": "dind-var-lib-containerd-${devcontainerId}", + "target": "/var/lib/containerd", + "type": "volume" + } + ], + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/docker-in-docker/install.sh b/src/docker-in-docker/install.sh index afcdc1afc..dbef9ae32 100755 --- a/src/docker-in-docker/install.sh +++ b/src/docker-in-docker/install.sh @@ -5,24 +5,31 @@ #------------------------------------------------------------------------------------------------------------- # # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker-in-docker.md -# Maintainer: The VS Code and Codespaces Teams +# Maintainer: The Dev Container spec maintainers -DOCKER_VERSION=${VERSION:-"latest"} # The Docker/Moby Engine + CLI should match in version -USE_MOBY=${MOBY:-"true"} -DOCKER_DASH_COMPOSE_VERSION=${DOCKERDASHCOMPOSEVERSION:-"v1"} # v1 or v2 -AZURE_DNS_AUTO_DETECTION=${AZUREDNSAUTODETECTION:-"true"} - -ENABLE_NONROOT_DOCKER=${ENABLE_NONROOT_DOCKER:-"true"} -USERNAME=${USERNAME:-"automatic"} - +DOCKER_VERSION="${VERSION:-"latest"}" # The Docker/Moby Engine + CLI should match in version +USE_MOBY="${MOBY:-"true"}" +MOBY_BUILDX_VERSION="${MOBYBUILDXVERSION:-"latest"}" +DOCKER_DASH_COMPOSE_VERSION="${DOCKERDASHCOMPOSEVERSION:-"latest"}" #v1, v2, latest or none +AZURE_DNS_AUTO_DETECTION="${AZUREDNSAUTODETECTION:-"true"}" +DOCKER_DEFAULT_ADDRESS_POOL="${DOCKERDEFAULTADDRESSPOOL:-""}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +INSTALL_DOCKER_BUILDX="${INSTALLDOCKERBUILDX:-"true"}" +INSTALL_DOCKER_COMPOSE_SWITCH="${INSTALLDOCKERCOMPOSESWITCH:-"false"}" MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" -DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES="buster bullseye bionic focal jammy" -DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES="buster bullseye bionic focal hirsute impish jammy" +MICROSOFT_GPG_KEYS_ROLLING_URI="https://packages.microsoft.com/keys/microsoft-rolling.asc" +DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES="trixie bookworm buster bullseye bionic focal jammy noble" +DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES="trixie bookworm buster bullseye bionic focal hirsute impish jammy noble resolute" +DISABLE_IP6_TABLES="${DISABLEIP6TABLES:-false}" +IPTABLES_SWITCH_AT_RUNTIME="${IPTABLESSWITCHATRUNTIME:-true}" # Default: Exit on any failure. set -e +# Clean up +rm -rf /var/lib/apt/lists/* + # Setup STDERR. err() { echo "(!) $*" >&2 @@ -55,33 +62,45 @@ elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then USERNAME=root fi -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} - -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y +# Package manager update function +pkg_mgr_update() { + case ${ADJUSTED_ID} in + debian) + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi + ;; + rhel) + if [ ${PKG_MGR_CMD} = "microdnf" ]; then + cache_check_dir="/var/cache/yum" + else + cache_check_dir="/var/cache/${PKG_MGR_CMD}" + fi + if [ "$(ls ${cache_check_dir}/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} makecache ..." + ${PKG_MGR_CMD} makecache + fi + ;; + esac } # Checks if packages are installed and installs them if not check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" - fi + case ${ADJUSTED_ID} in + debian) + if ! dpkg -s "$@" > /dev/null 2>&1; then + pkg_mgr_update + apt-get -y install --no-install-recommends "$@" + fi + ;; + rhel) + if ! rpm -q "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${PKG_MGR_CMD} -y install "$@" + fi + ;; + esac } # Figure out correct version of a three part version number is not passed @@ -92,7 +111,7 @@ find_version_from_git_tags() { local repository=$2 local prefix=${3:-"tags/v"} local separator=${4:-"."} - local last_part_optional=${5:-"false"} + local last_part_optional=${5:-"false"} if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then local escaped_separator=${separator//./\\.} local last_part @@ -118,6 +137,77 @@ find_version_from_git_tags() { echo "${variable_name}=${!variable_name}" } +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} + +# Function to fetch the version released prior to the latest version +get_previous_version() { + local url=$1 + local repo_url=$2 + local variable_name=$3 + prev_version=${!variable_name} + + output=$(curl -s "$repo_url"); + if echo "$output" | jq -e 'type == "object"' > /dev/null; then + message=$(echo "$output" | jq -r '.message') + + if [[ $message == "API rate limit exceeded"* ]]; then + echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}" + echo -e "\nAttempting to find latest version using GitHub tags." + find_prev_version_from_git_tags prev_version "$url" "tags/v" + declare -g ${variable_name}="${prev_version}" + fi + elif echo "$output" | jq -e 'type == "array"' > /dev/null; then + echo -e "\nAttempting to find latest version using GitHub Api." + version=$(echo "$output" | jq -r '.[1].tag_name') + declare -g ${variable_name}="${version#v}" + fi + echo "${variable_name}=${!variable_name}" +} + +get_github_api_repo_url() { + local url=$1 + echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases" +} + ########################################### # Start docker-in-docker installation ########################################### @@ -125,70 +215,273 @@ find_version_from_git_tags() { # Ensure apt is in non-interactive to avoid prompts export DEBIAN_FRONTEND=noninteractive - # Source /etc/os-release to get OS info . /etc/os-release -# Fetch host/container arch. -architecture="$(dpkg --print-architecture)" -# Check if distro is suppported -if [ "${USE_MOBY}" = "true" ]; then - # 'get_common_setting' allows attribute to be updated remotely - get_common_setting DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES - if [[ "${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then - err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS distribution" - err "Support distributions include: ${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" +# Determine adjusted ID and package manager +if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" + PKG_MGR_CMD="apt-get" + # Use dpkg for Debian-based systems + architecture="$(dpkg --print-architecture 2>/dev/null || uname -m)" +elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "azurelinux" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"azurelinux"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + # Determine the appropriate package manager for RHEL-based systems + for pkg_mgr in tdnf dnf microdnf yum; do + if command -v "$pkg_mgr" >/dev/null 2>&1; then + PKG_MGR_CMD="$pkg_mgr" + break + fi + done + + if [ -z "${PKG_MGR_CMD}" ]; then + err "Unable to find a supported package manager (tdnf, dnf, microdnf, yum)" exit 1 fi - echo "Distro codename '${VERSION_CODENAME}' matched filter '${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}'" + + architecture="$(rpm --eval '%{_arch}' 2>/dev/null || uname -m)" else - get_common_setting DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES - if [[ "${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then - err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, please choose a compatible OS distribution" - err "Support distributions include: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" - exit 1 + err "Linux distro ${ID} not supported." + exit 1 +fi + +# Azure Linux specific setup +if [ "${ID}" = "azurelinux" ]; then + VERSION_CODENAME="azurelinux${VERSION_ID}" +fi + +# Prevent attempting to install Moby on Debian trixie/resolute (packages removed) +if [ "${USE_MOBY}" = "true" ] && [ "${ADJUSTED_ID}" = "debian" ] && ([ "${VERSION_CODENAME}" = "trixie" ] || [ "${VERSION_CODENAME}" = "resolute" ]); then + err "The 'moby' option is not supported on ${ID} '${VERSION_CODENAME}' because 'moby-cli' and related system packages are not available in that distribution." + err "To continue, either set the feature option '\"moby\": false' or use a different base image." + exit 1 +fi + +# Check if distro is supported +if [ "${USE_MOBY}" = "true" ]; then + if [ "${ADJUSTED_ID}" = "debian" ]; then + if [[ "${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then + err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS distribution" + err "Supported distributions include: ${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" + exit 1 + fi + echo "(*) ${VERSION_CODENAME} is supported for Moby installation - setting up Microsoft repository" + elif [ "${ADJUSTED_ID}" = "rhel" ]; then + if [ "${ID}" = "azurelinux" ] || [ "${ID}" = "mariner" ]; then + echo " (*) ${ID} ${VERSION_ID} detected - using Microsoft repositories for Moby packages" + else + echo "RHEL-based system (${ID}) detected - Moby packages may require additional configuration" + fi + fi +else + if [ "${ADJUSTED_ID}" = "debian" ]; then + if [[ "${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then + err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, please choose a compatible OS distribution" + err "Supported distributions include: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" + exit 1 + fi + echo "(*) ${VERSION_CODENAME} is supported for Docker CE installation (supported: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}) - setting up Docker repository" + elif [ "${ADJUSTED_ID}" = "rhel" ]; then + + echo "RHEL-based system (${ID}) detected - using Docker CE packages" fi - echo "Distro codename '${VERSION_CODENAME}' matched filter '${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}'" fi -# Install dependencies -check_packages apt-transport-https curl ca-certificates pigz iptables gnupg2 dirmngr -if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install git +# Install base dependencies +# Note: erofs-utils provides mkfs.erofs, required by containerd >= 2.3.x snapshotter +# to avoid "failed to check mkfs.erofs availability" errors at dockerd startup +# (see https://github.com/devcontainers/features/issues/1642). +base_packages="curl ca-certificates pigz iptables gnupg2 wget jq" +case ${ADJUSTED_ID} in + debian) + check_packages apt-transport-https $base_packages dirmngr erofs-utils + ;; + rhel) + check_packages $base_packages tar gawk shadow-utils policycoreutils procps-ng systemd-libs systemd-devel + + ;; +esac + +# Install git if not already present +if ! command -v git >/dev/null 2>&1; then + check_packages git fi -# Swap to legacy iptables for compatibility -if type iptables-legacy > /dev/null 2>&1; then - update-alternatives --set iptables /usr/sbin/iptables-legacy - update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy +# Update CA certificates to ensure HTTPS connections work properly +# This is especially important for Ubuntu 24.04 (Noble) and Debian Trixie +# Only run for Debian-based systems (RHEL uses update-ca-trust instead) +if [ "${ADJUSTED_ID}" = "debian" ] && command -v update-ca-certificates > /dev/null 2>&1; then + update-ca-certificates fi +# Swap to legacy iptables for compatibility (Debian only) - install-time path. +# When IPTABLES_SWITCH_AT_RUNTIME=true the same logic is emitted into +# docker-init.sh and runs at container start instead. +if [ "${IPTABLES_SWITCH_AT_RUNTIME}" != "true" ] && [ "${ADJUSTED_ID}" = "debian" ]; then + # On distros where legacy iptables is no longer kernel-supported (e.g. Ubuntu 26.04 / resolute), + # prefer iptables-nft. Otherwise prefer legacy for backward compatibility. + use_nft=false + case "${VERSION_CODENAME}" in + resolute) use_nft=true ;; + esac + if [ "${use_nft}" = "true" ] && type iptables-nft > /dev/null 2>&1; then + echo "(*) Setting iptables alternatives to nft for better compatibility with newer kernels" + update-alternatives --set iptables /usr/sbin/iptables-nft || true + update-alternatives --set ip6tables /usr/sbin/ip6tables-nft || true + elif type iptables-legacy > /dev/null 2>&1 && iptables-legacy -L > /dev/null 2>&1; then + echo "(*) Setting iptables alternatives to legacy for better compatibility with Docker and older kernels" + update-alternatives --set iptables /usr/sbin/iptables-legacy || true + update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy || true + elif type iptables-nft > /dev/null 2>&1; then + echo "(*) Setting iptables alternatives to nft for better compatibility with newer kernels for non resolute" + update-alternatives --set iptables /usr/sbin/iptables-nft || true + update-alternatives --set ip6tables /usr/sbin/ip6tables-nft || true + fi +fi -# Set up the necessary apt repos (either Microsoft's or Docker's) +# Set up the necessary repositories if [ "${USE_MOBY}" = "true" ]; then - # Name of open source engine/cli engine_package_name="moby-engine" cli_package_name="moby-cli" - # Import key safely and import Microsoft apt repo - get_common_setting MICROSOFT_GPG_KEYS_URI - curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg - echo "deb [arch=${architecture} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list + case ${ADJUSTED_ID} in + debian) + # Import key safely and import Microsoft apt repo + { + curl -sSL ${MICROSOFT_GPG_KEYS_URI} + curl -sSL ${MICROSOFT_GPG_KEYS_ROLLING_URI} + } | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg + echo "deb [arch=${architecture} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list + ;; + rhel) + echo "(*) ${ID} detected - checking for Moby packages..." + + # Check if moby packages are available in default repos + if ${PKG_MGR_CMD} list available moby-engine >/dev/null 2>&1; then + echo "(*) Using built-in ${ID} Moby packages" + else + case "${ID}" in + azurelinux) + echo "(*) Moby packages not found in Azure Linux repositories" + echo "(*) For Azure Linux, Docker CE ('moby': false) is recommended" + err "Moby packages are not available for Azure Linux ${VERSION_ID}." + err "Recommendation: Use '\"moby\": false' to install Docker CE instead." + exit 1 + ;; + mariner) + echo "(*) Adding Microsoft repository for CBL-Mariner..." + # Add Microsoft repository if packages aren't available locally + curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /etc/pki/rpm-gpg/microsoft.gpg + cat > /etc/yum.repos.d/microsoft.repo << EOF +[microsoft] +name=Microsoft Repository +baseurl=https://packages.microsoft.com/repos/microsoft-cbl-mariner-2.0-prod-base/ +enabled=1 +gpgcheck=1 +gpgkey=file:///etc/pki/rpm-gpg/microsoft.gpg +EOF + # Verify packages are available after adding repo + pkg_mgr_update + if ! ${PKG_MGR_CMD} list available moby-engine >/dev/null 2>&1; then + echo "(*) Moby packages not found in Microsoft repository either" + err "Moby packages are not available for CBL-Mariner ${VERSION_ID}." + err "Recommendation: Use '\"moby\": false' to install Docker CE instead." + exit 1 + fi + ;; + *) + err "Moby packages are not available for ${ID}. Please use 'moby': false option." + exit 1 + ;; + esac + fi + ;; + esac else # Name of licensed engine/cli engine_package_name="docker-ce" cli_package_name="docker-ce-cli" - - # Import key safely and import Docker apt repo - curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor > /usr/share/keyrings/docker-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list + case ${ADJUSTED_ID} in + debian) + curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor > /usr/share/keyrings/docker-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list + ;; + rhel) + # Docker CE repository setup for RHEL-based systems + setup_docker_ce_repo() { + curl -fsSL https://download.docker.com/linux/centos/gpg > /etc/pki/rpm-gpg/docker-ce.gpg + cat > /etc/yum.repos.d/docker-ce.repo << EOF +[docker-ce-stable] +name=Docker CE Stable +baseurl=https://download.docker.com/linux/centos/9/\$basearch/stable +enabled=1 +gpgcheck=1 +gpgkey=file:///etc/pki/rpm-gpg/docker-ce.gpg +skip_if_unavailable=1 +module_hotfixes=1 +EOF + } + install_azure_linux_deps() { + echo "(*) Installing device-mapper libraries for Docker CE..." + [ "${ID}" != "mariner" ] && ${PKG_MGR_CMD} -y install device-mapper-libs 2>/dev/null || echo "(*) Device-mapper install failed, proceeding" + echo "(*) Installing additional Docker CE dependencies..." + ${PKG_MGR_CMD} -y install libseccomp libtool-ltdl systemd-libs libcgroup tar xz || { + echo "(*) Some optional dependencies could not be installed, continuing..." + } + } + setup_selinux_context() { + if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null)" != "Disabled" ]; then + echo "(*) Creating minimal SELinux context for Docker compatibility..." + mkdir -p /etc/selinux/targeted/contexts/files/ 2>/dev/null || true + echo "/var/lib/docker(/.*)? system_u:object_r:container_file_t:s0" >> /etc/selinux/targeted/contexts/files/file_contexts.local 2>/dev/null || true + fi + } + + # Special handling for RHEL Docker CE installation + case "${ID}" in + azurelinux|mariner) + echo "(*) ${ID} detected" + echo "(*) Note: Moby packages work better on Azure Linux. Consider using 'moby': true" + echo "(*) Setting up Docker CE repository..." + + setup_docker_ce_repo + install_azure_linux_deps + + if [ "${USE_MOBY}" != "true" ]; then + echo "(*) Docker CE installation for Azure Linux - skipping container-selinux" + echo "(*) Note: SELinux policies will be minimal but Docker will function normally" + setup_selinux_context + else + echo "(*) Using Moby - container-selinux not required" + fi + ;; + *) + # Standard RHEL/CentOS/Fedora approach + if command -v dnf >/dev/null 2>&1; then + dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + elif command -v yum-config-manager >/dev/null 2>&1; then + yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + else + # Manual fallback + setup_docker_ce_repo + fi + ;; + esac + ;; + esac fi -# Refresh apt lists -apt-get update +# Refresh package database +case ${ADJUSTED_ID} in + debian) + apt-get update + ;; + rhel) + pkg_mgr_update + ;; +esac # Soft version matching if [ "${DOCKER_VERSION}" = "latest" ] || [ "${DOCKER_VERSION}" = "lts" ] || [ "${DOCKER_VERSION}" = "stable" ]; then @@ -196,6 +489,8 @@ if [ "${DOCKER_VERSION}" = "latest" ] || [ "${DOCKER_VERSION}" = "lts" ] || [ "$ engine_version_suffix="" cli_version_suffix="" else + case ${ADJUSTED_ID} in + debian) # Fetch a valid version from the apt-cache (eg: the Microsoft repo appends +azure, breakfix, etc...) docker_version_dot_escaped="${DOCKER_VERSION//./\\.}" docker_version_dot_plus_escaped="${docker_version_dot_escaped//+/\\+}" @@ -210,106 +505,460 @@ else apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' exit 1 fi - echo "engine_version_suffix ${engine_version_suffix}" - echo "cli_version_suffix ${cli_version_suffix}" + ;; +rhel) + # For RHEL-based systems, use dnf/yum to find versions + docker_version_escaped="${DOCKER_VERSION//./\\.}" + set +e # Don't exit if finding version fails - will handle gracefully + if [ "${USE_MOBY}" = "true" ]; then + available_versions=$(${PKG_MGR_CMD} list --available moby-engine 2>/dev/null | grep -v "Available Packages" | awk '{print $2}' | grep -E "^${docker_version_escaped}" | head -1) + else + available_versions=$(${PKG_MGR_CMD} list --available docker-ce 2>/dev/null | grep -v "Available Packages" | awk '{print $2}' | grep -E "^${docker_version_escaped}" | head -1) + fi + set -e + if [ -n "${available_versions}" ]; then + engine_version_suffix="-${available_versions}" + cli_version_suffix="-${available_versions}" + else + echo "(*) Exact version ${DOCKER_VERSION} not found, using latest available" + engine_version_suffix="" + cli_version_suffix="" + fi + ;; + esac +fi + +# Version matching for moby-buildx +if [ "${USE_MOBY}" = "true" ]; then + if [ "${MOBY_BUILDX_VERSION}" = "latest" ]; then + # Empty, meaning grab whatever "latest" is in apt repo + buildx_version_suffix="" + else + case ${ADJUSTED_ID} in + debian) + buildx_version_dot_escaped="${MOBY_BUILDX_VERSION//./\\.}" + buildx_version_dot_plus_escaped="${buildx_version_dot_escaped//+/\\+}" + buildx_version_regex="^(.+:)?${buildx_version_dot_plus_escaped}([\\.\\+ ~:-]|$)" + set +e + buildx_version_suffix="=$(apt-cache madison moby-buildx | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${buildx_version_regex}")" + set -e + if [ -z "${buildx_version_suffix}" ] || [ "${buildx_version_suffix}" = "=" ]; then + err "No full or partial moby-buildx version match found for \"${MOBY_BUILDX_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:" + apt-cache madison moby-buildx | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' + exit 1 + fi + ;; + rhel) + # For RHEL-based systems, try to find buildx version or use latest + buildx_version_escaped="${MOBY_BUILDX_VERSION//./\\.}" + set +e + available_buildx=$(${PKG_MGR_CMD} list --available moby-buildx 2>/dev/null | grep -v "Available Packages" | awk '{print $2}' | grep -E "^${buildx_version_escaped}" | head -1) + set -e + if [ -n "${available_buildx}" ]; then + buildx_version_suffix="-${available_buildx}" + else + echo "(*) Exact buildx version ${MOBY_BUILDX_VERSION} not found, using latest available" + buildx_version_suffix="" + fi + ;; + esac + echo "buildx_version_suffix ${buildx_version_suffix}" + fi fi # Install Docker / Moby CLI if not already installed if type docker > /dev/null 2>&1 && type dockerd > /dev/null 2>&1; then echo "Docker / Moby CLI and Engine already installed." else - if [ "${USE_MOBY}" = "true" ]; then - # Install engine - set +e # Handle error gracefully - apt-get -y install --no-install-recommends moby-cli${cli_version_suffix} moby-buildx moby-engine${engine_version_suffix} - if [ $? -ne 0 ]; then - err "Packages for moby not available in OS ${ID} ${VERSION_CODENAME} (${architecture}). To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS version (eg: 'ubuntu-20.04')." - exit 1 + case ${ADJUSTED_ID} in + debian) + if [ "${USE_MOBY}" = "true" ]; then + # Install engine + set +e # Handle error gracefully + apt-get -y install --no-install-recommends moby-cli${cli_version_suffix} moby-buildx${buildx_version_suffix} moby-engine${engine_version_suffix} + exit_code=$? + set -e + + if [ ${exit_code} -ne 0 ]; then + err "Packages for moby not available in OS ${ID} ${VERSION_CODENAME} (${architecture}). To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS version (eg: 'ubuntu-24.04')." + exit 1 + fi + + # Install compose + apt-get -y install --no-install-recommends moby-compose || err "Package moby-compose (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." + else + apt-get -y install --no-install-recommends docker-ce-cli${cli_version_suffix} docker-ce${engine_version_suffix} + # Install compose + apt-mark hold docker-ce docker-ce-cli + apt-get -y install --no-install-recommends docker-compose-plugin || echo "(*) Package docker-compose-plugin (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." fi - set -e + ;; + rhel) + if [ "${USE_MOBY}" = "true" ]; then + set +e # Handle error gracefully + ${PKG_MGR_CMD} -y install moby-cli${cli_version_suffix} moby-engine${engine_version_suffix} + exit_code=$? + set -e + + if [ ${exit_code} -ne 0 ]; then + err "Packages for moby not available in OS ${ID} ${VERSION_CODENAME} (${architecture}). To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS version." + exit 1 + fi - # Install compose - apt-get -y install --no-install-recommends moby-compose || err "Package moby-compose (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." - else - apt-get -y install --no-install-recommends docker-ce-cli${cli_version_suffix} docker-ce${engine_version_suffix} - # Install compose - apt-get -y install --no-install-recommends docker-compose-plugin || echo "(*) Package docker-compose-plugin (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." - fi + # Install compose + if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then + ${PKG_MGR_CMD} -y install moby-compose || echo "(*) Package moby-compose not available for ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." + fi + else + # Special handling for Azure Linux Docker CE installation + if [ "${ID}" = "azurelinux" ] || [ "${ID}" = "mariner" ]; then + echo "(*) Installing Docker CE on Azure Linux (bypassing container-selinux dependency)..." + + # Use rpm with --force and --nodeps for Azure Linux + set +e # Don't exit on error for this section + ${PKG_MGR_CMD} -y install docker-ce${cli_version_suffix} docker-ce-cli${engine_version_suffix} containerd.io + install_result=$? + set -e + + if [ $install_result -ne 0 ]; then + echo "(*) Standard installation failed, trying manual installation..." + + echo "(*) Standard installation failed, trying manual installation..." + + # Create directory for downloading packages + mkdir -p /tmp/docker-ce-install + + # Download packages manually using curl since tdnf doesn't support download + echo "(*) Downloading Docker CE packages manually..." + + # Derive repo arch from the current platform. The Docker CE centos + # repo uses 'x86_64' and 'aarch64' as the per-arch directory names, + # which matches the values produced by `rpm --eval '%{_arch}'` / + # `uname -m` for those platforms. + case "${architecture}" in + amd64|x86_64) repo_arch="x86_64" ;; + arm64|aarch64) repo_arch="aarch64" ;; + *) repo_arch="${architecture}" ;; + esac + + # Get the repository baseurl + repo_baseurl="https://download.docker.com/linux/centos/9/${repo_arch}/stable" + + # Download packages directly + cd /tmp/docker-ce-install + + # Get package names with versions + if [ -n "${cli_version_suffix}" ]; then + docker_ce_version="${cli_version_suffix#-}" + docker_cli_version="${engine_version_suffix#-}" + else + # Get latest version from repository + docker_ce_version="latest" + fi + + echo "(*) Attempting to download Docker CE packages from repository..." + + # Try to download latest packages if specific version fails + if ! curl -fsSL "${repo_baseurl}/Packages/docker-ce-${docker_ce_version}.el9.${repo_arch}.rpm" -o docker-ce.rpm 2>/dev/null; then + # Fallback: try to get latest available version + echo "(*) Specific version not found, trying latest..." + latest_docker=$(curl -s "${repo_baseurl}/Packages/" | grep -o "docker-ce-[0-9][^\"]*\.el9\.${repo_arch}\.rpm" | head -1) + latest_cli=$(curl -s "${repo_baseurl}/Packages/" | grep -o "docker-ce-cli-[0-9][^\"]*\.el9\.${repo_arch}\.rpm" | head -1) + latest_containerd=$(curl -s "${repo_baseurl}/Packages/" | grep -o "containerd\.io-[0-9][^\"]*\.el9\.${repo_arch}\.rpm" | head -1) + + if [ -n "${latest_docker}" ]; then + curl -fsSL "${repo_baseurl}/Packages/${latest_docker}" -o docker-ce.rpm + curl -fsSL "${repo_baseurl}/Packages/${latest_cli}" -o docker-ce-cli.rpm + curl -fsSL "${repo_baseurl}/Packages/${latest_containerd}" -o containerd.io.rpm + else + echo "(*) ERROR: Could not find Docker CE packages in repository" + echo "(*) Please check repository configuration or use 'moby': true" + exit 1 + fi + fi + # Install systemd libraries required by Docker CE + echo "(*) Installing systemd libraries required by Docker CE..." + ${PKG_MGR_CMD} -y install systemd-libs || ${PKG_MGR_CMD} -y install systemd-devel || { + echo "(*) WARNING: Could not install systemd libraries" + echo "(*) Docker may fail to start without these" + } + + # Install with rpm --force --nodeps + echo "(*) Installing Docker CE packages with dependency override..." + rpm -Uvh --force --nodeps *.rpm + + # Cleanup + cd / + rm -rf /tmp/docker-ce-install + + echo "(*) Docker CE installation completed with dependency bypass" + echo "(*) Note: Some SELinux functionality may be limited without container-selinux" + fi + else + # Standard installation for other RHEL-based systems + ${PKG_MGR_CMD} -y install docker-ce${cli_version_suffix} docker-ce-cli${engine_version_suffix} containerd.io + fi + # Install compose + if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then + ${PKG_MGR_CMD} -y install docker-compose-plugin || echo "(*) Package docker-compose-plugin not available for ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." + fi + fi + ;; + esac fi echo "Finished installing docker / moby!" -# Install Docker Compose if not already installed and is on a supported architecture -if type docker-compose > /dev/null 2>&1; then - echo "Docker Compose v1 already installed." -else - target_compose_arch="${architecture}" - if [ "${target_compose_arch}" = "amd64" ]; then - target_compose_arch="x86_64" - fi - if [ "${target_compose_arch}" != "x86_64" ]; then - # Use pip to get a version that runs on this architecture - if ! dpkg -s python3-minimal python3-pip libffi-dev python3-venv > /dev/null 2>&1; then - apt_get_update - apt-get -y install python3-minimal python3-pip libffi-dev python3-venv - fi - export PIPX_HOME=/usr/local/pipx - mkdir -p ${PIPX_HOME} - export PIPX_BIN_DIR=/usr/local/bin - export PYTHONUSERBASE=/tmp/pip-tmp - export PIP_CACHE_DIR=/tmp/pip-tmp/cache - pipx_bin=pipx - if ! type pipx > /dev/null 2>&1; then - pip3 install --disable-pip-version-check --no-cache-dir --user pipx - pipx_bin=/tmp/pip-tmp/bin/pipx +docker_home="/usr/libexec/docker" +cli_plugins_dir="${docker_home}/cli-plugins" + +# fallback for docker-compose +fallback_compose(){ + local url=$1 + local repo_url=$(get_github_api_repo_url "$url") + echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..." + get_previous_version "${url}" "${repo_url}" compose_version + echo -e "\nAttempting to install v${compose_version}" + curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} +} + +# If 'docker-compose' command is to be included +if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then + case "${architecture}" in + amd64|x86_64) target_compose_arch=x86_64 ;; + arm64|aarch64) target_compose_arch=aarch64 ;; + *) + echo "(!) Docker in docker does not support machine architecture '$architecture'. Please use an x86-64 or ARM64 machine." + exit 1 + esac + + docker_compose_path="/usr/local/bin/docker-compose" + if [ "${DOCKER_DASH_COMPOSE_VERSION}" = "v1" ]; then + err "The final Compose V1 release, version 1.29.2, was May 10, 2021. These packages haven't received any security updates since then. Use at your own risk." + INSTALL_DOCKER_COMPOSE_SWITCH="false" + + if [ "${target_compose_arch}" = "x86_64" ]; then + echo "(*) Installing docker compose v1..." + curl -fsSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64" -o ${docker_compose_path} + chmod +x ${docker_compose_path} + + # Download the SHA256 checksum + DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64.sha256" | awk '{print $1}')" + echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum + sha256sum -c docker-compose.sha256sum --ignore-missing + elif [ "${VERSION_CODENAME}" = "bookworm" ]; then + err "Docker compose v1 is unavailable for 'bookworm' on Arm64. Kindly switch to use v2" + exit 1 + else + # Use pip (inside an isolated venv) to get a version that runs on this architecture. + # A dedicated venv avoids PEP 668 "externally-managed-environment" errors on newer + # distros (Debian trixie, Ubuntu noble, etc.) and guarantees we do not modify or + # shadow the distro-managed system Python site-packages. + check_packages python3-minimal python3-pip libffi-dev python3-venv + echo "(*) Installing docker compose v1 via pip into an isolated virtualenv..." + + compose_v1_venv="/usr/local/share/docker-compose-v1-venv" + python3 -m venv "${compose_v1_venv}" + "${compose_v1_venv}/bin/pip" install --disable-pip-version-check --no-cache-dir --upgrade pip setuptools wheel + "${compose_v1_venv}/bin/pip" install --disable-pip-version-check --no-cache-dir "Cython<3.0" pyyaml docker-compose --no-build-isolation + + # Expose the venv's docker-compose entrypoint on PATH at the expected location. + ln -sf "${compose_v1_venv}/bin/docker-compose" "${docker_compose_path}" + chmod +x "${docker_compose_path}" fi - ${pipx_bin} install --pip-args '--no-cache-dir --force-reinstall' docker-compose - rm -rf /tmp/pip-tmp else - compose_v1_version="1" - find_version_from_git_tags compose_v1_version "https://github.com/docker/compose" "tags/" - echo "(*) Installing docker-compose ${compose_v1_version}..." - curl -fsSL "https://github.com/docker/compose/releases/download/${compose_v1_version}/docker-compose-Linux-x86_64" -o /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose + compose_version=${DOCKER_DASH_COMPOSE_VERSION#v} + docker_compose_url="https://github.com/docker/compose" + find_version_from_git_tags compose_version "$docker_compose_url" "tags/v" + echo "(*) Installing docker-compose ${compose_version}..." + curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} || { + echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..." + fallback_compose "$docker_compose_url" + } + + chmod +x ${docker_compose_path} + + # Download the SHA256 checksum + DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}.sha256" | awk '{print $1}')" + echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum + sha256sum -c docker-compose.sha256sum --ignore-missing + + mkdir -p ${cli_plugins_dir} + cp ${docker_compose_path} ${cli_plugins_dir} fi fi +# fallback method for compose-switch +fallback_compose-switch() { + local url=$1 + local repo_url=$(get_github_api_repo_url "$url") + echo -e "\n(!) Failed to fetch the latest artifacts for compose-switch v${compose_switch_version}..." + get_previous_version "$url" "$repo_url" compose_switch_version + echo -e "\nAttempting to install v${compose_switch_version}" + curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${target_switch_arch}" -o /usr/local/bin/compose-switch +} # Install docker-compose switch if not already installed - https://github.com/docker/compose-switch#manual-installation -current_v1_compose_path="$(which docker-compose)" -target_v1_compose_path="$(dirname "${current_v1_compose_path}")/docker-compose-v1" -if ! type compose-switch > /dev/null 2>&1; then - echo "(*) Installing compose-switch..." - compose_switch_version="latest" - find_version_from_git_tags compose_switch_version "https://github.com/docker/compose-switch" - curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${architecture}" -o /usr/local/bin/compose-switch - chmod +x /usr/local/bin/compose-switch - # TODO: Verify checksum once available: https://github.com/docker/compose-switch/issues/11 - - # Setup v1 CLI as alternative in addition to compose-switch (which maps to v2) - mv "${current_v1_compose_path}" "${target_v1_compose_path}" - update-alternatives --install /usr/local/bin/docker-compose docker-compose /usr/local/bin/compose-switch 99 - update-alternatives --install /usr/local/bin/docker-compose docker-compose "${target_v1_compose_path}" 1 -fi -if [ "${DOCKER_DASH_COMPOSE_VERSION}" = "v1" ]; then - update-alternatives --set docker-compose "${target_v1_compose_path}" -else - update-alternatives --set docker-compose /usr/local/bin/compose-switch +if [ "${INSTALL_DOCKER_COMPOSE_SWITCH}" = "true" ] && ! type compose-switch > /dev/null 2>&1; then + if type docker-compose > /dev/null 2>&1; then + echo "(*) Installing compose-switch..." + current_compose_path="$(command -v docker-compose)" + target_compose_path="$(dirname "${current_compose_path}")/docker-compose-v1" + compose_switch_version="latest" + compose_switch_url="https://github.com/docker/compose-switch" + # Try to get latest version, fallback to known stable version if GitHub API fails + set +e + find_version_from_git_tags compose_switch_version "$compose_switch_url" + if [ $? -ne 0 ] || [ -z "${compose_switch_version}" ] || [ "${compose_switch_version}" = "latest" ]; then + echo "(*) GitHub API rate limited or failed, using fallback method" + fallback_compose-switch "$compose_switch_url" + fi + set -e + + # Map architecture for compose-switch downloads + case "${architecture}" in + amd64|x86_64) target_switch_arch=amd64 ;; + arm64|aarch64) target_switch_arch=arm64 ;; + *) target_switch_arch=${architecture} ;; + esac + curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${target_switch_arch}" -o /usr/local/bin/compose-switch || fallback_compose-switch "$compose_switch_url" + chmod +x /usr/local/bin/compose-switch + # TODO: Verify checksum once available: https://github.com/docker/compose-switch/issues/11 + # Setup v1 CLI as alternative in addition to compose-switch (which maps to v2) + mv "${current_compose_path}" "${target_compose_path}" + update-alternatives --install ${docker_compose_path} docker-compose /usr/local/bin/compose-switch 99 + update-alternatives --install ${docker_compose_path} docker-compose "${target_compose_path}" 1 + else + err "Skipping installation of compose-switch as docker compose is unavailable..." + fi fi # If init file already exists, exit if [ -f "/usr/local/share/docker-init.sh" ]; then echo "/usr/local/share/docker-init.sh already exists, so exiting." + # Clean up + rm -rf /var/lib/apt/lists/* exit 0 fi -echo "docker-init doesnt exist, adding..." +echo "docker-init doesn't exist, adding..." + +if ! cat /etc/group | grep -e "^docker:" > /dev/null 2>&1; then + groupadd -r docker +fi + +usermod -aG docker ${USERNAME} + +# fallback for docker/buildx +fallback_buildx() { + local url=$1 + local repo_url=$(get_github_api_repo_url "$url") + echo -e "\n(!) Failed to fetch the latest artifacts for docker buildx v${buildx_version}..." + get_previous_version "$url" "$repo_url" buildx_version + buildx_file_name="buildx-v${buildx_version}.linux-${target_buildx_arch}" + echo -e "\nAttempting to install v${buildx_version}" + wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name} +} + +if [ "${INSTALL_DOCKER_BUILDX}" = "true" ]; then + buildx_version="latest" + docker_buildx_url="https://github.com/docker/buildx" + find_version_from_git_tags buildx_version "$docker_buildx_url" "refs/tags/v" + echo "(*) Installing buildx ${buildx_version}..." + + # Map architecture for buildx downloads + case "${architecture}" in + amd64|x86_64) target_buildx_arch=amd64 ;; + arm64|aarch64) target_buildx_arch=arm64 ;; + *) target_buildx_arch=${architecture} ;; + esac + + buildx_file_name="buildx-v${buildx_version}.linux-${target_buildx_arch}" + + cd /tmp + wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name} || fallback_buildx "$docker_buildx_url" + + docker_home="/usr/libexec/docker" + cli_plugins_dir="${docker_home}/cli-plugins" + + mkdir -p ${cli_plugins_dir} + mv ${buildx_file_name} ${cli_plugins_dir}/docker-buildx + chmod +x ${cli_plugins_dir}/docker-buildx + + chown -R "${USERNAME}:docker" "${docker_home}" + chmod -R g+r+w "${docker_home}" + find "${docker_home}" -type d -print0 | xargs -n 1 -0 chmod g+s +fi + +DOCKER_DEFAULT_IP6_TABLES="" +if [ "$DISABLE_IP6_TABLES" == true ]; then + requested_version="" + # checking whether the version requested either is in semver format or just a number denoting the major version + # and, extracting the major version number out of the two scenarios + semver_regex="^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$" + if echo "$DOCKER_VERSION" | grep -Eq $semver_regex; then + requested_version=$(echo $DOCKER_VERSION | cut -d. -f1) + elif echo "$DOCKER_VERSION" | grep -Eq "^[1-9][0-9]*$"; then + requested_version=$DOCKER_VERSION + fi + if [ "$DOCKER_VERSION" = "latest" ] || [[ -n "$requested_version" && "$requested_version" -ge 27 ]] ; then + DOCKER_DEFAULT_IP6_TABLES="--ip6tables=false" + echo "(!) As requested, passing '${DOCKER_DEFAULT_IP6_TABLES}'" + fi +fi + +# Workaround for https://github.com/devcontainers/features/issues/1642 +# containerd >= 2.3 ships an erofs snapshotter that requires mkfs.erofs >= 1.7. +# Older distros (Debian 12, Ubuntu 22.04) ship erofs-utils 1.4/1.5, so when the +# host kernel exposes the 'erofs' filesystem the snapshotter fails to +# initialize and dockerd times out waiting for containerd. Disable the plugin +# via the top-level `disabled_plugins` list so containerd always uses +# overlayfs, regardless of distro / mkfs.erofs version. +mkdir -p /etc/containerd +if [ ! -s /etc/containerd/config.toml ]; then + if command -v containerd >/dev/null 2>&1; then + containerd config default > /etc/containerd/config.toml 2>/dev/null || : > /etc/containerd/config.toml + elif [ -x /usr/sbin/containerd ]; then + /usr/sbin/containerd config default > /etc/containerd/config.toml 2>/dev/null || : > /etc/containerd/config.toml + elif [ -x /usr/bin/containerd ]; then + /usr/bin/containerd config default > /etc/containerd/config.toml 2>/dev/null || : > /etc/containerd/config.toml + else + : > /etc/containerd/config.toml + fi +fi + +EROFS_PLUGIN_URI='io.containerd.snapshotter.v1.erofs' +EROFS_MARKER='# devcontainers-features:disable-erofs' -# Add user to the docker group -if [ "${ENABLE_NONROOT_DOCKER}" = "true" ]; then - if ! getent group docker > /dev/null 2>&1; then - groupadd docker +if ! grep -qF "${EROFS_MARKER}" /etc/containerd/config.toml; then + if grep -qE '^[[:space:]]*disabled_plugins[[:space:]]*=' /etc/containerd/config.toml; then + # Add erofs URI to the existing top-level disabled_plugins list. + # Branches are mutually exclusive and guarded so we never produce + # duplicate entries on re-runs. + if grep -qE '^[[:space:]]*disabled_plugins[[:space:]]*=[[:space:]]*\[[[:space:]]*\]' /etc/containerd/config.toml; then + # disabled_plugins = [] -> insert URI as the only entry. + sed -i -E \ + "s|^([[:space:]]*disabled_plugins[[:space:]]*=[[:space:]]*\[)[[:space:]]*\]|\1\"${EROFS_PLUGIN_URI}\"]|" \ + /etc/containerd/config.toml + elif ! grep -qF "\"${EROFS_PLUGIN_URI}\"" /etc/containerd/config.toml; then + # disabled_plugins = ["existing", ...] -> append URI. + sed -i -E \ + "s|^([[:space:]]*disabled_plugins[[:space:]]*=[[:space:]]*\[)([^]]*[^],[:space:]])[[:space:]]*\]|\1\2, \"${EROFS_PLUGIN_URI}\"]|" \ + /etc/containerd/config.toml + fi + else + # No disabled_plugins key in the config: prepend one. + tmp_cfg="$(mktemp)" + { + printf 'disabled_plugins = ["%s"]\n\n' "${EROFS_PLUGIN_URI}" + cat /etc/containerd/config.toml + } > "${tmp_cfg}" + mv "${tmp_cfg}" /etc/containerd/config.toml fi + # Idempotency marker + printf '\n%s\n' "${EROFS_MARKER}" >> /etc/containerd/config.toml +fi - usermod -aG docker ${USERNAME} +if [ ! -d /usr/local/share ]; then + mkdir -p /usr/local/share fi tee /usr/local/share/docker-init.sh > /dev/null \ @@ -322,18 +971,42 @@ tee /usr/local/share/docker-init.sh > /dev/null \ set -e -AZURE_DNS_AUTO_DETECTION=$AZURE_DNS_AUTO_DETECTION +AZURE_DNS_AUTO_DETECTION=${AZURE_DNS_AUTO_DETECTION} +DOCKER_DEFAULT_ADDRESS_POOL=${DOCKER_DEFAULT_ADDRESS_POOL} +DOCKER_DEFAULT_IP6_TABLES=${DOCKER_DEFAULT_IP6_TABLES} EOF +# On Debian-based images, re-assert the iptables alternative at container start +# (only when the user opted into runtime switching via iptablesSwitchAtRuntime=true). +if [ "${IPTABLES_SWITCH_AT_RUNTIME}" = "true" ] && [ "${ADJUSTED_ID}" = "debian" ]; then + tee -a /usr/local/share/docker-init.sh > /dev/null \ +<< 'EOF' +# Prefer legacy only when the ip_tables kernel module is actually present. +# (Do NOT call `iptables-legacy -L/-nL` to test this — it auto-modprobes ip_tables +# and would defeat hosts/scenarios where the module is intentionally absent +# such as the newer kernels which leaves out ip_tables legacy.) +if type iptables-legacy > /dev/null 2>&1 \ + && { grep -qE '^(ip_tables)\b' /proc/modules \ + || [ -d /sys/module/ip_tables ]; } \ + && update-alternatives --list iptables 2>/dev/null | grep -q '/usr/sbin/iptables-legacy'; then + update-alternatives --set iptables /usr/sbin/iptables-legacy || true + update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy || true +elif type iptables-nft > /dev/null 2>&1 \ + && update-alternatives --list iptables 2>/dev/null | grep -q '/usr/sbin/iptables-nft'; then + update-alternatives --set iptables /usr/sbin/iptables-nft || true + update-alternatives --set ip6tables /usr/sbin/ip6tables-nft || true +fi +EOF +fi + tee -a /usr/local/share/docker-init.sh > /dev/null \ << 'EOF' -dockerd_start="$(cat << 'INNEREOF' +dockerd_start="AZURE_DNS_AUTO_DETECTION=${AZURE_DNS_AUTO_DETECTION} DOCKER_DEFAULT_ADDRESS_POOL=${DOCKER_DEFAULT_ADDRESS_POOL} DOCKER_DEFAULT_IP6_TABLES=${DOCKER_DEFAULT_IP6_TABLES} $(cat << 'INNEREOF' # explicitly remove dockerd and containerd PID file to ensure that it can start properly if it was stopped uncleanly - # ie: docker kill find /run /var/run -iname 'docker*.pid' -delete || : find /run /var/run -iname 'container*.pid' -delete || : - ## Dind wrapper script from docker team, adapted to a function + # -- Start: dind wrapper script -- # Maintained: https://github.com/moby/moby/blob/master/hack/dind export container=docker @@ -350,45 +1023,143 @@ dockerd_start="$(cat << 'INNEREOF' mount -t tmpfs none /tmp fi - # cgroup v2: enable nesting - if [ -f /sys/fs/cgroup/cgroup.controllers ]; then - # move the processes from the root group to the /init group, - # otherwise writing subtree_control fails with EBUSY. - # An error during moving non-existent process (i.e., "cat") is ignored. - mkdir -p /sys/fs/cgroup/init - xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs || : - # enable controllers - sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \ - > /sys/fs/cgroup/cgroup.subtree_control - fi - ## Dind wrapper over. + set_cgroup_nesting() + { + # cgroup v2: enable nesting + if [ -f /sys/fs/cgroup/cgroup.controllers ]; then + # move the processes from the root group to the /init group, + # otherwise writing subtree_control fails with EBUSY. + # An error during moving non-existent process (i.e., "cat") is ignored. + mkdir -p /sys/fs/cgroup/init + xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs || : + # enable controllers + sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \ + > /sys/fs/cgroup/cgroup.subtree_control + fi + } + + # Set cgroup nesting, retrying if necessary + retry_cgroup_nesting=0 + + until [ "${retry_cgroup_nesting}" -eq "5" ]; + do + set +e + set_cgroup_nesting + + if [ $? -ne 0 ]; then + echo "(*) cgroup v2: Failed to enable nesting, retrying..." + else + break + fi + + retry_cgroup_nesting=`expr $retry_cgroup_nesting + 1` + set -e + done + + # -- End: dind wrapper script -- # Handle DNS set +e - cat /etc/resolv.conf | grep -i 'internal.cloudapp.net' - if [ $? -eq 0 ] && [ ${AZURE_DNS_AUTO_DETECTION} = "true" ] + cat /etc/resolv.conf | grep -i 'internal.cloudapp.net' > /dev/null 2>&1 + if [ $? -eq 0 ] && [ "${AZURE_DNS_AUTO_DETECTION}" = "true" ] + then + echo "Setting dockerd Azure DNS." + CUSTOMDNS="--dns 168.63.129.16" + else + echo "Not setting dockerd DNS manually." + CUSTOMDNS="" + fi + set -e + + if [ -z "$DOCKER_DEFAULT_ADDRESS_POOL" ] then - echo "Setting dockerd Azure DNS." - CUSTOMDNS="--dns 168.63.129.16" + DEFAULT_ADDRESS_POOL="" else - echo "Not setting dockerd DNS manually." - CUSTOMDNS="" + DEFAULT_ADDRESS_POOL="--default-address-pool $DOCKER_DEFAULT_ADDRESS_POOL" + fi + + + # Start our own containerd so it picks up /etc/containerd/config.toml + # (notably the disabled_plugins entry for the erofs snapshotter, see + # https://github.com/devcontainers/features/issues/1642). dockerd's + # built-in containerd child uses an auto-generated config that ignores + # /etc/containerd/config.toml, so we must run containerd ourselves and + # point dockerd at it via --containerd. + CONTAINERD_SOCK="/run/containerd/containerd.sock" + CONTAINERD_BIN="" + for candidate in /usr/local/bin/containerd /usr/bin/containerd /usr/sbin/containerd; do + if [ -x "$candidate" ]; then + CONTAINERD_BIN="$candidate" + break + fi + done + DOCKERD_CONTAINERD_ARG="" + if [ -n "$CONTAINERD_BIN" ] && [ -f /etc/containerd/config.toml ]; then + mkdir -p /run/containerd + if ! pgrep -x containerd > /dev/null 2>&1; then + ( "$CONTAINERD_BIN" --config /etc/containerd/config.toml > /tmp/containerd.log 2>&1 ) & + fi + # Wait up to ~5s for the socket to appear + i=0 + while [ $i -lt 50 ] && [ ! -S "$CONTAINERD_SOCK" ]; do + sleep 0.1 + i=$((i + 1)) + done + if [ -S "$CONTAINERD_SOCK" ]; then + DOCKERD_CONTAINERD_ARG="--containerd $CONTAINERD_SOCK" + else + echo "(*) containerd socket not ready; letting dockerd spawn its own containerd." + fi fi - set -e # Start docker/moby engine - ( dockerd $CUSTOMDNS > /tmp/dockerd.log 2>&1 ) & + ( dockerd $DOCKERD_CONTAINERD_ARG $CUSTOMDNS $DEFAULT_ADDRESS_POOL $DOCKER_DEFAULT_IP6_TABLES > /tmp/dockerd.log 2>&1 ) & INNEREOF )" -# Start using sudo if not invoked as root -if [ "$(id -u)" -ne 0 ]; then - sudo /bin/sh -c "${dockerd_start}" -else - eval "${dockerd_start}" -fi +sudo_if() { + COMMAND="$*" + + if [ "$(id -u)" -ne 0 ]; then + sudo $COMMAND + else + $COMMAND + fi +} + +retry_docker_start_count=0 +docker_ok="false" -set +e +until [ "${docker_ok}" = "true" ] || [ "${retry_docker_start_count}" -eq "5" ]; +do + # Start using sudo if not invoked as root + if [ "$(id -u)" -ne 0 ]; then + sudo /bin/sh -c "${dockerd_start}" + else + eval "${dockerd_start}" + fi + + retry_count=0 + until [ "${docker_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; + do + sleep 1s + set +e + docker info > /dev/null 2>&1 && docker_ok="true" + set -e + + retry_count=`expr $retry_count + 1` + done + + if [ "${docker_ok}" != "true" ] && [ "${retry_docker_start_count}" != "4" ]; then + echo "(*) Failed to start docker, retrying..." + set +e + sudo_if pkill dockerd + sudo_if pkill containerd + set -e + fi + + retry_docker_start_count=`expr $retry_docker_start_count + 1` +done # Execute whatever commands were passed in (if any). This allows us # to set this script to ENTRYPOINT while still executing the default CMD. @@ -398,4 +1169,7 @@ EOF chmod +x /usr/local/share/docker-init.sh chown ${USERNAME}:root /usr/local/share/docker-init.sh +# Clean up +rm -rf /var/lib/apt/lists/* + echo 'docker-in-docker-debian script has completed!' diff --git a/src/docker-outside-of-docker/NOTES.md b/src/docker-outside-of-docker/NOTES.md new file mode 100644 index 000000000..f9d7dbc06 --- /dev/null +++ b/src/docker-outside-of-docker/NOTES.md @@ -0,0 +1,73 @@ +## Limitations + +- As the name implies, the Feature is expected to work when the host is running Docker (or the OSS Moby container engine it is built on). It may be possible to get running in other container engines, but it has not been tested with them. +- The host and the container must be running on the same chip architecture. You will not be able to use it with an emulated x86 image with Docker Desktop on an Apple Silicon Mac, for example. +- This approach does not currently enable bind mounting the workspace folder by default, and cannot support folders outside of the workspace folder. Consider whether the [Docker-in-Docker Feature](../docker-in-docker) would better meet your needs given it does not have this limitation. + +## Supporting bind mounts from the workspace folder + +A common question that comes up is how you can use `bind` mounts from the Docker CLI from within the a dev container using this Feature (e.g. via `-v`). If you cannot use the [Docker-in-Docker Feature](../docker-in-docker), the only way to work around this is to use the **host**'s folder paths instead of the container's paths. There are 2 ways to do this + +### 1. Use the `${localWorkspaceFolder}` as environment variable in your code + +1. Add the following to `devcontainer.json`: + +```json +"remoteEnv": { "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" } +``` + +2. Usage with Docker commands + +```bash +docker run -it --rm -v ${LOCAL_WORKSPACE_FOLDER}:/workspace debian bash +``` + +3. Usage with Docker-compose + +```yaml +version: "3.9" + +services: + debian: + image: debian + volumes: + - ${LOCAL_WORKSPACE_FOLDER:-./}:/workspace +``` + +- The defaults value `./` is added so that the `docker-compose.yaml` file can work when it is run outside of the container + +### 2. Change the workspace to `${localWorkspaceFolder}` + +- This is useful if we don't want to edit the `docker-compose.yaml` file + +1. Add the following to `devcontainer.json` + +```json +"workspaceFolder": "${localWorkspaceFolder}", +"workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind" +``` + +2. Rebuild the container. +3. When the container first started with this settings, select the Workspace with the absolute path to the working directory inside the container +4. Docker commands with bind mount should work as they did outside of the devcontainer + +> **Note:** There is no `${localWorkspaceFolder}` when using the **Clone Repository in Container Volume** command in the VS Code Dev Containers extension ([info](https://github.com/microsoft/vscode-remote-release/issues/6160#issuecomment-1014701007)). + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +Debian Trixie (13) does not include moby-cli and related system packages, so the feature cannot install with "moby": "true". To use this feature on Trixie, please set "moby": "false" or choose a different base image (for example, Ubuntu 24.04). + +Ubuntu 26.04 LTS (Resolute) does not have moby-cli packages available, so the feature only supports installation with `"moby": false`. To use this feature on Ubuntu 26.04, set `"moby": false` in your feature configuration: + +```json +"features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "moby": false + } +} +``` + +`bash` is required to execute the `install.sh` script. diff --git a/src/docker-outside-of-docker/README.md b/src/docker-outside-of-docker/README.md new file mode 100644 index 000000000..3b37028e2 --- /dev/null +++ b/src/docker-outside-of-docker/README.md @@ -0,0 +1,123 @@ +### **IMPORTANT NOTE** +- **Ids used to publish this Feature in the past - 'docker-from-docker'** + +# Docker (docker-outside-of-docker) (docker-outside-of-docker) + +Re-use the host docker socket, adding the Docker CLI to a container. Feature invokes a script to enable using a forwarded Docker socket within a container to run Docker commands. + +## Example Usage + +```json +"features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| version | Select or enter a Docker/Moby CLI version. (Availability can vary by OS version.) | string | latest | +| moby | Install OSS Moby build instead of Docker CE | boolean | true | +| mobyBuildxVersion | Install a specific version of moby-buildx when using Moby | string | latest | +| dockerDashComposeVersion | Compose version to use for docker-compose (v1 or v2 or none or latest) | string | latest | +| installDockerBuildx | Install Docker Buildx | boolean | true | +| installDockerComposeSwitch | Install Compose Switch (provided docker compose is available) which is a replacement to the Compose V1 docker-compose (python) executable. It translates the command line into Compose V2 docker compose then runs the latter. | boolean | true | +| socketPath | Path where the Docker socket is mounted inside the container. For rootless Docker, override the mount in devcontainer.json to map your host socket to this path. | string | /var/run/docker-host.sock | + +## Customizations + +### VS Code Extensions + +- `ms-azuretools.vscode-containers` + +## Limitations + +- As the name implies, the Feature is expected to work when the host is running Docker (or the OSS Moby container engine it is built on). It may be possible to get running in other container engines, but it has not been tested with them. +- The host and the container must be running on the same chip architecture. You will not be able to use it with an emulated x86 image with Docker Desktop on an Apple Silicon Mac, for example. +- This approach does not currently enable bind mounting the workspace folder by default, and cannot support folders outside of the workspace folder. Consider whether the [Docker-in-Docker Feature](../docker-in-docker) would better meet your needs given it does not have this limitation. + +## Rootless Docker Support + +By default, this feature expects the Docker socket at `/var/run/docker.sock` on the host, which works for standard (root) Docker installations. For **rootless Docker** setups where the socket is located at `/run/user/$UID/docker.sock` or `$XDG_RUNTIME_DIR/docker.sock`, you need to override the mount in your `devcontainer.json`: + +```json +{ + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + }, + "mounts": [ + { + "source": "/run/user/1000/docker.sock", + "target": "/var/run/docker-host.sock", + "type": "bind" + } + ] +} +``` + +**Notes:** +- Replace `1000` with your actual user ID (run `id -u` to find it) +- The feature will automatically detect the socket at `/var/run/docker-host.sock` +- Your custom mount will override the feature's default mount + +## Supporting bind mounts from the workspace folder + +A common question that comes up is how you can use `bind` mounts from the Docker CLI from within the a dev container using this Feature (e.g. via `-v`). If you cannot use the [Docker-in-Docker Feature](../docker-in-docker), the only way to work around this is to use the **host**'s folder paths instead of the container's paths. There are 2 ways to do this + +### 1. Use the `${localWorkspaceFolder}` as environment variable in your code + +1. Add the following to `devcontainer.json`: + +```json +"remoteEnv": { "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" } +``` + +2. Usage with Docker commands + +```bash +docker run -it --rm -v ${LOCAL_WORKSPACE_FOLDER}:/workspace debian bash +``` + +3. Usage with Docker-compose + +```yaml +version: "3.9" + +services: + debian: + image: debian + volumes: + - ${LOCAL_WORKSPACE_FOLDER:-./}:/workspace +``` + +- The defaults value `./` is added so that the `docker-compose.yaml` file can work when it is run outside of the container + +### 2. Change the workspace to `${localWorkspaceFolder}` + +- This is useful if we don't want to edit the `docker-compose.yaml` file + +1. Add the following to `devcontainer.json` + +```json +"workspaceFolder": "${localWorkspaceFolder}", +"workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind" +``` + +2. Rebuild the container. +3. When the container first started with this settings, select the Workspace with the absolute path to the working directory inside the container +4. Docker commands with bind mount should work as they did outside of the devcontainer + +> **Note:** There is no `${localWorkspaceFolder}` when using the **Clone Repository in Container Volume** command in the VS Code Dev Containers extension ([info](https://github.com/microsoft/vscode-remote-release/issues/6160#issuecomment-1014701007)). + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/docker-outside-of-docker/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/docker-outside-of-docker/devcontainer-feature.json b/src/docker-outside-of-docker/devcontainer-feature.json new file mode 100644 index 000000000..3cf3513c0 --- /dev/null +++ b/src/docker-outside-of-docker/devcontainer-feature.json @@ -0,0 +1,87 @@ +{ + + "id": "docker-outside-of-docker", + "version": "1.10.0", + "name": "Docker (docker-outside-of-docker)", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/docker-outside-of-docker", + "description": "Re-use the host docker socket, adding the Docker CLI to a container. Feature invokes a script to enable using a forwarded Docker socket within a container to run Docker commands.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "none", + "20.10" + ], + "default": "latest", + "description": "Select or enter a Docker/Moby CLI version. (Availability can vary by OS version.)" + }, + "moby": { + "type": "boolean", + "default": true, + "description": "Install OSS Moby build instead of Docker CE" + }, + "mobyBuildxVersion": { + "type": "string", + "default": "latest", + "description": "Install a specific version of moby-buildx when using Moby" + }, + "dockerDashComposeVersion": { + "type": "string", + "enum": [ + "none", + "latest", + "v1", + "v2" + ], + "default": "latest", + "description": "Compose version to use for docker-compose (v1 or v2 or none or latest)" + }, + "installDockerBuildx": { + "type": "boolean", + "default": true, + "description": "Install Docker Buildx" + }, + "installDockerComposeSwitch": { + "type": "boolean", + "default": false, + "description": "Install Compose Switch (provided docker compose is available) which is a replacement to the Compose V1 docker-compose (python) executable. It translates the command line into Compose V2 docker compose then runs the latter." + }, + "socketPath": { + "type": "string", + "default": "/var/run/docker-host.sock", + "description": "Path where the Docker socket is mounted inside the container. For rootless Docker, override the mount in devcontainer.json to map your host socket to this path." + } + }, + "entrypoint": "/usr/local/share/docker-init.sh", + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.vscode-containers" + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the Docker CLI (`docker`) pre-installed and available on the `PATH` for running and managing containers using the Docker daemon on the host machine." + } + ] + } + } + }, + "mounts": [ + { + "source": "/var/run/docker.sock", + "target": "/var/run/docker-host.sock", + "type": "bind" + } + ], + "securityOpt": [ + "label=disable" + ], + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ], + "legacyIds": [ + "docker-from-docker" + ] +} diff --git a/src/docker-outside-of-docker/install.sh b/src/docker-outside-of-docker/install.sh new file mode 100755 index 000000000..ae4c7ae51 --- /dev/null +++ b/src/docker-outside-of-docker/install.sh @@ -0,0 +1,535 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker.md +# Maintainer: The VS Code and Codespaces Teams + +DOCKER_VERSION="${VERSION:-"latest"}" +USE_MOBY="${MOBY:-"true"}" +MOBY_BUILDX_VERSION="${MOBYBUILDXVERSION:-"latest"}" +DOCKER_DASH_COMPOSE_VERSION="${DOCKERDASHCOMPOSEVERSION:-"latest"}" # v1 or v2 or none or latest + +ENABLE_NONROOT_DOCKER="${ENABLE_NONROOT_DOCKER:-"true"}" +SOCKET_PATH="${SOCKETPATH:-"/var/run/docker-host.sock"}" # From feature option +SOURCE_SOCKET="${SOURCE_SOCKET:-"${SOCKET_PATH}"}" +TARGET_SOCKET="${TARGET_SOCKET:-"/var/run/docker.sock"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +INSTALL_DOCKER_BUILDX="${INSTALLDOCKERBUILDX:-"true"}" +INSTALL_DOCKER_COMPOSE_SWITCH="${INSTALLDOCKERCOMPOSESWITCH:-"true"}" +MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" +MICROSOFT_GPG_KEYS_ROLLING_URI="https://packages.microsoft.com/keys/microsoft-rolling.asc" +DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES="trixie bookworm buster bullseye bionic focal jammy noble plucky" +DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES="trixie bookworm buster bullseye bionic focal hirsute impish jammy noble plucky resolute" + +set -e + +# Clean up +rm -rf /var/lib/apt/lists/* + +# Setup STDERR. +err() { + echo "(!) $*" >&2 +} + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +# Determine the appropriate non-root user +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi +elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root +fi + +apt_get_update() +{ + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi +} + +# Checks if packages are installed and installs them if not +check_packages() { + if ! dpkg -s "$@" > /dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi +} + +# Figure out correct version of a three part version number is not passed +find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + local escaped_separator=${separator//./\\.} + local last_part + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + fi + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then + echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + echo "${variable_name}=${!variable_name}" +} + +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} + +# Function to fetch the version released prior to the latest version +get_previous_version() { + local url=$1 + local repo_url=$2 + local variable_name=$3 + prev_version=${!variable_name} + + output=$(curl -s "$repo_url"); + + check_packages jq + + if echo "$output" | jq -e 'type == "object"' > /dev/null; then + message=$(echo "$output" | jq -r '.message') + if [[ $message == "API rate limit exceeded"* ]]; then + echo -e "\nAn attempt to find previous to latest version using GitHub Api Failed... \nReason: ${message}" + echo -e "\nAttempting to find previous to latest version using GitHub tags." + find_prev_version_from_git_tags prev_version "$url" "tags/v" + declare -g ${variable_name}="${prev_version}" + fi + elif echo "$output" | jq -e 'type == "array"' > /dev/null; then + echo -e "\nAttempting to find previous version using GitHub Api." + version=$(echo "$output" | jq -r '.[1].tag_name') + declare -g ${variable_name}="${version#v}" + fi + echo "${variable_name}=${!variable_name}" +} + +get_github_api_repo_url() { + local url=$1 + echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases" +} + +install_compose_switch_fallback() { + compose_switch_url=$1 + repo_url=$(get_github_api_repo_url "${compose_switch_url}") + echo -e "\n(!) Failed to fetch the latest artifacts for compose-switch v${compose_switch_version}..." + get_previous_version "${compose_switch_url}" "${repo_url}" compose_switch_version + echo -e "\nAttempting to install v${compose_switch_version}" + curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${architecture}" -o /usr/local/bin/compose-switch +} + +# Ensure apt is in non-interactive to avoid prompts +export DEBIAN_FRONTEND=noninteractive + +# Install dependencies +check_packages apt-transport-https curl ca-certificates gnupg2 dirmngr wget +# Update CA certificates to ensure HTTPS connections work properly +# This is especially important for Ubuntu 24.04 (Noble) and Debian Trixie +if command -v update-ca-certificates > /dev/null 2>&1; then + update-ca-certificates +fi +if ! type git > /dev/null 2>&1; then + check_packages git +fi + +# Source /etc/os-release to get OS info +. /etc/os-release +# Fetch host/container arch. +architecture="$(dpkg --print-architecture)" + +# Prevent attempting to install Moby on Debian trixie or Ubuntu resolute (packages not available) +if [ "${USE_MOBY}" = "true" ] && ([ "${VERSION_CODENAME}" = "trixie" ] || [ "${VERSION_CODENAME}" = "resolute" ]); then + err "The 'moby' option is not supported on ${ID} '${VERSION_CODENAME}' because 'moby-cli' and related system packages are not available in that distribution." + err "To continue, either set the feature option '\"moby\": false' or use a different base image (for example: 'debian:bookworm' or 'ubuntu-24.04')." + exit 1 +fi + +# Check if distro is supported +if [ "${USE_MOBY}" = "true" ]; then + if [[ "${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then + err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS distribution" + err "Supported distributions include: ${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" + exit 1 + fi + echo "Distro codename '${VERSION_CODENAME}' matched filter '${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}'" +else + if [[ "${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then + err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, please choose a compatible OS distribution" + err "Supported distributions include: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" + exit 1 + fi + echo "Distro codename '${VERSION_CODENAME}' matched filter '${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}'" +fi + +# Set up the necessary apt repos (either Microsoft's or Docker's) +if [ "${USE_MOBY}" = "true" ]; then + + cli_package_name="moby-cli" + + # Import key safely and import Microsoft apt repo + { + curl -sSL ${MICROSOFT_GPG_KEYS_URI} + curl -sSL ${MICROSOFT_GPG_KEYS_ROLLING_URI} + } | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg + echo "deb [arch=${architecture} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list +else + # Name of proprietary engine package + cli_package_name="docker-ce-cli" + + # Import key safely and import Docker apt repo + curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor > /usr/share/keyrings/docker-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list +fi + +# Refresh apt lists +apt-get update + +# Soft version matching for CLI +if [ "${DOCKER_VERSION}" = "latest" ] || [ "${DOCKER_VERSION}" = "lts" ] || [ "${DOCKER_VERSION}" = "stable" ]; then + # Empty, meaning grab whatever "latest" is in apt repo + cli_version_suffix="" +else + # Fetch a valid version from the apt-cache (eg: the Microsoft repo appends +azure, breakfix, etc...) + docker_version_dot_escaped="${DOCKER_VERSION//./\\.}" + docker_version_dot_plus_escaped="${docker_version_dot_escaped//+/\\+}" + # Regex needs to handle debian package version number format: https://www.systutorials.com/docs/linux/man/5-deb-version/ + docker_version_regex="^(.+:)?${docker_version_dot_plus_escaped}([\\.\\+ ~:-]|$)" + set +e # Don't exit if finding version fails - will handle gracefully + cli_version_suffix="=$(apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${docker_version_regex}")" + set -e + if [ -z "${cli_version_suffix}" ] || [ "${cli_version_suffix}" = "=" ]; then + echo "(!) No full or partial Docker / Moby version match found for \"${DOCKER_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:" + apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' + exit 1 + fi + echo "cli_version_suffix ${cli_version_suffix}" +fi + +# Version matching for moby-buildx +if [ "${USE_MOBY}" = "true" ]; then + if [ "${MOBY_BUILDX_VERSION}" = "latest" ]; then + # Empty, meaning grab whatever "latest" is in apt repo + buildx_version_suffix="" + else + buildx_version_dot_escaped="${MOBY_BUILDX_VERSION//./\\.}" + buildx_version_dot_plus_escaped="${buildx_version_dot_escaped//+/\\+}" + buildx_version_regex="^(.+:)?${buildx_version_dot_plus_escaped}([\\.\\+ ~:-]|$)" + set +e + buildx_version_suffix="=$(apt-cache madison moby-buildx | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${buildx_version_regex}")" + set -e + if [ -z "${buildx_version_suffix}" ] || [ "${buildx_version_suffix}" = "=" ]; then + err "No full or partial moby-buildx version match found for \"${MOBY_BUILDX_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:" + apt-cache madison moby-buildx | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' + exit 1 + fi + echo "buildx_version_suffix ${buildx_version_suffix}" + fi +fi + + +docker_home="/usr/libexec/docker" +cli_plugins_dir="${docker_home}/cli-plugins" + +install_compose_fallback(){ + local url=$1 + local repo_url=$(get_github_api_repo_url "$url") + echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..." + get_previous_version "${url}" "${repo_url}" compose_version + echo -e "\nAttempting to install v${compose_version}" + curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} +} + +# Install Docker / Moby CLI if not already installed +if type docker > /dev/null 2>&1; then + echo "Docker / Moby CLI already installed." +else + if [ "${USE_MOBY}" = "true" ]; then + buildx=() + if [ "${INSTALL_DOCKER_BUILDX}" = "true" ]; then + buildx=(moby-buildx${buildx_version_suffix}) + fi + apt-get -y install --no-install-recommends ${cli_package_name}${cli_version_suffix} "${buildx[@]}" || { err "It seems packages for moby not available in OS ${ID} ${VERSION_CODENAME} (${architecture}). To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS version (eg: 'ubuntu-24.04')." ; exit 1 ; } + if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "v1" ]; then + apt-get -y install --no-install-recommends moby-compose || echo "(*) Package moby-compose (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." + fi + else + buildx=() + if [ "${INSTALL_DOCKER_BUILDX}" = "true" ]; then + buildx=(docker-buildx-plugin) + fi + #install cli + buildx first + apt-get -y install --no-install-recommends ${cli_package_name}${cli_version_suffix} "${buildx[@]}" + + # Backward compatibility: Older Docker CE versions bundled buildx with CLI + # Modern versions have separate packages, but this ensures consistent behavior + buildx_path="/usr/libexec/docker/cli-plugins/docker-buildx" + if [ "${INSTALL_DOCKER_BUILDX}" = "false" ] && [ -f "${buildx_path}" ]; then + echo "(*) Removing docker-buildx (bundled in older Docker CE) since installDockerBuildx is disabled..." + rm -f "${buildx_path}" + fi + + if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "v1" ]; then + apt-get -y install --no-install-recommends docker-compose-plugin + fi + fi + unset buildx buildx_path +fi +# If 'docker-compose' command is to be included +if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then + case "${architecture}" in + amd64) target_compose_arch=x86_64 ;; + arm64) target_compose_arch=aarch64 ;; + *) + echo "(!) Docker outside of docker does not support machine architecture '$architecture'. Please use an x86-64 or ARM64 machine." + exit 1 + esac + docker_compose_path="/usr/local/bin/docker-compose" + # Install Docker Compose if not already installed and is on a supported architecture + if type docker-compose > /dev/null 2>&1; then + echo "Docker Compose already installed." + elif [ "${DOCKER_DASH_COMPOSE_VERSION}" = "v1" ]; then + err "The final Compose V1 release, version 1.29.2, was May 10, 2021. These packages haven't received any security updates since then. Use at your own risk." + INSTALL_DOCKER_COMPOSE_SWITCH="false" + + if [ "${target_compose_arch}" = "x86_64" ]; then + echo "(*) Installing docker compose v1..." + curl -fsSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64" -o ${docker_compose_path} + chmod +x ${docker_compose_path} + + # Download the SHA256 checksum + DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64.sha256" | awk '{print $1}')" + echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum + sha256sum -c docker-compose.sha256sum --ignore-missing + elif [ "${VERSION_CODENAME}" = "bookworm" ]; then + err "Docker compose v1 is unavailable for 'bookworm' on Arm64. Kindly switch to use v2" + exit 1 + else + # Use pip to get a version that runs on this architecture + check_packages python3-minimal python3-pip libffi-dev python3-venv + echo "(*) Installing docker compose v1 via pip..." + export PYTHONUSERBASE=/usr/local + pip3 install --disable-pip-version-check --no-cache-dir --user "Cython<3.0" pyyaml wheel docker-compose --no-build-isolation + fi + else + compose_version=${DOCKER_DASH_COMPOSE_VERSION#v} + docker_compose_url="https://github.com/docker/compose" + find_version_from_git_tags compose_version "$docker_compose_url" "tags/v" + echo "(*) Installing docker-compose ${compose_version}..." + curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} || { + install_compose_fallback "$docker_compose_url" "$compose_version" "$target_compose_arch" "$docker_compose_path" + } + chmod +x ${docker_compose_path} + + # Download the SHA256 checksum + DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}.sha256" | awk '{print $1}')" + echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum + sha256sum -c docker-compose.sha256sum --ignore-missing + + mkdir -p ${cli_plugins_dir} + cp ${docker_compose_path} ${cli_plugins_dir} + fi +fi + +# Install docker-compose switch if not already installed - https://github.com/docker/compose-switch#manual-installation +if [ "${INSTALL_DOCKER_COMPOSE_SWITCH}" = "true" ] && ! type compose-switch > /dev/null 2>&1; then + if type docker-compose > /dev/null 2>&1; then + echo "(*) Installing compose-switch..." + current_compose_path="$(which docker-compose)" + target_compose_path="$(dirname "${current_compose_path}")/docker-compose-v1" + compose_switch_version="latest" + compose_switch_url="https://github.com/docker/compose-switch" + find_version_from_git_tags compose_switch_version "${compose_switch_url}" + curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${architecture}" -o /usr/local/bin/compose-switch || install_compose_switch_fallback "${compose_switch_url}" + chmod +x /usr/local/bin/compose-switch + # TODO: Verify checksum once available: https://github.com/docker/compose-switch/issues/11 + # Setup v1 CLI as alternative in addition to compose-switch (which maps to v2) + mv "${current_compose_path}" "${target_compose_path}" + update-alternatives --install ${docker_compose_path} docker-compose /usr/local/bin/compose-switch 99 + update-alternatives --install ${docker_compose_path} docker-compose "${target_compose_path}" 1 + else + err "Skipping installation of compose-switch as docker compose is unavailable..." + fi +fi + +# Setup a docker group in the event the docker socket's group is not root +if ! grep -qE '^docker:' /etc/group; then + echo "(*) Creating missing docker group..." + groupadd --system docker +fi + +# Remarking this out to restore functionality in Azure VMs. ID 999 is a reserved group ID +# Ensure docker group gid is 999 +# if [ "$(getent group docker | cut -d: -f3)" != "999" ]; then +# echo "(*) Updating docker group gid to 999..." +# groupmod -g 999 docker +# fi + + +usermod -aG docker "${USERNAME}" + +# If init file already exists, exit +if [ -f "/usr/local/share/docker-init.sh" ]; then + # Clean up + rm -rf /var/lib/apt/lists/* + exit 0 +fi +echo "docker-init doesn't exist, adding..." + +# By default, make the source and target sockets the same +if [ "${SOURCE_SOCKET}" != "${TARGET_SOCKET}" ]; then + # Create parent directory if it doesn't exist + mkdir -p "$(dirname "${SOURCE_SOCKET}")" + touch "${SOURCE_SOCKET}" + ln -s "${SOURCE_SOCKET}" "${TARGET_SOCKET}" +fi + +# Add a stub if not adding non-root user access, user is root +if [ "${ENABLE_NONROOT_DOCKER}" = "false" ] || [ "${USERNAME}" = "root" ]; then + echo -e '#!/usr/bin/env bash\nexec "$@"' > /usr/local/share/docker-init.sh + chmod +x /usr/local/share/docker-init.sh + # Clean up + rm -rf /var/lib/apt/lists/* + exit 0 +fi + +DOCKER_GID="$(grep -oP '^docker:x:\K[^:]+' /etc/group)" + +# If enabling non-root access and specified user is found, setup socat and add script +chown -h "${USERNAME}":root "${TARGET_SOCKET}" +check_packages socat +tee /usr/local/share/docker-init.sh > /dev/null \ +<< EOF +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -e + +SOCAT_PATH_BASE=/tmp/vscr-docker-from-docker +SOCAT_LOG=\${SOCAT_PATH_BASE}.log +SOCAT_PID=\${SOCAT_PATH_BASE}.pid + +# Wrapper function to only use sudo if not already root +sudoIf() +{ + if [ "\$(id -u)" -ne 0 ]; then + sudo "\$@" + else + "\$@" + fi +} + +# Log messages +log() +{ + echo -e "[\$(date)] \$@" | sudoIf tee -a \${SOCAT_LOG} > /dev/null +} + +echo -e "\n** \$(date) **" | sudoIf tee -a \${SOCAT_LOG} > /dev/null +log "Ensuring ${USERNAME} has access to ${SOURCE_SOCKET} via ${TARGET_SOCKET}" + +# If enabled, try to update the docker group with the right GID. If the group is root, +# fall back on using socat to forward the docker socket to another unix socket so +# that we can set permissions on it without affecting the host. +if [ "${ENABLE_NONROOT_DOCKER}" = "true" ] && [ "${SOURCE_SOCKET}" != "${TARGET_SOCKET}" ] && [ "${USERNAME}" != "root" ] && [ "${USERNAME}" != "0" ]; then + SOCKET_GID=\$(stat -c '%g' ${SOURCE_SOCKET}) + if [ "\${SOCKET_GID}" != "0" ] && [ "\${SOCKET_GID}" != "${DOCKER_GID}" ] && ! grep -E ".+:x:\${SOCKET_GID}" /etc/group; then + sudoIf groupmod --gid "\${SOCKET_GID}" docker + else + # Enable proxy if not already running + if [ ! -f "\${SOCAT_PID}" ] || ! ps -p \$(cat \${SOCAT_PID}) > /dev/null; then + log "Enabling socket proxy." + log "Proxying ${SOURCE_SOCKET} to ${TARGET_SOCKET} for vscode" + sudoIf rm -rf ${TARGET_SOCKET} + (sudoIf socat UNIX-LISTEN:${TARGET_SOCKET},fork,mode=660,user=${USERNAME},backlog=128 UNIX-CONNECT:${SOURCE_SOCKET} 2>&1 | sudoIf tee -a \${SOCAT_LOG} > /dev/null & echo "\$!" | sudoIf tee \${SOCAT_PID} > /dev/null) + else + log "Socket proxy already running." + fi + fi + log "Success" +fi + +# Execute whatever commands were passed in (if any). This allows us +# to set this script to ENTRYPOINT while still executing the default CMD. +set +e +exec "\$@" +EOF +chmod +x /usr/local/share/docker-init.sh +chown ${USERNAME}:root /usr/local/share/docker-init.sh + +# Clean up +rm -rf /var/lib/apt/lists/* + +echo "Done!" diff --git a/src/dotnet/NOTES.md b/src/dotnet/NOTES.md new file mode 100644 index 000000000..c5d6f6071 --- /dev/null +++ b/src/dotnet/NOTES.md @@ -0,0 +1,110 @@ +## Configuration examples + +Installing only the latest .NET SDK version (the default). + +``` jsonc +"features": { + "ghcr.io/devcontainers/features/dotnet:2": "latest" // or "" or {} +} +``` + +Installing an additional SDK version. Multiple versions can be specified as comma-separated values. + +``` jsonc +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "latest", // (this can be omitted) + "additionalVersions": "lts" + } +} +``` + +Installing specific SDK versions. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "6.0", + "additionalVersions": "7.0, 8.0" + } +} +``` + +Installing a specific SDK feature band. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "6.0.4xx", + } +} +``` + +Installing a specific SDK patch version. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "6.0.412", + } +} +``` + +Installing only the .NET Runtime or the ASP.NET Core Runtime. (The SDK includes all runtimes so this configuration is only useful if you need to run .NET apps without building them from source.) + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "none", + "dotnetRuntimeVersions": "latest, lts", + "aspNetCoreRuntimeVersions": "latest, lts", + } +} +``` + +Installing .NET workloads. Multiple workloads can be specified as comma-separated values. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "workloads": "wasm-tools" + } +} +``` + +Installing prerelease builds. Supports `preview` and `daily` suffixes. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "10.0-preview", + "additionalVersions": "10.0.1xx-daily", + "dotnetRuntimeVersions": "10.0-daily", + "aspNetCoreRuntimeVersions": "10.0-daily" + } +} +``` + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + +## Tab completions + +When using .NET SDK 10 or newer, tab completions for the `dotnet` CLI are automatically installed for bash, zsh, and fish. The completion scripts are placed in the standard system-wide directories so they work for all users: + +- **Bash**: `/usr/share/bash-completion/completions/dotnet` +- **Zsh**: `/usr/share/zsh/site-functions/_dotnet` +- **Fish**: `/usr/share/fish/vendor_completions.d/dotnet.fish` + +To disable this, set `tabCompletions` to `false`: + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "tabCompletions": false + } +} +``` diff --git a/src/dotnet/README.md b/src/dotnet/README.md index 63d2a7cc5..ceb9c544f 100644 --- a/src/dotnet/README.md +++ b/src/dotnet/README.md @@ -1,15 +1,13 @@ # Dotnet CLI (dotnet) -Installs the .NET CLI. Provides option of installing sdk or runtime, and option of versions to install. Uses latest version of .NET sdk as defaults to install. +This Feature installs the latest .NET SDK, which includes the .NET CLI and the shared runtime. Options are provided to choose a different version or additional versions. ## Example Usage ```json "features": { - "ghcr.io/devcontainers/features/dotnet:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/dotnet:2": {} } ``` @@ -17,10 +15,97 @@ Installs the .NET CLI. Provides option of installing sdk or runtime, and option | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| -| version | Select or enter a dotnet CLI version. (Available versions may vary by Linux distribution.) | string | latest | -| runtimeOnly | Install just the dotnet runtime if true, and sdk if false. | boolean | - | -| installUsingApt | If true, it installs using apt instead of the release URL | boolean | true | +| version | Select or enter a .NET SDK version. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version, 'X.Y-preview' or 'X.Y-daily' for prereleases. | string | latest | +| additionalVersions | Enter additional .NET SDK versions, separated by commas. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version, 'X.Y-preview' or 'X.Y-daily' for prereleases. | string | - | +| dotnetRuntimeVersions | Enter additional .NET runtime versions, separated by commas. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version, 'X.Y-preview' or 'X.Y-daily' for prereleases. | string | - | +| aspNetCoreRuntimeVersions | Enter additional ASP.NET Core runtime versions, separated by commas. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version, 'X.Y-preview' or 'X.Y-daily' for prereleases. | string | - | +| workloads | Enter additional .NET SDK workloads, separated by commas. Use 'dotnet workload search' to learn what workloads are available to install. | string | - | +| tabCompletions | Install shell tab completions for the dotnet CLI. Requires SDK 10 or newer. | boolean | true | +## Customizations + +### VS Code Extensions + +- `ms-dotnettools.csharp` + +## Configuration examples + +Installing only the latest .NET SDK version (the default). + +``` jsonc +"features": { + "ghcr.io/devcontainers/features/dotnet:2": "latest" // or "" or {} +} +``` + +Installing an additional SDK version. Multiple versions can be specified as comma-separated values. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "additionalVersions": "lts" + } +} +``` + +Installing specific SDK versions. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "6.0", + "additionalVersions": "7.0, 8.0" + } +} +``` + +Installing a specific SDK feature band. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "6.0.4xx", + } +} +``` + +Installing a specific SDK patch version. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "6.0.412", + } +} +``` + +Installing only the .NET Runtime or the ASP.NET Core Runtime. (The SDK includes all runtimes so this configuration is only useful if you need to run .NET apps without building them from source.) + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "none", + "dotnetRuntimeVersions": "latest, lts", + "aspNetCoreRuntimeVersions": "latest, lts", + } +} +``` + +Installing .NET workloads. Multiple workloads can be specified as comma-separated values. + +``` json +"features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "workloads": "wasm-tools" + } +} +``` + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/dotnet/devcontainer-feature.json b/src/dotnet/devcontainer-feature.json index 5d9b0f334..4bd3168c7 100644 --- a/src/dotnet/devcontainer-feature.json +++ b/src/dotnet/devcontainer-feature.json @@ -1,41 +1,72 @@ { "id": "dotnet", - "version": "1.0.6", + "version": "2.5.0", "name": "Dotnet CLI", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/dotnet", - "description": "Installs the .NET CLI. Provides option of installing sdk or runtime, and option of versions to install. Uses latest version of .NET sdk as defaults to install.", + "description": "This Feature installs the latest .NET SDK, which includes the .NET CLI and the shared runtime. Options are provided to choose a different version or additional versions.", "options": { "version": { "type": "string", "proposals": [ "latest", - "6", - "5", - "3.1" + "lts", + "none", + "10.0", + "9.0", + "8.0", + "7.0", + "6.0" ], "default": "latest", - "description": "Select or enter a dotnet CLI version. (Available versions may vary by Linux distribution.)" + "description": "Select or enter a .NET SDK version. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version, 'X.Y-preview' or 'X.Y-daily' for prereleases." }, - "runtimeOnly": { - "type": "boolean", - "default": false, - "description": "Install just the dotnet runtime if true, and sdk if false." + "additionalVersions": { + "type": "string", + "default": "", + "description": "Enter additional .NET SDK versions, separated by commas. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version, 'X.Y-preview' or 'X.Y-daily' for prereleases." + }, + "dotnetRuntimeVersions": { + "type": "string", + "default": "", + "description": "Enter additional .NET runtime versions, separated by commas. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version, 'X.Y-preview' or 'X.Y-daily' for prereleases." + }, + "aspNetCoreRuntimeVersions": { + "type": "string", + "default": "", + "description": "Enter additional ASP.NET Core runtime versions, separated by commas. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version, 'X.Y-preview' or 'X.Y-daily' for prereleases." + }, + "workloads": { + "type": "string", + "default": "", + "description": "Enter additional .NET SDK workloads, separated by commas. Use 'dotnet workload search' to learn what workloads are available to install." }, - "installUsingApt": { + "tabCompletions": { "type": "boolean", - "default": "true", - "description": "If true, it installs using apt instead of the release URL" + "default": true, + "description": "Install shell tab completions for the dotnet CLI. Requires SDK 10 or newer." } }, "containerEnv": { - "DOTNET_ROOT": "/usr/local/dotnet/current", - "PATH": "/usr/local/dotnet/current:${PATH}" + "DOTNET_ROOT": "/usr/share/dotnet", + "PATH": "$PATH:$DOTNET_ROOT", + "DOTNET_RUNNING_IN_CONTAINER": "true", + "DOTNET_USE_POLLING_FILE_WATCHER": "true" }, "customizations": { "vscode": { "extensions": [ "ms-dotnettools.csharp" - ] + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the .NET SDK, which includes the .NET CLI and the shared runtime, pre-installed and available on the `PATH`, along with the C# language extension for .NET development." + } + ] + } } - } -} + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} \ No newline at end of file diff --git a/src/dotnet/install.sh b/src/dotnet/install.sh old mode 100755 new mode 100644 index d9399cad4..a8e8436e5 --- a/src/dotnet/install.sh +++ b/src/dotnet/install.sh @@ -4,116 +4,46 @@ # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. #------------------------------------------------------------------------------------------------------------- # -# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/dotnet.md -# Maintainer: The VS Code and Codespaces Teams +# Docs: https://github.com/devcontainers/features/tree/main/src/dotnet +# Maintainer: The Dev Container spec maintainers +DOTNET_VERSION="${VERSION:-"latest"}" +ADDITIONAL_VERSIONS="${ADDITIONALVERSIONS:-""}" +DOTNET_RUNTIME_VERSIONS="${DOTNETRUNTIMEVERSIONS:-""}" +ASPNETCORE_RUNTIME_VERSIONS="${ASPNETCORERUNTIMEVERSIONS:-""}" +WORKLOADS="${WORKLOADS:-""}" +TAB_COMPLETIONS="${TABCOMPLETIONS:-"true"}" -DOTNET_VERSION=${VERSION:-"latest"} -DOTNET_RUNTIME_ONLY=${RUNTIMEONLY:-"false"} -OVERRIDE_DEFAULT_VERSION=${OVERRIDEDEFAULTVERSION:-"true"} -INSTALL_USING_APT=${INSTALLUSINGAPT:-"true"} +# Prevent "Welcome to .NET" message from dotnet +export DOTNET_NOLOGO=true -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} -TARGET_DOTNET_ROOT=${TARGET_DOTNET_ROOT:-"/usr/local/dotnet"} -ACCESS_GROUP=${ACCESS_GROUP:-"dotnet"} +# Prevent generating a development certificate while running this script +# Otherwise it would be stored in the image, which is undesirable +export DOTNET_GENERATE_ASPNET_CERTIFICATE=false -MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" -DOTNET_ARCHIVE_ARCHITECTURES="amd64" -DOTNET_ARCHIVE_VERSION_CODENAMES="buster bullseye bionic focal hirsute jammy" -# Feed URI sourced from the official dotnet-install.sh -# https://github.com/dotnet/install-scripts/blob/1b98b94a6f6d81cc4845eb88e0195fac67caa0a6/src/dotnet-install.sh#L1342-L1343 -DOTNET_CDN_FEED_URI="https://dotnetcli.azureedge.net" -# Ubuntu 22.04 and on do not ship with libssl1.1, which is required for versions of .NET < 6.0 -DOTNET_VERSION_CODENAMES_REQUIRE_OLDER_LIBSSL_1="buster bullseye bionic focal hirsute" +set -e -# Comma-separated list of dotnet versions to be installed -# alongside DOTNET_VERSION, but not set as default. -ADDITIONAL_VERSIONS=${ADDITIONALVERSIONS:-""} +# Import trim_whitespace and split_csv +source "scripts/string-helpers.sh" + +# Import install_sdk and install_runtime +source "scripts/dotnet-helpers.sh" + +# Clean up +rm -rf /var/lib/apt/lists/* # Setup STDERR. err() { echo "(!) $*" >&2 } -# Ensure the appropriate root user is running the script. -if [ "$(id -u)" -ne 0 ]; then - err 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' - exit 1 -fi - -# Ensure that login shells get the correct path if the user updated the PATH using ENV. -rm -f /etc/profile.d/00-restore-env.sh -echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh -chmod +x /etc/profile.d/00-restore-env.sh - -# Determine the appropriate non-root user. -if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u "${CURRENT_USER}" > /dev/null 2>&1; then - USERNAME="${CURRENT_USER}" - break - fi - done - if [ "${USERNAME}" = "" ]; then - USERNAME=root - fi -elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then - USERNAME=root -fi - -################### -# Helper Functions -################### - -# Cleanup temporary directory and associated files when exiting the script. -cleanup() { - EXIT_CODE=$? - set +e - if [[ -n "${TMP_DIR}" ]]; then - echo "Executing cleanup of tmp files" - rm -Rf "${TMP_DIR}" - fi - exit $EXIT_CODE -} -trap cleanup EXIT - -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi +apt_get_update() { + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y fi - echo "$1=${!1}" -} - -# Add dotnet directory to PATH in bashrc/zshrc files if OVERRIDE_DEFAULT_VERSION=true. -updaterc() { - if [ "${UPDATE_RC}" = "true" ]; then - echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." - if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/bash.bashrc - fi - if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/zsh/zshrc - fi - fi -} - -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y } -# Check if packages are installed and installs them if not. +# Checks if packages are installed and installs them if not check_packages() { if ! dpkg -s "$@" > /dev/null 2>&1; then apt_get_update @@ -121,346 +51,115 @@ check_packages() { fi } -# Get appropriate architecture name for .NET binaries for the target OS -get_architecture_name_for_target_os() { - local architecture - architecture="$(uname -m)" - case $architecture in - x86_64) architecture="x64";; - aarch64 | armv8*) architecture="arm64";; - *) err "Architecture ${architecture} unsupported"; exit 1 ;; - esac - - echo "${architecture}" -} - -# Soft version matching that resolves a version for a given package in the *current apt-cache* -# Return value is stored in first argument (the unprocessed version) -apt_cache_package_and_version_soft_match() { - # Version - local version_variable_name="$1" - local requested_version=${!version_variable_name} - # Package Name - local package_variable_name="$2" - local partial_package_name=${!package_variable_name} - local package_name - # Exit on no match? - local exit_on_no_match="${3:-true}" - local major_minor_version - - # Ensure we've exported useful variables - . /etc/os-release - local architecture="$(dpkg --print-architecture)" - - major_minor_version="$(echo "${requested_version}" | cut -d "." --field=1,2)" - package_name="$(apt-cache search "${partial_package_name}-[0-9].[0-9]" | awk -F" - " '{print $1}' | grep -m 1 "${partial_package_name}-${major_minor_version}")" - - dot_escaped="${requested_version//./\\.}" - dot_plus_escaped="${dot_escaped//+/\\+}" - # Regex needs to handle debian package version number format: https://www.systutorials.com/docs/linux/man/5-deb-version/ - version_regex="^(.+:)?${dot_plus_escaped}([\\.\\+ ~:-]|$)" - set +e # Don't exit if finding version fails - handle gracefully - fuzzy_version="$(apt-cache madison ${package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${version_regex}")" - set -e - if [ -z "${fuzzy_version}" ]; then - echo "(!) No full or partial for package \"${partial_package_name}\" (resolved: \"${package_name}\") match found in apt-cache for \"${requested_version}\" on OS ${ID} ${VERSION_CODENAME} (${architecture})." - - if $exit_on_no_match; then - echo "Available versions:" - apt-cache madison ${package_name} | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' - exit 1 # Fail entire script - else - echo "Continuing to fallback method if available" - return 1; - fi - fi - - # Globally assign fuzzy_version to this value - # Use this value as the return value of this function - declare -g ${version_variable_name}="=${fuzzy_version}" - echo "${version_variable_name} ${!version_variable_name}" - - # Globally assign package to this value - # Use this value as the return value of this function - declare -g ${package_variable_name}="${package_name}" - echo "${package_variable_name} ${!package_variable_name}" -} - -# Install .NET CLI using apt-get package installer -install_using_apt_from_microsoft_repo() { - local sdk_or_runtime="$1" - local dotnet_major_minor_version - export DOTNET_PACKAGE="dotnet-${sdk_or_runtime}" - - # Install dependencies - check_packages apt-transport-https curl ca-certificates gnupg2 dirmngr - - # Import key safely and import Microsoft apt repo - get_common_setting MICROSOFT_GPG_KEYS_URI - curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg - echo "deb [arch=${architecture} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list - apt-get update -y - - if [ "${DOTNET_VERSION}" = "latest" ] || [ "${DOTNET_VERSION}" = "lts" ]; then - DOTNET_VERSION="" - DOTNET_PACKAGE="${DOTNET_PACKAGE}-6.0" - else - # Sets DOTNET_VERSION and DOTNET_PACKAGE if matches found. - apt_cache_package_and_version_soft_match DOTNET_VERSION DOTNET_PACKAGE false - if [ "$?" != 0 ]; then - echo "Failed to find requested version." - return 1 - fi - - if [[ $(dotnet --version) == *"${DOTNET_VERSION}"* ]] ; then - echo "Dotnet version ${DOTNET_VERSION} is already installed" - return 1 - fi - - fi - - echo "Installing '${DOTNET_PACKAGE}${DOTNET_VERSION}'..." - apt-get install -yq ${DOTNET_PACKAGE}${DOTNET_VERSION} - if [ "$?" != 0 ]; then - echo "Failed to complete apt install of ${DOTNET_PACKAGE}${DOTNET_VERSION}" - return 1 - fi -} - -install_using_default_apt_repo() { - DOTNET_PACKAGE="dotnet6" - - apt_get_update - - if [[ "${DOTNET_VERSION}" = "latest" ]] || [[ "${DOTNET_VERSION}" = "lts" ]] || [[ ${DOTNET_VERSION} = "6"* ]]; then - if ! (apt-get install -yq ${DOTNET_PACKAGE}); then - echo "Failed to install 'dotnet6' package from default apt repo." - return 1 - fi - else - echo "The provided dotnet version is not distributed in this distro's default apt repo." - return 1 - fi -} - -# Find and extract .NET binary download details based on user-requested version -# args: -# sdk_or_runtime $1 -# exports: -# DOTNET_DOWNLOAD_URL -# DOTNET_DOWNLOAD_HASH -# DOTNET_DOWNLOAD_NAME -get_full_version_details() { - local sdk_or_runtime="$1" - local VERSION="$2" - local architecture - local dotnet_channel_version - local dotnet_releases_url - local dotnet_releases_json - local dotnet_latest_version - local dotnet_download_details - - export DOTNET_DOWNLOAD_URL - export DOTNET_DOWNLOAD_HASH - export DOTNET_DOWNLOAD_NAME - - # Set architecture variable to current user's architecture (x64 or ARM64). - architecture="$(get_architecture_name_for_target_os)" - - # Set VERSION to empty string to ensure jq includes all .NET versions in reverse sort below - if [ "${VERSION}" = "latest" ]; then - VERSION="" - fi - - dotnet_patchless_version="$(echo "${VERSION}" | cut -d "." --field=1,2)" - - set +e - dotnet_channel_version="$(curl -s "${DOTNET_CDN_FEED_URI}/dotnet/release-metadata/releases-index.json" | jq -r --arg channel_version "${dotnet_patchless_version}" '[."releases-index"[]] | sort_by(."channel-version") | reverse | map( select(."channel-version" | startswith($channel_version))) | first | ."channel-version"')" - set -e +if [ "$(id -u)" -ne 0 ]; then + err 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi - # Construct the releases URL using the official channel-version if one was found. Otherwise make a best-effort using the user input. - if [ -n "${dotnet_channel_version}" ] && [ "${dotnet_channel_version}" != "null" ]; then - dotnet_releases_url="${DOTNET_CDN_FEED_URI}/dotnet/release-metadata/${dotnet_channel_version}/releases.json" - else - dotnet_releases_url="${DOTNET_CDN_FEED_URI}/dotnet/release-metadata/${dotnet_patchless_version}/releases.json" - fi +# For our own convenience, combine DOTNET_VERSION and ADDITIONAL_VERSIONS into a single 'versions' array +versions=() - set +e - dotnet_releases_json="$(curl -s "${dotnet_releases_url}")" - set -e - - if [ -n "${dotnet_releases_json}" ] && [[ ! "${dotnet_releases_json}" = *"Error"* ]]; then - dotnet_latest_version="$(echo "${dotnet_releases_json}" | jq -r --arg sdk_or_runtime "${sdk_or_runtime}" '."latest-\($sdk_or_runtime)"')" - # If user-specified version has 2 or more dots, use it as is. Otherwise use latest version. - if [ "$(echo "${VERSION}" | grep -o "\." | wc -l)" -lt "2" ]; then - VERSION="${dotnet_latest_version}" - fi +# The version can be set to 'none' for runtime-only installations, then the array will just remain empty +# Ensure there are no leading or trailing spaces that can break regex pattern matching +if [ "$DOTNET_VERSION" != "none" ]; then + versions+=("$(trim_whitespace "$DOTNET_VERSION")") + for additional_version in $(split_csv "$ADDITIONAL_VERSIONS"); do + versions+=("$additional_version") + done +fi - dotnet_download_details="$(echo "${dotnet_releases_json}" | jq -r --arg sdk_or_runtime "${sdk_or_runtime}" --arg dotnet_version "${VERSION}" --arg arch "${architecture}" '.releases[]."\($sdk_or_runtime)" | select(.version==$dotnet_version) | .files[] | select(.name=="dotnet-\($sdk_or_runtime)-linux-\($arch).tar.gz")')" - if [ -n "${dotnet_download_details}" ]; then - echo "Found .NET binary version ${VERSION}" - DOTNET_DOWNLOAD_URL="$(echo "${dotnet_download_details}" | jq -r '.url')" - DOTNET_DOWNLOAD_HASH="$(echo "${dotnet_download_details}" | jq -r '.hash')" - DOTNET_DOWNLOAD_NAME="$(echo "${dotnet_download_details}" | jq -r '.name')" - else - err "Unable to find .NET binary for version ${VERSION}" - exit 1 - fi - else - err "Unable to find .NET release details for version ${VERSION} at ${dotnet_releases_url}" +dotnetRuntimeVersions=() +for dotnetRuntimeVersion in $(split_csv "$DOTNET_RUNTIME_VERSIONS"); do + dotnetRuntimeVersions+=("$dotnetRuntimeVersion") +done + +aspNetCoreRuntimeVersions=() +for aspNetCoreRuntimeVersion in $(split_csv "$ASPNETCORE_RUNTIME_VERSIONS"); do + aspNetCoreRuntimeVersions+=("$aspNetCoreRuntimeVersion") +done + +# Fail fast in case of bad input to avoid unneccesary work +# v1 of the .NET feature allowed specifying only a major version 'X' like '3' +# v2 removed this ability +# - because install-dotnet.sh does not support it directly +# - because the previous behavior installed an old version like '3.0.103', not the newest version '3.1.426', which was counterintuitive +for version in "${versions[@]}"; do + if [[ "$version" =~ ^[0-9]+$ ]]; then + err "Unsupported .NET SDK version '${version}'. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version." exit 1 fi -} - -# Install .NET CLI using the .NET releases url -install_using_dotnet_releases_url() { - local sdk_or_runtime="$1" - local VERSION="$2" +done - # Check listed package dependecies and install them if they are not already installed. - # NOTE: icu-devtools is a small package with similar dependecies to .NET. - # It will install the appropriate dependencies based on the OS: - # - libgcc-s1 OR libgcc1 depending on OS - # - the latest libicuXX depending on OS (eg libicu57 for stretch) - # - also installs libc6 and libstdc++6 which are required by .NET - check_packages curl ca-certificates tar jq icu-devtools libgssapi-krb5-2 zlib1g - - # Starting with Ubuntu 22.04 (jammy), libssl1.1 does not ship with the OS anymore. - if [[ "${DOTNET_VERSION_CODENAMES_REQUIRE_OLDER_LIBSSL_1}" = *"${VERSION_CODENAME}"* ]]; then - check_packages libssl1.1 - else - check_packages libssl3.0 - fi - - get_full_version_details "${sdk_or_runtime}" "${VERSION}" - - DOTNET_INSTALL_PATH="${TARGET_DOTNET_ROOT}/${VERSION}" - if [ -d "${DOTNET_INSTALL_PATH}" ]; then - echo "(!) Dotnet version ${VERSION} already exists." +for version in "${dotnetRuntimeVersions[@]}"; do + if [[ "$version" =~ ^[0-9]+$ ]]; then + err "Unsupported .NET Runtime version '${version}'. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version." exit 1 fi - # exports DOTNET_DOWNLOAD_URL, DOTNET_DOWNLOAD_HASH, DOTNET_DOWNLOAD_NAME - echo "DOWNLOAD LINK: ${DOTNET_DOWNLOAD_URL}" +done - # Setup the access group and add the user to it. - umask 0002 - if ! cat /etc/group | grep -e "^${ACCESS_GROUP}:" > /dev/null 2>&1; then - groupadd -r "${ACCESS_GROUP}" +for version in "${aspNetCoreRuntimeVersions[@]}"; do + if [[ "$version" =~ ^[0-9]+$ ]]; then + err "Unsupported ASP.NET Core Runtime version '${version}'. Use 'latest' for the latest version, 'lts' for the latest LTS version, 'X.Y' or 'X.Y.Z' for a specific version." + exit 1 fi - usermod -a -G "${ACCESS_GROUP}" "${USERNAME}" +done - # Download the .NET binaries. - echo "DOWNLOADING BINARY..." - TMP_DIR="/tmp/dotnetinstall" - mkdir -p "${TMP_DIR}" - curl -sSL "${DOTNET_DOWNLOAD_URL}" -o "${TMP_DIR}/${DOTNET_DOWNLOAD_NAME}" +# Install .NET versions and dependencies +# icu-devtools includes dependencies for .NET +check_packages wget ca-certificates icu-devtools jq - # Get checksum from .NET CLI blob storage using the runtime version and - # run validation (sha512sum) of checksum against the expected checksum hash. - echo "VERIFY CHECKSUM" - cd "${TMP_DIR}" - echo "${DOTNET_DOWNLOAD_HASH} *${DOTNET_DOWNLOAD_NAME}" | sha512sum -c - - - # Extract binaries and add to path. - mkdir -p "${DOTNET_INSTALL_PATH}" - echo "Extract Binary to ${DOTNET_INSTALL_PATH}" - tar -xzf "${TMP_DIR}/${DOTNET_DOWNLOAD_NAME}" -C "${DOTNET_INSTALL_PATH}" --strip-components=1 - - CURRENT_DIR="${TARGET_DOTNET_ROOT}/current" - if [[ ! -d "${CURRENT_DIR}" ]]; then - ln -s "${DOTNET_INSTALL_PATH}" "${CURRENT_DIR}" +for version in "${versions[@]}"; do + read -r clean_version quality < <(parse_version_and_quality "$version") + if [ -n "$quality" ]; then + echo "Interpreting requested version '$version' as version '$clean_version' with quality '$quality'" fi + install_sdk "$clean_version" "$quality" +done - # Give write permissions to the user. - chown -R "${USERNAME}:${USERNAME}" "${CURRENT_DIR}" - chmod g+r+w+s "${CURRENT_DIR}" - chmod -R g+r+w "${CURRENT_DIR}" - - if [[ "${OVERRIDE_DEFAULT_VERSION}" = "true" ]]; then - if [[ $(ls -l ${CURRENT_DIR}) != *"-> ${DOTNET_INSTALL_PATH}"* ]] ; then - rm "${CURRENT_DIR}" - ln -s "${DOTNET_INSTALL_PATH}" "${CURRENT_DIR}" - fi +for version in "${dotnetRuntimeVersions[@]}"; do + read -r clean_version quality < <(parse_version_and_quality "$version") + if [ -n "$quality" ]; then + echo "Interpreting requested runtime version '$version' as version '$clean_version' with quality '$quality'" fi - - updaterc "if [[ \"\${PATH}\" != *\"${CURRENT_DIR}\"* ]]; then export PATH=${CURRENT_DIR}:\${PATH}; fi" -} - -########################### -# Start .NET installation -########################### - -export DEBIAN_FRONTEND=noninteractive + install_runtime "dotnet" "$clean_version" "$quality" +done -. /etc/os-release -architecture="$(dpkg --print-architecture)" - -# Dotnet 3.1 and 5 are not supported on Ubuntu 22.04 (jammy)+, -# due to lack of libssl3.0 support. -# See: https://github.com/microsoft/vscode-dev-containers/issues/1458#issuecomment-1135077775 -# NOTE: This will only guard against installation of the dotnet versions we propose via 'features'. -# The user can attempt to install any other version at their own risk. -if [[ "${DOTNET_VERSION}" = "3"* ]] || [[ "${DOTNET_VERSION}" = "5"* ]]; then - if [[ ! "${DOTNET_VERSION_CODENAMES_REQUIRE_OLDER_LIBSSL_1}" = *"${VERSION_CODENAME}"* ]]; then - err "Dotnet ${DOTNET_VERSION} is not supported on Ubuntu ${VERSION_CODENAME} due to a change in the 'libssl' dependency across distributions.\n Please upgrade your version of dotnet, or downgrade your OS version." - exit 1 +for version in "${aspNetCoreRuntimeVersions[@]}"; do + read -r clean_version quality < <(parse_version_and_quality "$version") + if [ -n "$quality" ]; then + echo "Interpreting requested ASP.NET Core runtime version '$version' as version '$clean_version' with quality '$quality'" fi -fi + install_runtime "aspnetcore" "$clean_version" "$quality" +done -# Determine if the user wants to download .NET Runtime only, or .NET SDK & Runtime -# and set the appropriate variables. -if [ "${DOTNET_RUNTIME_ONLY}" = "true" ]; then - DOTNET_SDK_OR_RUNTIME="runtime" -elif [ "${DOTNET_RUNTIME_ONLY}" = "false" ]; then - DOTNET_SDK_OR_RUNTIME="sdk" -else - err "Expected true for installing dotnet Runtime only or false for installing SDK and Runtime. Received ${DOTNET_RUNTIME_ONLY}." - exit 1 -fi +workloads=() +for workload in $(split_csv "$WORKLOADS"); do + workloads+=("$workload") +done -# Install the .NET CLI -echo "(*) Installing .NET CLI..." - -CHANGE_OWNERSHIP="false" -if [[ "${DOTNET_ARCHIVE_ARCHITECTURES}" = *"${architecture}"* ]] && [[ "${DOTNET_ARCHIVE_VERSION_CODENAMES}" = *"${VERSION_CODENAME}"* ]] && [[ "${INSTALL_USING_APT}" = "true" ]]; then - echo "Detected ${VERSION_CODENAME} on ${architecture}. Attempting to install dotnet from apt" +if [ ${#workloads[@]} -ne 0 ]; then + install_workloads "${workloads[@]}" +fi - install_using_default_apt_repo || install_using_apt_from_microsoft_repo "${DOTNET_SDK_OR_RUNTIME}" - if [ "$?" != 0 ]; then - echo "Could not install requested version from apt on current distribution." - exit 1 - fi -else - if [[ "${INSTALL_USING_APT}" = "false" ]]; then - echo "Installing dotnet from releases url" - else - echo "Attempting to install dotnet from releases url" - fi - install_using_dotnet_releases_url "${DOTNET_SDK_OR_RUNTIME}" "${DOTNET_VERSION}" - CHANGE_OWNERSHIP="true" +# Create a symbolic link '/usr/bin/dotnet', to make dotnet available to 'sudo' +# This is necessary because 'sudo' resets the PATH variable, so it won't search the DOTNET_ROOT directory +if [ ! -e /usr/bin/dotnet ]; then + ln --symbolic "$DOTNET_ROOT/dotnet" /usr/bin/dotnet fi -# Additional dotnet versions to be installed but not be set as default. -if [ ! -z "${ADDITIONAL_VERSIONS}" ]; then - OLDIFS=$IFS - IFS="," - read -a additional_versions <<< "$ADDITIONAL_VERSIONS" - for version in "${additional_versions[@]}"; do - OVERRIDE_DEFAULT_VERSION="false" - install_using_dotnet_releases_url "${DOTNET_SDK_OR_RUNTIME}" "${version}" - done - IFS=$OLDIFS +if [ "$TAB_COMPLETIONS" = "true" ]; then + install_completions fi -if [ "${CHANGE_OWNERSHIP}" = "true" ]; then - if ! cat /etc/group | grep -e "^dotnet:" > /dev/null 2>&1; then - groupadd -r dotnet - fi - usermod -a -G dotnet "${USERNAME}" +# Add .NET Core SDK tools to PATH for bash and zsh users +# This is where 'dotnet tool install --global ' installs tools to +# Use single-quoted EOF to defer $PATH expansion until sourcing the file +cat << 'EOF' >> /etc/profile.d/dotnet.sh +export PATH="$PATH:$HOME/.dotnet/tools" +EOF - chown -R "${USERNAME}:dotnet" "${TARGET_DOTNET_ROOT}" - chmod -R g+r+w "${TARGET_DOTNET_ROOT}" - find "${TARGET_DOTNET_ROOT}" -type d -print0 | xargs -n 1 -0 chmod g+s -fi +# Clean up +rm -rf /var/lib/apt/lists/* +rm -rf scripts echo "Done!" diff --git a/src/dotnet/scripts/dotnet-helpers.sh b/src/dotnet/scripts/dotnet-helpers.sh new file mode 100644 index 000000000..e4c90819a --- /dev/null +++ b/src/dotnet/scripts/dotnet-helpers.sh @@ -0,0 +1,238 @@ +#!/bin/bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/devcontainers/features/tree/main/src/dotnet +# Maintainer: The Dev Container spec maintainers +DOTNET_SCRIPTS=$(dirname "${BASH_SOURCE[0]}") +DOTNET_INSTALL_SCRIPT="$DOTNET_SCRIPTS/vendor/dotnet-install.sh" +DOTNET_RELEASES_INDEX_URL="https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json" + +# Prints the latest active dotnet version from the releases index. +# Usage: fetch_latest_version [] +# With no target, resolves the latest SDK version. +# With "sdk", resolves the latest SDK version explicitly. +# With "dotnet" or "aspnetcore", resolves the latest runtime version. +# Note: the upstream releases index only distinguishes SDK vs runtime for +# latest resolution, so "dotnet" and "aspnetcore" currently resolve to the +# same version. +# Example: fetch_latest_version +# Example: fetch_latest_version "sdk" +# Example: fetch_latest_version "dotnet" +# Example: fetch_latest_version "aspnetcore" +fetch_latest_version() { + local target="$1" + local version_field="" + local releases_index="" + + case "$target" in + ""|sdk) + version_field="latest-sdk" + ;; + dotnet|aspnetcore) + version_field="latest-runtime" + ;; + *) + echo "Unsupported target '$target'. Expected 'sdk', 'dotnet', or 'aspnetcore'." >&2 + return 1 + ;; + esac + + releases_index="$(wget -qO- "$DOTNET_RELEASES_INDEX_URL")" || return $? + + printf '%s\n' "$releases_index" \ + | jq -er --arg version_field "$version_field" ' + .["releases-index"] + | map( + select(."support-phase" == "active") + | .[$version_field] + ) + | .[0] + ' +} + +# Installs a version of the .NET SDK +# Usage: install_sdk [] +# Example: install_sdk "9.0" +# Example: install_sdk "10.0" "preview" +install_sdk() { + local inputVersion="$1" # Could be 'latest', 'lts', 'X.Y', 'X.Y.Z', 'X.Y.4xx', or base channel when paired with quality + local quality="$2" # Optional quality: GA, preview, daily (empty implies GA) + local version="" + local channel="" + if [[ "$inputVersion" == "latest" ]]; then + # Fetch the latest version manually, because dotnet-install.sh does not support it directly + version=$(fetch_latest_version) + elif [[ "$inputVersion" == "lts" ]]; then + # When user input is 'lts' + # Then version=latest, channel=LTS + version="latest" + channel="LTS" + elif [[ "$inputVersion" =~ ^[0-9]+\.[0-9]+$ ]]; then + # When user input is form 'A.B' like '3.1' + # Then version=latest, channel=3.1 + version="latest" + channel="$inputVersion" + elif [[ "$inputVersion" =~ ^[0-9]+\.[0-9]+\.[0-9]xx$ ]]; then + # When user input is form 'A.B.Cxx' like '6.0.4xx' + # Then version=latest, channel=6.0.4xx + version="latest" + channel="$inputVersion" + else + # Assume version is an exact version string like '6.0.413' or '8.0.100-rc.2.23425.18' + version="$inputVersion" + fi + + local cmd=("$DOTNET_INSTALL_SCRIPT" "--version" "$version" "--install-dir" "$DOTNET_ROOT") + if [ -n "$channel" ]; then + cmd+=("--channel" "$channel") + fi + if [ -n "$quality" ]; then + cmd+=("--quality" "$quality") + fi + echo "Executing ${cmd[*]}" + "${cmd[@]}" +} + +# Installs a version of the .NET Runtime +# Usage: install_runtime [] +# Example: install_runtime "dotnet" "9.0" +# Example: install_runtime "aspnetcore" "10.0" "preview" +install_runtime() { + local runtime="$1" + local inputVersion="$2" # Could be 'latest', 'lts', 'X.Y', 'X.Y.Z' + local quality="$3" # Optional quality: GA, preview, daily (empty implies GA) + local version="" + local channel="" + if [[ "$inputVersion" == "latest" ]]; then + # Fetch the latest version manually, because dotnet-install.sh does not support it directly + version=$(fetch_latest_version "$runtime") + elif [[ "$inputVersion" == "lts" ]]; then + # When user input is 'lts' + # Then version=latest, channel=LTS + version="latest" + channel="LTS" + elif [[ "$inputVersion" =~ ^[0-9]+\.[0-9]+$ ]]; then + # When user input is form 'A.B' like '3.1' + # Then version=latest, channel=3.1 + version="latest" + channel="$inputVersion" + else + # Assume version is an exact version string like '6.0.21' or '8.0.0-preview.7.23375.6' + version="$inputVersion" + fi + + local cmd=("$DOTNET_INSTALL_SCRIPT" "--runtime" "$runtime" "--version" "$version" "--install-dir" "$DOTNET_ROOT" "--no-path") + if [ -n "$channel" ]; then + cmd+=("--channel" "$channel") + fi + if [ -n "$quality" ]; then + cmd+=("--quality" "$quality") + fi + echo "Executing ${cmd[*]}" + "${cmd[@]}" +} + +# Installs one or more .NET workloads +# Usage: install_workload [ ...] +# Reference: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-workload-install +install_workloads() { + local workloads="$@" + + echo "Installing .NET workload(s) $workloads" + dotnet workload install $workloads --temp-dir /tmp/dotnet-workload-temp-dir + + # Clean up + rm -r /tmp/dotnet-workload-temp-dir +} + +# Input: version spec possibly containing -preview or -daily +# Supports channels in the forms: +# A.B (e.g. 10.0) +# A.B.Cxx (feature band e.g. 6.0.4xx) +# A.B-preview (adds quality) +# A.B-daily +# A.B.Cxx-preview +# A.B.Cxx-daily +# Output (stdout): " " +# - For channel specs (A.B or A.B.Cxx) without suffix -> quality is GA +# - For channel specs with -preview/-daily suffix -> quality is preview/daily +# - For exact version specs (contain a third numeric segment or prerelease labels beyond channel patterns, e.g. 8.0.100-rc.2.23502.2) -> quality is empty +# Examples: +# parse_version_and_quality "10.0-preview" => "10.0 preview" +# parse_version_and_quality "10.0-daily" => "10.0 daily" +# parse_version_and_quality "10.0" => "10.0 GA" +# parse_version_and_quality "6.0.4xx" => "6.0.4xx GA" +# parse_version_and_quality "6.0.4xx-preview" => "6.0.4xx preview" +# parse_version_and_quality "6.0.4xx-daily" => "6.0.4xx daily" +parse_version_and_quality() { + local input="$1" + local quality="" + local clean_version="$input" + # Match feature band with quality + if [[ "$input" =~ ^([0-9]+\.[0-9]+\.[0-9]xx)-(preview|daily)$ ]]; then + clean_version="${BASH_REMATCH[1]}" + quality="${BASH_REMATCH[2]}" + # Match simple channel with quality + elif [[ "$input" =~ ^([0-9]+\.[0-9]+)-(preview|daily)$ ]]; then + clean_version="${BASH_REMATCH[1]}" + quality="${BASH_REMATCH[2]}" + # Match plain feature band channel (defaults to GA) + elif [[ "$input" =~ ^[0-9]+\.[0-9]+\.[0-9]xx$ ]]; then + clean_version="$input" + quality="GA" + # Match simple channel (defaults to GA) + elif [[ "$input" =~ ^[0-9]+\.[0-9]+$ ]]; then + clean_version="$input" + quality="GA" + else + # Exact version (leave quality empty) + clean_version="$input" + quality="" + fi + echo "$clean_version" "$quality" +} + +# Checks if the installed .NET SDK is at least the given major version. +# Returns 0 (true) if the SDK major version >= the specified version, 1 otherwise. +# Also returns 1 if no SDK is installed (e.g. runtime-only installs). +# Usage: is_at_least_sdk_version +# Example: is_at_least_sdk_version 10 +is_at_least_sdk_version() { + local required_major="$1" + local dotnet_version + dotnet_version=$("$DOTNET_ROOT/dotnet" --version 2>/dev/null || true) + local major_version="${dotnet_version%%.*}" + [[ "$major_version" =~ ^[0-9]+$ ]] && [ "$major_version" -ge "$required_major" ] +} + +# Sets up dotnet tab completions for bash, zsh, and fish. +# The 'dotnet completions script' command is only available in .NET SDK 10+. +# Older SDKs and runtime-only installs will naturally skip this since the +# command won't be available. +# Reference: https://learn.microsoft.com/en-us/dotnet/core/tools/enable-tab-autocomplete +# Completion scripts are generated at install time and placed in the standard +# system-wide completion directories, which are auto-discovered by +# bash-completion, zsh, and fish without modifying any rc files. +install_completions() { + if ! is_at_least_sdk_version 10; then + echo "Skipping dotnet tab completions (requires SDK 10+)." + return + fi + + echo "Setting up dotnet tab completions..." + + # Bash: drop into the standard bash-completion directory + mkdir -p /usr/share/bash-completion/completions + "$DOTNET_ROOT/dotnet" completions script bash > /usr/share/bash-completion/completions/dotnet + + # Zsh: drop into the standard site-functions directory + mkdir -p /usr/share/zsh/site-functions + "$DOTNET_ROOT/dotnet" completions script zsh > /usr/share/zsh/site-functions/_dotnet + + # Fish: drop into the standard vendor completions directory + mkdir -p /usr/share/fish/vendor_completions.d + "$DOTNET_ROOT/dotnet" completions script fish > /usr/share/fish/vendor_completions.d/dotnet.fish +} \ No newline at end of file diff --git a/src/dotnet/scripts/fetch-latest-dotnet-install.sh b/src/dotnet/scripts/fetch-latest-dotnet-install.sh new file mode 100755 index 000000000..361d539c4 --- /dev/null +++ b/src/dotnet/scripts/fetch-latest-dotnet-install.sh @@ -0,0 +1,16 @@ +#!/bin/bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/devcontainers/features/tree/main/src/dotnet +# Maintainer: The Dev Container spec maintainers +# +# Run this script to replace dotnet-install.sh with the latest and greatest available version +# +DOTNET_SCRIPTS=$(dirname "${BASH_SOURCE[0]}") +DOTNET_INSTALL_SCRIPT="$DOTNET_SCRIPTS/vendor/dotnet-install.sh" + +wget https://dot.net/v1/dotnet-install.sh -O "$DOTNET_INSTALL_SCRIPT" +chmod +x "$DOTNET_INSTALL_SCRIPT" diff --git a/src/dotnet/scripts/string-helpers.sh b/src/dotnet/scripts/string-helpers.sh new file mode 100644 index 000000000..35b4017c8 --- /dev/null +++ b/src/dotnet/scripts/string-helpers.sh @@ -0,0 +1,42 @@ +#!/bin/bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/devcontainers/features/tree/main/src/dotnet +# Maintainer: The Dev Container spec maintainers + +# Removes leading and trailing whitespace from an input string +# Usage: trim_whitespace +trim_whitespace() { + text="$1" + + # Remove leading spaces + while [ "${text:0:1}" == " " ]; do + text="${text:1}" + done + + # Remove trailing spaces + while [ "${text: -1}" == " " ]; do + text="${text:0:-1}" + done + + echo "$text" +} + +# Splits comma-separated values into an array while ignoring empty entries +# Usage: split_csv +split_csv() { + local -a values=() + while IFS="," read -ra entries; do + for entry in "${entries[@]}"; do + entry="$(trim_whitespace "$entry")" + if [ -n "$entry" ]; then + values+=("$entry") + fi + done + done <<< "$1" + + echo "${values[@]}" +} \ No newline at end of file diff --git a/src/dotnet/scripts/vendor/README.md b/src/dotnet/scripts/vendor/README.md new file mode 100644 index 000000000..9e330e524 --- /dev/null +++ b/src/dotnet/scripts/vendor/README.md @@ -0,0 +1,28 @@ +### **IMPORTANT NOTE** + +Scripts in this directory are sourced externally and not maintained by the Dev Container spec maintainers. Do not make changes directly as they might be overwritten at any moment. + +## dotnet-install.sh + +`dotnet-install.sh` is a copy of . ([Script reference](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script)) + +Quick options reminder for `dotnet-install.sh`: + +- `--version`: `"latest"` (default) or an exact version in the form A.B.C like `"6.0.413"` +- `--channel`: `"LTS"` (default), `"STS"`, a two-part version in the form A.B like `"6.0"` or three-part form A.B.Cxx like `"6.0.1xx"` +- `--quality`: `"daily"`, `"preview"` or `"GA"` +- The channel option is only used when version is 'latest' because an exact version overrides the channel option +- The quality option is only used when channel is 'A.B' or 'A.B.Cxx' because it can't be used with STS or LTS + +Examples + +``` +dotnet-install.sh [--version latest] [--channel LTS] +dotnet-install.sh [--version latest] --channel STS +dotnet-install.sh [--version latest] --channel 6.0 [--quality GA] +dotnet-install.sh [--version latest] --channel 6.0.4xx [--quality GA] +dotnet-install.sh [--version latest] --channel 8.0 --quality preview +dotnet-install.sh [--version latest] --channel 8.0 --quality daily +dotnet-install.sh [--version latest] --channel 10.0 --quality preview +dotnet-install.sh --version 6.0.413 +``` \ No newline at end of file diff --git a/src/dotnet/scripts/vendor/dotnet-install.sh b/src/dotnet/scripts/vendor/dotnet-install.sh new file mode 100755 index 000000000..034d2dfb1 --- /dev/null +++ b/src/dotnet/scripts/vendor/dotnet-install.sh @@ -0,0 +1,1888 @@ +#!/usr/bin/env bash +# Copyright (c) .NET Foundation and contributors. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +# Stop script on NZEC +set -e +# Stop script if unbound variable found (use ${var:-} if intentional) +set -u +# By default cmd1 | cmd2 returns exit code of cmd2 regardless of cmd1 success +# This is causing it to fail +set -o pipefail + +# Use in the the functions: eval $invocation +invocation='say_verbose "Calling: ${yellow:-}${FUNCNAME[0]} ${green:-}$*${normal:-}"' + +# standard output may be used as a return value in the functions +# we need a way to write text on the screen in the functions so that +# it won't interfere with the return value. +# Exposing stream 3 as a pipe to standard output of the script itself +exec 3>&1 + +# Setup some colors to use. These need to work in fairly limited shells, like the Ubuntu Docker container where there are only 8 colors. +# See if stdout is a terminal +if [ -t 1 ] && command -v tput > /dev/null; then + # see if it supports colors + ncolors=$(tput colors || echo 0) + if [ -n "$ncolors" ] && [ $ncolors -ge 8 ]; then + bold="$(tput bold || echo)" + normal="$(tput sgr0 || echo)" + black="$(tput setaf 0 || echo)" + red="$(tput setaf 1 || echo)" + green="$(tput setaf 2 || echo)" + yellow="$(tput setaf 3 || echo)" + blue="$(tput setaf 4 || echo)" + magenta="$(tput setaf 5 || echo)" + cyan="$(tput setaf 6 || echo)" + white="$(tput setaf 7 || echo)" + fi +fi + +say_warning() { + printf "%b\n" "${yellow:-}dotnet_install: Warning: $1${normal:-}" >&3 +} + +say_err() { + printf "%b\n" "${red:-}dotnet_install: Error: $1${normal:-}" >&2 +} + +say() { + # using stream 3 (defined in the beginning) to not interfere with stdout of functions + # which may be used as return value + printf "%b\n" "${cyan:-}dotnet-install:${normal:-} $1" >&3 +} + +say_verbose() { + if [ "$verbose" = true ]; then + say "$1" + fi +} + +# This platform list is finite - if the SDK/Runtime has supported Linux distribution-specific assets, +# then and only then should the Linux distribution appear in this list. +# Adding a Linux distribution to this list does not imply distribution-specific support. +get_legacy_os_name_from_platform() { + eval $invocation + + platform="$1" + case "$platform" in + "centos.7") + echo "centos" + return 0 + ;; + "debian.8") + echo "debian" + return 0 + ;; + "debian.9") + echo "debian.9" + return 0 + ;; + "fedora.23") + echo "fedora.23" + return 0 + ;; + "fedora.24") + echo "fedora.24" + return 0 + ;; + "fedora.27") + echo "fedora.27" + return 0 + ;; + "fedora.28") + echo "fedora.28" + return 0 + ;; + "opensuse.13.2") + echo "opensuse.13.2" + return 0 + ;; + "opensuse.42.1") + echo "opensuse.42.1" + return 0 + ;; + "opensuse.42.3") + echo "opensuse.42.3" + return 0 + ;; + "rhel.7"*) + echo "rhel" + return 0 + ;; + "ubuntu.14.04") + echo "ubuntu" + return 0 + ;; + "ubuntu.16.04") + echo "ubuntu.16.04" + return 0 + ;; + "ubuntu.16.10") + echo "ubuntu.16.10" + return 0 + ;; + "ubuntu.18.04") + echo "ubuntu.18.04" + return 0 + ;; + "alpine.3.4.3") + echo "alpine" + return 0 + ;; + esac + return 1 +} + +get_legacy_os_name() { + eval $invocation + + local uname=$(uname) + if [ "$uname" = "Darwin" ]; then + echo "osx" + return 0 + elif [ -n "$runtime_id" ]; then + echo $(get_legacy_os_name_from_platform "${runtime_id%-*}" || echo "${runtime_id%-*}") + return 0 + else + if [ -e /etc/os-release ]; then + . /etc/os-release + os=$(get_legacy_os_name_from_platform "$ID${VERSION_ID:+.${VERSION_ID}}" || echo "") + if [ -n "$os" ]; then + echo "$os" + return 0 + fi + fi + fi + + say_verbose "Distribution specific OS name and version could not be detected: UName = $uname" + return 1 +} + +get_linux_platform_name() { + eval $invocation + + if [ -n "$runtime_id" ]; then + echo "${runtime_id%-*}" + return 0 + else + if [ -e /etc/os-release ]; then + . /etc/os-release + echo "$ID${VERSION_ID:+.${VERSION_ID}}" + return 0 + elif [ -e /etc/redhat-release ]; then + local redhatRelease=$(&1 || true) | grep -q musl +} + +get_current_os_name() { + eval $invocation + + local uname=$(uname) + if [ "$uname" = "Darwin" ]; then + echo "osx" + return 0 + elif [ "$uname" = "FreeBSD" ]; then + echo "freebsd" + return 0 + elif [ "$uname" = "Linux" ]; then + local linux_platform_name="" + linux_platform_name="$(get_linux_platform_name)" || true + + if [ "$linux_platform_name" = "rhel.6" ]; then + echo $linux_platform_name + return 0 + elif is_musl_based_distro; then + echo "linux-musl" + return 0 + elif [ "$linux_platform_name" = "linux-musl" ]; then + echo "linux-musl" + return 0 + else + echo "linux" + return 0 + fi + fi + + say_err "OS name could not be detected: UName = $uname" + return 1 +} + +machine_has() { + eval $invocation + + command -v "$1" > /dev/null 2>&1 + return $? +} + +check_min_reqs() { + local hasMinimum=false + if machine_has "curl"; then + hasMinimum=true + elif machine_has "wget"; then + hasMinimum=true + fi + + if [ "$hasMinimum" = "false" ]; then + say_err "curl (recommended) or wget are required to download dotnet. Install missing prerequisite to proceed." + return 1 + fi + return 0 +} + +# args: +# input - $1 +to_lowercase() { + #eval $invocation + + echo "$1" | tr '[:upper:]' '[:lower:]' + return 0 +} + +# args: +# input - $1 +remove_trailing_slash() { + #eval $invocation + + local input="${1:-}" + echo "${input%/}" + return 0 +} + +# args: +# input - $1 +remove_beginning_slash() { + #eval $invocation + + local input="${1:-}" + echo "${input#/}" + return 0 +} + +# args: +# root_path - $1 +# child_path - $2 - this parameter can be empty +combine_paths() { + eval $invocation + + # TODO: Consider making it work with any number of paths. For now: + if [ ! -z "${3:-}" ]; then + say_err "combine_paths: Function takes two parameters." + return 1 + fi + + local root_path="$(remove_trailing_slash "$1")" + local child_path="$(remove_beginning_slash "${2:-}")" + say_verbose "combine_paths: root_path=$root_path" + say_verbose "combine_paths: child_path=$child_path" + echo "$root_path/$child_path" + return 0 +} + +get_machine_architecture() { + eval $invocation + + if command -v uname > /dev/null; then + CPUName=$(uname -m) + case $CPUName in + armv1*|armv2*|armv3*|armv4*|armv5*|armv6*) + echo "armv6-or-below" + return 0 + ;; + armv*l) + echo "arm" + return 0 + ;; + aarch64|arm64) + if [ "$(getconf LONG_BIT)" -lt 64 ]; then + # This is 32-bit OS running on 64-bit CPU (for example Raspberry Pi OS) + echo "arm" + return 0 + fi + echo "arm64" + return 0 + ;; + s390x) + echo "s390x" + return 0 + ;; + ppc64le) + echo "ppc64le" + return 0 + ;; + loongarch64) + echo "loongarch64" + return 0 + ;; + riscv64) + echo "riscv64" + return 0 + ;; + powerpc|ppc) + echo "ppc" + return 0 + ;; + esac + fi + + # Always default to 'x64' + echo "x64" + return 0 +} + +# args: +# architecture - $1 +get_normalized_architecture_from_architecture() { + eval $invocation + + local architecture="$(to_lowercase "$1")" + + if [[ $architecture == \ ]]; then + machine_architecture="$(get_machine_architecture)" + if [[ "$machine_architecture" == "armv6-or-below" ]]; then + say_err "Architecture \`$machine_architecture\` not supported. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues" + return 1 + fi + + echo $machine_architecture + return 0 + fi + + case "$architecture" in + amd64|x64) + echo "x64" + return 0 + ;; + arm) + echo "arm" + return 0 + ;; + arm64) + echo "arm64" + return 0 + ;; + s390x) + echo "s390x" + return 0 + ;; + ppc64le) + echo "ppc64le" + return 0 + ;; + loongarch64) + echo "loongarch64" + return 0 + ;; + esac + + say_err "Architecture \`$architecture\` not supported. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues" + return 1 +} + +# args: +# version - $1 +# channel - $2 +# architecture - $3 +get_normalized_architecture_for_specific_sdk_version() { + eval $invocation + + local is_version_support_arm64="$(is_arm64_supported "$1")" + local is_channel_support_arm64="$(is_arm64_supported "$2")" + local architecture="$3"; + local osname="$(get_current_os_name)" + + if [ "$osname" == "osx" ] && [ "$architecture" == "arm64" ] && { [ "$is_version_support_arm64" = false ] || [ "$is_channel_support_arm64" = false ]; }; then + #check if rosetta is installed + if [ "$(/usr/bin/pgrep oahd >/dev/null 2>&1;echo $?)" -eq 0 ]; then + say_verbose "Changing user architecture from '$architecture' to 'x64' because .NET SDKs prior to version 6.0 do not support arm64." + echo "x64" + return 0; + else + say_err "Architecture \`$architecture\` is not supported for .NET SDK version \`$version\`. Please install Rosetta to allow emulation of the \`$architecture\` .NET SDK on this platform" + return 1 + fi + fi + + echo "$architecture" + return 0 +} + +# args: +# version or channel - $1 +is_arm64_supported() { + # Extract the major version by splitting on the dot + major_version="${1%%.*}" + + # Check if the major version is a valid number and less than 6 + case "$major_version" in + [0-9]*) + if [ "$major_version" -lt 6 ]; then + echo false + return 0 + fi + ;; + esac + + echo true + return 0 +} + +# args: +# user_defined_os - $1 +get_normalized_os() { + eval $invocation + + local osname="$(to_lowercase "$1")" + if [ ! -z "$osname" ]; then + case "$osname" in + osx | freebsd | rhel.6 | linux-musl | linux) + echo "$osname" + return 0 + ;; + macos) + osname='osx' + echo "$osname" + return 0 + ;; + *) + say_err "'$user_defined_os' is not a supported value for --os option, supported values are: osx, macos, linux, linux-musl, freebsd, rhel.6. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues." + return 1 + ;; + esac + else + osname="$(get_current_os_name)" || return 1 + fi + echo "$osname" + return 0 +} + +# args: +# quality - $1 +get_normalized_quality() { + eval $invocation + + local quality="$(to_lowercase "$1")" + if [ ! -z "$quality" ]; then + case "$quality" in + daily | preview) + echo "$quality" + return 0 + ;; + ga) + #ga quality is available without specifying quality, so normalizing it to empty + return 0 + ;; + *) + say_err "'$quality' is not a supported value for --quality option. Supported values are: daily, preview, ga. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues." + return 1 + ;; + esac + fi + return 0 +} + +# args: +# channel - $1 +get_normalized_channel() { + eval $invocation + + local channel="$(to_lowercase "$1")" + + if [[ $channel == current ]]; then + say_warning 'Value "Current" is deprecated for -Channel option. Use "STS" instead.' + fi + + if [[ $channel == release/* ]]; then + say_warning 'Using branch name with -Channel option is no longer supported with newer releases. Use -Quality option with a channel in X.Y format instead.'; + fi + + if [ ! -z "$channel" ]; then + case "$channel" in + lts) + echo "LTS" + return 0 + ;; + sts) + echo "STS" + return 0 + ;; + current) + echo "STS" + return 0 + ;; + *) + echo "$channel" + return 0 + ;; + esac + fi + + return 0 +} + +# args: +# runtime - $1 +get_normalized_product() { + eval $invocation + + local product="" + local runtime="$(to_lowercase "$1")" + if [[ "$runtime" == "dotnet" ]]; then + product="dotnet-runtime" + elif [[ "$runtime" == "aspnetcore" ]]; then + product="aspnetcore-runtime" + elif [ -z "$runtime" ]; then + product="dotnet-sdk" + fi + echo "$product" + return 0 +} + +# The version text returned from the feeds is a 1-line or 2-line string: +# For the SDK and the dotnet runtime (2 lines): +# Line 1: # commit_hash +# Line 2: # 4-part version +# For the aspnetcore runtime (1 line): +# Line 1: # 4-part version + +# args: +# version_text - stdin +get_version_from_latestversion_file_content() { + eval $invocation + + cat | tail -n 1 | sed 's/\r$//' + return 0 +} + +# args: +# install_root - $1 +# relative_path_to_package - $2 +# specific_version - $3 +is_dotnet_package_installed() { + eval $invocation + + local install_root="$1" + local relative_path_to_package="$2" + local specific_version="${3//[$'\t\r\n']}" + + local dotnet_package_path="$(combine_paths "$(combine_paths "$install_root" "$relative_path_to_package")" "$specific_version")" + say_verbose "is_dotnet_package_installed: dotnet_package_path=$dotnet_package_path" + + if [ -d "$dotnet_package_path" ]; then + return 0 + else + return 1 + fi +} + +# args: +# downloaded file - $1 +# remote_file_size - $2 +validate_remote_local_file_sizes() +{ + eval $invocation + + local downloaded_file="$1" + local remote_file_size="$2" + local file_size='' + + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + file_size="$(stat -c '%s' "$downloaded_file")" + elif [[ "$OSTYPE" == "darwin"* ]]; then + # hardcode in order to avoid conflicts with GNU stat + file_size="$(/usr/bin/stat -f '%z' "$downloaded_file")" + fi + + if [ -n "$file_size" ]; then + say "Downloaded file size is $file_size bytes." + + if [ -n "$remote_file_size" ] && [ -n "$file_size" ]; then + if [ "$remote_file_size" -ne "$file_size" ]; then + say "The remote and local file sizes are not equal. The remote file size is $remote_file_size bytes and the local size is $file_size bytes. The local package may be corrupted." + else + say "The remote and local file sizes are equal." + fi + fi + + else + say "Either downloaded or local package size can not be measured. One of them may be corrupted." + fi +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +get_version_from_latestversion_file() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + + local version_file_url=null + if [[ "$runtime" == "dotnet" ]]; then + version_file_url="$azure_feed/Runtime/$channel/latest.version" + elif [[ "$runtime" == "aspnetcore" ]]; then + version_file_url="$azure_feed/aspnetcore/Runtime/$channel/latest.version" + elif [ -z "$runtime" ]; then + version_file_url="$azure_feed/Sdk/$channel/latest.version" + else + say_err "Invalid value for \$runtime" + return 1 + fi + say_verbose "get_version_from_latestversion_file: latest url: $version_file_url" + + download "$version_file_url" || return $? + return 0 +} + +# args: +# json_file - $1 +parse_globaljson_file_for_version() { + eval $invocation + + local json_file="$1" + if [ ! -f "$json_file" ]; then + say_err "Unable to find \`$json_file\`" + return 1 + fi + + sdk_section=$(cat $json_file | tr -d "\r" | awk '/"sdk"/,/}/') + if [ -z "$sdk_section" ]; then + say_err "Unable to parse the SDK node in \`$json_file\`" + return 1 + fi + + sdk_list=$(echo $sdk_section | awk -F"[{}]" '{print $2}') + sdk_list=${sdk_list//[\" ]/} + sdk_list=${sdk_list//,/$'\n'} + + local version_info="" + while read -r line; do + IFS=: + while read -r key value; do + if [[ "$key" == "version" ]]; then + version_info=$value + fi + done <<< "$line" + done <<< "$sdk_list" + if [ -z "$version_info" ]; then + say_err "Unable to find the SDK:version node in \`$json_file\`" + return 1 + fi + + unset IFS; + echo "$version_info" + return 0 +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# version - $4 +# json_file - $5 +get_specific_version_from_version() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local version="$(to_lowercase "$4")" + local json_file="$5" + + if [ -z "$json_file" ]; then + if [[ "$version" == "latest" ]]; then + local version_info + version_info="$(get_version_from_latestversion_file "$azure_feed" "$channel" "$normalized_architecture" false)" || return 1 + say_verbose "get_specific_version_from_version: version_info=$version_info" + echo "$version_info" | get_version_from_latestversion_file_content + return 0 + else + echo "$version" + return 0 + fi + else + local version_info + version_info="$(parse_globaljson_file_for_version "$json_file")" || return 1 + echo "$version_info" + return 0 + fi +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# specific_version - $4 +# normalized_os - $5 +construct_download_link() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local specific_version="${4//[$'\t\r\n']}" + local specific_product_version="$(get_specific_product_version "$1" "$4")" + local osname="$5" + + local download_link=null + if [[ "$runtime" == "dotnet" ]]; then + download_link="$azure_feed/Runtime/$specific_version/dotnet-runtime-$specific_product_version-$osname-$normalized_architecture.tar.gz" + elif [[ "$runtime" == "aspnetcore" ]]; then + download_link="$azure_feed/aspnetcore/Runtime/$specific_version/aspnetcore-runtime-$specific_product_version-$osname-$normalized_architecture.tar.gz" + elif [ -z "$runtime" ]; then + download_link="$azure_feed/Sdk/$specific_version/dotnet-sdk-$specific_product_version-$osname-$normalized_architecture.tar.gz" + else + return 1 + fi + + echo "$download_link" + return 0 +} + +# args: +# azure_feed - $1 +# specific_version - $2 +# download link - $3 (optional) +get_specific_product_version() { + # If we find a 'productVersion.txt' at the root of any folder, we'll use its contents + # to resolve the version of what's in the folder, superseding the specified version. + # if 'productVersion.txt' is missing but download link is already available, product version will be taken from download link + eval $invocation + + local azure_feed="$1" + local specific_version="${2//[$'\t\r\n']}" + local package_download_link="" + if [ $# -gt 2 ]; then + local package_download_link="$3" + fi + local specific_product_version=null + + # Try to get the version number, using the productVersion.txt file located next to the installer file. + local download_links=($(get_specific_product_version_url "$azure_feed" "$specific_version" true "$package_download_link") + $(get_specific_product_version_url "$azure_feed" "$specific_version" false "$package_download_link")) + + for download_link in "${download_links[@]}" + do + say_verbose "Checking for the existence of $download_link" + + if machine_has "curl" + then + if ! specific_product_version=$(curl -s --fail "${download_link}${feed_credential}" 2>&1); then + continue + else + echo "${specific_product_version//[$'\t\r\n']}" + return 0 + fi + + elif machine_has "wget" + then + specific_product_version=$(wget -qO- "${download_link}${feed_credential}" 2>&1) + if [ $? = 0 ]; then + echo "${specific_product_version//[$'\t\r\n']}" + return 0 + fi + fi + done + + # Getting the version number with productVersion.txt has failed. Try parsing the download link for a version number. + say_verbose "Failed to get the version using productVersion.txt file. Download link will be parsed instead." + specific_product_version="$(get_product_specific_version_from_download_link "$package_download_link" "$specific_version")" + echo "${specific_product_version//[$'\t\r\n']}" + return 0 +} + +# args: +# azure_feed - $1 +# specific_version - $2 +# is_flattened - $3 +# download link - $4 (optional) +get_specific_product_version_url() { + eval $invocation + + local azure_feed="$1" + local specific_version="$2" + local is_flattened="$3" + local package_download_link="" + if [ $# -gt 3 ]; then + local package_download_link="$4" + fi + + local pvFileName="productVersion.txt" + if [ "$is_flattened" = true ]; then + if [ -z "$runtime" ]; then + pvFileName="sdk-productVersion.txt" + elif [[ "$runtime" == "dotnet" ]]; then + pvFileName="runtime-productVersion.txt" + else + pvFileName="$runtime-productVersion.txt" + fi + fi + + local download_link=null + + if [ -z "$package_download_link" ]; then + if [[ "$runtime" == "dotnet" ]]; then + download_link="$azure_feed/Runtime/$specific_version/${pvFileName}" + elif [[ "$runtime" == "aspnetcore" ]]; then + download_link="$azure_feed/aspnetcore/Runtime/$specific_version/${pvFileName}" + elif [ -z "$runtime" ]; then + download_link="$azure_feed/Sdk/$specific_version/${pvFileName}" + else + return 1 + fi + else + download_link="${package_download_link%/*}/${pvFileName}" + fi + + say_verbose "Constructed productVersion link: $download_link" + echo "$download_link" + return 0 +} + +# args: +# download link - $1 +# specific version - $2 +get_product_specific_version_from_download_link() +{ + eval $invocation + + local download_link="$1" + local specific_version="$2" + local specific_product_version="" + + if [ -z "$download_link" ]; then + echo "$specific_version" + return 0 + fi + + #get filename + filename="${download_link##*/}" + + #product specific version follows the product name + #for filename 'dotnet-sdk-3.1.404-linux-x64.tar.gz': the product version is 3.1.404 + IFS='-' + read -ra filename_elems <<< "$filename" + count=${#filename_elems[@]} + if [[ "$count" -gt 2 ]]; then + specific_product_version="${filename_elems[2]}" + else + specific_product_version=$specific_version + fi + unset IFS; + echo "$specific_product_version" + return 0 +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# specific_version - $4 +construct_legacy_download_link() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local specific_version="${4//[$'\t\r\n']}" + + local distro_specific_osname + distro_specific_osname="$(get_legacy_os_name)" || return 1 + + local legacy_download_link=null + if [[ "$runtime" == "dotnet" ]]; then + legacy_download_link="$azure_feed/Runtime/$specific_version/dotnet-$distro_specific_osname-$normalized_architecture.$specific_version.tar.gz" + elif [ -z "$runtime" ]; then + legacy_download_link="$azure_feed/Sdk/$specific_version/dotnet-dev-$distro_specific_osname-$normalized_architecture.$specific_version.tar.gz" + else + return 1 + fi + + echo "$legacy_download_link" + return 0 +} + +get_user_install_path() { + eval $invocation + + if [ ! -z "${DOTNET_INSTALL_DIR:-}" ]; then + echo "$DOTNET_INSTALL_DIR" + else + echo "$HOME/.dotnet" + fi + return 0 +} + +# args: +# install_dir - $1 +resolve_installation_path() { + eval $invocation + + local install_dir=$1 + if [ "$install_dir" = "" ]; then + local user_install_path="$(get_user_install_path)" + say_verbose "resolve_installation_path: user_install_path=$user_install_path" + echo "$user_install_path" + return 0 + fi + + echo "$install_dir" + return 0 +} + +# args: +# relative_or_absolute_path - $1 +get_absolute_path() { + eval $invocation + + local relative_or_absolute_path=$1 + echo "$(cd "$(dirname "$1")" && pwd -P)/$(basename "$1")" + return 0 +} + +# args: +# override - $1 (boolean, true or false) +get_cp_options() { + eval $invocation + + local override="$1" + local override_switch="" + + if [ "$override" = false ]; then + override_switch="-n" + + # create temporary files to check if 'cp -u' is supported + tmp_dir="$(mktemp -d)" + tmp_file="$tmp_dir/testfile" + tmp_file2="$tmp_dir/testfile2" + + touch "$tmp_file" + + # use -u instead of -n if it's available + if cp -u "$tmp_file" "$tmp_file2" 2>/dev/null; then + override_switch="-u" + fi + + # clean up + rm -f "$tmp_file" "$tmp_file2" + rm -rf "$tmp_dir" + fi + + echo "$override_switch" +} + +# args: +# input_files - stdin +# root_path - $1 +# out_path - $2 +# override - $3 +copy_files_or_dirs_from_list() { + eval $invocation + + local root_path="$(remove_trailing_slash "$1")" + local out_path="$(remove_trailing_slash "$2")" + local override="$3" + local override_switch="$(get_cp_options "$override")" + + cat | uniq | while read -r file_path; do + local path="$(remove_beginning_slash "${file_path#$root_path}")" + local target="$out_path/$path" + if [ "$override" = true ] || (! ([ -d "$target" ] || [ -e "$target" ])); then + mkdir -p "$out_path/$(dirname "$path")" + if [ -d "$target" ]; then + rm -rf "$target" + fi + cp -R $override_switch "$root_path/$path" "$target" + fi + done +} + +# args: +# zip_uri - $1 +get_remote_file_size() { + local zip_uri="$1" + + if machine_has "curl"; then + file_size=$(curl -sI "$zip_uri" | grep -i content-length | awk '{ num = $2 + 0; print num }') + elif machine_has "wget"; then + file_size=$(wget --spider --server-response -O /dev/null "$zip_uri" 2>&1 | grep -i 'Content-Length:' | awk '{ num = $2 + 0; print num }') + else + say "Neither curl nor wget is available on this system." + return + fi + + if [ -n "$file_size" ]; then + say "Remote file $zip_uri size is $file_size bytes." + echo "$file_size" + else + say_verbose "Content-Length header was not extracted for $zip_uri." + echo "" + fi +} + +# args: +# zip_path - $1 +# out_path - $2 +# remote_file_size - $3 +extract_dotnet_package() { + eval $invocation + + local zip_path="$1" + local out_path="$2" + local remote_file_size="$3" + + local temp_out_path="$(mktemp -d "$temporary_file_template")" + + local failed=false + tar -xzf "$zip_path" -C "$temp_out_path" > /dev/null || failed=true + + local folders_with_version_regex='^.*/[0-9]+\.[0-9]+[^/]+/' + find "$temp_out_path" -type f | grep -Eo "$folders_with_version_regex" | sort | copy_files_or_dirs_from_list "$temp_out_path" "$out_path" false + find "$temp_out_path" -type f | grep -Ev "$folders_with_version_regex" | copy_files_or_dirs_from_list "$temp_out_path" "$out_path" "$override_non_versioned_files" + + validate_remote_local_file_sizes "$zip_path" "$remote_file_size" + + rm -rf "$temp_out_path" + if [ -z ${keep_zip+x} ]; then + rm -f "$zip_path" && say_verbose "Temporary archive file $zip_path was removed" + fi + + if [ "$failed" = true ]; then + say_err "Extraction failed" + return 1 + fi + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header() +{ + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + + local failed=false + local response + if machine_has "curl"; then + get_http_header_curl $remote_path $disable_feed_credential || failed=true + elif machine_has "wget"; then + get_http_header_wget $remote_path $disable_feed_credential || failed=true + else + failed=true + fi + if [ "$failed" = true ]; then + say_verbose "Failed to get HTTP header: '$remote_path'." + return 1 + fi + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header_curl() { + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + + remote_path_with_credential="$remote_path" + if [ "$disable_feed_credential" = false ]; then + remote_path_with_credential+="$feed_credential" + fi + + curl_options="-I -sSL --retry 5 --retry-delay 2 --connect-timeout 15 " + curl $curl_options "$remote_path_with_credential" 2>&1 || return 1 + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header_wget() { + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + local wget_options="-q -S --spider --tries 5 " + + local wget_options_extra='' + + # Test for options that aren't supported on all wget implementations. + if [[ $(wget -h 2>&1 | grep -E 'waitretry|connect-timeout') ]]; then + wget_options_extra="--waitretry 2 --connect-timeout 15 " + else + say "wget extra options are unavailable for this environment" + fi + + remote_path_with_credential="$remote_path" + if [ "$disable_feed_credential" = false ]; then + remote_path_with_credential+="$feed_credential" + fi + + wget $wget_options $wget_options_extra "$remote_path_with_credential" 2>&1 + + return $? +} + +# args: +# remote_path - $1 +# [out_path] - $2 - stdout if not provided +download() { + eval $invocation + + local remote_path="$1" + local out_path="${2:-}" + + if [[ "$remote_path" != "http"* ]]; then + cp "$remote_path" "$out_path" + return $? + fi + + local failed=false + local attempts=0 + while [ $attempts -lt 3 ]; do + attempts=$((attempts+1)) + failed=false + if machine_has "curl"; then + downloadcurl "$remote_path" "$out_path" || failed=true + elif machine_has "wget"; then + downloadwget "$remote_path" "$out_path" || failed=true + else + say_err "Missing dependency: neither curl nor wget was found." + exit 1 + fi + + if [ "$failed" = false ] || [ $attempts -ge 3 ] || { [ ! -z $http_code ] && [ $http_code = "404" ]; }; then + break + fi + + say "Download attempt #$attempts has failed: $http_code $download_error_msg" + say "Attempt #$((attempts+1)) will start in $((attempts*10)) seconds." + sleep $((attempts*10)) + done + + if [ "$failed" = true ]; then + say_verbose "Download failed: $remote_path" + return 1 + fi + return 0 +} + +# Updates global variables $http_code and $download_error_msg +downloadcurl() { + eval $invocation + unset http_code + unset download_error_msg + local remote_path="$1" + local out_path="${2:-}" + # Append feed_credential as late as possible before calling curl to avoid logging feed_credential + # Avoid passing URI with credentials to functions: note, most of them echoing parameters of invocation in verbose output. + local remote_path_with_credential="${remote_path}${feed_credential}" + local curl_options="--retry 20 --retry-delay 2 --connect-timeout 15 -sSL -f --create-dirs " + local curl_exit_code=0; + if [ -z "$out_path" ]; then + curl_output=$(curl $curl_options "$remote_path_with_credential" 2>&1) + curl_exit_code=$? + echo "$curl_output" + else + curl_output=$(curl $curl_options -o "$out_path" "$remote_path_with_credential" 2>&1) + curl_exit_code=$? + fi + + # Regression in curl causes curl with --retry to return a 0 exit code even when it fails to download a file - https://github.com/curl/curl/issues/17554 + if [ $curl_exit_code -eq 0 ] && echo "$curl_output" | grep -q "^curl: ([0-9]*) "; then + curl_exit_code=$(echo "$curl_output" | sed 's/curl: (\([0-9]*\)).*/\1/') + fi + + if [ $curl_exit_code -gt 0 ]; then + download_error_msg="Unable to download $remote_path." + # Check for curl timeout codes + if [[ $curl_exit_code == 7 || $curl_exit_code == 28 ]]; then + download_error_msg+=" Failed to reach the server: connection timeout." + else + local disable_feed_credential=false + local response=$(get_http_header_curl $remote_path $disable_feed_credential) + http_code=$( echo "$response" | awk '/^HTTP/{print $2}' | tail -1 ) + if [[ ! -z $http_code && $http_code != 2* ]]; then + download_error_msg+=" Returned HTTP status code: $http_code." + fi + fi + say_verbose "$download_error_msg" + return 1 + fi + return 0 +} + + +# Updates global variables $http_code and $download_error_msg +downloadwget() { + eval $invocation + unset http_code + unset download_error_msg + local remote_path="$1" + local out_path="${2:-}" + # Append feed_credential as late as possible before calling wget to avoid logging feed_credential + local remote_path_with_credential="${remote_path}${feed_credential}" + local wget_options="--tries 20 " + + local wget_options_extra='' + local wget_result='' + + # Test for options that aren't supported on all wget implementations. + if [[ $(wget -h 2>&1 | grep -E 'waitretry|connect-timeout') ]]; then + wget_options_extra="--waitretry 2 --connect-timeout 15 " + else + say "wget extra options are unavailable for this environment" + fi + + if [ -z "$out_path" ]; then + wget -q $wget_options $wget_options_extra -O - "$remote_path_with_credential" 2>&1 + wget_result=$? + else + wget $wget_options $wget_options_extra -O "$out_path" "$remote_path_with_credential" 2>&1 + wget_result=$? + fi + + if [[ $wget_result != 0 ]]; then + local disable_feed_credential=false + local response=$(get_http_header_wget $remote_path $disable_feed_credential) + http_code=$( echo "$response" | awk '/^ HTTP/{print $2}' | tail -1 ) + download_error_msg="Unable to download $remote_path." + if [[ ! -z $http_code && $http_code != 2* ]]; then + download_error_msg+=" Returned HTTP status code: $http_code." + # wget exit code 4 stands for network-issue + elif [[ $wget_result == 4 ]]; then + download_error_msg+=" Failed to reach the server: connection timeout." + fi + say_verbose "$download_error_msg" + return 1 + fi + + return 0 +} + +get_download_link_from_aka_ms() { + eval $invocation + + #quality is not supported for LTS or STS channel + #STS maps to current + if [[ ! -z "$normalized_quality" && ("$normalized_channel" == "LTS" || "$normalized_channel" == "STS") ]]; then + normalized_quality="" + say_warning "Specifying quality for STS or LTS channel is not supported, the quality will be ignored." + fi + + say_verbose "Retrieving primary payload URL from aka.ms for channel: '$normalized_channel', quality: '$normalized_quality', product: '$normalized_product', os: '$normalized_os', architecture: '$normalized_architecture'." + + #construct aka.ms link + aka_ms_link="https://aka.ms/dotnet" + if [ "$internal" = true ]; then + aka_ms_link="$aka_ms_link/internal" + fi + aka_ms_link="$aka_ms_link/$normalized_channel" + if [[ ! -z "$normalized_quality" ]]; then + aka_ms_link="$aka_ms_link/$normalized_quality" + fi + aka_ms_link="$aka_ms_link/$normalized_product-$normalized_os-$normalized_architecture.tar.gz" + say_verbose "Constructed aka.ms link: '$aka_ms_link'." + + #get HTTP response + #do not pass credentials as a part of the $aka_ms_link and do not apply credentials in the get_http_header function + #otherwise the redirect link would have credentials as well + #it would result in applying credentials twice to the resulting link and thus breaking it, and in echoing credentials to the output as a part of redirect link + disable_feed_credential=true + response="$(get_http_header $aka_ms_link $disable_feed_credential)" + + say_verbose "Received response: $response" + # Get results of all the redirects. + http_codes=$( echo "$response" | awk '$1 ~ /^HTTP/ {print $2}' ) + # They all need to be 301, otherwise some links are broken (except for the last, which is not a redirect but 200 or 404). + broken_redirects=$( echo "$http_codes" | sed '$d' | grep -v '301' ) + # The response may end without final code 2xx/4xx/5xx somehow, e.g. network restrictions on www.bing.com causes redirecting to bing.com fails with connection refused. + # In this case it should not exclude the last. + last_http_code=$( echo "$http_codes" | tail -n 1 ) + if ! [[ $last_http_code =~ ^(2|4|5)[0-9][0-9]$ ]]; then + broken_redirects=$( echo "$http_codes" | grep -v '301' ) + fi + + # All HTTP codes are 301 (Moved Permanently), the redirect link exists. + if [[ -z "$broken_redirects" ]]; then + aka_ms_download_link=$( echo "$response" | awk '$1 ~ /^Location/{print $2}' | tail -1 | tr -d '\r') + + if [[ -z "$aka_ms_download_link" ]]; then + say_verbose "The aka.ms link '$aka_ms_link' is not valid: failed to get redirect location." + return 1 + fi + + say_verbose "The redirect location retrieved: '$aka_ms_download_link'." + return 0 + else + say_verbose "The aka.ms link '$aka_ms_link' is not valid: received HTTP code: $(echo "$broken_redirects" | paste -sd "," -)." + return 1 + fi +} + +get_feeds_to_use() +{ + feeds=( + "https://builds.dotnet.microsoft.com/dotnet" + "https://ci.dot.net/public" + ) + + if [[ -n "$azure_feed" ]]; then + feeds=("$azure_feed") + fi + + if [[ -n "$uncached_feed" ]]; then + feeds=("$uncached_feed") + fi +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed). +generate_download_links() { + + download_links=() + specific_versions=() + effective_versions=() + link_types=() + + # If generate_akams_links returns false, no fallback to old links. Just terminate. + # This function may also 'exit' (if the determined version is already installed). + generate_akams_links || return + + # Check other feeds only if we haven't been able to find an aka.ms link. + if [[ "${#download_links[@]}" -lt 1 ]]; then + for feed in ${feeds[@]} + do + # generate_regular_links may also 'exit' (if the determined version is already installed). + generate_regular_links $feed || return + done + fi + + if [[ "${#download_links[@]}" -eq 0 ]]; then + say_err "Failed to resolve the exact version number." + return 1 + fi + + say_verbose "Generated ${#download_links[@]} links." + for link_index in ${!download_links[@]} + do + say_verbose "Link $link_index: ${link_types[$link_index]}, ${effective_versions[$link_index]}, ${download_links[$link_index]}" + done +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed). +generate_akams_links() { + local valid_aka_ms_link=true; + + normalized_version="$(to_lowercase "$version")" + if [[ "$normalized_version" != "latest" ]] && [ -n "$normalized_quality" ]; then + say_err "Quality and Version options are not allowed to be specified simultaneously. See https://learn.microsoft.com/dotnet/core/tools/dotnet-install-script#options for details." + return 1 + fi + + if [[ -n "$json_file" || "$normalized_version" != "latest" ]]; then + # aka.ms links are not needed when exact version is specified via command or json file + return + fi + + get_download_link_from_aka_ms || valid_aka_ms_link=false + + if [[ "$valid_aka_ms_link" == true ]]; then + say_verbose "Retrieved primary payload URL from aka.ms link: '$aka_ms_download_link'." + say_verbose "Downloading using legacy url will not be attempted." + + download_link=$aka_ms_download_link + + #get version from the path + IFS='/' + read -ra pathElems <<< "$download_link" + count=${#pathElems[@]} + specific_version="${pathElems[count-2]}" + unset IFS; + say_verbose "Version: '$specific_version'." + + #Retrieve effective version + effective_version="$(get_specific_product_version "$azure_feed" "$specific_version" "$download_link")" + + # Add link info to arrays + download_links+=($download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("aka.ms") + + # Check if the SDK version is already installed. + if [[ "$dry_run" != true ]] && is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "$asset_name with version '$effective_version' is already installed." + exit 0 + fi + + return 0 + fi + + # if quality is specified - exit with error - there is no fallback approach + if [ ! -z "$normalized_quality" ]; then + say_err "Failed to locate the latest version in the channel '$normalized_channel' with '$normalized_quality' quality for '$normalized_product', os: '$normalized_os', architecture: '$normalized_architecture'." + say_err "Refer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support." + return 1 + fi + say_verbose "Falling back to latest.version file approach." +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed) +# args: +# feed - $1 +generate_regular_links() { + local feed="$1" + local valid_legacy_download_link=true + + specific_version=$(get_specific_version_from_version "$feed" "$channel" "$normalized_architecture" "$version" "$json_file") || specific_version='0' + + if [[ "$specific_version" == '0' ]]; then + say_verbose "Failed to resolve the specific version number using feed '$feed'" + return + fi + + effective_version="$(get_specific_product_version "$feed" "$specific_version")" + say_verbose "specific_version=$specific_version" + + download_link="$(construct_download_link "$feed" "$channel" "$normalized_architecture" "$specific_version" "$normalized_os")" + say_verbose "Constructed primary named payload URL: $download_link" + + # Add link info to arrays + download_links+=($download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("primary") + + legacy_download_link="$(construct_legacy_download_link "$feed" "$channel" "$normalized_architecture" "$specific_version")" || valid_legacy_download_link=false + + if [ "$valid_legacy_download_link" = true ]; then + say_verbose "Constructed legacy named payload URL: $legacy_download_link" + + download_links+=($legacy_download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("legacy") + else + legacy_download_link="" + say_verbose "Could not construct a legacy_download_link; omitting..." + fi + + # Check if the SDK version is already installed. + if [[ "$dry_run" != true ]] && is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "$asset_name with version '$effective_version' is already installed." + exit 0 + fi +} + +print_dry_run() { + + say "Payload URLs:" + + for link_index in "${!download_links[@]}" + do + say "URL #$link_index - ${link_types[$link_index]}: ${download_links[$link_index]}" + done + + resolved_version=${specific_versions[0]} + repeatable_command="./$script_name --version "\""$resolved_version"\"" --install-dir "\""$install_root"\"" --architecture "\""$normalized_architecture"\"" --os "\""$normalized_os"\""" + + if [ ! -z "$normalized_quality" ]; then + repeatable_command+=" --quality "\""$normalized_quality"\""" + fi + + if [[ "$runtime" == "dotnet" ]]; then + repeatable_command+=" --runtime "\""dotnet"\""" + elif [[ "$runtime" == "aspnetcore" ]]; then + repeatable_command+=" --runtime "\""aspnetcore"\""" + fi + + repeatable_command+="$non_dynamic_parameters" + + if [ -n "$feed_credential" ]; then + repeatable_command+=" --feed-credential "\"""\""" + fi + + say "Repeatable invocation: $repeatable_command" +} + +calculate_vars() { + eval $invocation + + script_name=$(basename "$0") + normalized_architecture="$(get_normalized_architecture_from_architecture "$architecture")" + say_verbose "Normalized architecture: '$normalized_architecture'." + normalized_os="$(get_normalized_os "$user_defined_os")" + say_verbose "Normalized OS: '$normalized_os'." + normalized_quality="$(get_normalized_quality "$quality")" + say_verbose "Normalized quality: '$normalized_quality'." + normalized_channel="$(get_normalized_channel "$channel")" + say_verbose "Normalized channel: '$normalized_channel'." + normalized_product="$(get_normalized_product "$runtime")" + say_verbose "Normalized product: '$normalized_product'." + install_root="$(resolve_installation_path "$install_dir")" + say_verbose "InstallRoot: '$install_root'." + + normalized_architecture="$(get_normalized_architecture_for_specific_sdk_version "$version" "$normalized_channel" "$normalized_architecture")" + + if [[ "$runtime" == "dotnet" ]]; then + asset_relative_path="shared/Microsoft.NETCore.App" + asset_name=".NET Core Runtime" + elif [[ "$runtime" == "aspnetcore" ]]; then + asset_relative_path="shared/Microsoft.AspNetCore.App" + asset_name="ASP.NET Core Runtime" + elif [ -z "$runtime" ]; then + asset_relative_path="sdk" + asset_name=".NET Core SDK" + fi + + get_feeds_to_use +} + +install_dotnet() { + eval $invocation + local download_failed=false + local download_completed=false + local remote_file_size=0 + + mkdir -p "$install_root" + zip_path="${zip_path:-$(mktemp "$temporary_file_template")}" + say_verbose "Archive path: $zip_path" + + for link_index in "${!download_links[@]}" + do + download_link="${download_links[$link_index]}" + specific_version="${specific_versions[$link_index]}" + effective_version="${effective_versions[$link_index]}" + link_type="${link_types[$link_index]}" + + say "Attempting to download using $link_type link $download_link" + + # The download function will set variables $http_code and $download_error_msg in case of failure. + download_failed=false + download "$download_link" "$zip_path" 2>&1 || download_failed=true + + if [ "$download_failed" = true ]; then + case $http_code in + 404) + say "The resource at $link_type link '$download_link' is not available." + ;; + *) + say "Failed to download $link_type link '$download_link': $http_code $download_error_msg" + ;; + esac + rm -f "$zip_path" 2>&1 && say_verbose "Temporary archive file $zip_path was removed" + else + download_completed=true + break + fi + done + + if [[ "$download_completed" == false ]]; then + say_err "Could not find \`$asset_name\` with version = $specific_version" + say_err "Refer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support" + return 1 + fi + + remote_file_size="$(get_remote_file_size "$download_link")" + + say "Extracting archive from $download_link" + extract_dotnet_package "$zip_path" "$install_root" "$remote_file_size" || return 1 + + # Check if the SDK version is installed; if not, fail the installation. + # if the version contains "RTM" or "servicing"; check if a 'release-type' SDK version is installed. + if [[ $specific_version == *"rtm"* || $specific_version == *"servicing"* ]]; then + IFS='-' + read -ra verArr <<< "$specific_version" + release_version="${verArr[0]}" + unset IFS; + say_verbose "Checking installation: version = $release_version" + if is_dotnet_package_installed "$install_root" "$asset_relative_path" "$release_version"; then + say "Installed version is $effective_version" + return 0 + fi + fi + + # Check if the standard SDK version is installed. + say_verbose "Checking installation: version = $effective_version" + if is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "Installed version is $effective_version" + return 0 + fi + + # Version verification failed. More likely something is wrong either with the downloaded content or with the verification algorithm. + say_err "Failed to verify the version of installed \`$asset_name\`.\nInstallation source: $download_link.\nInstallation location: $install_root.\nReport the bug at https://github.com/dotnet/install-scripts/issues." + say_err "\`$asset_name\` with version = $effective_version failed to install with an error." + return 1 +} + +args=("$@") + +local_version_file_relative_path="/.version" +bin_folder_relative_path="" +temporary_file_template="${TMPDIR:-/tmp}/dotnet.XXXXXXXXX" + +channel="LTS" +version="Latest" +json_file="" +install_dir="" +architecture="" +dry_run=false +no_path=false +azure_feed="" +uncached_feed="" +feed_credential="" +verbose=false +runtime="" +runtime_id="" +quality="" +internal=false +override_non_versioned_files=true +non_dynamic_parameters="" +user_defined_os="" + +while [ $# -ne 0 ] +do + name="$1" + case "$name" in + -c|--channel|-[Cc]hannel) + shift + channel="$1" + ;; + -v|--version|-[Vv]ersion) + shift + version="$1" + ;; + -q|--quality|-[Qq]uality) + shift + quality="$1" + ;; + --internal|-[Ii]nternal) + internal=true + non_dynamic_parameters+=" $name" + ;; + -i|--install-dir|-[Ii]nstall[Dd]ir) + shift + install_dir="$1" + ;; + --arch|--architecture|-[Aa]rch|-[Aa]rchitecture) + shift + architecture="$1" + ;; + --os|-[Oo][SS]) + shift + user_defined_os="$1" + ;; + --shared-runtime|-[Ss]hared[Rr]untime) + say_warning "The --shared-runtime flag is obsolete and may be removed in a future version of this script. The recommended usage is to specify '--runtime dotnet'." + if [ -z "$runtime" ]; then + runtime="dotnet" + fi + ;; + --runtime|-[Rr]untime) + shift + runtime="$1" + if [[ "$runtime" != "dotnet" ]] && [[ "$runtime" != "aspnetcore" ]]; then + say_err "Unsupported value for --runtime: '$1'. Valid values are 'dotnet' and 'aspnetcore'." + if [[ "$runtime" == "windowsdesktop" ]]; then + say_err "WindowsDesktop archives are manufactured for Windows platforms only." + fi + exit 1 + fi + ;; + --dry-run|-[Dd]ry[Rr]un) + dry_run=true + ;; + --no-path|-[Nn]o[Pp]ath) + no_path=true + non_dynamic_parameters+=" $name" + ;; + --verbose|-[Vv]erbose) + verbose=true + non_dynamic_parameters+=" $name" + ;; + --azure-feed|-[Aa]zure[Ff]eed) + shift + azure_feed="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + ;; + --uncached-feed|-[Uu]ncached[Ff]eed) + shift + uncached_feed="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + ;; + --feed-credential|-[Ff]eed[Cc]redential) + shift + feed_credential="$1" + #feed_credential should start with "?", for it to be added to the end of the link. + #adding "?" at the beginning of the feed_credential if needed. + [[ -z "$(echo $feed_credential)" ]] || [[ $feed_credential == \?* ]] || feed_credential="?$feed_credential" + ;; + --runtime-id|-[Rr]untime[Ii]d) + shift + runtime_id="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + say_warning "Use of --runtime-id is obsolete and should be limited to the versions below 2.1. To override architecture, use --architecture option instead. To override OS, use --os option instead." + ;; + --jsonfile|-[Jj][Ss]on[Ff]ile) + shift + json_file="$1" + ;; + --skip-non-versioned-files|-[Ss]kip[Nn]on[Vv]ersioned[Ff]iles) + override_non_versioned_files=false + non_dynamic_parameters+=" $name" + ;; + --keep-zip|-[Kk]eep[Zz]ip) + keep_zip=true + non_dynamic_parameters+=" $name" + ;; + --zip-path|-[Zz]ip[Pp]ath) + shift + zip_path="$1" + ;; + -?|--?|-h|--help|-[Hh]elp) + script_name="dotnet-install.sh" + echo ".NET Tools Installer" + echo "Usage:" + echo " # Install a .NET SDK of a given Quality from a given Channel" + echo " $script_name [-c|--channel ] [-q|--quality ]" + echo " # Install a .NET SDK of a specific public version" + echo " $script_name [-v|--version ]" + echo " $script_name -h|-?|--help" + echo "" + echo "$script_name is a simple command line interface for obtaining dotnet cli." + echo " Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" + echo " - The SDK needs to be installed without user interaction and without admin rights." + echo " - The SDK installation doesn't need to persist across multiple CI runs." + echo " To set up a development environment or to run apps, use installers rather than this script. Visit https://dotnet.microsoft.com/download to get the installer." + echo "" + echo "Options:" + echo " -c,--channel Download from the channel specified, Defaults to \`$channel\`." + echo " -Channel" + echo " Possible values:" + echo " - STS - the most recent Standard Term Support release" + echo " - LTS - the most recent Long Term Support release" + echo " - 2-part version in a format A.B - represents a specific release" + echo " examples: 2.0; 1.0" + echo " - 3-part version in a format A.B.Cxx - represents a specific SDK release" + echo " examples: 5.0.1xx, 5.0.2xx." + echo " Supported since 5.0 release" + echo " Warning: Value 'Current' is deprecated for the Channel parameter. Use 'STS' instead." + echo " Note: The version parameter overrides the channel parameter when any version other than 'latest' is used." + echo " -v,--version Use specific VERSION, Defaults to \`$version\`." + echo " -Version" + echo " Possible values:" + echo " - latest - the latest build on specific channel" + echo " - 3-part version in a format A.B.C - represents specific version of build" + echo " examples: 2.0.0-preview2-006120; 1.1.0" + echo " -q,--quality Download the latest build of specified quality in the channel." + echo " -Quality" + echo " The possible values are: daily, preview, GA." + echo " Works only in combination with channel. Not applicable for STS and LTS channels and will be ignored if those channels are used." + echo " For SDK use channel in A.B.Cxx format. Using quality for SDK together with channel in A.B format is not supported." + echo " Supported since 5.0 release." + echo " Note: The version parameter overrides the channel parameter when any version other than 'latest' is used, and therefore overrides the quality." + echo " --internal,-Internal Download internal builds. Requires providing credentials via --feed-credential parameter." + echo " --feed-credential Token to access Azure feed. Used as a query string to append to the Azure feed." + echo " -FeedCredential This parameter typically is not specified." + echo " -i,--install-dir Install under specified location (see Install Location below)" + echo " -InstallDir" + echo " --architecture Architecture of dotnet binaries to be installed, Defaults to \`$architecture\`." + echo " --arch,-Architecture,-Arch" + echo " Possible values: x64, arm, arm64, s390x, ppc64le and loongarch64" + echo " --os Specifies operating system to be used when selecting the installer." + echo " Overrides the OS determination approach used by the script. Supported values: osx, linux, linux-musl, freebsd, rhel.6." + echo " In case any other value is provided, the platform will be determined by the script based on machine configuration." + echo " Not supported for legacy links. Use --runtime-id to specify platform for legacy links." + echo " Refer to: https://aka.ms/dotnet-os-lifecycle for more information." + echo " --runtime Installs a shared runtime only, without the SDK." + echo " -Runtime" + echo " Possible values:" + echo " - dotnet - the Microsoft.NETCore.App shared runtime" + echo " - aspnetcore - the Microsoft.AspNetCore.App shared runtime" + echo " --dry-run,-DryRun Do not perform installation. Display download link." + echo " --no-path, -NoPath Do not set PATH for the current process." + echo " --verbose,-Verbose Display diagnostics information." + echo " --azure-feed,-AzureFeed For internal use only." + echo " Allows using a different storage to download SDK archives from." + echo " --uncached-feed,-UncachedFeed For internal use only." + echo " Allows using a different storage to download SDK archives from." + echo " --skip-non-versioned-files Skips non-versioned files if they already exist, such as the dotnet executable." + echo " -SkipNonVersionedFiles" + echo " --jsonfile Determines the SDK version from a user specified global.json file." + echo " Note: global.json must have a value for 'SDK:Version'" + echo " --keep-zip,-KeepZip If set, downloaded file is kept." + echo " --zip-path, -ZipPath If set, downloaded file is stored at the specified path." + echo " -?,--?,-h,--help,-Help Shows this help message" + echo "" + echo "Install Location:" + echo " Location is chosen in following order:" + echo " - --install-dir option" + echo " - Environmental variable DOTNET_INSTALL_DIR" + echo " - $HOME/.dotnet" + exit 0 + ;; + *) + say_err "Unknown argument \`$name\`" + exit 1 + ;; + esac + + shift +done + +say_verbose "Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" +say_verbose "- The SDK needs to be installed without user interaction and without admin rights." +say_verbose "- The SDK installation doesn't need to persist across multiple CI runs." +say_verbose "To set up a development environment or to run apps, use installers rather than this script. Visit https://dotnet.microsoft.com/download to get the installer.\n" + +if [ "$internal" = true ] && [ -z "$(echo $feed_credential)" ]; then + message="Provide credentials via --feed-credential parameter." + if [ "$dry_run" = true ]; then + say_warning "$message" + else + say_err "$message" + exit 1 + fi +fi + +check_min_reqs +calculate_vars +# generate_regular_links call below will 'exit' if the determined version is already installed. +generate_download_links + +if [[ "$dry_run" = true ]]; then + print_dry_run + exit 0 +fi + +install_dotnet + +bin_path="$(get_absolute_path "$(combine_paths "$install_root" "$bin_folder_relative_path")")" +if [ "$no_path" = false ]; then + say "Adding to current process PATH: \`$bin_path\`. Note: This change will be visible only when sourcing script." + export PATH="$bin_path":"$PATH" +else + say "Binaries of dotnet can be found in $bin_path" +fi + +say "Note that the script does not resolve dependencies during installation." +say "To check the list of dependencies, go to https://learn.microsoft.com/dotnet/core/install, select your operating system and check the \"Dependencies\" section." +say "Installation finished successfully." diff --git a/src/git-lfs/NOTES.md b/src/git-lfs/NOTES.md new file mode 100644 index 000000000..19fe92f31 --- /dev/null +++ b/src/git-lfs/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/git-lfs/README.md b/src/git-lfs/README.md index dfc58f2ce..07f671755 100644 --- a/src/git-lfs/README.md +++ b/src/git-lfs/README.md @@ -7,9 +7,7 @@ Installs Git Large File Support (Git LFS) along with needed dependencies. Useful ```json "features": { - "ghcr.io/devcontainers/features/git-lfs:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/git-lfs:1": {} } ``` @@ -18,9 +16,18 @@ Installs Git Large File Support (Git LFS) along with needed dependencies. Useful | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select version of Git LFS to install | string | latest | +| autoPull | Automatically pull LFS files when creating the container. When false, running 'git lfs pull' in the container will have the same effect. | boolean | true | +| installDirectlyFromGitHubRelease | Installs 'git-lfs' from GitHub releases instead of package manager feeds | boolean | false | +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/git-lfs/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/git-lfs/devcontainer-feature.json b/src/git-lfs/devcontainer-feature.json index 0f075ca2d..fe23e02cf 100644 --- a/src/git-lfs/devcontainer-feature.json +++ b/src/git-lfs/devcontainer-feature.json @@ -1,18 +1,43 @@ { "id": "git-lfs", - "version": "1.0.2", + "version": "1.2.5", "name": "Git Large File Support (LFS)", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/git-lfs", "description": "Installs Git Large File Support (Git LFS) along with needed dependencies. Useful for base Dockerfiles that often are missing required install dependencies like git and curl.", "options": { "version": { "type": "string", - "enum": [ + "proposals": [ "latest", "none" ], "default": "latest", "description": "Select version of Git LFS to install" + }, + "autoPull": { + "type": "boolean", + "default": true, + "description": "Automatically pull LFS files when creating the container. When false, running 'git lfs pull' in the container will have the same effect." + }, + "installDirectlyFromGitHubRelease": { + "type": "boolean", + "default": false, + "description": "Installs 'git-lfs' from GitHub releases instead of package manager feeds" } - } -} + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes Git Large File Support (Git LFS) along with needed dependencies pre-installed and available on the `PATH`." + } + ] + } + } + }, + "postCreateCommand": "/usr/local/share/pull-git-lfs-artifacts.sh", + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} \ No newline at end of file diff --git a/src/git-lfs/install.sh b/src/git-lfs/install.sh index d88a3799a..71066c7b3 100755 --- a/src/git-lfs/install.sh +++ b/src/git-lfs/install.sh @@ -8,37 +8,24 @@ # Maintainer: The VS Code and Codespaces Teams GIT_LFS_VERSION=${VERSION:-"latest"} +AUTO_PULL=${AUTOPULL:="true"} +INSTALL_WITH_GITHUB=${INSTALLDIRECTLYFROMGITHUBRELEASE:="false"} GIT_LFS_ARCHIVE_GPG_KEY_URI="https://packagecloud.io/github/git-lfs/gpgkey" GIT_LFS_ARCHIVE_ARCHITECTURES="amd64 arm64" GIT_LFS_ARCHIVE_VERSION_CODENAMES="stretch buster bullseye bionic focal jammy" GIT_LFS_CHECKSUM_GPG_KEYS="0x88ace9b29196305ba9947552f1ba225c0223b187 0x86cd3297749375bcf8206715f54fe648088335a9 0xaa3b3450295830d2de6db90caba67be5a5795889" -GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80 -keyserver hkps://keys.openpgp.org -keyserver hkp://keyserver.pgp.com" set -e +# Clean up +rm -rf /var/lib/apt/lists/* + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 fi -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} - # Figure out correct version of a three part version number is not passed find_version_from_git_tags() { local variable_name=$1 @@ -73,17 +60,56 @@ find_version_from_git_tags() { echo "${variable_name}=${!variable_name}" } +# Get the list of GPG key servers that are reachable +get_gpg_key_servers() { + local curl_args="" + local keyserver_reachable=false # Flag to indicate if any keyserver is reachable + + if [ ! -z "${KEYSERVER_PROXY}" ]; then + curl_args="--proxy ${KEYSERVER_PROXY}" + fi + + test_keyserver() { + local keyserver="$1" + local keyserver_curl_url="$2" + if curl -s ${curl_args} --max-time 5 "${keyserver_curl_url}" > /dev/null; then + echo "keyserver ${keyserver}" + keyserver_reachable=true + else + echo "(*) Keyserver ${keyserver} is not reachable." >&2 + fi + } + + # Explicitly test these in order because Bash v4.4.20 (Ubuntu Bionic) + # enumerates associative array keys in a different order than Bash v5 + test_keyserver "hkp://keyserver.ubuntu.com" "http://keyserver.ubuntu.com:11371" + test_keyserver "hkp://keyserver.ubuntu.com:80" "http://keyserver.ubuntu.com" + test_keyserver "hkp://keyserver.pgp.com" "http://keyserver.pgp.com:11371" + # Test this server last because keys.openpgp.org strips user IDs from keys unless + # the owner gives permission, which causes gpg in Ubuntu Bionic to reject the key + # (https://github.com/devcontainers/features/issues/1055) + test_keyserver "hkps://keys.openpgp.org" "https://keys.openpgp.org" + + if ! $keyserver_reachable; then + echo "(!) No keyserver is reachable." >&2 + exit 1 + fi +} + # Import the specified key in a variable name passed in as receive_gpg_keys() { - get_common_setting $1 local keys=${!1} - get_common_setting GPG_KEY_SERVERS true - # Use a temporary locaiton for gpg keys to avoid polluting image + # Install curl + if ! type curl > /dev/null 2>&1; then + check_packages curl + fi + + # Use a temporary location for gpg keys to avoid polluting image export GNUPGHOME="/tmp/tmp-gnupg" mkdir -p ${GNUPGHOME} chmod 700 ${GNUPGHOME} - echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf + echo -e "disable-ipv6\n$(get_gpg_key_servers)" > ${GNUPGHOME}/dirmngr.conf # GPG key download sometimes fails for some reason and retrying fixes it. local retry_count=0 local gpg_ok="false" @@ -93,7 +119,7 @@ receive_gpg_keys() { echo "(*) Downloading GPG key..." ( echo "${keys}" | xargs -n 1 gpg --recv-keys) 2>&1 && gpg_ok="true" if [ "${gpg_ok}" != "true" ]; then - echo "(*) Failed getting key, retring in 10s..." + echo "(*) Failed getting key, retrying in 10s..." (( retry_count++ )) sleep 10s fi @@ -107,8 +133,10 @@ receive_gpg_keys() { apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not @@ -128,7 +156,6 @@ install_using_apt() { version_suffix="" fi # Install - get_common_setting GIT_LFS_ARCHIVE_GPG_KEY_URI curl -sSL "${GIT_LFS_ARCHIVE_GPG_KEY_URI}" | gpg --dearmor > /usr/share/keyrings/gitlfs-archive-keyring.gpg echo -e "deb [arch=${architecture} signed-by=/usr/share/keyrings/gitlfs-archive-keyring.gpg] https://packagecloud.io/github/git-lfs/${ID} ${VERSION_CODENAME} main\ndeb-src [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gitlfs-archive-keyring.gpg] https://packagecloud.io/github/git-lfs/${ID} ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/git-lfs.list @@ -141,14 +168,34 @@ install_using_apt() { git-lfs install --skip-repo } +# Function to fetch the version released prior to the latest version +get_previous_version() { + repo_url=$1 + curl -s "$repo_url" | jq -r 'del(.[].assets) | .[0].tag_name' +} + +install_from_release() { + git_lfs_filename="git-lfs-linux-${architecture}-v${GIT_LFS_VERSION}.tar.gz" + echo "Looking for release artfact: ${git_lfs_filename}" + curl -sSL -o "${git_lfs_filename}" "https://github.com/git-lfs/git-lfs/releases/download/v${GIT_LFS_VERSION}/${git_lfs_filename}" +} + install_using_github() { echo "(*) No apt package for ${VERSION_CODENAME} ${architecture}. Installing manually." mkdir -p /tmp/git-lfs cd /tmp/git-lfs find_version_from_git_tags GIT_LFS_VERSION "https://github.com/git-lfs/git-lfs" - git_lfs_filename="git-lfs-linux-${architecture}-v${GIT_LFS_VERSION}.tar.gz" - echo "Looking for release artfact: ${git_lfs_filename}" - curl -sSL -o "${git_lfs_filename}" "https://github.com/git-lfs/git-lfs/releases/download/v${GIT_LFS_VERSION}/${git_lfs_filename}" + install_from_release + + if grep -q "Not Found" "${git_lfs_filename}"; then + echo -e "\n(!) Failed to fetch the latest artifacts for Git lfs v${GIT_LFS_VERSION}..." + repo_url=https://api.github.com/repos/git-lfs/git-lfs/releases + requested_version=$(get_previous_version "${repo_url}") + echo -e "\nAttempting to install ${requested_version}" + GIT_LFS_VERSION=${requested_version#v} + install_from_release + fi + # Verify file curl -sSL -o "sha256sums.asc" "https://github.com/git-lfs/git-lfs/releases/download/v${GIT_LFS_VERSION}/sha256sums.asc" receive_gpg_keys GIT_LFS_CHECKSUM_GPG_KEYS @@ -176,10 +223,9 @@ export DEBIAN_FRONTEND=noninteractive # Install git, curl, gpg, dirmngr and debian-archive-keyring if missing . /etc/os-release -check_packages curl ca-certificates gnupg2 dirmngr apt-transport-https +check_packages curl ca-certificates gnupg2 dirmngr apt-transport-https jq if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends git + check_packages git fi if [ "${ID}" = "debian" ]; then check_packages debian-archive-keyring @@ -188,15 +234,49 @@ fi # Install Git LFS echo "Installing Git LFS..." architecture="$(dpkg --print-architecture)" -if [[ "${GIT_LFS_ARCHIVE_ARCHITECTURES}" = *"${architecture}"* ]] && [[ "${GIT_LFS_ARCHIVE_VERSION_CODENAMES}" = *"${VERSION_CODENAME}"* ]]; then - install_using_apt || use_github="true" +if [[ "${GIT_LFS_ARCHIVE_ARCHITECTURES}" = *"${architecture}"* ]] && [[ "${GIT_LFS_ARCHIVE_VERSION_CODENAMES}" = *"${VERSION_CODENAME}"* ]] && [[ "${INSTALL_WITH_GITHUB}" = "false" ]]; then + install_using_apt || INSTALL_WITH_GITHUB="true" else - use_github="true" + INSTALL_WITH_GITHUB="true" fi # If no archive exists or apt install fails, try direct from github -if [ "${use_github}" = "true" ]; then +if [ "${INSTALL_WITH_GITHUB}" = "true" ]; then install_using_github fi -echo "Done!" \ No newline at end of file +# --- Generate a 'pull-git-lfs-artifacts.sh' script to be executed by the 'postCreateCommand' lifecycle hook +PULL_GIT_LFS_SCRIPT_PATH="/usr/local/share/pull-git-lfs-artifacts.sh" + +tee "$PULL_GIT_LFS_SCRIPT_PATH" > /dev/null \ +<< EOF +#!/bin/sh +set -e +AUTO_PULL=${AUTO_PULL} +EOF + +tee -a "$PULL_GIT_LFS_SCRIPT_PATH" > /dev/null \ +<< 'EOF' + +echo "Fetching git lfs artifacts..." + +if [ "${AUTO_PULL}" != "true" ]; then + echo "(!) Skipping 'git lfs pull' because 'autoPull' is not set to 'true'" + exit 0 +fi + +# Check if repo is a git lfs repo. +if ! git lfs ls-files > /dev/null 2>&1; then + echo "(!) Skipping automatic 'git lfs pull' because no git lfs files were detected" + exit 0 +fi +git lfs install +git lfs pull +EOF + +chmod 755 "$PULL_GIT_LFS_SCRIPT_PATH" + +# Clean up +rm -rf /var/lib/apt/lists/* + +echo "Done!" diff --git a/src/git/NOTES.md b/src/git/NOTES.md new file mode 100644 index 000000000..507ba8e8b --- /dev/null +++ b/src/git/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +This Feature should work on recent versions of Alpine, Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and RockyLinux distributions with the `apk`, `apt`, `yum`, `dnf`, or `microdnf` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/git/README.md b/src/git/README.md index d14ec69bd..e750bbed2 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -7,9 +7,7 @@ Install an up-to-date version of Git, built from source as needed. Useful for wh ```json "features": { - "ghcr.io/devcontainers/features/git:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/git:1": {} } ``` @@ -18,10 +16,17 @@ Install an up-to-date version of Git, built from source as needed. Useful for wh | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select or enter a Git version. | string | os-provided | -| ppa | Install from PPA if available | boolean | true | +| ppa | Install from PPA if available (only supported for Ubuntu distributions) | boolean | true | +## OS Support + +This Feature should work on recent versions of Alpine, Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and RockyLinux distributions with the `apk`, `apt`, `yum`, `dnf`, or `microdnf` package manager installed. + +`bash` is required to execute the `install.sh` script. + + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/git/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/git/devcontainer-feature.json b/src/git/devcontainer-feature.json index 6f0e71750..1aa7e237b 100644 --- a/src/git/devcontainer-feature.json +++ b/src/git/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "git", - "version": "1.0.3", + "version": "1.3.8", "name": "Git (from source)", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/git", "description": "Install an up-to-date version of Git, built from source as needed. Useful for when you want the latest and greatest features. Auto-detects latest stable version and installs needed dependencies.", @@ -9,6 +9,7 @@ "type": "string", "proposals": [ "latest", + "system", "os-provided" ], "default": "os-provided", @@ -17,7 +18,21 @@ "ppa": { "type": "boolean", "default": true, - "description": "Install from PPA if available" + "description": "Install from PPA if available (only supported for Ubuntu distributions)" } - } + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/git/install.sh b/src/git/install.sh index 507599ecf..e289ce16b 100755 --- a/src/git/install.sh +++ b/src/git/install.sh @@ -10,62 +10,136 @@ GIT_VERSION=${VERSION} # 'system' checks the base image first, else installs 'latest' USE_PPA_IF_AVAILABLE=${PPA} -GIT_CORE_PPA_ARCHIVE_GPG_KEY=E1DD270288B4E6030699E45FA1715D88E1DF1F24 -GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80 -keyserver hkps://keys.openpgp.org -keyserver hkp://keyserver.pgp.com" - -set -e +GIT_CORE_PPA_ARCHIVE_GPG_KEY=F911AB184317630C59970973E363C90F8F1B6217 if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 fi -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true +# Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME +. /etc/os-release +# Get an adjusted ID independent of distro variants +if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" +elif [ "${ID}" = "alpine" ]; then + ADJUSTED_ID="alpine" +elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + VERSION_CODENAME="${ID}${VERSION_ID}" +else + echo "Linux distro ${ID} not supported." + exit 1 +fi + +if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${VERSION_CODENAME-}" = "centos7" ]; then + # As of 1 July 2024, mirrorlist.centos.org no longer exists. + # Update the repo files to reference vault.centos.org. + sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo + sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo + sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo +fi + +if type apt-get > /dev/null 2>&1; then + INSTALL_CMD=apt-get +elif type apk > /dev/null 2>&1; then + INSTALL_CMD=apk +elif type microdnf > /dev/null 2>&1; then + INSTALL_CMD=microdnf +elif type dnf > /dev/null 2>&1; then + INSTALL_CMD=dnf +elif type yum > /dev/null 2>&1; then + INSTALL_CMD=yum +else + echo "(Error) Unable to find a supported package manager." + exit 1 +fi + +# Clean up +clean_up() { + case $ADJUSTED_ID in + debian) + rm -rf /var/lib/apt/lists/* + ;; + alpine) + rm -rf /var/cache/apk/* + ;; + rhel) + rm -rf /var/cache/dnf/* + rm -rf /var/cache/yum/* + ;; + esac +} +clean_up + +# Get the list of GPG key servers that are reachable +get_gpg_key_servers() { + local curl_args="" + local keyserver_reachable=false # Flag to indicate if any keyserver is reachable + + if [ ! -z "${KEYSERVER_PROXY}" ]; then + curl_args="--proxy ${KEYSERVER_PROXY}" fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi + + test_keyserver() { + local keyserver="$1" + local keyserver_curl_url="$2" + if curl -s ${curl_args} --max-time 5 "${keyserver_curl_url}" > /dev/null; then + echo "keyserver ${keyserver}" + keyserver_reachable=true + else + echo "(*) Keyserver ${keyserver} is not reachable." >&2 + fi + } + + # Explicitly test these in order because Bash v4.4.20 (Ubuntu Bionic) + # enumerates associative array keys in a different order than Bash v5 + test_keyserver "hkp://keyserver.ubuntu.com" "http://keyserver.ubuntu.com:11371" + test_keyserver "hkp://keyserver.ubuntu.com:80" "http://keyserver.ubuntu.com" + test_keyserver "hkp://keyserver.pgp.com" "http://keyserver.pgp.com:11371" + # Test this server last because keys.openpgp.org strips user IDs from keys unless + # the owner gives permission, which causes gpg in Ubuntu Bionic to reject the key + # (https://github.com/devcontainers/features/issues/1055) + test_keyserver "hkps://keys.openpgp.org" "https://keys.openpgp.org" + + if ! $keyserver_reachable; then + echo "(!) No keyserver is reachable." >&2 + exit 1 fi - echo "$1=${!1}" } -# Import the specified key in a variable name passed in as +# Import the specified key in a variable name passed in as receive_gpg_keys() { - get_common_setting $1 - local keys=${!1} - get_common_setting GPG_KEY_SERVERS true - local keyring_args="" - if [ ! -z "$2" ]; then - mkdir -p "$(dirname \"$2\")" - keyring_args="--no-default-keyring --keyring $2" + local -a keys="(${!1})" + mkdir -p "$(dirname "$2")" + + # Install curl + if ! type curl > /dev/null 2>&1; then + check_packages curl fi - # Use a temporary locaiton for gpg keys to avoid polluting image + # Use a temporary location for gpg keys to avoid polluting image export GNUPGHOME="/tmp/tmp-gnupg" mkdir -p ${GNUPGHOME} chmod 700 ${GNUPGHOME} - echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf + echo -e "disable-ipv6\n$(get_gpg_key_servers)" > ${GNUPGHOME}/dirmngr.conf # GPG key download sometimes fails for some reason and retrying fixes it. local retry_count=0 local gpg_ok="false" set +e - until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; + until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; do - echo "(*) Downloading GPG key..." - ( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true" - if [ "${gpg_ok}" != "true" ]; then - echo "(*) Failed getting key, retring in 10s..." - (( retry_count++ )) - sleep 10s - fi + for key in "${keys[@]}"; do + echo "(*) Downloading GPG key '${key}'..." + gpg --recv-keys "${key}" \ + && gpg --export "${key}" | gpg --dearmor --yes -o "$2" \ + && gpg_ok="true" + if [ "${gpg_ok}" != "true" ]; then + echo "(*) Failed getting key, retrying in 10s..." + (( retry_count++ )) + sleep 10s + fi + done done set -e if [ "${gpg_ok}" = "false" ]; then @@ -74,34 +148,84 @@ receive_gpg_keys() { fi } -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y +pkg_mgr_update() { + if [ ${INSTALL_CMD} = "apt-get" ]; then + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + ${INSTALL_CMD} update -y + fi + elif [ ${INSTALL_CMD} = "apk" ]; then + if [ "$(find /var/cache/apk/* | wc -l)" = "0" ]; then + echo "Running apk update..." + ${INSTALL_CMD} update + fi + elif [ ${INSTALL_CMD} = "dnf" ] || [ ${INSTALL_CMD} = "yum" ]; then + if [ "$(find /var/cache/${INSTALL_CMD}/* | wc -l)" = "0" ]; then + echo "Running ${INSTALL_CMD} check-update ..." + ${INSTALL_CMD} check-update + fi + fi } + # Checks if packages are installed and installs them if not check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" + if [ ${INSTALL_CMD} = "apt-get" ]; then + if ! dpkg -s "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${INSTALL_CMD} -y install --no-install-recommends "$@" + fi + elif [ ${INSTALL_CMD} = "apk" ]; then + ${INSTALL_CMD} add \ + --no-cache \ + "$@" + elif [ ${INSTALL_CMD} = "dnf" ] || [ ${INSTALL_CMD} = "yum" ]; then + _num_pkgs=$(echo "$@" | tr ' ' \\012 | wc -l) + _num_installed=$(${INSTALL_CMD} -C list installed "$@" | sed '1,/^Installed/d' | wc -l) + if [ ${_num_pkgs} != ${_num_installed} ]; then + pkg_mgr_update + ${INSTALL_CMD} -y install "$@" + fi + elif [ ${INSTALL_CMD} = "microdnf" ]; then + ${INSTALL_CMD} -y install \ + --refresh \ + --best \ + --nodocs \ + --noplugins \ + --setopt=install_weak_deps=0 \ + "$@" + else + echo "Linux distro ${ID} not supported." + exit 1 fi } export DEBIAN_FRONTEND=noninteractive -# Source /etc/os-release to get OS info -. /etc/os-release +# Debian / Ubuntu packages # If the os provided version is "good enough", just install that. if [ ${GIT_VERSION} = "os-provided" ] || [ ${GIT_VERSION} = "system" ]; then if type git > /dev/null 2>&1; then - echo "Detected existing system install: $(git version)" - exit 0 + echo "Detected existing system install: $(git version)" + # Clean up + clean_up + exit 0 fi - echo "Installing git from OS apt repository" + if [ "$INSTALL_CMD" = "apt-get" ]; then + echo "Installing git from OS apt repository" + elif [ "$INSTALL_CMD" = "apk" ]; then + echo "Installing git from OS apk repository" + else + echo "Installing git from OS yum/dnf repository" + fi + if [ $ID = "mariner" ]; then + check_packages ca-certificates + fi check_packages git + # Clean up + clean_up exit 0 fi @@ -111,19 +235,69 @@ if ([ "${GIT_VERSION}" = "latest" ] || [ "${GIT_VERSION}" = "lts" ] || [ "${GIT_ check_packages apt-transport-https curl ca-certificates gnupg2 dirmngr receive_gpg_keys GIT_CORE_PPA_ARCHIVE_GPG_KEY /usr/share/keyrings/gitcoreppa-archive-keyring.gpg echo -e "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gitcoreppa-archive-keyring.gpg] http://ppa.launchpad.net/git-core/ppa/ubuntu ${VERSION_CODENAME} main\ndeb-src [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gitcoreppa-archive-keyring.gpg] http://ppa.launchpad.net/git-core/ppa/ubuntu ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/git-core-ppa.list - apt-get update - apt-get -y install --no-install-recommends git + ${INSTALL_CMD} update + ${INSTALL_CMD} -y install --no-install-recommends git rm -rf "/tmp/tmp-gnupg" + rm -rf /var/lib/apt/lists/* exit 0 fi # Install required packages to build if missing -check_packages build-essential curl ca-certificates tar gettext libssl-dev zlib1g-dev libcurl?-openssl-dev libexpat1-dev +if [ "${ADJUSTED_ID}" = "debian" ]; then + + check_packages build-essential curl ca-certificates tar gettext libssl-dev zlib1g-dev libcurl?-openssl-dev libexpat1-dev + + check_packages libpcre2-dev + + if [ "${VERSION_CODENAME}" = "focal" ] || [ "${VERSION_CODENAME}" = "bullseye" ]; then + check_packages libpcre2-posix2 + elif [ "${VERSION_CODENAME}" = "bionic" ] || [ "${VERSION_CODENAME}" = "buster" ]; then + check_packages libpcre2-posix0 + else + check_packages libpcre2-posix3 + fi + +elif [ "${ADJUSTED_ID}" = "alpine" ]; then + + # update build dependencies + ${INSTALL_CMD} add --no-cache --update curl grep make zlib-dev + + # ref. + check_packages asciidoc curl-dev expat-dev g++ gcc linux-headers openssl-dev pcre2-dev perl-dev perl-error python3-dev tcl tk xmlto + +elif [ "${ADJUSTED_ID}" = "rhel" ]; then + check_packages gcc make libcurl-devel expat-devel gettext-devel openssl-devel perl-devel zlib-devel cmake pcre2-devel tar gzip ca-certificates + if ! type curl > /dev/null 2>&1; then + check_packages curl + fi + if ! type cmp > /dev/null 2>&1; then + check_packages diffutils + fi + if ! type awk > /dev/null 2>&1; then + check_packages gawk + fi + if [ $ID = "mariner" ]; then + check_packages glibc-devel kernel-headers binutils + fi + +else + echo "Linux distro ${ID} not supported." + exit 1 +fi # Partial version matching if [ "$(echo "${GIT_VERSION}" | grep -o '\.' | wc -l)" != "2" ]; then requested_version="${GIT_VERSION}" - version_list="$(curl -sSL -H "Accept: application/vnd.github.v3+json" "https://api.github.com/repos/git/git/tags" | grep -oP '"name":\s*"v\K[0-9]+\.[0-9]+\.[0-9]+"' | tr -d '"' | sort -rV )" + response_output_file=$(mktemp) + trap 'rm "$response_output_file"' EXIT + + http_code=$(curl --silent --output $response_output_file --write-out "%{http_code}" -sSL -H "Accept: application/vnd.github.v3+json" "https://api.github.com/repos/git/git/tags") + version_content=$(cat "$response_output_file") + if [[ ${http_code} -lt 200 || ${http_code} -gt 299 ]] ; then + echo "$version_content" >&2 + exit 1 + fi + version_list="$(echo "$version_content" | grep -oP '"name":\s*"v\K[0-9]+\.[0-9]+\.[0-9]+"' | tr -d '"' | sort -rV )" if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "current" ]; then GIT_VERSION="$(echo "${version_list}" | head -n 1)" else @@ -141,6 +315,21 @@ echo "Downloading source for ${GIT_VERSION}..." curl -sL https://github.com/git/git/archive/v${GIT_VERSION}.tar.gz | tar -xzC /tmp 2>&1 echo "Building..." cd /tmp/git-${GIT_VERSION} -make -s prefix=/usr/local all && make -s prefix=/usr/local install 2>&1 +git_options=("prefix=/usr/local") +git_options+=("sysconfdir=/etc") +git_options+=("USE_LIBPCRE=YesPlease") +git_options+=("NO_RUST=YesPlease") +if [ "${ADJUSTED_ID}" = "alpine" ]; then + # ref. + git_options+=("NO_REGEX=YesPlease") + git_options+=("NO_GETTEXT=YesPlease") +fi +make -s "${git_options[@]}" all && make -s "${git_options[@]}" install 2>&1 +build_result=$? rm -rf /tmp/git-${GIT_VERSION} +clean_up +if [ "${build_result}" -ne 0 ]; then + echo "(!) Failed to build and install git ${GIT_VERSION}." >&2 + exit 1 +fi echo "Done!" diff --git a/src/github-cli/NOTES.md b/src/github-cli/NOTES.md new file mode 100644 index 000000000..53c5322dd --- /dev/null +++ b/src/github-cli/NOTES.md @@ -0,0 +1,11 @@ +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + +## Extensions + +If you set the `extensions` option, the feature will install each comma-separated entry. Extensions are installed for the most appropriate non-root user (based on `USERNAME` / `_REMOTE_USER`), with a fallback to `root`. + +Private extensions can be installed when `GH_TOKEN` or `GITHUB_TOKEN` is available during feature installation. The token is forwarded to the selected non-root user and used through the GitHub CLI Git credential helper. diff --git a/src/github-cli/README.md b/src/github-cli/README.md index 1305656f0..0da722f69 100644 --- a/src/github-cli/README.md +++ b/src/github-cli/README.md @@ -1,4 +1,3 @@ - # GitHub CLI (github-cli) Installs the GitHub CLI. Auto-detects latest version and installs needed dependencies. @@ -7,21 +6,24 @@ Installs the GitHub CLI. Auto-detects latest version and installs needed depende ```json "features": { - "ghcr.io/devcontainers/features/github-cli:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/github-cli:1": {} } ``` ## Options -| Options Id | Description | Type | Default Value | -|-----|-----|-----|-----| -| version | Select version of the GitHub CLI, if not latest. | string | latest | -| installDirectlyFromGitHubRelease | - | boolean | true | +| Options Id | Description | Type | Default Value | +| -------------------------------- | --------------------------------------------------------------------------------------------------- | ------- | ------------- | +| version | Select version of the GitHub CLI, if not latest. | string | latest | +| installDirectlyFromGitHubRelease | - | boolean | true | +| extensions | Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot'). | string | | + +## OS Support +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. +`bash` is required to execute the `install.sh` script. --- -_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/github-cli/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/github-cli/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/github-cli/devcontainer-feature.json b/src/github-cli/devcontainer-feature.json index 597291d36..58b3e5b2f 100644 --- a/src/github-cli/devcontainer-feature.json +++ b/src/github-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "github-cli", - "version": "1.0.4", + "version": "1.1.1", "name": "GitHub CLI", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/github-cli", "description": "Installs the GitHub CLI. Auto-detects latest version and installs needed dependencies.", @@ -17,7 +17,26 @@ "installDirectlyFromGitHubRelease": { "type": "boolean", "default": true + }, + "extensions": { + "type": "string", + "default": "", + "description": "Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot')." + } + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the GitHub CLI (`gh`), which is pre-installed and available on the `PATH`. IMPORTANT: `gh api -f` does not support object values, use multiple `-f` flags with hierarchical keys and string values instead. When using GitHub actions `actions/upload-artifact` or `actions/download-artifact` use v4 or later." + } + ] + } } - } -} - + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils", + "ghcr.io/devcontainers/features/git" + ] +} \ No newline at end of file diff --git a/src/github-cli/install.sh b/src/github-cli/install.sh index 22ed04018..3638d9392 100755 --- a/src/github-cli/install.sh +++ b/src/github-cli/install.sh @@ -9,115 +9,70 @@ CLI_VERSION=${VERSION:-"latest"} INSTALL_DIRECTLY_FROM_GITHUB_RELEASE=${INSTALLDIRECTLYFROMGITHUBRELEASE:-"true"} +EXTENSIONS=${EXTENSIONS:-""} GITHUB_CLI_ARCHIVE_GPG_KEY=23F3D4EA75716059 -GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80 -keyserver hkps://keys.openpgp.org -keyserver hkp://keyserver.pgp.com" set -e +# Clean up +rm -rf /var/lib/apt/lists/* + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 fi -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} +# Get the list of GPG key servers that are reachable +get_gpg_key_servers() { + declare -A keyservers_curl_map=( + ["hkp://keyserver.ubuntu.com"]="http://keyserver.ubuntu.com:11371" + ["hkp://keyserver.ubuntu.com:80"]="http://keyserver.ubuntu.com" + ["hkps://keys.openpgp.org"]="https://keys.openpgp.org" + ["hkp://keyserver.pgp.com"]="http://keyserver.pgp.com:11371" + ) -# Import the specified key in a variable name passed in as -receive_gpg_keys() { - get_common_setting $1 - local keys=${!1} - get_common_setting GPG_KEY_SERVERS true + local curl_args="" + local keyserver_reachable=false # Flag to indicate if any keyserver is reachable - # Use a temporary locaiton for gpg keys to avoid polluting image - export GNUPGHOME="/tmp/tmp-gnupg" - mkdir -p ${GNUPGHOME} - chmod 700 ${GNUPGHOME} - echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf - # GPG key download sometimes fails for some reason and retrying fixes it. - local retry_count=0 - local gpg_ok="false" - set +e - until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; - do - echo "(*) Downloading GPG key..." - ( echo "${keys}" | xargs -n 1 gpg --recv-keys) 2>&1 && gpg_ok="true" - if [ "${gpg_ok}" != "true" ]; then - echo "(*) Failed getting key, retring in 10s..." - (( retry_count++ )) - sleep 10s - fi - done - set -e - if [ "${gpg_ok}" = "false" ]; then - echo "(!) Failed to get gpg key." - exit 1 + if [ ! -z "${KEYSERVER_PROXY}" ]; then + curl_args="--proxy ${KEYSERVER_PROXY}" fi -} -# Figure out correct version of a three part version number is not passed -find_version_from_git_tags() { - local variable_name=$1 - local requested_version=${!variable_name} - if [ "${requested_version}" = "none" ]; then return; fi - local repository=$2 - local prefix=${3:-"tags/v"} - local separator=${4:-"."} - local last_part_optional=${5:-"false"} - if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then - local escaped_separator=${separator//./\\.} - local last_part - if [ "${last_part_optional}" = "true" ]; then - last_part="(${escaped_separator}[0-9]+)?" + for keyserver in "${!keyservers_curl_map[@]}"; do + local keyserver_curl_url="${keyservers_curl_map[${keyserver}]}" + if curl -s ${curl_args} --max-time 5 ${keyserver_curl_url} > /dev/null; then + echo "keyserver ${keyserver}" + keyserver_reachable=true else - last_part="${escaped_separator}[0-9]+" + echo "(*) Keyserver ${keyserver} is not reachable." >&2 fi - local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" - local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" - if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then - declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" - else - set +e - declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" - set -e - fi - fi - if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then - echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + done + + if ! $keyserver_reachable; then + echo "(!) No keyserver is reachable." >&2 exit 1 fi - echo "${variable_name}=${!variable_name}" } # Import the specified key in a variable name passed in as receive_gpg_keys() { - get_common_setting $1 local keys=${!1} - get_common_setting GPG_KEY_SERVERS true local keyring_args="" if [ ! -z "$2" ]; then keyring_args="--no-default-keyring --keyring $2" fi - # Use a temporary locaiton for gpg keys to avoid polluting image + # Install curl + if ! type curl > /dev/null 2>&1; then + check_packages curl + fi + + # Use a temporary location for gpg keys to avoid polluting image export GNUPGHOME="/tmp/tmp-gnupg" mkdir -p ${GNUPGHOME} chmod 700 ${GNUPGHOME} - echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf + echo -e "disable-ipv6\n$(get_gpg_key_servers)" > ${GNUPGHOME}/dirmngr.conf # GPG key download sometimes fails for some reason and retrying fixes it. local retry_count=0 local gpg_ok="false" @@ -127,7 +82,7 @@ receive_gpg_keys() { echo "(*) Downloading GPG key..." ( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true" if [ "${gpg_ok}" != "true" ]; then - echo "(*) Failed getting key, retring in 10s..." + echo "(*) Failed getting key, retrying in 10s..." (( retry_count++ )) sleep 10s fi @@ -141,8 +96,10 @@ receive_gpg_keys() { apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not @@ -153,6 +110,7 @@ check_packages() { fi } +# Figure out correct version of a three part version number is not passed find_version_from_git_tags() { local variable_name=$1 local requested_version=${!variable_name} @@ -186,6 +144,46 @@ find_version_from_git_tags() { echo "${variable_name}=${!variable_name}" } +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} # Fall back on direct download if no apt package exists # Fetches .deb file to be installed with dpkg @@ -198,7 +196,16 @@ install_deb_using_github() { mkdir -p /tmp/ghcli pushd /tmp/ghcli - wget https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename} + wget -q --show-progress --progress=dot:giga https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename} + exit_code=$? + set -e + if [ "$exit_code" != "0" ]; then + # Handle situation where git tags are ahead of what was is available to actually download + echo "(!) github-cli version ${CLI_VERSION} failed to download. Attempting to fall back one version to retry..." + find_prev_version_from_git_tags CLI_VERSION https://github.com/cli/cli + wget -q --show-progress --progress=dot:giga https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename} + fi + dpkg -i /tmp/ghcli/${cli_filename} popd rm -rf /tmp/ghcli @@ -209,8 +216,7 @@ export DEBIAN_FRONTEND=noninteractive # Install curl, apt-transport-https, curl, gpg, or dirmngr, git if missing check_packages curl ca-certificates apt-transport-https dirmngr gnupg2 if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends git + check_packages git fi # Soft version matching @@ -236,3 +242,43 @@ else rm -rf "/tmp/gh/gnupg" echo "Done!" fi + +# Install requested GitHub CLI extensions (if any) +if [ -n "${EXTENSIONS}" ]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + EXTENSIONS_SCRIPT="${SCRIPT_DIR}/scripts/install-extensions.sh" + + # Determine the appropriate non-root user (mirrors other features' "automatic" behavior) + USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" + if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do + if [ -n "${CURRENT_USER}" ] && id -u "${CURRENT_USER}" > /dev/null 2>&1; then + USERNAME="${CURRENT_USER}" + break + fi + done + if [ -z "${USERNAME}" ]; then + USERNAME=root + fi + elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" > /dev/null 2>&1; then + USERNAME=root + fi + + if [ "${USERNAME}" = "root" ]; then + EXTENSIONS="${EXTENSIONS}" bash "${EXTENSIONS_SCRIPT}" + else + EXTENSIONS_ESCAPED="$(printf '%q' "${EXTENSIONS}")" + USERNAME_ESCAPED="$(printf '%q' "${USERNAME}")" + su \ + --login \ + --whitelist-environment=GH_TOKEN,GITHUB_TOKEN \ + --command "EXTENSIONS=${EXTENSIONS_ESCAPED} USERNAME=${USERNAME_ESCAPED} INSTALL_EXTENSIONS=true bash '${EXTENSIONS_SCRIPT}'" \ + "${USERNAME}" + INSTALL_EXTENSIONS=false bash "${EXTENSIONS_SCRIPT}" + fi +fi + +# Clean up +rm -rf /var/lib/apt/lists/* diff --git a/src/github-cli/scripts/install-extensions.sh b/src/github-cli/scripts/install-extensions.sh new file mode 100644 index 000000000..f8a893534 --- /dev/null +++ b/src/github-cli/scripts/install-extensions.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -e + +EXTENSIONS=${EXTENSIONS:-""} +INSTALL_EXTENSIONS=${INSTALL_EXTENSIONS:-"true"} + +trim() { + local value="$1" + value="${value#${value%%[![:space:]]*}}" + value="${value%${value##*[![:space:]]}}" + echo "${value}" +} + +install_extension() { + local extension="$1" + local extensions_root + local repo_name + + extensions_root="${XDG_DATA_HOME:-"${HOME}/.local/share"}/gh/extensions" + repo_name="${extension##*/}" + + mkdir -p "${extensions_root}" + if [ ! -d "${extensions_root}/${repo_name}" ]; then + git \ + -c credential.helper= \ + -c credential.helper='!gh auth git-credential' \ + clone --depth 1 "https://github.com/${extension}.git" "${extensions_root}/${repo_name}" + fi +} + +ensure_gh_extension_list_wrapper() { + local gh_config_dir + + if [ "$(id -u)" -ne 0 ]; then + return + fi + + gh_config_dir="$(mktemp -d)" + if env \ + -u GH_TOKEN \ + -u GITHUB_TOKEN \ + -u GH_ENTERPRISE_TOKEN \ + -u GITHUB_ENTERPRISE_TOKEN \ + GH_CONFIG_DIR="${gh_config_dir}" \ + gh extension list >/dev/null 2>&1; then + rm -rf "${gh_config_dir}" + return + fi + rm -rf "${gh_config_dir}" + + cat > /usr/local/bin/gh <<'EOF' +#!/usr/bin/env bash +set -e + +REAL_GH=/usr/bin/gh + +if [ "$#" -ge 2 ]; then + cmd="$1" + sub="$2" + if { [ "$cmd" = "extension" ] || [ "$cmd" = "extensions" ] || [ "$cmd" = "ext" ]; } && { [ "$sub" = "list" ] || [ "$sub" = "ls" ]; }; then + extensions_root="${XDG_DATA_HOME:-"$HOME/.local/share"}/gh/extensions" + if [ -d "$extensions_root" ]; then + shopt -s nullglob + for d in "$extensions_root"/*; do + [ -d "$d" ] || continue + url="" + if command -v git >/dev/null 2>&1 && [ -d "$d/.git" ]; then + url="$(git -C "$d" config --get remote.origin.url 2>/dev/null || true)" + fi + if [ -n "$url" ]; then + url="${url%.git}" + url="${url#https://github.com/}" + url="${url#http://github.com/}" + url="${url#ssh://git@github.com/}" + url="${url#git@github.com:}" + echo "$url" + fi + done + fi + exit 0 + fi +fi + +exec "$REAL_GH" "$@" +EOF + chmod +x /usr/local/bin/gh +} + +if [ "${INSTALL_EXTENSIONS}" = "true" ]; then + if [ -z "${EXTENSIONS}" ]; then + exit 0 + fi + + echo "Installing GitHub CLI extensions: ${EXTENSIONS}" + IFS=',' read -r -a extension_list <<< "${EXTENSIONS}" + for extension in "${extension_list[@]}"; do + extension="$(trim "${extension}")" + if [ -z "${extension}" ]; then + continue + fi + + install_extension "${extension}" + done +fi + +ensure_gh_extension_list_wrapper diff --git a/src/go/NOTES.md b/src/go/NOTES.md new file mode 100644 index 000000000..79a308cf5 --- /dev/null +++ b/src/go/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and RockyLinux distributions with the apt, yum, dnf, or microdnf package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/go/README.md b/src/go/README.md index 0b03e874f..5f35da948 100644 --- a/src/go/README.md +++ b/src/go/README.md @@ -7,9 +7,7 @@ Installs Go and common Go utilities. Auto-detects latest version and installs ne ```json "features": { - "ghcr.io/devcontainers/features/go:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/go:1": {} } ``` @@ -18,7 +16,21 @@ Installs Go and common Go utilities. Auto-detects latest version and installs ne | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select or enter a Go version to install | string | latest | +| golangciLintVersion | Version of golangci-lint to install | string | latest | +## Customizations + +### VS Code Extensions + +- `golang.Go` + + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and RockyLinux distributions with the apt, yum, dnf, or microdnf package manager installed. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/go/devcontainer-feature.json b/src/go/devcontainer-feature.json index 3c532bb2c..b61e93605 100644 --- a/src/go/devcontainer-feature.json +++ b/src/go/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "go", - "version": "1.0.7", + "version": "1.3.4", "name": "Go", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/go", "description": "Installs Go and common Go utilities. Auto-detects latest version and installs needed dependencies.", @@ -10,11 +10,16 @@ "proposals": [ "latest", "none", - "1.19", - "1.18" + "1.24", + "1.23" ], "default": "latest", "description": "Select or enter a Go version to install" + }, + "golangciLintVersion": { + "type": "string", + "default": "latest", + "description": "Version of golangci-lint to install" } }, "init": true, @@ -22,7 +27,14 @@ "vscode": { "extensions": [ "golang.Go" - ] + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes Go and common Go utilities pre-installed and available on the `PATH`, along with the Go language extension for Go development." + } + ] + } } }, "containerEnv": { @@ -35,5 +47,8 @@ ], "securityOpt": [ "seccomp=unconfined" + ], + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" ] } diff --git a/src/go/install.sh b/src/go/install.sh index 86bdbc64a..db0ac7977 100755 --- a/src/go/install.sh +++ b/src/go/install.sh @@ -7,12 +7,13 @@ # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/go.md # Maintainer: The VS Code and Codespaces Teams -TARGET_GO_VERSION=${VERSION:-"latest"} +TARGET_GO_VERSION="${VERSION:-"latest"}" +GOLANGCILINT_VERSION="${GOLANGCILINTVERSION:-"latest"}" -TARGET_GOROOT=${TARGET_GOROOT:-"/usr/local/go"} -TARGET_GOPATH=${TARGET_GOPATH:-"/go"} -USERNAME=${USERNAME:-"automatic"} -INSTALL_GO_TOOLS=${INSTALL_GO_TOOLS:-"true"} +TARGET_GOROOT="${TARGET_GOROOT:-"/usr/local/go"}" +TARGET_GOPATH="${TARGET_GOPATH:-"/go"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +INSTALL_GO_TOOLS="${INSTALL_GO_TOOLS:-"true"}" # https://www.google.com/linuxrepositories/ GO_GPG_KEY_URI="https://dl.google.com/linux/linux_signing_key.pub" @@ -24,28 +25,63 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -# Ensure that login shells get the correct path if the user updated the PATH using ENV. -rm -f /etc/profile.d/00-restore-env.sh -echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh -chmod +x /etc/profile.d/00-restore-env.sh - -# Determine the appropriate non-root user -if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u ${CURRENT_USER} > /dev/null 2>&1; then - USERNAME=${CURRENT_USER} - break - fi - done - if [ "${USERNAME}" = "" ]; then - USERNAME=root +# Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME +. /etc/os-release +# Get an adjusted ID independent of distro variants +MAJOR_VERSION_ID=$(echo ${VERSION_ID} | cut -d . -f 1) +if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" +elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + if [[ "${ID}" = "rhel" ]] || [[ "${ID}" = *"alma"* ]] || [[ "${ID}" = *"rocky"* ]]; then + VERSION_CODENAME="rhel${MAJOR_VERSION_ID}" + else + VERSION_CODENAME="${ID}${MAJOR_VERSION_ID}" fi -elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then - USERNAME=root +else + echo "Linux distro ${ID} not supported." + exit 1 +fi + +if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${VERSION_CODENAME-}" = "centos7" ]; then + # As of 1 July 2024, mirrorlist.centos.org no longer exists. + # Update the repo files to reference vault.centos.org. + sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo + sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo + sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo +fi + +# Setup INSTALL_CMD & PKG_MGR_CMD +if type apt-get > /dev/null 2>&1; then + PKG_MGR_CMD=apt-get + INSTALL_CMD="${PKG_MGR_CMD} -y install --no-install-recommends" +elif type microdnf > /dev/null 2>&1; then + PKG_MGR_CMD=microdnf + INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0" +elif type dnf > /dev/null 2>&1; then + PKG_MGR_CMD=dnf + INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0" +else + PKG_MGR_CMD=yum + INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --noplugins --setopt=install_weak_deps=0" fi +# Clean up +clean_up() { + case ${ADJUSTED_ID} in + debian) + rm -rf /var/lib/apt/lists/* + ;; + rhel) + rm -rf /var/cache/dnf/* /var/cache/yum/* + rm -rf /tmp/yum.log + rm -rf ${GPG_INSTALL_PATH} + ;; + esac +} +clean_up + + # Figure out correct version of a three part version number is not passed find_version_from_git_tags() { local variable_name=$1 @@ -80,42 +116,107 @@ find_version_from_git_tags() { echo "${variable_name}=${!variable_name}" } -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} - -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y +pkg_mgr_update() { + case $ADJUSTED_ID in + debian) + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + ${PKG_MGR_CMD} update -y + fi + ;; + rhel) + if [ ${PKG_MGR_CMD} = "microdnf" ]; then + if [ "$(ls /var/cache/yum/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} makecache ..." + ${PKG_MGR_CMD} makecache + fi + else + if [ "$(ls /var/cache/${PKG_MGR_CMD}/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} check-update ..." + set +e + ${PKG_MGR_CMD} check-update + rc=$? + if [ $rc != 0 ] && [ $rc != 100 ]; then + exit 1 + fi + set -e + fi + fi + ;; + esac } # Checks if packages are installed and installs them if not check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" - fi + case ${ADJUSTED_ID} in + debian) + if ! dpkg -s "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${INSTALL_CMD} "$@" + fi + ;; + rhel) + if ! rpm -q "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${INSTALL_CMD} "$@" + fi + ;; + esac } +# Ensure that login shells get the correct path if the user updated the PATH using ENV. +rm -f /etc/profile.d/00-restore-env.sh +echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh +chmod +x /etc/profile.d/00-restore-env.sh + +# Some distributions do not install awk by default (e.g. Mariner) +if ! type awk >/dev/null 2>&1; then + check_packages awk +fi + +# Determine the appropriate non-root user +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi +elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root +fi + export DEBIAN_FRONTEND=noninteractive -# Install curl, tar, git, other dependencies if missing -check_packages curl ca-certificates gnupg2 tar g++ gcc libc6-dev make pkg-config +check_packages ca-certificates gnupg2 tar gcc make pkg-config + +if [ $ADJUSTED_ID = "debian" ]; then + check_packages g++ libc6-dev +else + check_packages gcc-c++ glibc-devel +fi +# Install curl, git, other dependencies if missing +if ! type curl > /dev/null 2>&1; then + check_packages curl +fi if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends git + check_packages git +fi +# Some systems, e.g. Mariner, still a few more packages +if ! type as > /dev/null 2>&1; then + check_packages binutils +fi +if ! [ -f /usr/include/linux/errno.h ]; then + check_packages kernel-headers +fi +# Minimal RHEL install may need findutils installed +if ! [ -f /usr/bin/find ]; then + check_packages findutils fi # Get closest match for version number specified @@ -137,12 +238,12 @@ if ! cat /etc/group | grep -e "^golang:" > /dev/null 2>&1; then fi usermod -a -G golang "${USERNAME}" mkdir -p "${TARGET_GOROOT}" "${TARGET_GOPATH}" -if [ "${TARGET_GO_VERSION}" != "none" ] && ! type go > /dev/null 2>&1; then - # Use a temporary locaiton for gpg keys to avoid polluting image + +if [[ "${TARGET_GO_VERSION}" != "none" ]] && [[ "$(go version 2>/dev/null)" != *"${TARGET_GO_VERSION}"* ]]; then + # Use a temporary location for gpg keys to avoid polluting image export GNUPGHOME="/tmp/tmp-gnupg" mkdir -p ${GNUPGHOME} chmod 700 ${GNUPGHOME} - get_common_setting GO_GPG_KEY_URI curl -sSL -o /tmp/tmp-gnupg/golang_key "${GO_GPG_KEY_URI}" gpg -q --import /tmp/tmp-gnupg/golang_key echo "Downloading Go ${TARGET_GO_VERSION}..." @@ -181,27 +282,30 @@ if [ "${TARGET_GO_VERSION}" != "none" ] && ! type go > /dev/null 2>&1; then tar -xzf /tmp/go.tar.gz -C "${TARGET_GOROOT}" --strip-components=1 rm -rf /tmp/go.tar.gz /tmp/go.tar.gz.asc /tmp/tmp-gnupg else - echo "Go already installed. Skipping." + echo "(!) Go is already installed with version ${TARGET_GO_VERSION}. Skipping." fi # Install Go tools that are isImportant && !replacedByGopls based on -# https://github.com/golang/vscode-go/blob/v0.31.1/src/goToolsInformation.ts +# https://github.com/golang/vscode-go/blob/v0.38.0/src/goToolsInformation.ts GO_TOOLS="\ golang.org/x/tools/gopls@latest \ honnef.co/go/tools/cmd/staticcheck@latest \ golang.org/x/lint/golint@latest \ github.com/mgechev/revive@latest \ - github.com/uudashr/gopkgs/v2/cmd/gopkgs@latest \ - github.com/ramya-rao-a/go-outline@latest \ github.com/go-delve/delve/cmd/dlv@latest \ - github.com/golangci/golangci-lint/cmd/golangci-lint@latest" + github.com/fatih/gomodifytags@latest \ + github.com/haya14busa/goplay/cmd/goplay@latest \ + github.com/cweill/gotests/gotests@latest \ + github.com/josharian/impl@latest" + if [ "${INSTALL_GO_TOOLS}" = "true" ]; then echo "Installing common Go tools..." export PATH=${TARGET_GOROOT}/bin:${PATH} - mkdir -p /tmp/gotools /usr/local/etc/vscode-dev-containers ${TARGET_GOPATH}/bin - cd /tmp/gotools export GOPATH=/tmp/gotools - export GOCACHE=/tmp/gotools/cache + export GOCACHE="${GOPATH}/cache" + + mkdir -p "${GOPATH}" /usr/local/etc/vscode-dev-containers "${TARGET_GOPATH}/bin" + cd "${GOPATH}" # Use go get for versions of go under 1.16 go_install_command=install @@ -213,10 +317,24 @@ if [ "${INSTALL_GO_TOOLS}" = "true" ]; then (echo "${GO_TOOLS}" | xargs -n 1 go ${go_install_command} -v )2>&1 | tee -a /usr/local/etc/vscode-dev-containers/go.log - # Move Go tools into path and clean up - mv /tmp/gotools/bin/* ${TARGET_GOPATH}/bin/ + # Move Go tools into path + if [ -d "${GOPATH}/bin" ]; then + mv "${GOPATH}/bin"/* "${TARGET_GOPATH}/bin/" + fi + + # Install golangci-lint from precompiled binares + if [ "$GOLANGCILINT_VERSION" = "latest" ] || [ "$GOLANGCILINT_VERSION" = "" ]; then + echo "Installing golangci-lint latest..." + curl -fsSL https://golangci-lint.run/install.sh | \ + sh -s -- -b "${TARGET_GOPATH}/bin" + else + echo "Installing golangci-lint ${GOLANGCILINT_VERSION}..." + curl -fsSL https://golangci-lint.run/install.sh | \ + sh -s -- -b "${TARGET_GOPATH}/bin" "v${GOLANGCILINT_VERSION}" + fi - rm -rf /tmp/gotools + # Remove Go tools temp directory + rm -rf "${GOPATH}" fi @@ -225,4 +343,7 @@ chmod -R g+r+w "${TARGET_GOROOT}" "${TARGET_GOPATH}" find "${TARGET_GOROOT}" -type d -print0 | xargs -n 1 -0 chmod g+s find "${TARGET_GOPATH}" -type d -print0 | xargs -n 1 -0 chmod g+s +# Clean up +clean_up + echo "Done!" diff --git a/src/hugo/NOTES.md b/src/hugo/NOTES.md new file mode 100644 index 000000000..19fe92f31 --- /dev/null +++ b/src/hugo/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/hugo/README.md b/src/hugo/README.md index 6249610dd..3d346c39b 100644 --- a/src/hugo/README.md +++ b/src/hugo/README.md @@ -7,9 +7,7 @@ ```json "features": { - "ghcr.io/devcontainers/features/hugo:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/hugo:1": {} } ``` @@ -18,9 +16,17 @@ | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select or enter a version. | string | latest | +| extended | Install Hugo extended for SASS/SCSS changes | boolean | false | +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/hugo/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/hugo/devcontainer-feature.json b/src/hugo/devcontainer-feature.json index 230339739..376826509 100644 --- a/src/hugo/devcontainer-feature.json +++ b/src/hugo/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "hugo", - "version": "1.0.4", + "version": "1.1.3", "name": "Hugo", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/hugo", "options": { @@ -11,10 +11,29 @@ ], "default": "latest", "description": "Select or enter a version." + }, + "extended": { + "type": "boolean", + "default": false, + "description": "Install Hugo extended for SASS/SCSS changes" } }, "containerEnv": { "HUGO_DIR": "/usr/local/hugo", "PATH": "/usr/local/hugo/bin:${PATH}" - } + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes Hugo, a popular open-source static site generator written in Go, pre-installed and available on the `PATH`." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/hugo/install.sh b/src/hugo/install.sh index 62c072466..b268e384b 100755 --- a/src/hugo/install.sh +++ b/src/hugo/install.sh @@ -7,15 +7,18 @@ # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/hugo.md # Maintainer: The VS Code and Codespaces Teams -VERSION=${VERSION:-"latest"} +VERSION="${VERSION:-"latest"}" -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +UPDATE_RC="${UPDATE_RC:-"true"}" -HUGO_DIR=${HUGO_DIR:-"/usr/local/hugo"} +HUGO_DIR="${HUGO_DIR:-"/usr/local/hugo"}" set -e +# Clean up +rm -rf /var/lib/apt/lists/* + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -63,8 +66,10 @@ updaterc() { apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not @@ -101,7 +106,14 @@ if ! hugo version &> /dev/null ; then arch="64bit" fi - hugo_filename="hugo_${VERSION}_Linux-${arch}.tar.gz" + # Install extended version of hugo if desired + if [ "${EXTENDED}" = "true" ]; then + extended="extended_" + else + extended="" + fi + + hugo_filename="hugo_${extended}${VERSION}_Linux-${arch}.tar.gz" curl -fsSLO --compressed "https://github.com/gohugoio/hugo/releases/download/v${VERSION}/${hugo_filename}" tar -xzf "$hugo_filename" -C "$installation_dir" @@ -114,4 +126,7 @@ if ! hugo version &> /dev/null ; then find "${HUGO_DIR}" -type d -print0 | xargs -n 1 -0 chmod g+s fi +# Clean up +rm -rf /var/lib/apt/lists/* + echo "Done!" diff --git a/src/java/NOTES.md b/src/java/NOTES.md index 7e10513ee..edc310e23 100644 --- a/src/java/NOTES.md +++ b/src/java/NOTES.md @@ -1,3 +1,10 @@ ## License For the Java Feature from this repository, see [NOTICE.txt](https://github.com/devcontainers/features/tree/main/src/java/NOTICE.txt) for licensing information on JDK distributions. + + +## OS Support + +Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and RockyLinux distributions with the `apt`, `yum`, `dnf`, or `microdnf` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/java/README.md b/src/java/README.md index 02a6413e2..1a2d91851 100644 --- a/src/java/README.md +++ b/src/java/README.md @@ -7,9 +7,7 @@ Installs Java, SDKMAN! (if not installed), and needed dependencies. ```json "features": { - "ghcr.io/devcontainers/features/java:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/java:1": {} } ``` @@ -18,15 +16,35 @@ Installs Java, SDKMAN! (if not installed), and needed dependencies. | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select or enter a Java version to install | string | latest | +| additionalVersions | Enter additional Java versions, separated by commas. | string | - | | jdkDistro | Select or enter a JDK distribution | string | ms | -| installGradle | Install Gradle, a build automation tool for multi-language software development | boolean | - | -| installMaven | Install Maven, a management tool for Java | boolean | - | +| installGradle | Install Gradle, a build automation tool for multi-language software development | boolean | false | +| gradleVersion | Select or enter a Gradle version | string | latest | +| installMaven | Install Maven, a management tool for Java | boolean | false | +| mavenVersion | Select or enter a Maven version | string | latest | +| installAnt | Install Ant, a software tool for automating software build processes | boolean | false | +| antVersion | Select or enter an Ant version | string | latest | +| installGroovy | Install Groovy, powerful, optionally typed and dynamic language with static-typing and static compilation capabilities | boolean | false | +| groovyVersion | Select or enter a Groovy version | string | latest | + +## Customizations + +### VS Code Extensions + +- `vscjava.vscode-java-pack` ## License For the Java Feature from this repository, see [NOTICE.txt](https://github.com/devcontainers/features/tree/main/src/java/NOTICE.txt) for licensing information on JDK distributions. +## OS Support + +Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and RockyLinux distributions with the `apt`, `yum`, `dnf`, or `microdnf` package manager installed. + +`bash` is required to execute the `install.sh` script. + + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/java/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/java/devcontainer-feature.json b/src/java/devcontainer-feature.json index 3d8b8d813..3a1170df7 100644 --- a/src/java/devcontainer-feature.json +++ b/src/java/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "java", - "version": "1.0.9", + "version": "1.8.1", "name": "Java (via SDKMAN!)", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/java", "description": "Installs Java, SDKMAN! (if not installed), and needed dependencies.", @@ -10,6 +10,7 @@ "proposals": [ "latest", "none", + "21", "17", "11", "8" @@ -17,13 +18,19 @@ "default": "latest", "description": "Select or enter a Java version to install" }, + "additionalVersions": { + "type": "string", + "default": "", + "description": "Enter additional Java versions, separated by commas." + }, "jdkDistro": { "type": "string", "proposals": [ "ms", "open", "oracle", - "tem" + "tem", + "amzn" ], "default": "ms", "description": "Select or enter a JDK distribution" @@ -33,26 +40,86 @@ "default": false, "description": "Install Gradle, a build automation tool for multi-language software development" }, + "gradleVersion": { + "type": "string", + "proposals": [ + "latest", + "7.5.1", + "6.9.3", + "5.6.4" + ], + "default": "latest", + "description": "Select or enter a Gradle version" + }, "installMaven": { "type": "boolean", "default": false, "description": "Install Maven, a management tool for Java" + }, + "mavenVersion": { + "type": "string", + "proposals": [ + "latest", + "3.8.6", + "3.6.3", + "3.5.4" + ], + "default": "latest", + "description": "Select or enter a Maven version" + }, + "installAnt": { + "type": "boolean", + "default": false, + "description": "Install Ant, a software tool for automating software build processes" + }, + "antVersion": { + "type": "string", + "proposals": [ + "latest", + "1.10.12", + "1.9.15" + ], + "default": "latest", + "description": "Select or enter an Ant version" + }, + "installGroovy": { + "type": "boolean", + "default": false, + "description": "Install Groovy, powerful, optionally typed and dynamic language with static-typing and static compilation capabilities" + }, + "groovyVersion": { + "type": "string", + "proposals": [ + "latest", + "2.5.22", + "3.0.19", + "4.0.16" + ], + "default": "latest", + "description": "Select or enter a Groovy version" } }, - "buildArg": "_VSC_INSTALL_JAVA", "customizations": { "vscode": { "extensions": [ "vscjava.vscode-java-pack" ], "settings": { - "java.import.gradle.java.home": "/usr/local/sdkman/candidates/java/current" + "java.import.gradle.java.home": "/usr/local/sdkman/candidates/java/current", + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes Java, SDKMAN! and needed dependencies pre-installed and available on the `PATH`, along with the Java language extension pack for Java development." + } + ] } } }, "containerEnv": { "SDKMAN_DIR": "/usr/local/sdkman", "JAVA_HOME": "/usr/local/sdkman/candidates/java/current", - "PATH": "/usr/local/sdkman/bin:/usr/local/sdkman/candidates/java/current/bin:/usr/local/sdkman/candidates/gradle/current/bin:/usr/local/sdkman/candidates/maven/current/bin:${PATH}" - } + "PATH": "/usr/local/sdkman/bin:/usr/local/sdkman/candidates/java/current/bin:/usr/local/sdkman/candidates/gradle/current/bin:/usr/local/sdkman/candidates/maven/current/bin:/usr/local/sdkman/candidates/ant/current/bin:${PATH}" + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/java/install.sh b/src/java/install.sh old mode 100755 new mode 100644 index 838f7825d..a142ab9e3 --- a/src/java/install.sh +++ b/src/java/install.sh @@ -9,18 +9,24 @@ # # Syntax: ./java-debian.sh [JDK version] [SDKMAN_DIR] [non-root user] [Add to rc files flag] -JAVA_VERSION=${VERSION:-"lts"} -INSTALL_GRADLE=${INSTALLGRADLE:-"false"} -INSTALL_MAVEN=${INSTALLMAVEN:-"false"} -JDK_DISTRO=${JDKDISTRO} +JAVA_VERSION="${VERSION:-"latest"}" +INSTALL_GRADLE="${INSTALLGRADLE:-"false"}" +GRADLE_VERSION="${GRADLEVERSION:-"latest"}" +INSTALL_MAVEN="${INSTALLMAVEN:-"false"}" +MAVEN_VERSION="${MAVENVERSION:-"latest"}" +INSTALL_ANT="${INSTALLANT:-"false"}" +ANT_VERSION="${ANTVERSION:-"latest"}" +INSTALL_GROOVY="${INSTALLGROOVY:-"false"}" +GROOVY_VERSION="${GROOVYVERSION:-"latest"}" +JDK_DISTRO="${JDKDISTRO:-"ms"}" -export SDKMAN_DIR=${SDKMAN_DIR:-"/usr/local/sdkman"} -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} +export SDKMAN_DIR="${SDKMAN_DIR:-"/usr/local/sdkman"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +UPDATE_RC="${UPDATE_RC:-"true"}" # Comma-separated list of java versions to be installed # alongside JAVA_VERSION, but not set as default. -ADDITIONAL_VERSIONS=${ADDITIONALVERSIONS:-""} +ADDITIONAL_VERSIONS="${ADDITIONALVERSIONS:-""}" set -e @@ -29,6 +35,120 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi +# Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME +. /etc/os-release +# Get an adjusted ID independent of distro variants +MAJOR_VERSION_ID=$(echo ${VERSION_ID} | cut -d . -f 1) +if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" +elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + if [[ "${ID}" = "rhel" ]] || [[ "${ID}" = *"alma"* ]] || [[ "${ID}" = *"rocky"* ]]; then + VERSION_CODENAME="rhel${MAJOR_VERSION_ID}" + else + VERSION_CODENAME="${ID}${MAJOR_VERSION_ID}" + fi +else + echo "Linux distro ${ID} not supported." + exit 1 +fi + +# Setup INSTALL_CMD & PKG_MGR_CMD +if type apt-get > /dev/null 2>&1; then + PKG_MGR_CMD=apt-get + INSTALL_CMD="${PKG_MGR_CMD} -y install --no-install-recommends" +elif type microdnf > /dev/null 2>&1; then + PKG_MGR_CMD=microdnf + INSTALL_CMD="${PKG_MGR_CMD} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0" +elif type dnf > /dev/null 2>&1; then + PKG_MGR_CMD=dnf + INSTALL_CMD="${PKG_MGR_CMD} -y install" +elif type yum > /dev/null 2>&1; then + PKG_MGR_CMD=yum + INSTALL_CMD="${PKG_MGR_CMD} -y install" +else + echo "(Error) Unable to find a supported package manager." + exit 1 +fi + +pkg_manager_update() { + case $ADJUSTED_ID in + debian) + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + ${PKG_MGR_CMD} update -y + fi + ;; + rhel) + if [ ${PKG_MGR_CMD} = "microdnf" ]; then + if [ "$(ls /var/cache/yum/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} makecache ..." + ${PKG_MGR_CMD} makecache + fi + else + if [ "$(ls /var/cache/${PKG_MGR_CMD}/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} check-update ..." + set +e + stderr_messages=$(${PKG_MGR_CMD} -q check-update 2>&1) + rc=$? + # centos 7 sometimes returns a status of 100 when it apears to work. + if [ $rc != 0 ] && [ $rc != 100 ]; then + echo "(Error) ${PKG_MGR_CMD} check-update produced the following error message(s):" + echo "${stderr_messages}" + exit 1 + fi + set -e + fi + fi + ;; + esac +} + +# Checks if packages are installed and installs them if not +check_packages() { + case ${ADJUSTED_ID} in + debian) + if ! dpkg -s "$@" > /dev/null 2>&1; then + pkg_manager_update + ${INSTALL_CMD} "$@" + fi + ;; + rhel) + if ! rpm -q "$@" > /dev/null 2>&1; then + pkg_manager_update + ${INSTALL_CMD} "$@" + fi + ;; + esac +} + +if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${VERSION_CODENAME-}" = "centos7" ]; then + # As of 1 July 2024, mirrorlist.centos.org no longer exists. + # Update the repo files to reference vault.centos.org. + sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo + sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo + sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo + yum update -y + check_packages epel-release +fi + +# Clean up +clean_up() { + local pkg + case ${ADJUSTED_ID} in + debian) + rm -rf /var/lib/apt/lists/* + ;; + rhel) + for pkg in epel-release epel-release-latest packages-microsoft-prod; do + ${PKG_MGR_CMD} -y remove $pkg 2>/dev/null || /bin/true + done + rm -rf /var/cache/dnf/* /var/cache/yum/* + rm -f /etc/yum.repos.d/docker-ce.repo + ;; + esac +} + # Ensure that login shells get the correct path if the user updated the PATH using ENV. rm -f /etc/profile.d/00-restore-env.sh echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh @@ -52,39 +172,90 @@ elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then fi updaterc() { + local _bashrc + local _zshrc if [ "${UPDATE_RC}" = "true" ]; then - echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." - if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/bash.bashrc + case $ADJUSTED_ID in + debian) + _bashrc=/etc/bash.bashrc + _zshrc=/etc/zsh/zshrc + ;; + rhel) + _bashrc=/etc/bashrc + _zshrc=/etc/zshrc + ;; + esac + echo "Updating ${_bashrc} and ${_zshrc}..." + if [[ "$(cat ${_bashrc})" != *"$1"* ]]; then + echo -e "$1" >> "${_bashrc}" fi - if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/zsh/zshrc + if [ -f "${_zshrc}" ] && [[ "$(cat ${_zshrc})" != *"$1"* ]]; then + echo -e "$1" >> "${_zshrc}" fi fi } -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y +run_with_retries() { + local max_attempts="$1" + local wait_seconds="$2" + local operation="$3" + local attempt=1 + shift 3 + + until "$@"; do + if [ "${attempt}" -ge "${max_attempts}" ]; then + echo "(!) ${operation} failed after ${max_attempts} attempts." + return 1 + fi + + echo "(*) ${operation} failed on attempt ${attempt}. Retrying in ${wait_seconds}s..." + attempt=$((attempt + 1)) + sleep "${wait_seconds}" + done } -# Checks if packages are installed and installs them if not -check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" - fi +install_sdkman_cli() { + bash -o pipefail -c 'curl -fsSL "https://get.sdkman.io?rcupdate=false" | bash' } -# Use Microsoft JDK for everything but JDK 8 and 18 (unless specified differently with jdkDistro option) -get_jdk_distro() { - VERSION="$1" +find_version_list() { + prefix="$1" + suffix="$2" + install_type=$3 + ifLts="$4" + version_list=$5 + java_ver=$6 + + check_packages jq + all_versions=$(curl -s https://api.adoptium.net/v3/info/available_releases) + if [ "${ifLts}" = "true" ]; then + major_version=$(echo "$all_versions" | jq -r '.most_recent_lts') + elif [ "${java_ver}" = "latest" ]; then + major_version=$(echo "$all_versions" | jq -r '.most_recent_feature_release') + else + major_version=$(echo "$java_ver" | cut -d '.' -f 1) + fi + + # Remove the hardcoded fallback as this fails for new jdk latest version released ex: 24 + # Related Issue: https://github.com/devcontainers/features/issues/1308 if [ "${JDK_DISTRO}" = "ms" ]; then - if echo "${VERSION}" | grep -E '^8([\s\.]|$)' > /dev/null 2>&1 || echo "${VERSION}" | grep -E '^18([\s\.]|$)' > /dev/null 2>&1; then + # Check if the requested version is available in the 'ms' distribution + echo "Check if OpenJDK is available for version ${major_version} for ${JDK_DISTRO} Distro" + available_versions=$(su ${USERNAME} -c ". ${SDKMAN_DIR}/bin/sdkman-init.sh && sdk list ${install_type} | grep ${JDK_DISTRO} | grep -oE '[0-9]+(\.[0-9]+(\.[0-9]+)?)?' | sort -u") + if echo "${available_versions}" | grep -q "^${major_version}"; then + echo "JDK version ${major_version} is available in ${JDK_DISTRO}..." + else + echo "JDK version ${major_version} not available in ${JDK_DISTRO}.... Switching to (tem)." JDK_DISTRO="tem" fi fi + echo "JDK_DISTRO: ${JDK_DISTRO}" + if [ "${install_type}" != "java" ]; then + regex="${prefix}\\K[0-9]+\\.?[0-9]*\\.?[0-9]*${suffix}" + else + regex="${prefix}\\K${major_version}\\.?[0-9]*\\.?[0-9]*${suffix}${JDK_DISTRO}\\s*" + fi + declare -g ${version_list}="$(su ${USERNAME} -c ". \${SDKMAN_DIR}/bin/sdkman-init.sh && sdk list ${install_type} 2>&1 | grep -oP \"${regex}\" | tr -d ' ' | sort -rV")" } # Use SDKMAN to install something using a partial version match @@ -95,32 +266,49 @@ sdk_install() { local suffix="${4:-"\\s*"}" local full_version_check=${5:-".*-[a-z]+"} local set_as_default=${6:-"true"} + pkgs=("maven" "gradle" "ant" "groovy") + pkg_vals="${pkgs[@]}" if [ "${requested_version}" = "none" ]; then return; fi - # Blank will install latest stable version SDKMAN has - if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "default" ]; then - requested_version="" + if [ "${requested_version}" = "default" ]; then + requested_version="" + elif [[ "${pkg_vals}" =~ "${install_type}" ]] && [ "${requested_version}" = "latest" ]; then + requested_version="" + elif [ "${requested_version}" = "lts" ]; then + find_version_list "$prefix" "$suffix" "$install_type" "true" version_list "${requested_version}" + requested_version="$(echo "${version_list}" | head -n 1)" elif echo "${requested_version}" | grep -oE "${full_version_check}" > /dev/null 2>&1; then echo "${requested_version}" - else - local regex="${prefix}\\K[0-9]+\\.[0-9]+\\.[0-9]+${suffix}" - local version_list=$(su ${USERNAME} -c ". \${SDKMAN_DIR}/bin/sdkman-init.sh && sdk list ${install_type} 2>&1 | grep -oP \"${regex}\" | tr -d ' ' | sort -rV") + else + find_version_list "$prefix" "$suffix" "$install_type" "false" version_list "${requested_version}" if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ]; then requested_version="$(echo "${version_list}" | head -n 1)" else set +e - requested_version="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + requested_version="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|-|$)")" set -e fi if [ -z "${requested_version}" ] || ! echo "${version_list}" | grep "^${requested_version//./\\.}$" > /dev/null 2>&1; then - echo -e "Version $2 not found. Available versions:\n${version_list}" >&2 - exit 1 + # Fallback to LTS if "latest" was requested and not found (java only) + if [ "$2" = "latest" ] && [ "${install_type}" = "java" ]; then + echo "Latest version not found in SDKMAN. Falling back to LTS..." + find_version_list "$prefix" "$suffix" "$install_type" "true" version_list "lts" + requested_version="$(echo "${version_list}" | head -n 1)" + if [ -z "${requested_version}" ] || ! echo "${version_list}" | grep "^${requested_version//./\\.}$" > /dev/null 2>&1; then + echo -e "Version $2 (and LTS fallback) not found. Available versions:\n${version_list}" >&2 + exit 1 + fi + else + echo -e "Version $2 not found. Available versions:\n${version_list}" >&2 + exit 1 + fi fi fi if [ "${set_as_default}" = "true" ]; then JAVA_VERSION=${requested_version} fi - su ${USERNAME} -c "umask 0002 && . ${SDKMAN_DIR}/bin/sdkman-init.sh && sdk install ${install_type} ${requested_version} && sdk flush archives && sdk flush temp" + run_with_retries 5 10 "Installing ${install_type} ${requested_version} via SDKMAN" \ + su ${USERNAME} -c "umask 0002 && . ${SDKMAN_DIR}/bin/sdkman-init.sh && sdk install ${install_type} ${requested_version} && sdk flush archives && sdk flush temp" } export DEBIAN_FRONTEND=noninteractive @@ -131,8 +319,19 @@ if [ "${architecture}" != "amd64" ] && [ "${architecture}" != "x86_64" ] && [ "$ exit 1 fi -# Install dependencies -check_packages curl ca-certificates zip unzip sed +# Install dependencies, +check_packages ca-certificates zip unzip sed findutils util-linux tar +# Make sure passwd (Debian) and shadow-utils RHEL family is installed +if [ ${ADJUSTED_ID} = "debian" ]; then + check_packages passwd +elif [ ${ADJUSTED_ID} = "rhel" ]; then + check_packages shadow-utils +fi +# minimal RHEL installs may not include curl, or includes curl-minimal instead. +# Install curl if the "curl" command is not present. +if ! type curl > /dev/null 2>&1; then + check_packages curl +fi # Install sdkman if not installed if [ ! -d "${SDKMAN_DIR}" ]; then @@ -143,15 +342,35 @@ if [ ! -d "${SDKMAN_DIR}" ]; then usermod -a -G sdkman ${USERNAME} umask 0002 # Install SDKMAN - curl -sSL "https://get.sdkman.io?rcupdate=false" | bash + # For RHEL 8 systems (glibc 2.28), disable native version to avoid glibc compatibility issues + # SDKMAN native binaries require glibc 2.30+ which is not available in RHEL 8 / AlmaLinux 8 / Rocky 8 + if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${MAJOR_VERSION_ID}" = "8" ]; then + export SDKMAN_NATIVE_VERSION="false" + fi + run_with_retries 5 10 "Installing SDKMAN" install_sdkman_cli + # For RHEL 8 systems, also disable native CLI in config file and remove native binaries + if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${MAJOR_VERSION_ID}" = "8" ]; then + # Disable native CLI in config to prevent future usage + # The SDKMAN config key is sdkman_native_enable (checked in sdkman-main.sh) + if [ -f "${SDKMAN_DIR}/etc/config" ]; then + if grep -q "sdkman_native_enable" "${SDKMAN_DIR}/etc/config"; then + sed -i 's/sdkman_native_enable=.*/sdkman_native_enable=false/' "${SDKMAN_DIR}/etc/config" + else + echo "sdkman_native_enable=false" >> "${SDKMAN_DIR}/etc/config" + fi + fi + # Remove native binaries if they were installed + if [ -d "${SDKMAN_DIR}/libexec" ]; then + rm -rf "${SDKMAN_DIR}/libexec" + fi + fi chown -R "${USERNAME}:sdkman" ${SDKMAN_DIR} find ${SDKMAN_DIR} -type d -print0 | xargs -d '\n' -0 chmod g+s # Add sourcing of sdkman into bashrc/zshrc files (unless disabled) updaterc "export SDKMAN_DIR=${SDKMAN_DIR}\n. \${SDKMAN_DIR}/bin/sdkman-init.sh" fi -get_jdk_distro ${JAVA_VERSION} -sdk_install java ${JAVA_VERSION} "\\s*" "(\\.[a-z0-9]+)*-${JDK_DISTRO}\\s*" ".*-[a-z]+$" "true" +sdk_install java ${JAVA_VERSION} "\\s*" "(\\.[a-z0-9]+)*-" ".*-[a-z]+$" "true" # Additional java versions to be installed but not be set as default. if [ ! -z "${ADDITIONAL_VERSIONS}" ]; then @@ -159,21 +378,33 @@ if [ ! -z "${ADDITIONAL_VERSIONS}" ]; then IFS="," read -a additional_versions <<< "$ADDITIONAL_VERSIONS" for version in "${additional_versions[@]}"; do - get_jdk_distro ${version} - sdk_install java ${version} "\\s*" "(\\.[a-z0-9]+)*-${JDK_DISTRO}\\s*" ".*-[a-z]+$" "false" + sdk_install java ${version} "\\s*" "(\\.[a-z0-9]+)*-" ".*-[a-z]+$" "false" done IFS=$OLDIFS su ${USERNAME} -c ". ${SDKMAN_DIR}/bin/sdkman-init.sh && sdk default java ${JAVA_VERSION}" fi +# Install Ant +if [[ "${INSTALL_ANT}" = "true" ]] && ! ant -version > /dev/null 2>&1; then + sdk_install ant ${ANT_VERSION} +fi + # Install Gradle -if [[ "${INSTALL_GRADLE}" = "true" ]] && ! gradle --version > /dev/null; then - sdk_install gradle latest +if [[ "${INSTALL_GRADLE}" = "true" ]] && ! gradle --version > /dev/null 2>&1; then + sdk_install gradle ${GRADLE_VERSION} fi # Install Maven -if [[ "${INSTALL_MAVEN}" = "true" ]] && ! mvn --version > /dev/null; then - sdk_install maven latest +if [[ "${INSTALL_MAVEN}" = "true" ]] && ! mvn --version > /dev/null 2>&1; then + sdk_install maven ${MAVEN_VERSION} fi +# Install Groovy +if [[ "${INSTALL_GROOVY}" = "true" ]] && ! groovy --version > /dev/null 2>&1; then + sdk_install groovy "${GROOVY_VERSION}" +fi + +# Clean up +clean_up + echo "Done!" diff --git a/src/java/wrapper.sh b/src/java/wrapper.sh index d7f05de6c..bea343f96 100644 --- a/src/java/wrapper.sh +++ b/src/java/wrapper.sh @@ -3,11 +3,11 @@ set -e -JAVA_VERSION=${1:-"default"} -SDKMAN_DIR=${2:-"/usr/local/sdkman"} -USERNAME=${3:-"automatic"} -UPDATE_RC=${4:-"true"} -ADDITIONAL_JAVA_VERSION=11 +JAVA_VERSION="${1:-"default"}" +SDKMAN_DIR="${2:-"/usr/local/sdkman"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +UPDATE_RC="${4:-"true"}" +ADDITIONAL_JAVA_VERSION="11" cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" chmod +x install.sh diff --git a/src/kubectl-helm-minikube/NOTES.md b/src/kubectl-helm-minikube/NOTES.md index b0d1a8089..6626d2c93 100644 --- a/src/kubectl-helm-minikube/NOTES.md +++ b/src/kubectl-helm-minikube/NOTES.md @@ -10,3 +10,10 @@ minikube addons enable ingress # Run this to forward to localhost in the background nohup kubectl port-forward --pod-running-timeout=24h -n ingress-nginx service/ingress-nginx-controller :80 & ``` + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/kubectl-helm-minikube/README.md b/src/kubectl-helm-minikube/README.md index 0f600756f..81ce8c574 100644 --- a/src/kubectl-helm-minikube/README.md +++ b/src/kubectl-helm-minikube/README.md @@ -1,5 +1,5 @@ -# Kubectl, Helm, and Minkube (kubectl-helm-minikube) +# Kubectl, Helm, and Minikube (kubectl-helm-minikube) Installs latest version of kubectl, Helm, and optionally minikube. Auto-detects latest versions and installs needed dependencies. @@ -7,9 +7,7 @@ Installs latest version of kubectl, Helm, and optionally minikube. Auto-detects ```json "features": { - "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {} } ``` @@ -35,6 +33,13 @@ nohup kubectl port-forward --pod-running-timeout=24h -n ingress-nginx service/in ``` +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/kubectl-helm-minikube/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/kubectl-helm-minikube/devcontainer-feature.json b/src/kubectl-helm-minikube/devcontainer-feature.json index 0aead19fe..a88aebde6 100644 --- a/src/kubectl-helm-minikube/devcontainer-feature.json +++ b/src/kubectl-helm-minikube/devcontainer-feature.json @@ -1,51 +1,66 @@ { - "id": "kubectl-helm-minikube", - "version": "1.0.3", - "name": "Kubectl, Helm, and Minkube", - "documentationURL": "https://github.com/devcontainers/features/tree/main/src/kubectl-helm-minikube", - "description": "Installs latest version of kubectl, Helm, and optionally minikube. Auto-detects latest versions and installs needed dependencies.", - "options": { - "version": { - "type": "string", - "proposals": [ - "latest", - "none", - "1.23", - "1.22", - "1.21" - ], - "default": "latest", - "description": "Select or enter a Kubernetes version to install" - }, - "helm": { - "type": "string", - "proposals": [ - "latest" - ], - "default": "latest", - "description": "Select or enter a Helm version to install" - }, - "minikube": { - "type": "string", - "proposals": [ - "latest" - ], - "default": "latest", - "description": "Select or enter a Minikube version to install" - } + "id": "kubectl-helm-minikube", + "version": "1.3.1", + "name": "Kubectl, Helm, and Minikube", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/kubectl-helm-minikube", + "description": "Installs latest version of kubectl, Helm, and optionally minikube. Auto-detects latest versions and installs needed dependencies.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "none", + "1.23", + "1.22", + "1.21", + "none" + ], + "default": "latest", + "description": "Select or enter a Kubernetes version to install" }, - "customizations": { - "vscode": { - "extensions": [ - "ms-kubernetes-tools.vscode-kubernetes-tools" - ] - } + "helm": { + "type": "string", + "proposals": [ + "latest", + "none" + ], + "default": "latest", + "description": "Select or enter a Helm version to install" }, - "mounts": [ - { - "source": "minikube-config", - "target": "/home/vscode/.minikube", - "type": "volume" - } - ] + "minikube": { + "type": "string", + "proposals": [ + "latest", + "none" + ], + "default": "latest", + "description": "Select or enter a Minikube version to install" + }, + "kubectlFallbackVersion": { + "type": "string", + "default": "v1.35.1", + "description": "Fallback kubectl version to use when the latest stable version cannot be fetched" + } + }, + "mounts": [ + { + "source": "minikube-config", + "target": "/home/vscode/.minikube", + "type": "volume" + } + ], + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes kubectl, Helm, optionally minikube, and needed dependencies pre-installed and available on the `PATH`. When configuring Ingress for your Kubernetes cluster, note that by default Kubernetes will bind to a specific interface's IP rather than localhost or all interfaces. This is why you need to use the Kubernetes Node's IP when connecting - even if there's only one Node as in the case of Minikube." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/kubectl-helm-minikube/install.sh b/src/kubectl-helm-minikube/install.sh index e9993a114..40901d3cb 100755 --- a/src/kubectl-helm-minikube/install.sh +++ b/src/kubectl-helm-minikube/install.sh @@ -9,19 +9,22 @@ set -e +# Clean up +rm -rf /var/lib/apt/lists/* + +# Fallback version when stable.txt cannot be fetched +KUBECTL_FALLBACK_VERSION="${KUBECTLFALLBACKVERSION:-"v1.35.1"}" + KUBECTL_VERSION="${VERSION:-"latest"}" HELM_VERSION="${HELM:-"latest"}" -MINIKUBE_VERSION="${MINIKUBE:-"none"}" # latest is also valid +MINIKUBE_VERSION="${MINIKUBE:-"latest"}" # latest is also valid KUBECTL_SHA256="${KUBECTL_SHA256:-"automatic"}" HELM_SHA256="${HELM_SHA256:-"automatic"}" MINIKUBE_SHA256="${MINIKUBE_SHA256:-"automatic"}" -USERNAME=${USERNAME:-"automatic"} +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" HELM_GPG_KEYS_URI="https://raw.githubusercontent.com/helm/helm/main/KEYS" -GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80 -keyserver hkps://keys.openpgp.org -keyserver hkp://keyserver.pgp.com" if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' @@ -50,31 +53,16 @@ if [ "$USERNAME" = "root" ]; then USERHOME="/root" fi - -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} - # Figure out correct version of a three part version number is not passed find_version_from_git_tags() { local variable_name=$1 local requested_version=${!variable_name} + requested_version="${requested_version#v}" if [ "${requested_version}" = "none" ]; then return; fi local repository=$2 local prefix=${3:-"tags/v"} local separator=${4:-"."} - local last_part_optional=${5:-"false"} + local last_part_optional=${5:-"false"} if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then local escaped_separator=${separator//./\\.} local last_part @@ -100,10 +88,53 @@ find_version_from_git_tags() { echo "${variable_name}=${!variable_name}" } +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} + apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not @@ -120,8 +151,7 @@ export DEBIAN_FRONTEND=noninteractive # Install dependencies check_packages curl ca-certificates coreutils gnupg2 dirmngr bash-completion if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends git + check_packages git fi architecture="$(uname -m)" @@ -133,79 +163,176 @@ case $architecture in *) echo "(!) Architecture $architecture unsupported"; exit 1 ;; esac -# Install the kubectl, verify checksum -echo "Downloading kubectl..." -if [ "${KUBECTL_VERSION}" = "latest" ] || [ "${KUBECTL_VERSION}" = "lts" ] || [ "${KUBECTL_VERSION}" = "current" ] || [ "${KUBECTL_VERSION}" = "stable" ]; then - KUBECTL_VERSION="$(curl -sSL https://dl.k8s.io/release/stable.txt)" -else - find_version_from_git_tags KUBECTL_VERSION https://github.com/kubernetes/kubernetes -fi -if [ "${KUBECTL_VERSION::1}" != 'v' ]; then - KUBECTL_VERSION="v${KUBECTL_VERSION}" -fi -curl -sSL -o /usr/local/bin/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${architecture}/kubectl" -chmod 0755 /usr/local/bin/kubectl -if [ "$KUBECTL_SHA256" = "automatic" ]; then - KUBECTL_SHA256="$(curl -sSL "https://dl.k8s.io/${KUBECTL_VERSION}/bin/linux/${architecture}/kubectl.sha256")" -fi -([ "${KUBECTL_SHA256}" = "dev-mode" ] || (echo "${KUBECTL_SHA256} */usr/local/bin/kubectl" | sha256sum -c -)) -if ! type kubectl > /dev/null 2>&1; then - echo '(!) kubectl installation failed!' - exit 1 -fi +if [ ${KUBECTL_VERSION} != "none" ]; then + # Install the kubectl, verify checksum + echo "Downloading kubectl..." + if [ "${KUBECTL_VERSION}" = "latest" ] || [ "${KUBECTL_VERSION}" = "lts" ] || [ "${KUBECTL_VERSION}" = "current" ] || [ "${KUBECTL_VERSION}" = "stable" ]; then + KUBECTL_VERSION="$(curl -fsSL --connect-timeout 10 --max-time 30 https://dl.k8s.io/release/stable.txt 2>/dev/null | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' || echo "")" + if [ -z "${KUBECTL_VERSION}" ]; then + echo "(!) Failed to fetch kubectl stable version from dl.k8s.io, trying alternative URL..." + KUBECTL_VERSION="$(curl -fsSL --connect-timeout 10 --max-time 30 https://storage.googleapis.com/kubernetes-release/release/stable.txt 2>/dev/null | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' || echo "")" + fi + if [ -z "${KUBECTL_VERSION}" ]; then + echo "(!) Failed to fetch kubectl stable version from both URLs. Using fallback version ${KUBECTL_FALLBACK_VERSION}" + KUBECTL_VERSION="${KUBECTL_FALLBACK_VERSION}" + fi + else + find_version_from_git_tags KUBECTL_VERSION https://github.com/kubernetes/kubernetes + fi + if [ "${KUBECTL_VERSION::1}" != 'v' ]; then + KUBECTL_VERSION="v${KUBECTL_VERSION}" + fi + curl -sSL -o /usr/local/bin/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${architecture}/kubectl" + chmod 0755 /usr/local/bin/kubectl + if [ "$KUBECTL_SHA256" = "automatic" ]; then + KUBECTL_SHA256="$(curl -sSL "https://dl.k8s.io/${KUBECTL_VERSION}/bin/linux/${architecture}/kubectl.sha256")" + fi + ([ "${KUBECTL_SHA256}" = "dev-mode" ] || (echo "${KUBECTL_SHA256} */usr/local/bin/kubectl" | sha256sum -c -)) + if ! type kubectl > /dev/null 2>&1; then + echo '(!) kubectl installation failed!' + exit 1 + fi -# kubectl bash completion -kubectl completion bash > /etc/bash_completion.d/kubectl + # kubectl bash completion + kubectl completion bash > /etc/bash_completion.d/kubectl -# kubectl zsh completion -if [ -e "${USERHOME}}/.oh-my-zsh" ]; then - mkdir -p "${USERHOME}/.oh-my-zsh/completions" - kubectl completion zsh > "${USERHOME}/.oh-my-zsh/completions/_kubectl" - chown -R "${USERNAME}" "${USERHOME}/.oh-my-zsh" + # kubectl zsh completion + if [ -e "${USERHOME}/.oh-my-zsh" ]; then + mkdir -p "${USERHOME}/.oh-my-zsh/completions" + kubectl completion zsh > "${USERHOME}/.oh-my-zsh/completions/_kubectl" + chown -R "${USERNAME}" "${USERHOME}/.oh-my-zsh" + fi fi -# Install Helm, verify signature and checksum -echo "Downloading Helm..." -find_version_from_git_tags HELM_VERSION "https://github.com/helm/helm" -if [ "${HELM_VERSION::1}" != 'v' ]; then - HELM_VERSION="v${HELM_VERSION}" -fi -mkdir -p /tmp/helm -helm_filename="helm-${HELM_VERSION}-linux-${architecture}.tar.gz" -tmp_helm_filename="/tmp/helm/${helm_filename}" -curl -sSL "https://get.helm.sh/${helm_filename}" -o "${tmp_helm_filename}" -curl -sSL "https://github.com/helm/helm/releases/download/${HELM_VERSION}/${helm_filename}.asc" -o "${tmp_helm_filename}.asc" -export GNUPGHOME="/tmp/helm/gnupg" -mkdir -p "${GNUPGHOME}" -chmod 700 ${GNUPGHOME} -get_common_setting HELM_GPG_KEYS_URI -get_common_setting GPG_KEY_SERVERS true -curl -sSL "${HELM_GPG_KEYS_URI}" -o /tmp/helm/KEYS -echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf -gpg -q --import "/tmp/helm/KEYS" -if ! gpg --verify "${tmp_helm_filename}.asc" > ${GNUPGHOME}/verify.log 2>&1; then - echo "Verification failed!" - cat /tmp/helm/gnupg/verify.log - exit 1 -fi -if [ "${HELM_SHA256}" = "automatic" ]; then - curl -sSL "https://get.helm.sh/${helm_filename}.sha256" -o "${tmp_helm_filename}.sha256" - curl -sSL "https://github.com/helm/helm/releases/download/${HELM_VERSION}/${helm_filename}.sha256.asc" -o "${tmp_helm_filename}.sha256.asc" - if ! gpg --verify "${tmp_helm_filename}.sha256.asc" > /tmp/helm/gnupg/verify.log 2>&1; then +# Function to fetch the version released prior to the latest version +get_previous_version() { + local url=$1 + local repo_url=$2 + local variable_name=$3 + prev_version=${!variable_name#v} + + output=$(curl -s "$repo_url"); + + check_packages jq + + message=$(echo "$output" | jq -r '.message') + if [[ $message == "API rate limit exceeded"* ]]; then + echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}" + echo -e "\nAttempting to find latest version using GitHub tags." + find_prev_version_from_git_tags prev_version "$url" "tags/v" + declare -g ${variable_name}="v${prev_version}" + else + echo -e "\nAttempting to find latest version using GitHub Api." + version=$(echo "$output" | jq -r '.tag_name') + declare -g ${variable_name}="${version}" + fi + echo "${variable_name}=${!variable_name}" +} + +get_github_api_repo_url() { + local url=$1 + echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases/latest" +} + +get_helm() { + HELM_VERSION=$1 + helm_filename="helm-${HELM_VERSION}-linux-${architecture}.tar.gz" + tmp_helm_filename="/tmp/helm/${helm_filename}" + curl -sSL "https://get.helm.sh/${helm_filename}" -o "${tmp_helm_filename}" + curl -sSL "https://github.com/helm/helm/releases/download/${HELM_VERSION}/${helm_filename}.asc" -o "${tmp_helm_filename}.asc" +} + +# Get the list of GPG key servers that are reachable +get_gpg_key_servers() { + declare -A keyservers_curl_map=( + ["hkp://keyserver.ubuntu.com"]="http://keyserver.ubuntu.com:11371" + ["hkp://keyserver.ubuntu.com:80"]="http://keyserver.ubuntu.com" + ["hkps://keys.openpgp.org"]="https://keys.openpgp.org" + ["hkp://keyserver.pgp.com"]="http://keyserver.pgp.com:11371" + ) + + local curl_args="" + local keyserver_reachable=false # Flag to indicate if any keyserver is reachable + + if [ ! -z "${KEYSERVER_PROXY}" ]; then + curl_args="--proxy ${KEYSERVER_PROXY}" + fi + + for keyserver in "${!keyservers_curl_map[@]}"; do + local keyserver_curl_url="${keyservers_curl_map[${keyserver}]}" + if curl -s ${curl_args} --max-time 5 ${keyserver_curl_url} > /dev/null; then + echo "keyserver ${keyserver}" + keyserver_reachable=true + else + echo "(*) Keyserver ${keyserver} is not reachable." >&2 + fi + done + + if ! $keyserver_reachable; then + echo "(!) No keyserver is reachable." >&2 + exit 1 + fi +} + +if [ ${HELM_VERSION} != "none" ]; then + # Install Helm, verify signature and checksum + echo "Downloading Helm..." + helm_url="https://github.com/helm/helm" + find_version_from_git_tags HELM_VERSION "${helm_url}" + if [ "${HELM_VERSION::1}" != 'v' ]; then + HELM_VERSION="v${HELM_VERSION}" + fi + mkdir -p /tmp/helm + get_helm "${HELM_VERSION}" + if grep -q "BlobNotFound" "${tmp_helm_filename}"; then + echo -e "\n(!) Failed to fetch the latest artifacts for helm ${HELM_VERSION}..." + repo_url=$(get_github_api_repo_url "${helm_url}") + get_previous_version "${helm_url}" "${repo_url}" HELM_VERSION + echo -e "\nAttempting to install ${HELM_VERSION}" + get_helm "${HELM_VERSION}" + fi + export GNUPGHOME="/tmp/helm/gnupg" + mkdir -p "${GNUPGHOME}" + chmod 700 ${GNUPGHOME} + curl -sSL "${HELM_GPG_KEYS_URI}" -o /tmp/helm/KEYS + echo -e "disable-ipv6\n$(get_gpg_key_servers)" > ${GNUPGHOME}/dirmngr.conf + gpg -q --import "/tmp/helm/KEYS" + if ! gpg --verify "${tmp_helm_filename}.asc" > ${GNUPGHOME}/verify.log 2>&1; then echo "Verification failed!" cat /tmp/helm/gnupg/verify.log exit 1 fi - HELM_SHA256="$(cat "${tmp_helm_filename}.sha256")" -fi -([ "${HELM_SHA256}" = "dev-mode" ] || (echo "${HELM_SHA256} *${tmp_helm_filename}" | sha256sum -c -)) -tar xf "${tmp_helm_filename}" -C /tmp/helm -mv -f "/tmp/helm/linux-${architecture}/helm" /usr/local/bin/ -chmod 0755 /usr/local/bin/helm -rm -rf /tmp/helm -if ! type helm > /dev/null 2>&1; then - echo '(!) Helm installation failed!' - exit 1 + + if [ "${HELM_SHA256}" = "automatic" ]; then + curl -sSL "https://get.helm.sh/${helm_filename}.sha256" -o "${tmp_helm_filename}.sha256" + curl -sSL "https://github.com/helm/helm/releases/download/${HELM_VERSION}/${helm_filename}.sha256.asc" -o "${tmp_helm_filename}.sha256.asc" + if ! gpg --verify "${tmp_helm_filename}.sha256.asc" > /tmp/helm/gnupg/verify.log 2>&1; then + echo "Verification failed!" + cat /tmp/helm/gnupg/verify.log + exit 1 + fi + HELM_SHA256="$(cat "${tmp_helm_filename}.sha256")" + fi + + ([ "${HELM_SHA256}" = "dev-mode" ] || (echo "${HELM_SHA256} *${tmp_helm_filename}" | sha256sum -c -)) + tar xf "${tmp_helm_filename}" -C /tmp/helm + mv -f "/tmp/helm/linux-${architecture}/helm" /usr/local/bin/ + chmod 0755 /usr/local/bin/helm + rm -rf /tmp/helm + if ! type helm > /dev/null 2>&1; then + echo '(!) Helm installation failed!' + exit 1 + fi + + # helm bash completion + helm completion bash > /etc/bash_completion.d/helm + + # helm zsh completion + if [ -e "${USERHOME}/.oh-my-zsh" ]; then + mkdir -p "${USERHOME}/.oh-my-zsh/completions" + helm completion zsh > "${USERHOME}/.oh-my-zsh/completions/_helm" + chown -R "${USERNAME}" "${USERHOME}/.oh-my-zsh" + fi fi # Install Minikube, verify checksum @@ -240,4 +367,7 @@ if ! type docker > /dev/null 2>&1; then echo -e '\n(*) Warning: The docker command was not found.\n\nYou can use one of the following scripts to install it:\n\nhttps://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker-in-docker.md\n\nor\n\nhttps://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker.md' fi -echo -e "\nDone!" \ No newline at end of file +# Clean up +rm -rf /var/lib/apt/lists/* + +echo -e "\nDone!" diff --git a/src/nix/NOTES.md b/src/nix/NOTES.md new file mode 100644 index 000000000..b3b53476a --- /dev/null +++ b/src/nix/NOTES.md @@ -0,0 +1,35 @@ +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, RockyLinux, and Alpine Linux. + +## Location of Flakes + +Currently `flakeUri` works best with a remote URI (e.g., `github:nixos/nixpkgs/nixpkgs-unstable#hello`) as local files need to be in the image. + +> Proposed support for lifecycle hooks in Features ([#60](https://github.com/devcontainers/spec/issues/60)) would allow for expressions files or Flakes to exist in the source tree to be automatically installed on initial container startup, but today you will have to manually add the appropriate install command to `postCreateCommand` to your `devcontainer.json` instead. + +## Multi-user vs. single-user installs + +This Dev Container Feature supports two installation models for Nix: multi-user and single user. Multi-user is the default, but each has pros and cons. + +| Installation Model | Pros | Cons | +| --- | --- | --- | +| *Multi-User* | Nix can be used with any user including root.

Also still works if the UID or GID of any user is updated. | Only works with Nix 2.11 and up due to a Nix installer limitation.

Container must run either: run as root (but `remoteUser` in devcontainer.json can be non-root), or includes `sudo` with the `remoteUser` being configured to use it.

Note that automated start of the `nix-daemon` requires passwordless `sudo` if the container itself (e.g., `containerUser`) is not running as root. Manual startup using `sudo` can require a password, however (more next). | +| *Single-User* | Does not require the container to run as root or `sudo` to be included in the image. | Only works with the user specified in the `remoteUser` property or an auto-detected user. If this user's UID/GID is updated, that user will no longer be able to work with Nix. This is primarily a consideration when running on Linux where the UID/GID is sync'd to the local user. | + +### Manually starting the Nix daemon + +If you have `sudo` in your base image, but have a password set so automatic startup is not possible, you can manually start the Nix daemon by running the following command in a terminal: + +```bash +sudo /usr/local/share/nix-entrypoint.sh +``` + +This same command can be used to restart the daemon if it has stopped for some reason. Logs are available at `/tmp/nix-daemon.log`. + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/nix/README.md b/src/nix/README.md new file mode 100644 index 000000000..4fd9700f3 --- /dev/null +++ b/src/nix/README.md @@ -0,0 +1,64 @@ + +# Nix Package Manager (nix) + +Installs the Nix package manager and optionally a set of packages. + +## Example Usage + +```json +"features": { + "ghcr.io/devcontainers/features/nix:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| version | Version of Nix to install. | string | latest | +| multiUser | Perform a multi-user install (instead of single user) | boolean | true | +| packages | Optional comma separated list of Nix packages to install in profile. | string | - | +| useAttributePath | Enable this option to use exact attribute path of the package in the Nixpkgs repository, aligning with the nix-env -iA command. | boolean | false | +| flakeUri | Optional URI to a Nix Flake to install in profile. | string | - | +| extraNixConfig | Optional comma separated list of extra lines to add to /etc/nix/nix.conf. | string | - | + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, RockyLinux, and Alpine Linux. + +## Location of Flakes + +Currently `flakeUri` works best with a remote URI (e.g., `github:nixos/nixpkgs/nixpkgs-unstable#hello`) as local files need to be in the image. + +> Proposed support for lifecycle hooks in Features ([#60](https://github.com/devcontainers/spec/issues/60)) would allow for expressions files or Flakes to exist in the source tree to be automatically installed on initial container startup, but today you will have to manually add the appropriate install command to `postCreateCommand` to your `devcontainer.json` instead. + +## Multi-user vs. single-user installs + +This Dev Container Feature supports two installation models for Nix: multi-user and single user. Multi-user is the default, but each has pros and cons. + +| Installation Model | Pros | Cons | +| --- | --- | --- | +| *Multi-User* | Nix can be used with any user including root.

Also still works if the UID or GID of any user is updated. | Only works with Nix 2.11 and up due to a Nix installer limitation.

Container must run either: run as root (but `remoteUser` in devcontainer.json can be non-root), or includes `sudo` with the `remoteUser` being configured to use it.

Note that automated start of the `nix-daemon` requires passwordless `sudo` if the container itself (e.g., `containerUser`) is not running as root. Manual startup using `sudo` can require a password, however (more next). | +| *Single-User* | Does not require the container to run as root or `sudo` to be included in the image. | Only works with the user specified in the `remoteUser` property or an auto-detected user. If this user's UID/GID is updated, that user will no longer be able to work with Nix. This is primarily a consideration when running on Linux where the UID/GID is sync'd to the local user. | + +### Manually starting the Nix daemon + +If you have `sudo` in your base image, but have a password set so automatic startup is not possible, you can manually start the Nix daemon by running the following command in a terminal: + +```bash +sudo /usr/local/share/nix-entrypoint.sh +``` + +This same command can be used to restart the daemon if it has stopped for some reason. Logs are available at `/tmp/nix-daemon.log`. + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/nix/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/nix/devcontainer-feature.json b/src/nix/devcontainer-feature.json new file mode 100644 index 000000000..63723e591 --- /dev/null +++ b/src/nix/devcontainer-feature.json @@ -0,0 +1,68 @@ +{ + "id": "nix", + "version": "1.3.1", + "name": "Nix Package Manager", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/nix", + "description": "Installs the Nix package manager and optionally a set of packages.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "2.11" + ], + "default": "latest", + "description": "Version of Nix to install." + }, + "multiUser": { + "type": "boolean", + "default": true, + "description": "Perform a multi-user install (instead of single user)" + }, + "packages": { + "type": "string", + "default": "", + "description": "Optional comma separated list of Nix packages to install in profile." + }, + "useAttributePath": { + "type": "boolean", + "default": false, + "description": "Enable this option to use exact attribute path of the package in the Nixpkgs repository, aligning with the nix-env -iA command." + }, + "flakeUri": { + "type": "string", + "default": "", + "description": "Optional URI to a Nix Flake to install in profile." + }, + "extraNixConfig": { + "type": "string", + "default": "", + "description": "Optional comma separated list of extra lines to add to /etc/nix/nix.conf." + } + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the Nix package manager pre-installed and available on the `PATH`. Currently `flakeUri` works best with a remote URI (e.g., `github:nixos/nixpkgs/nixpkgs-unstable#hello`) as local files need to be in the image. The dev container supports two installation models for Nix: multi-user and single user. Multi-user is the default." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ], + "containerEnv": { + "PATH": "/nix/var/nix/profiles/default/bin:/nix/var/nix/profiles/default/sbin:${PATH}" + }, + "mounts": [ + { + "source": "nix-store-${devcontainerId}", + "target": "/nix", + "type": "volume" + } + ], + "entrypoint": "/usr/local/share/nix-entrypoint.sh" +} diff --git a/src/nix/install.sh b/src/nix/install.sh new file mode 100755 index 000000000..ca19f658a --- /dev/null +++ b/src/nix/install.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Move to the same directory as this script +set -e +FEATURE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "${FEATURE_DIR}" + +# Option defaults +VERSION="${VERSION:-"latest"}" +MULTIUSER="${MULTIUSER:-"true"}" +PACKAGES="${PACKAGES//,/ }" +USEATTRIBUTEPATH="${USEATTRIBUTEPATH:-"false"}" +FLAKEURI="${FLAKEURI:-""}" +EXTRANIXCONFIG="${EXTRANIXCONFIG:-""}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +# Import common utils +. ./utils.sh + +detect_user USERNAME + +if [ "${USERNAME}" = "root" ] && [ "${MULTIUSER}" != "true" ]; then + echo "(!) A single user install is not allowed for root. Add a non-root user to your image or set multiUser to true in your feature configuration." + exit 1 +fi + +# Verify dependencies +apt_get_update_if_exists +check_command curl "curl ca-certificates" "curl ca-certificates" "curl ca-certificates" +check_command gpg2 gnupg2 gnupg gnupg2 +check_command dirmngr dirmngr dirmngr dirmngr +check_command xz xz-utils xz xz +check_command git git git git +check_command xargs findutils findutils findutils + +# Determine version +find_version_from_git_tags VERSION https://github.com/NixOS/nix "tags/" + +# Download and verify install per https://nixos.org/download.html#nix-verify-installation +tmpdir="$(mktemp -d)" +echo "(*) Downloading Nix installer..." +set +e +curl -sSLf -o "${tmpdir}/install-nix" https://releases.nixos.org/nix/nix-${VERSION}/install +exit_code=$? +set -e +if [ "$exit_code" != "0" ]; then + # Handle situation where git tags are ahead of what was is available to actually download + echo "(!) Nix version ${VERSION} failed to download. Attempting to fall back one version to retry..." + find_prev_version_from_git_tags VERSION https://github.com/NixOS/nix "tags/" + curl -sSLf -o "${tmpdir}/install-nix" https://releases.nixos.org/nix/nix-${VERSION}/install +fi +cd "${FEATURE_DIR}" + +# Do a multi or single-user setup based on feature config +if [ "${MULTIUSER}" = "true" ]; then + echo "(*) Performing multi-user install..." + sh "${tmpdir}/install-nix" --daemon +else + home_dir="$(eval echo ~${USERNAME})" + if [ ! -e "${home_dir}" ]; then + echo "(!) Home directory ${home_dir} does not exist for ${USERNAME}. Nix install will fail." + exit 1 + fi + echo "(*) Performing single-user install..." + echo -e "\n**NOTE: Nix will only work for user ${USERNAME} on Linux if the host machine user's UID is $(id -u ${USERNAME}). You will need to chown /nix otherwise.**\n" + # Install per https://nixos.org/manual/nix/stable/installation/installing-binary.html#single-user-installation + mkdir -p /nix + chown ${USERNAME} /nix ${tmpdir} + su ${USERNAME} -c "sh \"${tmpdir}/install-nix\" --no-daemon --no-modify-profile" + # nix installer does not update ~/.bashrc, and USER may or may not be defined, so update rc/profile files directly to handle that + snippet=' + if [ "${PATH#*$HOME/.nix-profile/bin}" = "${PATH}" ]; then if [ -z "$USER" ]; then USER=$(whoami); fi; . $HOME/.nix-profile/etc/profile.d/nix.sh; fi + ' + update_rc_file "$home_dir/.bashrc" "${snippet}" + update_rc_file "$home_dir/.zshenv" "${snippet}" + update_rc_file "$home_dir/.profile" "${snippet}" +fi +rm -rf "${tmpdir}" "/tmp/tmp-gnupg" + +# Set nix config +mkdir -p /etc/nix +create_or_update_file /etc/nix/nix.conf 'sandbox = false' +if [ ! -z "${FLAKEURI}" ] && [ "${FLAKEURI}" != "none" ]; then + create_or_update_file /etc/nix/nix.conf 'experimental-features = nix-command flakes' +fi +# Extra nix config +if [ ! -z "${EXTRANIXCONFIG}" ]; then + OLDIFS=$IFS + IFS="," + read -a extra_nix_config <<< "$EXTRANIXCONFIG" + for line in "${extra_nix_config[@]}"; do + create_or_update_file /etc/nix/nix.conf "$line" + done + IFS=$OLDIFS +fi + +# Create entrypoint if needed +if [ ! -e "/usr/local/share/nix-entrypoint.sh" ]; then + if [ "${MULTIUSER}" = "true" ]; then + echo "(*) Setting up entrypoint..." + cp -f nix-entrypoint.sh /usr/local/share/ + else + echo -e '#!/bin/bash\nexec "$@"' > /usr/local/share/nix-entrypoint.sh + fi + chmod +x /usr/local/share/nix-entrypoint.sh +fi + +# Install packages, flakes, etc if specified +chmod +x,o+r ${FEATURE_DIR} ${FEATURE_DIR}/post-install-steps.sh +if [ "${MULTIUSER}" = "true" ]; then + /usr/local/share/nix-entrypoint.sh + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + NIX_FEATURE_INSTALL_PROFILE=/nix/var/nix/profiles/default ${FEATURE_DIR}/post-install-steps.sh +else + su ${USERNAME} -c " + . \$HOME/.nix-profile/etc/profile.d/nix.sh + ${FEATURE_DIR}/post-install-steps.sh + " +fi + +echo "Done!" diff --git a/src/nix/nix-entrypoint.sh b/src/nix/nix-entrypoint.sh new file mode 100755 index 000000000..0ec7188db --- /dev/null +++ b/src/nix/nix-entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Attempt to start daemon +set +e +if ! pidof nix-daemon > /dev/null 2>&1; then + start_ok=false + if [ "$(id -u)" = "0" ]; then + ( . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh; /nix/var/nix/profiles/default/bin/nix-daemon > /tmp/nix-daemon.log 2>&1 ) & + if [ "$?" = "0" ]; then + start_ok=true + fi + elif type sudo > /dev/null 2>&1; then + sudo -n sh -c '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh; /nix/var/nix/profiles/default/bin/nix-daemon > /tmp/nix-daemon.log 2>&1' & + if [ "$?" = "0" ]; then + start_ok=true + fi + fi + if [ "${start_ok}" = "false" ]; then + echo -e 'Failed to start nix-daemon as root. Set multiUser to false in your feature configuration if you would\nprefer to run the container as a non-root. You may also start the daemon manually if you have sudo\ninstalled and configured for your user by running "sudo -c nix-daemon &"' + fi +fi +exec "$@" diff --git a/src/nix/post-install-steps.sh b/src/nix/post-install-steps.sh new file mode 100755 index 000000000..94cfed8a3 --- /dev/null +++ b/src/nix/post-install-steps.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -e +echo "(*) Executing post-installation steps..." + +# In multi-user mode, install into the default profile that is on PATH. +NIX_ENV_PROFILE_ARGS=() +NIX_PROFILE_INSTALL_ARGS=() +if [ -n "${NIX_FEATURE_INSTALL_PROFILE}" ]; then + NIX_ENV_PROFILE_ARGS=(-p "${NIX_FEATURE_INSTALL_PROFILE}") + NIX_PROFILE_INSTALL_ARGS=(--profile "${NIX_FEATURE_INSTALL_PROFILE}") +fi + +# if not starts with "nixpkgs." add it as prefix to package name +add_nixpkgs_prefix() { + local packages=$1 + local -a addr + IFS=' ' read -ra addr <<<"$packages" + for i in "${!addr[@]}"; do + if [[ ${addr[i]} != nixpkgs.* ]]; then + addr[i]="nixpkgs.${addr[i]}" + fi + done + IFS=' ' echo "${addr[*]}" +} + +# Install list of packages in profile if specified. +if [ ! -z "${PACKAGES}" ] && [ "${PACKAGES}" != "none" ]; then + if [ "${USEATTRIBUTEPATH}" = "true" ]; then + PACKAGES=$(add_nixpkgs_prefix "$PACKAGES") + echo "Installing packages \"${PACKAGES}\" in profile..." + nix-env "${NIX_ENV_PROFILE_ARGS[@]}" -iA ${PACKAGES} + else + echo "Installing packages \"${PACKAGES}\" in profile..." + nix-env "${NIX_ENV_PROFILE_ARGS[@]}" --install ${PACKAGES} + fi +fi + +# Install Nix flake in profile if specified +if [ ! -z "${FLAKEURI}" ] && [ "${FLAKEURI}" != "none" ]; then + echo "Installing flake ${FLAKEURI} in profile..." + nix profile install "${NIX_PROFILE_INSTALL_ARGS[@]}" "${FLAKEURI}" +fi + +nix-collect-garbage --delete-old +nix-store --optimise diff --git a/src/nix/utils.sh b/src/nix/utils.sh new file mode 100755 index 000000000..f94fd3d4e --- /dev/null +++ b/src/nix/utils.sh @@ -0,0 +1,272 @@ +# Function to run apt-get if needed +apt_get_update_if_needed() +{ + export DEBIAN_FRONTEND=noninteractive + if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update + else + echo "Skipping apt-get update." + fi +} + +# Function to run apt-get if command exists +apt_get_update_if_exists() +{ + if type apt-get > /dev/null 2>&1; then + apt-get update + fi +} + +# Checks if packages are installed and installs them if not +check_packages() { + if type dpkg > /dev/null 2>&1 && dpkg -s $1 > /dev/null 2>&1; then + return 0 + elif type apk > /dev/null 2>&1 && apk -e info $2 > /dev/null 2>&1; then + return 0 + elif type rpm > /dev/null 2>&1 && rpm -q $3 > /dev/null 2>&1; then + return 0 + else + echo "Unable to find package manager to check for packages." + exit 1 + fi + install_packages "$@" + return $? +} + +# Checks if command exists, installs it if not +# check_command "" "" "" +check_command() { + command_to_check=$1 + shift + if type "${command_to_check}" > /dev/null 2>&1; then + return 0 + fi + install_packages "$@" + return $? +} + +# Installs packages using the appropriate package manager (apt, apk, dnf, or yum) +# install_packages "" "" "" +install_packages() { + if type apt-get > /dev/null 2>&1; then + apt_get_update_if_needed + apt-get -y install --no-install-recommends $1 + elif type apk > /dev/null 2>&1; then + apk add $2 + elif type dnf > /dev/null 2>&1; then + dnf install -y $3 + elif type yum > /dev/null 2>&1; then + yum install -y $3 + else + echo "Unable to find package manager to install ${command_to_check}" + exit 1 + fi +} + +# If in automatic mode, determine if a user already exists, if not use root +detect_user() { + local user_variable_name=${1:-username} + local possible_users=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + if [ "${!user_variable_name}" = "auto" ] || [ "${!user_variable_name}" = "automatic" ]; then + declare -g ${user_variable_name}="" + for current_user in ${possible_users[@]}; do + if id -u "${current_user}" > /dev/null 2>&1; then + declare -g ${user_variable_name}="${current_user}" + break + fi + done + fi + if [ "${!user_variable_name}" = "" ] || [ "${!user_variable_name}" = "none" ] || ! id -u "${!user_variable_name}" > /dev/null 2>&1; then + declare -g ${user_variable_name}=root + fi +} + +# Figure out correct version of a three part version number is not passed +find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + + local escaped_separator=${separator//./\\.} + local break_fix_digit_regex + if [ "${last_part_optional}" = "true" ]; then + break_fix_digit_regex="(${escaped_separator}[0-9]+)?" + else + break_fix_digit_regex="${escaped_separator}[0-9]+" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${break_fix_digit_regex}${version_suffix_regex//./\\.}" + # If we're passed a matching version number, just return it, otherwise look for a version + if ! echo "${requested_version}" | grep -E "^${versionMatchRegex}$" > /dev/null 2>&1; then + local version_list="$(git ls-remote --tags ${repository} | grep -oP "${prefix}\\K${version_regex}$" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|${version_suffix_regex//./\\.}|$)")" + set -e + fi + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then + echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + fi + echo "Adjusted ${variable_name}=${!variable_name}" +} + +# Soft version matching that resolves a version for a given package in the *current apt-cache* +# Return value is stored in first argument (the unprocessed version) +apt_cache_version_soft_match() { + # Version + local variable_name="$1" + local requested_version=${!variable_name} + # Package Name + local package_name="$2" + # Exit on no match? + local exit_on_no_match="${3:-true}" + + # Ensure we've exported useful variables + . /etc/os-release + local architecture="$(dpkg --print-architecture)" + + dot_escaped="${requested_version//./\\.}" + dot_plus_escaped="${dot_escaped//+/\\+}" + # Regex needs to handle debian package version number format: https://www.systutorials.com/docs/linux/man/5-deb-version/ + version_regex="^(.+:)?${dot_plus_escaped}([\\.\\+ ~:-]|$)" + set +e # Don't exit if finding version fails - handle gracefully + fuzzy_version="$(apt-cache madison ${package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${version_regex}")" + set -e + if [ -z "${fuzzy_version}" ]; then + echo "(!) No full or partial for package \"${package_name}\" match found in apt-cache for \"${requested_version}\" on OS ${ID} ${VERSION_CODENAME} (${architecture})." + + if $exit_on_no_match; then + echo "Available versions:" + apt-cache madison ${package_name} | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' + exit 1 # Fail entire script + else + echo "Continuing to fallback method (if available)" + return 1; + fi + fi + + # Globally assign fuzzy_version to this value + # Use this value as the return value of this function + declare -g ${variable_name}="=${fuzzy_version}" + echo "${variable_name}=${!variable_name}" +} + +# Checks if a marker file exists with the correct contents +# check_marker [argument to be validated]... +check_marker() { + local marker_path="$1" + shift + local verifier_string="$(echo "$@")" + if [ -e "${marker_path}" ] && [ "${verifier_string}" = "$(cat ${marker_path})" ]; then + return 1 + else + return 0 + fi +} + +# Updates marker for future checking +# update_marker [argument to be validated]... +update_marker() { + local marker_path="$1" + shift + mkdir -p "$(dirname "${marker_path}")" + echo "$(echo "$@")" > "${marker_path}" +} + +# run_if_exists ... +run_if_exists() { + if [ -e "$1" ]; then + "$@" + fi +} + +# run_as_user_if_exists ... +run_as_user_if_exists() { + local username=$1 + shift + if [ -e "$1" ]; then + local command_string="$@" + su "${username}" -c "${command_string//"/\\"}" + fi +} + +# symlink_if_ne +symlink_if_ne() { + if [ ! -e "$2" ]; then + ln -s "$1" "$2" + fi +} + +# Update a rc/profile file if it exists and string is not already present +update_rc_file() { + # see if folder containing file exists + local rc_file_folder="$(dirname "$1")" + if [ ! -d "${rc_file_folder}" ]; then + echo "${rc_file_folder} does not exist. Skipping update of $1." + elif [ ! -e "$1" ] || [[ "$(cat "$1")" != *"$2"* ]]; then + echo "$2" >> "$1" + fi +} + +# Update a file if with string if not already present +# create_or_update_file +create_or_update_file() { + if [ ! -e "$1" ] || [[ "$(cat "$1")" != *"$2"* ]]; then + echo "$2" >> "$1" + fi +} + +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + + set -e +} \ No newline at end of file diff --git a/src/node/NOTES.md b/src/node/NOTES.md new file mode 100644 index 000000000..9f1c1d3bc --- /dev/null +++ b/src/node/NOTES.md @@ -0,0 +1,51 @@ +## Using nvm from postCreateCommand or another lifecycle command + +Certain operations like `postCreateCommand` run non-interactive, non-login shells. Unfortunately, `nvm` is really particular that it needs to be "sourced" before it is used, which can only happen automatically with interactive and/or login shells. Fortunately, this is easy to work around: + +Just can source the `nvm` startup script before using it: + +```json +"postCreateCommand": ". ${NVM_DIR}/nvm.sh && nvm install --lts" +``` + +Note that typically the default shell in these cases is `sh` not `bash`, so use `. ${NVM_DIR}/nvm.sh` instead of `source ${NVM_DIR}/nvm.sh`. + +Alternatively, you can start up an interactive shell which will in turn source `nvm`: + +```json +"postCreateCommand": "bash -i -c 'nvm install --lts'" +``` + + + +## OS Support + +Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and Rocky Linux distributions with the `apt`, `yum`, `dnf`, or `microdnf` package manager installed. + +**Note**: RedHat 7 Family (RedHat, CentOS, etc.) must use Node versions less than 18 due to its system libraries and long-term support (LTS) policies. + +`bash` is required to execute the `install.sh` script. + +## Pre-bundled items + +> [!NOTE] +> Beyond the core install, this feature also sets up a few items by default for convenience — recommended VS Code extensions (such as a linter) and supporting tools. This is intentional behavior shared across features in this repository. + +## Excluding pre-bundled items + +Exclude a bundled **VS Code extension** by prefixing its ID with `-`, or (when supported by a feature option) disable a bundled **tool** by setting its version option to `none` (for example, `pnpmVersion`: `none`): + +```json +{ + "features": { + "ghcr.io/devcontainers/features/node:2": { + "pnpmVersion": "none" + } + }, + "customizations": { + "vscode": { + "extensions": [ "-dbaeumer.vscode-eslint" ] + } + } +} +``` diff --git a/src/node/README.md b/src/node/README.md index 6a490ba8e..028b47401 100644 --- a/src/node/README.md +++ b/src/node/README.md @@ -1,15 +1,22 @@ -# Node.js (via nvm) and yarn (node) +# Node.js (via nvm), yarn and pnpm (node) -Installs Node.js, nvm, yarn, and needed dependencies. +Installs Node.js, nvm, yarn, pnpm, and needed dependencies. ## Example Usage ```json "features": { - "ghcr.io/devcontainers/features/node:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/node:2": {} +} +``` + +```json +"features": { + "ghcr.io/devcontainers/features/node:2": { + "version": "20", + "npmVersion": "10.8.0" + } } ``` @@ -20,7 +27,44 @@ Installs Node.js, nvm, yarn, and needed dependencies. | version | Select or enter a Node.js version to install | string | lts | | nodeGypDependencies | Install dependencies to compile native node modules (node-gyp)? | boolean | true | | nvmInstallPath | The path where NVM will be installed. | string | /usr/local/share/nvm | +| npmVersion | Select or enter a specific NPM version to install globally. Use 'latest' for the latest version, 'none' to skip npm version update, or specify a version like '10.9.0'. | string | none | +| pnpmVersion | Select or enter the PNPM version to install | string | latest | +| nvmVersion | Version of NVM to install. | string | latest | +| installYarnUsingApt | On Debian and Ubuntu systems, you have the option to install Yarn globally via APT. If you choose not to use this option, Yarn will be set up using Corepack instead. This choice is specific to Debian and Ubuntu; for other Linux distributions, Yarn is always installed using Corepack, with a fallback to installation via NPM if an error occurs. | boolean | false | + +## Customizations + +### VS Code Extensions + +- `dbaeumer.vscode-eslint` + +## Using nvm from postCreateCommand or another lifecycle command + +Certain operations like `postCreateCommand` run non-interactive, non-login shells. Unfortunately, `nvm` is really particular that it needs to be "sourced" before it is used, which can only happen automatically with interactive and/or login shells. Fortunately, this is easy to work around: + +Just can source the `nvm` startup script before using it: + +```json +"postCreateCommand": ". ${NVM_DIR}/nvm.sh && nvm install --lts" +``` + +Note that typically the default shell in these cases is `sh` not `bash`, so use `. ${NVM_DIR}/nvm.sh` instead of `source ${NVM_DIR}/nvm.sh`. + +Alternatively, you can start up an interactive shell which will in turn source `nvm`: + +```json +"postCreateCommand": "bash -i -c 'nvm install --lts'" +``` + + + +## OS Support + +Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and Rocky Linux distributions with the `apt`, `yum`, `dnf`, or `microdnf` package manager installed. + +**Note**: RedHat 7 Family (RedHat, CentOS, etc.) must use Node versions less than 18 due to its system libraries and long-term support (LTS) policies. +`bash` is required to execute the `install.sh` script. --- diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json index 8f9236503..5e0970b2d 100644 --- a/src/node/devcontainer-feature.json +++ b/src/node/devcontainer-feature.json @@ -1,9 +1,9 @@ { "id": "node", - "version": "1.0.6", - "name": "Node.js (via nvm) and yarn", + "version": "2.1.0", + "name": "Node.js (via nvm), yarn and pnpm.", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/node", - "description": "Installs Node.js, nvm, yarn, and needed dependencies.", + "description": "Installs Node.js, nvm, yarn, pnpm, and needed dependencies.", "options": { "version": { "type": "string", @@ -11,9 +11,8 @@ "lts", "latest", "none", - "18", - "16", - "14" + "22", + "20" ], "default": "lts", "description": "Select or enter a Node.js version to install" @@ -27,18 +26,71 @@ "type": "string", "default": "/usr/local/share/nvm", "description": "The path where NVM will be installed." + }, + "npmVersion": { + "type": "string", + "proposals": [ + "lts", + "latest", + "10.9.0", + "10.8.0", + "10.7.0", + "9.9.3", + "8.19.4", + "none" + ], + "default": "none", + "description": "Select or enter a specific NPM version to install globally. Use 'latest' for the latest version, 'none' to skip npm version update, or specify a version like '10.9.0'." + }, + "pnpmVersion": { + "type": "string", + "proposals": [ + "latest", + "8.8.0", + "8.0.0", + "7.30.0", + "6.14.8", + "5.18.10", + "none" + ], + "default": "latest", + "description": "Select or enter the PNPM version to install" + }, + "nvmVersion": { + "type": "string", + "proposals": [ + "latest", + "0.39" + ], + "default": "latest", + "description": "Version of NVM to install." + }, + "installYarnUsingApt": { + "type": "boolean", + "default": false, + "description": "On Debian and Ubuntu systems, you have the option to install Yarn globally via APT. If you choose not to use this option, Yarn will be set up using Corepack instead. This choice is specific to Debian and Ubuntu; for other Linux distributions, Yarn is always installed using Corepack, with a fallback to installation via NPM if an error occurs." } }, "customizations": { "vscode": { "extensions": [ "dbaeumer.vscode-eslint" - ] + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes `node`, `npm` and `eslint` pre-installed and available on the `PATH` for Node.js and JavaScript development." + } + ] + } } }, "containerEnv": { "NVM_DIR": "/usr/local/share/nvm", "NVM_SYMLINK_CURRENT": "true", "PATH": "/usr/local/share/nvm/current/bin:${PATH}" - } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/node/install.sh b/src/node/install.sh index 6a237d66b..0e277812e 100755 --- a/src/node/install.sh +++ b/src/node/install.sh @@ -1,24 +1,26 @@ #!/bin/bash -#------------------------------------------------------------------------------------------------------------- +#------------------------------------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. -#------------------------------------------------------------------------------------------------------------- +# Licensed under the MIT License. See https://github.com/devcontainers/features/blob/main/LICENSE for license information. +#------------------------------------------------------------------------------------------------------------------------- # -# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/node.md -# Maintainer: The VS Code and Codespaces Teams +# Docs: https://github.com/devcontainers/features/tree/main/src/node +# Maintainer: The Dev Container spec maintainers -export NODE_VERSION=${VERSION:-"lts"} -export NVM_DIR=${NVMINSTALLPATH:-"/usr/local/share/nvm"} +export NODE_VERSION="${VERSION:-"lts"}" +export NPM_VERSION="${NPMVERSION:-"lts"}" +export PNPM_VERSION="${PNPMVERSION:-"latest"}" +export NVM_VERSION="${NVMVERSION:-"latest"}" +export NVM_DIR="${NVMINSTALLPATH:-"/usr/local/share/nvm"}" INSTALL_TOOLS_FOR_NODE_GYP="${NODEGYPDEPENDENCIES:-true}" +export INSTALL_YARN_USING_APT="${INSTALLYARNUSINGAPT:-false}" # only concerns Debian-based systems # Comma-separated list of node versions to be installed (with nvm) # alongside NODE_VERSION, but not set as default. -ADDITIONAL_VERSIONS=${ADDITIONALVERSIONS:-""} +ADDITIONAL_VERSIONS="${ADDITIONALVERSIONS:-""}" -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} - -export NVM_VERSION="0.38.0" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +UPDATE_RC="${UPDATE_RC:-"true"}" set -e @@ -27,11 +29,219 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi +# Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME +. /etc/os-release +# Get an adjusted ID independent of distro variants +MAJOR_VERSION_ID=$(echo ${VERSION_ID} | cut -d . -f 1) +if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" +elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + if [[ "${ID}" = "rhel" ]] || [[ "${ID}" = *"alma"* ]] || [[ "${ID}" = *"rocky"* ]]; then + VERSION_CODENAME="rhel${MAJOR_VERSION_ID}" + else + VERSION_CODENAME="${ID}${MAJOR_VERSION_ID}" + fi +else + echo "Linux distro ${ID} not supported." + exit 1 +fi + +if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${VERSION_CODENAME-}" = "centos7" ]; then + # As of 1 July 2024, mirrorlist.centos.org no longer exists. + # Update the repo files to reference vault.centos.org. + sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo + sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo + sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo +fi + +# Setup INSTALL_CMD & PKG_MGR_CMD +if type apt-get > /dev/null 2>&1; then + PKG_MGR_CMD=apt-get + INSTALL_CMD="${PKG_MGR_CMD} -y install --no-install-recommends" +elif type microdnf > /dev/null 2>&1; then + PKG_MGR_CMD=microdnf + INSTALL_CMD="${PKG_MGR_CMD} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0" +elif type dnf > /dev/null 2>&1; then + PKG_MGR_CMD=dnf + INSTALL_CMD="${PKG_MGR_CMD} -y install" +else + PKG_MGR_CMD=yum + INSTALL_CMD="${PKG_MGR_CMD} -y install" +fi + +# Clean up +clean_up() { + case ${ADJUSTED_ID} in + debian) + rm -rf /var/lib/apt/lists/* + ;; + rhel) + rm -rf /var/cache/dnf/* /var/cache/yum/* + rm -f /etc/yum.repos.d/yarn.repo + ;; + esac +} +clean_up + # Ensure that login shells get the correct path if the user updated the PATH using ENV. rm -f /etc/profile.d/00-restore-env.sh echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh chmod +x /etc/profile.d/00-restore-env.sh +updaterc() { + local _bashrc + local _zshrc + if [ "${UPDATE_RC}" = "true" ]; then + case $ADJUSTED_ID in + debian) + _bashrc=/etc/bash.bashrc + _zshrc=/etc/zsh/zshrc + ;; + rhel) + _bashrc=/etc/bashrc + _zshrc=/etc/zshrc + ;; + esac + echo "Updating ${_bashrc} and ${_zshrc}..." + if [[ "$(cat ${_bashrc})" != *"$1"* ]]; then + echo -e "$1" >> "${_bashrc}" + fi + if [ -f "${_zshrc}" ] && [[ "$(cat ${_zshrc})" != *"$1"* ]]; then + echo -e "$1" >> "${_zshrc}" + fi + fi +} + +pkg_mgr_update() { + case $ADJUSTED_ID in + debian) + if [ "$(find /var/lib/apt/lists/* 2>/dev/null | wc -l)" = "0" ]; then + echo "Running apt-get update..." + ${PKG_MGR_CMD} update -y + fi + ;; + rhel) + if [ ${PKG_MGR_CMD} = "microdnf" ]; then + if [ "$(ls /var/cache/yum/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} makecache ..." + ${PKG_MGR_CMD} makecache + fi + else + if [ "$(ls /var/cache/${PKG_MGR_CMD}/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} check-update ..." + set +e + stderr_messages=$(${PKG_MGR_CMD} -q check-update 2>&1) + rc=$? + # centos 7 sometimes returns a status of 100 when it apears to work. + if [ $rc != 0 ] && [ $rc != 100 ]; then + echo "(Error) ${PKG_MGR_CMD} check-update produced the following error message(s):" + echo "${stderr_messages}" + exit 1 + fi + set -e + fi + fi + ;; + esac +} + +# Checks if packages are installed and installs them if not +check_packages() { + case ${ADJUSTED_ID} in + debian) + if ! dpkg -s "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${INSTALL_CMD} "$@" + fi + ;; + rhel) + if ! rpm -q "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${INSTALL_CMD} "$@" + fi + ;; + esac +} + +# Figure out correct version of a three part version number is not passed +find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + local escaped_separator=${separator//./\\.} + local last_part + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + fi + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then + echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + echo "${variable_name}=${!variable_name}" +} + +install_yarn() { + if [ "${ADJUSTED_ID}" = "debian" ] && [ "${INSTALL_YARN_USING_APT}" = "true" ]; then + # for backward compatiblity with existing devcontainer features, install yarn + # via apt-get on Debian systems + if ! type yarn >/dev/null 2>&1; then + # Import key safely (new method rather than deprecated apt-key approach) and install + mkdir -p /etc/apt/keyrings + curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor --yes -o /etc/apt/keyrings/yarn-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list + apt-get update + apt-get -y install --no-install-recommends yarn + else + echo "Yarn is already installed." + fi + else + local _ver=${1:-node} + # on non-debian systems or if user opted not to use APT, prefer corepack + # Fallback to npm based installation of yarn. + # But try to leverage corepack if possible + # From https://yarnpkg.com: + # The preferred way to manage Yarn is by-project and through Corepack, a tool + # shipped by default with Node.js. Modern releases of Yarn aren't meant to be + # installed globally, or from npm. + if ! bash -c ". '${NVM_DIR}/nvm.sh' && nvm use ${_ver} && type yarn >/dev/null 2>&1"; then + if bash -c ". '${NVM_DIR}/nvm.sh' && nvm use ${_ver} && type corepack >/dev/null 2>&1"; then + su ${USERNAME} -c "umask 0002 && . '${NVM_DIR}/nvm.sh' && nvm use ${_ver} && corepack enable" + fi + if ! bash -c ". '${NVM_DIR}/nvm.sh' && nvm use ${_ver} && type yarn >/dev/null 2>&1"; then + # Yum/DNF want to install nodejs dependencies, we'll use NPM to install yarn + su ${USERNAME} -c "umask 0002 && . '${NVM_DIR}/nvm.sh' && nvm use ${_ver} && npm install --global yarn" + fi + else + echo "Yarn already installed." + fi + fi +} + +# Mariner does not have awk installed by default, this can cause +# problems is username is auto* and later when we try to install +# node via npm. +if ! type awk >/dev/null 2>&1; then + check_packages awk +fi + # Determine the appropriate non-root user if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then USERNAME="" @@ -49,46 +259,34 @@ elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then USERNAME=root fi -updaterc() { - if [ "${UPDATE_RC}" = "true" ]; then - echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." - if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/bash.bashrc - fi - if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/zsh/zshrc - fi - fi -} - -apt_get_update() { - echo "Running apt-get update..." - apt-get update -y -} - -# Checks if packages are installed and installs them if not -check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" - fi -} - # Ensure apt is in non-interactive to avoid prompts export DEBIAN_FRONTEND=noninteractive +if ( [ -n "${VERSION_CODENAME}" ] && [[ "bionic" = *"${VERSION_CODENAME}"* ]] ) || [[ "rhel7" = *"${ADJUSTED_ID}${MAJOR_VERSION_ID}"* ]]; then + node_major_version=$(echo "${NODE_VERSION}" | cut -d . -f 1) + if [[ "${node_major_version}" -ge 18 ]] || [[ "${NODE_VERSION}" = "lts" ]] || [[ "${NODE_VERSION}" = "latest" ]]; then + echo "(!) Unsupported distribution version '${VERSION_CODENAME}' for Node >= 18. Details: https://github.com/nodejs/node/issues/42351#issuecomment-1068424442" + exit 1 + fi +fi + # Install dependencies -check_packages apt-transport-https curl ca-certificates tar gnupg2 dirmngr +case ${ADJUSTED_ID} in + debian) + check_packages apt-transport-https curl ca-certificates tar gnupg2 dirmngr + ;; + rhel) + check_packages ca-certificates tar gnupg2 which findutils util-linux tar + # minimal RHEL installs may not include curl, or includes curl-minimal instead. + # Install curl if the "curl" command is not present. + if ! type curl > /dev/null 2>&1; then + check_packages curl + fi + ;; +esac -# Install yarn -if type yarn > /dev/null 2>&1; then - echo "Yarn already installed." -else - # Import key safely (new method rather than deprecated apt-key approach) and install - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor > /usr/share/keyrings/yarn-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list - apt-get update - apt-get -y install --no-install-recommends yarn +if ! type git > /dev/null 2>&1; then + check_packages git fi # Adjust node version if required @@ -100,69 +298,196 @@ elif [ "${NODE_VERSION}" = "latest" ]; then export NODE_VERSION="node" fi -# Create a symlink to the installed version for use in Dockerfile PATH statements -export NVM_SYMLINK_CURRENT=true +find_version_from_git_tags NVM_VERSION "https://github.com/nvm-sh/nvm" -# Install the specified node version if NVM directory already exists, then exit -if [ -d "${NVM_DIR}" ]; then - echo "NVM already installed." - if [ "${NODE_VERSION}" != "" ]; then - su ${USERNAME} -c ". $NVM_DIR/nvm.sh && nvm install ${NODE_VERSION} && nvm clear-cache" - fi - exit 0 +# Install snipppet that we will run as the user +nvm_install_snippet="$(cat << EOF +set -e +umask 0002 +# Do not update profile - we'll do this manually +export PROFILE=/dev/null +curl -so- "https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh" | bash || { + PREV_NVM_VERSION=$(curl -s https://api.github.com/repos/nvm-sh/nvm/releases/latest | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') + curl -so- "https://raw.githubusercontent.com/nvm-sh/nvm/\${PREV_NVM_VERSION}/install.sh" | bash + NVM_VERSION="\${PREV_NVM_VERSION}" +} +[ -s "${NVM_DIR}/nvm.sh" ] && source "${NVM_DIR}/nvm.sh" +if [ "${NODE_VERSION}" != "" ]; then + nvm alias default "${NODE_VERSION}" fi +EOF +)" -# Create nvm group, nvm dir, and set sticky bit +# Snippet that should be added into rc / profiles +nvm_rc_snippet="$(cat << EOF +export NVM_DIR="${NVM_DIR}" +[ -s "\$NVM_DIR/nvm.sh" ] && . "\$NVM_DIR/nvm.sh" +[ -s "\$NVM_DIR/bash_completion" ] && . "\$NVM_DIR/bash_completion" +EOF +)" + +# Create a symlink to the installed version for use in Dockerfile PATH statements +export NVM_SYMLINK_CURRENT=true + +# Create nvm group to the user's UID or GID to change while still allowing access to nvm if ! cat /etc/group | grep -e "^nvm:" > /dev/null 2>&1; then groupadd -r nvm fi -umask 0002 usermod -a -G nvm ${USERNAME} -mkdir -p ${NVM_DIR} -chown "${USERNAME}:nvm" ${NVM_DIR} -chmod -R g+r+w ${NVM_DIR} -su ${USERNAME} -c "$(cat << EOF - set -e - umask 0002 - # Do not update profile - we'll do this manually - export PROFILE=/dev/null - curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh | bash - source ${NVM_DIR}/nvm.sh + +# Install nvm (which also installs NODE_VERSION), otherwise +# use nvm to install the specified node version. Always use +# umask 0002 so both the owner so that everything is u+rw,g+rw +umask 0002 +if [ ! -d "${NVM_DIR}" ]; then + # Create nvm dir, and set sticky bit + mkdir -p "${NVM_DIR}" + chown "${USERNAME}:nvm" "${NVM_DIR}" + chmod g+rws "${NVM_DIR}" + su ${USERNAME} -c "${nvm_install_snippet}" 2>&1 + # Update rc files + if [ "${UPDATE_RC}" = "true" ]; then + updaterc "${nvm_rc_snippet}" + fi +else + echo "NVM already installed." if [ "${NODE_VERSION}" != "" ]; then - nvm alias default ${NODE_VERSION} + su ${USERNAME} -c "umask 0002 && . '$NVM_DIR/nvm.sh' && nvm install '${NODE_VERSION}' && nvm alias default '${NODE_VERSION}'" fi - nvm clear-cache -EOF -)" 2>&1 -# Update rc files -if [ "${UPDATE_RC}" = "true" ]; then -updaterc "$(cat </dev/null 2>&1"; then + ( + . "${NVM_DIR}/nvm.sh" + [ ! -z "$http_proxy" ] && npm set proxy="$http_proxy" + [ ! -z "$https_proxy" ] && npm set https-proxy="$https_proxy" + [ ! -z "$no_proxy" ] && npm set noproxy="$no_proxy" + echo "Installing npm version ${NPM_VERSION}..." + + CURRENT_NPM_VERSION=$(npm --version 2>/dev/null || echo 'unknown') + echo "Current npm version: $CURRENT_NPM_VERSION" + + # Clear npm cache and extract version numbers + npm cache clean --force 2>/dev/null || true + CURRENT_MAJOR=$(echo "$CURRENT_NPM_VERSION" | cut -d. -f1 || echo "0") + NODE_MAJOR=$(node --version 2>/dev/null | cut -d. -f1 | tr -d 'v' || echo "0") + + # Dynamically check npm's Node.js requirements and auto-fallback if incompatible + ORIGINAL_NPM_VERSION="$NPM_VERSION" + if [ "$NPM_VERSION" != "none" ]; then + echo "Checking npm compatibility requirements..." + NPM_NODE_REQUIREMENT=$(npm view npm@${NPM_VERSION} engines.node 2>/dev/null || echo "") + + if [ -n "$NPM_NODE_REQUIREMENT" ]; then + echo "npm $NPM_VERSION requires Node.js: $NPM_NODE_REQUIREMENT" + + # Extract minimum required Node version from requirement string + MIN_NODE=$(echo "$NPM_NODE_REQUIREMENT" | grep -oE '[0-9]+' | head -1 || echo "0") + + if [ "$MIN_NODE" -gt "0" ] && [ "$NODE_MAJOR" -lt "$MIN_NODE" ]; then + echo "⚠️ WARNING: npm $NPM_VERSION requires Node.js $MIN_NODE+, you have $NODE_MAJOR.x" + + # Find compatible npm version dynamically using same logic + echo "🔍 Finding compatible npm version for Node.js $NODE_MAJOR.x..." + + # Try npm major versions in descending order to find highest compatible version + for npm_major in 10 9 8 7 6; do + echo "Checking npm $npm_major compatibility..." + FALLBACK_NODE_REQUIREMENT=$(npm view "npm@${npm_major}" engines.node 2>/dev/null || echo "") + + if [ -n "$FALLBACK_NODE_REQUIREMENT" ]; then + MIN_NODE=$(echo "$FALLBACK_NODE_REQUIREMENT" | grep -oE '[0-9]+' | head -1 || echo "0") + + if [ "$MIN_NODE" -le "$NODE_MAJOR" ]; then + # Get latest patch version for this compatible major version + NPM_VERSION=$(npm view "npm@${npm_major}" version 2>/dev/null || echo "") + if [ -n "$NPM_VERSION" ]; then + echo "✓ Found compatible npm $NPM_VERSION (requires Node.js $MIN_NODE+)" + echo "🔄 Auto-fallback: Installing compatible npm $NPM_VERSION instead" + break + fi + fi + fi + done + + # If no compatible version found, skip npm installation + if [ "$NPM_VERSION" = "$ORIGINAL_NPM_VERSION" ]; then + echo "❌ Could not find compatible npm version, keeping current npm" + NPM_VERSION="none" + fi + elif [ "$MIN_NODE" -gt "0" ]; then + echo "✓ Node.js $NODE_MAJOR.x meets npm $NPM_VERSION requirement" + fi + else + echo "Could not determine Node.js requirements for npm $NPM_VERSION, proceeding anyway..." + fi + fi + + # Check if npm installation was cancelled due to compatibility issues + if [ "$NPM_VERSION" = "none" ]; then + echo "Skipping npm installation due to compatibility issues." + else + # Try npm installation with retries + for i in 1 2 3; do + echo "Attempt $i: Running npm install -g npm@$NPM_VERSION" + if npm install -g npm@$NPM_VERSION --force --no-audit --no-fund 2>&1; then + NEW_VERSION=$(npm --version 2>/dev/null || echo 'unknown') + echo "Successfully installed npm@${NPM_VERSION}, new version: $NEW_VERSION" + break + else + echo "Attempt $i failed, retrying..." + sleep 2 + if [ $i -eq 3 ]; then + echo "Failed to install npm@${NPM_VERSION} after 3 attempts. Keeping current npm version $(npm --version 2>/dev/null || echo 'unknown')." + fi + fi + done + fi + ) + else + echo "Skip installing/updating npm because npm is not available" + fi + +# Install pnpm +if [ ! -z "${PNPM_VERSION}" ] && [ "${PNPM_VERSION}" = "none" ]; then + echo "Ignoring installation of PNPM" +else + if bash -c ". '${NVM_DIR}/nvm.sh' && type npm >/dev/null 2>&1"; then + su ${USERNAME} -c "umask 0002 && . '${NVM_DIR}/nvm.sh' && npm install -g pnpm@${PNPM_VERSION} --force" + else + echo "Skip installing pnpm because npm is missing" + fi +fi + # If enabled, verify "python3", "make", "gcc", "g++" commands are available so node-gyp works - https://github.com/nodejs/node-gyp if [ "${INSTALL_TOOLS_FOR_NODE_GYP}" = "true" ]; then echo "Verifying node-gyp OS requirements..." @@ -174,17 +499,34 @@ if [ "${INSTALL_TOOLS_FOR_NODE_GYP}" = "true" ]; then to_install="${to_install} gcc" fi if ! type g++ > /dev/null 2>&1; then - to_install="${to_install} g++" + if [ ${ADJUSTED_ID} = "debian" ]; then + to_install="${to_install} g++" + elif [ ${ADJUSTED_ID} = "rhel" ]; then + to_install="${to_install} gcc-c++" + fi fi if ! type python3 > /dev/null 2>&1; then - to_install="${to_install} python3-minimal" + if [ ${ADJUSTED_ID} = "debian" ]; then + to_install="${to_install} python3-minimal" + elif [ ${ADJUSTED_ID} = "rhel" ]; then + to_install="${to_install} python3" + fi fi if [ ! -z "${to_install}" ]; then - apt_get_update - apt-get -y install ${to_install} + pkg_mgr_update + check_packages ${to_install} fi fi -find "${NVM_DIR}" -type d -print0 | xargs -n 1 -0 chmod g+s + +# Clean up +su ${USERNAME} -c "umask 0002 && . '$NVM_DIR/nvm.sh' && nvm clear-cache" +clean_up + +# Ensure privs are correct for installed node versions. Unfortunately the +# way nvm installs node versions pulls privs from the tar which does not +# have group write set. We need this when the gid/uid is updated. +mkdir -p "${NVM_DIR}/versions" +chmod -R g+rw "${NVM_DIR}/versions" echo "Done!" diff --git a/src/nvidia-cuda/NOTES.md b/src/nvidia-cuda/NOTES.md index b19fde2ac..db2b843a2 100644 --- a/src/nvidia-cuda/NOTES.md +++ b/src/nvidia-cuda/NOTES.md @@ -10,10 +10,22 @@ Follow [NVIDIA's instructions to install the NVIDIA Container Toolkit](https://d ### Enable GPU passthrough -Enable GPU passthrough to your devcontainer by adding `["--gpus", "all"]` to your devcontainer's `runArgs` property. Here's an example of a devcontainer with this property: +Enable GPU passthrough to your devcontainer by using `hostRequirements`. Here's an example of a devcontainer with this property: ```json { - "runArgs": ["--gpus", "all"] + "hostRequirements": { + "gpu": "optional" + } } ``` + +> Note: Setting `gpu` property's value to `true` will work with GPU machine types, but fail with CPUs. Hence, setting it to `optional` works in both cases. See [schema](https://containers.dev/implementors/json_schema/#base-schema) for more configuration details. + + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/nvidia-cuda/README.md b/src/nvidia-cuda/README.md index 331d9773f..7982cfc9e 100644 --- a/src/nvidia-cuda/README.md +++ b/src/nvidia-cuda/README.md @@ -7,9 +7,7 @@ Installs shared libraries for NVIDIA CUDA. ```json "features": { - "ghcr.io/devcontainers/features/nvidia-cuda:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/nvidia-cuda:2": {} } ``` @@ -17,10 +15,12 @@ Installs shared libraries for NVIDIA CUDA. | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| -| installCudnn | Additionally install CUDA Deep Neural Network (cuDNN) shared library | boolean | - | -| installNvtx | Additionally install NVIDIA Tools Extension (NVTX) | boolean | - | -| cudaVersion | Version of CUDA to install | string | 11.7 | -| cudnnVersion | Version of cuDNN to install | string | 8.5.0.96 | +| installCudnn | Additionally install CUDA Deep Neural Network (cuDNN) shared library | boolean | false | +| installCudnnDev | Additionally install CUDA Deep Neural Network (cuDNN) development libraries and headers | boolean | false | +| installNvtx | Additionally install NVIDIA Tools Extension (NVTX) | boolean | false | +| installToolkit | Additionally install NVIDIA CUDA Toolkit | boolean | false | +| cudaVersion | Version of CUDA to install | string | 11.8 | +| cudnnVersion | Version of cuDNN to install | string | automatic | ## Compatibility @@ -34,14 +34,26 @@ Follow [NVIDIA's instructions to install the NVIDIA Container Toolkit](https://d ### Enable GPU passthrough -Enable GPU passthrough to your devcontainer by adding `["--gpus", "all"]` to your devcontainer's `runArgs` property. Here's an example of a devcontainer with this property: +Enable GPU passthrough to your devcontainer by using `hostRequirements`. Here's an example of a devcontainer with this property: ```json { - "runArgs": ["--gpus", "all"] + "hostRequirements": { + "gpu": "optional" + } } ``` +> Note: Setting `gpu` property's value to `true` will work with GPU machine types, but fail with CPUs. Hence, setting it to `optional` works in both cases. See [schema](https://containers.dev/implementors/json_schema/#base-schema) for more configuration details. + + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + --- diff --git a/src/nvidia-cuda/devcontainer-feature.json b/src/nvidia-cuda/devcontainer-feature.json index 7ede27124..477faf17a 100644 --- a/src/nvidia-cuda/devcontainer-feature.json +++ b/src/nvidia-cuda/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "nvidia-cuda", - "version": "1.0.2", + "version": "3.0.0", "name": "NVIDIA CUDA", "description": "Installs shared libraries for NVIDIA CUDA.", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/nvidia-cuda", @@ -10,14 +10,31 @@ "default": false, "description": "Additionally install CUDA Deep Neural Network (cuDNN) shared library" }, + "installCudnnDev": { + "type": "boolean", + "default": false, + "description": "Additionally install CUDA Deep Neural Network (cuDNN) development libraries and headers" + }, "installNvtx": { "type": "boolean", "default": false, "description": "Additionally install NVIDIA Tools Extension (NVTX)" }, + "installToolkit": { + "type": "boolean", + "default": false, + "description": "Additionally install NVIDIA CUDA Toolkit" + }, "cudaVersion": { "type": "string", - "enum": [ + "proposals": [ + "12.5", + "12.4", + "12.3", + "12.2", + "12.1", + "12.0", + "11.8", "11.7", "11.6", "11.5", @@ -25,12 +42,23 @@ "11.3", "11.2" ], - "default": "11.7", + "default": "12.5", "description": "Version of CUDA to install" }, "cudnnVersion": { "type": "string", - "enum": [ + "proposals": [ + "automatic", + "8.9.5.29", + "8.9.4.25", + "8.9.3.28", + "8.9.2.26", + "8.9.1.23", + "8.9.0.131", + "8.8.1.3", + "8.8.0.121", + "8.7.0.84", + "8.6.0.163", "8.5.0.96", "8.4.1.50", "8.4.0.27", @@ -43,10 +71,31 @@ "8.2.1.32", "8.2.0.53", "8.1.1.33", - "8.1.0.77" + "8.1.0.77", + "9.0.0.312", + "9.1.0.70", + "9.1.1.17", + "9.2.0.82", + "9.2.1.18", + "9.3.0.75", + "9.4.0.58" ], - "default": "8.5.0.96", + "default": "automatic", "description": "Version of cuDNN to install" } - } + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes shared libraries for NVIDIA CUDA pre-installed and available on the `PATH`. It's only useful for dev containers that run on a host machine with an NVIDIA GPU. Within your dev container, use the `nvidia-smi` command to ensure that your GPU is available for CUDA. If the `nvidia-smi` command is not available, you may need to follow NVIDIA's instructions to install the NVIDIA Container Toolkit on your host machine." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/nvidia-cuda/install.sh b/src/nvidia-cuda/install.sh index f017ecb23..66e4a6834 100644 --- a/src/nvidia-cuda/install.sh +++ b/src/nvidia-cuda/install.sh @@ -2,57 +2,171 @@ set -e +# Clean up +rm -rf /var/lib/apt/lists/* + INSTALL_CUDNN=${INSTALLCUDNN} +INSTALL_CUDNNDEV=${INSTALLCUDNNDEV} INSTALL_NVTX=${INSTALLNVTX} +INSTALL_TOOLKIT=${INSTALLTOOLKIT} CUDA_VERSION=${CUDAVERSION} CUDNN_VERSION=${CUDNNVERSION} +. /etc/os-release + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 fi -# Install dependencies -apt-get update -yq -apt-get install -yq wget ca-certificates +apt_get_update() +{ + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi +} + +# Checks if packages are installed and installs them if not +check_packages() { + if ! dpkg -s "$@" > /dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi +} + +if [ $VERSION_CODENAME = "bookworm" ] || [ $VERSION_CODENAME = "jammy" ] && [ $CUDA_VERSION \< 11.7 ]; then + echo "(!) Unsupported distribution version '${VERSION_CODENAME}' for CUDA < 11.7" + exit 1 +fi + +export DEBIAN_FRONTEND=noninteractive + +check_packages wget ca-certificates + +# Determine system architecture and set NVIDIA repository URL accordingly +ARCH=$(uname -m) +case $ARCH in + x86_64) + NVIDIA_ARCH="x86_64" + ;; + aarch64 | arm64) + NVIDIA_ARCH="arm64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; +esac # Add NVIDIA's package repository to apt so that we can download packages -# Always use the ubuntu2004 repo because the other repos (e.g., debian11) are missing packages -NVIDIA_REPO_URL="https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2004/x86_64" -KEYRING_PACKAGE="cuda-keyring_1.0-1_all.deb" -KEYRING_PACKAGE_URL="$NVIDIA_REPO_URL/$KEYRING_PACKAGE" -KEYRING_PACKAGE_PATH="$(mktemp -d)" -KEYRING_PACKAGE_FILE="$KEYRING_PACKAGE_PATH/$KEYRING_PACKAGE" -wget -O "$KEYRING_PACKAGE_FILE" "$KEYRING_PACKAGE_URL" -apt-get install -yq "$KEYRING_PACKAGE_FILE" -apt-get update -yq +# Updating the repo to ubuntu2204 as ubuntu 20.04 is going out of support. +NVIDIA_REPO_URL="https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/$NVIDIA_ARCH" + + +if [ "${ID}" = "debian" ] && [ "${VERSION_CODENAME}" = "trixie" ]; then + echo "(!) Temporary workaround on debian:trixie: bypassing NVIDIA repo signature checks" + cat > /etc/apt/sources.list.d/cuda.list <_-1 package + #else we need to install libcudnn8_-1+cuda" package + if [[ $major_cudnn_version -ge "9" ]] + then + cudnn_pkg_version="libcudnn9-cuda-${major_cuda_version}=${CUDNN_VERSION}-1" + else + cudnn_pkg_version="libcudnn8=${CUDNN_VERSION}-1+cuda${CUDA_VERSION}" + fi + + if ! apt-cache show "$cudnn_pkg_version"; then + echo "The requested version of cuDNN is not available: cuDNN $CUDNN_VERSION for CUDA $CUDA_VERSION" + if [ "$NVIDIA_ARCH" = "arm64" ]; then + echo "Note: arm64 has limited cuDNN package availability" + fi + exit 1 + fi + echo "Installing cuDNN libraries..." apt-get install -yq "$cudnn_pkg_version" fi +if [ "$INSTALL_CUDNNDEV" = "true" ]; then + # Ensure that the requested version of cuDNN development package is available AND compatible + #if major cudnn version is 9, then we need to install libcudnn9-dev-cuda-_-1 package + #else we need to install libcudnn8-dev_-1+cuda" package + if [[ $major_cudnn_version -ge "9" ]] + then + cudnn_dev_pkg_version="libcudnn9-dev-cuda-${major_cuda_version}=${CUDNN_VERSION}-1" + else + cudnn_dev_pkg_version="libcudnn8-dev=${CUDNN_VERSION}-1+cuda${CUDA_VERSION}" + fi + if ! apt-cache show "$cudnn_dev_pkg_version"; then + echo "The requested version of cuDNN development package is not available: cuDNN $CUDNN_VERSION for CUDA $CUDA_VERSION" + if [ "$NVIDIA_ARCH" = "arm64" ]; then + echo "Note: arm64 has limited cuDNN development package availability" + fi + exit 1 + fi + + echo "Installing cuDNN dev libraries..." + apt-get install -yq "$cudnn_dev_pkg_version" +fi + if [ "$INSTALL_NVTX" = "true" ]; then echo "Installing NVTX..." apt-get install -yq "$nvtx_pkg" fi +if [ "$INSTALL_TOOLKIT" = "true" ]; then + echo "Installing CUDA Toolkit..." + apt-get install -yq "$toolkit_pkg" +fi + +# Clean up +rm -rf /var/lib/apt/lists/* + echo "Done!" diff --git a/src/oryx/NOTES.md b/src/oryx/NOTES.md new file mode 100644 index 000000000..ec69ec059 --- /dev/null +++ b/src/oryx/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +See [supportedPlatformVersions.md](https://github.com/microsoft/Oryx/blob/main/doc/supportedPlatformVersions.md) on the Oryx repository for supported platforms. Notably, `oryx` does not support Debian "jammy". + +`bash` is required to execute the `install.sh` script. diff --git a/src/oryx/README.md b/src/oryx/README.md index 121bda887..c115e570e 100644 --- a/src/oryx/README.md +++ b/src/oryx/README.md @@ -7,17 +7,20 @@ Installs the oryx CLI ```json "features": { - "ghcr.io/devcontainers/features/oryx:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/oryx:2": {} } ``` -## Options +## OS Support + +See [supportedPlatformVersions.md](https://github.com/microsoft/Oryx/blob/main/doc/supportedPlatformVersions.md) on the Oryx repository for supported platforms. Notably, `oryx` does not support Debian "jammy". + +`bash` is required to execute the `install.sh` script. + --- diff --git a/src/oryx/devcontainer-feature.json b/src/oryx/devcontainer-feature.json index 2e40e88c0..5e60e3218 100644 --- a/src/oryx/devcontainer-feature.json +++ b/src/oryx/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "oryx", - "version": "1.0.6", + "version": "2.0.1", "name": "Oryx", "description": "Installs the oryx CLI", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/oryx", @@ -10,7 +10,22 @@ "DYNAMIC_INSTALL_ROOT_DIR": "/opt", "ORYX_PREFER_USER_INSTALLED_SDKS": "true", "ORYX_DIR": "/usr/local/oryx", - "DEBIAN_FLAVOR": "focal-scm", + "DEBIAN_FLAVOR": "bookworm", "PATH": "/usr/local/oryx:${PATH}" - } + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the oryx CLI pre-installed and available on the `PATH`." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils", + "ghcr.io/devcontainers/features/dotnet" + ] } diff --git a/src/oryx/install.sh b/src/oryx/install.sh index 826d916ce..8a85fecd1 100755 --- a/src/oryx/install.sh +++ b/src/oryx/install.sh @@ -5,13 +5,16 @@ #------------------------------------------------------------------------------------------------------------- -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +UPDATE_RC="${UPDATE_RC:-"true"}" MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" set -eu +# Clean up +rm -rf /var/lib/apt/lists/* + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -53,27 +56,43 @@ function updaterc() { apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not check_packages() { if ! dpkg -s "$@" > /dev/null 2>&1; then apt_get_update - DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends "$@" - - # Clean up - apt-get clean -y - rm -rf /var/lib/apt/lists/* + apt-get -y install --no-install-recommends "$@" fi } +install_dotnet_with_script() +{ + local version="$1" + CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + DOTNET_INSTALL_SCRIPT="$CURRENT_DIR/scripts/vendor/dotnet-install.sh" + DOTNET_INSTALL_DIR='/usr/share/dotnet' + + check_packages icu-devtools + + "$DOTNET_INSTALL_SCRIPT" \ + --version "$version" \ + --install-dir "$DOTNET_INSTALL_DIR" \ + --no-path + + DOTNET_BINARY="dotnet" + export PATH="${PATH}:/usr/share/dotnet" +} + install_dotnet_using_apt() { echo "Attempting to auto-install dotnet..." install_from_microsoft_feed=false apt_get_update - DOTNET_INSTALLATION_PACKAGE="dotnet6" + DOTNET_INSTALLATION_PACKAGE="dotnet8" apt-get -yq install $DOTNET_INSTALLATION_PACKAGE || install_from_microsoft_feed="true" if [ "${install_from_microsoft_feed}" = "true" ]; then @@ -81,10 +100,11 @@ install_dotnet_using_apt() { curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg echo "deb [arch=${architecture} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list apt-get update -y - DOTNET_INSTALLATION_PACKAGE="dotnet-sdk-6.0" + DOTNET_INSTALLATION_PACKAGE="dotnet-sdk-8.0" DOTNET_SKIP_FIRST_TIME_EXPERIENCE="true" apt-get install -yq $DOTNET_INSTALLATION_PACKAGE fi + DOTNET_BINARY="/usr/bin/dotnet" echo -e "Finished attempt to install dotnet. Sdks installed:\n" dotnet --list-sdks @@ -105,6 +125,8 @@ fi # If we don't already have Oryx installed, install it now. if oryx --version > /dev/null ; then echo "oryx is already installed. Skipping installation." + # Clean up + rm -rf /var/lib/apt/lists/* exit 0 fi @@ -113,9 +135,8 @@ echo "Installing Oryx..." # Ensure apt is in non-interactive to avoid prompts export DEBIAN_FRONTEND=noninteractive - # Install dependencies -check_packages git sudo curl ca-certificates apt-transport-https gnupg2 dirmngr libc-bin +check_packages git sudo curl ca-certificates apt-transport-https gnupg2 dirmngr libc-bin moreutils if ! cat /etc/group | grep -e "^oryx:" > /dev/null 2>&1; then groupadd -r oryx @@ -124,12 +145,25 @@ usermod -a -G oryx "${USERNAME}" # Required to decide if we want to clean up dotnet later. DOTNET_INSTALLATION_PACKAGE="" +DOTNET_BINARY="" -# Install dotnet unless available -if ! dotnet --version > /dev/null ; then - echo "'dotnet' was not detected. Attempting to install the latest version of the dotnet sdk to build oryx." - install_dotnet_using_apt +if dotnet --version > /dev/null ; then + DOTNET_BINARY=$(which dotnet) + RUNTIME_VERSIONS=$(dotnet --list-runtimes | awk '{print $2}' | sort | uniq) +fi +MAJOR_VERSION_ID=$(echo $(dotnet --version) | cut -d . -f 1) +PATCH_VERSION_ID=$(echo $(dotnet --version) | cut -d . -f 3) + +PINNED_SDK_VERSION="" +# Oryx needs to be built with .NET 8 +if [[ "${DOTNET_BINARY}" = "" ]] || [[ $MAJOR_VERSION_ID != "8" ]] || [[ $MAJOR_VERSION_ID = "8" && ${PATCH_VERSION_ID} -ne "202" ]] ; then + echo "'dotnet 8' was not detected. Attempting to install .NET 8 to build oryx." + # The oryx build fails with .Net 8.0.201, see https://github.com/devcontainers/images/issues/974 + # Pinning it to a working version until the upstream Oryx repo updates the dependency + # install_dotnet_using_apt + PINNED_SDK_VERSION="8.0.202" + install_dotnet_with_script ${PINNED_SDK_VERSION} if ! dotnet --version > /dev/null ; then echo "(!) Please install Dotnet before installing Oryx" exit 1 @@ -145,26 +179,41 @@ mkdir -p ${ORYX} git clone --depth=1 https://github.com/microsoft/Oryx $GIT_ORYX -$GIT_ORYX/build/buildSln.sh +if [[ "${PINNED_SDK_VERSION}" != "" ]]; then + cd $GIT_ORYX + dotnet new globaljson --sdk-version ${PINNED_SDK_VERSION} +fi + +SOLUTION_FILE_NAME="Oryx.sln" +echo "Building solution '$SOLUTION_FILE_NAME'..." + +cd $GIT_ORYX +${DOTNET_BINARY} build "$SOLUTION_FILE_NAME" -c Debug -p:NuGetAudit=false -dotnet publish -property:ValidateExecutableReferencesMatchSelfContained=false -r linux-x64 -o ${BUILD_SCRIPT_GENERATOR} -c Release $GIT_ORYX/src/BuildScriptGeneratorCli/BuildScriptGeneratorCli.csproj -dotnet publish -r linux-x64 -o ${BUILD_SCRIPT_GENERATOR} -c Release $GIT_ORYX/src/BuildServer/BuildServer.csproj +${DOTNET_BINARY} publish -p:NuGetAudit=false -property:ValidateExecutableReferencesMatchSelfContained=false -r linux-x64 -o ${BUILD_SCRIPT_GENERATOR} -c Release $GIT_ORYX/src/BuildScriptGeneratorCli/BuildScriptGeneratorCli.csproj --self-contained true +${DOTNET_BINARY} publish -p:NuGetAudit=false -r linux-x64 -o ${BUILD_SCRIPT_GENERATOR} -c Release $GIT_ORYX/src/BuildServer/BuildServer.csproj --self-contained true chmod a+x ${BUILD_SCRIPT_GENERATOR}/GenerateBuildScript ln -s ${BUILD_SCRIPT_GENERATOR}/GenerateBuildScript ${ORYX}/oryx cp -f $GIT_ORYX/images/build/benv.sh ${ORYX}/benv +cp -f $GIT_ORYX/images/build/logger.sh ${ORYX}/logger ORYX_INSTALL_DIR="/opt" mkdir -p "${ORYX_INSTALL_DIR}" -updaterc "export ORYX_SDK_STORAGE_BASE_URL=https://oryx-cdn.microsoft.io && export ENABLE_DYNAMIC_INSTALL=true && DYNAMIC_INSTALL_ROOT_DIR=$ORYX_INSTALL_DIR && ORYX_PREFER_USER_INSTALLED_SDKS=true && export DEBIAN_FLAVOR=focal-scm" +# Directory used by the oryx tool to cache the automatically installed python packages from `requirements.txt` +PIP_CACHE_DIR="/usr/local/share/pip-cache/lib" +mkdir -p ${PIP_CACHE_DIR} + +updaterc "export ORYX_SDK_STORAGE_BASE_URL=https://oryx-cdn.microsoft.io && export ENABLE_DYNAMIC_INSTALL=true && DYNAMIC_INSTALL_ROOT_DIR=$ORYX_INSTALL_DIR && ORYX_PREFER_USER_INSTALLED_SDKS=true && export DEBIAN_FLAVOR=bookworm" -chown -R "${USERNAME}:oryx" "${ORYX_INSTALL_DIR}" "${BUILD_SCRIPT_GENERATOR}" "${ORYX}" -chmod -R g+r+w "${ORYX_INSTALL_DIR}" "${BUILD_SCRIPT_GENERATOR}" "${ORYX}" +chown -R "${USERNAME}:oryx" "${ORYX_INSTALL_DIR}" "${BUILD_SCRIPT_GENERATOR}" "${ORYX}" "${PIP_CACHE_DIR}" +chmod -R g+r+w "${ORYX_INSTALL_DIR}" "${BUILD_SCRIPT_GENERATOR}" "${ORYX}" "${PIP_CACHE_DIR}" find "${ORYX_INSTALL_DIR}" -type d -print0 | xargs -n 1 -0 chmod g+s find "${BUILD_SCRIPT_GENERATOR}" -type d -print0 | xargs -n 1 -0 chmod g+s find "${ORYX}" -type d -print0 | xargs -n 1 -0 chmod g+s +find "${PIP_CACHE_DIR}" -type d -print0 | xargs -n 1 -0 chmod g+s # /opt/tmp/build and /opt/tmp/images is required by Oryx for dynamically installing platforms cp -rf $GIT_ORYX/build /opt/tmp @@ -182,4 +231,22 @@ if [[ "${DOTNET_INSTALLATION_PACKAGE}" != "" ]]; then apt purge -yq $DOTNET_INSTALLATION_PACKAGE fi -echo "Done!" +if [[ "${PINNED_SDK_VERSION}" != "" ]]; then + rm -f ${GIT_ORYX}/global.json + rm -rf /usr/share/dotnet/sdk/$PINNED_SDK_VERSION + NEW_RUNTIME_VERSIONS=$(dotnet --list-runtimes | awk '{print $2}' | sort | uniq) + if [ -n "${RUNTIME_VERSIONS:-}" ]; then + SDK_INSTALLED_RUNTIME=$(echo "$NEW_RUNTIME_VERSIONS" | grep -vxFf <(echo "$RUNTIME_VERSIONS")) + else + SDK_INSTALLED_RUNTIME="$NEW_RUNTIME_VERSIONS" + fi + rm -rf /usr/share/dotnet/shared/Microsoft.NETCore.App/$SDK_INSTALLED_RUNTIME + rm -rf /usr/share/dotnet/shared/Microsoft.AspNetCore.App/$SDK_INSTALLED_RUNTIME + rm -rf /usr/share/dotnet/templates/$SDK_INSTALLED_RUNTIME +fi + + +# Clean up +rm -rf /var/lib/apt/lists/* + +echo "Done!" \ No newline at end of file diff --git a/src/oryx/scripts/vendor/README.md b/src/oryx/scripts/vendor/README.md new file mode 100644 index 000000000..181b53781 --- /dev/null +++ b/src/oryx/scripts/vendor/README.md @@ -0,0 +1,27 @@ +### **IMPORTANT NOTE** + +Scripts in this directory are sourced externally and not maintained by the Dev Container spec maintainers. Do not make changes directly as they might be overwritten at any moment. + +## dotnet-install.sh + +`dotnet-install.sh` is a copy of . ([Script reference](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script)) + +Quick options reminder for `dotnet-install.sh`: + +- `--version`: `"latest"` (default) or an exact version in the form A.B.C like `"6.0.413"` +- `--channel`: `"LTS"` (default), `"STS"`, a two-part version in the form A.B like `"6.0"` or three-part form A.B.Cxx like `"6.0.1xx"` +- `--quality`: `"daily"`, `"preview"` or `"GA"` +- The channel option is only used when version is 'latest' because an exact version overrides the channel option +- The quality option is only used when channel is 'A.B' or 'A.B.Cxx' because it can't be used with STS or LTS + +Examples + +``` +dotnet-install.sh [--version latest] [--channel LTS] +dotnet-install.sh [--version latest] --channel STS +dotnet-install.sh [--version latest] --channel 6.0 [--quality GA] +dotnet-install.sh [--version latest] --channel 6.0.4xx [--quality GA] +dotnet-install.sh [--version latest] --channel 8.0 --quality preview +dotnet-install.sh [--version latest] --channel 8.0 --quality daily +dotnet-install.sh --version 6.0.413 +``` \ No newline at end of file diff --git a/src/oryx/scripts/vendor/dotnet-install.sh b/src/oryx/scripts/vendor/dotnet-install.sh new file mode 100755 index 000000000..122ee68ed --- /dev/null +++ b/src/oryx/scripts/vendor/dotnet-install.sh @@ -0,0 +1,1959 @@ +#!/usr/bin/env bash +# Copyright (c) .NET Foundation and contributors. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +# Stop script on NZEC +set -e +# Stop script if unbound variable found (use ${var:-} if intentional) +set -u +# By default cmd1 | cmd2 returns exit code of cmd2 regardless of cmd1 success +# This is causing it to fail +set -o pipefail + +# Use in the the functions: eval $invocation +invocation='say_verbose "Calling: ${yellow:-}${FUNCNAME[0]} ${green:-}$*${normal:-}"' + +# standard output may be used as a return value in the functions +# we need a way to write text on the screen in the functions so that +# it won't interfere with the return value. +# Exposing stream 3 as a pipe to standard output of the script itself +exec 3>&1 + +# Setup some colors to use. These need to work in fairly limited shells, like the Ubuntu Docker container where there are only 8 colors. +# See if stdout is a terminal +if [ -t 1 ] && command -v tput > /dev/null; then + # see if it supports colors + ncolors=$(tput colors || echo 0) + if [ -n "$ncolors" ] && [ $ncolors -ge 8 ]; then + bold="$(tput bold || echo)" + normal="$(tput sgr0 || echo)" + black="$(tput setaf 0 || echo)" + red="$(tput setaf 1 || echo)" + green="$(tput setaf 2 || echo)" + yellow="$(tput setaf 3 || echo)" + blue="$(tput setaf 4 || echo)" + magenta="$(tput setaf 5 || echo)" + cyan="$(tput setaf 6 || echo)" + white="$(tput setaf 7 || echo)" + fi +fi + +say_warning() { + printf "%b\n" "${yellow:-}dotnet_install: Warning: $1${normal:-}" >&3 +} + +say_err() { + printf "%b\n" "${red:-}dotnet_install: Error: $1${normal:-}" >&2 +} + +say() { + # using stream 3 (defined in the beginning) to not interfere with stdout of functions + # which may be used as return value + printf "%b\n" "${cyan:-}dotnet-install:${normal:-} $1" >&3 +} + +say_verbose() { + if [ "$verbose" = true ]; then + say "$1" + fi +} + +# This platform list is finite - if the SDK/Runtime has supported Linux distribution-specific assets, +# then and only then should the Linux distribution appear in this list. +# Adding a Linux distribution to this list does not imply distribution-specific support. +get_legacy_os_name_from_platform() { + eval $invocation + + platform="$1" + case "$platform" in + "centos.7") + echo "centos" + return 0 + ;; + "debian.8") + echo "debian" + return 0 + ;; + "debian.9") + echo "debian.9" + return 0 + ;; + "fedora.23") + echo "fedora.23" + return 0 + ;; + "fedora.24") + echo "fedora.24" + return 0 + ;; + "fedora.27") + echo "fedora.27" + return 0 + ;; + "fedora.28") + echo "fedora.28" + return 0 + ;; + "opensuse.13.2") + echo "opensuse.13.2" + return 0 + ;; + "opensuse.42.1") + echo "opensuse.42.1" + return 0 + ;; + "opensuse.42.3") + echo "opensuse.42.3" + return 0 + ;; + "rhel.7"*) + echo "rhel" + return 0 + ;; + "ubuntu.14.04") + echo "ubuntu" + return 0 + ;; + "ubuntu.16.04") + echo "ubuntu.16.04" + return 0 + ;; + "ubuntu.16.10") + echo "ubuntu.16.10" + return 0 + ;; + "ubuntu.18.04") + echo "ubuntu.18.04" + return 0 + ;; + "alpine.3.4.3") + echo "alpine" + return 0 + ;; + esac + return 1 +} + +get_legacy_os_name() { + eval $invocation + + local uname=$(uname) + if [ "$uname" = "Darwin" ]; then + echo "osx" + return 0 + elif [ -n "$runtime_id" ]; then + echo $(get_legacy_os_name_from_platform "${runtime_id%-*}" || echo "${runtime_id%-*}") + return 0 + else + if [ -e /etc/os-release ]; then + . /etc/os-release + os=$(get_legacy_os_name_from_platform "$ID${VERSION_ID:+.${VERSION_ID}}" || echo "") + if [ -n "$os" ]; then + echo "$os" + return 0 + fi + fi + fi + + say_verbose "Distribution specific OS name and version could not be detected: UName = $uname" + return 1 +} + +get_linux_platform_name() { + eval $invocation + + if [ -n "$runtime_id" ]; then + echo "${runtime_id%-*}" + return 0 + else + if [ -e /etc/os-release ]; then + . /etc/os-release + echo "$ID${VERSION_ID:+.${VERSION_ID}}" + return 0 + elif [ -e /etc/redhat-release ]; then + local redhatRelease=$(&1 || true) | grep -q musl +} + +get_current_os_name() { + eval $invocation + + local uname=$(uname) + if [ "$uname" = "Darwin" ]; then + echo "osx" + return 0 + elif [ "$uname" = "FreeBSD" ]; then + echo "freebsd" + return 0 + elif [ "$uname" = "Linux" ]; then + local linux_platform_name="" + linux_platform_name="$(get_linux_platform_name)" || true + + if [ "$linux_platform_name" = "rhel.6" ]; then + echo $linux_platform_name + return 0 + elif is_musl_based_distro; then + echo "linux-musl" + return 0 + elif [ "$linux_platform_name" = "linux-musl" ]; then + echo "linux-musl" + return 0 + else + echo "linux" + return 0 + fi + fi + + say_err "OS name could not be detected: UName = $uname" + return 1 +} + +machine_has() { + eval $invocation + + command -v "$1" > /dev/null 2>&1 + return $? +} + +check_min_reqs() { + local hasMinimum=false + if machine_has "curl"; then + hasMinimum=true + elif machine_has "wget"; then + hasMinimum=true + fi + + if [ "$hasMinimum" = "false" ]; then + say_err "curl (recommended) or wget are required to download dotnet. Install missing prerequisite to proceed." + return 1 + fi + return 0 +} + +# args: +# input - $1 +to_lowercase() { + #eval $invocation + + echo "$1" | tr '[:upper:]' '[:lower:]' + return 0 +} + +# args: +# input - $1 +remove_trailing_slash() { + #eval $invocation + + local input="${1:-}" + echo "${input%/}" + return 0 +} + +# args: +# input - $1 +remove_beginning_slash() { + #eval $invocation + + local input="${1:-}" + echo "${input#/}" + return 0 +} + +# args: +# root_path - $1 +# child_path - $2 - this parameter can be empty +combine_paths() { + eval $invocation + + # TODO: Consider making it work with any number of paths. For now: + if [ ! -z "${3:-}" ]; then + say_err "combine_paths: Function takes two parameters." + return 1 + fi + + local root_path="$(remove_trailing_slash "$1")" + local child_path="$(remove_beginning_slash "${2:-}")" + say_verbose "combine_paths: root_path=$root_path" + say_verbose "combine_paths: child_path=$child_path" + echo "$root_path/$child_path" + return 0 +} + +get_machine_architecture() { + eval $invocation + + if command -v uname > /dev/null; then + CPUName=$(uname -m) + case $CPUName in + armv1*|armv2*|armv3*|armv4*|armv5*|armv6*) + echo "armv6-or-below" + return 0 + ;; + armv*l) + echo "arm" + return 0 + ;; + aarch64|arm64) + if [ "$(getconf LONG_BIT)" -lt 64 ]; then + # This is 32-bit OS running on 64-bit CPU (for example Raspberry Pi OS) + echo "arm" + return 0 + fi + echo "arm64" + return 0 + ;; + s390x) + echo "s390x" + return 0 + ;; + ppc64le) + echo "ppc64le" + return 0 + ;; + loongarch64) + echo "loongarch64" + return 0 + ;; + riscv64) + echo "riscv64" + return 0 + ;; + powerpc|ppc) + echo "ppc" + return 0 + ;; + esac + fi + + # Always default to 'x64' + echo "x64" + return 0 +} + +# args: +# architecture - $1 +get_normalized_architecture_from_architecture() { + eval $invocation + + local architecture="$(to_lowercase "$1")" + + if [[ $architecture == \ ]]; then + machine_architecture="$(get_machine_architecture)" + if [[ "$machine_architecture" == "armv6-or-below" ]]; then + say_err "Architecture \`$machine_architecture\` not supported. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues" + return 1 + fi + + echo $machine_architecture + return 0 + fi + + case "$architecture" in + amd64|x64) + echo "x64" + return 0 + ;; + arm) + echo "arm" + return 0 + ;; + arm64) + echo "arm64" + return 0 + ;; + s390x) + echo "s390x" + return 0 + ;; + ppc64le) + echo "ppc64le" + return 0 + ;; + loongarch64) + echo "loongarch64" + return 0 + ;; + esac + + say_err "Architecture \`$architecture\` not supported. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues" + return 1 +} + +# args: +# version - $1 +# channel - $2 +# architecture - $3 +get_normalized_architecture_for_specific_sdk_version() { + eval $invocation + + local is_version_support_arm64="$(is_arm64_supported "$1")" + local is_channel_support_arm64="$(is_arm64_supported "$2")" + local architecture="$3"; + local osname="$(get_current_os_name)" + + if [ "$osname" == "osx" ] && [ "$architecture" == "arm64" ] && { [ "$is_version_support_arm64" = false ] || [ "$is_channel_support_arm64" = false ]; }; then + #check if rosetta is installed + if [ "$(/usr/bin/pgrep oahd >/dev/null 2>&1;echo $?)" -eq 0 ]; then + say_verbose "Changing user architecture from '$architecture' to 'x64' because .NET SDKs prior to version 6.0 do not support arm64." + echo "x64" + return 0; + else + say_err "Architecture \`$architecture\` is not supported for .NET SDK version \`$version\`. Please install Rosetta to allow emulation of the \`$architecture\` .NET SDK on this platform" + return 1 + fi + fi + + echo "$architecture" + return 0 +} + +# args: +# version or channel - $1 +is_arm64_supported() { + # Extract the major version by splitting on the dot + major_version="${1%%.*}" + + # Check if the major version is a valid number and less than 6 + case "$major_version" in + [0-9]*) + if [ "$major_version" -lt 6 ]; then + echo false + return 0 + fi + ;; + esac + + echo true + return 0 +} + +# args: +# user_defined_os - $1 +get_normalized_os() { + eval $invocation + + local osname="$(to_lowercase "$1")" + if [ ! -z "$osname" ]; then + case "$osname" in + osx | freebsd | rhel.6 | linux-musl | linux) + echo "$osname" + return 0 + ;; + macos) + osname='osx' + echo "$osname" + return 0 + ;; + *) + say_err "'$user_defined_os' is not a supported value for --os option, supported values are: osx, macos, linux, linux-musl, freebsd, rhel.6. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues." + return 1 + ;; + esac + else + osname="$(get_current_os_name)" || return 1 + fi + echo "$osname" + return 0 +} + +# args: +# quality - $1 +get_normalized_quality() { + eval $invocation + + local quality="$(to_lowercase "$1")" + if [ ! -z "$quality" ]; then + case "$quality" in + daily | signed | validated | preview) + echo "$quality" + return 0 + ;; + ga) + #ga quality is available without specifying quality, so normalizing it to empty + return 0 + ;; + *) + say_err "'$quality' is not a supported value for --quality option. Supported values are: daily, signed, validated, preview, ga. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues." + return 1 + ;; + esac + fi + return 0 +} + +# args: +# channel - $1 +get_normalized_channel() { + eval $invocation + + local channel="$(to_lowercase "$1")" + + if [[ $channel == current ]]; then + say_warning 'Value "Current" is deprecated for -Channel option. Use "STS" instead.' + fi + + if [[ $channel == release/* ]]; then + say_warning 'Using branch name with -Channel option is no longer supported with newer releases. Use -Quality option with a channel in X.Y format instead.'; + fi + + if [ ! -z "$channel" ]; then + case "$channel" in + lts) + echo "LTS" + return 0 + ;; + sts) + echo "STS" + return 0 + ;; + current) + echo "STS" + return 0 + ;; + *) + echo "$channel" + return 0 + ;; + esac + fi + + return 0 +} + +# args: +# runtime - $1 +get_normalized_product() { + eval $invocation + + local product="" + local runtime="$(to_lowercase "$1")" + if [[ "$runtime" == "dotnet" ]]; then + product="dotnet-runtime" + elif [[ "$runtime" == "aspnetcore" ]]; then + product="aspnetcore-runtime" + elif [ -z "$runtime" ]; then + product="dotnet-sdk" + fi + echo "$product" + return 0 +} + +# The version text returned from the feeds is a 1-line or 2-line string: +# For the SDK and the dotnet runtime (2 lines): +# Line 1: # commit_hash +# Line 2: # 4-part version +# For the aspnetcore runtime (1 line): +# Line 1: # 4-part version + +# args: +# version_text - stdin +get_version_from_latestversion_file_content() { + eval $invocation + + cat | tail -n 1 | sed 's/\r$//' + return 0 +} + +# args: +# install_root - $1 +# relative_path_to_package - $2 +# specific_version - $3 +is_dotnet_package_installed() { + eval $invocation + + local install_root="$1" + local relative_path_to_package="$2" + local specific_version="${3//[$'\t\r\n']}" + + local dotnet_package_path="$(combine_paths "$(combine_paths "$install_root" "$relative_path_to_package")" "$specific_version")" + say_verbose "is_dotnet_package_installed: dotnet_package_path=$dotnet_package_path" + + if [ -d "$dotnet_package_path" ]; then + return 0 + else + return 1 + fi +} + +# args: +# downloaded file - $1 +# remote_file_size - $2 +validate_remote_local_file_sizes() +{ + eval $invocation + + local downloaded_file="$1" + local remote_file_size="$2" + local file_size='' + + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + file_size="$(stat -c '%s' "$downloaded_file")" + elif [[ "$OSTYPE" == "darwin"* ]]; then + # hardcode in order to avoid conflicts with GNU stat + file_size="$(/usr/bin/stat -f '%z' "$downloaded_file")" + fi + + if [ -n "$file_size" ]; then + say "Downloaded file size is $file_size bytes." + + if [ -n "$remote_file_size" ] && [ -n "$file_size" ]; then + if [ "$remote_file_size" -ne "$file_size" ]; then + say "The remote and local file sizes are not equal. The remote file size is $remote_file_size bytes and the local size is $file_size bytes. The local package may be corrupted." + else + say "The remote and local file sizes are equal." + fi + fi + + else + say "Either downloaded or local package size can not be measured. One of them may be corrupted." + fi +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +get_version_from_latestversion_file() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + + local version_file_url=null + if [[ "$runtime" == "dotnet" ]]; then + version_file_url="$azure_feed/Runtime/$channel/latest.version" + elif [[ "$runtime" == "aspnetcore" ]]; then + version_file_url="$azure_feed/aspnetcore/Runtime/$channel/latest.version" + elif [ -z "$runtime" ]; then + version_file_url="$azure_feed/Sdk/$channel/latest.version" + else + say_err "Invalid value for \$runtime" + return 1 + fi + say_verbose "get_version_from_latestversion_file: latest url: $version_file_url" + + download "$version_file_url" || return $? + return 0 +} + +# args: +# json_file - $1 +parse_globaljson_file_for_version() { + eval $invocation + + local json_file="$1" + if [ ! -f "$json_file" ]; then + say_err "Unable to find \`$json_file\`" + return 1 + fi + + sdk_section=$(cat $json_file | tr -d "\r" | awk '/"sdk"/,/}/') + if [ -z "$sdk_section" ]; then + say_err "Unable to parse the SDK node in \`$json_file\`" + return 1 + fi + + sdk_list=$(echo $sdk_section | awk -F"[{}]" '{print $2}') + sdk_list=${sdk_list//[\" ]/} + sdk_list=${sdk_list//,/$'\n'} + + local version_info="" + while read -r line; do + IFS=: + while read -r key value; do + if [[ "$key" == "version" ]]; then + version_info=$value + fi + done <<< "$line" + done <<< "$sdk_list" + if [ -z "$version_info" ]; then + say_err "Unable to find the SDK:version node in \`$json_file\`" + return 1 + fi + + unset IFS; + echo "$version_info" + return 0 +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# version - $4 +# json_file - $5 +get_specific_version_from_version() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local version="$(to_lowercase "$4")" + local json_file="$5" + + if [ -z "$json_file" ]; then + if [[ "$version" == "latest" ]]; then + local version_info + version_info="$(get_version_from_latestversion_file "$azure_feed" "$channel" "$normalized_architecture" false)" || return 1 + say_verbose "get_specific_version_from_version: version_info=$version_info" + echo "$version_info" | get_version_from_latestversion_file_content + return 0 + else + echo "$version" + return 0 + fi + else + local version_info + version_info="$(parse_globaljson_file_for_version "$json_file")" || return 1 + echo "$version_info" + return 0 + fi +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# specific_version - $4 +# normalized_os - $5 +construct_download_link() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local specific_version="${4//[$'\t\r\n']}" + local specific_product_version="$(get_specific_product_version "$1" "$4")" + local osname="$5" + + local download_link=null + if [[ "$runtime" == "dotnet" ]]; then + download_link="$azure_feed/Runtime/$specific_version/dotnet-runtime-$specific_product_version-$osname-$normalized_architecture.tar.gz" + elif [[ "$runtime" == "aspnetcore" ]]; then + download_link="$azure_feed/aspnetcore/Runtime/$specific_version/aspnetcore-runtime-$specific_product_version-$osname-$normalized_architecture.tar.gz" + elif [ -z "$runtime" ]; then + download_link="$azure_feed/Sdk/$specific_version/dotnet-sdk-$specific_product_version-$osname-$normalized_architecture.tar.gz" + else + return 1 + fi + + echo "$download_link" + return 0 +} + +# args: +# azure_feed - $1 +# specific_version - $2 +# download link - $3 (optional) +get_specific_product_version() { + # If we find a 'productVersion.txt' at the root of any folder, we'll use its contents + # to resolve the version of what's in the folder, superseding the specified version. + # if 'productVersion.txt' is missing but download link is already available, product version will be taken from download link + eval $invocation + + local azure_feed="$1" + local specific_version="${2//[$'\t\r\n']}" + local package_download_link="" + if [ $# -gt 2 ]; then + local package_download_link="$3" + fi + local specific_product_version=null + + # Try to get the version number, using the productVersion.txt file located next to the installer file. + local download_links=($(get_specific_product_version_url "$azure_feed" "$specific_version" true "$package_download_link") + $(get_specific_product_version_url "$azure_feed" "$specific_version" false "$package_download_link")) + + for download_link in "${download_links[@]}" + do + say_verbose "Checking for the existence of $download_link" + + if machine_has "curl" + then + if ! specific_product_version=$(curl -s --fail "${download_link}${feed_credential}" 2>&1); then + continue + else + echo "${specific_product_version//[$'\t\r\n']}" + return 0 + fi + + elif machine_has "wget" + then + specific_product_version=$(wget -qO- "${download_link}${feed_credential}" 2>&1) + if [ $? = 0 ]; then + echo "${specific_product_version//[$'\t\r\n']}" + return 0 + fi + fi + done + + # Getting the version number with productVersion.txt has failed. Try parsing the download link for a version number. + say_verbose "Failed to get the version using productVersion.txt file. Download link will be parsed instead." + specific_product_version="$(get_product_specific_version_from_download_link "$package_download_link" "$specific_version")" + echo "${specific_product_version//[$'\t\r\n']}" + return 0 +} + +# args: +# azure_feed - $1 +# specific_version - $2 +# is_flattened - $3 +# download link - $4 (optional) +get_specific_product_version_url() { + eval $invocation + + local azure_feed="$1" + local specific_version="$2" + local is_flattened="$3" + local package_download_link="" + if [ $# -gt 3 ]; then + local package_download_link="$4" + fi + + local pvFileName="productVersion.txt" + if [ "$is_flattened" = true ]; then + if [ -z "$runtime" ]; then + pvFileName="sdk-productVersion.txt" + elif [[ "$runtime" == "dotnet" ]]; then + pvFileName="runtime-productVersion.txt" + else + pvFileName="$runtime-productVersion.txt" + fi + fi + + local download_link=null + + if [ -z "$package_download_link" ]; then + if [[ "$runtime" == "dotnet" ]]; then + download_link="$azure_feed/Runtime/$specific_version/${pvFileName}" + elif [[ "$runtime" == "aspnetcore" ]]; then + download_link="$azure_feed/aspnetcore/Runtime/$specific_version/${pvFileName}" + elif [ -z "$runtime" ]; then + download_link="$azure_feed/Sdk/$specific_version/${pvFileName}" + else + return 1 + fi + else + download_link="${package_download_link%/*}/${pvFileName}" + fi + + say_verbose "Constructed productVersion link: $download_link" + echo "$download_link" + return 0 +} + +# args: +# download link - $1 +# specific version - $2 +get_product_specific_version_from_download_link() +{ + eval $invocation + + local download_link="$1" + local specific_version="$2" + local specific_product_version="" + + if [ -z "$download_link" ]; then + echo "$specific_version" + return 0 + fi + + #get filename + filename="${download_link##*/}" + + #product specific version follows the product name + #for filename 'dotnet-sdk-3.1.404-linux-x64.tar.gz': the product version is 3.1.404 + IFS='-' + read -ra filename_elems <<< "$filename" + count=${#filename_elems[@]} + if [[ "$count" -gt 2 ]]; then + specific_product_version="${filename_elems[2]}" + else + specific_product_version=$specific_version + fi + unset IFS; + echo "$specific_product_version" + return 0 +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# specific_version - $4 +construct_legacy_download_link() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local specific_version="${4//[$'\t\r\n']}" + + local distro_specific_osname + distro_specific_osname="$(get_legacy_os_name)" || return 1 + + local legacy_download_link=null + if [[ "$runtime" == "dotnet" ]]; then + legacy_download_link="$azure_feed/Runtime/$specific_version/dotnet-$distro_specific_osname-$normalized_architecture.$specific_version.tar.gz" + elif [ -z "$runtime" ]; then + legacy_download_link="$azure_feed/Sdk/$specific_version/dotnet-dev-$distro_specific_osname-$normalized_architecture.$specific_version.tar.gz" + else + return 1 + fi + + echo "$legacy_download_link" + return 0 +} + +get_user_install_path() { + eval $invocation + + if [ ! -z "${DOTNET_INSTALL_DIR:-}" ]; then + echo "$DOTNET_INSTALL_DIR" + else + echo "$HOME/.dotnet" + fi + return 0 +} + +# args: +# install_dir - $1 +resolve_installation_path() { + eval $invocation + + local install_dir=$1 + if [ "$install_dir" = "" ]; then + local user_install_path="$(get_user_install_path)" + say_verbose "resolve_installation_path: user_install_path=$user_install_path" + echo "$user_install_path" + return 0 + fi + + echo "$install_dir" + return 0 +} + +# args: +# relative_or_absolute_path - $1 +get_absolute_path() { + eval $invocation + + local relative_or_absolute_path=$1 + echo "$(cd "$(dirname "$1")" && pwd -P)/$(basename "$1")" + return 0 +} + +# args: +# override - $1 (boolean, true or false) +get_cp_options() { + eval $invocation + + local override="$1" + local override_switch="" + + if [ "$override" = false ]; then + override_switch="-n" + + # create temporary files to check if 'cp -u' is supported + tmp_dir="$(mktemp -d)" + tmp_file="$tmp_dir/testfile" + tmp_file2="$tmp_dir/testfile2" + + touch "$tmp_file" + + # use -u instead of -n if it's available + if cp -u "$tmp_file" "$tmp_file2" 2>/dev/null; then + override_switch="-u" + fi + + # clean up + rm -f "$tmp_file" "$tmp_file2" + rm -rf "$tmp_dir" + fi + + echo "$override_switch" +} + +# args: +# input_files - stdin +# root_path - $1 +# out_path - $2 +# override - $3 +copy_files_or_dirs_from_list() { + eval $invocation + + local root_path="$(remove_trailing_slash "$1")" + local out_path="$(remove_trailing_slash "$2")" + local override="$3" + local override_switch="$(get_cp_options "$override")" + + cat | uniq | while read -r file_path; do + local path="$(remove_beginning_slash "${file_path#$root_path}")" + local target="$out_path/$path" + if [ "$override" = true ] || (! ([ -d "$target" ] || [ -e "$target" ])); then + mkdir -p "$out_path/$(dirname "$path")" + if [ -d "$target" ]; then + rm -rf "$target" + fi + cp -R $override_switch "$root_path/$path" "$target" + fi + done +} + +# args: +# zip_uri - $1 +get_remote_file_size() { + local zip_uri="$1" + + if machine_has "curl"; then + file_size=$(curl -sI "$zip_uri" | grep -i content-length | awk '{ num = $2 + 0; print num }') + elif machine_has "wget"; then + file_size=$(wget --spider --server-response -O /dev/null "$zip_uri" 2>&1 | grep -i 'Content-Length:' | awk '{ num = $2 + 0; print num }') + else + say "Neither curl nor wget is available on this system." + return + fi + + if [ -n "$file_size" ]; then + say "Remote file $zip_uri size is $file_size bytes." + echo "$file_size" + else + say_verbose "Content-Length header was not extracted for $zip_uri." + echo "" + fi +} + +# args: +# zip_path - $1 +# out_path - $2 +# remote_file_size - $3 +extract_dotnet_package() { + eval $invocation + + local zip_path="$1" + local out_path="$2" + local remote_file_size="$3" + + local temp_out_path="$(mktemp -d "$temporary_file_template")" + + local failed=false + tar -xzf "$zip_path" -C "$temp_out_path" > /dev/null || failed=true + + local folders_with_version_regex='^.*/[0-9]+\.[0-9]+[^/]+/' + find "$temp_out_path" -type f | grep -Eo "$folders_with_version_regex" | sort | copy_files_or_dirs_from_list "$temp_out_path" "$out_path" false + find "$temp_out_path" -type f | grep -Ev "$folders_with_version_regex" | copy_files_or_dirs_from_list "$temp_out_path" "$out_path" "$override_non_versioned_files" + + validate_remote_local_file_sizes "$zip_path" "$remote_file_size" + + rm -rf "$temp_out_path" + if [ -z ${keep_zip+x} ]; then + rm -f "$zip_path" && say_verbose "Temporary archive file $zip_path was removed" + fi + + if [ "$failed" = true ]; then + say_err "Extraction failed" + return 1 + fi + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header() +{ + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + + local failed=false + local response + if machine_has "curl"; then + get_http_header_curl $remote_path $disable_feed_credential || failed=true + elif machine_has "wget"; then + get_http_header_wget $remote_path $disable_feed_credential || failed=true + else + failed=true + fi + if [ "$failed" = true ]; then + say_verbose "Failed to get HTTP header: '$remote_path'." + return 1 + fi + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header_curl() { + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + + remote_path_with_credential="$remote_path" + if [ "$disable_feed_credential" = false ]; then + remote_path_with_credential+="$feed_credential" + fi + + curl_options="-I -sSL --retry 5 --retry-delay 2 --connect-timeout 15 " + curl $curl_options "$remote_path_with_credential" 2>&1 || return 1 + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header_wget() { + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + local wget_options="-q -S --spider --tries 5 " + + local wget_options_extra='' + + # Test for options that aren't supported on all wget implementations. + if [[ $(wget -h 2>&1 | grep -E 'waitretry|connect-timeout') ]]; then + wget_options_extra="--waitretry 2 --connect-timeout 15 " + else + say "wget extra options are unavailable for this environment" + fi + + remote_path_with_credential="$remote_path" + if [ "$disable_feed_credential" = false ]; then + remote_path_with_credential+="$feed_credential" + fi + + wget $wget_options $wget_options_extra "$remote_path_with_credential" 2>&1 + + return $? +} + +# args: +# remote_path - $1 +# [out_path] - $2 - stdout if not provided +download() { + eval $invocation + + local remote_path="$1" + local out_path="${2:-}" + + if [[ "$remote_path" != "http"* ]]; then + cp "$remote_path" "$out_path" + return $? + fi + + local failed=false + local attempts=0 + while [ $attempts -lt 3 ]; do + attempts=$((attempts+1)) + failed=false + if machine_has "curl"; then + downloadcurl "$remote_path" "$out_path" || failed=true + elif machine_has "wget"; then + downloadwget "$remote_path" "$out_path" || failed=true + else + say_err "Missing dependency: neither curl nor wget was found." + exit 1 + fi + + if [ "$failed" = false ] || [ $attempts -ge 3 ] || { [ ! -z $http_code ] && [ $http_code = "404" ]; }; then + break + fi + + say "Download attempt #$attempts has failed: $http_code $download_error_msg" + say "Attempt #$((attempts+1)) will start in $((attempts*10)) seconds." + sleep $((attempts*10)) + done + + if [ "$failed" = true ]; then + say_verbose "Download failed: $remote_path" + return 1 + fi + return 0 +} + +# Updates global variables $http_code and $download_error_msg +downloadcurl() { + eval $invocation + unset http_code + unset download_error_msg + local remote_path="$1" + local out_path="${2:-}" + # Append feed_credential as late as possible before calling curl to avoid logging feed_credential + # Avoid passing URI with credentials to functions: note, most of them echoing parameters of invocation in verbose output. + local remote_path_with_credential="${remote_path}${feed_credential}" + local curl_options="--retry 20 --retry-delay 2 --connect-timeout 15 -sSL -f --create-dirs " + local curl_exit_code=0; + if [ -z "$out_path" ]; then + curl $curl_options "$remote_path_with_credential" 2>&1 + curl_exit_code=$? + else + curl $curl_options -o "$out_path" "$remote_path_with_credential" 2>&1 + curl_exit_code=$? + fi + + if [ $curl_exit_code -gt 0 ]; then + download_error_msg="Unable to download $remote_path." + # Check for curl timeout codes + if [[ $curl_exit_code == 7 || $curl_exit_code == 28 ]]; then + download_error_msg+=" Failed to reach the server: connection timeout." + else + local disable_feed_credential=false + local response=$(get_http_header_curl $remote_path $disable_feed_credential) + http_code=$( echo "$response" | awk '/^HTTP/{print $2}' | tail -1 ) + if [[ ! -z $http_code && $http_code != 2* ]]; then + download_error_msg+=" Returned HTTP status code: $http_code." + fi + fi + say_verbose "$download_error_msg" + return 1 + fi + return 0 +} + + +# Updates global variables $http_code and $download_error_msg +downloadwget() { + eval $invocation + unset http_code + unset download_error_msg + local remote_path="$1" + local out_path="${2:-}" + # Append feed_credential as late as possible before calling wget to avoid logging feed_credential + local remote_path_with_credential="${remote_path}${feed_credential}" + local wget_options="--tries 20 " + + local wget_options_extra='' + local wget_result='' + + # Test for options that aren't supported on all wget implementations. + if [[ $(wget -h 2>&1 | grep -E 'waitretry|connect-timeout') ]]; then + wget_options_extra="--waitretry 2 --connect-timeout 15 " + else + say "wget extra options are unavailable for this environment" + fi + + if [ -z "$out_path" ]; then + wget -q $wget_options $wget_options_extra -O - "$remote_path_with_credential" 2>&1 + wget_result=$? + else + wget $wget_options $wget_options_extra -O "$out_path" "$remote_path_with_credential" 2>&1 + wget_result=$? + fi + + if [[ $wget_result != 0 ]]; then + local disable_feed_credential=false + local response=$(get_http_header_wget $remote_path $disable_feed_credential) + http_code=$( echo "$response" | awk '/^ HTTP/{print $2}' | tail -1 ) + download_error_msg="Unable to download $remote_path." + if [[ ! -z $http_code && $http_code != 2* ]]; then + download_error_msg+=" Returned HTTP status code: $http_code." + # wget exit code 4 stands for network-issue + elif [[ $wget_result == 4 ]]; then + download_error_msg+=" Failed to reach the server: connection timeout." + fi + say_verbose "$download_error_msg" + return 1 + fi + + return 0 +} + +extract_stem() { + local url="$1" + # extract the protocol + proto="$(echo $1 | grep :// | sed -e's,^\(.*://\).*,\1,g')" + # remove the protocol + url="${1/$proto/}" + # extract the path (if any) - since we know all of our feeds have a first path segment, we can skip the first one. otherwise we'd use -f2- to get the full path + full_path="$(echo $url | grep / | cut -d/ -f2-)" + path="$(echo $full_path | cut -d/ -f2-)" + echo $path +} + +check_url_exists() { + eval $invocation + local url="$1" + + local code="" + if machine_has "curl" + then + code=$(curl --head -o /dev/null -w "%{http_code}" -s --fail "$url"); + elif machine_has "wget" + then + # get the http response, grab the status code + server_response=$(wget -qO- --method=HEAD --server-response "$url" 2>&1) + code=$(echo "$server_response" | grep "HTTP/" | awk '{print $2}') + fi + if [ $code = "200" ]; then + return 0 + else + return 1 + fi +} + +sanitize_redirect_url() { + eval $invocation + + local url_stem + url_stem=$(extract_stem "$1") + say_verbose "Checking configured feeds for the asset at ${yellow:-}$url_stem${normal:-}" + + for feed in "${feeds[@]}" + do + local trial_url="$feed/$url_stem" + say_verbose "Checking ${yellow:-}$trial_url${normal:-}" + if check_url_exists "$trial_url"; then + say_verbose "Found a match at ${yellow:-}$trial_url${normal:-}" + echo "$trial_url" + return 0 + else + say_verbose "No match at ${yellow:-}$trial_url${normal:-}" + fi + done + return 1 +} + +get_download_link_from_aka_ms() { + eval $invocation + + #quality is not supported for LTS or STS channel + #STS maps to current + if [[ ! -z "$normalized_quality" && ("$normalized_channel" == "LTS" || "$normalized_channel" == "STS") ]]; then + normalized_quality="" + say_warning "Specifying quality for STS or LTS channel is not supported, the quality will be ignored." + fi + + say_verbose "Retrieving primary payload URL from aka.ms for channel: '$normalized_channel', quality: '$normalized_quality', product: '$normalized_product', os: '$normalized_os', architecture: '$normalized_architecture'." + + #construct aka.ms link + aka_ms_link="https://aka.ms/dotnet" + if [ "$internal" = true ]; then + aka_ms_link="$aka_ms_link/internal" + fi + aka_ms_link="$aka_ms_link/$normalized_channel" + if [[ ! -z "$normalized_quality" ]]; then + aka_ms_link="$aka_ms_link/$normalized_quality" + fi + aka_ms_link="$aka_ms_link/$normalized_product-$normalized_os-$normalized_architecture.tar.gz" + say_verbose "Constructed aka.ms link: '$aka_ms_link'." + + #get HTTP response + #do not pass credentials as a part of the $aka_ms_link and do not apply credentials in the get_http_header function + #otherwise the redirect link would have credentials as well + #it would result in applying credentials twice to the resulting link and thus breaking it, and in echoing credentials to the output as a part of redirect link + disable_feed_credential=true + response="$(get_http_header $aka_ms_link $disable_feed_credential)" + + say_verbose "Received response: $response" + # Get results of all the redirects. + http_codes=$( echo "$response" | awk '$1 ~ /^HTTP/ {print $2}' ) + # They all need to be 301, otherwise some links are broken (except for the last, which is not a redirect but 200 or 404). + broken_redirects=$( echo "$http_codes" | sed '$d' | grep -v '301' ) + # The response may end without final code 2xx/4xx/5xx somehow, e.g. network restrictions on www.bing.com causes redirecting to bing.com fails with connection refused. + # In this case it should not exclude the last. + last_http_code=$( echo "$http_codes" | tail -n 1 ) + if ! [[ $last_http_code =~ ^(2|4|5)[0-9][0-9]$ ]]; then + broken_redirects=$( echo "$http_codes" | grep -v '301' ) + fi + + # All HTTP codes are 301 (Moved Permanently), the redirect link exists. + if [[ -z "$broken_redirects" ]]; then + aka_ms_download_link=$( echo "$response" | awk '$1 ~ /^Location/{print $2}' | tail -1 | tr -d '\r') + + if [[ -z "$aka_ms_download_link" ]]; then + say_verbose "The aka.ms link '$aka_ms_link' is not valid: failed to get redirect location." + return 1 + fi + + sanitized_redirect_url=$(sanitize_redirect_url "$aka_ms_download_link") + if [[ -n "$sanitized_redirect_url" ]]; then + aka_ms_download_link="$sanitized_redirect_url" + fi + + say_verbose "The redirect location retrieved: '$aka_ms_download_link'." + return 0 + else + say_verbose "The aka.ms link '$aka_ms_link' is not valid: received HTTP code: $(echo "$broken_redirects" | paste -sd "," -)." + return 1 + fi +} + +get_feeds_to_use() +{ + feeds=( + "https://builds.dotnet.microsoft.com/dotnet" + "https://dotnetcli.azureedge.net/dotnet" + "https://ci.dot.net/public" + "https://dotnetbuilds.azureedge.net/public" + ) + + if [[ -n "$azure_feed" ]]; then + feeds=("$azure_feed") + fi + + if [[ "$no_cdn" == "true" ]]; then + feeds=( + "https://dotnetcli.blob.core.windows.net/dotnet" + "https://dotnetbuilds.blob.core.windows.net/public" + ) + + if [[ -n "$uncached_feed" ]]; then + feeds=("$uncached_feed") + fi + fi +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed). +generate_download_links() { + + download_links=() + specific_versions=() + effective_versions=() + link_types=() + + # If generate_akams_links returns false, no fallback to old links. Just terminate. + # This function may also 'exit' (if the determined version is already installed). + generate_akams_links || return + + # Check other feeds only if we haven't been able to find an aka.ms link. + if [[ "${#download_links[@]}" -lt 1 ]]; then + for feed in ${feeds[@]} + do + # generate_regular_links may also 'exit' (if the determined version is already installed). + generate_regular_links $feed || return + done + fi + + if [[ "${#download_links[@]}" -eq 0 ]]; then + say_err "Failed to resolve the exact version number." + return 1 + fi + + say_verbose "Generated ${#download_links[@]} links." + for link_index in ${!download_links[@]} + do + say_verbose "Link $link_index: ${link_types[$link_index]}, ${effective_versions[$link_index]}, ${download_links[$link_index]}" + done +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed). +generate_akams_links() { + local valid_aka_ms_link=true; + + normalized_version="$(to_lowercase "$version")" + if [[ "$normalized_version" != "latest" ]] && [ -n "$normalized_quality" ]; then + say_err "Quality and Version options are not allowed to be specified simultaneously. See https://learn.microsoft.com/dotnet/core/tools/dotnet-install-script#options for details." + return 1 + fi + + if [[ -n "$json_file" || "$normalized_version" != "latest" ]]; then + # aka.ms links are not needed when exact version is specified via command or json file + return + fi + + get_download_link_from_aka_ms || valid_aka_ms_link=false + + if [[ "$valid_aka_ms_link" == true ]]; then + say_verbose "Retrieved primary payload URL from aka.ms link: '$aka_ms_download_link'." + say_verbose "Downloading using legacy url will not be attempted." + + download_link=$aka_ms_download_link + + #get version from the path + IFS='/' + read -ra pathElems <<< "$download_link" + count=${#pathElems[@]} + specific_version="${pathElems[count-2]}" + unset IFS; + say_verbose "Version: '$specific_version'." + + #Retrieve effective version + effective_version="$(get_specific_product_version "$azure_feed" "$specific_version" "$download_link")" + + # Add link info to arrays + download_links+=($download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("aka.ms") + + # Check if the SDK version is already installed. + if [[ "$dry_run" != true ]] && is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "$asset_name with version '$effective_version' is already installed." + exit 0 + fi + + return 0 + fi + + # if quality is specified - exit with error - there is no fallback approach + if [ ! -z "$normalized_quality" ]; then + say_err "Failed to locate the latest version in the channel '$normalized_channel' with '$normalized_quality' quality for '$normalized_product', os: '$normalized_os', architecture: '$normalized_architecture'." + say_err "Refer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support." + return 1 + fi + say_verbose "Falling back to latest.version file approach." +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed) +# args: +# feed - $1 +generate_regular_links() { + local feed="$1" + local valid_legacy_download_link=true + + specific_version=$(get_specific_version_from_version "$feed" "$channel" "$normalized_architecture" "$version" "$json_file") || specific_version='0' + + if [[ "$specific_version" == '0' ]]; then + say_verbose "Failed to resolve the specific version number using feed '$feed'" + return + fi + + effective_version="$(get_specific_product_version "$feed" "$specific_version")" + say_verbose "specific_version=$specific_version" + + download_link="$(construct_download_link "$feed" "$channel" "$normalized_architecture" "$specific_version" "$normalized_os")" + say_verbose "Constructed primary named payload URL: $download_link" + + # Add link info to arrays + download_links+=($download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("primary") + + legacy_download_link="$(construct_legacy_download_link "$feed" "$channel" "$normalized_architecture" "$specific_version")" || valid_legacy_download_link=false + + if [ "$valid_legacy_download_link" = true ]; then + say_verbose "Constructed legacy named payload URL: $legacy_download_link" + + download_links+=($legacy_download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("legacy") + else + legacy_download_link="" + say_verbose "Cound not construct a legacy_download_link; omitting..." + fi + + # Check if the SDK version is already installed. + if [[ "$dry_run" != true ]] && is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "$asset_name with version '$effective_version' is already installed." + exit 0 + fi +} + +print_dry_run() { + + say "Payload URLs:" + + for link_index in "${!download_links[@]}" + do + say "URL #$link_index - ${link_types[$link_index]}: ${download_links[$link_index]}" + done + + resolved_version=${specific_versions[0]} + repeatable_command="./$script_name --version "\""$resolved_version"\"" --install-dir "\""$install_root"\"" --architecture "\""$normalized_architecture"\"" --os "\""$normalized_os"\""" + + if [ ! -z "$normalized_quality" ]; then + repeatable_command+=" --quality "\""$normalized_quality"\""" + fi + + if [[ "$runtime" == "dotnet" ]]; then + repeatable_command+=" --runtime "\""dotnet"\""" + elif [[ "$runtime" == "aspnetcore" ]]; then + repeatable_command+=" --runtime "\""aspnetcore"\""" + fi + + repeatable_command+="$non_dynamic_parameters" + + if [ -n "$feed_credential" ]; then + repeatable_command+=" --feed-credential "\"""\""" + fi + + say "Repeatable invocation: $repeatable_command" +} + +calculate_vars() { + eval $invocation + + script_name=$(basename "$0") + normalized_architecture="$(get_normalized_architecture_from_architecture "$architecture")" + say_verbose "Normalized architecture: '$normalized_architecture'." + normalized_os="$(get_normalized_os "$user_defined_os")" + say_verbose "Normalized OS: '$normalized_os'." + normalized_quality="$(get_normalized_quality "$quality")" + say_verbose "Normalized quality: '$normalized_quality'." + normalized_channel="$(get_normalized_channel "$channel")" + say_verbose "Normalized channel: '$normalized_channel'." + normalized_product="$(get_normalized_product "$runtime")" + say_verbose "Normalized product: '$normalized_product'." + install_root="$(resolve_installation_path "$install_dir")" + say_verbose "InstallRoot: '$install_root'." + + normalized_architecture="$(get_normalized_architecture_for_specific_sdk_version "$version" "$normalized_channel" "$normalized_architecture")" + + if [[ "$runtime" == "dotnet" ]]; then + asset_relative_path="shared/Microsoft.NETCore.App" + asset_name=".NET Core Runtime" + elif [[ "$runtime" == "aspnetcore" ]]; then + asset_relative_path="shared/Microsoft.AspNetCore.App" + asset_name="ASP.NET Core Runtime" + elif [ -z "$runtime" ]; then + asset_relative_path="sdk" + asset_name=".NET Core SDK" + fi + + get_feeds_to_use +} + +install_dotnet() { + eval $invocation + local download_failed=false + local download_completed=false + local remote_file_size=0 + + mkdir -p "$install_root" + zip_path="${zip_path:-$(mktemp "$temporary_file_template")}" + say_verbose "Archive path: $zip_path" + + for link_index in "${!download_links[@]}" + do + download_link="${download_links[$link_index]}" + specific_version="${specific_versions[$link_index]}" + effective_version="${effective_versions[$link_index]}" + link_type="${link_types[$link_index]}" + + say "Attempting to download using $link_type link $download_link" + + # The download function will set variables $http_code and $download_error_msg in case of failure. + download_failed=false + download "$download_link" "$zip_path" 2>&1 || download_failed=true + + if [ "$download_failed" = true ]; then + case $http_code in + 404) + say "The resource at $link_type link '$download_link' is not available." + ;; + *) + say "Failed to download $link_type link '$download_link': $download_error_msg" + ;; + esac + rm -f "$zip_path" 2>&1 && say_verbose "Temporary archive file $zip_path was removed" + else + download_completed=true + break + fi + done + + if [[ "$download_completed" == false ]]; then + say_err "Could not find \`$asset_name\` with version = $specific_version" + say_err "Refer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support" + return 1 + fi + + remote_file_size="$(get_remote_file_size "$download_link")" + + say "Extracting archive from $download_link" + extract_dotnet_package "$zip_path" "$install_root" "$remote_file_size" || return 1 + + # Check if the SDK version is installed; if not, fail the installation. + # if the version contains "RTM" or "servicing"; check if a 'release-type' SDK version is installed. + if [[ $specific_version == *"rtm"* || $specific_version == *"servicing"* ]]; then + IFS='-' + read -ra verArr <<< "$specific_version" + release_version="${verArr[0]}" + unset IFS; + say_verbose "Checking installation: version = $release_version" + if is_dotnet_package_installed "$install_root" "$asset_relative_path" "$release_version"; then + say "Installed version is $effective_version" + return 0 + fi + fi + + # Check if the standard SDK version is installed. + say_verbose "Checking installation: version = $effective_version" + if is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "Installed version is $effective_version" + return 0 + fi + + # Version verification failed. More likely something is wrong either with the downloaded content or with the verification algorithm. + say_err "Failed to verify the version of installed \`$asset_name\`.\nInstallation source: $download_link.\nInstallation location: $install_root.\nReport the bug at https://github.com/dotnet/install-scripts/issues." + say_err "\`$asset_name\` with version = $effective_version failed to install with an error." + return 1 +} + +args=("$@") + +local_version_file_relative_path="/.version" +bin_folder_relative_path="" +temporary_file_template="${TMPDIR:-/tmp}/dotnet.XXXXXXXXX" + +channel="LTS" +version="Latest" +json_file="" +install_dir="" +architecture="" +dry_run=false +no_path=false +no_cdn=false +azure_feed="" +uncached_feed="" +feed_credential="" +verbose=false +runtime="" +runtime_id="" +quality="" +internal=false +override_non_versioned_files=true +non_dynamic_parameters="" +user_defined_os="" + +while [ $# -ne 0 ] +do + name="$1" + case "$name" in + -c|--channel|-[Cc]hannel) + shift + channel="$1" + ;; + -v|--version|-[Vv]ersion) + shift + version="$1" + ;; + -q|--quality|-[Qq]uality) + shift + quality="$1" + ;; + --internal|-[Ii]nternal) + internal=true + non_dynamic_parameters+=" $name" + ;; + -i|--install-dir|-[Ii]nstall[Dd]ir) + shift + install_dir="$1" + ;; + --arch|--architecture|-[Aa]rch|-[Aa]rchitecture) + shift + architecture="$1" + ;; + --os|-[Oo][SS]) + shift + user_defined_os="$1" + ;; + --shared-runtime|-[Ss]hared[Rr]untime) + say_warning "The --shared-runtime flag is obsolete and may be removed in a future version of this script. The recommended usage is to specify '--runtime dotnet'." + if [ -z "$runtime" ]; then + runtime="dotnet" + fi + ;; + --runtime|-[Rr]untime) + shift + runtime="$1" + if [[ "$runtime" != "dotnet" ]] && [[ "$runtime" != "aspnetcore" ]]; then + say_err "Unsupported value for --runtime: '$1'. Valid values are 'dotnet' and 'aspnetcore'." + if [[ "$runtime" == "windowsdesktop" ]]; then + say_err "WindowsDesktop archives are manufactured for Windows platforms only." + fi + exit 1 + fi + ;; + --dry-run|-[Dd]ry[Rr]un) + dry_run=true + ;; + --no-path|-[Nn]o[Pp]ath) + no_path=true + non_dynamic_parameters+=" $name" + ;; + --verbose|-[Vv]erbose) + verbose=true + non_dynamic_parameters+=" $name" + ;; + --no-cdn|-[Nn]o[Cc]dn) + no_cdn=true + non_dynamic_parameters+=" $name" + ;; + --azure-feed|-[Aa]zure[Ff]eed) + shift + azure_feed="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + ;; + --uncached-feed|-[Uu]ncached[Ff]eed) + shift + uncached_feed="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + ;; + --feed-credential|-[Ff]eed[Cc]redential) + shift + feed_credential="$1" + #feed_credential should start with "?", for it to be added to the end of the link. + #adding "?" at the beginning of the feed_credential if needed. + [[ -z "$(echo $feed_credential)" ]] || [[ $feed_credential == \?* ]] || feed_credential="?$feed_credential" + ;; + --runtime-id|-[Rr]untime[Ii]d) + shift + runtime_id="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + say_warning "Use of --runtime-id is obsolete and should be limited to the versions below 2.1. To override architecture, use --architecture option instead. To override OS, use --os option instead." + ;; + --jsonfile|-[Jj][Ss]on[Ff]ile) + shift + json_file="$1" + ;; + --skip-non-versioned-files|-[Ss]kip[Nn]on[Vv]ersioned[Ff]iles) + override_non_versioned_files=false + non_dynamic_parameters+=" $name" + ;; + --keep-zip|-[Kk]eep[Zz]ip) + keep_zip=true + non_dynamic_parameters+=" $name" + ;; + --zip-path|-[Zz]ip[Pp]ath) + shift + zip_path="$1" + ;; + -?|--?|-h|--help|-[Hh]elp) + script_name="dotnet-install.sh" + echo ".NET Tools Installer" + echo "Usage:" + echo " # Install a .NET SDK of a given Quality from a given Channel" + echo " $script_name [-c|--channel ] [-q|--quality ]" + echo " # Install a .NET SDK of a specific public version" + echo " $script_name [-v|--version ]" + echo " $script_name -h|-?|--help" + echo "" + echo "$script_name is a simple command line interface for obtaining dotnet cli." + echo " Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" + echo " - The SDK needs to be installed without user interaction and without admin rights." + echo " - The SDK installation doesn't need to persist across multiple CI runs." + echo " To set up a development environment or to run apps, use installers rather than this script. Visit https://dotnet.microsoft.com/download to get the installer." + echo "" + echo "Options:" + echo " -c,--channel Download from the channel specified, Defaults to \`$channel\`." + echo " -Channel" + echo " Possible values:" + echo " - STS - the most recent Standard Term Support release" + echo " - LTS - the most recent Long Term Support release" + echo " - 2-part version in a format A.B - represents a specific release" + echo " examples: 2.0; 1.0" + echo " - 3-part version in a format A.B.Cxx - represents a specific SDK release" + echo " examples: 5.0.1xx, 5.0.2xx." + echo " Supported since 5.0 release" + echo " Warning: Value 'Current' is deprecated for the Channel parameter. Use 'STS' instead." + echo " Note: The version parameter overrides the channel parameter when any version other than 'latest' is used." + echo " -v,--version Use specific VERSION, Defaults to \`$version\`." + echo " -Version" + echo " Possible values:" + echo " - latest - the latest build on specific channel" + echo " - 3-part version in a format A.B.C - represents specific version of build" + echo " examples: 2.0.0-preview2-006120; 1.1.0" + echo " -q,--quality Download the latest build of specified quality in the channel." + echo " -Quality" + echo " The possible values are: daily, signed, validated, preview, GA." + echo " Works only in combination with channel. Not applicable for STS and LTS channels and will be ignored if those channels are used." + echo " For SDK use channel in A.B.Cxx format. Using quality for SDK together with channel in A.B format is not supported." + echo " Supported since 5.0 release." + echo " Note: The version parameter overrides the channel parameter when any version other than 'latest' is used, and therefore overrides the quality." + echo " --internal,-Internal Download internal builds. Requires providing credentials via --feed-credential parameter." + echo " --feed-credential Token to access Azure feed. Used as a query string to append to the Azure feed." + echo " -FeedCredential This parameter typically is not specified." + echo " -i,--install-dir Install under specified location (see Install Location below)" + echo " -InstallDir" + echo " --architecture Architecture of dotnet binaries to be installed, Defaults to \`$architecture\`." + echo " --arch,-Architecture,-Arch" + echo " Possible values: x64, arm, arm64, s390x, ppc64le and loongarch64" + echo " --os Specifies operating system to be used when selecting the installer." + echo " Overrides the OS determination approach used by the script. Supported values: osx, linux, linux-musl, freebsd, rhel.6." + echo " In case any other value is provided, the platform will be determined by the script based on machine configuration." + echo " Not supported for legacy links. Use --runtime-id to specify platform for legacy links." + echo " Refer to: https://aka.ms/dotnet-os-lifecycle for more information." + echo " --runtime Installs a shared runtime only, without the SDK." + echo " -Runtime" + echo " Possible values:" + echo " - dotnet - the Microsoft.NETCore.App shared runtime" + echo " - aspnetcore - the Microsoft.AspNetCore.App shared runtime" + echo " --dry-run,-DryRun Do not perform installation. Display download link." + echo " --no-path, -NoPath Do not set PATH for the current process." + echo " --verbose,-Verbose Display diagnostics information." + echo " --azure-feed,-AzureFeed For internal use only." + echo " Allows using a different storage to download SDK archives from." + echo " This parameter is only used if --no-cdn is false." + echo " --uncached-feed,-UncachedFeed For internal use only." + echo " Allows using a different storage to download SDK archives from." + echo " This parameter is only used if --no-cdn is true." + echo " --skip-non-versioned-files Skips non-versioned files if they already exist, such as the dotnet executable." + echo " -SkipNonVersionedFiles" + echo " --no-cdn,-NoCdn Disable downloading from the Azure CDN, and use the uncached feed directly." + echo " --jsonfile Determines the SDK version from a user specified global.json file." + echo " Note: global.json must have a value for 'SDK:Version'" + echo " --keep-zip,-KeepZip If set, downloaded file is kept." + echo " --zip-path, -ZipPath If set, downloaded file is stored at the specified path." + echo " -?,--?,-h,--help,-Help Shows this help message" + echo "" + echo "Install Location:" + echo " Location is chosen in following order:" + echo " - --install-dir option" + echo " - Environmental variable DOTNET_INSTALL_DIR" + echo " - $HOME/.dotnet" + exit 0 + ;; + *) + say_err "Unknown argument \`$name\`" + exit 1 + ;; + esac + + shift +done + +say_verbose "Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" +say_verbose "- The SDK needs to be installed without user interaction and without admin rights." +say_verbose "- The SDK installation doesn't need to persist across multiple CI runs." +say_verbose "To set up a development environment or to run apps, use installers rather than this script. Visit https://dotnet.microsoft.com/download to get the installer.\n" + +if [ "$internal" = true ] && [ -z "$(echo $feed_credential)" ]; then + message="Provide credentials via --feed-credential parameter." + if [ "$dry_run" = true ]; then + say_warning "$message" + else + say_err "$message" + exit 1 + fi +fi + +check_min_reqs +calculate_vars +# generate_regular_links call below will 'exit' if the determined version is already installed. +generate_download_links + +if [[ "$dry_run" = true ]]; then + print_dry_run + exit 0 +fi + +install_dotnet + +bin_path="$(get_absolute_path "$(combine_paths "$install_root" "$bin_folder_relative_path")")" +if [ "$no_path" = false ]; then + say "Adding to current process PATH: \`$bin_path\`. Note: This change will be visible only when sourcing script." + export PATH="$bin_path":"$PATH" +else + say "Binaries of dotnet can be found in $bin_path" +fi + +say "Note that the script does not resolve dependencies during installation." +say "To check the list of dependencies, go to https://learn.microsoft.com/dotnet/core/install, select your operating system and check the \"Dependencies\" section." +say "Installation finished successfully." \ No newline at end of file diff --git a/src/php/NOTES.md b/src/php/NOTES.md new file mode 100644 index 000000000..19fe92f31 --- /dev/null +++ b/src/php/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/php/README.md b/src/php/README.md index 0f85132b4..14f4a1797 100644 --- a/src/php/README.md +++ b/src/php/README.md @@ -7,9 +7,7 @@ ```json "features": { - "ghcr.io/devcontainers/features/php:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/php:1": {} } ``` @@ -20,6 +18,22 @@ | version | Select or enter a PHP version | string | latest | | installComposer | Install PHP Composer? | boolean | true | +## Customizations + +### VS Code Extensions + +- `xdebug.php-debug` +- `bmewburn.vscode-intelephense-client` +- `xdebug.php-pack` +- `devsense.phptools-vscode` + + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/php/devcontainer-feature.json b/src/php/devcontainer-feature.json index 418162fe7..6abdc965b 100644 --- a/src/php/devcontainer-feature.json +++ b/src/php/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "php", - "version": "1.0.6", + "version": "1.1.5", "name": "PHP", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/php", "options": { @@ -8,7 +8,10 @@ "type": "string", "proposals": [ "latest", - "8.0.16" + "8", + "8.5", + "8.5.0", + "none" ], "default": "latest", "description": "Select or enter a PHP version" @@ -26,11 +29,21 @@ "bmewburn.vscode-intelephense-client", "xdebug.php-pack", "devsense.phptools-vscode" - ] + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes PHP pre-installed and available on the `PATH`, along with PHP language extensions for PHP development." + } + ] + } } }, "containerEnv": { "PHP_PATH": "/usr/local/php/current", "PATH": "/usr/local/php/current/bin:${PATH}" - } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/php/install.sh b/src/php/install.sh index fdc374acb..531b16518 100755 --- a/src/php/install.sh +++ b/src/php/install.sh @@ -8,17 +8,20 @@ set -eux -VERSION=${VERSION:-"latest"} -INSTALL_COMPOSER=${INSTALLCOMPOSER:-"true"} -OVERRIDE_DEFAULT_VERSION=${OVERRIDEDEFAULTVERSION:-"true"} +# Clean up +rm -rf /var/lib/apt/lists/* -export PHP_DIR=${PHP_DIR:-"/usr/local/php"} -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} +PHP_VERSION="${VERSION:-"latest"}" +INSTALL_COMPOSER="${INSTALLCOMPOSER:-"true"}" +OVERRIDE_DEFAULT_VERSION="${OVERRIDEDEFAULTVERSION:-"true"}" + +export PHP_DIR="${PHP_DIR:-"/usr/local/php"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +UPDATE_RC="${UPDATE_RC:-"true"}" # Comma-separated list of php versions to be installed -# alongside VERSION, but not set as default. -ADDITIONAL_VERSIONS=${ADDITIONALVERSIONS:-""} +# alongside PHP_VERSION, but not set as default. +ADDITIONAL_VERSIONS="${ADDITIONALVERSIONS:-""}" export DEBIAN_FRONTEND=noninteractive @@ -73,59 +76,129 @@ updaterc() { # Checks if packages are installed and installs them if not check_packages() { if ! dpkg -s "$@" > /dev/null 2>&1; then - apt-get update + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi apt-get -y install --no-install-recommends "$@" fi } -# Figure out correct version of PHP +# Figure out correct version of a three part version number is not passed find_version_from_git_tags() { - local repository="https://github.com/php/php-src" - local separator="." - local escaped_separator=${separator//./\\.} - local last_part="${escaped_separator}[0-9]+" - local regex="\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" - local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" - VERSION="$(echo "${version_list}" | head -n 1)" + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + echo "${!variable_name}" + echo "$(echo "${requested_version}" | grep -o "." | wc -l)" + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + local escaped_separator=${separator//./\\.} + local last_part + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + fi + echo "${!variable_name}" + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then + echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + echo "${variable_name}=${!variable_name}" +} + +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e } # Install PHP Composer addcomposer() { - "${PHP_INSTALL_DIR}/bin/php" -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" + "${PHP_SRC}" -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" HASH="$(wget -q -O - https://composer.github.io/installer.sig)" - "${PHP_INSTALL_DIR}/bin/php" -r "if (hash_file('sha384', 'composer-setup.php') === '$HASH') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" - "${PHP_INSTALL_DIR}/bin/php" composer-setup.php - "${PHP_INSTALL_DIR}/bin/php" -r "unlink('composer-setup.php');" - - mv composer.phar "${PHP_INSTALL_DIR}/bin/composer" + "${PHP_SRC}" -r "if (hash_file('sha384', 'composer-setup.php') === '$HASH') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" + "${PHP_SRC}" composer-setup.php --install-dir="/usr/local/bin" --filename=composer + "${PHP_SRC}" -r "unlink('composer-setup.php');" } -# Install PHP if it's missing - - -# Persistent / runtime dependencies -RUNTIME_DEPS="wget ca-certificates git build-essential xz-utils" - -# PHP dependencies -PHP_DEPS="libssl-dev libcurl4-openssl-dev libedit-dev libsqlite3-dev libxml2-dev zlib1g-dev libsodium-dev libargon2-dev libonig-dev" - -# Dependencies required for running "phpize" -PHPIZE_DEPS="autoconf dpkg-dev file g++ gcc libc-dev make pkg-config re2c" - -# Install dependencies -check_packages $RUNTIME_DEPS $PHP_DEPS $PHPIZE_DEPS - -install_php() { - VERSION="$1" - - # Fetch latest version of PHP if needed - if [ "${VERSION}" = "latest" ] || [ "${VERSION}" = "lts" ]; then - find_version_from_git_tags - fi +# Build xdebug from its official source tarball. Used as a fallback when +# pecl.php.net is broken or has no release advertising compatibility with +# the current PHP version (common around new PHP releases). +install_xdebug_from_source() { + XDEBUG_VERSION="latest" + find_version_from_git_tags XDEBUG_VERSION https://github.com/xdebug/xdebug "tags/" + + local xdebug_src_dir="/tmp/xdebug-src" + rm -rf "${xdebug_src_dir}" + mkdir -p "${xdebug_src_dir}" + + wget -O /tmp/xdebug.tgz "https://xdebug.org/files/xdebug-${XDEBUG_VERSION}.tgz" + tar -xzf /tmp/xdebug.tgz -C "${xdebug_src_dir}" --strip-components=1 + + ( + cd "${xdebug_src_dir}" + "${PHP_INSTALL_DIR}/bin/phpize" + ./configure --enable-xdebug --with-php-config="${PHP_INSTALL_DIR}/bin/php-config" + make -j "$(nproc)" + make install + ) + + rm -rf "${xdebug_src_dir}" /tmp/xdebug.tgz +} - PHP_INSTALL_DIR="${PHP_DIR}/${VERSION}" +init_php_install() { + PHP_INSTALL_DIR="${PHP_DIR}/${PHP_VERSION}" if [ -d "${PHP_INSTALL_DIR}" ]; then - echo "(!) PHP version ${VERSION} already exists." + echo "(!) PHP version ${PHP_VERSION} already exists." exit 1 fi @@ -133,8 +206,7 @@ install_php() { groupadd -r php fi usermod -a -G php "${USERNAME}" - - PHP_URL="https://www.php.net/distributions/php-${VERSION}.tar.gz" + PHP_URL="https://www.php.net/distributions/php-${PHP_VERSION}.tar.gz" PHP_INI_DIR="${PHP_INSTALL_DIR}/ini" CONF_DIR="${PHP_INI_DIR}/conf.d" @@ -146,15 +218,36 @@ install_php() { PHP_SRC_DIR="/usr/src/php" mkdir -p $PHP_SRC_DIR cd $PHP_SRC_DIR - wget -O php.tar.xz "$PHP_URL" +} + +install_previous_version() { + PHP_VERSION=$1 + if [[ "$ORIGINAL_PHP_VERSION" == "latest" ]]; then + find_prev_version_from_git_tags PHP_VERSION https://github.com/php/php-src "tags/php-" + echo -e "\nAttempting to install previous version v${PHP_VERSION}" + init_php_install + wget -O php.tar.xz "$PHP_URL" + else + echo -e "\nFailed to install v$PHP_VERSION" + fi +} + +install_php() { + PHP_VERSION="$1" + + init_php_install + + wget -O php.tar.xz "$PHP_URL" || install_previous_version "$PHP_VERSION" tar -xf $PHP_SRC_DIR/php.tar.xz -C "$PHP_SRC_DIR" --strip-components=1 cd $PHP_SRC_DIR; # PHP 7.4+, the pecl/pear installers are officially deprecated and are removed in PHP 8+ # Thus, requiring an explicit "--with-pear" + OLDIFS=$IFS IFS="." - read -a versions <<< "${VERSION}" + read -a versions <<< "${PHP_VERSION}" + IFS=$OLDIFS PHP_MAJOR_VERSION=${versions[0]} PHP_MINOR_VERSION=${versions[1]} @@ -163,7 +256,7 @@ install_php() { VERSION_CONFIG="--with-pear" fi - ./configure --prefix="${PHP_INSTALL_DIR}" --with-config-file-path="$PHP_INI_DIR" --with-config-file-scan-dir="$CONF_DIR" --enable-option-checking=fatal --with-curl --with-libedit --with-openssl --with-zlib --with-password-argon2 --with-sodium=shared "$VERSION_CONFIG" EXTENSION_DIR="$PHP_EXT_DIR"; + ./configure --prefix="${PHP_INSTALL_DIR}" --with-config-file-path="$PHP_INI_DIR" --with-config-file-scan-dir="$CONF_DIR" --enable-option-checking=fatal --with-curl --with-libedit --enable-mbstring --with-openssl --with-zlib --with-password-argon2 --with-sodium=shared "$VERSION_CONFIG" EXTENSION_DIR="$PHP_EXT_DIR"; make -j "$(nproc)" find -type f -name '*.a' -delete @@ -174,20 +267,77 @@ install_php() { cp -v $PHP_SRC_DIR/php.ini-* "$PHP_INI_DIR/"; cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" - # Install xdebug - "${PHP_INSTALL_DIR}/bin/pecl" install xdebug + # Install xdebug. Try PECL first (fast path), then fall back to building + # from source if PECL's channel cache is broken or no release advertises + # compatibility with the current PHP version. + "${PHP_INSTALL_DIR}/bin/pecl" channel-update pecl.php.net || true + if ! "${PHP_INSTALL_DIR}/bin/pecl" install xdebug; then + install_xdebug_from_source + fi XDEBUG_INI="${CONF_DIR}/xdebug.ini" echo "zend_extension=${PHP_EXT_DIR}/xdebug.so" > "${XDEBUG_INI}" echo "xdebug.mode = debug" >> "${XDEBUG_INI}" echo "xdebug.start_with_request = yes" >> "${XDEBUG_INI}" echo "xdebug.client_port = 9003" >> "${XDEBUG_INI}" +} + +if [ "${PHP_VERSION}" != "none" ]; then + # Persistent / runtime dependencies + RUNTIME_DEPS="wget ca-certificates git build-essential xz-utils curl" + + # PHP dependencies + PHP_DEPS="libssl-dev libcurl4-openssl-dev libedit-dev libsqlite3-dev libxml2-dev zlib1g-dev libsodium-dev libonig-dev" - # Install PHP Composer if needed - if [[ "${INSTALL_COMPOSER}" = "true" ]] || [[ $(composer --version) = "" ]]; then - addcomposer + . /etc/os-release + + if [ "${VERSION_CODENAME}" = "bionic" ]; then + PHP_DEPS="${PHP_DEPS} libargon2-0-dev" + else + PHP_DEPS="${PHP_DEPS} libargon2-dev" + fi + + # Dependencies required for running "phpize" + PHPIZE_DEPS="autoconf dpkg-dev file g++ gcc libc-dev make pkg-config re2c" + + # Install dependencies + check_packages $RUNTIME_DEPS $PHP_DEPS $PHPIZE_DEPS + + # storing value of PHP_VERSION before it changes + ORIGINAL_PHP_VERSION=$PHP_VERSION + find_version_from_git_tags PHP_VERSION https://github.com/php/php-src "tags/php-" + install_php "${PHP_VERSION}" + + PHP_SRC="${PHP_INSTALL_DIR}/bin/php" +else + set +e + PHP_SRC=$(which php) + set -e +fi + +# Install PHP Composer if needed +if [[ "${INSTALL_COMPOSER}" = "true" ]]; then + if [ -z "${PHP_SRC}" ]; then + echo "(!) Could not install Composer. PHP not found." + exit 1 fi + addcomposer +fi + +# Additional php versions to be installed but not be set as default. +if [ ! -z "${ADDITIONAL_VERSIONS}" ]; then + OLDIFS=$IFS + IFS="," + read -a additional_versions <<< "$ADDITIONAL_VERSIONS" + for version in "${additional_versions[@]}"; do + OVERRIDE_DEFAULT_VERSION="false" + install_php "${version}" + done + IFS=$OLDIFS +fi + +if [ "${PHP_VERSION}" != "none" ]; then CURRENT_DIR="${PHP_DIR}/current" if [[ ! -d "${CURRENT_DIR}" ]]; then ln -s -r "${PHP_INSTALL_DIR}" ${CURRENT_DIR} @@ -202,24 +352,13 @@ install_php() { rm -rf "${PHP_SRC_DIR}" updaterc "if [[ \"\${PATH}\" != *\"${CURRENT_DIR}\"* ]]; then export PATH=\"${CURRENT_DIR}/bin:\${PATH}\"; fi" -} - -install_php "${VERSION}" -# Additional php versions to be installed but not be set as default. -if [ ! -z "${ADDITIONAL_VERSIONS}" ]; then - OLDIFS=$IFS - IFS="," - read -a additional_versions <<< "$ADDITIONAL_VERSIONS" - for version in "${additional_versions[@]}"; do - OVERRIDE_DEFAULT_VERSION="false" - install_php "${version}" - done - IFS=$OLDIFS + chown -R "${USERNAME}:php" "${PHP_DIR}" + chmod -R g+r+w "${PHP_DIR}" + find "${PHP_DIR}" -type d -print0 | xargs -n 1 -0 chmod g+s fi -chown -R "${USERNAME}:php" "${PHP_DIR}" -chmod -R g+r+w "${PHP_DIR}" -find "${PHP_DIR}" -type d -print0 | xargs -n 1 -0 chmod g+s +# Clean up +rm -rf /var/lib/apt/lists/* echo "Done!" diff --git a/src/powershell/NOTES.md b/src/powershell/NOTES.md new file mode 100644 index 000000000..19fe92f31 --- /dev/null +++ b/src/powershell/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/powershell/README.md b/src/powershell/README.md index 715d6c68d..f09ac1f02 100644 --- a/src/powershell/README.md +++ b/src/powershell/README.md @@ -7,9 +7,7 @@ Installs PowerShell along with needed dependencies. Useful for base Dockerfiles ```json "features": { - "ghcr.io/devcontainers/features/powershell:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/powershell:2": {} } ``` @@ -18,7 +16,22 @@ Installs PowerShell along with needed dependencies. Useful for base Dockerfiles | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select or enter a version of PowerShell. | string | latest | +| modules | Optional comma separated list of PowerShell modules to install. If you need to install a specific version of a module, use '==' to specify the version (e.g. 'az.resources==2.5.0') | string | - | +| powershellProfileURL | Optional (publicly accessible) URL to download PowerShell profile. | string | - | +## Customizations + +### VS Code Extensions + +- `ms-vscode.powershell` + + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/powershell/devcontainer-feature.json b/src/powershell/devcontainer-feature.json index 150cf268c..009f4c238 100644 --- a/src/powershell/devcontainer-feature.json +++ b/src/powershell/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "powershell", - "version": "1.0.2", + "version": "2.0.2", "name": "PowerShell", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/powershell", "description": "Installs PowerShell along with needed dependencies. Useful for base Dockerfiles that often are missing required install dependencies like gpg.", @@ -9,11 +9,42 @@ "type": "string", "proposals": [ "latest", + "lts", + "preview", + "stable", "none", - "7.1" + "7.5", + "7.4" ], "default": "latest", "description": "Select or enter a version of PowerShell." + }, + "modules": { + "type": "string", + "default": "", + "description": "Optional comma separated list of PowerShell modules to install. If you need to install a specific version of a module, use '==' to specify the version (e.g. 'az.resources==2.5.0')." + }, + "powershellProfileURL": { + "type": "string", + "default": "", + "description": "Optional (publicly accessible) URL to download PowerShell profile." } - } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-vscode.powershell" + ] + }, + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes PowerShell along with needed dependencies pre-installed and available on the `PATH`, along with the PowerShell extension." + } + ] + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/powershell/install.sh b/src/powershell/install.sh index 328ae9326..cabd2cbb4 100755 --- a/src/powershell/install.sh +++ b/src/powershell/install.sh @@ -9,20 +9,174 @@ set -e +# Clean up +rm -rf /var/lib/apt/lists/* + POWERSHELL_VERSION=${VERSION:-"latest"} +POWERSHELL_MODULES="${MODULES:-""}" +POWERSHELL_PROFILE_URL="${POWERSHELLPROFILEURL}" MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" -POWERSHELL_ARCHIVE_ARCHITECTURES="amd64" -POWERSHELL_ARCHIVE_VERSION_CODENAMES="stretch buster bionic focal bullseye jammy" -GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80 +#MICROSOFT_GPG_KEYS_URI=$(curl https://packages.microsoft.com/keys/microsoft.asc -o /usr/share/keyrings/microsoft-archive-keyring.gpg) +POWERSHELL_ARCHIVE_ARCHITECTURES_UBUNTU="amd64" +POWERSHELL_ARCHIVE_ARCHITECTURES_ALMALINUX="x86_64" +POWERSHELL_ARCHIVE_VERSION_CODENAMES="stretch buster bionic focal bullseye jammy bookworm noble" + +#These key servers are used to verify the authenticity of packages and repositories. +#keyservers for ubuntu and almalinux are different so we need to specify both +GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com +keyserver hkp://keyserver.ubuntu.com:80 keyserver hkps://keys.openpgp.org -keyserver hkp://keyserver.pgp.com" +keyserver hkp://keyserver.pgp.com +keyserver hkp://keyserver.fedoraproject.org +keyserver hkps://keys.openpgp.org +keyserver hkp://pgp.mit.edu +keyserver hkp://keyserver.redhat.com" + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 fi +# Clean up package manager cache +clean_cache() { + if [ -d "/var/cache/apt" ]; then + apt-get clean + fi + if [ -d "/var/cache/dnf" ]; then + rm -rf /var/cache/dnf/* + fi +} +# Function to resolve PowerShell version from Microsoft redirect URLs +resolve_powershell_version() { + local version_tag="$1" + local redirect_url="https://aka.ms/powershell-release?tag=${version_tag}" + + # Follow the redirect and extract the version from the final URL + local resolved_url + resolved_url=$(curl -sSL -o /dev/null -w '%{url_effective}' "${redirect_url}") + + # Extract version from URL (e.g., https://github.com/PowerShell/PowerShell/releases/tag/v7.4.7 -> 7.4.7) + local resolved_version + resolved_version=$(echo "${resolved_url}" | grep -oP 'v\K[0-9]+\.[0-9]+\.[0-9]+(-\w+\.\d+)?' || echo "") + + if [ -z "${resolved_version}" ]; then + echo "Failed to resolve version for tag: ${version_tag}" >&2 + return 1 + fi + + echo "${resolved_version}" +} +# Install dependencies for RHEL/CentOS/AlmaLinux (DNF-based systems) +install_using_dnf() { + dnf remove -y curl-minimal + dnf install -y curl gnupg2 ca-certificates dnf-plugins-core + dnf clean all + dnf makecache + curl --version +} + +# Install PowerShell on RHEL/CentOS/AlmaLinux-based systems (DNF) +install_powershell_dnf() { + # Install wget, if not already installed + dnf install -y wget + + # Download Microsoft GPG key + curl https://packages.microsoft.com/keys/microsoft.asc -o /usr/share/keyrings/microsoft-archive-keyring.gpg + ls -l /usr/share/keyrings/microsoft-archive-keyring.gpg + + # Install necessary dependencies + dnf install -y krb5-libs libicu openssl-libs zlib + + # Add Microsoft PowerShell repository + curl "https://packages.microsoft.com/config/rhel/9.0/prod.repo" > /etc/yum.repos.d/microsoft.repo + + # Install PowerShell + dnf install --assumeyes powershell +} + + +# Detect the package manager and OS +detect_package_manager() { + if [ -f /etc/os-release ]; then + . /etc/os-release + if [[ "$ID" == "ubuntu" || "$ID" == "debian" ]]; then + echo "Detected Debian/Ubuntu-based system" + install_using_apt + install_pwsh + elif [[ "$ID" == "centos" || "$ID" == "rhel" || "$ID" == "almalinux" ]]; then + echo "Detected RHEL/CentOS/AlmaLinux-based system" + install_using_dnf + install_powershell_dnf + install_pwsh + else + echo "Unsupported Linux distribution: $ID" + exit 1 + fi + else + echo "Could not detect OS" + exit 1 + fi +} + +# Function to find the latest preview version from git tags +find_preview_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + local repository_url=$2 + + if [ -z "${git_cmd_name}" ]; then + if type git > /dev/null 2>&1; then + git_cmd_name="git" + else + echo "Git not found. Cannot determine preview version." + return 1 + fi + fi + + # Fetch tags from remote repository (match both -preview.X and -rc.X tags) + local tags + tags=$(git ls-remote --tags "${repository_url}" 2>/dev/null | grep -oP 'refs/tags/v\K[0-9]+\.[0-9]+\.[0-9]+-(preview|rc)\.[0-9]+' | sort -V) + + if [ -z "${tags}" ]; then + echo "No preview/rc tags found in repository." + return 1 + fi + + local version="" + + if [ "${requested_version}" = "preview" ] || [ "${requested_version}" = "latest" ]; then + # Get the latest preview/rc version + version=$(echo "${tags}" | tail -n 1) + elif [[ "${requested_version}" =~ ^[0-9]+\.[0-9]+$ ]]; then + # Partial version provided (e.g., "7.6"), find latest preview/rc matching that major.minor + version=$(echo "${tags}" | grep "^${requested_version}\." | tail -n 1) + elif [[ "${requested_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-preview$ ]]; then + # Version like "7.6.0-preview" provided, find latest preview for that version + local base_version="${requested_version%-preview}" + version=$(echo "${tags}" | grep "^${base_version}-preview\." | tail -n 1) + elif [[ "${requested_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-preview\.[0-9]+$ ]]; then + # Exact preview version provided, verify it exists + if echo "${tags}" | grep -q "^${requested_version}$"; then + version="${requested_version}" + fi + elif [[ "${requested_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + # Exact RC version provided, verify it exists + if echo "${tags}" | grep -q "^${requested_version}$"; then + version="${requested_version}" + fi + fi + + if [ -z "${version}" ]; then + echo "Could not find matching preview version for: ${requested_version}" + return 1 + fi + + declare -g "${variable_name}=${version}" + echo "${variable_name}=${version}" +} + # Figure out correct version of a three part version number is not passed find_version_from_git_tags() { local variable_name=$1 @@ -57,42 +211,53 @@ find_version_from_git_tags() { echo "${variable_name}=${!variable_name}" } -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} - apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not -check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" - fi + check_packages() { + if command -v dpkg > /dev/null 2>&1; then + # If dpkg exists, assume APT-based system (Debian/Ubuntu) + for package in "$@"; do + if ! dpkg -s "$package" > /dev/null 2>&1; then + echo "Package $package not installed. Installing using apt-get..." + apt-get update + apt-get install -y --no-install-recommends "$package" + else + echo "Package $package is already installed (APT)." + fi + done + elif command -v dnf > /dev/null 2>&1; then + for package in "$@"; do + if ! dnf list installed "$package" > /dev/null 2>&1; then + echo "Package $package not installed. Installing using dnf..." + # Use --allowerasing to handle conflicts like curl-minimal vs curl + dnf install -y --allowerasing "$package" + else + echo "Package $package is already installed (DNF)." + fi + done +else + echo "Unsupported package manager. Neither APT nor DNF found." + return 1 +fi + + } install_using_apt() { # Install dependencies check_packages apt-transport-https curl ca-certificates gnupg2 dirmngr # Import key safely (new 'signed-by' method rather than deprecated apt-key approach) and install - get_common_setting MICROSOFT_GPG_KEYS_URI + curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list + # Update lists apt-get update -yq @@ -114,24 +279,131 @@ install_using_apt() { apt-get install -yq powershell${version_suffix} || return 1 } +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} + +# Function to fetch the version released prior to the latest version +get_previous_version() { + local url=$1 + local repo_url=$2 + local variable_name=$3 + prev_version=${!variable_name} + + output=$(curl -s "$repo_url"); + check_packages jq + message=$(echo "$output" | jq -r '.message') + + if [[ $message == "API rate limit exceeded"* ]]; then + echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}" + echo -e "\nAttempting to find latest version using GitHub tags." + find_prev_version_from_git_tags prev_version "$url" "tags/v" + declare -g ${variable_name}="${prev_version}" + else + echo -e "\nAttempting to find latest version using GitHub Api." + version=$(echo "$output" | jq -r '.tag_name') + declare -g ${variable_name}="${version#v}" + fi + echo "${variable_name}=${!variable_name}" +} + +get_github_api_repo_url() { + local url=$1 + echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases/latest" +} + + +install_prev_pwsh() { + pwsh_url=$1 + repo_url=$(get_github_api_repo_url $pwsh_url) + echo -e "\n(!) Failed to fetch the latest artifacts for powershell v${POWERSHELL_VERSION}..." + get_previous_version $pwsh_url $repo_url POWERSHELL_VERSION + echo -e "\nAttempting to install v${POWERSHELL_VERSION}" + install_pwsh "${POWERSHELL_VERSION}" +} + +install_pwsh() { + POWERSHELL_VERSION=$1 + powershell_filename="powershell-${POWERSHELL_VERSION}-linux-${architecture}.tar.gz" + powershell_target_path="/opt/microsoft/powershell/$(echo ${POWERSHELL_VERSION} | grep -oE '[^\.]+' | head -n 1)" + mkdir -p /tmp/pwsh "${powershell_target_path}" + cd /tmp/pwsh + curl -sSL -o "${powershell_filename}" "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/${powershell_filename}" +} + install_using_github() { # Fall back on direct download if no apt package exists in microsoft pool - check_packages curl ca-certificates gnupg2 dirmngr libc6 libgcc1 libgssapi-krb5-2 libstdc++6 libunwind8 libuuid1 zlib1g libicu[0-9][0-9] + if command -v apt-get > /dev/null 2>&1; then + # Debian/Ubuntu dependencies + check_packages curl ca-certificates gnupg2 dirmngr libc6 libgcc1 libgssapi-krb5-2 libstdc++6 libunwind8 libuuid1 zlib1g libicu[0-9][0-9] wget + elif command -v dnf > /dev/null 2>&1; then + # AlmaLinux/RHEL dependencies + check_packages curl ca-certificates gnupg2 glibc libgcc krb5-libs libstdc++ libuuid zlib libicu wget tar + fi if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get install -y --no-install-recommends git + check_packages git fi - if [ "${architecture}" = "amd64" ]; then + if [ "${architecture}" = "amd64" ] || [ "${architecture}" = "x86_64" ]; then architecture="x64" + elif [ "${architecture}" = "aarch64" ]; then + architecture="arm64" fi - find_version_from_git_tags POWERSHELL_VERSION https://github.com/PowerShell/PowerShell - powershell_filename="powershell-${POWERSHELL_VERSION}-linux-${architecture}.tar.gz" - powershell_target_path="/opt/microsoft/powershell/$(echo ${POWERSHELL_VERSION} | grep -oE '[^\.]+' | head -n 1)" - mkdir -p /tmp/pwsh "${powershell_target_path}" - cd /tmp/pwsh - curl -sSL -o "${powershell_filename}" "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/${powershell_filename}" - # Ugly - but only way to get sha256 is to parse release HTML. Remove newlines and tags, then look for filename followed by 64 hex characters. - curl -sSL -o "release.html" "https://github.com/PowerShell/PowerShell/releases/tag/v${POWERSHELL_VERSION}" + pwsh_url="https://github.com/PowerShell/PowerShell" + # Check if we need to find a preview version or stable version + if [[ "${POWERSHELL_VERSION}" == *"preview"* ]] || [ "${POWERSHELL_VERSION}" = "preview" ] || [[ "${POWERSHELL_VERSION}" == *"-rc."* ]]; then + echo "Finding preview version..." + find_preview_version_from_git_tags POWERSHELL_VERSION "${pwsh_url}" + else + find_version_from_git_tags POWERSHELL_VERSION "${pwsh_url}" + fi + + install_pwsh "${POWERSHELL_VERSION}" + if grep -q "Not Found" "${powershell_filename}"; then + install_prev_pwsh $pwsh_url + fi + + # download the latest version of powershell and extracting the file to powershell directory + wget https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/${powershell_filename} + mkdir ~/powershell + tar -xvf ${powershell_filename} -C ~/powershell + powershell_archive_sha256="$(cat release.html | tr '\n' ' ' | sed 's|<[^>]*>||g' | grep -oP "${powershell_filename}\s+\K[0-9a-fA-F]{64}" || echo '')" if [ -z "${powershell_archive_sha256}" ]; then echo "(!) WARNING: Failed to retrieve SHA256 for archive. Skipping validaiton." @@ -140,25 +412,92 @@ install_using_github() { echo "${powershell_archive_sha256} *${powershell_filename}" | sha256sum -c - fi tar xf "${powershell_filename}" -C "${powershell_target_path}" - ln -s "${powershell_target_path}/pwsh" /usr/local/bin/pwsh + chmod 755 "${powershell_target_path}/pwsh" + ln -sf "${powershell_target_path}/pwsh" /usr/bin/pwsh + # Add pwsh to /etc/shells + if command -v add-shell > /dev/null 2>&1; then + # Debian/Ubuntu - use add-shell + add-shell "/usr/bin/pwsh" + else + # AlmaLinux/RHEL - manually add to /etc/shells - add-shell is not available in almalinux repos and manual approach is simpler than adding a dependency just for this + if ! grep -q "/usr/bin/pwsh" /etc/shells; then + echo "/usr/bin/pwsh" >> /etc/shells + fi + fi + cd /tmp rm -rf /tmp/pwsh } -export DEBIAN_FRONTEND=noninteractive +if ! type pwsh >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + if [ "${POWERSHELL_VERSION}" = "lts" ] || [ "${POWERSHELL_VERSION}" = "stable" ] || [ "${POWERSHELL_VERSION}" = "preview" ]; then + echo "Resolving PowerShell '${POWERSHELL_VERSION}' version from Microsoft..." + resolved_version=$(resolve_powershell_version "${POWERSHELL_VERSION}") + if [ -n "${resolved_version}" ]; then + echo "Resolved '${POWERSHELL_VERSION}' to version: ${resolved_version}" + POWERSHELL_VERSION="${resolved_version}" + else + echo "Warning: Could not resolve '${POWERSHELL_VERSION}' version. Falling back to 'latest'." + POWERSHELL_VERSION="latest" + fi + fi + + # Source /etc/os-release to get OS info + . /etc/os-release + architecture="$(uname -m)" + if [[ "$ID" == "ubuntu" || "$ID" == "debian" ]]; then + POWERSHELL_ARCHIVE_ARCHITECTURES="${POWERSHELL_ARCHIVE_ARCHITECTURES_UBUNTU}" + elif [[ "$ID" == "centos" || "$ID" == "rhel" || "$ID" == "almalinux" ]]; then + POWERSHELL_ARCHIVE_ARCHITECTURES="${POWERSHELL_ARCHIVE_ARCHITECTURES_ALMALINUX}" + fi -# Source /etc/os-release to get OS info -. /etc/os-release -architecture="$(dpkg --print-architecture)" + if [[ "${POWERSHELL_ARCHIVE_ARCHITECTURES}" = *"${POWERSHELL_ARCHIVE_ARCHITECTURES_UBUNTU}"* ]] && [[ "${POWERSHELL_ARCHIVE_VERSION_CODENAMES}" = *"${VERSION_CODENAME}"* ]] && [[ "${POWERSHELL_VERSION}" != *"preview"* ]] && [[ "${POWERSHELL_VERSION}" != *"-rc."* ]]; then + install_using_apt || use_github="true" + elif [[ "${POWERSHELL_ARCHIVE_ARCHITECTURES}" = *"${POWERSHELL_ARCHIVE_ARCHITECTURES_ALMALINUX}"* ]] && [[ "${POWERSHELL_VERSION}" != *"preview"* ]] && [[ "${POWERSHELL_VERSION}" != *"-rc."* ]]; then + install_using_dnf && install_powershell_dnf || use_github="true" + else + use_github="true" + fi -if [[ "${POWERSHELL_ARCHIVE_ARCHITECTURES}" = *"${architecture}"* ]] && [[ "${POWERSHELL_ARCHIVE_VERSION_CODENAMES}" = *"${VERSION_CODENAME}"* ]]; then - install_using_apt || use_github="true" + if [ "${use_github}" = "true" ]; then + echo "Attempting install from GitHub release..." + install_using_github + fi else - use_github="true" + echo "PowerShell is already installed." fi -if [ "${use_github}" = "true" ]; then - echo "Attempting install from GitHub release..." - install_using_github +# If PowerShell modules are requested, loop through and install +if [ ${#POWERSHELL_MODULES[@]} -gt 0 ]; then + echo "Installing PowerShell Modules: ${POWERSHELL_MODULES}" + modules=(`echo ${POWERSHELL_MODULES} | tr ',' ' '`) + for i in "${modules[@]}" + do + module_parts=(`echo ${i} | tr '==' ' '`) + module_name="${module_parts[0]}" + args="-Name ${module_name} -AllowClobber -Force -Scope AllUsers" + if [ "${#module_parts[@]}" -eq 2 ]; then + module_version="${module_parts[1]}" + echo "Installing ${module_name} v${module_version}" + args+=" -RequiredVersion ${module_version}" + else + echo "Installing latest version for ${i} module" + fi + + pwsh -Command "Install-Module $args" || continue + done +fi + + +# If URL for powershell profile is provided, download it to '/opt/microsoft/powershell/7/profile.ps1' +if [ -n "$POWERSHELL_PROFILE_URL" ]; then + echo "Downloading PowerShell Profile from: $POWERSHELL_PROFILE_URL" + # Get profile path from currently installed pwsh + profilePath=$(pwsh -noni -c '$PROFILE.AllUsersAllHosts') + sudo -E curl -sSL -o "$profilePath" "$POWERSHELL_PROFILE_URL" fi +# Clean up +rm -rf /var/lib/apt/lists/* + echo "Done!" \ No newline at end of file diff --git a/src/python/NOTES.md b/src/python/NOTES.md new file mode 100644 index 000000000..79a308cf5 --- /dev/null +++ b/src/python/NOTES.md @@ -0,0 +1,7 @@ + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and RockyLinux distributions with the apt, yum, dnf, or microdnf package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/python/README.md b/src/python/README.md index ff28867f3..90d79aee8 100644 --- a/src/python/README.md +++ b/src/python/README.md @@ -7,9 +7,7 @@ Installs the provided version of Python, as well as PIPX, and other common Pytho ```json "features": { - "ghcr.io/devcontainers/features/python:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/python:1": {} } ``` @@ -18,12 +16,30 @@ Installs the provided version of Python, as well as PIPX, and other common Pytho | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select a Python version to install. | string | os-provided | -| installTools | Install common Python tools like pylint | boolean | true | -| optimize | Optimize Python for performance when compiled (slow) | boolean | - | +| installTools | Flag indicating whether or not to install the tools specified via the 'toolsToInstall' option. Default is 'true'. | boolean | true | +| toolsToInstall | Comma-separated list of tools to install when 'installTools' is true. Defaults to a set of common Python tools like pylint. | string | flake8,autopep8,black,yapf,mypy,pydocstyle,pycodestyle,bandit,pipenv,virtualenv,pytest,pylint | +| optimize | Optimize Python for performance when compiled (slow) | boolean | false | +| enableShared | Enable building a shared Python library | boolean | false | | installPath | The path where python will be installed. | string | /usr/local/python | -| installJupyterlab | Install JupyterLab, a web-based interactive development environment for notebooks | boolean | - | +| installJupyterlab | Install JupyterLab, a web-based interactive development environment for notebooks | boolean | false | | configureJupyterlabAllowOrigin | Configure JupyterLab to accept HTTP requests from the specified origin | string | - | +| httpProxy | Connect to GPG keyservers using a proxy for fetching source code signatures by configuring this option | string | - | +## Customizations + +### VS Code Extensions + +- `ms-python.python` +- `ms-python.vscode-pylance` +- `ms-python.autopep8` + + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and RockyLinux distributions with the apt, yum, dnf, or microdnf package manager installed. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/python/devcontainer-feature.json b/src/python/devcontainer-feature.json index 0c90e0b02..6a4121415 100644 --- a/src/python/devcontainer-feature.json +++ b/src/python/devcontainer-feature.json @@ -1,16 +1,18 @@ { "id": "python", - "version": "1.0.8", + "version": "1.8.0", "name": "Python", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/python", "description": "Installs the provided version of Python, as well as PIPX, and other common Python utilities. JupyterLab is conditionally installed with the python feature. Note: May require source code compilation.", "options": { "version": { "type": "string", - "enum": [ + "proposals": [ "latest", "os-provided", "none", + "3.12", + "3.11", "3.10", "3.9", "3.8", @@ -23,13 +25,23 @@ "installTools": { "type": "boolean", "default": true, - "description": "Install common Python tools like pylint" + "description": "Flag indicating whether or not to install the tools specified via the 'toolsToInstall' option. Default is 'true'." + }, + "toolsToInstall": { + "type": "string", + "default": "flake8,autopep8,black,yapf,mypy,pydocstyle,pycodestyle,bandit,pipenv,virtualenv,pytest,pylint", + "description": "Comma-separated list of tools to install when 'installTools' is true. Defaults to a set of common Python tools like pylint." }, "optimize": { "type": "boolean", "default": false, "description": "Optimize Python for performance when compiled (slow)" }, + "enableShared": { + "type": "boolean", + "default": false, + "description": "Enable building a shared Python library" + }, "installPath": { "type": "string", "default": "/usr/local/python", @@ -44,32 +56,41 @@ "type": "string", "default": "", "description": "Configure JupyterLab to accept HTTP requests from the specified origin" + }, + "httpProxy": { + "type": "string", + "default": "", + "description": "Connect to GPG keyservers using a proxy for fetching source code signatures by configuring this option" } }, "containerEnv": { "PYTHON_PATH": "/usr/local/python/current", "PIPX_HOME": "/usr/local/py-utils", "PIPX_BIN_DIR": "/usr/local/py-utils/bin", - "PATH": "/usr/local/python/current/bin:/usr/local/py-utils/bin:${PATH}" + "PATH": "/usr/local/python/current/bin:/usr/local/py-utils/bin:/usr/local/jupyter:${PATH}" }, "customizations": { "vscode": { "extensions": [ "ms-python.python", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", + "ms-python.autopep8" ], "settings": { - "python.defaultInterpreterPath": "/usr/local/bin/python", - "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", - "python.formatting.blackPath": "/usr/local/py-utils/bin/black", - "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", - "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", - "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", - "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", - "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", - "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes `python3` and `pip3` pre-installed and available on the `PATH`, along with the Python language extensions for Python development." + } + ], + "python.defaultInterpreterPath": "/usr/local/python/current/bin/python", + "[python]": { + "editor.defaultFormatter": "ms-python.autopep8" + } } } - } -} + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils", + "ghcr.io/devcontainers/features/oryx" + ] +} \ No newline at end of file diff --git a/src/python/install.sh b/src/python/install.sh index 3cf3a8867..be895927d 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -7,30 +7,33 @@ # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/python.md # Maintainer: The VS Code and Codespaces Teams -PYTHON_VERSION=${VERSION:-"latest"} # 'system' checks the base image first, else installs 'latest' -INSTALL_PYTHON_TOOLS=${INSTALLTOOLS:-"true"} -OPTIMIZE_BUILD_FROM_SOURCE=${OPTIMIZE:-"false"} -PYTHON_INSTALL_PATH=${INSTALLPATH:-"/usr/local/python"} -OVERRIDE_DEFAULT_VERSION=${OVERRIDEDEFAULTVERSION:-"true"} +PYTHON_VERSION="${VERSION:-"latest"}" # 'system' or 'os-provided' checks the base image first, else installs 'latest' +INSTALL_PYTHON_TOOLS="${INSTALLTOOLS:-"true"}" +SKIP_VULNERABILITY_PATCHING="${SKIPVULNERABILITYPATCHING:-"false"}" +OPTIMIZE_BUILD_FROM_SOURCE="${OPTIMIZE:-"false"}" +ENABLE_SHARED_FROM_SOURCE="${ENABLESHARED:-"false"}" +PYTHON_INSTALL_PATH="${INSTALLPATH:-"/usr/local/python"}" +OVERRIDE_DEFAULT_VERSION="${OVERRIDEDEFAULTVERSION:-"true"}" export PIPX_HOME=${PIPX_HOME:-"/usr/local/py-utils"} -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} -USE_ORYX_IF_AVAILABLE=${USE_ORYX_IF_AVAILABLE:-"true"} +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +UPDATE_RC="${UPDATE_RC:-"true"}" +USE_ORYX_IF_AVAILABLE="${USEORYXIFAVAILABLE:-"true"}" -INSTALL_JUPYTERLAB=${INSTALLJUPYTERLAB:-"false"} -CONFIGURE_JUPYTERLAB_ALLOW_ORIGIN=${CONFIGUREJUPYTERLABALLOWORIGIN:-""} +INSTALL_JUPYTERLAB="${INSTALLJUPYTERLAB:-"false"}" +CONFIGURE_JUPYTERLAB_ALLOW_ORIGIN="${CONFIGUREJUPYTERLABALLOWORIGIN:-""}" # Comma-separated list of python versions to be installed # alongside PYTHON_VERSION, but not set as default. -ADDITIONAL_VERSIONS=${ADDITIONALVERSIONS:-""} +ADDITIONAL_VERSIONS="${ADDITIONALVERSIONS:-""}" -DEFAULT_UTILS=("pylint" "flake8" "autopep8" "black" "yapf" "mypy" "pydocstyle" "pycodestyle" "bandit" "pipenv" "virtualenv") -PYTHON_SOURCE_GPG_KEYS="64E628F8D684696D B26995E310250568 2D347EA6AA65421D FB9921286F5E1540 3A5CA953F73C700D 04C367C218ADD4FF 0EDDC5F26A45C816 6AF053F07D9DC8D2 C9BE28DEE6DF025C 126EB563A74B06BF D9866941EA5BBD71 ED9D77D5" -GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80 -keyserver hkps://keys.openpgp.org -keyserver hkp://keyserver.pgp.com" +# Comma-separated list of additional tools to be installed via pipx. +IFS="," read -r -a DEFAULT_UTILS <<< "${TOOLSTOINSTALL:-flake8,autopep8,black,yapf,mypy,pydocstyle,pycodestyle,bandit,pipenv,virtualenv,pytest}" + +PYTHON_SOURCE_GPG_KEYS="64E628F8D684696D B26995E310250568 2D347EA6AA65421D FB9921286F5E1540 3A5CA953F73C700D 04C367C218ADD4FF 0EDDC5F26A45C816 6AF053F07D9DC8D2 C9BE28DEE6DF025C 126EB563A74B06BF D9866941EA5BBD71 ED9D77D5 A821E680E5FA6305" + +KEYSERVER_PROXY="${HTTPPROXY:-"${HTTP_PROXY:-""}"}" set -e @@ -39,81 +42,183 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -# Ensure that login shells get the correct path if the user updated the PATH using ENV. -rm -f /etc/profile.d/00-restore-env.sh -echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh -chmod +x /etc/profile.d/00-restore-env.sh +# Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME +. /etc/os-release +# Get an adjusted ID independent of distro variants +MAJOR_VERSION_ID=$(echo ${VERSION_ID} | cut -d . -f 1) +if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" +elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + if [[ "${ID}" = "rhel" ]] || [[ "${ID}" = *"alma"* ]] || [[ "${ID}" = *"rocky"* ]]; then + VERSION_CODENAME="rhel${MAJOR_VERSION_ID}" + else + VERSION_CODENAME="${ID}${MAJOR_VERSION_ID}" + fi +else + echo "Linux distro ${ID} not supported." + exit 1 +fi -# Determine the appropriate non-root user -if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u ${CURRENT_USER} > /dev/null 2>&1; then - USERNAME=${CURRENT_USER} - break - fi - done - if [ "${USERNAME}" = "" ]; then - USERNAME=root +if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${VERSION_CODENAME-}" = "centos7" ]; then + # As of 1 July 2024, mirrorlist.centos.org no longer exists. + # Update the repo files to reference vault.centos.org. + sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo + sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo + sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo +fi + +# To find some devel packages, some rhel need to enable specific extra repos, but not on RedHat ubi images... +INSTALL_CMD_ADDL_REPOS="" +if [ ${ADJUSTED_ID} = "rhel" ] && [ ${ID} != "rhel" ]; then + if [ ${MAJOR_VERSION_ID} = "8" ]; then + INSTALL_CMD_ADDL_REPOS="--enablerepo powertools" + elif [ ${MAJOR_VERSION_ID} = "9" ]; then + INSTALL_CMD_ADDL_REPOS="--enablerepo crb" fi -elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then - USERNAME=root fi +# Setup INSTALL_CMD & PKG_MGR_CMD +if type apt-get > /dev/null 2>&1; then + PKG_MGR_CMD=apt-get + INSTALL_CMD="${PKG_MGR_CMD} -y install --no-install-recommends" +elif type microdnf > /dev/null 2>&1; then + PKG_MGR_CMD=microdnf + INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0" +elif type dnf > /dev/null 2>&1; then + PKG_MGR_CMD=dnf + INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0" +else + PKG_MGR_CMD=yum + INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --noplugins --setopt=install_weak_deps=0" +fi + +# Install Time::Piece Perl module required by OpenSSL 3.0.18+ build system on CentOS 7/RHEL 7 +install_time_piece() { + echo "(*) Ensuring Time::Piece Perl module is available for OpenSSL 3.0.18+ build..." + + # Check if Time::Piece is already available (it's usually in Perl core) + if perl -MTime::Piece -e 'exit 0' 2>/dev/null; then + echo "(*) Time::Piece already available" + return 0 + fi + + echo "(*) Time::Piece not found, installing perl-Time-Piece package..." + + # Install perl-Time-Piece package for CentOS 7/RHEL 7 + if ${INSTALL_CMD} perl-Time-Piece; then + echo "(*) perl-Time-Piece installed for OpenSSL 3.0.18+ build" + else + echo "(!) Failed to install perl-Time-Piece package. This will cause OpenSSL 3.0.18+ build to fail" + return 1 + fi +} + +# Clean up +clean_up() { + case ${ADJUSTED_ID} in + debian) + rm -rf /var/lib/apt/lists/* + ;; + rhel) + rm -rf /var/cache/dnf/* /var/cache/yum/* + rm -rf /tmp/yum.log + rm -rf ${GPG_INSTALL_PATH} + ;; + esac +} +clean_up + + + updaterc() { + local _bashrc + local _zshrc if [ "${UPDATE_RC}" = "true" ]; then - echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." - if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/bash.bashrc + case $ADJUSTED_ID in + debian) echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." + _bashrc=/etc/bash.bashrc + _zshrc=/etc/zsh/zshrc + ;; + rhel) echo "Updating /etc/bashrc and /etc/zshrc..." + _bashrc=/etc/bashrc + _zshrc=/etc/zshrc + ;; + esac + if [[ "$(cat ${_bashrc})" != *"$1"* ]]; then + echo -e "$1" >> ${_bashrc} fi - if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/zsh/zshrc + if [ -f "${_zshrc}" ] && [[ "$(cat ${_zshrc})" != *"$1"* ]]; then + echo -e "$1" >> ${_zshrc} fi fi } -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true +# Get the list of GPG key servers that are reachable +get_gpg_key_servers() { + declare -A keyservers_curl_map=( + ["hkp://keyserver.ubuntu.com"]="http://keyserver.ubuntu.com:11371" + ["hkp://keyserver.ubuntu.com:80"]="http://keyserver.ubuntu.com" + ["hkps://keys.openpgp.org"]="https://keys.openpgp.org" + ["hkp://keyserver.pgp.com"]="http://keyserver.pgp.com:11371" + ) + + local curl_args="" + local keyserver_reachable=false # Flag to indicate if any keyserver is reachable + + if [ ! -z "${KEYSERVER_PROXY}" ]; then + curl_args="--proxy ${KEYSERVER_PROXY}" fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi + + for keyserver in "${!keyservers_curl_map[@]}"; do + local keyserver_curl_url="${keyservers_curl_map[${keyserver}]}" + if curl -s ${curl_args} --max-time 5 ${keyserver_curl_url} > /dev/null; then + echo "keyserver ${keyserver}" + keyserver_reachable=true + else + echo "(*) Keyserver ${keyserver} is not reachable." >&2 + fi + done + + if ! $keyserver_reachable; then + echo "(!) No keyserver is reachable." >&2 + exit 1 fi - echo "$1=${!1}" } -# Import the specified key in a variable name passed in as +# Import the specified key in a variable name passed in as receive_gpg_keys() { - get_common_setting $1 local keys=${!1} - get_common_setting GPG_KEY_SERVERS true local keyring_args="" + local gpg_cmd="gpg" if [ ! -z "$2" ]; then mkdir -p "$(dirname \"$2\")" keyring_args="--no-default-keyring --keyring $2" fi + if [ ! -z "${KEYSERVER_PROXY}" ]; then + keyring_args="${keyring_args} --keyserver-options http-proxy=${KEYSERVER_PROXY}" + fi + + # Install curl + if ! type curl > /dev/null 2>&1; then + check_packages curl + fi - # Use a temporary locaiton for gpg keys to avoid polluting image + # Use a temporary location for gpg keys to avoid polluting image export GNUPGHOME="/tmp/tmp-gnupg" mkdir -p ${GNUPGHOME} chmod 700 ${GNUPGHOME} - echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf + echo -e "disable-ipv6\n$(get_gpg_key_servers)" > ${GNUPGHOME}/dirmngr.conf # GPG key download sometimes fails for some reason and retrying fixes it. local retry_count=0 local gpg_ok="false" set +e - until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; + until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; do echo "(*) Downloading GPG key..." ( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true" if [ "${gpg_ok}" != "true" ]; then - echo "(*) Failed getting key, retring in 10s..." + echo "(*) Failed getting key, retrying in 10s..." (( retry_count++ )) sleep 10s fi @@ -124,6 +229,56 @@ receive_gpg_keys() { exit 1 fi } +# RHEL7/CentOS7 has an older gpg that does not have dirmngr +# Iterate through keyservers until we have all the keys downloaded +receive_gpg_keys_centos7() { + local keys=${!1} + local keyring_args="" + local gpg_cmd="gpg" + if [ ! -z "$2" ]; then + mkdir -p "$(dirname \"$2\")" + keyring_args="--no-default-keyring --keyring $2" + fi + if [ ! -z "${KEYSERVER_PROXY}" ]; then + keyring_args="${keyring_args} --keyserver-options http-proxy=${KEYSERVER_PROXY}" + fi + + # Install curl + if ! type curl > /dev/null 2>&1; then + check_packages curl + fi + + # Use a temporary location for gpg keys to avoid polluting image + export GNUPGHOME="/tmp/tmp-gnupg" + mkdir -p ${GNUPGHOME} + chmod 700 ${GNUPGHOME} + # GPG key download sometimes fails for some reason and retrying fixes it. + local retry_count=0 + local gpg_ok="false" + num_keys=$(echo ${keys} | wc -w) + set +e + echo "(*) Downloading GPG keys..." + until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; do + for keyserver in $(echo "$(get_gpg_key_servers)" | sed 's/keyserver //'); do + ( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys --keyserver=${keyserver} ) 2>&1 + downloaded_keys=$(gpg --list-keys | grep ^pub | wc -l) + if [[ ${num_keys} = ${downloaded_keys} ]]; then + gpg_ok="true" + break + fi + done + if [ "${gpg_ok}" != "true" ]; then + echo "(*) Failed getting key, retrying in 10s..." + (( retry_count++ )) + sleep 10s + fi + done + set -e + if [ "${gpg_ok}" = "false" ]; then + echo "(!) Failed to get gpg key." + exit 1 + fi +} # Figure out correct version of a three part version number is not passed find_version_from_git_tags() { @@ -133,7 +288,7 @@ find_version_from_git_tags() { local repository=$2 local prefix=${3:-"tags/v"} local separator=${4:-"."} - local last_part_optional=${5:-"false"} + local last_part_optional=${5:-"false"} if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then local escaped_separator=${separator//./\\.} local last_part @@ -159,6 +314,48 @@ find_version_from_git_tags() { echo "${variable_name}=${!variable_name}" } +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} + + # Use Oryx to install something using a partial version match oryx_install() { local platform=$1 @@ -197,83 +394,316 @@ oryx_install() { fi } -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y +pkg_mgr_update() { + case $ADJUSTED_ID in + debian) + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + ${PKG_MGR_CMD} update -y + fi + ;; + rhel) + if [ ${PKG_MGR_CMD} = "microdnf" ]; then + if [ "$(ls /var/cache/yum/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} makecache ..." + ${PKG_MGR_CMD} makecache + fi + else + if [ "$(ls /var/cache/${PKG_MGR_CMD}/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} check-update ..." + set +e + ${PKG_MGR_CMD} check-update + rc=$? + if [ $rc != 0 ] && [ $rc != 100 ]; then + exit 1 + fi + set -e + fi + fi + ;; + esac } # Checks if packages are installed and installs them if not check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" - fi + case ${ADJUSTED_ID} in + debian) + if ! dpkg -s "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${INSTALL_CMD} "$@" + fi + ;; + rhel) + if ! rpm -q "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${INSTALL_CMD} "$@" + fi + ;; + esac } add_symlink() { if [[ ! -d "${CURRENT_PATH}" ]]; then - ln -s -r "${INSTALL_PATH}" "${CURRENT_PATH}" + ln -s -r "${INSTALL_PATH}" "${CURRENT_PATH}" fi if [ "${OVERRIDE_DEFAULT_VERSION}" = "true" ]; then if [[ $(ls -l ${CURRENT_PATH}) != *"-> ${INSTALL_PATH}"* ]] ; then rm "${CURRENT_PATH}" - ln -s -r "${INSTALL_PATH}" "${CURRENT_PATH}" + ln -s -r "${INSTALL_PATH}" "${CURRENT_PATH}" + fi + fi +} + +install_openssl3() { + mkdir /tmp/openssl3 + ( + cd /tmp/openssl3 + openssl3_version="3.0" + # Find version using soft match + find_version_from_git_tags openssl3_version "https://github.com/openssl/openssl" "openssl-" + local tgz_filename="openssl-${openssl3_version}.tar.gz" + local tgz_url="https://github.com/openssl/openssl/releases/download/openssl-${openssl3_version}/${tgz_filename}" + echo "Downloading ${tgz_filename}..." + curl -sSL -o "/tmp/openssl3/${tgz_filename}" "${tgz_url}" + tar xzf ${tgz_filename} + cd openssl-${openssl3_version} + ./config --libdir=lib + make -j $(nproc) + make install_dev + ) + rm -rf /tmp/openssl3 +} + +install_prev_vers_cpython() { + VERSION=$1 + echo -e "\n(!) Failed to fetch the latest artifacts for cpython ${VERSION}..." + find_prev_version_from_git_tags VERSION https://github.com/python/cpython + echo -e "\nAttempting to install ${VERSION}" + install_cpython "${VERSION}" +} + +install_cpython() { + VERSION=$1 + INSTALL_PATH="${PYTHON_INSTALL_PATH}/${VERSION}" + + # Check if the specified Python version is already installed + if [ -d "${INSTALL_PATH}" ]; then + echo "(!) Python version ${VERSION} already exists." + else + mkdir -p /tmp/python-src ${INSTALL_PATH} + cd /tmp/python-src + cpython_tgz_filename="Python-${VERSION}.tgz" + cpython_tgz_url="https://www.python.org/ftp/python/${VERSION}/${cpython_tgz_filename}" + echo "Downloading ${cpython_tgz_filename}..." + curl -sSL -o "/tmp/python-src/${cpython_tgz_filename}" "${cpython_tgz_url}" + fi +} +# Get system architecture for downloads +get_architecture() { + local architecture="" + case $(uname -m) in + x86_64) architecture="amd64" ;; + aarch64 | armv8*) architecture="arm64" ;; + aarch32 | armv7* | armvhf*) architecture="armhf" ;; + i?86) architecture="386" ;; + *) echo "(!) Architecture $(uname -m) unsupported"; exit 1 ;; + esac + echo ${architecture} +} + +# Install cosign with multi-distro support +install_cosign() { + + COSIGN_VERSION="latest" + local cosign_url='https://github.com/sigstore/cosign' + local architecture=$(get_architecture) + + find_version_from_git_tags COSIGN_VERSION "${cosign_url}" + + # Remove 'v' prefix if present for download URL + local version_for_url="${COSIGN_VERSION#v}" + + local cosign_filename="/tmp/cosign_${version_for_url}_${architecture}.deb" + local cosign_url="https://github.com/sigstore/cosign/releases/download/v${version_for_url}/cosign_${version_for_url}_${architecture}.deb" + + echo "Downloading cosign from: ${cosign_url}" + + if curl -L -f --fail-with-body "${cosign_url}" -o "$cosign_filename" 2>/dev/null; then + echo "(*) Successfully downloaded cosign v${COSIGN_VERSION}" + else + echo -e "\n(!) Failed to fetch cosign v${COSIGN_VERSION}..." + # Try previous version + find_prev_version_from_git_tags COSIGN_VERSION "https://github.com/sigstore/cosign" + echo -e "\nAttempting to install previous cosign ${COSIGN_VERSION} version as fallback mechanism" + + version_for_url="${COSIGN_VERSION#v}" + cosign_filename="/tmp/cosign_${version_for_url}_${architecture}.deb" + cosign_url="https://github.com/sigstore/cosign/releases/download/v${version_for_url}/cosign_${version_for_url}_${architecture}.deb" + + if ! curl -L -f --fail-with-body "${cosign_url}" -o "$cosign_filename" 2>/dev/null; then + echo "(!) Failed to download cosign v${COSIGN_VERSION} as fallback" + return 1 + fi + fi + + # Install the package + if [ -f "$cosign_filename" ]; then + dpkg -i "$cosign_filename" + rm "$cosign_filename" + echo "Installation of cosign succeeded with ${COSIGN_VERSION}." + else + echo "(!) Failed to install cosign package" + return 1 + fi + +} + +# COSIGN signature verification for python versions >= 3.14 +cosign_verification() { + local VERSION="$1" + + # Ensure cosign is installed + if ! type cosign > /dev/null 2>&1; then + echo "(*) cosign not found, installing..." + if ! install_cosign; then + echo "(!) Failed to install cosign" + return 1 fi + else + echo "(*) cosign is already available on the system" + fi + + echo "(*) Attempting COSIGN verification for Python ${VERSION}..." + + # Check if COSIGN signature files exist (these don't exist yet for Python releases) + local cosign_sig_url="${cpython_tgz_url}.sig" + local cosign_cert_url="${cpython_tgz_url}.pem" + + # Download COSIGN signature and certificate files with proper error handling + echo "(*) Checking for cosign signature files..." + if ! curl -sSL -f --fail-with-body -o "/tmp/python-src/${cpython_tgz_filename}.sig" "${cosign_sig_url}" 2>/dev/null; then + echo "(!) COSIGN signature file not available for Python ${VERSION}" + echo " Signature URL: ${cosign_sig_url}" + return 1 + fi + + if ! curl -sSL -f --fail-with-body -o "/tmp/python-src/${cpython_tgz_filename}.pem" "${cosign_cert_url}" 2>/dev/null; then + echo "(!) COSIGN certificate file not available for Python ${VERSION}" + echo " Certificate URL: ${cosign_cert_url}" + return 1 fi + + # Perform COSIGN verification + if cosign verify-blob \ + --certificate "/tmp/python-src/${cpython_tgz_filename}.pem" \ + --signature "/tmp/python-src/${cpython_tgz_filename}.sig" \ + --certificate-identity-regexp=".*" \ + --certificate-oidc-issuer-regexp=".*" \ + "/tmp/python-src/${cpython_tgz_filename}"; then + echo "(*) COSIGN signature verification successful" + return 0 + else + echo "(!) COSIGN signature verification failed" + return 1 + fi +} + +# GPG verification for python versions < 3.14 +gpg_verification() { + echo "(*) Using GPG signature verification..." + if [[ ${VERSION_CODENAME} = "centos7" ]] || [[ ${VERSION_CODENAME} = "rhel7" ]]; then + receive_gpg_keys_centos7 PYTHON_SOURCE_GPG_KEYS + else + receive_gpg_keys PYTHON_SOURCE_GPG_KEYS + fi + echo "Downloading ${cpython_tgz_filename}.asc..." + curl -sSL -o "/tmp/python-src/${cpython_tgz_filename}.asc" "${cpython_tgz_url}.asc" + gpg --verify "${cpython_tgz_filename}.asc" + echo "(*) GPG signature verification successful" } + install_from_source() { - VERSION=$1 + VERSION=$1 echo "(*) Building Python ${VERSION} from source..." - # Install prereqs if missing - check_packages curl ca-certificates gnupg2 tar make gcc libssl-dev zlib1g-dev libncurses5-dev \ - libbz2-dev libreadline-dev libxml2-dev xz-utils libgdbm-dev tk-dev dirmngr \ - libxmlsec1-dev libsqlite3-dev libffi-dev liblzma-dev uuid-dev if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends git + check_packages git fi # Find version using soft match find_version_from_git_tags VERSION "https://github.com/python/cpython" - INSTALL_PATH="${PYTHON_INSTALL_PATH}/${VERSION}" - - if [ -d "${INSTALL_PATH}" ]; then - echo "(!) Python version ${VERSION} already exists." - exit 1 - fi + # Some platforms/os versions need modern versions of openssl installed + # via common package repositories, for now rhel-7 family, use case statement to + # make it easy to expand + SSL_INSTALL_PATH="/usr/local" + case ${VERSION_CODENAME} in + centos7|rhel7) + check_packages perl-IPC-Cmd + # Install Time::Piece Perl module required by OpenSSL 3.0.18+ build system + install_time_piece + install_openssl3 + ADDL_CONFIG_ARGS="--with-openssl=${SSL_INSTALL_PATH} --with-openssl-rpath=${SSL_INSTALL_PATH}/lib" + ;; + esac - # Download tgz of source - mkdir -p /tmp/python-src ${INSTALL_PATH} - cd /tmp/python-src - local tgz_filename="Python-${VERSION}.tgz" - local tgz_url="https://www.python.org/ftp/python/${VERSION}/${tgz_filename}" - echo "Downloading ${tgz_filename}..." - curl -sSL -o "/tmp/python-src/${tgz_filename}" "${tgz_url}" + install_cpython "${VERSION}" + if [ -f "/tmp/python-src/${cpython_tgz_filename}" ]; then + if grep -q "404 Not Found" "/tmp/python-src/${cpython_tgz_filename}"; then + install_prev_vers_cpython "${VERSION}" + fi + fi; - # Verify signature - receive_gpg_keys PYTHON_SOURCE_GPG_KEYS - echo "Downloading ${tgz_filename}.asc..." - curl -sSL -o "/tmp/python-src/${tgz_filename}.asc" "${tgz_url}.asc" - gpg --verify "${tgz_filename}.asc" + # Discontinuation of PGP signatures for releases of Python 3.14 or future versions + # CPython release artifacts are additionally signed with Sigstore starting with the Python 3.11.0 + local major_version=$(echo "$VERSION" | cut -d. -f1) + local minor_version=$(echo "$VERSION" | cut -d. -f2) + echo "(*) Detected Python version: ${major_version}.${minor_version}" + if (( major_version > 3 )) || { (( major_version == 3 )) && (( minor_version >= 14 )); }; then + echo "(*) Python 3.14+ detected. Attempting cosign verification..." + if cosign_verification "$VERSION"; then + echo "(*) COSIGN verification successful." + else + echo "(*) COSIGN verification failed or not available for Python ${VERSION}" + echo "(*) WARNING: Installing Python ${VERSION} without signature verification" + echo "(*) This is expected for newly released versions where cosign signatures are not yet available" + echo "(*) Python 3.14+ discontinued PGP signatures in favor of cosign, but cosign signatures may take time to be published" + fi + else + echo "(*) Python < 3.14 detected. Using GPG signature verification..." + gpg_verification + echo "(*) GPG verification successful." + fi # Update min protocol for testing only - https://bugs.python.org/issue41561 - cp /etc/ssl/openssl.cnf /tmp/python-src/ + if [ -f /etc/pki/tls/openssl.cnf ]; then + cp /etc/pki/tls/openssl.cnf /tmp/python-src/ + else + cp /etc/ssl/openssl.cnf /tmp/python-src/ + fi sed -i -E 's/MinProtocol[=\ ]+.*/MinProtocol = TLSv1.0/g' /tmp/python-src/openssl.cnf export OPENSSL_CONF=/tmp/python-src/openssl.cnf # Untar and build - tar -xzf "/tmp/python-src/${tgz_filename}" -C "/tmp/python-src" --strip-components=1 + tar -xzf "/tmp/python-src/${cpython_tgz_filename}" -C "/tmp/python-src" --strip-components=1 local config_args="" if [ "${OPTIMIZE_BUILD_FROM_SOURCE}" = "true" ]; then - config_args="--enable-optimizations" + config_args="${config_args} --enable-optimizations" + fi + if [ "${ENABLESHARED}" = "true" ]; then + config_args=" ${config_args} --enable-shared" + # need double-$: LDFLAGS ends up in Makefile $$ becomes $ when evaluated. + # backslash needed for shell that Make calls escape the $. + export LDFLAGS="${LDFLAGS} -Wl,-rpath="'\$$ORIGIN'"/../lib" + fi + if [ -n "${ADDL_CONFIG_ARGS}" ]; then + config_args="${config_args} ${ADDL_CONFIG_ARGS}" fi ./configure --prefix="${INSTALL_PATH}" --with-ensurepip=install ${config_args} make -j 8 make install + cd /tmp rm -rf /tmp/python-src ${GNUPGHOME} /tmp/vscdc-settings.env @@ -288,20 +718,23 @@ install_from_source() { } install_using_oryx() { - VERSION=$1 + VERSION=$1 INSTALL_PATH="${PYTHON_INSTALL_PATH}/${VERSION}" - + + # Check if the specified Python version is already installed if [ -d "${INSTALL_PATH}" ]; then echo "(!) Python version ${VERSION} already exists." - exit 1 - fi - oryx_install "python" "${VERSION}" "${INSTALL_PATH}" "lib" || return 1 + else + # The python install root path may not exist, so create it + mkdir -p "${PYTHON_INSTALL_PATH}" + oryx_install "python" "${VERSION}" "${INSTALL_PATH}" "lib" || return 1 - ln -s "${INSTALL_PATH}/bin/idle3" "${INSTALL_PATH}/bin/idle" - ln -s "${INSTALL_PATH}/bin/pydoc3" "${INSTALL_PATH}/bin/pydoc" - ln -s "${INSTALL_PATH}/bin/python3-config" "${INSTALL_PATH}/bin/python-config" + ln -s "${INSTALL_PATH}/bin/idle3" "${INSTALL_PATH}/bin/idle" + ln -s "${INSTALL_PATH}/bin/pydoc3" "${INSTALL_PATH}/bin/pydoc" + ln -s "${INSTALL_PATH}/bin/python3-config" "${INSTALL_PATH}/bin/python-config" - add_symlink + add_symlink + fi } sudo_if() { @@ -309,40 +742,65 @@ sudo_if() { if [ "$(id -u)" -eq 0 ] && [ "$USERNAME" != "root" ]; then su - "$USERNAME" -c "$COMMAND" else - "$COMMAND" + $COMMAND fi } install_user_package() { - PACKAGE="$1" - sudo_if "$INSTALL_PATH/bin/python3" -m pip install --user --upgrade --no-cache-dir "$PACKAGE" + INSTALL_UNDER_ROOT="$1" + PACKAGE="$2" + + if [ "$INSTALL_UNDER_ROOT" = true ]; then + sudo_if "${PYTHON_SRC}" -m pip install --upgrade --no-cache-dir "$PACKAGE" + else + sudo_if "${PYTHON_SRC}" -m pip install --user --upgrade --no-cache-dir "$PACKAGE" + fi } add_user_jupyter_config() { - CONFIG_DIR="/home/$USERNAME/.jupyter" - CONFIG_FILE="$CONFIG_DIR/jupyter_server_config.py" + CONFIG_DIR="$1" + CONFIG_FILE="$2" # Make sure the config file exists or create it with proper permissions test -d "$CONFIG_DIR" || sudo_if mkdir "$CONFIG_DIR" test -f "$CONFIG_FILE" || sudo_if touch "$CONFIG_FILE" # Don't write the same config more than once - grep -q "$1" "$CONFIG_FILE" || echo "$1" >> "$CONFIG_FILE" + grep -q "$3" "$CONFIG_FILE" || echo "$3" >> "$CONFIG_FILE" } install_python() { version=$1 # If the os-provided versions are "good enough", detect that and bail out. - if [ ${PYTHON_VERSION} = "os-provided" ] || [ ${PYTHON_VERSION} = "system" ]; then - check_packages python3 python3-doc python3-pip python3-venv python3-dev python3-tk - PYTHON_ROOT="/usr/bin" + if [ ${version} = "os-provided" ] || [ ${version} = "system" ]; then + if [ ${ADJUSTED_ID} = "debian" ]; then + check_packages python3 python3-doc python3-pip python3-venv python3-dev python3-tk + else + if [ ${ID} != "mariner" ]; then + check_packages python3 python3-pip python3-devel python3-tkinter + else + check_packages python3 python3-pip python3-devel + fi + fi + INSTALL_PATH="/usr" - ln -s "${PYTHON_ROOT}/python3" "${PYTHON_ROOT}/python" - ln -s "${PYTHON_ROOT}/pydoc3" "${PYTHON_ROOT}/pydoc" - ln -s "${PYTHON_ROOT}/python3-config" "${PYTHON_ROOT}/python-config" + local current_bin_path="${CURRENT_PATH}/bin" + if [ "${OVERRIDE_DEFAULT_VERSION}" = "true" ]; then + rm -rf "${current_bin_path}" + fi + if [ ! -d "${current_bin_path}" ] ; then + mkdir -p "${current_bin_path}" + # Add an interpreter symlink but point it to "/usr" since python is at /usr/bin/python, add other alises + ln -s "${INSTALL_PATH}/bin/python3" "${current_bin_path}/python3" + ln -s "${INSTALL_PATH}/bin/python3" "${current_bin_path}/python" + ln -s "${INSTALL_PATH}/bin/pydoc3" "${current_bin_path}/pydoc3" + ln -s "${INSTALL_PATH}/bin/pydoc3" "${current_bin_path}/pydoc" + ln -s "${INSTALL_PATH}/bin/python3-config" "${current_bin_path}/python3-config" + ln -s "${INSTALL_PATH}/bin/python3-config" "${current_bin_path}/python-config" + fi should_install_from_source=false - elif [ "$(dpkg --print-architecture)" = "amd64" ] && [ "${USE_ORYX_IF_AVAILABLE}" = "true" ] && type oryx > /dev/null 2>&1; then + elif [ ${ADJUSTED_ID} = "debian" ] && [ "$(dpkg --print-architecture)" = "amd64" ] && [ "${USE_ORYX_IF_AVAILABLE}" = "true" ] && type oryx > /dev/null 2>&1; then install_using_oryx $version || should_install_from_source=true else should_install_from_source=true @@ -352,14 +810,126 @@ install_python() { fi } +python_is_externally_managed() { + local _python_cmd=$1 + local python_stdlib_dir=$( + ${_python_cmd} -c ' +import sys +import sysconfig +sys.prefix == sys.base_prefix and print(sysconfig.get_path("stdlib", sysconfig.get_default_scheme()))' + ) + if [ -f ${python_stdlib_dir}/EXTERNALLY-MANAGED ]; then + return 0 + else + return 1 + fi +} + +# Ensure that login shells get the correct path if the user updated the PATH using ENV. +rm -f /etc/profile.d/00-restore-env.sh +echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh +chmod +x /etc/profile.d/00-restore-env.sh + +# Some distributions do not install awk by default (e.g. Mariner) +if ! type awk >/dev/null 2>&1; then + check_packages awk +fi + +# Determine the appropriate non-root user +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi +elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root +fi + # Ensure apt is in non-interactive to avoid prompts export DEBIAN_FRONTEND=noninteractive # General requirements -check_packages curl ca-certificates gnupg2 tar make gcc libssl-dev zlib1g-dev libncurses5-dev \ - libbz2-dev libreadline-dev libxml2-dev xz-utils libgdbm-dev tk-dev dirmngr \ - libxmlsec1-dev libsqlite3-dev libffi-dev liblzma-dev uuid-dev +REQUIRED_PKGS="" +case ${ADJUSTED_ID} in + debian) + REQUIRED_PKGS="${REQUIRED_PKGS} \ + ca-certificates \ + curl \ + dirmngr \ + gcc \ + gnupg2 \ + libbz2-dev \ + libffi-dev \ + libgdbm-dev \ + liblzma-dev \ + libncurses5-dev \ + libreadline-dev \ + libsqlite3-dev \ + libssl-dev \ + libxml2-dev \ + libxmlsec1-dev \ + make \ + tar \ + tk-dev \ + uuid-dev \ + xz-utils \ + zlib1g-dev" + ;; + rhel) + REQUIRED_PKGS="${REQUIRED_PKGS} \ + bzip2-devel \ + ca-certificates \ + findutils \ + gcc \ + gnupg2 \ + libffi-devel \ + libxml2-devel \ + make \ + ncurses-devel \ + openssl-devel \ + shadow-utils \ + sqlite-devel \ + tar \ + which \ + xz-devel \ + xz \ + zlib-devel" + if ! type curl >/dev/null 2>&1; then + REQUIRED_PKGS="${REQUIRED_PKGS} \ + curl" + fi + # Mariner does not have tk-devel package available, RedHat ubi8 and ubi9 do not have tk-devel + if [ ${ID} != "mariner" ] && [ ${ID} != "rhel" ]; then + REQUIRED_PKGS="${REQUIRED_PKGS} \ + tk-devel" + fi + # Redhat ubi8 and ubi9 do not have some packages by default, only add them + # if we're not on RedHat ... + if [ ${ID} != "rhel" ]; then + REQUIRED_PKGS="${REQUIRED_PKGS} \ + gdbm-devel \ + readline-devel \ + uuid-devel \ + xmlsec1-devel" + fi + ;; +esac + +check_packages ${REQUIRED_PKGS} + +# Function to get the major version from a SemVer string +get_major_version() { + local version="$1" + echo "$version" | cut -d '.' -f 1 +} # Install Python from source if needed if [ "${PYTHON_VERSION}" != "none" ]; then @@ -367,9 +937,7 @@ if [ "${PYTHON_VERSION}" != "none" ]; then groupadd -r python fi usermod -a -G python "${USERNAME}" - CURRENT_PATH="${PYTHON_INSTALL_PATH}/current" - install_python ${PYTHON_VERSION} # Additional python versions to be installed but not be set as default. @@ -378,9 +946,27 @@ if [ "${PYTHON_VERSION}" != "none" ]; then OLDIFS=$IFS IFS="," read -a additional_versions <<< "$ADDITIONAL_VERSIONS" - for version in "${additional_versions[@]}"; do + major_version=$(get_major_version ${VERSION}) + if type apt-get > /dev/null 2>&1; then + # Debian/Ubuntu: Use update-alternatives + update-alternatives --install ${CURRENT_PATH} python${major_version} ${PYTHON_INSTALL_PATH}/${VERSION} $((${#additional_versions[@]}+1)) + update-alternatives --set python${major_version} ${PYTHON_INSTALL_PATH}/${VERSION} + elif type dnf > /dev/null 2>&1 || type yum > /dev/null 2>&1 || type microdnf > /dev/null 2>&1; then + # Fedora/RHEL/CentOS: Use alternatives + alternatives --install ${CURRENT_PATH} python${major_version} ${PYTHON_INSTALL_PATH}/${VERSION} $((${#additional_versions[@]}+1)) + alternatives --set python${major_version} ${PYTHON_INSTALL_PATH}/${VERSION} + fi + for i in "${!additional_versions[@]}"; do + version=${additional_versions[$i]} OVERRIDE_DEFAULT_VERSION="false" install_python $version + if type apt-get > /dev/null 2>&1; then + # Debian/Ubuntu: Use update-alternatives + update-alternatives --install ${CURRENT_PATH} python${major_version} ${PYTHON_INSTALL_PATH}/${VERSION} $((${i}+1)) + elif type dnf > /dev/null 2>&1 || type yum > /dev/null 2>&1 || type microdnf > /dev/null 2>&1; then + # Fedora/RHEL/CentOS: Use alternatives + alternatives --install ${CURRENT_PATH} python${major_version} ${PYTHON_INSTALL_PATH}/${VERSION} $((${i}+1)) + fi done INSTALL_PATH="${OLD_INSTALL_PATH}" IFS=$OLDIFS @@ -388,16 +974,24 @@ if [ "${PYTHON_VERSION}" != "none" ]; then if [ ${PYTHON_VERSION} != "os-provided" ] && [ ${PYTHON_VERSION} != "system" ]; then updaterc "if [[ \"\${PATH}\" != *\"${CURRENT_PATH}/bin\"* ]]; then export PATH=${CURRENT_PATH}/bin:\${PATH}; fi" - chown -R "${USERNAME}:python" "${PYTHON_INSTALL_PATH}" - chmod -R g+r+w "${PYTHON_INSTALL_PATH}" - find "${PYTHON_INSTALL_PATH}" -type d -print0 | xargs -0 -n 1 chmod g+s - PATH="${INSTALL_PATH}/bin:${PATH}" fi + + # Updates the symlinks for os-provided, or the installed python version in other cases + chown -R "${USERNAME}:python" "${PYTHON_INSTALL_PATH}" + chmod -R g+r+w "${PYTHON_INSTALL_PATH}" + find "${PYTHON_INSTALL_PATH}" -type d -print0 | xargs -0 -n 1 chmod g+s + + PYTHON_SRC="${INSTALL_PATH}/bin/python3" + if ! type pip >/dev/null 2>&1 && type pip3 >/dev/null 2>&1; then + ln -s /usr/bin/pip3 /usr/bin/pip + fi +else + PYTHON_SRC=$(which python) fi # Install Python tools if needed -if [[ "${INSTALL_PYTHON_TOOLS}" = "true" ]] && [[ $(python --version) != "" ]]; then +if [[ "${INSTALL_PYTHON_TOOLS}" = "true" ]] && [[ -n "${PYTHON_SRC}" ]]; then echo 'Installing Python tools...' export PIPX_BIN_DIR="${PIPX_HOME}/bin" PATH="${PATH}:${PIPX_BIN_DIR}" @@ -410,32 +1004,73 @@ if [[ "${INSTALL_PYTHON_TOOLS}" = "true" ]] && [[ $(python --version) != "" ]]; umask 0002 mkdir -p ${PIPX_BIN_DIR} chown -R "${USERNAME}:pipx" ${PIPX_HOME} - chmod -R g+r+w "${PIPX_HOME}" + chmod -R g+r+w "${PIPX_HOME}" find "${PIPX_HOME}" -type d -print0 | xargs -0 -n 1 chmod g+s # Update pip if not using os provided python - if [[ $(python --version) != "" ]] || [[ ${PYTHON_VERSION} != "os-provided" ]] && [[ ${PYTHON_VERSION} != "system" ]] && [[ ${PYTHON_VERSION} != "none" ]]; then + if [[ -n "${PYTHON_SRC}" ]] && [[ ${PYTHON_VERSION} != "os-provided" ]] && [[ ${PYTHON_VERSION} != "system" ]] && [[ ${PYTHON_VERSION} != "none" ]]; then echo "Updating pip..." - python -m pip install --no-cache-dir --upgrade pip + ${PYTHON_SRC} -m pip install --no-cache-dir --upgrade pip fi # Install tools - echo "Installing Python tools..." export PYTHONUSERBASE=/tmp/pip-tmp export PIP_CACHE_DIR=/tmp/pip-tmp/cache PIPX_DIR="" if ! type pipx > /dev/null 2>&1; then - pip3 install --disable-pip-version-check --no-cache-dir --user pipx 2>&1 - /tmp/pip-tmp/bin/pipx install --pip-args=--no-cache-dir pipx - PIPX_DIR="/tmp/pip-tmp/bin" + if python_is_externally_managed ${PYTHON_SRC}; then + check_packages pipx + else + pip3 install --disable-pip-version-check --no-cache-dir --user pipx 2>&1 + /tmp/pip-tmp/bin/pipx install --pip-args=--no-cache-dir pipx + PIPX_DIR="/tmp/pip-tmp/bin/" + fi fi for util in "${DEFAULT_UTILS[@]}"; do if ! type ${util} > /dev/null 2>&1; then - "${PIPX_DIR}/pipx" install --system-site-packages --pip-args '--no-cache-dir --force-reinstall' ${util} + "${PIPX_DIR}pipx" install --system-site-packages --pip-args '--no-cache-dir --force-reinstall' ${util} else echo "${util} already installed. Skipping." fi done + + # Temporary: Removes “setup tools” metadata directory due to https://github.com/advisories/GHSA-r9hx-vwmv-q579 + if [[ $SKIP_VULNERABILITY_PATCHING = "false" ]]; then + VULNERABLE_VERSIONS=("3.10" "3.11") + RUN_TIME_PY_VER_DETECT=$(${PYTHON_SRC} --version 2>&1) + PY_MAJOR_MINOR_VER=${RUN_TIME_PY_VER_DETECT:7:4}; + if [[ ${VULNERABLE_VERSIONS[*]} =~ $PY_MAJOR_MINOR_VER ]]; then + rm -rf ${PIPX_HOME}/shared/lib/"python${PY_MAJOR_MINOR_VER}"/site-packages/setuptools-65.5.0.dist-info + if [[ -e "/usr/local/lib/python${PY_MAJOR_MINOR_VER}/ensurepip/_bundled/setuptools-65.5.0-py3-none-any.whl" ]]; then + # remove the vulnerable setuptools-65.5.0-py3-none-any.whl file + rm /usr/local/lib/python${PY_MAJOR_MINOR_VER}/ensurepip/_bundled/setuptools-65.5.0-py3-none-any.whl + # create and change to the setuptools_downloaded directory + mkdir -p /tmp/setuptools_downloaded + cd /tmp/setuptools_downloaded + # download the source distribution for setuptools using pip + pip download setuptools==65.5.1 --no-binary :all: + # extract the filename of the setuptools-*.tar.gz file + filename=$(find . -maxdepth 1 -type f) + # create a directory to store unpacked contents of the source distribution + mkdir -p /tmp/setuptools_src_dist + # extract the contents inside the new directory + tar -xzf $filename -C /tmp/setuptools_src_dist + # move to the setuptools-* directory inside /setuptools_src_dist + cd /tmp/setuptools_src_dist/setuptools-65.5.1/ + # look for setup.py file in the current directory and create a wheel file + python setup.py bdist_wheel + # move inside the dist directory in pwd + cd dist + # copy this file to the ensurepip/_bundled directory + cp setuptools-65.5.1-py3-none-any.whl /usr/local/lib/python${PY_MAJOR_MINOR_VER}/ensurepip/_bundled/ + # replace the version in __init__.py file with the installed version + sed -i 's/_SETUPTOOLS_VERSION = \"65\.5\.0\"/_SETUPTOOLS_VERSION = "65.5.1"/g' /usr/local/lib/"python${PY_MAJOR_MINOR_VER}"/ensurepip/__init__.py + # cleanup created dir's + rm -rf /tmp/setuptools_downloaded /tmp/setuptools_src_dist + fi + fi + fi + rm -rf /tmp/pip-tmp updaterc "export PIPX_HOME=\"${PIPX_HOME}\"" @@ -445,13 +1080,46 @@ fi # Install JupyterLab if needed if [ "${INSTALL_JUPYTERLAB}" = "true" ]; then - install_user_package jupyterlab + if [ -z "${PYTHON_SRC}" ]; then + echo "(!) Could not install Jupyterlab. Python not found." + exit 1 + fi + + INSTALL_UNDER_ROOT=true + if [ "$(id -u)" -eq 0 ] && [ "$USERNAME" != "root" ]; then + INSTALL_UNDER_ROOT=false + fi + + install_user_package $INSTALL_UNDER_ROOT jupyterlab + install_user_package $INSTALL_UNDER_ROOT jupyterlab-git + + # Create a symlink to the JupyterLab binary for non root users + if [ "$INSTALL_UNDER_ROOT" = false ]; then + JUPYTER_INPATH=/home/${USERNAME}/.local/bin + if [ ! -d "$JUPYTER_INPATH" ]; then + echo "Error: $JUPYTER_INPATH does not exist." + exit 1 + fi + JUPYTER_PATH=/usr/local/jupyter + ln -s "$JUPYTER_INPATH" "$JUPYTER_PATH" + fi # Configure JupyterLab if needed if [ -n "${CONFIGURE_JUPYTERLAB_ALLOW_ORIGIN}" ]; then - add_user_jupyter_config "c.ServerApp.allow_origin = '${CONFIGURE_JUPYTERLAB_ALLOW_ORIGIN}'" - add_user_jupyter_config "c.NotebookApp.allow_origin = '${CONFIGURE_JUPYTERLAB_ALLOW_ORIGIN}'" + # Resolve config directory + CONFIG_DIR="/root/.jupyter" + if [ "$INSTALL_UNDER_ROOT" = false ]; then + CONFIG_DIR="/home/$USERNAME/.jupyter" + fi + + CONFIG_FILE="$CONFIG_DIR/jupyter_server_config.py" + + add_user_jupyter_config $CONFIG_DIR $CONFIG_FILE "c.ServerApp.allow_origin = '${CONFIGURE_JUPYTERLAB_ALLOW_ORIGIN}'" + add_user_jupyter_config $CONFIG_DIR $CONFIG_FILE "c.NotebookApp.allow_origin = '${CONFIGURE_JUPYTERLAB_ALLOW_ORIGIN}'" fi fi +# Clean up +clean_up + echo "Done!" diff --git a/src/ruby/NOTES.md b/src/ruby/NOTES.md new file mode 100644 index 000000000..53da243c1 --- /dev/null +++ b/src/ruby/NOTES.md @@ -0,0 +1,14 @@ + + +## OS Support + +This Feature supports Linux images that ship one of the following package managers: `apt`, `dnf`/`yum`, `apk`, `zypper`, or `pacman`. The script detects the available package manager and installs the build dependencies that ruby-build needs. + +`bash` is required to execute the `install.sh` script. + +## Layout + +- Ruby is installed under `/usr/local/rubies/` by ruby-build. +- The default Ruby is exposed via the `/usr/local/rubies/current` symlink, which is placed on the `PATH` through `containerEnv`. +- `ruby-build` itself is cloned to `/usr/local/share/ruby-build` and symlinked into `/usr/local/bin/ruby-build` so additional versions can be installed later. +- A shared `ruby` group owns `/usr/local/rubies`; the configured non-root user is added to it so `gem install` can write into the active Ruby tree without `sudo`. diff --git a/src/ruby/README.md b/src/ruby/README.md index d5b9601d3..351ffe906 100644 --- a/src/ruby/README.md +++ b/src/ruby/README.md @@ -1,15 +1,13 @@ -# Ruby (via rvm) (ruby) +# Ruby (via ruby-build) (ruby) -Installs Ruby, rvm, rbenv, common Ruby utilities, and needed dependencies. +Installs Ruby using ruby-build, with optional rbenv or rvm for version management. ## Example Usage ```json "features": { - "ghcr.io/devcontainers/features/ruby:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/ruby:2": {} } ``` @@ -18,7 +16,21 @@ Installs Ruby, rvm, rbenv, common Ruby utilities, and needed dependencies. | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Select or enter a Ruby version to install | string | latest | +| versionManager | Version manager to install alongside Ruby: 'rbenv', 'rvm', or 'none' (ruby-build only) | string | none | +## Customizations + +### VS Code Extensions + +- `shopify.ruby-lsp` + + + +## OS Support + +This Feature supports Linux images that ship one of the following package managers: `apt`, `dnf`/`yum`, `apk`, `zypper`, or `pacman`. The script detects the available package manager and installs the build dependencies that ruby-build needs. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/ruby/devcontainer-feature.json b/src/ruby/devcontainer-feature.json index 9e617d0d4..841afbc3d 100644 --- a/src/ruby/devcontainer-feature.json +++ b/src/ruby/devcontainer-feature.json @@ -1,34 +1,47 @@ { "id": "ruby", - "version": "1.0.4", - "name": "Ruby (via rvm)", + "version": "2.0.0", + "name": "Ruby (via ruby-build)", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/ruby", - "description": "Installs Ruby, rvm, rbenv, common Ruby utilities, and needed dependencies.", + "description": "Installs Ruby using ruby-build, with optional rbenv or rvm for version management.", "options": { "version": { "type": "string", "proposals": [ "latest", "none", - "3.1", - "3.0", - "2.7" + "4.0", + "3.4" ], "default": "latest", "description": "Select or enter a Ruby version to install" + }, + "versionManager": { + "type": "string", + "enum": ["none", "rbenv", "rvm"], + "default": "none", + "description": "Version manager to install alongside Ruby: 'rbenv', 'rvm', or 'none' (ruby-build only)" } }, "customizations": { "vscode": { "extensions": [ - "rebornix.Ruby" - ] + "shopify.ruby-lsp" + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container installs Ruby via ruby-build. rbenv or rvm may also be available depending on the versionManager option. The default Ruby is on the PATH via /usr/local/rubies/current/bin (or rbenv shims if rbenv is the version manager)." + } + ] + } } }, "containerEnv": { - "GEM_PATH": "/usr/local/rvm/gems/default:/usr/local/rvm/gems/default@global", - "GEM_HOME": "/usr/local/rvm/gems/default", - "MY_RUBY_HOME": "/usr/local/rvm/rubies/default", - "PATH": "/usr/local/rvm/gems/default/bin:/usr/local/rvm/gems/default@global/bin:/usr/local/rvm/rubies/default/bin:/usr/local/share/rbenv/bin:${PATH}" - } -} \ No newline at end of file + "RBENV_ROOT": "/usr/local/share/rbenv", + "PATH": "/usr/local/share/rbenv/shims:/usr/local/share/rbenv/bin:/usr/local/rubies/current/bin:${PATH}" + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/src/ruby/install.sh b/src/ruby/install.sh index 6ff3b2f87..2b990cda8 100755 --- a/src/ruby/install.sh +++ b/src/ruby/install.sh @@ -7,27 +7,31 @@ # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/ruby.md # Maintainer: The VS Code and Codespaces Teams -RUBY_VERSION=${VERSION:-"latest"} +RUBY_VERSION="${VERSION:-"latest"}" -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} -INSTALL_RUBY_TOOLS=${INSTALL_RUBY_TOOLS:-"true"} +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +INSTALL_RUBY_TOOLS="${INSTALL_RUBY_TOOLS:-"true"}" -# Comma-separated list of ruby versions to be installed (with rvm) -# alongside RUBY_VERSION, but not set as default. -ADDITIONAL_VERSIONS=${ADDITIONALVERSIONS:-""} +# Comma-separated list of ruby versions to be installed alongside RUBY_VERSION, +# but not set as default. +ADDITIONAL_VERSIONS="${ADDITIONALVERSIONS:-""}" -# Note: ruby-debug-ide will install the right version of debase if missing and -# installing debase directly fails on Ruby 3.1.0 as of 1/7/2022, so omitting. -DEFAULT_GEMS="rake ruby-debug-ide" +VERSION_MANAGER="${VERSIONMANAGER:-"none"}" +DEFAULT_GEMS="rake" + +RUBY_BUILD_DIR="/usr/local/share/ruby-build" +RUBIES_DIR="/usr/local/rubies" +RUBY_GROUP="ruby" +RBENV_ROOT="/usr/local/share/rbenv" +RVM_PATH="/usr/local/rvm" RVM_GPG_KEYS="409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB" -GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80 -keyserver hkps://keys.openpgp.org -keyserver hkp://keyserver.pgp.com" set -e +# Force apt to refresh its lists below by clearing them up front (no-op on non-apt systems). +rm -rf /var/lib/apt/lists/* 2>/dev/null || true + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -38,7 +42,6 @@ rm -f /etc/profile.d/00-restore-env.sh echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh chmod +x /etc/profile.d/00-restore-env.sh -# Determine the appropriate non-root user if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then USERNAME="" POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") @@ -55,58 +58,87 @@ elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then USERNAME=root fi -updaterc() { - if [ "${UPDATE_RC}" = "true" ]; then - echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." - if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/bash.bashrc - fi - if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/zsh/zshrc - fi +architecture="$(uname -m)" +if [ "${architecture}" != "amd64" ] && [ "${architecture}" != "x86_64" ] && [ "${architecture}" != "arm64" ] && [ "${architecture}" != "aarch64" ]; then + echo "(!) Architecture $architecture unsupported" + exit 1 +fi + +clone_or_update_repo() { + local repo=$1 dest=$2 + if [ ! -d "${dest}" ]; then + git clone --depth=1 \ + -c core.eol=lf \ + -c core.autocrlf=false \ + -c fsck.zeroPaddedFilemode=ignore \ + -c fetch.fsck.zeroPaddedFilemode=ignore \ + -c receive.fsck.zeroPaddedFilemode=ignore \ + "${repo}" "${dest}" + else + git -C "${dest}" fetch --depth=1 origin && \ + git -C "${dest}" reset --hard origin/HEAD || true fi } -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true +apply_group_perms() { + local dir=$1 + chgrp -R "${RUBY_GROUP}" "${dir}" 2>/dev/null || true + chmod -R g+rw "${dir}" 2>/dev/null || true + find "${dir}" -type d -exec chmod g+s {} + 2>/dev/null || true +} + +get_gpg_key_servers() { + declare -A keyservers_curl_map=( + ["hkp://keyserver.ubuntu.com"]="http://keyserver.ubuntu.com:11371" + ["hkp://keyserver.ubuntu.com:80"]="http://keyserver.ubuntu.com" + ["hkps://keys.openpgp.org"]="https://keys.openpgp.org" + ["hkp://keyserver.pgp.com"]="http://keyserver.pgp.com:11371" + ) + + local curl_args="" + local keyserver_reachable=false + + if [ -n "${KEYSERVER_PROXY:-}" ]; then + curl_args="--proxy ${KEYSERVER_PROXY}" fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi + + for keyserver in "${!keyservers_curl_map[@]}"; do + local keyserver_curl_url="${keyservers_curl_map[${keyserver}]}" + if curl -s ${curl_args} --max-time 5 "${keyserver_curl_url}" > /dev/null; then + echo "keyserver ${keyserver}" + keyserver_reachable=true + else + echo "(*) Keyserver ${keyserver} is not reachable." >&2 + fi + done + + if ! $keyserver_reachable; then + echo "(!) No keyserver is reachable." >&2 + exit 1 fi - echo "$1=${!1}" } -# Import the specified key in a variable name passed in as receive_gpg_keys() { - get_common_setting $1 local keys=${!1} - get_common_setting GPG_KEY_SERVERS true local keyring_args="" - if [ ! -z "$2" ]; then + if [ -n "${2:-}" ]; then keyring_args="--no-default-keyring --keyring \"$2\"" fi - # Use a temporary locaiton for gpg keys to avoid polluting image export GNUPGHOME="/tmp/tmp-gnupg" - mkdir -p ${GNUPGHOME} - chmod 700 ${GNUPGHOME} - echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf - # GPG key download sometimes fails for some reason and retrying fixes it. + mkdir -p "${GNUPGHOME}" + chmod 700 "${GNUPGHOME}" + echo -e "disable-ipv6\n$(get_gpg_key_servers)" > "${GNUPGHOME}/dirmngr.conf" + local retry_count=0 local gpg_ok="false" set +e - until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; - do + until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; do echo "(*) Downloading GPG key..." + # shellcheck disable=SC2086 ( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true" if [ "${gpg_ok}" != "true" ]; then - echo "(*) Failed getting key, retring in 10s..." + echo "(*) Failed getting key, retrying in 10s..." (( retry_count++ )) sleep 10s fi @@ -118,187 +150,305 @@ receive_gpg_keys() { fi } -# Figure out correct version of a three part version number is not passed -find_version_from_git_tags() { - local variable_name=$1 - local requested_version=${!variable_name} - if [ "${requested_version}" = "none" ]; then return; fi - local repository=$2 - local prefix=${3:-"tags/v"} - local separator=${4:-"."} - local last_part_optional=${5:-"false"} - if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then - local escaped_separator=${separator//./\\.} - local last_part - if [ "${last_part_optional}" = "true" ]; then - last_part="(${escaped_separator}[0-9]+)?" - else - last_part="${escaped_separator}[0-9]+" - fi - local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" - local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" - if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then - declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" - else - set +e - declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" - set -e - fi - fi - if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then - echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 +default_ruby_version() { + [ -L "${RUBIES_DIR}/current" ] && basename "$(readlink "${RUBIES_DIR}/current")" +} + +install_build_deps() { + if command -v apt-get > /dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + # libgdbm-dev pulls the appropriate libgdbm runtime, so no version-specific package is needed. + apt-get -y install --no-install-recommends \ + curl ca-certificates git autoconf bison patch build-essential \ + libssl-dev libyaml-dev libreadline-dev zlib1g-dev libgmp-dev \ + libncurses-dev libffi-dev libgdbm-dev libdb-dev uuid-dev + elif command -v dnf > /dev/null 2>&1 || command -v yum > /dev/null 2>&1; then + local pm + pm="$(command -v dnf || command -v yum)" + "${pm}" install -y \ + curl ca-certificates git gcc make patch autoconf bison \ + openssl-devel libyaml-devel zlib-devel libffi-devel \ + readline-devel ncurses-devel gdbm-devel + elif command -v apk > /dev/null 2>&1; then + apk add --no-cache \ + bash curl ca-certificates git build-base linux-headers \ + autoconf bison patch openssl-dev yaml-dev zlib-dev \ + readline-dev ncurses-dev libffi-dev gdbm-dev + elif command -v zypper > /dev/null 2>&1; then + zypper --non-interactive install --no-recommends \ + curl ca-certificates git gcc-c++ make patch \ + autoconf automake libtool bison \ + libopenssl-devel libyaml-devel zlib-devel libffi-devel \ + readline-devel ncurses-devel gdbm-devel + elif command -v pacman > /dev/null 2>&1; then + pacman -Sy --noconfirm --needed \ + curl ca-certificates git base-devel autoconf bison \ + openssl libyaml zlib libffi readline ncurses gdbm + else + echo "(!) No supported package manager found. Install Ruby build dependencies manually." exit 1 fi - echo "${variable_name}=${!variable_name}" } -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y +install_ruby_build() { + clone_or_update_repo "https://github.com/rbenv/ruby-build.git" "${RUBY_BUILD_DIR}" + ln -sf "${RUBY_BUILD_DIR}/bin/ruby-build" /usr/local/bin/ruby-build } -# Checks if packages are installed and installs them if not -check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" +resolve_ruby_version() { + local requested=$1 + local definitions_dir="${RUBY_BUILD_DIR}/share/ruby-build" + local stable_versions + stable_versions="$(ls "${definitions_dir}" 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)" + + if [ -z "${stable_versions}" ]; then + echo "(!) ruby-build has no version definitions at ${definitions_dir}." >&2 + exit 1 fi + + case "${requested}" in + latest|current|lts) + echo "${stable_versions}" | tail -n 1 + ;; + *) + if echo "${stable_versions}" | grep -qx "${requested}"; then + echo "${requested}" + return + fi + # Resolve a partial X.Y to the highest matching X.Y.Z. + local match + match="$(echo "${stable_versions}" | grep -E "^${requested//./\\.}\\.[0-9]+$" | sort -V | tail -n 1)" + if [ -n "${match}" ]; then + echo "${match}" + return + fi + echo "(!) Ruby version '${requested}' is not known to ruby-build." >&2 + exit 1 + ;; + esac } -# Ensure apt is in non-interactive to avoid prompts -export DEBIAN_FRONTEND=noninteractive +install_ruby_version() { + local requested=$1 + local set_default=$2 + local resolved + resolved="$(resolve_ruby_version "${requested}")" + local prefix="${RUBIES_DIR}/${resolved}" + + if [ -x "${prefix}/bin/ruby" ]; then + echo "(!) Ruby ${resolved} already installed at ${prefix}. Skipping..." + elif [ "${VERSION_MANAGER}" = "rbenv" ] && [ -x "${RBENV_ROOT}/bin/rbenv" ]; then + echo "Installing Ruby ${resolved} via rbenv..." + mkdir -p "${RUBIES_DIR}" + LANG="${LANG:-C.UTF-8}" RBENV_ROOT="${RBENV_ROOT}" \ + "${RBENV_ROOT}/bin/rbenv" install --skip-existing "${resolved}" + # Mirror into RUBIES_DIR so the rest of the script uses a consistent path. + ln -sfn "${RBENV_ROOT}/versions/${resolved}" "${prefix}" + elif [ "${VERSION_MANAGER}" = "rvm" ] && [ -s "${RVM_PATH}/scripts/rvm" ]; then + echo "Installing Ruby ${resolved} via rvm..." + mkdir -p "${RUBIES_DIR}" + # shellcheck disable=SC1091 + source "${RVM_PATH}/scripts/rvm" + LANG="${LANG:-C.UTF-8}" rvm install "${resolved}" + local rvm_ruby="${RVM_PATH}/rubies/ruby-${resolved}" + if [ -d "${rvm_ruby}" ]; then + ln -sfn "${rvm_ruby}" "${prefix}" + fi + else + mkdir -p "${RUBIES_DIR}" + echo "Installing Ruby ${resolved} via ruby-build..." + # Ensure a UTF-8 locale so that rdoc (and other tools bundled with Ruby) + # can process non-ASCII bytes during `make install`, even on minimal + # base images that ship with no LANG set (e.g. Debian 11 bullseye). + LANG="${LANG:-C.UTF-8}" ruby-build "${resolved}" "${prefix}" + fi -architecture="$(uname -m)" -if [ "${architecture}" != "amd64" ] && [ "${architecture}" != "x86_64" ] && [ "${architecture}" != "arm64" ] && [ "${architecture}" != "aarch64" ]; then - echo "(!) Architecture $architecture unsupported" - exit 1 -fi + if [ "${set_default}" = "true" ]; then + ln -sfn "${prefix}" "${RUBIES_DIR}/current" + fi +} -# Install dependencies -check_packages curl ca-certificates software-properties-common build-essential gnupg2 libreadline-dev \ - procps dirmngr gawk autoconf automake bison libffi-dev libgdbm-dev libncurses5-dev \ - libsqlite3-dev libtool libyaml-dev pkg-config sqlite3 zlib1g-dev libgmp-dev libssl-dev -if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends git -fi +# Called before ruby versions are installed so that install_ruby_version() +# can delegate to 'rbenv install'. +install_rbenv() { + echo "Installing rbenv..." + clone_or_update_repo "https://github.com/rbenv/rbenv.git" "${RBENV_ROOT}" + ln -sf "${RBENV_ROOT}/bin/rbenv" /usr/local/bin/rbenv -# Figure out correct version of a three part version number is not passed -find_version_from_git_tags RUBY_VERSION "https://github.com/ruby/ruby" "tags/v" "_" + # Wire the already-installed ruby-build as an rbenv plugin so that + # 'rbenv install' works out of the box. + mkdir -p "${RBENV_ROOT}/plugins" + if [ ! -e "${RBENV_ROOT}/plugins/ruby-build" ]; then + ln -sfn "${RUBY_BUILD_DIR}" "${RBENV_ROOT}/plugins/ruby-build" + fi + mkdir -p "${RBENV_ROOT}/versions" + echo "rbenv ready at ${RBENV_ROOT}." +} -# Just install Ruby if RVM already installed -if [ $(rvm --version) != "" ]; then - echo "Ruby Version Manager already exists." - if [ "${RUBY_VERSION}" != "none" ]; then - echo "Installing specified Ruby version." - su ${USERNAME} -c "rvm install ruby ${RUBY_VERSION}" +# Called after ruby versions are installed. +finalize_rbenv() { + # When rubies were installed via ruby-build (not 'rbenv install'), symlink them + # into rbenv's versions directory so 'rbenv versions' shows them. + # Skip entries that already point into RBENV_ROOT to avoid circular symlinks. + for ruby_dir in "${RUBIES_DIR}"/[0-9]*/; do + [ -d "${ruby_dir}" ] || continue + local ver + ver="$(basename "${ruby_dir%/}")" + local real_target + real_target="$(readlink -f "${ruby_dir%/}" 2>/dev/null || true)" + if [ "${real_target}" = "${RBENV_ROOT}/versions/${ver}" ]; then + continue + fi + ln -sfn "${ruby_dir%/}" "${RBENV_ROOT}/versions/${ver}" + done + + # Set the rbenv global version to match the ruby-build default. + local default_ver + default_ver="$(default_ruby_version)" + if [ -n "${default_ver}" ]; then + echo "${default_ver}" > "${RBENV_ROOT}/version" fi - SKIP_GEM_INSTALL="false" - SKIP_RBENV_RBUILD="true" -else - # Install RVM + + apply_group_perms "${RBENV_ROOT}" + + # Profile script for login shells (non-login shells rely on containerEnv + # which already prepends RBENV_ROOT/shims and RBENV_ROOT/bin). + cat > /etc/profile.d/rbenv.sh << 'RBENV_PROFILE' +export RBENV_ROOT=/usr/local/share/rbenv +export PATH="${RBENV_ROOT}/bin:${RBENV_ROOT}/shims:${PATH}" +eval "$(rbenv init - --no-rehash)" 2>/dev/null || true +RBENV_PROFILE + chmod +x /etc/profile.d/rbenv.sh + + RBENV_ROOT="${RBENV_ROOT}" "${RBENV_ROOT}/bin/rbenv" rehash 2>/dev/null || true + echo "rbenv configured." +} + +# Called before ruby versions are installed so that install_ruby_version() +# can delegate to 'rvm install'. +install_rvm() { + echo "Installing rvm..." + receive_gpg_keys RVM_GPG_KEYS - # Determine appropriate settings for rvm installer - if [ "${RUBY_VERSION}" = "none" ]; then - RVM_INSTALL_ARGS="" - else - if [ "${RUBY_VERSION}" = "latest" ] || [ "${RUBY_VERSION}" = "current" ] || [ "${RUBY_VERSION}" = "lts" ]; then - RVM_INSTALL_ARGS="--ruby" - RUBY_VERSION="" + + curl -sSL https://get.rvm.io | bash -s stable --path "${RVM_PATH}" + + # rvm is a shell function, so we must source it before calling 'rvm' below. + # shellcheck disable=SC1091 + if [ -s "${RVM_PATH}/scripts/rvm" ]; then + source "${RVM_PATH}/scripts/rvm" + fi + echo "rvm ready at ${RVM_PATH}." +} + +finalize_rvm() { + # shellcheck disable=SC1091 + [ -s "${RVM_PATH}/scripts/rvm" ] && source "${RVM_PATH}/scripts/rvm" || true + + # When rubies were installed via ruby-build (not 'rvm install'), mount them + # into rvm so 'rvm list' shows them. Skip entries that already live under + # RVM_PATH to avoid double-mounting. + if [ -d "${RUBIES_DIR}" ]; then + for ruby_dir in "${RUBIES_DIR}"/[0-9]*/; do + [ -d "${ruby_dir}" ] || continue + local ver + ver="$(basename "${ruby_dir%/}")" + local real_target + real_target="$(readlink -f "${ruby_dir%/}" 2>/dev/null || true)" + if [[ "${real_target}" == "${RVM_PATH}/rubies/"* ]]; then + continue + fi + rvm mount "${ruby_dir%/}" -n "${ver}" 2>/dev/null || true + done + fi + + # Set the rvm default to match the ruby-build default. + local default_ver + default_ver="$(default_ruby_version)" + if [ -n "${default_ver}" ]; then + local real_current + real_current="$(readlink -f "${RUBIES_DIR}/current" 2>/dev/null || true)" + if [[ "${real_current}" == "${RVM_PATH}/rubies/"* ]]; then + # Installed via 'rvm install': use the version name directly. + rvm use "${default_ver}" --default 2>/dev/null || true else - RVM_INSTALL_ARGS="--ruby=${RUBY_VERSION}" - fi - if [ "${INSTALL_RUBY_TOOLS}" = "true" ]; then - SKIP_GEM_INSTALL="true" - else - DEFAULT_GEMS="" + # Installed via ruby-build and mounted: use the 'ext-' prefix. + rvm use "ext-${default_ver}" --default 2>/dev/null || true fi fi - # Create rvm group as a system group to reduce the odds of conflict with local user UIDs - if ! cat /etc/group | grep -e "^rvm:" > /dev/null 2>&1; then - groupadd -r rvm + + echo "source ${RVM_PATH}/scripts/rvm" > /etc/profile.d/rvm.sh + chmod +x /etc/profile.d/rvm.sh + + if [ "${USERNAME}" != "root" ] && id -u "${USERNAME}" > /dev/null 2>&1; then + usermod -aG rvm "${USERNAME}" 2>/dev/null || true fi - # Install rvm - curl -sSL https://get.rvm.io | bash -s stable --ignore-dotfiles ${RVM_INSTALL_ARGS} --with-default-gems="${DEFAULT_GEMS}" 2>&1 - usermod -aG rvm ${USERNAME} - source /usr/local/rvm/scripts/rvm - rvm fix-permissions system - rm -rf ${GNUPGHOME} -fi + echo "rvm configured." +} -if [ "${INSTALL_RUBY_TOOLS}" = "true" ]; then - # Non-root user may not have "gem" in path when script is run and no ruby version - # is installed by rvm, so handle this by using root's default gem in this case - ROOT_GEM="$(which gem || echo "")" - ${ROOT_GEM} install ${DEFAULT_GEMS} +install_build_deps +install_ruby_build + +# Create a shared "ruby" group so the configured user can write under the rubies tree. +if ! getent group "${RUBY_GROUP}" > /dev/null 2>&1; then + groupadd -r "${RUBY_GROUP}" 2>/dev/null || addgroup -S "${RUBY_GROUP}" 2>/dev/null || true +fi +mkdir -p "${RUBIES_DIR}" +chgrp "${RUBY_GROUP}" "${RUBIES_DIR}" 2>/dev/null || true +chmod 2775 "${RUBIES_DIR}" 2>/dev/null || true + +# Set up the version manager BEFORE installing Ruby versions so that +# install_ruby_version() can delegate to it when requested. +if [ "${VERSION_MANAGER}" = "rbenv" ]; then + install_rbenv +elif [ "${VERSION_MANAGER}" = "rvm" ]; then + install_rvm fi -# VS Code server usually first in the path, so silence annoying rvm warning (that does not apply) and then source it -updaterc "if ! grep rvm_silence_path_mismatch_check_flag \$HOME/.rvmrc > /dev/null 2>&1; then echo 'rvm_silence_path_mismatch_check_flag=1' >> \$HOME/.rvmrc; fi\nsource /usr/local/rvm/scripts/rvm > /dev/null 2>&1" +if [ "${RUBY_VERSION}" != "none" ]; then + install_ruby_version "${RUBY_VERSION}" "true" +fi -# Additional ruby versions to be installed but not be set as default. if [ ! -z "${ADDITIONAL_VERSIONS}" ]; then OLDIFS=$IFS IFS="," read -a additional_versions <<< "$ADDITIONAL_VERSIONS" for version in "${additional_versions[@]}"; do - # Figure out correct version of a three part version number is not passed - find_version_from_git_tags version "https://github.com/ruby/ruby" "tags/v" "_" - source /usr/local/rvm/scripts/rvm - rvm install ruby ${version} + install_ruby_version "${version}" "false" done IFS=$OLDIFS fi -# Install rbenv/ruby-build for good measure -if [ "${SKIP_RBENV_RBUILD}" != "true" ]; then - - if [[ ! -d "/usr/local/share/rbenv" ]]; then - git clone --depth=1 \ - -c core.eol=lf \ - -c core.autocrlf=false \ - -c fsck.zeroPaddedFilemode=ignore \ - -c fetch.fsck.zeroPaddedFilemode=ignore \ - -c receive.fsck.zeroPaddedFilemode=ignore \ - https://github.com/rbenv/rbenv.git /usr/local/share/rbenv - fi +# Expose the default Ruby on the PATH for all login shells. +echo 'export PATH="/usr/local/rubies/current/bin:${PATH}"' > /etc/profile.d/ruby.sh +chmod +x /etc/profile.d/ruby.sh - if [[ ! -d "/usr/local/share/ruby-build" ]]; then - git clone --depth=1 \ - -c core.eol=lf \ - -c core.autocrlf=false \ - -c fsck.zeroPaddedFilemode=ignore \ - -c fetch.fsck.zeroPaddedFilemode=ignore \ - -c receive.fsck.zeroPaddedFilemode=ignore \ - https://github.com/rbenv/ruby-build.git /usr/local/share/ruby-build - mkdir -p /root/.rbenv/plugins +if [ "${RUBY_VERSION}" != "none" ] && [ "${INSTALL_RUBY_TOOLS}" = "true" ]; then + "${RUBIES_DIR}/current/bin/gem" install --no-document ${DEFAULT_GEMS} +fi - ln -s /usr/local/share/ruby-build /root/.rbenv/plugins/ruby-build +# Make sure the configured user can install gems against the shared rubies tree. +if [ "${USERNAME}" != "root" ] && id -u "${USERNAME}" > /dev/null 2>&1; then + if command -v usermod > /dev/null 2>&1; then + usermod -aG "${RUBY_GROUP}" "${USERNAME}" || true + elif command -v addgroup > /dev/null 2>&1; then + addgroup "${USERNAME}" "${RUBY_GROUP}" || true fi +fi - if [ "${USERNAME}" != "root" ]; then - mkdir -p /home/${USERNAME}/.rbenv/plugins - - if [[ ! -d "/home/${USERNAME}/.rbenv/plugins/ruby-build" ]]; then - ln -s /usr/local/share/ruby-build /home/${USERNAME}/.rbenv/plugins/ruby-build - fi +apply_group_perms "${RUBIES_DIR}" - ln -s /usr/local/rvm/rubies/default/bin/ruby /usr/local/rvm/gems/default/bin - - chown -R "${USERNAME}:rvm" "/home/${USERNAME}/.rbenv/" - chmod -R g+r+w "/home/${USERNAME}/.rbenv" - find "/home/${USERNAME}/.rbenv" -type d | xargs -n 1 chmod g+s - fi +if command -v apt-get > /dev/null 2>&1; then + rm -rf /var/lib/apt/lists/* fi -chown -R "${USERNAME}:rvm" "/usr/local/rvm/" -chmod -R g+r+w "/usr/local/rvm/" -find "/usr/local/rvm/" -type d | xargs -n 1 chmod g+s - -# Clean up -rvm cleanup all -${ROOT_GEM} cleanup +# Finalize the version manager now that all ruby versions are installed. +if [ "${VERSION_MANAGER}" = "rbenv" ]; then + finalize_rbenv +elif [ "${VERSION_MANAGER}" = "rvm" ]; then + finalize_rvm +fi echo "Done!" diff --git a/src/rust/NOTES.md b/src/rust/NOTES.md new file mode 100644 index 000000000..68d170302 --- /dev/null +++ b/src/rust/NOTES.md @@ -0,0 +1,12 @@ + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, RockyLinux, +Mariner and Azure Linux distributions with the `apt`, `yum`, `dnf`, `microdnf` and `tdnf` package manager installed. + + +**Note:** Alpine is not supported because the rustup-init binary requires glibc to run, but Alpine Linux does not include `glibc` +by default. Instead, it uses musl libc, which is not binary-compatible with glibc. + +`bash` is required to execute the `install.sh` script. diff --git a/src/rust/README.md b/src/rust/README.md index 046b67432..eca22932c 100644 --- a/src/rust/README.md +++ b/src/rust/README.md @@ -7,9 +7,7 @@ Installs Rust, common Rust utilities, and their required dependencies ```json "features": { - "ghcr.io/devcontainers/features/rust:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/rust:1": {} } ``` @@ -19,7 +17,29 @@ Installs Rust, common Rust utilities, and their required dependencies |-----|-----|-----|-----| | version | Select or enter a version of Rust to install. | string | latest | | profile | Select a rustup install profile. | string | minimal | +| targets | Optional comma separated list of additional Rust targets to install. | string | - | +| components | Optional, comma separated list of Rust components to be installed | string | rust-analyzer,rust-src,rustfmt,clippy | +## Customizations + +### VS Code Extensions + +- `vadimcn.vscode-lldb` +- `rust-lang.rust-analyzer` +- `tamasfe.even-better-toml` + + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, RockyLinux, +Mariner and Azure Linux distributions with the `apt`, `yum`, `dnf`, `microdnf` and `tdnf` package manager installed. + + +**Note:** Alpine is not supported because the rustup-init binary requires glibc to run, but Alpine Linux does not include `glibc` +by default. Instead, it uses musl libc, which is not binary-compatible with glibc. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/rust/devcontainer-feature.json b/src/rust/devcontainer-feature.json index e843f22bc..d8d399cde 100644 --- a/src/rust/devcontainer-feature.json +++ b/src/rust/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "rust", - "version": "1.0.6", + "version": "1.5.1", "name": "Rust", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/rust", "description": "Installs Rust, common Rust utilities, and their required dependencies", @@ -10,9 +10,30 @@ "proposals": [ "latest", "none", - "1.63", - "1.62", - "1.61" + "1.87", + "1.86", + "1.85", + "1.84", + "1.83", + "1.82", + "1.81", + "1.80", + "1.79", + "1.78", + "1.77", + "1.76", + "1.75", + "1.74", + "1.73", + "1.72", + "1.71", + "1.70", + "1.69", + "1.68", + "1.67", + "1.66", + "1.65", + "1.64" ], "default": "latest", "description": "Select or enter a version of Rust to install." @@ -26,23 +47,45 @@ ], "default": "minimal", "description": "Select a rustup install profile." - } + }, + "targets": { + "type": "string", + "default": "", + "description": "Optional comma separated list of additional Rust targets to install.", + "proposals": [ + "aarch64-unknown-linux-gnu", + "armv7-unknown-linux-gnueabihf", + "x86_64-unknown-redox,x86_64-unknown-uefi" + ] + }, + "components": { + "type": "string", + "default": "rust-analyzer,rust-src,rustfmt,clippy", + "description": "Optional, comma separated list of Rust components to be installed", + "proposals": [ + "rust-analyzer,rust-src,rustfmt,clippy", + "rust-analyzer,rust-src", + "rustfmt,clippy,rust-docs", + "llvm-tools-preview,rust-src,rustfmt" + ] + } }, "customizations": { "vscode": { "extensions": [ "vadimcn.vscode-lldb", - "mutantdino.resourcemonitor", "rust-lang.rust-analyzer", - "tamasfe.even-better-toml", - "serayuzgur.crates" + "tamasfe.even-better-toml" ], "settings": { - "lldb.executable": "/usr/bin/lldb", "files.watcherExclude": { "**/target/**": true }, - "rust-analyzer.checkOnSave.command": "clippy" + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes Rust, common Rust utilities, and needed dependencies pre-installed and available on the `PATH`, along with the Rust language extension for Rust development." + } + ] } } }, @@ -56,5 +99,8 @@ ], "securityOpt": [ "seccomp=unconfined" + ], + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" ] } diff --git a/src/rust/install.sh b/src/rust/install.sh index d8d3b74dc..56bb35ea8 100755 --- a/src/rust/install.sh +++ b/src/rust/install.sh @@ -7,17 +7,90 @@ # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/rust.md # Maintainer: The VS Code and Codespaces Teams -RUST_VERSION=${VERSION:-"latest"} -RUSTUP_PROFILE=${PROFILE:-"minimal"} +RUST_VERSION="${VERSION:-"latest"}" +RUSTUP_PROFILE="${PROFILE:-"minimal"}" +RUSTUP_TARGETS="${TARGETS:-""}" +IFS=',' read -ra components <<< "${COMPONENTS:-rust-analyzer,rust-src,rustfmt,clippy}" -export CARGO_HOME=${CARGO_HOME:-"/usr/local/cargo"} -export RUSTUP_HOME=${RUSTUP_HOME:-"/usr/local/rustup"} -USERNAME=${USERNAME:-"automatic"} -UPDATE_RC=${UPDATE_RC:-"true"} -UPDATE_RUST=${UPDATE_RUST:-"false"} +export CARGO_HOME="${CARGO_HOME:-"/usr/local/cargo"}" +export RUSTUP_HOME="${RUSTUP_HOME:-"/usr/local/rustup"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +UPDATE_RC="${UPDATE_RC:-"true"}" +UPDATE_RUST="${UPDATE_RUST:-"false"}" set -e +# Detect the Linux distribution and package manager +PKG_MANAGER="" + +# Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME +. /etc/os-release +# Get an adjusted ID independent of distro variants +if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" +elif [ "${ID}" = "alpine" ]; then + ADJUSTED_ID="alpine" +elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "azurelinux" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"azurelinux"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + VERSION_CODENAME="${ID}${VERSION_ID}" +else + echo "Linux distro ${ID} not supported." + exit 1 +fi + +if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${VERSION_CODENAME-}" = "centos7" ]; then + # As of 1 July 2024, mirrorlist.centos.org no longer exists. + # Update the repo files to reference vault.centos.org. + sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo + sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo + sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo +fi + + +# Detect package manager +if command -v apt-get >/dev/null 2>&1; then + PKG_MANAGER="apt" +elif command -v dnf >/dev/null 2>&1; then + PKG_MANAGER="dnf" +elif command -v yum >/dev/null 2>&1; then + PKG_MANAGER="yum" +elif command -v microdnf >/dev/null 2>&1; then + PKG_MANAGER="microdnf" +elif command -v tdnf >/dev/null 2>&1; then + PKG_MANAGER="tdnf" +else + echo "No supported package manager found. Supported: apt, dnf, yum, microdnf, tdnf" + exit 1 +fi + +echo "Detected package manager: $PKG_MANAGER" + +# Clean up based on package manager +clean_package_cache() { + case "$PKG_MANAGER" in + apt) + if [ "$(ls -1 /var/lib/apt/lists/ 2>/dev/null | wc -l)" -gt 0 ]; then + rm -rf /var/lib/apt/lists/* + fi + ;; + dnf|yum|microdnf) + if command -v dnf >/dev/null 2>&1; then + dnf clean all + elif command -v yum >/dev/null 2>&1; then + yum clean all + elif command -v microdnf >/dev/null 2>&1; then + microdnf clean all + fi + ;; + tdnf) + tdnf clean all + ;; + esac +} + +# Initial cleanup +clean_package_cache + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -33,7 +106,7 @@ if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then USERNAME="" POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if id -u ${CURRENT_USER} > /dev/null 2>&1; then + if id -u "${CURRENT_USER}" > /dev/null 2>&1; then USERNAME=${CURRENT_USER} break fi @@ -41,25 +114,10 @@ if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then if [ "${USERNAME}" = "" ]; then USERNAME=root fi -elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then +elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" > /dev/null 2>&1; then USERNAME=root fi -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true - fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi - fi - echo "$1=${!1}" -} - # Figure out correct version of a three part version number is not passed find_version_from_git_tags() { local variable_name=$1 @@ -68,7 +126,7 @@ find_version_from_git_tags() { local repository=$2 local prefix=${3:-"tags/v"} local separator=${4:-"."} - local last_part_optional=${5:-"false"} + local last_part_optional=${5:-"false"} if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then local escaped_separator=${separator//./\\.} local last_part @@ -78,7 +136,7 @@ find_version_from_git_tags() { last_part="${escaped_separator}[0-9]+" fi local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" - local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + local version_list="$(git ls-remote --tags "${repository}" | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" else @@ -101,13 +159,13 @@ check_nightly_version_formatting() { local version_date=$(echo ${requested_version} | sed -e "s/^nightly-//") - date -d ${version_date} &>/dev/null - if [ $? != 0 ]; then + + if ! date -d "${version_date}" &>/dev/null; then echo -e "Invalid ${variable_name} value: ${requested_version}\nNightly version should be in the format nightly-YYYY-MM-DD" >&2 exit 1 fi - if [ $(date -d ${version_date} +%s) -ge $(date +%s) ]; then + if [ "$(date -d "${version_date}" +%s)" -ge "$(date +%s)" ]; then echo -e "Invalid ${variable_name} value: ${requested_version}\nNightly version should not exceed current date" >&2 exit 1 fi @@ -115,38 +173,175 @@ check_nightly_version_formatting() { updaterc() { if [ "${UPDATE_RC}" = "true" ]; then - echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." - if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then - echo -e "$1" >> /etc/bash.bashrc + echo "Updating shell configuration files..." + local bashrc_file="/etc/bash.bashrc" + + # Different distributions use different bashrc locations + if [ ! -f "$bashrc_file" ]; then + if [ -f "/etc/bashrc" ]; then + bashrc_file="/etc/bashrc" + elif [ -f "/etc/bash/bashrc" ]; then + bashrc_file="/etc/bash/bashrc" + fi fi + + if [ -f "$bashrc_file" ] && [[ "$(cat "$bashrc_file")" != *"$1"* ]]; then + echo -e "$1" >> "$bashrc_file" + fi + if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then echo -e "$1" >> /etc/zsh/zshrc fi fi } -apt_get_update() -{ - echo "Running apt-get update..." - apt-get update -y +# Package update functions +pkg_mgr_update() { + case "$PKG_MANAGER" in + apt) + if [ "$(find /var/lib/apt/lists/* 2>/dev/null | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi + ;; + dnf) + dnf check-update || true + ;; + yum) + yum check-update || true + ;; + microdnf) + # microdnf doesn't have check-update + true + ;; + tdnf) + tdnf makecache || true + ;; + esac +} + +# Check if package is installed +is_package_installed() { + local package=$1 + case "$PKG_MANAGER" in + apt) + dpkg -s "$package" >/dev/null 2>&1 + ;; + dnf|yum|microdnf|tdnf) + rpm -q "$package" >/dev/null 2>&1 + ;; + esac +} + +# Unified package checking and installation function +check_packages() { + local packages=("$@") + local missing_packages=() + + # Check if curl-minimal is installed and swap it with curl + if is_package_installed "curl-minimal"; then + echo "curl-minimal is installed. Swapping it with curl..." + case "$PKG_MANAGER" in + dnf|yum|microdnf) + ${PKG_MANAGER} swap curl-minimal curl -y + ;; + tdnf) + tdnf remove -y curl-minimal + tdnf install -y curl + ;; + *) + echo "Package manager does not support swapping curl-minimal with curl. Please handle this manually." + ;; + esac + fi + + # Map package names based on distribution + for i in "${!packages[@]}"; do + case "$PKG_MANAGER" in + dnf|yum|microdnf|tdnf) + case "${packages[$i]}" in + "libc6-dev") packages[$i]="glibc-devel" ;; + "python3-minimal") packages[$i]="python3" ;; + "libpython3.*") packages[$i]="python3-devel" ;; + "gnupg2") packages[$i]="gnupg" ;; + "passwd") packages[$i]="shadow-utils" ;; + esac + ;; + esac + done + + # Check which packages are missing + for package in "${packages[@]}"; do + if [ -n "$package" ] && ! is_package_installed "$package"; then + missing_packages+=("$package") + fi + done + + # Install missing packages + if [ ${#missing_packages[@]} -gt 0 ]; then + pkg_mgr_update + case "$PKG_MANAGER" in + apt) + apt-get -y install --no-install-recommends "${missing_packages[@]}" + ;; + dnf) + dnf install -y "${missing_packages[@]}" + ;; + yum) + yum install -y "${missing_packages[@]}" + ;; + microdnf) + microdnf install -y "${missing_packages[@]}" + ;; + tdnf) + tdnf install -y "${missing_packages[@]}" + ;; + esac + fi } export DEBIAN_FRONTEND=noninteractive # Install curl, lldb, python3-minimal,libpython and rust dependencies if missing -if ! dpkg -s curl ca-certificates gnupg2 lldb python3-minimal gcc libc6-dev > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends curl ca-certificates gcc libc6-dev - apt-get -y install lldb python3-minimal libpython3.? +echo "Installing required dependencies..." +check_packages curl ca-certificates gcc libc6-dev gnupg2 git passwd + +# Install optional dependencies (continue if they fail) +case "$PKG_MANAGER" in + apt) + check_packages lldb python3-minimal libpython3.? || true + ;; + dnf|yum|microdnf) + check_packages lldb python3 python3-devel || true + ;; + tdnf) + check_packages python3 python3-devel || true + # LLDB might not be available in Photon/Mariner/Azure Linux + ;; +esac + +# Get architecture +if command -v dpkg >/dev/null 2>&1; then + architecture="$(dpkg --print-architecture)" +else + architecture="$(uname -m)" + # Convert common architectures to Debian equivalents + case ${architecture} in + x86_64) + architecture="amd64" + ;; + aarch64) + architecture="arm64" + ;; + esac fi -architecture="$(dpkg --print-architecture)" download_architecture="${architecture}" case ${download_architecture} in - amd64) + amd64|x86_64) download_architecture="x86_64" ;; - arm64) + arm64|aarch64) download_architecture="aarch64" ;; *) echo "(!) Architecture ${architecture} not supported." @@ -156,7 +351,7 @@ esac # Install Rust umask 0002 -if ! cat /etc/group | grep -e "^rustlang:" > /dev/null 2>&1; then +if ! grep -e "^rustlang:" /etc/group > /dev/null 2>&1; then groupadd -r rustlang fi usermod -a -G rustlang "${USERNAME}" @@ -170,12 +365,11 @@ else if [ "${RUST_VERSION}" != "latest" ] && [ "${RUST_VERSION}" != "lts" ] && [ "${RUST_VERSION}" != "stable" ]; then # Find version using soft match if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends git + check_packages git fi is_nightly=0 - echo ${RUST_VERSION} | grep -q "nightly" || is_nightly=$? + echo "${RUST_VERSION}" | grep -q "nightly" || is_nightly=$? if [ $is_nightly = 0 ]; then check_nightly_version_formatting RUST_VERSION else @@ -189,9 +383,10 @@ else curl -sSL --proto '=https' --tlsv1.2 "https://static.rust-lang.org/rustup/dist/${download_architecture}-unknown-linux-gnu/rustup-init" -o /tmp/rustup/target/${download_architecture}-unknown-linux-gnu/release/rustup-init curl -sSL --proto '=https' --tlsv1.2 "https://static.rust-lang.org/rustup/dist/${download_architecture}-unknown-linux-gnu/rustup-init.sha256" -o /tmp/rustup/rustup-init.sha256 cd /tmp/rustup + cp /tmp/rustup/target/${download_architecture}-unknown-linux-gnu/release/rustup-init /tmp/rustup/rustup-init sha256sum -c rustup-init.sha256 chmod +x target/${download_architecture}-unknown-linux-gnu/release/rustup-init - target/${download_architecture}-unknown-linux-gnu/release/rustup-init -y --no-modify-path --profile ${RUSTUP_PROFILE} ${default_toolchain_arg} + target/${download_architecture}-unknown-linux-gnu/release/rustup-init -y --no-modify-path --profile "${RUSTUP_PROFILE}" ${default_toolchain_arg} cd ~ rm -rf /tmp/rustup fi @@ -201,8 +396,27 @@ if [ "${UPDATE_RUST}" = "true" ]; then echo "Updating Rust..." rustup update 2>&1 fi -echo "Installing common Rust dependencies..." -rustup component add rls rust-analysis rust-src rustfmt clippy 2>&1 +# Install Rust components +echo "Installing Rust components..." +for component in "${components[@]}"; do + # Trim leading and trailing whitespace + component="${component#"${component%%[![:space:]]*}"}" && component="${component%"${component##*[![:space:]]}"}" + if [ -n "${component}" ]; then + echo "Installing Rust component: ${component}" + if ! rustup component add "${component}" 2>&1; then + echo "Warning: Failed to install component '${component}'. It may not be available for this toolchain." >&2 + exit 1 + fi + fi +done + +if [ -n "${RUSTUP_TARGETS}" ]; then + IFS=',' read -ra targets <<< "${RUSTUP_TARGETS}" + for target in "${targets[@]}"; do + echo "Installing additional Rust target $target" + rustup target add "$target" 2>&1 + done +fi # Add CARGO_HOME, RUSTUP_HOME and bin directory into bashrc/zshrc files (unless disabled) updaterc "$(cat << EOF @@ -215,5 +429,7 @@ EOF # Make files writable for rustlang group chmod -R g+r+w "${RUSTUP_HOME}" "${CARGO_HOME}" -echo "Done!" +# Clean up +clean_package_cache +echo "Done!" diff --git a/src/sshd/NOTES.md b/src/sshd/NOTES.md index 3126a1bef..c2ca8c8ad 100644 --- a/src/sshd/NOTES.md +++ b/src/sshd/NOTES.md @@ -57,6 +57,13 @@ While the some services automates SSH setup (e.g., when using the GitHub CLI for ``` \\sshfs.r\vscode@localhost!2222\workspaces ``` - ...where `vscode` above is the user you are running as in the container and `2222` after the `!` is the same local port you used in the `ssh` command in the previous seciton. + ...where `vscode` above is the user you are running as in the container and `2222` after the `!` is the same local port you used in the `ssh` command in the previous section. 4. Your dev container's filesystem should now be available in the `~/sshfs/devcontainer` folder on macOS or Linux or in a new explorer window on Windows. + + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/sshd/README.md b/src/sshd/README.md index 948159670..f4215b391 100644 --- a/src/sshd/README.md +++ b/src/sshd/README.md @@ -7,9 +7,7 @@ Adds a SSH server into a container so that you can use an external terminal, sft ```json "features": { - "ghcr.io/devcontainers/features/sshd:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/sshd:1": {} } ``` @@ -17,6 +15,7 @@ Adds a SSH server into a container so that you can use an external terminal, sft | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| +| gatewayPorts | Enable other hosts in the same network to connect to the forwarded ports | string | no | version | Currently unused. | string | latest | ## Usage @@ -78,11 +77,18 @@ While the some services automates SSH setup (e.g., when using the GitHub CLI for ``` \\sshfs.r\vscode@localhost!2222\workspaces ``` - ...where `vscode` above is the user you are running as in the container and `2222` after the `!` is the same local port you used in the `ssh` command in the previous seciton. + ...where `vscode` above is the user you are running as in the container and `2222` after the `!` is the same local port you used in the `ssh` command in the previous section. 4. Your dev container's filesystem should now be available in the `~/sshfs/devcontainer` folder on macOS or Linux or in a new explorer window on Windows. +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. + + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/sshd/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/sshd/devcontainer-feature.json b/src/sshd/devcontainer-feature.json index 518c35b7d..46c4ef302 100644 --- a/src/sshd/devcontainer-feature.json +++ b/src/sshd/devcontainer-feature.json @@ -1,18 +1,42 @@ { "id": "sshd", - "version": "1.0.3", + "version": "1.1.0", "name": "SSH server", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/sshd", "description": "Adds a SSH server into a container so that you can use an external terminal, sftp, or SSHFS to interact with it.", "options": { "version": { "type": "string", - "enum": [ + "proposals": [ "latest" ], "default": "latest", "description": "Currently unused." + }, + "gatewayPorts": { + "type": "string", + "enum": [ + "no", + "yes", + "clientspecified" + ], + "default": "no", + "description": "Enable other hosts in the same network to connect to the forwarded ports" + } + }, + "entrypoint": "/usr/local/share/ssh-init.sh", + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes an SSH server so that you can use an external terminal, sftp, or SSHFS to interact with it. The first time you've started the container, you will want to set a password for your user. With each connection to the container, you'll want to forward the SSH port to your local machine and use a local terminal or other tool to connect using the password you set." + } + ] + } } }, - "entrypoint": "/usr/local/share/ssh-init.sh" + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/sshd/install.sh b/src/sshd/install.sh index 25b8859dc..9b9ddedf2 100755 --- a/src/sshd/install.sh +++ b/src/sshd/install.sh @@ -9,14 +9,17 @@ # # Note: You can change your user's password with "sudo passwd $(whoami)" (or just "passwd" if running as root). -SSHD_PORT=${SSHD_PORT:-"2222"} -USERNAME=${USERNAME:-"automatic"} -START_SSHD=${START_SSHD:-"false"} -NEW_PASSWORD=${NEW_PASSWORD:-"skip"} -FIX_ENVIRONMENT=${FIX_ENVIRONMENT:-"true"} +SSHD_PORT="${SSHD_PORT:-"2222"}" +USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +START_SSHD="${START_SSHD:-"false"}" +NEW_PASSWORD="${NEW_PASSWORD:-"skip"}" +GATEWAY_PORTS="${GATEWAYPORTS:-"no"}" set -e +# Clean up +rm -rf /var/lib/apt/lists/* + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -41,8 +44,10 @@ fi apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not @@ -85,38 +90,10 @@ mkdir -p /var/run/sshd sed -i 's/session\s*required\s*pam_loginuid\.so/session optional pam_loginuid.so/g' /etc/pam.d/sshd sed -i 's/#*PermitRootLogin prohibit-password/PermitRootLogin yes/g' /etc/ssh/sshd_config sed -i -E "s/#*\s*Port\s+.+/Port ${SSHD_PORT}/g" /etc/ssh/sshd_config +sed -i "s/#GatewayPorts no/GatewayPorts ${GATEWAY_PORTS}/g" /etc/ssh/sshd_config # Need to UsePAM so /etc/environment is processed sed -i -E "s/#?\s*UsePAM\s+.+/UsePAM yes/g" /etc/ssh/sshd_config -# Script to store variables that exist at the time the ENTRYPOINT is fired -store_env_script="$(cat << 'EOF' -# Wire in codespaces secret processing to zsh if present (since may have been added to image after script was run) -if [ -f /etc/zsh/zlogin ] && ! grep '/etc/profile.d/00-restore-secrets.sh' /etc/zsh/zlogin > /dev/null 2>&1; then - echo -e "if [ -f /etc/profile.d/00-restore-secrets.sh ]; then . /etc/profile.d/00-restore-secrets.sh; fi\n$(cat /etc/zsh/zlogin 2>/dev/null || echo '')" | sudoIf tee /etc/zsh/zlogin > /dev/null -fi -EOF -)" - -# Script to ensure login shells get the latest Codespaces secrets -restore_secrets_script="$(cat << 'EOF' -#!/bin/sh -if [ "${CODESPACES}" != "true" ] || [ "${VSCDC_FIXED_SECRETS}" = "true" ] || [ ! -z "${GITHUB_CODESPACES_TOKEN}" ]; then - # Not codespaces, already run, or secrets already in environment, so return - return -fi -if [ -f /workspaces/.codespaces/shared/.env-secrets ]; then - while read line - do - key=$(echo $line | sed "s/=.*//") - value=$(echo $line | sed "s/$key=//1") - decodedValue=$(echo $value | base64 -d) - export $key="$decodedValue" - done < /workspaces/.codespaces/shared/.env-secrets -fi -export VSCDC_FIXED_SECRETS=true -EOF -)" - # Write out a scripts that can be referenced as an ENTRYPOINT to auto-start sshd and fix login environments tee /usr/local/share/ssh-init.sh > /dev/null \ << 'EOF' @@ -136,15 +113,6 @@ sudoIf() } EOF -if [ "${FIX_ENVIRONMENT}" = "true" ]; then - echo "${store_env_script}" >> /usr/local/share/ssh-init.sh - echo "${restore_secrets_script}" > /etc/profile.d/00-restore-secrets.sh - chmod +x /etc/profile.d/00-restore-secrets.sh - # Wire in zsh if present - if type zsh > /dev/null 2>&1; then - echo -e "if [ -f /etc/profile.d/00-restore-secrets.sh ]; then . /etc/profile.d/00-restore-secrets.sh; fi\n$(cat /etc/zsh/zlogin 2>/dev/null || echo '')" > /etc/zsh/zlogin - fi -fi tee -a /usr/local/share/ssh-init.sh > /dev/null \ << 'EOF' @@ -166,4 +134,8 @@ echo -e "Done!\n\n- Port: ${SSHD_PORT}\n- User: ${USERNAME}" if [ "${EMIT_PASSWORD}" = "true" ]; then echo "- Password: ${NEW_PASSWORD}" fi -echo -e "\nForward port ${SSHD_PORT} to your local machine and run:\n\n ssh -p ${SSHD_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o GlobalKnownHostsFile=/dev/null ${USERNAME}@localhost\n" \ No newline at end of file + +# Clean up +rm -rf /var/lib/apt/lists/* + +echo -e "\nForward port ${SSHD_PORT} to your local machine and run:\n\n ssh -p ${SSHD_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o GlobalKnownHostsFile=/dev/null ${USERNAME}@localhost\n" diff --git a/src/terraform/NOTES.md b/src/terraform/NOTES.md new file mode 100644 index 000000000..74a90639e --- /dev/null +++ b/src/terraform/NOTES.md @@ -0,0 +1,42 @@ + + +## Licensing + +On August 10, 2023, HashiCorp announced a change of license for its products, including Terraform. After ~9 years of Terraform being open source under the MPL v2 license, it was to move under a non-open source BSL v1.1 license, starting from the next (1.6) version. See https://github.com/hashicorp/terraform/blob/main/LICENSE + +## Custom Download Server + +The `customDownloadServer` option allows you to specify an alternative server for downloading Terraform and Sentinel packages. This is useful for organizations that maintain internal mirrors or have proxies for HashiCorp downloads. + +When using this option: +- Provide the complete URL including protocol (e.g., `https://my-mirror.example.com`) +- The server should mirror the HashiCorp releases structure + +Example: +```json +"features": { + "ghcr.io/devcontainers/features/terraform:1": { + "customDownloadServer": "https://my-mirror.example.com" + } +} +``` + +### ⚠️ Security Considerations + +When using a custom download server, be aware of the following security implications: + +- **Server Verification**: Always verify that the custom server is trustworthy and maintained by your organization or a trusted entity. Using an untrusted or compromised server could lead to downloading malicious software. + +- **Supply Chain Risks**: Malicious actors may attempt to distribute compromised versions of Terraform that contain backdoors, cryptominers, or other harmful code. + +- **Integrity Checks**: The feature performs SHA256 checks when available, but these are only as trustworthy as the source of the checksums. If both the binaries and checksums come from a compromised server, the integrity check may pass despite the software being malicious. + +- **Organizational Policy**: Ensure your custom download server adheres to your organization's security policies and implements proper access controls. + +Always use the official HashiCorp download server (https://releases.hashicorp.com) unless you have a specific need for an alternative source. + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. diff --git a/src/terraform/README.md b/src/terraform/README.md index 475407185..8cfb673dc 100644 --- a/src/terraform/README.md +++ b/src/terraform/README.md @@ -7,9 +7,7 @@ Installs the Terraform CLI and optionally TFLint and Terragrunt. Auto-detects la ```json "features": { - "ghcr.io/devcontainers/features/terraform:1": { - "version": "latest" - } + "ghcr.io/devcontainers/features/terraform:1": {} } ``` @@ -18,9 +16,63 @@ Installs the Terraform CLI and optionally TFLint and Terragrunt. Auto-detects la | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| | version | Terraform version | string | latest | -| tflint | Tflint version | string | latest | +| tflint | Tflint version (https://github.com/terraform-linters/tflint/releases) | string | latest | | terragrunt | Terragrunt version | string | latest | +| installSentinel | Install sentinel, a language and framework for policy built to be embedded in existing software to enable fine-grained, logic-based policy decisions | boolean | false | +| installTFsec | Install tfsec, a tool to spot potential misconfigurations for your terraform code | boolean | false | +| installTerraformDocs | Install terraform-docs, a utility to generate documentation from Terraform modules | boolean | false | +| terraformDocsVersion | terraform-docs version to install (only used when installTerraformDocs is true) (https://github.com/terraform-docs/terraform-docs/releases) | string | latest | +| httpProxy | Connect to a keyserver using a proxy by configuring this option | string | - | +| customDownloadServer | Custom server URL for downloading Terraform and Sentinel packages, including protocol (e.g., https://releases.hashicorp.com). If not provided, the default HashiCorp download server (https://releases.hashicorp.com) will be used. | string | - | +## Customizations + +### VS Code Extensions + +- `HashiCorp.terraform` + + + +## Licensing + +On August 10, 2023, HashiCorp announced a change of license for its products, including Terraform. After ~9 years of Terraform being open source under the MPL v2 license, it was to move under a non-open source BSL v1.1 license, starting from the next (1.6) version. See https://github.com/hashicorp/terraform/blob/main/LICENSE + +## Custom Download Server + +The `customDownloadServer` option allows you to specify an alternative server for downloading Terraform and Sentinel packages. This is useful for organizations that maintain internal mirrors or have proxies for HashiCorp downloads. + +When using this option: +- Provide the complete URL including protocol (e.g., `https://my-mirror.example.com`) +- The server should mirror the HashiCorp releases structure + +Example: +```json +"features": { + "ghcr.io/devcontainers/features/terraform:1": { + "customDownloadServer": "https://my-mirror.example.com" + } +} +``` + +### ⚠️ Security Considerations + +When using a custom download server, be aware of the following security implications: + +- **Server Verification**: Always verify that the custom server is trustworthy and maintained by your organization or a trusted entity. Using an untrusted or compromised server could lead to downloading malicious software. + +- **Supply Chain Risks**: Malicious actors may attempt to distribute compromised versions of Terraform that contain backdoors, cryptominers, or other harmful code. + +- **Integrity Checks**: The feature performs SHA256 checks when available, but these are only as trustworthy as the source of the checksums. If both the binaries and checksums come from a compromised server, the integrity check may pass despite the software being malicious. + +- **Organizational Policy**: Ensure your custom download server adheres to your organization's security policies and implements proper access controls. + +Always use the official HashiCorp download server (https://releases.hashicorp.com) unless you have a specific need for an alternative source. + +## OS Support + +This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + +`bash` is required to execute the `install.sh` script. --- diff --git a/src/terraform/devcontainer-feature.json b/src/terraform/devcontainer-feature.json index 4ace245c7..634d865ef 100644 --- a/src/terraform/devcontainer-feature.json +++ b/src/terraform/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "terraform", - "version": "1.0.5", + "version": "1.5.0", "name": "Terraform, tflint, and TFGrunt", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/terraform", "description": "Installs the Terraform CLI and optionally TFLint and Terragrunt. Auto-detects latest version and installs needed dependencies.", @@ -20,10 +20,12 @@ "tflint": { "type": "string", "proposals": [ - "latest" + "latest", + "0.47.0", + "0.46.1" ], "default": "latest", - "description": "Tflint version" + "description": "Tflint version (https://github.com/terraform-linters/tflint/releases)" }, "terragrunt": { "type": "string", @@ -32,21 +34,67 @@ ], "default": "latest", "description": "Terragrunt version" + }, + "installSentinel": { + "type": "boolean", + "default": false, + "description": "Install sentinel, a language and framework for policy built to be embedded in existing software to enable fine-grained, logic-based policy decisions" + }, + "installTFsec": { + "type": "boolean", + "default": false, + "description": "Install tfsec, a tool to spot potential misconfigurations for your terraform code" + }, + "installTerraformDocs": { + "type": "boolean", + "default": false, + "description": "Install terraform-docs, a utility to generate documentation from Terraform modules" + }, + "terraformDocsVersion": { + "type": "string", + "proposals": [ + "latest", + "0.20.0", + "0.19.0" + ], + "default": "latest", + "description": "terraform-docs version to install (only used when installTerraformDocs is true) (https://github.com/terraform-docs/terraform-docs/releases)" + }, + "httpProxy": { + "type": "string", + "default": "", + "description": "Connect to a keyserver using a proxy by configuring this option" + }, + "customDownloadServer": { + "type": "string", + "default": "", + "description": "Custom server URL for downloading Terraform and Sentinel packages, including protocol (e.g., https://releases.hashicorp.com). If not provided, the default HashiCorp download server (https://releases.hashicorp.com) will be used." } }, "customizations": { "vscode": { "extensions": [ - "HashiCorp.terraform", - "ms-azuretools.vscode-azureterraform" + "HashiCorp.terraform" ], "settings": { "terraform.languageServer.enable": true, "terraform.languageServer.args": [ "serve" ], - "azureTerraform.terminal": "integrated" + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the Terraform CLI and optionally TFLint and Terragrunt pre-installed and available on the `PATH`, along with the Terraform extension for Terraform development." + } + ] } } - } + }, + "dependsOn": { + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] } diff --git a/src/terraform/install.sh b/src/terraform/install.sh index 358b8b266..43779f825 100755 --- a/src/terraform/install.sh +++ b/src/terraform/install.sh @@ -9,19 +9,35 @@ set -e +# Clean up +rm -rf /var/lib/apt/lists/* + TERRAFORM_VERSION="${VERSION:-"latest"}" TFLINT_VERSION="${TFLINT:-"latest"}" TERRAGRUNT_VERSION="${TERRAGRUNT:-"latest"}" +INSTALL_SENTINEL=${INSTALLSENTINEL:-false} +INSTALL_TFSEC=${INSTALLTFSEC:-false} +INSTALL_TERRAFORM_DOCS=${INSTALLTERRAFORMDOCS:-false} +TERRAFORM_DOCS_VERSION="${TERRAFORMDOCSVERSION:-"latest"}" +CUSTOM_DOWNLOAD_SERVER="${CUSTOMDOWNLOADSERVER:-""}" +# This is because ubuntu noble, ubuntu resolute and debian trixie don't support the old format of GPG keys and validation +NEW_GPG_CODENAMES="trixie noble resolute" TERRAFORM_SHA256="${TERRAFORM_SHA256:-"automatic"}" TFLINT_SHA256="${TFLINT_SHA256:-"automatic"}" TERRAGRUNT_SHA256="${TERRAGRUNT_SHA256:-"automatic"}" +SENTINEL_SHA256="${SENTINEL_SHA256:-"automatic"}" +TFSEC_SHA256="${TFSEC_SHA256:-"automatic"}" +TERRAFORM_DOCS_SHA256="${TERRAFORM_DOCS_SHA256:-"automatic"}" + +HASHICORP_RELEASES_URL="https://releases.hashicorp.com" +if [ -n "${CUSTOM_DOWNLOAD_SERVER}" ]; then + HASHICORP_RELEASES_URL="${CUSTOM_DOWNLOAD_SERVER}" +fi TERRAFORM_GPG_KEY="72D7468F" -TFLINT_GPG_KEY_URI="https://raw.githubusercontent.com/terraform-linters/tflint/master/8CE69160EB3F2FE9.key" -GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80 -keyserver hkps://keys.openpgp.org -keyserver hkp://keyserver.pgp.com" +TFLINT_GPG_KEY_URI="https://raw.githubusercontent.com/terraform-linters/tflint/v0.46.1/8CE69160EB3F2FE9.key" +KEYSERVER_PROXY="${HTTPPROXY:-"${HTTP_PROXY:-""}"}" architecture="$(uname -m)" case ${architecture} in @@ -37,36 +53,80 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -# Get central common setting -get_common_setting() { - if [ "${common_settings_file_loaded}" != "true" ]; then - curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping." - common_settings_file_loaded=true +# Detect Ubuntu Noble, Ubuntu Resolute or Debian Trixie and use new repo setup, else use legacy GPG logic +IS_GPG_NEW=0 +. /etc/os-release +if [[ "${NEW_GPG_CODENAMES}" == *"${VERSION_CODENAME}"* ]]; then + IS_GPG_NEW=1 +fi + +# Get the list of GPG key servers that are reachable +get_gpg_key_servers() { + declare -A keyservers_curl_map=( + ["hkps://keyserver.ubuntu.com"]="https://keyserver.ubuntu.com" + ["hkps://keys.openpgp.org"]="https://keys.openpgp.org" + ["hkps://keyserver.pgp.com"]="https://keyserver.pgp.com" + ) + + local curl_args="" + local keyserver_reachable=false # Flag to indicate if any keyserver is reachable + + if [ ! -z "${KEYSERVER_PROXY}" ]; then + curl_args="--proxy ${KEYSERVER_PROXY}" fi - if [ -f "/tmp/vsdc-settings.env" ]; then - local multi_line="" - if [ "$2" = "true" ]; then multi_line="-z"; fi - local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')" - if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi + + for keyserver in "${!keyservers_curl_map[@]}"; do + local keyserver_curl_url="${keyservers_curl_map[${keyserver}]}" + if curl -s ${curl_args} --max-time 5 ${keyserver_curl_url} > /dev/null; then + echo "keyserver ${keyserver}" + keyserver_reachable=true + else + echo "(*) Keyserver ${keyserver} is not reachable." >&2 + fi + done + + if ! $keyserver_reachable; then + echo "(!) No keyserver is reachable." >&2 + exit 1 fi - echo "$1=${!1}" } # Import the specified key in a variable name passed in as receive_gpg_keys() { - get_common_setting $1 local keys=${!1} - get_common_setting GPG_KEY_SERVERS true local keyring_args="" if [ ! -z "$2" ]; then keyring_args="--no-default-keyring --keyring $2" fi + if [ ! -z "${KEYSERVER_PROXY}" ]; then + keyring_args="${keyring_args} --keyserver-options http-proxy=${KEYSERVER_PROXY}" + fi - # Use a temporary locaiton for gpg keys to avoid polluting image + # Install curl + if ! type curl > /dev/null 2>&1; then + check_packages curl + fi + + # Use a temporary location for gpg keys to avoid polluting image export GNUPGHOME="/tmp/tmp-gnupg" mkdir -p ${GNUPGHOME} chmod 700 ${GNUPGHOME} - echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf + + # Special handling for HashiCorp GPG key on Ubuntu Noble + if [ "$IS_GPG_NEW" -eq 1 ] && [ "$keys" = "$TERRAFORM_GPG_KEY" ]; then + echo "(*) Ubuntu Noble detected, using Keybase for HashiCorp GPG key import...." + curl -fsSL https://keybase.io/hashicorp/pgp_keys.asc | gpg --import + if ! gpg --list-keys "${TERRAFORM_GPG_KEY}" > /dev/null 2>&1; then + gpg --list-keys + echo "(*) Warning: HashiCorp GPG key not found in keyring after import." + echo " Continuing installation without GPG verification on Ubuntu Noble." + echo " This is expected behavior for Ubuntu Noble due to keyserver issues." + return 1 # Return failure to indicate GPG verification should be skipped + fi + return 0 + fi + + echo -e "disable-ipv6\n$(get_gpg_key_servers)" > ${GNUPGHOME}/dirmngr.conf # GPG key download sometimes fails for some reason and retrying fixes it. local retry_count=0 local gpg_ok="false" @@ -76,11 +136,30 @@ receive_gpg_keys() { echo "(*) Downloading GPG key..." ( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true" if [ "${gpg_ok}" != "true" ]; then - echo "(*) Failed getting key, retring in 10s..." + echo "(*) Failed getting key, retrying in 10s..." (( retry_count++ )) sleep 10s fi done + + # If all attempts fail, try getting the keyserver IP address and explicitly passing it to gpg + if [ "${gpg_ok}" = "false" ]; then + retry_count=0; + echo "(*) Resolving GPG keyserver IP address..." + local keyserver_ip_address=$( dig +short keyserver.ubuntu.com | head -n1 ) + echo "(*) GPG keyserver IP address $keyserver_ip_address" + + until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "3" ]; + do + echo "(*) Downloading GPG key..." + ( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys --keyserver ${keyserver_ip_address}) 2>&1 && gpg_ok="true" + if [ "${gpg_ok}" != "true" ]; then + echo "(*) Failed getting key, retrying in 10s..." + (( retry_count++ )) + sleep 10s + fi + done + fi set -e if [ "${gpg_ok}" = "false" ]; then echo "(!) Failed to get gpg key." @@ -122,10 +201,77 @@ find_version_from_git_tags() { echo "${variable_name}=${!variable_name}" } +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} + +find_sentinel_version_from_url() { + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + local prefix='sentinel_' + local regex="${prefix}\d.\d{2}.\d(?:-\w*)?" + local version_list="$(wget -q $2 -O - | grep -oP ${regex} | sed "s/^${prefix}//" | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + fi + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" >/dev/null 2>&1; then + echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + echo "${variable_name}=${!variable_name}" +} + apt_get_update() { - echo "Running apt-get update..." - apt-get update -y + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not @@ -136,34 +282,172 @@ check_packages() { fi } +# Function to fetch the version released prior to the latest version +get_previous_version() { + local url=$1 + local repo_url=$2 + local variable_name=$3 + prev_version=${!variable_name} + + output=$(curl -s "$repo_url"); + + # install jq + check_packages jq + + message=$(echo "$output" | jq -r '.message') + + if [[ $message == "API rate limit exceeded"* ]]; then + echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}" + echo -e "\nAttempting to find latest version using GitHub tags." + find_prev_version_from_git_tags prev_version "$url" "tags/v" + declare -g ${variable_name}="${prev_version}" + else + echo -e "\nAttempting to find latest version using GitHub Api." + version=$(echo "$output" | jq -r '.tag_name') + declare -g ${variable_name}="${version#v}" + fi + echo "${variable_name}=${!variable_name}" +} + +get_github_api_repo_url() { + local url=$1 + echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases/latest" +} + +install_previous_version() { + given_version=$1 + requested_version=${!given_version} + local URL=$2 + INSTALLER_FN=$3 + local REPO_URL=$(get_github_api_repo_url "$URL") + local PKG_NAME=$(get_pkg_name "${given_version}") + echo -e "\n(!) Failed to fetch the latest artifacts for ${PKG_NAME} v${requested_version}..." + get_previous_version "$URL" "$REPO_URL" requested_version + echo -e "\nAttempting to install ${requested_version}" + declare -g ${given_version}="${requested_version#v}" + $INSTALLER_FN "${!given_version}" + echo "${given_version}=${!given_version}" +} + +install_cosign() { + COSIGN_VERSION=$1 + local URL=$2 + cosign_filename="/tmp/cosign_${COSIGN_VERSION}_${architecture}.deb" + cosign_url="https://github.com/sigstore/cosign/releases/latest/download/cosign_${COSIGN_VERSION}_${architecture}.deb" + curl -L "${cosign_url}" -o $cosign_filename + if grep -q "Not Found" "$cosign_filename"; then + echo -e "\n(!) Failed to fetch the latest artifacts for cosign v${COSIGN_VERSION}..." + REPO_URL=$(get_github_api_repo_url "$URL") + get_previous_version "$URL" "$REPO_URL" COSIGN_VERSION + echo -e "\nAttempting to install ${COSIGN_VERSION}" + cosign_filename="/tmp/cosign_${COSIGN_VERSION}_${architecture}.deb" + cosign_url="https://github.com/sigstore/cosign/releases/latest/download/cosign_${COSIGN_VERSION}_${architecture}.deb" + curl -L "${cosign_url}" -o $cosign_filename + fi + dpkg -i $cosign_filename + rm $cosign_filename + echo "Installation of cosign succeeded with ${COSIGN_VERSION}." +} + +# Install 'cosign' for validating signatures +# https://docs.sigstore.dev/cosign/overview/ +ensure_cosign() { + check_packages curl ca-certificates gnupg2 + + if ! type cosign > /dev/null 2>&1; then + echo "Installing cosign..." + COSIGN_VERSION="latest" + cosign_url='https://github.com/sigstore/cosign' + find_version_from_git_tags COSIGN_VERSION "${cosign_url}" + install_cosign "${COSIGN_VERSION}" "${cosign_url}" + fi + if ! type cosign > /dev/null 2>&1; then + echo "(!) Failed to install cosign." + exit 1 + fi + cosign version +} + # Ensure apt is in non-interactive to avoid prompts export DEBIAN_FRONTEND=noninteractive # Install dependencies if missing -check_packages curl ca-certificates gnupg2 dirmngr coreutils unzip +check_packages curl ca-certificates gnupg2 dirmngr coreutils unzip dnsutils if ! type git > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends git + check_packages git fi +terraform_url='https://github.com/hashicorp/terraform' +tflint_url='https://github.com/terraform-linters/tflint' +terragrunt_url='https://github.com/gruntwork-io/terragrunt' # Verify requested version is available, convert latest -find_version_from_git_tags TERRAFORM_VERSION 'https://github.com/hashicorp/terraform' -find_version_from_git_tags TFLINT_VERSION 'https://github.com/terraform-linters/tflint' -find_version_from_git_tags TERRAGRUNT_VERSION 'https://github.com/gruntwork-io/terragrunt' +find_version_from_git_tags TERRAFORM_VERSION "$terraform_url" +find_version_from_git_tags TFLINT_VERSION "$tflint_url" +find_version_from_git_tags TERRAGRUNT_VERSION "$terragrunt_url" + +install_terraform() { + local TERRAFORM_VERSION=$1 + terraform_filename="terraform_${TERRAFORM_VERSION}_linux_${architecture}.zip" + curl -sSL -o ${terraform_filename} "${HASHICORP_RELEASES_URL}/terraform/${TERRAFORM_VERSION}/${terraform_filename}" +} + +verify_signature() { + local gpg_key=$1 + local sha256sums_url=$2 + local sig_url=$3 + local sha256sums_file=$4 + local sig_file=$5 + local verify_result=0 + + receive_gpg_keys "$gpg_key" + verify_result=$? + if [ $verify_result -ne 0 ] && [ "$IS_GPG_NEW" -eq 1 ]; then + echo "Skipping the gpg key validation for ubuntu noble as unable to import the key." + return 1 + fi + curl -sSL -o "$sha256sums_file" "$sha256sums_url" + curl -sSL -o "$sig_file" "$sig_url" + + # Try GPG verification, but don't fail on Noble + gpg --verify "$sig_file" "$sha256sums_file" + verify_result=$? + if [ $verify_result -ne 0 ]; then + echo "(!) GPG verification failed." + exit 1 + fi +} mkdir -p /tmp/tf-downloads cd /tmp/tf-downloads - # Install Terraform, tflint, Terragrunt echo "Downloading terraform..." terraform_filename="terraform_${TERRAFORM_VERSION}_linux_${architecture}.zip" -curl -sSL -o ${terraform_filename} "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/${terraform_filename}" +install_terraform "$TERRAFORM_VERSION" +if grep -q "The specified key does not exist." "${terraform_filename}"; then + install_previous_version TERRAFORM_VERSION $terraform_url "install_terraform" + terraform_filename="terraform_${TERRAFORM_VERSION}_linux_${architecture}.zip" +fi if [ "${TERRAFORM_SHA256}" != "dev-mode" ]; then if [ "${TERRAFORM_SHA256}" = "automatic" ]; then - receive_gpg_keys TERRAFORM_GPG_KEY - curl -sSL -o terraform_SHA256SUMS https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS - curl -sSL -o terraform_SHA256SUMS.sig https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS.${TERRAFORM_GPG_KEY}.sig - gpg --verify terraform_SHA256SUMS.sig terraform_SHA256SUMS + # For Ubuntu Noble, try GPG verification but continue if it fails + if [ "$IS_GPG_NEW" -eq 1 ]; then + echo "(*) Ubuntu Noble detected - attempting GPG verification with fallback..." + set +e + sha256sums_url="${HASHICORP_RELEASES_URL}/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS" + sig_url="${HASHICORP_RELEASES_URL}/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS.${TERRAFORM_GPG_KEY}.sig" + verify_signature TERRAFORM_GPG_KEY "$sha256sums_url" "$sig_url" "terraform_SHA256SUMS" "terraform_SHA256SUMS.sig" + verify_result=$? + set -e + if [ $verify_result -ne 0 ]; then + echo "(*) GPG verification failed on Ubuntu Noble, but continuing installation." + echo " Downloading checksums for basic integrity check..." + curl -sSL -o terraform_SHA256SUMS "${HASHICORP_RELEASES_URL}/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS" + fi + else + sha256sums_url="${HASHICORP_RELEASES_URL}/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS" + sig_url="${HASHICORP_RELEASES_URL}/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS.${TERRAFORM_GPG_KEY}.sig" + verify_signature TERRAFORM_GPG_KEY "$sha256sums_url" "$sig_url" "terraform_SHA256SUMS" "terraform_SHA256SUMS.sig" + fi else echo "${TERRAFORM_SHA256} *${terraform_filename}" > terraform_SHA256SUMS fi @@ -172,30 +456,102 @@ fi unzip ${terraform_filename} mv -f terraform /usr/local/bin/ +install_tflint() { + TFLINT_VERSION=$1 + curl -sSL -o /tmp/tf-downloads/${TFLINT_FILENAME} https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/${TFLINT_FILENAME} +} + +verify_tflint_attestations() { + local checksums=$1 + local checksums_sha256=$(sha256sum "$checksums" | cut -d " " -f 1) + + check_packages jq + + curl -L -f "https://api.github.com/repos/terraform-linters/tflint/attestations/sha256:${checksums_sha256}" > attestation.json + curl_exit_code=$? + if [ $curl_exit_code -ne 0 ]; then + echo "(*) Failed to fetch GitHub Attestations for tflint checksums" + return 1 + fi + + jq ".attestations[].bundle" attestation.json > bundle.jsonl + gh at verify "$checksums" -R terraform-linters/tflint -b bundle.jsonl +} + if [ "${TFLINT_VERSION}" != "none" ]; then echo "Downloading tflint..." TFLINT_FILENAME="tflint_linux_${architecture}.zip" - curl -sSL -o /tmp/tf-downloads/${TFLINT_FILENAME} https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/${TFLINT_FILENAME} + install_tflint "$TFLINT_VERSION" + if grep -q "Not Found" "/tmp/tf-downloads/${TFLINT_FILENAME}"; then + install_previous_version TFLINT_VERSION "$tflint_url" "install_tflint" + fi if [ "${TFLINT_SHA256}" != "dev-mode" ]; then - if [ "${TFLINT_SHA256}" = "automatic" ]; then - get_common_setting TFLINT_GPG_KEY_URI - curl -sSL -o tflint_key "${TFLINT_GPG_KEY_URI}" - gpg -q --import tflint_key - curl -sSL -o tflint_checksums.txt https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/checksums.txt - curl -sSL -o tflint_checksums.txt.sig https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/checksums.txt.sig - gpg --verify tflint_checksums.txt.sig tflint_checksums.txt - else + + if [ "${TFLINT_SHA256}" != "automatic" ]; then echo "${TFLINT_SHA256} *${TFLINT_FILENAME}" > tflint_checksums.txt + sha256sum --ignore-missing -c tflint_checksums.txt + else + curl -sSL -o tflint_checksums.txt https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/checksums.txt + + # Attempt GitHub Attestation verification (0.51.1+) + set +e + verify_tflint_attestations tflint_checksums.txt + verify_result=$? + set -e + + if [ $verify_result -eq 0 ]; then + sha256sum --ignore-missing -c tflint_checksums.txt + echo "(*) tflint_checksums.txt verified successfully using GitHub Attestation." + else + # Fallback to cosign verification + echo "(*) GitHub Attestation verification failed or not supported for this version, falling back to Cosign verification..." + set +e + curl -sSL -o checksums.txt.keyless.sig https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/checksums.txt.keyless.sig + set -e + + # Check that checksums.txt.keyless.sig exists and is not empty + if [ -s checksums.txt.keyless.sig ]; then + # Validate checksums with cosign + curl -sSL -o checksums.txt.pem https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/checksums.txt.pem + ensure_cosign + cosign verify-blob \ + --certificate=/tmp/tf-downloads/checksums.txt.pem \ + --signature=/tmp/tf-downloads/checksums.txt.keyless.sig \ + --certificate-identity-regexp="^https://github.com/terraform-linters/tflint" \ + --certificate-oidc-issuer=https://token.actions.githubusercontent.com \ + /tmp/tf-downloads/tflint_checksums.txt + # Ensure that checksums.txt has $TFLINT_FILENAME + grep ${TFLINT_FILENAME} /tmp/tf-downloads/tflint_checksums.txt + # Validate downloaded file + sha256sum --ignore-missing -c tflint_checksums.txt + else + # Fallback to older, GPG-based verification (pre-0.47.0 of tflint) + curl -sSL -o tflint_checksums.txt.sig https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/checksums.txt.sig + curl -sSL -o tflint_key "${TFLINT_GPG_KEY_URI}" + gpg -q --import tflint_key + gpg --verify tflint_checksums.txt.sig tflint_checksums.txt + fi + fi fi - sha256sum --ignore-missing -c tflint_checksums.txt fi + unzip /tmp/tf-downloads/${TFLINT_FILENAME} mv -f tflint /usr/local/bin/ fi + +install_terragrunt() { + TERRAGRUNT_VERSION=$1 + curl -sSL -o /tmp/tf-downloads/${terragrunt_filename} https://github.com/gruntwork-io/terragrunt/releases/download/v${TERRAGRUNT_VERSION}/${terragrunt_filename} +} + if [ "${TERRAGRUNT_VERSION}" != "none" ]; then echo "Downloading Terragrunt..." terragrunt_filename="terragrunt_linux_${architecture}" - curl -sSL -o /tmp/tf-downloads/${terragrunt_filename} https://github.com/gruntwork-io/terragrunt/releases/download/v${TERRAGRUNT_VERSION}/${terragrunt_filename} + install_terragrunt "$TERRAGRUNT_VERSION" + output=$(cat "/tmp/tf-downloads/${terragrunt_filename}") + if [[ $output == "Not Found" ]]; then + install_previous_version TERRAGRUNT_VERSION $terragrunt_url "install_terragrunt" + fi if [ "${TERRAGRUNT_SHA256}" != "dev-mode" ]; then if [ "${TERRAGRUNT_SHA256}" = "automatic" ]; then curl -sSL -o terragrunt_SHA256SUMS https://github.com/gruntwork-io/terragrunt/releases/download/v${TERRAGRUNT_VERSION}/SHA256SUMS @@ -208,5 +564,110 @@ if [ "${TERRAGRUNT_VERSION}" != "none" ]; then mv -f /tmp/tf-downloads/${terragrunt_filename} /usr/local/bin/terragrunt fi +if [ "${INSTALL_SENTINEL}" = "true" ]; then + SENTINEL_VERSION="latest" + sentinel_releases_url="${HASHICORP_RELEASES_URL}/sentinel" + find_sentinel_version_from_url SENTINEL_VERSION ${sentinel_releases_url} + sentinel_filename="sentinel_${SENTINEL_VERSION}_linux_${architecture}.zip" + echo "(*) Downloading Sentinel... ${sentinel_filename}" + curl -sSL -o /tmp/tf-downloads/${sentinel_filename} ${sentinel_releases_url}/${SENTINEL_VERSION}/${sentinel_filename} + if [ "${SENTINEL_SHA256}" != "dev-mode" ]; then + if [ "${SENTINEL_SHA256}" = "automatic" ]; then + # For Ubuntu Noble, try GPG verification but continue if it fails + if [ "$IS_GPG_NEW" -eq 1 ]; then + echo "(*) Ubuntu Noble detected - attempting Sentinel GPG verification with fallback..." + set +e + sha256sums_url="${sentinel_releases_url}/${SENTINEL_VERSION}/sentinel_${SENTINEL_VERSION}_SHA256SUMS" + sig_url="${sentinel_releases_url}/${SENTINEL_VERSION}/sentinel_${SENTINEL_VERSION}_SHA256SUMS.${TERRAFORM_GPG_KEY}.sig" + verify_signature TERRAFORM_GPG_KEY "$sha256sums_url" "$sig_url" "sentinel_checksums.txt" "sentinel_checksums.txt.sig" + verify_result=$? + set -e + if [ $verify_result -ne 0 ]; then + echo "(*) GPG verification failed on Ubuntu Noble, but continuing installation." + echo " Downloading checksums for basic integrity check..." + curl -sSL -o sentinel_checksums.txt "${sentinel_releases_url}/${SENTINEL_VERSION}/sentinel_${SENTINEL_VERSION}_SHA256SUMS" + fi + else + sha256sums_url="${sentinel_releases_url}/${SENTINEL_VERSION}/sentinel_${SENTINEL_VERSION}_SHA256SUMS" + sig_url="${sentinel_releases_url}/${SENTINEL_VERSION}/sentinel_${SENTINEL_VERSION}_SHA256SUMS.${TERRAFORM_GPG_KEY}.sig" + verify_signature TERRAFORM_GPG_KEY "$sha256sums_url" "$sig_url" "sentinel_checksums.txt" "sentinel_checksums.txt.sig" + fi + # Verify the SHASUM matches the archive + shasum -a 256 --ignore-missing -c sentinel_checksums.txt + else + echo "${SENTINEL_SHA256} *${SENTINEL_FILENAME}" >sentinel_checksums.txt + fi + sha256sum --ignore-missing -c sentinel_checksums.txt + fi + unzip /tmp/tf-downloads/${sentinel_filename} + chmod a+x /tmp/tf-downloads/sentinel + mv -f /tmp/tf-downloads/sentinel /usr/local/bin/sentinel +fi + +install_tfsec() { + local TFSEC_VERSION=$1 + tfsec_filename="tfsec_${TFSEC_VERSION}_linux_${architecture}.tar.gz" + curl -sSL -o /tmp/tf-downloads/${tfsec_filename} https://github.com/aquasecurity/tfsec/releases/download/v${TFSEC_VERSION}/${tfsec_filename} +} + +if [ "${INSTALL_TFSEC}" = "true" ]; then + TFSEC_VERSION="latest" + tfsec_url='https://github.com/aquasecurity/tfsec' + find_version_from_git_tags TFSEC_VERSION $tfsec_url + tfsec_filename="tfsec_${TFSEC_VERSION}_linux_${architecture}.tar.gz" + echo "(*) Downloading TFSec... ${tfsec_filename}" + install_tfsec "$TFSEC_VERSION" + if grep -q "Not Found" "/tmp/tf-downloads/${tfsec_filename}"; then + install_previous_version TFSEC_VERSION $tfsec_url "install_tfsec" + tfsec_filename="tfsec_${TFSEC_VERSION}_linux_${architecture}.tar.gz" + fi + if [ "${TFSEC_SHA256}" != "dev-mode" ]; then + if [ "${TFSEC_SHA256}" = "automatic" ]; then + curl -sSL -o tfsec_SHA256SUMS https://github.com/aquasecurity/tfsec/releases/download/v${TFSEC_VERSION}/tfsec_${TFSEC_VERSION}_checksums.txt + else + echo "${TFSEC_SHA256} *${tfsec_filename}" > tfsec_SHA256SUMS + fi + sha256sum --ignore-missing -c tfsec_SHA256SUMS + fi + mkdir -p /tmp/tf-downloads/tfsec + tar -xzf /tmp/tf-downloads/${tfsec_filename} -C /tmp/tf-downloads/tfsec + chmod a+x /tmp/tf-downloads/tfsec/tfsec + mv -f /tmp/tf-downloads/tfsec/tfsec /usr/local/bin/tfsec +fi + +install_terraform_docs() { + local TERRAFORM_DOCS_VERSION=$1 + tfdocs_filename="terraform-docs-v${TERRAFORM_DOCS_VERSION}-linux-${architecture}.tar.gz" + curl -sSL -o /tmp/tf-downloads/${tfdocs_filename} https://github.com/terraform-docs/terraform-docs/releases/download/v${TERRAFORM_DOCS_VERSION}/${tfdocs_filename} +} + +if [ "${INSTALL_TERRAFORM_DOCS}" = "true" ]; then + terraform_docs_url='https://github.com/terraform-docs/terraform-docs' + find_version_from_git_tags TERRAFORM_DOCS_VERSION $terraform_docs_url + tfdocs_filename="terraform-docs-v${TERRAFORM_DOCS_VERSION}-linux-${architecture}.tar.gz" + echo "(*) Downloading Terraform docs... ${tfdocs_filename}" + install_terraform_docs "$TERRAFORM_DOCS_VERSION" + if grep -q "Not Found" "/tmp/tf-downloads/${tfdocs_filename}"; then + install_previous_version TERRAFORM_DOCS_VERSION $terraform_docs_url "install_terraform_docs" + tfdocs_filename="terraform-docs-v${TERRAFORM_DOCS_VERSION}-linux-${architecture}.tar.gz" + fi + if [ "${TERRAFORM_DOCS_SHA256}" != "dev-mode" ]; then + if [ "${TERRAFORM_DOCS_SHA256}" = "automatic" ]; then + curl -sSL -o tfdocs_SHA256SUMS https://github.com/terraform-docs/terraform-docs/releases/download/v${TERRAFORM_DOCS_VERSION}/terraform-docs-v${TERRAFORM_DOCS_VERSION}.sha256sum + else + echo "${TERRAFORM_DOCS_SHA256} *${tfsec_filename}" > tfdocs_SHA256SUMS + fi + sha256sum --ignore-missing -c tfdocs_SHA256SUMS + fi + mkdir -p /tmp/tf-downloads/tfdocs + tar -xzf /tmp/tf-downloads/${tfdocs_filename} -C /tmp/tf-downloads/tfdocs + chmod a+x /tmp/tf-downloads/tfdocs/terraform-docs + mv -f /tmp/tf-downloads/tfdocs/terraform-docs /usr/local/bin/terraform-docs +fi + rm -rf /tmp/tf-downloads ${GNUPGHOME} + +# Clean up +rm -rf /var/lib/apt/lists/* + echo "Done!" diff --git a/test/_global/scenarios.json b/test/_global/scenarios.json index 2228c7d6e..184f8e87b 100644 --- a/test/_global/scenarios.json +++ b/test/_global/scenarios.json @@ -1,6 +1,6 @@ { "all_the_clis": { - "image": "ubuntu:focal", + "image": "ubuntu:noble", "features": { "aws-cli": {}, "azure-cli": {}, @@ -8,7 +8,7 @@ } }, "node_java_rust": { - "image": "ubuntu:focal", + "image": "ubuntu:noble", "features": { "node": {}, "java": {}, diff --git a/test/anaconda/install_anaconda_almalinux8.sh b/test/anaconda/install_anaconda_almalinux8.sh new file mode 100644 index 000000000..416d32a2b --- /dev/null +++ b/test/anaconda/install_anaconda_almalinux8.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + + diff --git a/test/anaconda/install_anaconda_almalinux9.sh b/test/anaconda/install_anaconda_almalinux9.sh new file mode 100644 index 000000000..416d32a2b --- /dev/null +++ b/test/anaconda/install_anaconda_almalinux9.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + + diff --git a/test/anaconda/install_anaconda_bookworm.sh b/test/anaconda/install_anaconda_bookworm.sh new file mode 100644 index 000000000..4a17f3dc1 --- /dev/null +++ b/test/anaconda/install_anaconda_bookworm.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + diff --git a/test/anaconda/install_anaconda_bullseye.sh b/test/anaconda/install_anaconda_bullseye.sh new file mode 100644 index 000000000..4a17f3dc1 --- /dev/null +++ b/test/anaconda/install_anaconda_bullseye.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + diff --git a/test/anaconda/install_anaconda_fedora.sh b/test/anaconda/install_anaconda_fedora.sh new file mode 100644 index 000000000..416d32a2b --- /dev/null +++ b/test/anaconda/install_anaconda_fedora.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + + diff --git a/test/anaconda/install_anaconda_jammy.sh b/test/anaconda/install_anaconda_jammy.sh new file mode 100644 index 000000000..4a17f3dc1 --- /dev/null +++ b/test/anaconda/install_anaconda_jammy.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + diff --git a/test/anaconda/install_anaconda_noble.sh b/test/anaconda/install_anaconda_noble.sh new file mode 100644 index 000000000..4a17f3dc1 --- /dev/null +++ b/test/anaconda/install_anaconda_noble.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + diff --git a/test/anaconda/install_anaconda_noble_without_user.sh b/test/anaconda/install_anaconda_noble_without_user.sh new file mode 100644 index 000000000..4a17f3dc1 --- /dev/null +++ b/test/anaconda/install_anaconda_noble_without_user.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + diff --git a/test/anaconda/install_anaconda_rockylinux8.sh b/test/anaconda/install_anaconda_rockylinux8.sh new file mode 100644 index 000000000..416d32a2b --- /dev/null +++ b/test/anaconda/install_anaconda_rockylinux8.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + + diff --git a/test/anaconda/install_anaconda_rockylinux9.sh b/test/anaconda/install_anaconda_rockylinux9.sh new file mode 100644 index 000000000..d71085c4c --- /dev/null +++ b/test/anaconda/install_anaconda_rockylinux9.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "conda" conda --version +check "python" python --version +check "pylint" pylint --version +check "flake8" flake8 --version +check "autopep8" autopep8 --version +check "yapf" yapf --version +check "pydocstyle" pydocstyle --version +check "pycodestyle" pycodestyle --version +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +check "certifi" pip show certifi | grep Version +check "cryptography" pip show cryptography | grep Version +check "setuptools" pip show setuptools | grep Version +check "tornado" pip show tornado | grep Version + +check "conda-update-conda" bash -c "conda update -y conda" +check "conda-install-tensorflow" bash -c "conda create --name test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name test-env -c conda-forge --yes pytorch" + +# Report result +reportResults + + + diff --git a/test/anaconda/scenarios.json b/test/anaconda/scenarios.json new file mode 100644 index 000000000..65fba8458 --- /dev/null +++ b/test/anaconda/scenarios.json @@ -0,0 +1,86 @@ +{ + "install_anaconda_noble": { + "image": "mcr.microsoft.com/devcontainers/base:noble", + "user": "vscode", + "features": { + "anaconda": { + "version": "latest" + } + } + }, + "install_anaconda_jammy": { + "image": "mcr.microsoft.com/devcontainers/base:jammy", + "user": "vscode", + "features": { + "anaconda": { + "version": "latest" + } + } + }, + "install_anaconda_bookworm": { + "image": "mcr.microsoft.com/devcontainers/base:bookworm", + "user": "vscode", + "features": { + "anaconda": { + "version": "latest" + } + } + }, + "install_anaconda_bullseye": { + "image": "mcr.microsoft.com/devcontainers/base:bullseye", + "user": "vscode", + "features": { + "anaconda": { + "version": "latest" + } + } + }, + "install_anaconda_noble_without_user": { + "image": "mcr.microsoft.com/devcontainers/base:noble", + "features": { + "anaconda": { + "version": "latest" + } + } + }, + "install_anaconda_almalinux8": { + "image": "almalinux:8", + "features": { + "anaconda": { + "version": "latest" + } + } + }, + "install_anaconda_almalinux9": { + "image": "almalinux:9", + "features": { + "anaconda": { + "version": "latest" + } + } + }, + "install_anaconda_rockylinux8": { + "image": "rockylinux:8", + "features": { + "anaconda": { + "version": "latest" + } + } + }, + "install_anaconda_rockylinux9": { + "image": "rockylinux:9", + "features": { + "anaconda": { + "version": "latest" + } + } + }, + "install_anaconda_fedora": { + "image": "fedora", + "features": { + "anaconda": { + "version": "latest" + } + } + } +} diff --git a/test/aws-cli/checkBashCompletion.sh b/test/aws-cli/checkBashCompletion.sh new file mode 100755 index 000000000..376461b52 --- /dev/null +++ b/test/aws-cli/checkBashCompletion.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +command=$1 +expected=$2 + +echo -e "Checking completion for command '$command'..." + +# Send command as a character stream, followed by two tab characters, into an interactive bash shell. +# Also note the 'y' which responds to the possible Bash question "Display all xxx possibilities? (y or n)". +# Bash produces the autocompletion output on stderr, so redirect that to stdout. +# The sed bit captures the lines between Header and Footer (used as output delimiters). +# The first grep removes the "Display all" message (that is atomatically answered to "y" by the script). +# The last grep filters the output to lines containing the expected result. +COMPLETE_OUTPUT=$(echo if false\; then "Header"\; $command$'\t'$'\t'y\; "Footer" fi | bash -i 2>&1 | sed -n '/Header/{:a;n;/Footer/q;p;ba}' | grep -v ^'Display all ') +echo -e "\nCompletion output:\n" +echo -e "$COMPLETE_OUTPUT" +echo -e "\n" + +FILTERED_COMPLETE_OUTPUT=$(echo "$COMPLETE_OUTPUT" | grep "$expected") + +if [ -z "$FILTERED_COMPLETE_OUTPUT" ]; then + echo -e "Completion output does not contains '$expected'." + exit 1 +else + echo -e "Completion output contains '$expected'." + exit 0 +fi diff --git a/test/aws-cli/less_installed.sh b/test/aws-cli/less_installed.sh new file mode 100644 index 000000000..a700a7a18 --- /dev/null +++ b/test/aws-cli/less_installed.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +check "less is installed, pagination works !" less --version +check "less binary installation path" which less +check "Testing paginated output with less" ls -R / | less + +# Report result +reportResults \ No newline at end of file diff --git a/test/aws-cli/scenarios.json b/test/aws-cli/scenarios.json new file mode 100644 index 000000000..f2577c0ff --- /dev/null +++ b/test/aws-cli/scenarios.json @@ -0,0 +1,17 @@ +{ + "less_installed": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "aws-cli": {} + } + }, + "zsh_completion": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "common-utils": { + "installZsh": true + }, + "aws-cli": {} + } + } +} \ No newline at end of file diff --git a/test/aws-cli/test.sh b/test/aws-cli/test.sh index d213eac38..45ef63db1 100755 --- a/test/aws-cli/test.sh +++ b/test/aws-cli/test.sh @@ -8,5 +8,15 @@ source dev-container-features-test-lib # Definition specific tests check "version" aws --version +# By default bash complete is disabled for the root user +# Enable it by replacing current ~/.bashrc with the /etc/skel/.bashrc file +mv ~/.bashrc ~/.bashrc.bak +cp /etc/skel/.bashrc ~/ + +check "aws-bash-completion-contains-version-option" ./checkBashCompletion.sh "aws --" "version" + +# Restore original ~/.bashrc +mv ~/.bashrc.bak ~/.bashrc + # Report result reportResults \ No newline at end of file diff --git a/test/aws-cli/zsh_completion.sh b/test/aws-cli/zsh_completion.sh new file mode 100644 index 000000000..ca08e077d --- /dev/null +++ b/test/aws-cli/zsh_completion.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +# Check that the zsh completion file exists in the correct location +check "zsh completion file installed" test -f /usr/local/share/zsh/site-functions/_aws + +# Check that the completion file has the proper zsh completion header +check "zsh completion file has compdef header" grep -q "^#compdef aws" /usr/local/share/zsh/site-functions/_aws + +# Actual ZSH completion testing is a pain, so just ignoring it for now. + +# Report result +reportResults diff --git a/test/azure-cli/install_azcli_dotnet_dockerindocker_trixie.sh b/test/azure-cli/install_azcli_dotnet_dockerindocker_trixie.sh new file mode 100644 index 000000000..3f5e6f540 --- /dev/null +++ b/test/azure-cli/install_azcli_dotnet_dockerindocker_trixie.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +check "version" az --version + +check "docker installed" bash -c "type docker" + +# Report result +reportResults \ No newline at end of file diff --git a/test/azure-cli/install_bicep.sh b/test/azure-cli/install_bicep.sh new file mode 100644 index 000000000..0430c64da --- /dev/null +++ b/test/azure-cli/install_bicep.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +# Check to make sure the user is vscode +check "user is vscode" whoami | grep vscode + +check "version" az --version + +# Bicep-specific tests +check "bicep" bicep --version +check "az bicep" az bicep version + +# Report result +reportResults \ No newline at end of file diff --git a/test/azure-cli/install_bicep_trixie.sh b/test/azure-cli/install_bicep_trixie.sh new file mode 100644 index 000000000..28ff7e509 --- /dev/null +++ b/test/azure-cli/install_bicep_trixie.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +# Check to make sure the user is vscode +check "user is vscode" whoami | grep vscode + +check "version" az --version + +# Bicep-specific tests +check "bicep" bicep --version +check "az bicep" az bicep version + +# Report result +reportResults + diff --git a/test/azure-cli/install_extensions.sh b/test/azure-cli/install_extensions.sh new file mode 100644 index 000000000..58d1cfff2 --- /dev/null +++ b/test/azure-cli/install_extensions.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +# Check to make sure the user is vscode +check "user is vscode" whoami | grep vscode + +check "version" az --version + +# Extension-specific tests +check "aks-preview" az extension show --name aks-preview +check "amg" az extension show --name amg +check "containerapp" az extension show --name containerapp + +# Report result +reportResults \ No newline at end of file diff --git a/test/azure-cli/install_extensions_bookworm.sh b/test/azure-cli/install_extensions_bookworm.sh new file mode 100644 index 000000000..3c281e685 --- /dev/null +++ b/test/azure-cli/install_extensions_bookworm.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +./install_extensions.sh \ No newline at end of file diff --git a/test/azure-cli/install_extensions_trixie.sh b/test/azure-cli/install_extensions_trixie.sh new file mode 100644 index 000000000..fa55c2f5d --- /dev/null +++ b/test/azure-cli/install_extensions_trixie.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +./install_extensions.sh + diff --git a/test/azure-cli/install_fallback_pip_ubuntu_plucky.sh b/test/azure-cli/install_fallback_pip_ubuntu_plucky.sh new file mode 100644 index 000000000..f6fffd02b --- /dev/null +++ b/test/azure-cli/install_fallback_pip_ubuntu_plucky.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +# Ubuntu plucky (25.04) is NOT in the apt archive codename allowlist, +# so this test validates the pip fallback path. +check "version" az --version + +# Report result +reportResults diff --git a/test/azure-cli/install_in_ubuntu_resolute.sh b/test/azure-cli/install_in_ubuntu_resolute.sh new file mode 100644 index 000000000..eff30a25c --- /dev/null +++ b/test/azure-cli/install_in_ubuntu_resolute.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +# Check to make sure the user is vscode +check "user is vscode" whoami | grep vscode + +check "version" az --version + +# Report result +reportResults diff --git a/test/azure-cli/install_using_python_with_python_3_11_bullseye.sh b/test/azure-cli/install_using_python_with_python_3_11_bullseye.sh new file mode 100644 index 000000000..b9957843e --- /dev/null +++ b/test/azure-cli/install_using_python_with_python_3_11_bullseye.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +# Check to make sure the user is vscode +check "user is vscode" whoami | grep vscode +check "version" az --version + +echo -e "\n\n🔄 Testing 'O.S'" +if cat /etc/os-release | grep -q 'PRETTY_NAME="Debian GNU/Linux 11 (bullseye)"'; then + echo -e "\n\n✅ Passed 'O.S is Linux 11 (bullseye)'!" +else + echo -e "\n\n❌ Failed 'O.S is other than Linux 11 (bullseye)'!" +fi + + +# Report result +reportResults \ No newline at end of file diff --git a/test/azure-cli/install_with_python.sh b/test/azure-cli/install_with_python.sh new file mode 100644 index 000000000..4848ac55f --- /dev/null +++ b/test/azure-cli/install_with_python.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + +# Check to make sure the user is vscode +check "user is vscode" whoami | grep vscode +check "version" az --version + + +# Report result +reportResults \ No newline at end of file diff --git a/test/azure-cli/install_with_python_3_12_bookworm.sh b/test/azure-cli/install_with_python_3_12_bookworm.sh new file mode 100644 index 000000000..2c8e1fd72 --- /dev/null +++ b/test/azure-cli/install_with_python_3_12_bookworm.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + + +echo -e "\n🔄 Testing 'O.S'" +if cat /etc/os-release | grep -q 'PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"'; then + echo -e "\n✅ Passed 'O.S is Linux 12 (bookworm)'!\n" +else + echo -e "\n❌ Failed 'O.S is other than Linux 12 (bookworm)'!\n" +fi + +# Check to make sure the user is vscode +check "user is vscode" whoami | grep vscode +check "version" az --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/azure-cli/install_with_python_3_13_trixie.sh b/test/azure-cli/install_with_python_3_13_trixie.sh new file mode 100644 index 000000000..aac4f3c2d --- /dev/null +++ b/test/azure-cli/install_with_python_3_13_trixie.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +# Import test library for `check` command +source dev-container-features-test-lib + + +echo -e "\n🔄 Testing 'O.S'" +if cat /etc/os-release | grep -q 'PRETTY_NAME="Debian GNU/Linux 13 (trixie)"'; then + echo -e "\n✅ Passed 'O.S is Linux 13 (trixie)'!\n" +else + echo -e "\n❌ Failed 'O.S is other than Linux 13 (trixie)'!\n" +fi + +# Check to make sure the user is vscode +check "user is vscode" whoami | grep vscode +check "version" az --version + +# Report result +reportResults + diff --git a/test/azure-cli/scenarios.json b/test/azure-cli/scenarios.json new file mode 100644 index 000000000..29fb4f935 --- /dev/null +++ b/test/azure-cli/scenarios.json @@ -0,0 +1,123 @@ +{ + "install_fallback_pip_ubuntu_plucky": { + "image": "ubuntu:plucky", + "features": { + "azure-cli": { + "version": "latest" + } + } + }, + "install_extensions_trixie": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest", + "extensions": "aks-preview,amg,containerapp" + } + } + }, + "install_extensions": { + "image": "mcr.microsoft.com/devcontainers/base:jammy", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest", + "extensions": "aks-preview,amg,containerapp" + } + } + }, + "install_extensions_bookworm": { + "image": "mcr.microsoft.com/devcontainers/base:bookworm", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest", + "extensions": "aks-preview,amg,containerapp" + } + } + }, + "install_bicep": { + "image": "mcr.microsoft.com/devcontainers/base:jammy", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest", + "installBicep": true + } + } + }, + "install_bicep_trixie": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest", + "installBicep": true + } + } + }, + "install_with_python": { + "image": "mcr.microsoft.com/devcontainers/base:jammy", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest", + "installUsingPython": true + } + } + }, + "install_with_python_3_13_trixie": { + "image": "mcr.microsoft.com/devcontainers/python:2-3.13-trixie", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest", + "installUsingPython": true + } + } + }, + "install_with_python_3_12_bookworm": { + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest" + } + } + }, + "install_using_python_with_python_3_11_bullseye": { + "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest", + "installUsingPython": "true" + } + } + }, + "install_azcli_dotnet_dockerindocker_trixie": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest" + }, + "dotnet": { + "aspNetCoreRuntimeVersions": "8.0" + }, + "docker-in-docker": { + "moby": false + } + } + }, + "install_in_ubuntu_resolute": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "user": "vscode", + "features": { + "azure-cli": { + "version": "latest" + } + } + } +} \ No newline at end of file diff --git a/test/common-utils/Azure-linux-CU.sh b/test/common-utils/Azure-linux-CU.sh new file mode 100644 index 000000000..eeba25d10 --- /dev/null +++ b/test/common-utils/Azure-linux-CU.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Load Linux distribution info +. /etc/os-release + +# Check if the current user is root +check "root user" test "$(whoami)" = "root" + +# Check if the Linux distro is Azure Linux +check "azurelinux distro" test "$ID" = "azurelinux" + +# Definition specific tests +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults diff --git a/test/common-utils/alma-8-minimal.sh b/test/common-utils/alma-8-minimal.sh new file mode 100755 index 000000000..d08731824 --- /dev/null +++ b/test/common-utils/alma-8-minimal.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el8" +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alma-8.sh b/test/common-utils/alma-8.sh new file mode 100755 index 000000000..d08731824 --- /dev/null +++ b/test/common-utils/alma-8.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el8" +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alma-9-minimal.sh b/test/common-utils/alma-9-minimal.sh new file mode 100755 index 000000000..cb2b339e1 --- /dev/null +++ b/test/common-utils/alma-9-minimal.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el9" +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alma-9.sh b/test/common-utils/alma-9.sh new file mode 100755 index 000000000..c0771df14 --- /dev/null +++ b/test/common-utils/alma-9.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el9" +check "curl" curl --version +check "jq" jq --version +check "bubblewrap" bwrap --version +check "socat" socat -V + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alma-minimal-8.sh b/test/common-utils/alma-minimal-8.sh new file mode 100755 index 000000000..cb2b339e1 --- /dev/null +++ b/test/common-utils/alma-minimal-8.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el9" +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alma-minimal-9.sh b/test/common-utils/alma-minimal-9.sh new file mode 100755 index 000000000..cb2b339e1 --- /dev/null +++ b/test/common-utils/alma-minimal-9.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el9" +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alpine-3-14.sh b/test/common-utils/alpine-3-14.sh new file mode 100755 index 000000000..5ed334c61 --- /dev/null +++ b/test/common-utils/alpine-3-14.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${ID}" = "alpine" +check "bashrc" ls /etc/bash/bashrc + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alpine-3-15.sh b/test/common-utils/alpine-3-15.sh new file mode 100755 index 000000000..5ed334c61 --- /dev/null +++ b/test/common-utils/alpine-3-15.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${ID}" = "alpine" +check "bashrc" ls /etc/bash/bashrc + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alpine-3-18.sh b/test/common-utils/alpine-3-18.sh new file mode 100755 index 000000000..eaf384b9e --- /dev/null +++ b/test/common-utils/alpine-3-18.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${ID}" = "alpine" +check "bashrc" ls /etc/bash/bashrc +check "libssl1.1 is installed" grep "libssl1.1" <(apk list --no-cache libssl1.1) + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alpine-base-zsh-default.sh b/test/common-utils/alpine-base-zsh-default.sh new file mode 100644 index 000000000..9fc00a267 --- /dev/null +++ b/test/common-utils/alpine-base-zsh-default.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "alpine default shell zsh" \ + bash -c "getent passwd $(whoami) | awk -F : '{ print $7 }' | grep '/bin/zsh'" + +# Report result +reportResults diff --git a/test/common-utils/alpine.sh b/test/common-utils/alpine.sh new file mode 100755 index 000000000..f60732527 --- /dev/null +++ b/test/common-utils/alpine.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${ID}" = "alpine" +check "bubblewrap" bwrap --version +check "socat" socat -V + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/already-run.sh b/test/common-utils/already-run.sh new file mode 100755 index 000000000..39d6f9ee2 --- /dev/null +++ b/test/common-utils/already-run.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "non-root user" test "$(whoami)" = "vscode" +check "jq" jq --version +check "curl" curl --version +check "git" git --version +check "zsh" zsh --version +check "ps" ps --version +check "Oh My Zsh! theme" test -e $HOME/.oh-my-zsh/custom/themes + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/alternate-values.sh b/test/common-utils/alternate-values.sh new file mode 100755 index 000000000..d208a2730 --- /dev/null +++ b/test/common-utils/alternate-values.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "non-root user" id alternate +check "running as root" test "$(whoami)" = "root" +check "jq" jq --version +check "curl" curl --version +check "git" git --version +check "ps" ps --version +check "no zsh" bash -c '! zsh --version' +check "No Oh My Zsh!" test ! -e /home/alternate/.oh-my-zsh/custom/themes/devcontainers.zsh-theme + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/bionic.sh b/test/common-utils/bionic.sh new file mode 100755 index 000000000..349b4c32d --- /dev/null +++ b/test/common-utils/bionic.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${VERSION_CODENAME}" = "bionic" + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/bookworm.sh b/test/common-utils/bookworm.sh new file mode 100755 index 000000000..db8628713 --- /dev/null +++ b/test/common-utils/bookworm.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +FAILED=() +echoStderr() +{ + echo "$@" 1>&2 +} + +checkOSPackages() { + LABEL=$1 + shift + echo -e "\n🧪 Testing $LABEL" + if dpkg-query --show -f='${Package}: ${Version}\n' "$@"; then + echo "✅ Passed!" + return 0 + else + echoStderr "❌ $LABEL check failed." + FAILED+=("$LABEL") + return 1 + fi +} + +checkCommon() +{ + PACKAGE_LIST="manpages-posix \ + manpages-posix-dev" + + checkOSPackages "Installation of manpages-posix and manpages-posix-dev (non-free)" ${PACKAGE_LIST} +} + +# Check for manpages-posix, manpages-posix-dev non-free packages +checkCommon + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${VERSION_CODENAME}" = "bookworm" + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/bullseye.sh b/test/common-utils/bullseye.sh new file mode 100755 index 000000000..5a396a7f4 --- /dev/null +++ b/test/common-utils/bullseye.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${VERSION_CODENAME}" = "bullseye" + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/centos-7.sh b/test/common-utils/centos-7.sh new file mode 100755 index 000000000..69f01d1ed --- /dev/null +++ b/test/common-utils/centos-7.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${VERSION_ID}" = "7" +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/config-subdirectory.sh b/test/common-utils/config-subdirectory.sh new file mode 100755 index 000000000..ba67fb68f --- /dev/null +++ b/test/common-utils/config-subdirectory.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "owned-config-sub-directory" bash -c "ls -ld ~/.config/subdirectory | awk '{print $3}' | grep 'devcontainer'" +check "owned-config-directory" bash -c "ls -ld ~/.config | awk '{print $3}' | grep 'devcontainer'" + +# Report result +reportResults diff --git a/test/common-utils/configure_zsh_as_default_shell.sh b/test/common-utils/configure_zsh_as_default_shell.sh new file mode 100644 index 000000000..21b12101e --- /dev/null +++ b/test/common-utils/configure_zsh_as_default_shell.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "default-shell-is-zsh" bash -c "getent passwd $(whoami) | awk -F: '{ print $7 }' | grep '/bin/zsh'" +# check it overrides the ~/.zshrc with default dev containers template +check "default-zshrc-is-dev-container-template" bash -c "cat ~/.zshrc | grep ZSH_THEME | grep devcontainers" +check "zsh-path-contains-local-bin" zsh -l -c "echo $PATH | grep '/home/devcontainer/.local/bin'" + +check "Ensure .zprofile is owned by remoteUser" bash -c "stat -c '%U' /home/devcontainer/.zprofile | grep devcontainer" + +# Report result +reportResults diff --git a/test/common-utils/configure_zsh_no_template_first_step.sh b/test/common-utils/configure_zsh_no_template_first_step.sh new file mode 100644 index 000000000..d26874d3a --- /dev/null +++ b/test/common-utils/configure_zsh_no_template_first_step.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "default-zsh-with-no-zshrc" bash -c "[ ! -e ~/.zshrc ]" + +# Report result +reportResults diff --git a/test/common-utils/configure_zsh_no_template_second_step.sh b/test/common-utils/configure_zsh_no_template_second_step.sh new file mode 100644 index 000000000..734fa66e9 --- /dev/null +++ b/test/common-utils/configure_zsh_no_template_second_step.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib +# Definition specific tests +function file_not_overridden() { + cat $1 | grep 'alias fnomockalias=' | grep testingmock +} +check "default-zsh-with-no-zshrc" file_not_overridden /home/devcontainer/.zshrc + +# Report result +reportResults diff --git a/test/common-utils/devcontainer-custom-home.sh b/test/common-utils/devcontainer-custom-home.sh new file mode 100644 index 000000000..9b64cc0bf --- /dev/null +++ b/test/common-utils/devcontainer-custom-home.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "user is customUser" grep customUser <(whoami) +check "home is /customHome" grep "/customHome" <(getent passwd customUser | cut -d: -f6) + +# Report result +reportResults diff --git a/test/common-utils/devcontainer-custom-home/Dockerfile b/test/common-utils/devcontainer-custom-home/Dockerfile new file mode 100644 index 000000000..824e1e343 --- /dev/null +++ b/test/common-utils/devcontainer-custom-home/Dockerfile @@ -0,0 +1,4 @@ +FROM ubuntu:noble + +RUN groupadd customUser -g 30000 && \ + useradd customUser -u 30000 -g 30000 --create-home --home-dir /customHome diff --git a/test/common-utils/devcontainer-custom-user-default-home.sh b/test/common-utils/devcontainer-custom-user-default-home.sh new file mode 100644 index 000000000..f29bd7c74 --- /dev/null +++ b/test/common-utils/devcontainer-custom-user-default-home.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "user is customUser" grep customUser <(whoami) +check "home is /home/customUser" grep "/home/customUser" <(getent passwd customUser | cut -d: -f6) + +# Report result +reportResults diff --git a/test/common-utils/devcontainer-info.sh b/test/common-utils/devcontainer-info.sh new file mode 100755 index 000000000..65a4a40fb --- /dev/null +++ b/test/common-utils/devcontainer-info.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check_info() { + local info=$1 + check "devcontainer-info ${info}" sh -c "devcontainer-info | grep test-${info}" +} + +# Definition specific tests +check "user" bash -c "whoami | grep vscode" +check_info "version" +check_info "id" +check_info "variant" +check_info "repository" +check_info "release" +check_info "revision" +check_info "time" +check_info "url" + +# Report result +reportResults diff --git a/test/common-utils/devcontainer-info/Dockerfile b/test/common-utils/devcontainer-info/Dockerfile new file mode 100644 index 000000000..07d7a1caf --- /dev/null +++ b/test/common-utils/devcontainer-info/Dockerfile @@ -0,0 +1,9 @@ +FROM ubuntu:noble + +COPY meta.env /usr/local/etc/dev-containers/meta.env + +RUN if id "ubuntu" &>/dev/null; then \ + echo "Deleting user 'ubuntu' for noble" && userdel -f -r ubuntu || echo "Failed to delete ubuntu user for noble"; \ + else \ + echo "User 'ubuntu' does not exist for noble"; \ + fi diff --git a/test/common-utils/devcontainer-info/meta.env b/test/common-utils/devcontainer-info/meta.env new file mode 100644 index 000000000..dfc61d31c --- /dev/null +++ b/test/common-utils/devcontainer-info/meta.env @@ -0,0 +1,8 @@ +VERSION='test-version' +DEFINITION_ID='test-id' +VARIANT='test-variant' +GIT_REPOSITORY='test-repository' +GIT_REPOSITORY_RELEASE='test-release' +GIT_REPOSITORY_REVISION='test-revision' +BUILD_TIMESTAMP='test-time' +CONTENTS_URL='test-url' diff --git a/test/common-utils/devcontainer-ruby-zshrc.sh b/test/common-utils/devcontainer-ruby-zshrc.sh new file mode 100644 index 000000000..9e7b26e4c --- /dev/null +++ b/test/common-utils/devcontainer-ruby-zshrc.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "configure-zshrc-without-overwrite" bash -c "grep 'rbenv init -' ~/.zshrc" + +# Report result +reportResults diff --git a/test/common-utils/devcontainer-ruby-zshrc/Dockerfile b/test/common-utils/devcontainer-ruby-zshrc/Dockerfile new file mode 100644 index 000000000..c5e7c69c4 --- /dev/null +++ b/test/common-utils/devcontainer-ruby-zshrc/Dockerfile @@ -0,0 +1,6 @@ +FROM mcr.microsoft.com/devcontainers/ruby:3.2 + +USER vscode +ENV USER=vscode +RUN echo 'eval "$(rbenv init -)"' >> /home/$USER/.zshrc + diff --git a/test/common-utils/fedora.sh b/test/common-utils/fedora.sh new file mode 100755 index 000000000..e52f99bff --- /dev/null +++ b/test/common-utils/fedora.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${ID}" = "fedora" +check "jq" jq --version +check "bubblewrap" bwrap --version +check "socat" socat -V + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/focal.sh b/test/common-utils/focal.sh new file mode 100755 index 000000000..28d8b8bcc --- /dev/null +++ b/test/common-utils/focal.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${VERSION_CODENAME}" = "focal" + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/jammy.sh b/test/common-utils/jammy.sh new file mode 100755 index 000000000..e09765168 --- /dev/null +++ b/test/common-utils/jammy.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${VERSION_CODENAME}" = "jammy" +check "bubblewrap" bwrap --version +check "socat" socat -V + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/mariner.sh b/test/common-utils/mariner.sh new file mode 100755 index 000000000..b0d2c91e4 --- /dev/null +++ b/test/common-utils/mariner.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${ID}" = "mariner" +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/no-terminal-title-by-default.sh b/test/common-utils/no-terminal-title-by-default.sh new file mode 100755 index 000000000..83fe7d221 --- /dev/null +++ b/test/common-utils/no-terminal-title-by-default.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release + +# Make sure bashrc is applied +source /root/.bashrc + +check "check_term_is_not_set" test !"$TERM" +check "check_prompt_command_not_set" test !"$PROMPT_COMMAND" + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/noble.sh b/test/common-utils/noble.sh new file mode 100644 index 000000000..e1d578271 --- /dev/null +++ b/test/common-utils/noble.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${VERSION_CODENAME}" = "noble" +check "bubblewrap" bwrap --version +check "socat" socat -V + +# Report result +reportResults + diff --git a/test/common-utils/rocky-8-minimal.sh b/test/common-utils/rocky-8-minimal.sh new file mode 100755 index 000000000..d08731824 --- /dev/null +++ b/test/common-utils/rocky-8-minimal.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el8" +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/rocky-8.sh b/test/common-utils/rocky-8.sh new file mode 100755 index 000000000..d08731824 --- /dev/null +++ b/test/common-utils/rocky-8.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el8" +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/rocky-9-minimal.sh b/test/common-utils/rocky-9-minimal.sh new file mode 100755 index 000000000..cb2b339e1 --- /dev/null +++ b/test/common-utils/rocky-9-minimal.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el9" +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/rocky-9.sh b/test/common-utils/rocky-9.sh new file mode 100755 index 000000000..c0771df14 --- /dev/null +++ b/test/common-utils/rocky-9.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el9" +check "curl" curl --version +check "jq" jq --version +check "bubblewrap" bwrap --version +check "socat" socat -V + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/scenarios.json b/test/common-utils/scenarios.json new file mode 100644 index 000000000..c70c574a7 --- /dev/null +++ b/test/common-utils/scenarios.json @@ -0,0 +1,295 @@ +{ + "jammy": { + "image": "ubuntu:jammy", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "noble": { + "image": "ubuntu:noble", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "bullseye": { + "image": "debian:bullseye", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "bookworm": { + "image": "debian:bookworm", + "remoteUser": "devcontainer", + "features": { + "common-utils": { + "nonFreePackages": "true" + } + } + }, + "centos-7": { + "image": "centos:7", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "alma-8": { + "image": "almalinux:8", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "alma-9": { + "image": "almalinux:9", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "alma-8-minimal": { + "image": "almalinux:8-minimal", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "alma-9-minimal": { + "image": "almalinux:9-minimal", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "rocky-8": { + "image": "rockylinux:8", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "rocky-9": { + "image": "rockylinux:9", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "rocky-8-minimal": { + "image": "rockylinux:8-minimal", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "rocky-9-minimal": { + "image": "rockylinux:9-minimal", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "fedora": { + "image": "fedora", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "mariner": { + "image": "mcr.microsoft.com/cbl-mariner/base/core:2.0", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "alpine": { + "image": "alpine", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "alternate-values": { + "image": "debian:bullseye", + "features": { + "common-utils": { + "username": "alternate", + "userUid": "1001", + "userGid": "1002", + "upgradePackages": false, + "installZsh": false, + "nonFreePackages": true + } + } + }, + "username-default": { + "image": "debian:bullseye", + "features": { + "common-utils": {} + } + }, + "username-detected": { + "image": "node", + "features": { + "common-utils": {} + } + }, + "already-run": { + "image": "mcr.microsoft.com/devcontainers/base:jammy", + "features": { + "common-utils": {} + } + }, + "configure_zsh_as_default_shell": { + "image": "ubuntu", + "features": { + "common-utils": { + "installZsh": true, + "configureZshAsDefaultShell": true + } + }, + "remoteUser": "devcontainer" + }, + "configure_zsh_no_template_second_step": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "postCreateCommand": "echo alias fnomockalias=testingmock >> /home/devcontainer/.zshrc", + "remoteUser": "devcontainer", + "features": { + "common-utils": { + "installZsh": true, + "installOhMyZshConfig": false + } + } + }, + "configure_zsh_no_template_first_step": { + "image": "debian:bullseye", + "remoteUser": "devcontainer", + "features": { + "common-utils": { + "installZsh": true, + "installOhMyZshConfig": false + } + } + }, + "config-subdirectory": { + "image": "alpine", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + }, + "mounts": [ + "source=${localEnv:HOME},target=/home/devcontainer/.config/subdirectory,type=bind,readonly" + ] + }, + "alpine-3-14": { + "image": "alpine:3.14", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "alpine-3-15": { + "image": "alpine:3.15", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "alpine-3-18": { + "image": "alpine:3.18", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + }, + "devcontainer-info": { + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "vscode", + "features": { + "common-utils": { + "username": "vscode", + "userUid": "1000", + "userGid": "1000", + "upgradePackages": true, + "installZsh": true + } + } + }, + "devcontainer-ruby-zshrc": { + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "vscode", + "features": { + "common-utils": { + "installZsh": true, + "username": "vscode", + "userUid": "1000", + "userGid": "1000", + "upgradePackages": true, + "installOhMyZsh": true, + "installOhMyZshConfig": true, + "configureZshAsDefaultShell": true + } + } + }, + "alpine-base-zsh-default": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "remoteUser": "vscode", + "features": { + "common-utils": { + "configureZshAsDefaultShell": true + } + } + }, + "devcontainer-custom-home": { + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "customUser", + "features": { + "common-utils": {} + } + }, + "devcontainer-custom-user-default-home": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "remoteUser": "customUser", + "features": { + "common-utils": {} + } + }, + "terminal-title-on-xterm": { + "image": "node", + "features": { + "common-utils": {} + }, + "containerEnv": { + "TERM": "xterm" + } + }, + "no-terminal-title-by-default": { + "image": "node", + "features": { + "common-utils": {} + } + }, + "Azure-linux-CU": { + "image": "mcr.microsoft.com/dotnet/sdk:8.0-azurelinux3.0", + "features": { + "common-utils": {} + } + }, + "ubi-8": { + "image": "registry.access.redhat.com/ubi8/ubi:8.10", + "remoteUser": "devcontainer", + "features": { + "common-utils": {} + } + } +} \ No newline at end of file diff --git a/test/common-utils/terminal-title-on-xterm.sh b/test/common-utils/terminal-title-on-xterm.sh new file mode 100755 index 000000000..9b483651f --- /dev/null +++ b/test/common-utils/terminal-title-on-xterm.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release + +# Make sure bashrc is applied +source /root/.bashrc + +check "check_term_is_set" test "$TERM" = "xterm" +check "check_term_is_set" test "$PROMPT_COMMAND" = "precmd" + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/test.sh b/test/common-utils/test.sh index 128201548..46cb119be 100755 --- a/test/common-utils/test.sh +++ b/test/common-utils/test.sh @@ -8,6 +8,13 @@ source dev-container-features-test-lib # Definition specific tests check "jq" jq --version check "curl" curl --version +check "git" git --version +check "zsh" zsh --version +check "bubblewrap" bwrap --version +check "socat" socat -V +check "ps" ps --version +check "Oh My Zsh! theme" test -e $HOME/.oh-my-zsh/custom/themes/devcontainers.zsh-theme +check "zsh theme symlink" test -e $HOME/.oh-my-zsh/custom/themes/codespaces.zsh-theme # Report result reportResults \ No newline at end of file diff --git a/test/common-utils/ubi-8.sh b/test/common-utils/ubi-8.sh new file mode 100644 index 000000000..d97e614a7 --- /dev/null +++ b/test/common-utils/ubi-8.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +check "non-root user" test "$(whoami)" = "devcontainer" +check "distro" test "${PLATFORM_ID}" = "platform:el8" +check "curl" curl --version +check "jq" jq --version + +# Report result +reportResults diff --git a/test/common-utils/username-default.sh b/test/common-utils/username-default.sh new file mode 100755 index 000000000..49784b759 --- /dev/null +++ b/test/common-utils/username-default.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +# .oh-my-zsh folder would only exist if user defaulting worked +check "non-root user" ls /home/vscode/.oh-my-zsh + + +# Report result +reportResults \ No newline at end of file diff --git a/test/common-utils/username-detected.sh b/test/common-utils/username-detected.sh new file mode 100755 index 000000000..39c1b2998 --- /dev/null +++ b/test/common-utils/username-detected.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +. /etc/os-release +# .oh-my-zsh folder would only exist if user detection worked +check "non-root user" ls /home/node/.oh-my-zsh + +# Report result +reportResults \ No newline at end of file diff --git a/test/conda/conda_channel_creation.sh b/test/conda/conda_channel_creation.sh new file mode 100644 index 000000000..d1af05d5d --- /dev/null +++ b/test/conda/conda_channel_creation.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +## Test Conda +check "conda-update-conda" bash -c "conda update -c defaults -y conda" +check "conda-install-tensorflow" bash -c "conda create --name tensorflow-test-env -c conda-forge --yes tensorflow" +check "conda-install-pytorch" bash -c "conda create --name pytorch-test-env -c conda-forge --yes pytorch" + +# Report result +reportResults diff --git a/test/conda/conda_channel_creation_with_root.sh b/test/conda/conda_channel_creation_with_root.sh new file mode 120000 index 000000000..906864b93 --- /dev/null +++ b/test/conda/conda_channel_creation_with_root.sh @@ -0,0 +1 @@ +conda_channel_creation.sh \ No newline at end of file diff --git a/test/conda/install_conda.sh b/test/conda/install_conda.sh index efe7f5e89..c7c67dc51 100644 --- a/test/conda/install_conda.sh +++ b/test/conda/install_conda.sh @@ -5,7 +5,9 @@ set -e # Optional: Import test library source dev-container-features-test-lib -check "conda" conda --version | grep 4.12.0 +check "conda" conda --version +check "conda update" conda update -n base -c defaults -y conda +check "conda updated version" conda --version check "conda-forge" conda config --show channels | grep conda-forge check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt diff --git a/test/conda/install_conda_package_after_upgrade.sh b/test/conda/install_conda_package_after_upgrade.sh new file mode 100644 index 000000000..70715a959 --- /dev/null +++ b/test/conda/install_conda_package_after_upgrade.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Test that conda can install packages without pluggy incompatibility errors +# This validates the fix for the pluggy/conda version mismatch issue where +# conda self-upgrades but the older pluggy lacks the 'wrapper' attribute +check "conda version" conda --version +check "conda update" conda update -n base -c defaults -y conda +check "conda updated version" conda --version +check "install pyopenssl" conda install -y -c defaults pyopenssl +check "install cryptography" conda install -y -c defaults cryptography +check "install scipy with bioconda" conda install -y -c bioconda scipy +check "conda-forge" conda config --show channels | grep conda-forge +check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/conda-notice.txt + +# Report result +reportResults diff --git a/test/conda/install_conda_package_after_upgrade_with_root.sh b/test/conda/install_conda_package_after_upgrade_with_root.sh new file mode 120000 index 000000000..c3d5608a0 --- /dev/null +++ b/test/conda/install_conda_package_after_upgrade_with_root.sh @@ -0,0 +1 @@ +install_conda_package_after_upgrade.sh \ No newline at end of file diff --git a/test/conda/scenarios.json b/test/conda/scenarios.json index 9cc2fc136..f7c2692cb 100644 --- a/test/conda/scenarios.json +++ b/test/conda/scenarios.json @@ -1,11 +1,35 @@ { "install_conda": { - "image": "ubuntu:focal", + "image": "mcr.microsoft.com/devcontainers/base:noble", "features": { "conda": { - "version": "4.12.0", + "version": "latest", "addCondaForge": "true" } } + }, + "install_conda_package_after_upgrade": { + "image": "mcr.microsoft.com/devcontainers/base:noble", + "features": { + "conda": {} + } + }, + "conda_channel_creation": { + "image": "mcr.microsoft.com/devcontainers/base:noble", + "features": { + "conda": {} + } + }, + "install_conda_package_after_upgrade_with_root": { + "image": "ubuntu:noble", + "features": { + "conda": {} + } + }, + "conda_channel_creation_with_root": { + "image": "ubuntu:noble", + "features": { + "conda": {} + } } } diff --git a/test/conda/test.sh b/test/conda/test.sh index f3002176e..fcb9d7ef8 100755 --- a/test/conda/test.sh +++ b/test/conda/test.sh @@ -12,5 +12,31 @@ check "if conda-notice.txt exists" cat /usr/local/etc/vscode-dev-containers/cond # Check env check "CONDA_SCRIPT is set correctly" echo $CONDA_SCRIPT | grep "/opt/conda/etc/profile.d/conda.sh" +check-version-ge() { + LABEL=$1 + CURRENT_VERSION=$2 + REQUIRED_VERSION=$3 + shift + echo -e "\n🧪 Testing $LABEL: '$CURRENT_VERSION' is >= '$REQUIRED_VERSION'" + local GREATER_VERSION=$((echo ${CURRENT_VERSION}; echo ${REQUIRED_VERSION}) | sort -V | tail -1) + if [ "${CURRENT_VERSION}" == "${GREATER_VERSION}" ]; then + echo "✅ Passed!" + return 0 + else + echoStderr "❌ $LABEL check failed." + FAILED+=("$LABEL") + return 1 + fi +} + +certifiVersion=$(python -c "import certifi; print(certifi.__version__)") +check-version-ge "certifi" "${certifiVersion}" "2022.12.07" + +cryptographyVersion=$(python -c "import cryptography; print(cryptography.__version__)") +check-version-ge "cryptography" "${cryptographyVersion}" "39.0.1" + +setuptoolsVersion=$(python -c "import setuptools; print(setuptools.__version__)") +check-version-ge "setuptools" "${setuptoolsVersion}" "65.5.1" + # Report result reportResults diff --git a/test/copilot-cli/install_prerelease.sh b/test/copilot-cli/install_prerelease.sh new file mode 100644 index 000000000..6ef707418 --- /dev/null +++ b/test/copilot-cli/install_prerelease.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# End-to-end check that the "prerelease" channel actually resolves a tag and +# installs the binary. Regression guard for the inline pipeline in +# src/copilot-cli/install.sh. + +check "copilot binary is on PATH" which copilot +check "copilot reports a version" bash -c "copilot -v" + +# Auto-update flag file must exist for prerelease channel. +check "auto-update flag created for prerelease" test -f /etc/devcontainer-copilot-cli/auto-update + +# Report result +reportResults diff --git a/test/copilot-cli/resolve_prerelease_version.sh b/test/copilot-cli/resolve_prerelease_version.sh new file mode 100644 index 000000000..aa4ff41fd --- /dev/null +++ b/test/copilot-cli/resolve_prerelease_version.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +resolve_prerelease_version() { + local repo_versions="${1:?resolve_prerelease_version requires the copilot-cli repo tags as input}" + printf '%s\n' "${repo_versions}" \ + | awk '{print $2}' | sed 's|refs/tags/||' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$' \ + | sort -V | tail -n1 +} + +# Tests the tag-resolution pipeline used by src/copilot-cli/install.sh for the +# "prerelease" channel. + +check "copilot binary is on PATH" which copilot +check "copilot reports a version" bash -c "copilot -v" + +result1="$(resolve_prerelease_version $'abc1234\trefs/tags/v1.0.1\ndef5678\trefs/tags/v1.0.9\nghi9012\trefs/tags/v1.0.10\njkl3456\trefs/tags/v1.0.45\nmno7890\trefs/tags/v1.0.2\n')" +check "picks highest version (v1.0.45)" bash -c "[ '${result1}' = 'v1.0.45' ]" + +result2="$(resolve_prerelease_version $'abc1234\trefs/tags/v1.0.44\ndef5678\trefs/tags/v1.0.45-1\nghi9012\trefs/tags/v1.0.45-10\njkl3456\trefs/tags/v1.0.45-2\nmno7890\trefs/tags/v1.0.45\n')" +check "picks highest prerelease (v1.0.45-10)" bash -c "[ '${result2}' = 'v1.0.45-10' ]" + +result3="$(resolve_prerelease_version $'abc1234\trefs/tags/latest\ndef5678\trefs/tags/v1.0.3\nghi9012\trefs/tags/nightly\njkl3456\trefs/tags/v1.0.20\n')" +check "picks highest version ignoring non-version tags (v1.0.20)" bash -c "[ '${result3}' = 'v1.0.20' ]" + +# Report result +reportResults diff --git a/test/copilot-cli/scenarios.json b/test/copilot-cli/scenarios.json new file mode 100644 index 000000000..3d8336bec --- /dev/null +++ b/test/copilot-cli/scenarios.json @@ -0,0 +1,18 @@ +{ + "resolve_prerelease_version": { + "image": "ubuntu:noble", + "features": { + "copilot-cli": { + "version": "latest" + } + } + }, + "install_prerelease": { + "image": "ubuntu:noble", + "features": { + "copilot-cli": { + "version": "prerelease" + } + } + } +} diff --git a/test/copilot-cli/test.sh b/test/copilot-cli/test.sh new file mode 100644 index 000000000..d1d624cda --- /dev/null +++ b/test/copilot-cli/test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +echo "Checking if GitHub Copilot is installed..." +which copilot +copilot -v \ No newline at end of file diff --git a/test/desktop-lite/check_asound_package.sh b/test/desktop-lite/check_asound_package.sh new file mode 100644 index 000000000..112dac197 --- /dev/null +++ b/test/desktop-lite/check_asound_package.sh @@ -0,0 +1,33 @@ +checkOSPackage() { + PACKAGE_NAME=$1 + # Check if the package exists and retrieve its exact version + if [ "$(dpkg-query -W -f='${Status}' "$PACKAGE_NAME" 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then + echo "✅ Package '$PACKAGE_NAME' is installed." + return 0 + else + echo "❌ Package '$PACKAGE_NAME' is not installed." + return 1 + fi +} + +findAvailableOSPackage() { + local candidate + local package_name + for package_name in "$@"; do + candidate="$(apt-cache policy "${package_name}" | awk '/Candidate:/ {print $2}')" + if [ -n "${candidate}" ] && [ "${candidate}" != "(none)" ]; then + echo "${package_name}" + return 0 + fi + done + return 1 +} + +checkAsoundPackage() { + local alsa_package + if ! alsa_package="$(findAvailableOSPackage libasound2 libasound2t64 libasound2-dev)"; then + echo "No supported ALSA package found in apt indexes." >&2 + exit 1 + fi + check "alsa-package-installed-${alsa_package}" checkOSPackage "${alsa_package}" +} diff --git a/test/desktop-lite/scenarios.json b/test/desktop-lite/scenarios.json new file mode 100644 index 000000000..fcb804ff5 --- /dev/null +++ b/test/desktop-lite/scenarios.json @@ -0,0 +1,66 @@ +{ + "test_xtigervnc_novnc_started": { + "image": "ubuntu:noble", + "features": { + "desktop-lite": {} + } + }, + "test_xtigervnc_novnc_started_noVNC_old_launch_script": { + "image": "ubuntu:noble", + "features": { + "desktop-lite": { + "noVncVersion": "1.2.0" + } + } + }, + "test_vnc_resolution_as_container_env_var": { + "image": "ubuntu:noble", + "features": { + "desktop-lite": {} + }, + "containerEnv": { + "VNC_RESOLUTION": "1920x1080x32" + }, + "forwardPorts": [ + 5901, + 6080 + ] + }, + "test_vnc_resolution_as_remote_env_var": { + "image": "ubuntu:noble", + "features": { + "desktop-lite": {} + }, + "remoteEnv": { + "VNC_RESOLUTION": "1920x1080x32" + }, + "forwardPorts": [ + 5901, + 6080 + ] + }, + "test_xtigervnc_novnc_started_trixie": { + "image": "debian:trixie", + "features": { + "desktop-lite": {} + } + }, + "test_asound_package_ubuntu_2204": { + "image": "ubuntu:22.04", + "features": { + "desktop-lite": {} + } + }, + "test_asound_package_ubuntu_2604": { + "image": "ubuntu:26.04", + "features": { + "desktop-lite": {} + } + }, + "test_desktop_init_exec_passthrough": { + "image": "ubuntu:noble", + "features": { + "desktop-lite": {} + } + } +} \ No newline at end of file diff --git a/test/desktop-lite/test.sh b/test/desktop-lite/test.sh index bd0a51639..a22c49532 100755 --- a/test/desktop-lite/test.sh +++ b/test/desktop-lite/test.sh @@ -5,7 +5,19 @@ set -e # Optional: Import test library source dev-container-features-test-lib -echo 'NO TESTS FOR ME YET!' +echoStderr() +{ + echo "$@" 1>&2 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/check_asound_package.sh" + +check "desktop-init-exists" bash -c "ls /usr/local/share/desktop-init.sh" +check "log-exists" bash -c "ls /tmp/container-init.log" +check "fluxbox-exists" bash -c "ls -la ~/.fluxbox" + +checkAsoundPackage # Report result reportResults \ No newline at end of file diff --git a/test/desktop-lite/test_asound_package_ubuntu_2204.sh b/test/desktop-lite/test_asound_package_ubuntu_2204.sh new file mode 120000 index 000000000..297c107db --- /dev/null +++ b/test/desktop-lite/test_asound_package_ubuntu_2204.sh @@ -0,0 +1 @@ +test_asound_package_ubuntu_2604.sh \ No newline at end of file diff --git a/test/desktop-lite/test_asound_package_ubuntu_2604.sh b/test/desktop-lite/test_asound_package_ubuntu_2604.sh new file mode 100755 index 000000000..150a806ed --- /dev/null +++ b/test/desktop-lite/test_asound_package_ubuntu_2604.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/check_asound_package.sh" + +checkAsoundPackage + +# Report result +reportResults diff --git a/test/desktop-lite/test_desktop_init_exec_passthrough.sh b/test/desktop-lite/test_desktop_init_exec_passthrough.sh new file mode 100644 index 000000000..27d224ca3 --- /dev/null +++ b/test/desktop-lite/test_desktop_init_exec_passthrough.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Verify that desktop-init.sh correctly passes through commands. +# Previously, the heredoc in install.sh did not escape $1 and $@, causing them to expand +# to empty strings at install time, so any command passed to desktop-init.sh was silently ignored. + +check "command is passed through and executed" \ + bash -c "result=\$(/usr/local/share/desktop-init.sh echo 'passthrough-test-token' 2>/dev/null) && echo \"\$result\" | grep -q 'passthrough-test-token'" + +# Report result +reportResults diff --git a/test/desktop-lite/test_vnc_resolution_as_container_env_var.sh b/test/desktop-lite/test_vnc_resolution_as_container_env_var.sh new file mode 100644 index 000000000..cc3dd3261 --- /dev/null +++ b/test/desktop-lite/test_vnc_resolution_as_container_env_var.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +GREEN='\033[0;32m'; NC='\033[0m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; + +# Check if xtigervnc & noVnc processes are running after successful installation and initialization +check_process_running() { + port=$1 + # Get process id of process running on specific port + PID=$(lsof -i :$port | awk 'NR==2 {print $2}') + if [ -n "$PID" ]; then + CMD=$(ps -p $PID -o cmd --no-headers) + echo -e "${GREEN}Command running on port $port: ${YELLOW}$CMD${NC}" + else + echo -e "${RED}No process found listening on port $port.${NC}" + exit 1 + fi +} + +check_w_config_resolution() { + configResolution=$1 + actualResolution=$2 + + if echo "$1" | grep -q "$2"; then + echo -e "\n👍👍 ${GREEN}Configured resolution i.e. ${YELLOW}$configResolution${GREEN} has been set as vnc resolution i.e. ${YELLOW}$actualResolution${GREEN} in container.${NC}" + else + echo -e "\n❌❌ ${GREEN}Configured resolution i.e. ${YELLOW}$configResolution${GREEN} couldn't be set as vnc resolution i.e. ${YELLOW}$actualResolution${GREEN} in container.${NC}" + fi +} + +check "Whether xtigervnc is Running" check_process_running 5901 +resolution=$(ps -x -ww | grep Xtigervnc | awk "{for(i=1;i<=NF;i++) if (\$i ~ /-geometry/) {print \$(i+1); exit}}") +check "xtigervnc resolution" bash -c '$resolution' +check_w_config_resolution $VNC_RESOLUTION $resolution +sleep 2 +check "Whether no_vnc is Running" check_process_running 6080 + +check "desktop-init-exists" bash -c "ls /usr/local/share/desktop-init.sh" +check "log-exists" bash -c "ls /tmp/container-init.log" +check "log file contents" bash -c "cat /tmp/container-init.log" + +# Report result +reportResults \ No newline at end of file diff --git a/test/desktop-lite/test_vnc_resolution_as_remote_env_var.sh b/test/desktop-lite/test_vnc_resolution_as_remote_env_var.sh new file mode 100644 index 000000000..18409505c --- /dev/null +++ b/test/desktop-lite/test_vnc_resolution_as_remote_env_var.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Check if xtigervnc & noVnc processes are running after successful installation and initialization +check_process_running() { + port=$1 + # Get process id of process running on specific port + PID=$(lsof -i :$port | awk 'NR==2 {print $2}') + GREEN='\033[0;32m'; NC='\033[0m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; + if [ -n "$PID" ]; then + CMD=$(ps -p $PID -o cmd --no-headers) + echo -e "${GREEN}Command running on port $port: ${YELLOW}$CMD${NC}" + else + echo -e "${RED}No process found listening on port $port.${NC}" + exit 1 + fi +} + +check "Whether xtigervnc is Running" check_process_running 5901 +check "xtigervnc resolution" bash -c 'ps -x -ww | grep Xtigervnc | awk "{for(i=1;i<=NF;i++) if (\$i ~ /-geometry/) {print \$(i+1); exit}}"' +sleep 2 +check "Whether no_vnc is Running" check_process_running 6080 + +check "desktop-init-exists" bash -c "ls /usr/local/share/desktop-init.sh" +check "log-exists" bash -c "ls /tmp/container-init.log" +check "log file contents" bash -c "cat /tmp/container-init.log" + +# Report result +reportResults \ No newline at end of file diff --git a/test/desktop-lite/test_xtigervnc_novnc_started.sh b/test/desktop-lite/test_xtigervnc_novnc_started.sh new file mode 100644 index 000000000..671032229 --- /dev/null +++ b/test/desktop-lite/test_xtigervnc_novnc_started.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Check if xtigervnc & noVnc processes are running after successful installation and initialization +check_process_running() { + port=$1 + # Get process id of process running on specific port + PID=$(lsof -i :$port | awk 'NR==2 {print $2}') + if [ -n "$PID" ]; then + CMD=$(ps -p $PID -o cmd --no-headers) + GREEN='\033[0;32m'; NC='\033[0m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; + echo -e "${GREEN}Command running on port $port: ${YELLOW}$CMD${NC}" + else + echo -e "${RED}No process found listening on port $port.${NC}" + fi +} + +check "Whether xtigervnc is Running" check_process_running 5901 +sleep 1 +check "Whether no_vnc is Running" check_process_running 6080 + +check "desktop-init-exists" bash -c "ls /usr/local/share/desktop-init.sh" +check "log-exists" bash -c "ls /tmp/container-init.log" +check "log file contents" bash -c "cat /tmp/container-init.log" + +# Report result +reportResults \ No newline at end of file diff --git a/test/desktop-lite/test_xtigervnc_novnc_started_noVNC_old_launch_script.sh b/test/desktop-lite/test_xtigervnc_novnc_started_noVNC_old_launch_script.sh new file mode 100644 index 000000000..5e0898c7d --- /dev/null +++ b/test/desktop-lite/test_xtigervnc_novnc_started_noVNC_old_launch_script.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Check if xtigervnc & noVnc processes are running after successful installation and initialization +check_process_running() { + port=$1 + # Get process id of process running on specific port + PID=$(lsof -i :$port | awk 'NR==2 {print $2}') + if [ -n "$PID" ]; then + CMD=$(ps -p $PID -o cmd --no-headers) + GREEN='\033[0;32m'; NC='\033[0m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; + echo -e "${GREEN}Command running on port $port: ${YELLOW}$CMD${NC}" + else + echo -e "${RED}No process found listening on port $port.${NC}" + fi +} + +check "Whether xtigervnc is Running" check_process_running 5901 +sleep 1 +check "Whether no_vnc is Running" check_process_running 6080 + +check "desktop-init-exists" bash -c "ls /usr/local/share/desktop-init.sh" +check "log-exists" bash -c "ls /tmp/container-init.log" +check "log file contents" bash -c "cat /tmp/container-init.log" + +# Report result +reportResults + + diff --git a/test/desktop-lite/test_xtigervnc_novnc_started_trixie.sh b/test/desktop-lite/test_xtigervnc_novnc_started_trixie.sh new file mode 100644 index 000000000..671032229 --- /dev/null +++ b/test/desktop-lite/test_xtigervnc_novnc_started_trixie.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Check if xtigervnc & noVnc processes are running after successful installation and initialization +check_process_running() { + port=$1 + # Get process id of process running on specific port + PID=$(lsof -i :$port | awk 'NR==2 {print $2}') + if [ -n "$PID" ]; then + CMD=$(ps -p $PID -o cmd --no-headers) + GREEN='\033[0;32m'; NC='\033[0m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; + echo -e "${GREEN}Command running on port $port: ${YELLOW}$CMD${NC}" + else + echo -e "${RED}No process found listening on port $port.${NC}" + fi +} + +check "Whether xtigervnc is Running" check_process_running 5901 +sleep 1 +check "Whether no_vnc is Running" check_process_running 6080 + +check "desktop-init-exists" bash -c "ls /usr/local/share/desktop-init.sh" +check "log-exists" bash -c "ls /tmp/container-init.log" +check "log file contents" bash -c "cat /tmp/container-init.log" + +# Report result +reportResults \ No newline at end of file diff --git a/test/docker-in-docker/Dockerfile b/test/docker-in-docker/Dockerfile new file mode 100644 index 000000000..f2ca69d1d --- /dev/null +++ b/test/docker-in-docker/Dockerfile @@ -0,0 +1 @@ +FROM ubuntu:noble diff --git a/test/docker-in-docker/azureDnsAutoDetectionDisabled.sh b/test/docker-in-docker/azureDnsAutoDetectionDisabled.sh new file mode 100644 index 000000000..2415116d1 --- /dev/null +++ b/test/docker-in-docker/azureDnsAutoDetectionDisabled.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "dns flag should not be present" test ! "$(ps -ax | grep -v grep | grep -E 'dockerd.+\-\-dns')" + +# Report result +reportResults \ No newline at end of file diff --git a/test/docker-in-docker/azureDnsAutoDetectionEnabled.sh b/test/docker-in-docker/azureDnsAutoDetectionEnabled.sh new file mode 100644 index 000000000..87f158aac --- /dev/null +++ b/test/docker-in-docker/azureDnsAutoDetectionEnabled.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "dns flag should be present" ps -ax | grep -v grep | grep -E "dockerd.+\-\-dns" + +# Report result +reportResults \ No newline at end of file diff --git a/test/docker-in-docker/azure_linux.sh b/test/docker-in-docker/azure_linux.sh new file mode 100644 index 000000000..ca5f51997 --- /dev/null +++ b/test/docker-in-docker/azure_linux.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# OS identification (optional, can fail gracefully) +check "azure os detection" bash -c "cat /etc/os-release | grep -i azure || echo 'Not Azure Linux, but test can continue'" + +# Core Docker functionality +check "docker version" docker --version +check "docker daemon running" docker info + +# Docker init script (if using docker-in-docker feature) +check "docker init script exists" test -f "/usr/local/share/docker-init.sh" + +# Basic functionality test +check "docker container test" docker run --rm alpine echo "test successful" + +# The main Azure Linux specific test - DNS flag should NOT be present +check "dns flag should not be present" test ! "$(ps -ax | grep -v grep | grep -E 'dockerd.+\-\-dns')" + +# Report result +reportResults \ No newline at end of file diff --git a/test/docker-in-docker/azure_linux_docker_ce.sh b/test/docker-in-docker/azure_linux_docker_ce.sh new file mode 100644 index 000000000..fd1b72c4c --- /dev/null +++ b/test/docker-in-docker/azure_linux_docker_ce.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +echo "=== Azure Linux Docker CE Test ===" + +echo "1. Verifying OS identification..." +cat /etc/os-release | grep -i azure + +echo "2. Checking Docker installation..." +docker --version || { + echo "ERROR: Docker is not installed" + exit 1 +} + +echo "3. Checking if Docker binaries exist..." +which dockerd || echo "dockerd not in PATH" +ls -la /usr/bin/docker* || echo "No docker binaries in /usr/bin" +ls -la /usr/local/bin/docker* || echo "No docker binaries in /usr/local/bin" + +echo "4. Checking Docker service files..." +ls -la /etc/systemd/system/docker* || echo "No systemd docker files" +ls -la /usr/lib/systemd/system/docker* || echo "No system docker service files" + +echo "6. Checking Docker daemon status..." +if ! docker info >/dev/null 2>&1; then + echo "Docker daemon not running, starting it..." + + # Check if docker-init script exists + if [ -f "/usr/local/share/docker-init.sh" ]; then + echo "Starting Docker using docker-init.sh..." + + # Clear any existing log + rm -f /tmp/dockerd.log + + # Start docker-init in background + nohup /usr/local/share/docker-init.sh > /tmp/docker-init.log 2>&1 & + + # Wait for Docker to start with better debugging + echo "Waiting for Docker daemon to start..." + for i in {1..30}; do + if docker info >/dev/null 2>&1; then + echo "Docker daemon started successfully" + break + fi + echo "Waiting... ($i/30)" + + # Show dockerd logs if available + if [ -f "/tmp/dockerd.log" ]; then + echo "--- Recent dockerd.log entries ---" + tail -5 /tmp/dockerd.log || echo "Could not read dockerd.log" + echo "--- End of dockerd.log ---" + fi + + sleep 2 + done + + # Final check with detailed error reporting + if ! docker info >/dev/null 2>&1; then + echo "ERROR: Docker daemon failed to start after 60 seconds" + echo "" + echo "=== DEBUGGING INFORMATION ===" + echo "" + echo "1. Docker init log:" + cat /tmp/docker-init.log 2>/dev/null || echo "No docker-init.log found" + echo "" + echo "2. Docker daemon log:" + cat /tmp/dockerd.log 2>/dev/null || echo "No dockerd.log found" + echo "" + echo "3. Process list:" + ps aux | grep -E "(docker|containerd)" | grep -v grep || echo "No docker/containerd processes found" + echo "" + echo "4. Network interfaces:" + ip addr show || ifconfig || echo "Could not get network info" + echo "" + echo "5. Mount points:" + mount | grep -E "(docker|container)" || echo "No docker-related mounts" + echo "" + echo "6. SELinux status:" + if command -v getenforce >/dev/null 2>&1; then + getenforce || echo "SELinux command failed" + else + echo "SELinux tools not available" + fi + echo "" + echo "7. Available storage:" + df -h /var/lib/docker 2>/dev/null || df -h / || echo "Could not check storage" + echo "" + echo "8. System resources:" + free -h || echo "Could not check memory" + echo "" + echo "=== END DEBUGGING ===" + exit 1 + fi + else + echo "ERROR: docker-init.sh not found at /usr/local/share/docker-init.sh" + ls -la /usr/local/share/ || echo "Could not list /usr/local/share/" + exit 1 + fi +else + echo "Docker daemon is already running" +fi + +echo "7. Testing basic Docker functionality..." +docker info | head -10 + +echo "8. Testing container execution..." +docker run --rm alpine echo "Basic container test successful" + +echo "=== Docker CE test completed successfully ===" \ No newline at end of file diff --git a/test/docker-in-docker/cbl_mariner.sh b/test/docker-in-docker/cbl_mariner.sh new file mode 100644 index 000000000..44a8ee38e --- /dev/null +++ b/test/docker-in-docker/cbl_mariner.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Simple test script for cbl_mariner scenario (Moby = true) +# Run with: sudo bash script_cbl_mariner.sh + +set -e + +echo "=== Testing cbl_mariner scenario (Moby) ===" + +# Set environment variables for the scenario +export VERSION="latest" +export MOBY="true" +export AZUREDNSAUTODETECTION="false" + +# Source OS info +. /etc/os-release +echo "OS: $ID $VERSION_ID" + +# Check package manager +if type tdnf > /dev/null 2>&1; then + echo "Using tdnf" +else + echo "ERROR: tdnf not found" + exit 1 +fi + +# Validate +if command -v docker > /dev/null 2>&1; then + docker --version + echo "SUCCESS: Docker installed" +else + echo "ERROR: Docker not installed" + exit 1 +fi + +echo "=== cbl_mariner test passed ===" \ No newline at end of file diff --git a/test/docker-in-docker/cbl_mariner_docker_ce.sh b/test/docker-in-docker/cbl_mariner_docker_ce.sh new file mode 100644 index 000000000..24788377b --- /dev/null +++ b/test/docker-in-docker/cbl_mariner_docker_ce.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# test script for cbl_mariner_docker_ce scenario (Moby = false) + +set -e + +echo "=== Testing cbl_mariner_docker_ce scenario (Docker CE) ===" + +# Set environment variables for the scenario +export VERSION="latest" +export MOBY="false" +export AZUREDNSAUTODETECTION="false" + +# Source OS info +. /etc/os-release +echo "OS: $ID $VERSION_ID" + +# Check package manager +if type tdnf > /dev/null 2>&1; then + echo "Using tdnf" +else + echo "ERROR: tdnf not found" + exit 1 +fi + +# Validate +if command -v docker > /dev/null 2>&1; then + docker --version + echo "SUCCESS: Docker installed" +else + echo "ERROR: Docker not installed" + exit 1 +fi + +echo "=== cbl_mariner_docker_ce test passed ===" \ No newline at end of file diff --git a/test/docker-in-docker/dockerDefaultAddressPool.sh b/test/docker-in-docker/dockerDefaultAddressPool.sh new file mode 100644 index 000000000..219ad8215 --- /dev/null +++ b/test/docker-in-docker/dockerDefaultAddressPool.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "default address pool setting set" ps -ax | grep -v grep | grep -E "dockerd.+base=192.168.0.0/16,size=24" + +# Report result +reportResults \ No newline at end of file diff --git a/test/docker-in-docker/dockerIp6tablesDisabledTest.sh b/test/docker-in-docker/dockerIp6tablesDisabledTest.sh new file mode 100644 index 000000000..5fe8c2a3b --- /dev/null +++ b/test/docker-in-docker/dockerIp6tablesDisabledTest.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +ip6tablesCheck() { + if command -v ip6tables > /dev/null 2>&1; then + if ip6tables -L > /dev/null 2>&1; then + echo "✔️ ip6tables is enabled." + else + echo "❌ ip6tables is disabled." + fi + else + echo "❕ip6tables command not found. ❕" + fi +} +check "docker ps" bash -c "docker ps" +check "ip6tables" ip6tablesCheck +check "ip6tables check" bash -c "docker network inspect bridge" +check "docker-build" docker build ./ + +reportResults \ No newline at end of file diff --git a/test/docker-in-docker/docker_build.sh b/test/docker-in-docker/docker_build.sh new file mode 100755 index 000000000..da5d31a3b --- /dev/null +++ b/test/docker-in-docker/docker_build.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "docker compose" bash -c "docker compose version | grep -E '2.[0-9]+.[0-9]+'" +check "docker-compose" bash -c "docker-compose --version | grep -E '2.[0-9]+.[0-9]+'" + +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +check "Not installing compose-switch by default" bash -c "[[ ! -f /usr/local/bin/compose-switch ]]" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_build_2.sh b/test/docker-in-docker/docker_build_2.sh new file mode 100644 index 000000000..d60cd937a --- /dev/null +++ b/test/docker-in-docker/docker_build_2.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_build_fallback_buildx.sh b/test/docker-in-docker/docker_build_fallback_buildx.sh new file mode 100644 index 000000000..e139613a0 --- /dev/null +++ b/test/docker-in-docker/docker_build_fallback_buildx.sh @@ -0,0 +1,190 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests before test for fallback +HL="\033[1;33m" +N="\033[0;37m" +echo -e "\n👉${HL} docker/buildx version as installed by docker-in-docker feature${N}" +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +# Code to test the made up scenario when latest version of docker/buildx fails on wget command for fetching the artifacts +architecture="$(dpkg --print-architecture)" +case "${architecture}" in + amd64) target_compose_arch=x86_64 ;; + arm64) target_compose_arch=aarch64 ;; + *) + echo "(!) Docker in docker does not support machine architecture '$architecture'. Please use an x86-64 or ARM64 machine." + exit 1 +esac + +docker_home="/usr/libexec/docker" +cli_plugins_dir="${docker_home}/cli-plugins" + +# Figure out correct version of a three part version number is not passed +find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + local escaped_separator=${separator//./\\.} + local last_part + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + fi + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then + err "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + echo "${variable_name}=${!variable_name}" +} + +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} + +# Function to fetch the version released prior to the latest version +get_previous_version() { + local url=$1 + local repo_url=$2 + local variable_name=$3 + local mode=$4 + prev_version=${!variable_name} + + echo -e "\nAttempting to find latest version using Github Api." + + output=$(curl -s "$repo_url"); + message=$(echo "$output" | jq -r '.message') + + if [[ $mode != "install_from_github_api_valid" ]]; then + message="API rate limit exceeded" + fi + + if [[ $message == "API rate limit exceeded"* ]]; then + echo -e "\nAttempting to find latest version using Github Api Failed. Exceeded API Rate Limit." + echo -e "\nAttempting to find latest version using Github Tags." + find_prev_version_from_git_tags prev_version "$url" "tags/v" + declare -g ${variable_name}="${prev_version}" + else + echo -e "\nAttempting to find latest version using Github Api Succeeded." + version=$(echo "$output" | jq -r '.tag_name') + declare -g ${variable_name}="${version#v}" + fi + echo "${variable_name}=${!variable_name}" +} + +get_github_api_repo_url() { + local url=$1 + echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases/latest" +} + +install_using_get_previous_version() { + local url=$1 + local mode=$2 + local repo_url=$(get_github_api_repo_url "$url") + echo -e "\n(!) Failed to fetch the latest artifacts for docker buildx v${buildx_version}..." + get_previous_version "${url}" "${repo_url}" buildx_version "${mode}" + buildx_file_name="buildx-v${buildx_version}.linux-${architecture}" + echo -e "\nAttempting to install v${buildx_version}" + wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name} +} + +install_docker_buildx() { + mode=$1 + echo -e "\n${HL} Creating a scenario for fallback${N}\n" + + buildx_version="0.13.xyz" + echo "(*) Installing buildx ${buildx_version}..." + buildx_file_name="buildx-v${buildx_version}.linux-${architecture}" + cd /tmp + + docker_buildx_url="https://github.com/docker/buildx" + wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name} || install_using_get_previous_version "${docker_buildx_url}" "${mode}" + + docker_home="/usr/libexec/docker" + cli_plugins_dir="${docker_home}/cli-plugins" + + mkdir -p ${cli_plugins_dir} + mv ${buildx_file_name} ${cli_plugins_dir}/docker-buildx + chmod +x ${cli_plugins_dir}/docker-buildx + + chown -R "${USERNAME}:docker" "${docker_home}" + chmod -R g+r+w "${docker_home}" + find "${docker_home}" -type d -print0 | xargs -n 1 -0 chmod g+s +} + +echo -e "\n👉${HL} docker-buildx version as installed by docker-in-docker test ( installing by github api ) ${N}" +install_docker_buildx "install_from_github_api_valid" + +# Definition specific tests after test for fallback +check "docker-buildx" docker buildx version +check "docker-buildx" bash -c "docker buildx version" + +echo -e "\n👉${HL} docker-buildx version as installed by docker-in-docker test ( installing by find_prev_version_from_git_tags ) ${N}" +install_docker_buildx + +# Definition specific tests after test for fallback +check "docker-buildx" docker buildx version +check "docker-buildx" bash -c "docker buildx version" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_build_fallback_compose.sh b/test/docker-in-docker/docker_build_fallback_compose.sh new file mode 100644 index 000000000..ed1dab3f4 --- /dev/null +++ b/test/docker-in-docker/docker_build_fallback_compose.sh @@ -0,0 +1,171 @@ +#!/bin/bash + +# Optional: Import test library +source dev-container-features-test-lib + +# Setup STDERR. +err() { + echo "(!) $*" >&2 +} + +HL="\033[1;33m" +N="\033[0;37m" +echo -e "\n👉${HL} docker-compose version as installed by docker-in-docker feature${N}" +check "docker-compose" bash -c "docker-compose version" + +architecture="$(dpkg --print-architecture)" +case "${architecture}" in + amd64) target_compose_arch=x86_64 ;; + arm64) target_compose_arch=aarch64 ;; + *) + echo "(!) Docker in docker does not support machine architecture '$architecture'. Please use an x86-64 or ARM64 machine." + exit 1 +esac + +docker_compose_path="/usr/local/bin/docker-compose" +cli_plugins_dir="${docker_home}/cli-plugins" + +# Figure out correct version of a three part version number is not passed +find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + local escaped_separator=${separator//./\\.} + local last_part + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + fi + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then + err "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + echo "${variable_name}=${!variable_name}" +} + +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} + +# Function to fetch the version released prior to the latest version +get_previous_version() { + local url=$1 + local repo_url=$2 + local variable_name=$3 + local mode=$4 + prev_version=${!variable_name} + + output=$(curl -s "$repo_url"); + + if echo "$output" | jq -e 'type == "object"' > /dev/null; then + message=$(echo "$output" | jq -r '.message') + + if [[ $message == "API rate limit exceeded"* ]]; then + echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}" + echo -e "\nAttempting to find latest version using GitHub tags." + find_prev_version_from_git_tags prev_version "$url" "tags/v" + declare -g ${variable_name}="${prev_version}" + fi + elif echo "$output" | jq -e 'type == "array"' > /dev/null; then + echo -e "\nAttempting to find latest version using GitHub Api." + version=$(echo "$output" | jq -r '.[1].tag_name') + declare -g ${variable_name}="${version#v}" + fi + echo "${variable_name}=${!variable_name}" +} + +get_github_api_repo_url() { + local url=$1 + echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases" +} + +install_using_get_previous_version() { + local url=$1 + local mode=$2 + local repo_url=$(get_github_api_repo_url "$url") + echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..." + get_previous_version "$url" "$repo_url" compose_version "$mode" + echo -e "\nAttempting to install v${compose_version}" + curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} +} + +install_docker_compose() { + mode=$1 + compose_version="2.25.xyz" + docker_compose_url="https://github.com/docker/compose" + echo "(*) Installing docker-compose ${compose_version}..." + curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} || install_using_get_previous_version "$docker_compose_url" "$mode" +} + +chmod +x ${docker_compose_path} + +# Download the SHA256 checksum +DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}.sha256" | awk '{print $1}')" +echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum +sha256sum -c docker-compose.sha256sum --ignore-missing + +mkdir -p ${cli_plugins_dir} +cp ${docker_compose_path} ${cli_plugins_dir} + +echo -e "\n👉${HL} docker-compose version as installed by docker-in-docker test ( installing by github api ) ${N}" +install_docker_compose "install_from_github_api_valid" + +check "docker-compose" bash -c "docker-compose version" + +echo -e "\n👉${HL} docker-compose version as installed by docker-in-docker test ( installing by find_prev_version_from_git_tags ) ${N}" +install_docker_compose + +check "docker-compose" bash -c "docker-compose version" diff --git a/test/docker-in-docker/docker_build_no_compose.sh b/test/docker-in-docker/docker_build_no_compose.sh new file mode 100755 index 000000000..e913be321 --- /dev/null +++ b/test/docker-in-docker/docker_build_no_compose.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "not installing compose skips docker-compose v1 install" bash -c "! type docker-compose" +check "not installing compose skips compose-switch" bash -c "[[ ! -f /usr/local/bin/compose-switch ]]" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_build_older.sh b/test/docker-in-docker/docker_build_older.sh new file mode 100644 index 000000000..ed9932a84 --- /dev/null +++ b/test/docker-in-docker/docker_build_older.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" +check "docker ps" bash -c "docker ps" +# Report result +reportResults diff --git a/test/docker-in-docker/docker_build_ubuntu_resolute.sh b/test/docker-in-docker/docker_build_ubuntu_resolute.sh new file mode 100644 index 000000000..c9fd7affc --- /dev/null +++ b/test/docker-in-docker/docker_build_ubuntu_resolute.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_build_with_compose_switch.sh b/test/docker-in-docker/docker_build_with_compose_switch.sh new file mode 100644 index 000000000..ff4f7b58f --- /dev/null +++ b/test/docker-in-docker/docker_build_with_compose_switch.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "docker compose" bash -c "docker compose version | grep -E '2.[0-9]+.[0-9]+'" +check "docker-compose" bash -c "docker-compose --version | grep -E '2.[0-9]+.[0-9]+'" + +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +check "installs compose-switch as the flag is on" bash -c "[[ -f /usr/local/bin/compose-switch ]]" + +# Report result +reportResults + diff --git a/test/docker-in-docker/docker_buildx.sh b/test/docker-in-docker/docker_buildx.sh new file mode 100755 index 000000000..b66f7c57f --- /dev/null +++ b/test/docker-in-docker/docker_buildx.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "installs docker-compose v1 install" bash -c "type docker-compose" + +check "Not installing compose-switch by default" bash -c "[[ ! -f /usr/local/bin/compose-switch ]]" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_compose_latest_moby.sh b/test/docker-in-docker/docker_compose_latest_moby.sh new file mode 100755 index 000000000..bd5a9d23f --- /dev/null +++ b/test/docker-in-docker/docker_compose_latest_moby.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker compose" bash -c "docker compose version | grep -E '[0-9]+\.[0-9]+\.[0-9]+'" +check "docker-compose" bash -c "docker-compose --version | grep -E '[0-9]+\.[0-9]+\.[0-9]+'" +check "installs compose as docker-compose" bash -c "[[ -f /usr/local/bin/docker-compose ]]" +check "moby-engine" bash -c "dpkg-query -W moby-engine" +check "moby-cli" bash -c "dpkg-query -W moby-cli" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_compose_latest_no_moby.sh b/test/docker-in-docker/docker_compose_latest_no_moby.sh new file mode 100644 index 000000000..5eeb34b45 --- /dev/null +++ b/test/docker-in-docker/docker_compose_latest_no_moby.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker compose" bash -c "docker compose version | grep -E '[0-9]+\.[0-9]+\.[0-9]+'" +check "docker-compose" bash -c "docker-compose --version | grep -E '[0-9]+\.[0-9]+\.[0-9]+'" +check "installs compose as docker-compose" bash -c "[[ -f /usr/local/bin/docker-compose ]]" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_compose_v1.sh b/test/docker-in-docker/docker_compose_v1.sh new file mode 100755 index 000000000..3f7453c83 --- /dev/null +++ b/test/docker-in-docker/docker_compose_v1.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests + +check "docker-compose" bash -c "docker-compose version | grep -E '1.[0-9]+.[0-9]+'" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_compose_v2.sh b/test/docker-in-docker/docker_compose_v2.sh new file mode 100755 index 000000000..854206cd1 --- /dev/null +++ b/test/docker-in-docker/docker_compose_v2.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests + +check "docker compose" bash -c "docker compose version | grep -E '2.[0-9]+.[0-9]+'" +check "docker-compose" bash -c "docker-compose --version | grep -E '2.[0-9]+.[0-9]+'" +check "installs compose-switch as docker-compose" bash -c "[[ -f /usr/local/bin/docker-compose ]]" + +check "Not installing compose-switch by default" bash -c "[[ ! -f /usr/local/bin/compose-switch ]]" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_iptables_switch_at_install.sh b/test/docker-in-docker/docker_iptables_switch_at_install.sh new file mode 100644 index 000000000..c87650a4c --- /dev/null +++ b/test/docker-in-docker/docker_iptables_switch_at_install.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Default behavior (iptablesSwitchAtRuntime omitted -> false): switching happens +# at image build time, so docker-init.sh should NOT contain the runtime block. +check "init-script-exists" bash -c "test -f /usr/local/share/docker-init.sh" +check "no-runtime-iptables-block" bash -c "! grep -q 'update-alternatives --set iptables' /usr/local/share/docker-init.sh" + +# The build-time switch should have set /etc/alternatives/iptables to one of the +# known backends. With the ip_tables module loaded on the host, legacy is preferred. +check "iptables-alternative-set" bash -c "readlink /etc/alternatives/iptables | grep -E 'iptables-(legacy|nft)$'" +check "iptables works" sudo iptables -L + +check "version" docker --version +check "docker-ps" bash -c "docker ps" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_iptables_switch_at_runtime.sh b/test/docker-in-docker/docker_iptables_switch_at_runtime.sh new file mode 100644 index 000000000..152a4ec98 --- /dev/null +++ b/test/docker-in-docker/docker_iptables_switch_at_runtime.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# iptablesSwitchAtRuntime=true: switching is deferred to container start, so the +# runtime block MUST have been written into docker-init.sh by install.sh. +check "init-script-exists" bash -c "test -f /usr/local/share/docker-init.sh" +check "runtime-iptables-block-present" bash -c "grep -q 'update-alternatives --set iptables' /usr/local/share/docker-init.sh" +check "runtime-iptables-block-has-legacy-branch" bash -c "grep -q '/usr/sbin/iptables-legacy' /usr/local/share/docker-init.sh" +check "runtime-iptables-block-has-nft-branch" bash -c "grep -q '/usr/sbin/iptables-nft' /usr/local/share/docker-init.sh" + +# The runtime block runs as part of docker-init.sh (the feature's entrypoint), +# so by the time these tests execute the alternative must already be set. +check "iptables-alternative-set" bash -c "readlink /etc/alternatives/iptables | grep -E 'iptables-(legacy|nft)$'" +check "iptables works" sudo iptables -L + +check "version" docker --version +check "docker-ps" bash -c "docker ps" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_python_bookworm.sh b/test/docker-in-docker/docker_python_bookworm.sh new file mode 100755 index 000000000..b7080dee1 --- /dev/null +++ b/test/docker-in-docker/docker_python_bookworm.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "installs docker-compose v2 install" bash -c "type docker-compose" +check "docker compose" bash -c "docker compose version | grep -E '2.[0-9]+.[0-9]+'" +check "docker-compose" bash -c "docker-compose --version | grep -E '2.[0-9]+.[0-9]+'" + +check "installs compose-switch as docker-compose" bash -c "[[ -f /usr/local/bin/docker-compose ]]" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_specific_moby_buildx.sh b/test/docker-in-docker/docker_specific_moby_buildx.sh new file mode 100755 index 000000000..a6a7062d6 --- /dev/null +++ b/test/docker-in-docker/docker_specific_moby_buildx.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib +# Definition specific tests +check "moby-buildx" bash -c "dpkg-query -W moby-buildx | grep -E '0.14.0'" + +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "installs docker-compose v1 install" bash -c "type docker-compose" + +check "Not installing compose-switch by default" bash -c "[[ ! -f /usr/local/bin/compose-switch ]]" + +# Report result +reportResults diff --git a/test/docker-in-docker/docker_with_default_iptables.sh b/test/docker-in-docker/docker_with_default_iptables.sh new file mode 100644 index 000000000..8336cda4c --- /dev/null +++ b/test/docker-in-docker/docker_with_default_iptables.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Feature specific tests +check "docker-ps" bash -c "docker ps" +# Fail loudly if dockerd never finished initializing, printing the real error +check "dockerd-started-successfully" bash -c ' + if ! grep -q "Daemon has completed initialization" /tmp/dockerd.log; then + echo "❌ Docker daemon failed to start. Last errors from /tmp/dockerd.log:" + echo "----- dockerd.log (tail) -----" + tail -n 100 /tmp/dockerd.log + echo "----- error/fatal lines -----" + grep -iE "error|fatal|failed|panic" /tmp/dockerd.log || true + exit 1 + fi +' + +check "iptables works" sudo iptables -L +check "iptables uses nf_tables" bash -c "iptables --version | grep nf_tables" + +check "version" docker --version +check "docker-ps" bash -c "docker ps" +check "log-exists" bash -c "ls /tmp/dockerd.log" +check "log-for-completion" bash -c "cat /tmp/dockerd.log | grep 'Daemon has completed initialization'" +check "log-contents" bash -c "cat /tmp/dockerd.log | grep 'API listen on /var/run/docker.sock'" + +# Report result +reportResults + diff --git a/test/docker-in-docker/docker_with_default_iptables_ubuntu.sh b/test/docker-in-docker/docker_with_default_iptables_ubuntu.sh new file mode 120000 index 000000000..7eb9de2c6 --- /dev/null +++ b/test/docker-in-docker/docker_with_default_iptables_ubuntu.sh @@ -0,0 +1 @@ +docker_with_default_iptables.sh \ No newline at end of file diff --git a/test/docker-in-docker/docker_with_legacy_iptables.sh b/test/docker-in-docker/docker_with_legacy_iptables.sh new file mode 100644 index 000000000..e29e10146 --- /dev/null +++ b/test/docker-in-docker/docker_with_legacy_iptables.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Feature specific tests +check "iptables works" sudo iptables -L +check "iptables uses legacy" bash -c "iptables --version | grep legacy" + +check "version" docker --version +check "docker-ps" bash -c "docker ps" +check "log-exists" bash -c "ls /tmp/dockerd.log" +check "log-for-completion" bash -c "cat /tmp/dockerd.log | grep 'Daemon has completed initialization'" +check "log-contents" bash -c "cat /tmp/dockerd.log | grep 'API listen on /var/run/docker.sock'" + +# Report result +reportResults + diff --git a/test/docker-in-docker/docker_with_legacy_iptables_ubuntu.sh b/test/docker-in-docker/docker_with_legacy_iptables_ubuntu.sh new file mode 120000 index 000000000..5b62242e3 --- /dev/null +++ b/test/docker-in-docker/docker_with_legacy_iptables_ubuntu.sh @@ -0,0 +1 @@ +docker_with_legacy_iptables.sh \ No newline at end of file diff --git a/test/docker-in-docker/docker_with_on_create_command.sh b/test/docker-in-docker/docker_with_on_create_command.sh new file mode 100755 index 000000000..131304acb --- /dev/null +++ b/test/docker-in-docker/docker_with_on_create_command.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Feature specific tests +check "version" docker --version +check "docker-ps" bash -c "docker ps" + +check "docker-init-exists" bash -c "ls /usr/local/share/docker-init.sh" +check "log-exists" bash -c "ls /tmp/dockerd.log" +check "log-for-completion" bash -c "cat /tmp/dockerd.log | grep 'Daemon has completed initialization'" +check "log-contents" bash -c "cat /tmp/dockerd.log | grep 'API listen on /var/run/docker.sock'" + +# Report result +reportResults \ No newline at end of file diff --git a/test/docker-in-docker/install_moby_on_debian_trixie.sh b/test/docker-in-docker/install_moby_on_debian_trixie.sh new file mode 120000 index 000000000..6c5c2a157 --- /dev/null +++ b/test/docker-in-docker/install_moby_on_debian_trixie.sh @@ -0,0 +1 @@ +install_on_debian_trixie.sh \ No newline at end of file diff --git a/test/docker-in-docker/install_on_debian_trixie.sh b/test/docker-in-docker/install_on_debian_trixie.sh new file mode 100644 index 000000000..c6c679684 --- /dev/null +++ b/test/docker-in-docker/install_on_debian_trixie.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +# Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker installed" bash -c "type docker" + +# Report results +reportResults diff --git a/test/docker-in-docker/overlayfs_containerd_root.sh b/test/docker-in-docker/overlayfs_containerd_root.sh new file mode 100644 index 000000000..f1099c91e --- /dev/null +++ b/test/docker-in-docker/overlayfs_containerd_root.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# +# Regression test for devcontainers/features#1639 / PR #1645 follow-up: +# verifies that when the dev container's root filesystem is overlayfs +# (the default under Docker / containerd-backed hosts), the standalone +# containerd started by the docker-in-docker Feature does NOT place its +# overlayfs snapshotter data on an overlay rootfs (which would fail with +# `invalid argument` when pulling images). +# +set -e + +source dev-container-features-test-lib + +# 1. Confirm we're really reproducing the affected condition: +# the dev container's / must be overlay. +check "rootfs is overlay (precondition)" \ + bash -c '[ "$(findmnt -no FSTYPE /)" = "overlay" ]' + +# 2. The Feature's volume mount must shadow /var/lib/containerd with a +# non-overlay filesystem. Without the mount, containerd's overlayfs +# snapshotter would be writing onto the overlay rootfs and fail at +# pull time. +check "/var/lib/containerd is not overlay" \ + bash -c '[ "$(findmnt -no FSTYPE /var/lib/containerd)" != "overlay" ]' + +check "/var/lib/docker is not overlay" \ + bash -c '[ "$(findmnt -no FSTYPE /var/lib/docker)" != "overlay" ]' + +# 3. The actual symptom: pulling and running an image must succeed. +# Pre-PR-#1645 this fails with: +# failed to mount /tmp/containerd-mountXXXXX ... err: invalid argument +check "docker run hello-world" \ + docker run --rm hello-world + +# 4. Belt-and-braces: confirm dockerd is actually using the +# containerd-snapshotter path so we know this test exercises the +# affected code path, not the legacy overlay2 driver. +check "containerd-snapshotter active" \ + bash -c "docker info 2>/dev/null | grep -qiE 'driver-type: io.containerd.snapshotter.v1|Storage Driver: overlayfs'" + +reportResults + diff --git a/test/docker-in-docker/pin_docker-ce_version_moby_false.sh b/test/docker-in-docker/pin_docker-ce_version_moby_false.sh new file mode 100644 index 000000000..4f1eedb84 --- /dev/null +++ b/test/docker-in-docker/pin_docker-ce_version_moby_false.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Optional: Import test library +source dev-container-features-test-lib + +check "docker-ce" bash -c "docker --version" +check "docker-ce-cli" bash -c "docker version" +check "docker ps" bash -c "docker ps" + +#report result +reportResults \ No newline at end of file diff --git a/test/docker-in-docker/scenarios.json b/test/docker-in-docker/scenarios.json new file mode 100644 index 000000000..a4ee2a3b0 --- /dev/null +++ b/test/docker-in-docker/scenarios.json @@ -0,0 +1,327 @@ +{ + "docker_iptables_switch_at_install": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "docker-in-docker": { + "moby": "false", + "iptablesSwitchAtRuntime": false + } + }, + "initializeCommand": "sudo modprobe ip_tables" + }, + // DO NOT REMOVE: This scenario is used by the docker-in-docker-daemon-startup-bulk-test workflow + "docker_iptables_switch_at_runtime": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "docker-in-docker": { + "moby": "false", + "iptablesSwitchAtRuntime": true + } + }, + "initializeCommand": "sudo modprobe ip_tables" + }, + "docker_with_default_iptables": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "docker-in-docker": { + "moby": "false" + } + }, + "initializeCommand": "sudo modprobe --remove --remove-holders --wait 1000 ip_tables" + }, + "docker_with_legacy_iptables": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "docker-in-docker": { + "moby": "false" + } + }, + "initializeCommand": "sudo modprobe ip_tables" + }, + "docker_with_default_iptables_ubuntu": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "docker-in-docker": { + "moby": "false" + } + }, + "initializeCommand": "sudo modprobe --remove --remove-holders --wait 1000 ip_tables" + }, + "docker_with_legacy_iptables_ubuntu": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "docker-in-docker": { + "moby": "false" + } + }, + "initializeCommand": "sudo modprobe ip_tables" + }, + "overlayfs_containerd_root": { + "image": "mcr.microsoft.com/devcontainers/base:noble", + "features": { + "docker-in-docker": { + "version": "latest", + "moby": true + } + } + }, + "docker_build_fallback_compose": { + "image": "ubuntu:noble", + "features": { + "docker-in-docker": { + "version": "latest", + "dockerDashComposeVersion": "latest" + } + } + }, + "dockerIp6tablesDisabledTest": { + "image": "ubuntu:noble", + "features": { + "docker-in-docker": { + "version": "27.0.3", + "disableIp6tables": true + } + } + }, + "dockerDefaultAddressPool": { + "image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18", + "remoteUser": "node", + "features": { + "docker-in-docker": { + "dockerDefaultAddressPool": "base=192.168.0.0/16,size=24" + } + } + }, + "azureDnsAutoDetectionEnabled": { + "image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18", + "remoteUser": "node", + "features": { + "docker-in-docker": { + "azureDnsAutoDetection": true + } + } + }, + "azureDnsAutoDetectionDisabled": { + "image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18", + "remoteUser": "node", + "features": { + "docker-in-docker": { + "azureDnsAutoDetection": false + } + } + }, + "docker_build": { + "image": "mcr.microsoft.com/devcontainers/javascript-node:0-18", + "features": { + "docker-in-docker": { + "version": "latest", + "moby": "false", + "dockerDashComposeVersion": "v2" + } + }, + "remoteUser": "node" + }, + "docker_build_with_compose_switch": { + "image": "mcr.microsoft.com/devcontainers/javascript-node:0-18", + "features": { + "docker-in-docker": { + "version": "latest", + "moby": "false", + "dockerDashComposeVersion": "v2", + "installDockerComposeSwitch": true + } + }, + "remoteUser": "node" + }, + "docker_build_2": { + "image": "ubuntu:noble", + "features": { + "docker-in-docker": { + "version": "latest", + "installDockerBuildx": true, + "moby": "false", + "dockerDashComposeVersion": "v2" + } + } + }, + "docker_build_older": { + "image": "mcr.microsoft.com/devcontainers/javascript-node:0-18", + "features": { + "docker-in-docker": { + "version": "20", + "moby": "false", + "dockerDashComposeVersion": "v2" + } + }, + "remoteUser": "node" + }, + "docker_build_no_compose": { + "image": "mcr.microsoft.com/devcontainers/javascript-node:0-18", + "features": { + "docker-in-docker": { + "dockerDashComposeVersion": "none" + } + }, + "remoteUser": "node" + }, + "docker_buildx": { + "image": "mcr.microsoft.com/devcontainers/base:bookworm", + "features": { + "docker-in-docker": { + "moby": false, + "installDockerBuildx": true + } + } + }, + "docker_python_bookworm": { + "image": "mcr.microsoft.com/devcontainers/base:2.1.8-bookworm", + "features": { + "docker-in-docker": { + "moby": true, + "installDockerBuildx": true, + "dockerDashComposeVersion": "v2" + } + } + }, + "docker_compose_v1": { + "image": "mcr.microsoft.com/devcontainers/base:noble", + "features": { + "docker-in-docker": { + "moby": true, + "installDockerBuildx": true, + "dockerDashComposeVersion": "v1" + } + } + }, + "docker_compose_v2": { + "image": "mcr.microsoft.com/devcontainers/base:noble", + "features": { + "docker-in-docker": { + "moby": true, + "installDockerBuildx": true, + "dockerDashComposeVersion": "v2" + } + } + }, + "docker_compose_latest_moby": { + "image": "mcr.microsoft.com/devcontainers/base:noble", + "features": { + "docker-in-docker": { + "moby": true, + "installDockerBuildx": true, + "dockerDashComposeVersion": "latest" + } + } + }, + "docker_compose_latest_no_moby": { + "image": "mcr.microsoft.com/devcontainers/base:noble", + "features": { + "docker-in-docker": { + "moby": false, + "installDockerBuildx": true, + "dockerDashComposeVersion": "latest" + } + } + }, + "docker_build_fallback_buildx": { + "image": "ubuntu:noble", + "features": { + "docker-in-docker": { + "version": "latest", + "installDockerBuildx": true + } + } + }, + "install_on_debian_trixie": { + "image": "debian:trixie", + "features": { + "docker-in-docker": { + "moby": false + } + } + }, + "docker_build_ubuntu_resolute": { + "image": "ubuntu:resolute", + "features": { + "docker-in-docker": { + "moby": false + } + } + }, + "docker_specific_moby_buildx": { + "image": "ubuntu:noble", + "features": { + "docker-in-docker": { + "mobyBuildxVersion": "0.14.0" + } + } + }, + "pin_docker-ce_version_moby_false": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "docker-in-docker": { + "version": "28.4.0", + "moby": "false", + "mobyBuildxVersion": "latest", + "dockerDashComposeVersion": "none", + "azureDnsAutoDetection": "true", + "dockerDefaultAddressPool": "", + "installDockerBuildx": "true", + "installDockerComposeSwitch": "true", + "disableIp6tables": "false" + } + } + }, + // DO NOT REMOVE: This scenario is used by the docker-in-docker-daemon-startup-bulk-test workflow + "docker_with_on_create_command": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "docker-in-docker": { + "version": "latest", + "moby": "false" + } + }, + "remoteUser": "vscode", + "onCreateCommand": "docker ps && sleep 5s && docker ps" + }, + "azure_linux": { + "image": "mcr.microsoft.com/azurelinux/base/core:3.0", + "features": { + "docker-in-docker": { + "version": "latest", + "moby": "true", + "azureDnsAutoDetection": false + } + } + }, + "azure_linux_docker_ce": { + "image": "mcr.microsoft.com/azurelinux/base/core:3.0", + "features": { + "docker-in-docker": { + "version": "latest", + "moby": "false", + "azureDnsAutoDetection": false + } + } + }, + "cbl_mariner": { + "image": "mcr.microsoft.com/cbl-mariner/base/core:2.0", + "features": { + "docker-in-docker": { + "version": "latest", + "moby": "true", + "azureDnsAutoDetection": false + } + } + }, + "cbl_mariner_docker_ce": { + "image": "mcr.microsoft.com/cbl-mariner/base/core:2.0", + "features": { + "docker-in-docker": { + "version": "latest", + "moby": "false", + "azureDnsAutoDetection": false + } + } + } +} diff --git a/test/docker-in-docker/test.sh b/test/docker-in-docker/test.sh index 3a802b59b..10a7232b6 100755 --- a/test/docker-in-docker/test.sh +++ b/test/docker-in-docker/test.sh @@ -5,8 +5,14 @@ set -e # Optional: Import test library source dev-container-features-test-lib -# Definition specific tests +# Feature specific tests check "version" docker --version +check "docker-init-exists" bash -c "ls /usr/local/share/docker-init.sh" +check "docker-ps" bash -c "docker ps" +check "log-exists" bash -c "ls /tmp/dockerd.log" +check "log-for-completion" bash -c "cat /tmp/dockerd.log | grep 'Daemon has completed initialization'" +check "log-contents" bash -c "cat /tmp/dockerd.log | grep 'API listen on /var/run/docker.sock'" +check "moby-buildx" bash -c "dpkg-query -W moby-buildx" # Report result reportResults \ No newline at end of file diff --git a/test/docker-outside-of-docker/Dockerfile b/test/docker-outside-of-docker/Dockerfile new file mode 100644 index 000000000..61217d945 --- /dev/null +++ b/test/docker-outside-of-docker/Dockerfile @@ -0,0 +1 @@ +FROM ubuntu:jammy diff --git a/test/docker-outside-of-docker/docker_build.sh b/test/docker-outside-of-docker/docker_build.sh new file mode 100755 index 000000000..056a76425 --- /dev/null +++ b/test/docker-outside-of-docker/docker_build.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "installs docker-compose v1 install" bash -c "type docker-compose" + +# Report result +reportResults diff --git a/test/docker-outside-of-docker/docker_build_compose_fallback.sh b/test/docker-outside-of-docker/docker_build_compose_fallback.sh new file mode 100644 index 000000000..c1bdeb544 --- /dev/null +++ b/test/docker-outside-of-docker/docker_build_compose_fallback.sh @@ -0,0 +1,164 @@ +#!/bin/bash + +# Optional: Import test library +source dev-container-features-test-lib + +echo -e "\n👉 Checking version of compose-switch installed as docker-compose as installed by feature"; +check "installs compose-switch as docker-compose" bash -c "[[ -f /usr/local/bin/docker-compose ]]" + +trap 'echo "Last executed command failed at line ${LINENO}"' ERR + +# Fetch host/container arch. +architecture="$(dpkg --print-architecture)" + +sudo mkdir -p /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose + +apt_get_update() +{ + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi +} + +# Checks if packages are installed and installs them if not +check_packages() { + if ! dpkg -s "$@" > /dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi +} + +# Figure out correct version of a three part version number is not passed +find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + local escaped_separator=${separator//./\\.} + local last_part + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + fi + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then + echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + echo "${variable_name}=${!variable_name}" +} + +# Use semver logic to decrement a version number then look for the closest match +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e +} + +# Function to fetch the version released prior to the latest version +get_previous_version() { + local url=$1 + local repo_url=$2 + local variable_name=$3 + local mode=$4 + prev_version=${!variable_name} + output=$(curl -s "$repo_url"); + check_packages jq + if echo "$output" | jq -e 'type == "object"' > /dev/null; then + message=$(echo "$output" | jq -r '.message') + if [[ $message == "API rate limit exceeded"* ]] || [[ $mode == 'mode1' ]]; then + echo -e "\nAn attempt to find previous to latest version using GitHub Api Failed... \nReason: ${message}" + echo -e "\nAttempting to find previous to latest version using GitHub tags." + find_prev_version_from_git_tags prev_version "$url" "tags/v" + declare -g ${variable_name}="${prev_version}" + fi + elif echo "$output" | jq -e 'type == "array"' > /dev/null; then + echo -e "\nAttempting to find previous version using GitHub Api." + version=$(echo "$output" | jq -r '.[1].tag_name') + declare -g ${variable_name}="${version#v}" + fi +} + +get_github_api_repo_url() { + local url=$1 + echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases" +} + +install_compose_switch_fallback() { + compose_switch_url=$1 + mode=$2 + repo_url=$(get_github_api_repo_url "${compose_switch_url}") + echo -e "\n(!) Failed to fetch the latest artifacts for compose-switch v${compose_switch_version}..." + get_previous_version "${compose_switch_url}" "${repo_url}" compose_switch_version $mode + echo -e "\nAttempting to install v${compose_switch_version}" + sudo curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${architecture}" -o /usr/local/bin/docker-compose +} + +install_compose-switch_as_docker-compose() { + mode=$1 + echo "(*) Installing compose-switch as docker-compose..." + compose_switch_version="1.0.6" + compose_switch_url="https://github.com/docker/compose-switch" + sudo curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${architecture}" -o /usr/local/bin/docker-compose || install_compose_switch_fallback "${compose_switch_url}" $mode + sudo chmod +x /usr/local/bin/docker-compose +} + +echo -e "\n👉 Trying to install compose-switch as docker-compose using mode 1 ( find_prev_version_from_git_tags method )"; +install_compose-switch_as_docker-compose "mode1" +check "installs compose-switch as docker-compose mode 1" bash -c "[[ -f /usr/local/bin/docker-compose ]]" +check "docker-compose version" bash -c "docker-compose version" + +echo -e "\n👉 Trying to install compose-switch as docker-compose using mode 2 ( GitHub Api )"; +install_compose-switch_as_docker-compose "mode2" +check "installs compose-switch as docker-compose mode 2" bash -c "[[ -f /usr/local/bin/docker-compose ]]" +check "docker-compose version" bash -c "docker-compose version" \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_build_moby.sh b/test/docker-outside-of-docker/docker_build_moby.sh new file mode 120000 index 000000000..0b420dbf4 --- /dev/null +++ b/test/docker-outside-of-docker/docker_build_moby.sh @@ -0,0 +1 @@ +./docker_build.sh \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_build_no_buildx.sh b/test/docker-outside-of-docker/docker_build_no_buildx.sh new file mode 100644 index 000000000..7b3870d3b --- /dev/null +++ b/test/docker-outside-of-docker/docker_build_no_buildx.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "no buildx" bash -c "docker buildx version 2>&1 | grep 'not a docker command'" +check "docker-build" docker build ./ + +# Report result +reportResults diff --git a/test/docker-outside-of-docker/docker_build_no_buildx_moby.sh b/test/docker-outside-of-docker/docker_build_no_buildx_moby.sh new file mode 120000 index 000000000..00b72a39d --- /dev/null +++ b/test/docker-outside-of-docker/docker_build_no_buildx_moby.sh @@ -0,0 +1 @@ +./docker_build_no_buildx.sh \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_build_no_compose.sh b/test/docker-outside-of-docker/docker_build_no_compose.sh new file mode 100755 index 000000000..477bf2149 --- /dev/null +++ b/test/docker-outside-of-docker/docker_build_no_compose.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "not installing compose skips docker-compose v1 install" bash -c "! type docker-compose" + +# Report result +reportResults diff --git a/test/docker-outside-of-docker/docker_dash_compose_latest_moby.sh b/test/docker-outside-of-docker/docker_dash_compose_latest_moby.sh new file mode 120000 index 000000000..d7e6cc99e --- /dev/null +++ b/test/docker-outside-of-docker/docker_dash_compose_latest_moby.sh @@ -0,0 +1 @@ +docker_dash_compose_latest_no_moby.sh \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_dash_compose_latest_no_moby.sh b/test/docker-outside-of-docker/docker_dash_compose_latest_no_moby.sh new file mode 100644 index 000000000..d032da9e4 --- /dev/null +++ b/test/docker-outside-of-docker/docker_dash_compose_latest_no_moby.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker compose" bash -c "docker compose version | grep -E '5.[0-9]+.[0-9]+'" +check "docker-compose" bash -c "docker-compose --version | grep -E '5.[0-9]+.[0-9]+'" +check "installs compose-switch as docker-compose" bash -c "[[ -f /usr/local/bin/docker-compose ]]" + +# Report result +reportResults diff --git a/test/docker-outside-of-docker/docker_dash_compose_v1.sh b/test/docker-outside-of-docker/docker_dash_compose_v1.sh new file mode 100755 index 000000000..33ae319f4 --- /dev/null +++ b/test/docker-outside-of-docker/docker_dash_compose_v1.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-compose" bash -c "docker-compose --version | grep -E '1.[0-9]+.[0-9]+'" +check "no docker compose plugin" bash -c "if command -v docker >/dev/null 2>&1; then ! docker compose version >/dev/null 2>&1; else true; fi" + +# Report result +reportResults diff --git a/test/docker-outside-of-docker/docker_dash_compose_v1_moby.sh b/test/docker-outside-of-docker/docker_dash_compose_v1_moby.sh new file mode 120000 index 000000000..51dc7d26b --- /dev/null +++ b/test/docker-outside-of-docker/docker_dash_compose_v1_moby.sh @@ -0,0 +1 @@ +./docker_dash_compose_v1.sh \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_dash_compose_v2.sh b/test/docker-outside-of-docker/docker_dash_compose_v2.sh new file mode 100755 index 000000000..b24238f0d --- /dev/null +++ b/test/docker-outside-of-docker/docker_dash_compose_v2.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker compose" bash -c "docker compose version | grep -E '2.[0-9]+.[0-9]+'" +check "docker-compose" bash -c "docker-compose --version | grep -E '2.[0-9]+.[0-9]+'" + +check "installs compose-switch as docker-compose" bash -c "[[ -f /usr/local/bin/docker-compose ]]" + +# Report result +reportResults diff --git a/test/docker-outside-of-docker/docker_dash_compose_v2_moby.sh b/test/docker-outside-of-docker/docker_dash_compose_v2_moby.sh new file mode 120000 index 000000000..78ae9bc70 --- /dev/null +++ b/test/docker-outside-of-docker/docker_dash_compose_v2_moby.sh @@ -0,0 +1 @@ +./docker_dash_compose_v2.sh \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_init.sh b/test/docker-outside-of-docker/docker_init.sh new file mode 100644 index 000000000..88153c1e0 --- /dev/null +++ b/test/docker-outside-of-docker/docker_init.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "docker buildx" bash -c "docker buildx version" +check "docker compose" bash -c "docker compose version" +check "docker-compose" bash -c "docker-compose --version" + +check "docker-init-exists" bash -c "ls /usr/local/share/docker-init.sh" +check "log-exists" bash -c "ls /tmp/vscr-docker-from-docker.log" +check "log-contents-for-success" bash -c "cat /tmp/vscr-docker-from-docker.log | grep 'Success'" + +check "log-contents" bash -c "cat /tmp/vscr-docker-from-docker.log | grep 'Ensuring vscode has access to /var/run/docker-host.sock via /var/run/docker.sock'" +check "docker-ps" bash -c "docker ps >/dev/null" + +# Report result +reportResults \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_init_debian.sh b/test/docker-outside-of-docker/docker_init_debian.sh new file mode 120000 index 000000000..7458cc9b7 --- /dev/null +++ b/test/docker-outside-of-docker/docker_init_debian.sh @@ -0,0 +1 @@ +./docker_init.sh \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_init_moby.sh b/test/docker-outside-of-docker/docker_init_moby.sh new file mode 120000 index 000000000..7458cc9b7 --- /dev/null +++ b/test/docker-outside-of-docker/docker_init_moby.sh @@ -0,0 +1 @@ +./docker_init.sh \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_init_ubuntu_22.sh b/test/docker-outside-of-docker/docker_init_ubuntu_22.sh new file mode 120000 index 000000000..7458cc9b7 --- /dev/null +++ b/test/docker-outside-of-docker/docker_init_ubuntu_22.sh @@ -0,0 +1 @@ +./docker_init.sh \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_install_compose_switch.sh b/test/docker-outside-of-docker/docker_install_compose_switch.sh new file mode 100644 index 000000000..77f7bbef6 --- /dev/null +++ b/test/docker-outside-of-docker/docker_install_compose_switch.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Check if compose-switch is installed +check_compose_switch_installation() { + COMPOSE_SWITCH_BINARY="/usr/local/bin/compose-switch" + # Check if the binary exists + if [ ! -x "$COMPOSE_SWITCH_BINARY" ]; then + echo "compose-switch binary not found at $COMPOSE_SWITCH_BINARY" + exit 1 + else + compose_switch_version=$("$COMPOSE_SWITCH_BINARY" --version | awk '{print $4}') + if [ -z "$compose_switch_version" ]; then + echo "Unable to determine compose-switch version" + else + echo "compose-switch version: $compose_switch_version" + echo -e "\n✅ compose-switch is installed" + fi + fi +} + +check "Check whether compose-switch is installed" check_compose_switch_installation + +reportResults + \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_not_install_compose_switch.sh b/test/docker-outside-of-docker/docker_not_install_compose_switch.sh new file mode 100644 index 000000000..7160ef7ce --- /dev/null +++ b/test/docker-outside-of-docker/docker_not_install_compose_switch.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Check if compose-switch is installed +check_compose_switch_installation() { + COMPOSE_SWITCH_BINARY="/usr/local/bin/compose-switch" + # Check if the binary exists + if [ ! -x "$COMPOSE_SWITCH_BINARY" ]; then + echo "compose-switch binary not found at $COMPOSE_SWITCH_BINARY" + echo -e "\n❎ compose-switch is not installed" + else + compose_switch_version=$("$COMPOSE_SWITCH_BINARY" --version | awk '{print $4}') + if [ -z "$compose_switch_version" ]; then + echo "Unable to determine compose-switch version" + else + echo "compose-switch version: $compose_switch_version" + fi + exit 1 + fi +} + +check "Check whether compose-switch is installed" check_compose_switch_installation + +reportResults + \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_python_bookworm.sh b/test/docker-outside-of-docker/docker_python_bookworm.sh new file mode 100644 index 000000000..21ad3b7a4 --- /dev/null +++ b/test/docker-outside-of-docker/docker_python_bookworm.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "installs docker-compose v2 install" bash -c "type docker-compose" +check "docker compose" bash -c "docker compose version | grep -E '2.[0-9]+.[0-9]+'" +check "docker-compose" bash -c "docker-compose --version | grep -E '2.[0-9]+.[0-9]+'" + +check "installs compose-switch as docker-compose" bash -c "[[ -f /usr/local/bin/docker-compose ]]" + +# Report result +reportResults \ No newline at end of file diff --git a/test/docker-outside-of-docker/docker_specific_moby_buildx.sh b/test/docker-outside-of-docker/docker_specific_moby_buildx.sh new file mode 100755 index 000000000..8428170e9 --- /dev/null +++ b/test/docker-outside-of-docker/docker_specific_moby_buildx.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib +# Definition specific tests +check "moby-buildx" bash -c "dpkg-query -W moby-buildx | grep -E '0.14.0'" + +check "docker-buildx" bash -c "docker buildx version" +check "docker-buildx-path" bash -c "ls -la /usr/libexec/docker/cli-plugins/docker-buildx" + +check "docker-buildx" docker buildx version +check "docker-build" docker build ./ + +check "installs docker-compose v1 install" bash -c "type docker-compose" + +# Report result +reportResults diff --git a/test/docker-outside-of-docker/install_moby_on_debian_trixie.sh b/test/docker-outside-of-docker/install_moby_on_debian_trixie.sh new file mode 120000 index 000000000..6c5c2a157 --- /dev/null +++ b/test/docker-outside-of-docker/install_moby_on_debian_trixie.sh @@ -0,0 +1 @@ +install_on_debian_trixie.sh \ No newline at end of file diff --git a/test/docker-outside-of-docker/install_on_debian_trixie.sh b/test/docker-outside-of-docker/install_on_debian_trixie.sh new file mode 100644 index 000000000..c6c679684 --- /dev/null +++ b/test/docker-outside-of-docker/install_on_debian_trixie.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +# Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker installed" bash -c "type docker" + +# Report results +reportResults diff --git a/test/docker-outside-of-docker/install_on_ubuntu_resolute.sh b/test/docker-outside-of-docker/install_on_ubuntu_resolute.sh new file mode 100644 index 000000000..c6c679684 --- /dev/null +++ b/test/docker-outside-of-docker/install_on_ubuntu_resolute.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +# Import test library +source dev-container-features-test-lib + +# Definition specific tests +check "docker installed" bash -c "type docker" + +# Report results +reportResults diff --git a/test/docker-outside-of-docker/root_docker_socket.sh b/test/docker-outside-of-docker/root_docker_socket.sh new file mode 100644 index 000000000..c40d8a0ce --- /dev/null +++ b/test/docker-outside-of-docker/root_docker_socket.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Test script to assert root Docker socket usage + +if [ ! -S "/var/run/docker-host.sock" ]; then + echo "ERROR: Root Docker socket not found" + exit 1 +fi + +echo "Root Docker detected" +export DOCKER_HOST="unix:///var/run/docker-host.sock" +docker --version \ No newline at end of file diff --git a/test/docker-outside-of-docker/rootless_docker_socket.sh b/test/docker-outside-of-docker/rootless_docker_socket.sh new file mode 100644 index 000000000..004554e9a --- /dev/null +++ b/test/docker-outside-of-docker/rootless_docker_socket.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +source dev-container-features-test-lib + +echo "=== Rootless Docker Socket Configuration Test ===" + +# Test the custom rootless socket path +EXPECTED_SOCKET="/var/run/docker-rootless.sock" + +# Check if the configured rootless socket exists and is accessible +check "rootless-socket-exists" test -S "$EXPECTED_SOCKET" +check "rootless-socket-readable" test -r "$EXPECTED_SOCKET" + +# Verify Docker functionality using the rootless socket +export DOCKER_HOST="unix://$EXPECTED_SOCKET" +check "docker-functional-rootless" docker ps >/dev/null + +# Test basic Docker operations with rootless configuration +check "docker-version-rootless" docker version --format '{{.Client.Version}}' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+' >/dev/null +check "docker-info-rootless" docker info >/dev/null + +# Demonstrate that customers can configure custom socket paths +echo "Configured rootless socket path: $EXPECTED_SOCKET" +echo "Docker host: $DOCKER_HOST" + +reportResults \ No newline at end of file diff --git a/test/docker-outside-of-docker/scenarios.json b/test/docker-outside-of-docker/scenarios.json new file mode 100644 index 000000000..2df748169 --- /dev/null +++ b/test/docker-outside-of-docker/scenarios.json @@ -0,0 +1,240 @@ +{ + "docker_build_compose_fallback": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": false, + "dockerDashComposeVersion": "latest" + } + }, + "containerUser": "vscode" + }, + "docker_init_moby": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": true + } + }, + "containerUser": "vscode" + }, + "docker_init": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": false + } + }, + "containerUser": "vscode" + }, + "docker_init_ubuntu_22": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-22.04", + "features": { + "docker-outside-of-docker": { + "moby": false + } + }, + "containerUser": "vscode" + }, + "docker_init_debian": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "docker-outside-of-docker": { + "moby": false + } + }, + "containerUser": "vscode" + }, + "docker_build": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": false, + "dockerDashComposeVersion": "v2" + } + }, + "containerUser": "vscode" + }, + "docker_build_moby": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": true + } + }, + "containerUser": "vscode" + }, + "docker_build_no_buildx": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "version": "26", + "moby": false, + "installDockerBuildx": false + } + }, + "containerUser": "vscode" + }, + "docker_build_no_buildx_moby": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "version": "26", + "moby": true, + "installDockerBuildx": false + } + }, + "containerUser": "vscode" + }, + "docker_dash_compose_v1": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": false, + "dockerDashComposeVersion": "v1" + } + }, + "containerUser": "vscode" + }, + "docker_dash_compose_v1_moby": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": true, + "dockerDashComposeVersion": "v1" + } + }, + "containerUser": "vscode" + }, + "docker_dash_compose_v2": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": false, + "dockerDashComposeVersion": "v2" + } + }, + "containerUser": "vscode" + }, + "docker_dash_compose_v2_moby": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": true, + "dockerDashComposeVersion": "v2" + } + }, + "containerUser": "vscode" + }, + "docker_build_no_compose": { + "image": "mcr.microsoft.com/devcontainers/javascript-node:0-18", + "features": { + "docker-in-docker": { + "dockerDashComposeVersion": "none" + } + }, + "remoteUser": "node" + }, + "docker_specific_moby_buildx": { + "image": "ubuntu:noble", + "features": { + "docker-outside-of-docker": { + "mobyBuildxVersion": "0.14.0" + } + } + }, + "docker_python_bookworm": { + "image": "mcr.microsoft.com/devcontainers/base:bookworm", + "features": { + "docker-outside-of-docker": { + "moby": true, + "installDockerBuildx": true, + "dockerDashComposeVersion": "v2" + } + } + }, + "docker_not_install_compose_switch": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "dockerDashComposeVersion": "latest", + "installDockerComposeSwitch": false + } + }, + "containerUser": "vscode" + }, + "docker_install_compose_switch": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "dockerDashComposeVersion": "latest", + "installDockerComposeSwitch": true + } + }, + "containerUser": "vscode" + }, + "install_on_debian_trixie": { + "image": "debian:trixie", + "features": { + "docker-outside-of-docker": { + "moby": false + } + } + }, + "install_on_ubuntu_resolute": { + "image": "ubuntu:resolute", + "features": { + "docker-outside-of-docker": { + "moby": false + } + } + }, + "rootless_docker_socket": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": false, + "socketPath": "/var/run/docker-rootless.sock" + } + }, + "mounts": [ + { + "source": "/var/run/docker.sock", + "target": "/var/run/docker-rootless.sock", + "type": "bind" + } + ], + "containerUser": "vscode" + }, + "root_docker_socket": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": false + } + }, + "containerUser": "vscode" + }, + "docker_dash_compose_latest_moby": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": true, + "dockerDashComposeVersion": "latest" + } + }, + "containerUser": "vscode" + }, + "docker_dash_compose_latest_no_moby": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": false, + "dockerDashComposeVersion": "latest" + } + }, + "containerUser": "vscode" + } +} + + \ No newline at end of file diff --git a/test/docker-outside-of-docker/test.sh b/test/docker-outside-of-docker/test.sh new file mode 100644 index 000000000..5206f5977 --- /dev/null +++ b/test/docker-outside-of-docker/test.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "docker buildx" bash -c "docker buildx version" +check "docker compose" bash -c "docker compose version" +check "docker-compose" bash -c "docker-compose --version" + +check "docker-ps" bash -c "docker ps >/dev/null" +check "moby-buildx" bash -c "dpkg-query -W moby-buildx" + +# Report result +reportResults \ No newline at end of file diff --git a/test/dotnet/dotnet_env.sh b/test/dotnet/dotnet_env.sh new file mode 100644 index 000000000..37b14ea2b --- /dev/null +++ b/test/dotnet/dotnet_env.sh @@ -0,0 +1,4 @@ +#!/bin/bash +export DOTNET_NOLOGO=true +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true +export DOTNET_GENERATE_ASPNET_CERTIFICATE=false \ No newline at end of file diff --git a/test/dotnet/dotnet_helpers.sh b/test/dotnet/dotnet_helpers.sh new file mode 100644 index 000000000..20671f84b --- /dev/null +++ b/test/dotnet/dotnet_helpers.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Include the same helper functions used by the install script +source ".devcontainer/dotnet/scripts/dotnet-helpers.sh" + +# Asserts that the specified .NET SDK version is installed +# Returns a non-zero exit code if the check fails +# Usage: is_dotnet_sdk_version_installed +# Example: is_dotnet_sdk_version_installed "6.0" +# Example: is_dotnet_sdk_version_installed "6.0.412" +is_dotnet_sdk_version_installed() { + local expected="$1" + dotnet --list-sdks | grep --fixed-strings --silent "$expected" + return $? +} + +# Asserts that the specified .NET Runtime version is installed +# Returns a non-zero exit code if the check fails +# Usage: is_dotnet_runtime_version_installed +# Example: is_dotnet_runtime_version_installed "6.0" +# Example: is_dotnet_runtime_version_installed "6.0.412" +is_dotnet_runtime_version_installed() { + local expected="$1" + dotnet --list-runtimes | grep --fixed-strings --silent "Microsoft.NETCore.App $expected" + return $? +} + +# Asserts that the specified ASP.NET Core Runtime version is installed +# Returns a non-zero exit code if the check fails +# Usage: is_aspnetcore_runtime_version_installed +# Example: is_aspnetcore_runtime_version_installed "6.0" +# Example: is_aspnetcore_runtime_version_installed "6.0.412" +is_aspnetcore_runtime_version_installed() { + local expected="$1" + dotnet --list-runtimes | grep --fixed-strings --silent "Microsoft.AspNetCore.App $expected" + return $? +} + +# Asserts that the specified workload is installed +# Returns a non-zero exit code if the check fails +# Usage: is_dotnet_workload_installed +# Example: is_dotnet_workload_installed "aspire" +is_dotnet_workload_installed() { + local expected="$1" + dotnet workload list | grep --fixed-strings --silent "$expected" + return $? +} diff --git a/test/dotnet/install_additional_dotnet.sh b/test/dotnet/install_additional_dotnet.sh deleted file mode 100644 index fdb2da765..000000000 --- a/test/dotnet/install_additional_dotnet.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -set -e - -# Optional: Import test library -source dev-container-features-test-lib - -check "dotnet version 6.0.301 installed as default" dotnet --version | grep 6.0.301 -check "dotnet version 5.0 installed" ls -l /usr/local/dotnet | grep 5.0 -check "dotnet version 3.1.420 installed" ls -l /usr/local/dotnet | grep 3.1.420 - -# Report result -reportResults diff --git a/test/dotnet/install_aspnetcore_runtime_only.sh b/test/dotnet/install_aspnetcore_runtime_only.sh new file mode 100644 index 000000000..78e4f099c --- /dev/null +++ b/test/dotnet/install_aspnetcore_runtime_only.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -e + +# Optional: Import test library bundled with the devcontainer CLI +# See https://github.com/devcontainers/cli/blob/HEAD/docs/features/test.md#dev-container-features-test-lib +# Provides the 'check' and 'reportResults' commands. +source dev-container-features-test-lib + +# Feature-specific tests +# The 'check' command comes from the dev-container-features-test-lib. Syntax is... +# check