From 8d4133c9cfea0501269c97f60d50a6f129616427 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 7 Sep 2026 09:15:28 -0700 Subject: [PATCH 1/3] Make the Aspire devcontainer polyglot and add lifecycle CI Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .devcontainer/devcontainer.json | 49 ++-- .github/scripts/smoke-test.mjs | 117 ++++++++ .github/workflows/devcontainer.yml | 60 ++++ .gitignore | 448 +++++------------------------ README.md | 62 +++- 5 files changed, 320 insertions(+), 416 deletions(-) create mode 100644 .github/scripts/smoke-test.mjs create mode 100644 .github/workflows/devcontainer.yml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ee8cf08..4036867 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,47 +1,40 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet { "name": "Aspire", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/dotnet:dev-10.0-noble", + "image": "mcr.microsoft.com/devcontainers/base:3-ubuntu24.04", "features": { "ghcr.io/microsoft/aspire-devcontainer-feature/aspire:2": {}, "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - "ghcr.io/devcontainers/features/powershell:1": {}, - "ghcr.io/devcontainers/features/node:1": {}, - "ghcr.io/devcontainers/features/python:1": {}, - "ghcr.io/devcontainers-extra/features/uv:1": {} + "ghcr.io/devcontainers/features/node:1": { + "version": "lts" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "os-provided" + }, + "ghcr.io/devcontainers-extra/features/uv:1": {}, + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "10.0" + }, + "ghcr.io/devcontainers/features/powershell:1": {} }, - "hostRequirements": { "cpus": 8, "memory": "32gb", "storage": "64gb" }, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [5000, 5001], - // "portsAttributes": { - // "5001": { - // "protocol": "https" - // } - // } - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "dotnet restore", - "postStartCommand": "dotnet dev-certs https --trust", + "remoteEnv": { + "SSL_CERT_DIR": "/usr/lib/ssl/certs:/home/vscode/.aspnet/dev-certs/trust" + }, + "postStartCommand": "aspire certs trust --non-interactive", "customizations": { "vscode": { "extensions": [ - "ms-dotnettools.csdevkit", + "microsoft-aspire.aspire-vscode", "GitHub.copilot-chat", - "microsoft-aspire.aspire-vscode" + "dbaeumer.vscode-eslint", + "ms-python.python", + "ms-python.vscode-pylance", + "ms-dotnettools.csdevkit" ] } } - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" } diff --git a/.github/scripts/smoke-test.mjs b/.github/scripts/smoke-test.mjs new file mode 100644 index 0000000..b6bd9c8 --- /dev/null +++ b/.github/scripts/smoke-test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { accessSync, constants, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +process.env.ASPIRE_CLI_TELEMETRY_OPTOUT = '1'; +process.env.DOTNET_CLI_TELEMETRY_OPTOUT = '1'; + +function execute(command, args, { cwd = process.cwd(), capture = false } = {}) { + console.log(`> ${command} ${args.join(' ')}`); + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', capture ? 'pipe' : 'inherit', 'inherit'], + }); +} + +assert.equal(process.platform, 'linux', 'Run this script inside the devcontainer.'); +assert.notEqual(process.getuid(), 0, 'The remote user must not be root.'); +accessSync('.devcontainer/devcontainer.json', constants.R_OK); +accessSync('.', constants.W_OK); + +// Check the lifecycle hook before Aspire starts and can perform its own certificate setup. +const trustDirectory = join(homedir(), '.aspnet', 'dev-certs', 'trust'); +const certificates = readdirSync(trustDirectory).filter(name => name.endsWith('.pem')); +assert.ok(certificates.length > 0, 'The startup hook must create a development certificate.'); +for (const certificate of certificates) { + execute('openssl', ['verify', join(trustDirectory, certificate)]); +} + +for (const command of ['node', 'npm', 'python3', 'uv', 'dotnet', 'pwsh', 'aspire']) { + execute(command, ['--version']); +} + +execute('docker', ['info', '--format', 'Docker server: {{.ServerVersion}}']); +execute('docker', ['run', '--rm', 'hello-world']); + +const testDirectory = mkdtempSync(join(homedir(), '.aspire-devcontainer-test-')); +const appDirectory = join(testDirectory, 'polyglot'); +const dotnetDirectory = join(testDirectory, 'dotnet'); +let startAttempted = false; + +try { + execute('dotnet', ['new', 'console', '--output', dotnetDirectory, '--no-restore']); + const consoleOutput = execute('dotnet', ['run', '--project', dotnetDirectory], { capture: true }); + assert.ok(consoleOutput.includes('Hello, World!'), '.NET must restore, compile, and run a project.'); + + execute('aspire', [ + 'new', 'aspire-py-starter', + '--name', 'DevcontainerSmoke', + '--output', appDirectory, + '--use-redis-cache', 'true', + '--suppress-agent-init', + '--non-interactive', + ], { cwd: testDirectory }); + + startAttempted = true; + execute('aspire', ['start', '--isolated', '--non-interactive'], { cwd: appDirectory }); + for (const name of ['cache', 'app', 'frontend']) { + execute('aspire', ['wait', name, '--timeout', '180', '--non-interactive'], { cwd: appDirectory }); + } + + const description = execute('aspire', ['describe', '--format', 'Json', '--non-interactive'], { + cwd: appDirectory, + capture: true, + }); + // The CLI can print a discovery message before its JSON output. + const jsonStart = description.indexOf('{'); + assert.ok(jsonStart >= 0, 'Aspire must return a resource description.'); + const { resources } = JSON.parse(description.slice(jsonStart)); + const resource = name => { + const result = resources.find(item => item.displayName === name); + assert.ok(result, `Missing resource: ${name}`); + assert.equal(result.healthStatus, 'Healthy', `${name} must be healthy.`); + return result; + }; + + assert.equal(resource('cache').resourceType, 'Container'); + const api = resource('app'); + const frontend = resource('frontend'); + const apiUrl = api.urls.find(endpoint => endpoint.name === 'http')?.url; + const frontendUrl = frontend.urls.find(endpoint => endpoint.name === 'http')?.url; + assert.ok(apiUrl, 'The Python API must expose an endpoint.'); + assert.ok(frontendUrl, 'The React frontend must expose an endpoint.'); + assert.equal(new URL(apiUrl).protocol, 'https:', 'The API smoke test must exercise HTTPS.'); + + const get = url => execute('curl', [ + '--fail', '--silent', '--show-error', '--max-time', '30', url, + ], { capture: true }); + + assert.equal(get(new URL('/health', apiUrl).href), 'Healthy'); + assert.ok(get(frontendUrl).includes('id="root"'), 'The frontend must serve the React app.'); + + const proxied = JSON.parse(get(new URL('/api/weatherforecast', frontendUrl).href)); + const direct = JSON.parse(get(new URL('/api/weatherforecast', apiUrl).href)); + assert.equal(proxied.length, 5, 'The frontend must proxy requests to the Python API.'); + assert.deepEqual(direct, proxied, 'Redis must cache the forecast between requests.'); + for (const forecast of direct) { + assert.equal(typeof forecast.temperatureC, 'number'); + assert.equal(typeof forecast.summary, 'string'); + } + + const dashboardStatus = execute('curl', [ + '--fail', '--silent', '--show-error', '--location', '--max-time', '30', + '--output', '/dev/null', '--write-out', '%{http_code}', + new URL(api.dashboardUrl).origin, + ], { capture: true }); + assert.equal(dashboardStatus, '200', 'The dashboard must be reachable over trusted HTTPS.'); +} finally { + if (startAttempted) { + execute('aspire', ['stop', '--non-interactive'], { cwd: appDirectory }); + } +} + +rmSync(testDirectory, { recursive: true }); +console.log('Devcontainer lifecycle prerequisites, tooling, HTTPS, Docker, and polyglot app checks passed.'); diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml new file mode 100644 index 0000000..6b177de --- /dev/null +++ b/.github/workflows/devcontainer.yml @@ -0,0 +1,60 @@ +name: Devcontainer + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke-test: + name: Lifecycle and polyglot smoke test + runs-on: ubuntu-24.04 + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Install Dev Containers CLI + run: npm install --global @devcontainers/cli@0.89.0 + + - name: Start devcontainer + id: container + run: | + devcontainer up --workspace-folder . --mount-workspace-git-root false --no-lockfile > "$RUNNER_TEMP/devcontainer-up.json" + container_id=$(jq --exit-status --raw-output '.containerId' "$RUNNER_TEMP/devcontainer-up.json") + echo "container_id=$container_id" >> "$GITHUB_OUTPUT" + + - name: Test fresh container + run: devcontainer exec --workspace-folder . node .github/scripts/smoke-test.mjs + + - name: Stop and reopen devcontainer + env: + CONTAINER_ID: ${{ steps.container.outputs.container_id }} + run: | + docker stop "$CONTAINER_ID" + devcontainer up --workspace-folder . --mount-workspace-git-root false --no-lockfile --expect-existing-container + + - name: Test restarted container + run: devcontainer exec --workspace-folder . node .github/scripts/smoke-test.mjs + + - name: Remove test container + if: always() && steps.container.outputs.container_id != '' + env: + CONTAINER_ID: ${{ steps.container.outputs.container_id }} + run: docker rm --force --volumes "$CONTAINER_ID" diff --git a/.gitignore b/.gitignore index a4fe18b..6906df7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,400 +1,80 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +# Local environment files and credentials +.env +.env.* +!.env.example +!.env.*.example +!.env.template +*.pfx +*.publishsettings -# User-specific files -*.rsuser +# Editor and OS files +.DS_Store +Thumbs.db +.idea/ +.vs/ +.history/ *.suo *.user *.userosscache *.sln.docstates +*.code-workspace +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json +# Shared build output, logs, and coverage +build/ +dist/ artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj +logs/ *.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool +coverage/ coverage*.json coverage*.xml coverage*.info -# Visual Studio code coverage results +# JavaScript / TypeScript +node_modules/ +.next/ +.nuxt/ +.output/ +.svelte-kit/ +.turbo/ +.parcel-cache/ +.npm/ +.pnpm-store/ +.eslintcache +.nyc_output/ +*.tsbuildinfo + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.tox/ +.nox/ +.ipynb_checkpoints/ +*.egg-info/ +.eggs/ +.coverage +.coverage.* +htmlcov/ + +# .NET +[Bb]in/ +[Oo]bj/ +[Tt]est[Rr]esult*/ +BenchmarkDotNet.Artifacts/ +*.binlog +*.nupkg +*.snupkg *.coverage *.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml diff --git a/README.md b/README.md index 3ef90bd..6d74175 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,68 @@ # Getting started with Aspire and Dev Containers -This is a repository template to streamline the process of getting started with Aspire using Dev Containers in both Visual Studio Code and GitHub Codespaces. Please refer to our product documentation on how to use these repository templates to get started. +Build apps with JavaScript/TypeScript, Python, .NET, or a mix of languages using [Aspire](https://aspire.dev). This repository template provides a ready-to-use development environment for Visual Studio Code Dev Containers and GitHub Codespaces, without choosing an application language or framework for you. -- [Aspire and GitHub Codespaces](https://learn.microsoft.com/dotnet/aspire/get-started/github-codespaces) -- [Aspire and Visual Studio Code Dev Containers](https://learn.microsoft.com/dotnet/aspire/get-started/dev-containers) +## What's included + +The container uses an Ubuntu 24.04 base image, with language tooling installed as Dev Container Features: + +| Tooling | Purpose | +| --- | --- | +| Aspire CLI and VS Code extension | Orchestrate and debug your app's services | +| Docker-in-Docker | Run containers for databases, caches, and other dependencies | +| Node.js LTS and npm | Develop JavaScript and TypeScript apps | +| Python and uv | Develop Python apps and manage packages and virtual environments | +| .NET 10 SDK and C# Dev Kit | Develop .NET apps | +| PowerShell | Run cross-platform automation scripts | + +VS Code includes JavaScript/TypeScript support, with ESLint, Python, and Pylance extensions installed alongside the .NET tooling. + +## Get started + +1. [Create a repository from this template](https://github.com/new?template_name=aspire-devcontainer&template_owner=microsoft). +2. Open it in GitHub Codespaces, or clone it and select **Dev Containers: Reopen in Container** in VS Code. Local development requires Docker and the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). +3. Run `aspire new` in the container terminal to choose an app template, or bring your existing services into the repository. + +For more guidance, see: + +- [Aspire and GitHub Codespaces](https://aspire.dev/get-started/github-codespaces/) +- [Aspire and Visual Studio Code Dev Containers](https://aspire.dev/get-started/dev-containers/) + +## Customize your environment + +Edit [`.devcontainer/devcontainer.json`](.devcontainer/devcontainer.json) to change language versions, add tooling, or remove features you don't need. Rebuild the container after changing its configuration. The [Dev Container configuration reference](https://containers.dev/implementors/json_reference/) describes the available options. + +The template does not run a language-specific restore command. Install your app's dependencies with its package manager, such as `npm install`, `uv sync`, or `dotnet restore`. + +The container configures HTTPS development certificates at startup through Aspire, independent of your app's language: + +```sh +aspire certs trust --non-interactive +``` + +Aspire stores these certificates under `~/.aspnet/dev-certs/trust`. The container's remote environment includes this directory in `SSL_CERT_DIR` so OpenSSL-based tools such as `curl` can verify HTTPS endpoints from the container terminal. + +Trust inside the container does not automatically make your host browser trust the certificate. Follow the [Dev Containers HTTPS guidance](https://aspire.dev/get-started/dev-containers/) for local browser setup. > [!NOTE] > Once you have created your repository from this template please remember to review the included files such as `LICENSE`, `CODE_OF_CONDUCT.md`, `SECURITY.md` and this `README.md` file to ensure they are appropriate for your circumstances. -# Code of Conduct +## CI checks + +The [Devcontainer workflow](.github/workflows/devcontainer.yml) runs on pull requests, pushes to `main`, and manual dispatch. It starts the actual devcontainer and runs [smoke checks](.github/scripts/smoke-test.mjs) before and after stopping and reopening it. + +The checks cover non-root workspace access, installed tooling, certificate trust from the startup hook, Docker-in-Docker, a compiled .NET console app, and an Aspire app with a TypeScript AppHost, Python API, React frontend, and Redis. HTTPS requests verify certificates normally; the checks do not bypass TLS validation. Sample projects are created inside the container, not in the repository. VS Code extension installation and editor/debugger behavior are not covered. + +To run the same checks locally with Docker running: + +```sh +npx --yes --package @devcontainers/cli@0.89.0 devcontainer up --workspace-folder . --mount-workspace-git-root false --no-lockfile +npx --yes --package @devcontainers/cli@0.89.0 devcontainer exec --workspace-folder . node .github/scripts/smoke-test.mjs +``` + +To check restart behavior, stop the container identified by the `up` output, then run both commands again. The smoke script stops its Aspire app; the devcontainer remains running for further use. + +## Code of Conduct This project has adopted the code of conduct defined by the Contributor Covenant to clarify expected behavior in our community. From 8b4786195393c74a2e0e2a4a678b488e3f45f38c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 7 Sep 2026 10:16:38 -0700 Subject: [PATCH 2/3] Expand devcontainer coverage across AppHost languages Add C# and SDK-free TypeScript lifecycle lanes, deterministic Redis assertions, and failure diagnostic artifacts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/prepare-config.mjs | 25 ++++++ .github/scripts/smoke-test.mjs | 127 ++++++++++++++++++++++------- .github/workflows/devcontainer.yml | 59 ++++++++++++-- README.md | 14 +++- 4 files changed, 190 insertions(+), 35 deletions(-) create mode 100644 .github/scripts/prepare-config.mjs diff --git a/.github/scripts/prepare-config.mjs b/.github/scripts/prepare-config.mjs new file mode 100644 index 0000000..89ceed1 --- /dev/null +++ b/.github/scripts/prepare-config.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +const { values } = parseArgs({ + options: { + output: { type: 'string' }, + 'without-dotnet': { type: 'boolean', default: false }, + }, +}); +assert.ok(values.output, '--output is required.'); + +const configuration = JSON.parse(readFileSync('.devcontainer/devcontainer.json', 'utf8')); +if (values['without-dotnet']) { + const features = Object.keys(configuration.features) + .filter(feature => feature.startsWith('ghcr.io/devcontainers/features/dotnet:')); + assert.equal(features.length, 1, 'Expected one standalone .NET SDK feature to omit.'); + delete configuration.features[features[0]]; +} + +const output = resolve(values.output); +assert.notEqual(output, resolve('.devcontainer/devcontainer.json'), 'Use a separate output path for the test configuration.'); +mkdirSync(dirname(output), { recursive: true }); +writeFileSync(output, `${JSON.stringify(configuration, null, 2)}\n`); diff --git a/.github/scripts/smoke-test.mjs b/.github/scripts/smoke-test.mjs index b6bd9c8..bd632fe 100644 --- a/.github/scripts/smoke-test.mjs +++ b/.github/scripts/smoke-test.mjs @@ -1,21 +1,38 @@ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { accessSync, constants, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { accessSync, constants, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; process.env.ASPIRE_CLI_TELEMETRY_OPTOUT = '1'; process.env.DOTNET_CLI_TELEMETRY_OPTOUT = '1'; -function execute(command, args, { cwd = process.cwd(), capture = false } = {}) { +const scenarios = { + python: { template: 'aspire-py-starter', api: 'app', frontend: 'frontend', cache: true }, + csharp: { template: 'aspire-starter', api: 'apiservice', frontend: 'webfrontend', cache: true }, + 'typescript-no-dotnet': { template: 'aspire-ts-starter', api: 'app', frontend: 'frontend', cache: false }, +}; +const scenarioName = process.argv[2] ?? 'python'; +assert.ok(Object.hasOwn(scenarios, scenarioName), `Unknown scenario: ${scenarioName}`); +const scenario = scenarios[scenarioName]; +const withoutDotnet = scenarioName === 'typescript-no-dotnet'; + +function execute(command, args, { cwd = process.cwd(), capture = false, input } = {}) { console.log(`> ${command} ${args.join(' ')}`); return execFileSync(command, args, { cwd, encoding: 'utf8', - stdio: ['ignore', capture ? 'pipe' : 'inherit', 'inherit'], + input, + stdio: [input === undefined ? 'ignore' : 'pipe', capture ? 'pipe' : 'inherit', 'inherit'], }); } +function assertNoDotnet() { + const result = spawnSync('dotnet', ['--version'], { encoding: 'utf8' }); + assert.equal(result.error?.code, 'ENOENT', 'The TypeScript scenario must have no standalone dotnet on PATH.'); + console.log('No standalone dotnet found on PATH.'); +} + assert.equal(process.platform, 'linux', 'Run this script inside the devcontainer.'); assert.notEqual(process.getuid(), 0, 'The remote user must not be root.'); accessSync('.devcontainer/devcontainer.json', constants.R_OK); @@ -29,35 +46,42 @@ for (const certificate of certificates) { execute('openssl', ['verify', join(trustDirectory, certificate)]); } -for (const command of ['node', 'npm', 'python3', 'uv', 'dotnet', 'pwsh', 'aspire']) { +for (const command of ['node', 'npm', 'python3', 'uv', 'pwsh', 'aspire']) { execute(command, ['--version']); } +if (withoutDotnet) { + assertNoDotnet(); +} else { + execute('dotnet', ['--version']); +} execute('docker', ['info', '--format', 'Docker server: {{.ServerVersion}}']); execute('docker', ['run', '--rm', 'hello-world']); const testDirectory = mkdtempSync(join(homedir(), '.aspire-devcontainer-test-')); -const appDirectory = join(testDirectory, 'polyglot'); +const appDirectory = join(testDirectory, 'app'); const dotnetDirectory = join(testDirectory, 'dotnet'); let startAttempted = false; try { - execute('dotnet', ['new', 'console', '--output', dotnetDirectory, '--no-restore']); - const consoleOutput = execute('dotnet', ['run', '--project', dotnetDirectory], { capture: true }); - assert.ok(consoleOutput.includes('Hello, World!'), '.NET must restore, compile, and run a project.'); + if (scenarioName === 'python') { + execute('dotnet', ['new', 'console', '--output', dotnetDirectory, '--no-restore']); + const consoleOutput = execute('dotnet', ['run', '--project', dotnetDirectory], { capture: true }); + assert.ok(consoleOutput.includes('Hello, World!'), '.NET must restore, compile, and run a project.'); + } execute('aspire', [ - 'new', 'aspire-py-starter', + 'new', scenario.template, '--name', 'DevcontainerSmoke', '--output', appDirectory, - '--use-redis-cache', 'true', + ...(scenario.cache ? ['--use-redis-cache', 'true'] : []), '--suppress-agent-init', '--non-interactive', ], { cwd: testDirectory }); startAttempted = true; execute('aspire', ['start', '--isolated', '--non-interactive'], { cwd: appDirectory }); - for (const name of ['cache', 'app', 'frontend']) { + for (const name of [...(scenario.cache ? ['cache'] : []), scenario.api, scenario.frontend]) { execute('aspire', ['wait', name, '--timeout', '180', '--non-interactive'], { cwd: appDirectory }); } @@ -76,37 +100,84 @@ try { return result; }; - assert.equal(resource('cache').resourceType, 'Container'); - const api = resource('app'); - const frontend = resource('frontend'); - const apiUrl = api.urls.find(endpoint => endpoint.name === 'http')?.url; - const frontendUrl = frontend.urls.find(endpoint => endpoint.name === 'http')?.url; - assert.ok(apiUrl, 'The Python API must expose an endpoint.'); - assert.ok(frontendUrl, 'The React frontend must expose an endpoint.'); - assert.equal(new URL(apiUrl).protocol, 'https:', 'The API smoke test must exercise HTTPS.'); + const api = resource(scenario.api); + const frontend = resource(scenario.frontend); + const endpoint = item => item.urls.find(url => url.url.startsWith('https:'))?.url + ?? item.urls.find(url => url.url.startsWith('http:'))?.url; + const apiUrl = endpoint(api); + const frontendUrl = endpoint(frontend); + assert.ok(apiUrl, 'The API must expose an endpoint.'); + assert.ok(frontendUrl, 'The frontend must expose an endpoint.'); + if (!withoutDotnet) { + assert.equal(new URL(apiUrl).protocol, 'https:', 'The API smoke test must exercise HTTPS.'); + } + if (scenario.cache) { + assert.equal(resource('cache').resourceType, 'Container'); + } const get = url => execute('curl', [ - '--fail', '--silent', '--show-error', '--max-time', '30', url, + '--fail', '--silent', '--show-error', '--location', '--max-time', '30', url, ], { capture: true }); assert.equal(get(new URL('/health', apiUrl).href), 'Healthy'); - assert.ok(get(frontendUrl).includes('id="root"'), 'The frontend must serve the React app.'); - - const proxied = JSON.parse(get(new URL('/api/weatherforecast', frontendUrl).href)); - const direct = JSON.parse(get(new URL('/api/weatherforecast', apiUrl).href)); - assert.equal(proxied.length, 5, 'The frontend must proxy requests to the Python API.'); - assert.deepEqual(direct, proxied, 'Redis must cache the forecast between requests.'); + const forecastPath = scenarioName === 'csharp' ? '/weatherforecast' : '/api/weatherforecast'; + const direct = JSON.parse(get(new URL(forecastPath, apiUrl).href)); + assert.equal(direct.length, 5, 'The API must return five forecasts.'); for (const forecast of direct) { assert.equal(typeof forecast.temperatureC, 'number'); assert.equal(typeof forecast.summary, 'string'); } + if (scenarioName === 'csharp') { + assert.equal(api.resourceType, 'Project'); + assert.equal(frontend.resourceType, 'Project'); + assert.equal(get(new URL('/health', frontendUrl).href), 'Healthy'); + const weatherPage = get(new URL('/weather', frontendUrl).href); + assert.ok(weatherPage.includes('

Weather

'), 'Blazor must render the weather page.'); + assert.equal([...weatherPage.matchAll(//g)].length, 20, 'Blazor must render all five API forecasts.'); + } else { + assert.ok(get(frontendUrl).includes('id="root"'), 'The frontend must serve the React app.'); + const proxied = JSON.parse(get(new URL(forecastPath, frontendUrl).href)); + assert.equal(proxied.length, 5, 'The frontend must proxy requests to the API.'); + } + + if (scenarioName === 'python') { + // Seed a non-expiring value so this checks cache hits without depending on the sample's five-second TTL. + const cached = direct.map(forecast => ({ ...forecast, summary: 'CI cache sentinel' })); + const result = execute('docker', [ + 'exec', '-i', resource('cache').properties['container.id'], + 'sh', '-c', + 'REDISCLI_AUTH="$REDIS_PASSWORD" redis-cli --tls --cacert /usr/lib/ssl/aspire/cert.pem --raw -x SET weatherforecast', + ], { capture: true, input: JSON.stringify(cached) }); + assert.equal(result.trim(), 'OK', 'The test must seed Redis successfully.'); + assert.deepEqual(JSON.parse(get(new URL(forecastPath, apiUrl).href)), cached, 'The API must read Redis.'); + assert.deepEqual(JSON.parse(get(new URL(forecastPath, frontendUrl).href)), cached, 'The frontend must return cached API data.'); + } + const dashboardStatus = execute('curl', [ '--fail', '--silent', '--show-error', '--location', '--max-time', '30', '--output', '/dev/null', '--write-out', '%{http_code}', new URL(api.dashboardUrl).origin, ], { capture: true }); assert.equal(dashboardStatus, '200', 'The dashboard must be reachable over trusted HTTPS.'); + if (withoutDotnet) { + assertNoDotnet(); + } +} catch (error) { + console.error(error); + if (startAttempted) { + const logs = spawnSync('aspire', [ + 'logs', '--tail', '200', '--include-hidden', '--format', 'Json', '--non-interactive', + ], { cwd: appDirectory, encoding: 'utf8', timeout: 30_000 }); + const logDirectory = join(homedir(), '.aspire', 'logs'); + mkdirSync(logDirectory, { recursive: true }); + writeFileSync(join(logDirectory, `smoke-${scenarioName}-resources.log`), + [logs.stdout, logs.stderr, logs.error?.stack, `Log capture exit status: ${logs.status}`].filter(Boolean).join('\n')); + if (logs.error || logs.status !== 0) { + console.error('Resource log capture failed:', logs.error ?? logs.stderr); + } + } + throw error; } finally { if (startAttempted) { execute('aspire', ['stop', '--non-interactive'], { cwd: appDirectory }); @@ -114,4 +185,4 @@ try { } rmSync(testDirectory, { recursive: true }); -console.log('Devcontainer lifecycle prerequisites, tooling, HTTPS, Docker, and polyglot app checks passed.'); +console.log(`Devcontainer ${scenarioName} checks passed.`); diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index 6b177de..132b3c6 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -15,9 +15,21 @@ concurrency: jobs: smoke-test: - name: Lifecycle and polyglot smoke test + name: ${{ matrix.name }} runs-on: ubuntu-24.04 timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - scenario: python + name: Python and React + - scenario: csharp + name: C# and Blazor + - scenario: typescript-no-dotnet + name: TypeScript without .NET SDK + env: + SCENARIO: ${{ matrix.scenario }} defaults: run: shell: bash @@ -33,25 +45,62 @@ jobs: - name: Install Dev Containers CLI run: npm install --global @devcontainers/cli@0.89.0 + - name: Prepare test configuration + run: | + mkdir -p "$RUNNER_TEMP/devcontainer-diagnostics" + config="$RUNNER_TEMP/devcontainer-test.json" + args=(--output "$config") + if [ "$SCENARIO" = typescript-no-dotnet ]; then + args+=(--without-dotnet) + fi + node .github/scripts/prepare-config.mjs "${args[@]}" + echo "DEVCONTAINER_CONFIG=$config" >> "$GITHUB_ENV" + - name: Start devcontainer id: container run: | - devcontainer up --workspace-folder . --mount-workspace-git-root false --no-lockfile > "$RUNNER_TEMP/devcontainer-up.json" + devcontainer up --workspace-folder . --config "$DEVCONTAINER_CONFIG" --mount-workspace-git-root false --no-lockfile \ + > "$RUNNER_TEMP/devcontainer-up.json" \ + 2> >(tee "$RUNNER_TEMP/devcontainer-diagnostics/startup.log" >&2) container_id=$(jq --exit-status --raw-output '.containerId' "$RUNNER_TEMP/devcontainer-up.json") echo "container_id=$container_id" >> "$GITHUB_OUTPUT" - name: Test fresh container - run: devcontainer exec --workspace-folder . node .github/scripts/smoke-test.mjs + run: | + devcontainer exec --workspace-folder . --config "$DEVCONTAINER_CONFIG" node .github/scripts/smoke-test.mjs "$SCENARIO" \ + 2>&1 | tee "$RUNNER_TEMP/devcontainer-diagnostics/fresh.log" - name: Stop and reopen devcontainer env: CONTAINER_ID: ${{ steps.container.outputs.container_id }} run: | docker stop "$CONTAINER_ID" - devcontainer up --workspace-folder . --mount-workspace-git-root false --no-lockfile --expect-existing-container + devcontainer up --workspace-folder . --config "$DEVCONTAINER_CONFIG" --mount-workspace-git-root false --no-lockfile --expect-existing-container \ + 2> >(tee "$RUNNER_TEMP/devcontainer-diagnostics/restart.log" >&2) - name: Test restarted container - run: devcontainer exec --workspace-folder . node .github/scripts/smoke-test.mjs + run: | + devcontainer exec --workspace-folder . --config "$DEVCONTAINER_CONFIG" node .github/scripts/smoke-test.mjs "$SCENARIO" \ + 2>&1 | tee "$RUNNER_TEMP/devcontainer-diagnostics/restarted.log" + + - name: Collect failure diagnostics + if: failure() && steps.container.outputs.container_id != '' + env: + CONTAINER_ID: ${{ steps.container.outputs.container_id }} + run: | + mkdir -p "$RUNNER_TEMP/devcontainer-diagnostics" + docker logs "$CONTAINER_ID" > "$RUNNER_TEMP/devcontainer-diagnostics/container.log" 2>&1 + docker inspect --format '{{json .State}}' "$CONTAINER_ID" > "$RUNNER_TEMP/devcontainer-diagnostics/container-state.json" + docker cp "$CONTAINER_ID:/home/vscode/.aspire/logs" "$RUNNER_TEMP/devcontainer-diagnostics/aspire" + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: devcontainer-${{ matrix.scenario }}-diagnostics + path: ${{ runner.temp }}/devcontainer-diagnostics + if-no-files-found: ignore + retention-days: 7 - name: Remove test container if: always() && steps.container.outputs.container_id != '' diff --git a/README.md b/README.md index 6d74175..ade40d1 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,17 @@ Trust inside the container does not automatically make your host browser trust t ## CI checks -The [Devcontainer workflow](.github/workflows/devcontainer.yml) runs on pull requests, pushes to `main`, and manual dispatch. It starts the actual devcontainer and runs [smoke checks](.github/scripts/smoke-test.mjs) before and after stopping and reopening it. +The [Devcontainer workflow](.github/workflows/devcontainer.yml) runs on pull requests, pushes to `main`, and manual dispatch. Each matrix job starts the actual devcontainer and runs [smoke checks](.github/scripts/smoke-test.mjs) before and after stopping and reopening it: -The checks cover non-root workspace access, installed tooling, certificate trust from the startup hook, Docker-in-Docker, a compiled .NET console app, and an Aspire app with a TypeScript AppHost, Python API, React frontend, and Redis. HTTPS requests verify certificates normally; the checks do not bypass TLS validation. Sample projects are created inside the container, not in the repository. VS Code extension installation and editor/debugger behavior are not covered. +| Scenario | AppHost and services | +| --- | --- | +| Python and React | TypeScript AppHost, FastAPI, React, and Redis; also compiles a .NET console app | +| C# and Blazor | C# AppHost, ASP.NET Core API, Blazor, and Redis | +| TypeScript without .NET SDK | TypeScript AppHost, Express, and React, with the standalone .NET SDK feature omitted | + +All jobs check non-root workspace access, tooling, startup certificate trust, Docker-in-Docker, and app endpoints. HTTPS requests verify certificates normally. The SDK-free job checks that `dotnet` is absent from `PATH` before and after running Aspire; Aspire still manages its own bundled .NET components. This is a test-only configuration, not a separate editor preset. + +The Redis check uses a non-expiring sentinel value rather than timing-dependent response comparisons. Failures preserve container, AppHost, and resource logs as workflow artifacts. Sample projects stay inside the container. VS Code extensions, debugging, browser rendering, and IDE port forwarding are not covered. To run the same checks locally with Docker running: @@ -60,6 +68,8 @@ npx --yes --package @devcontainers/cli@0.89.0 devcontainer up --workspace-folder npx --yes --package @devcontainers/cli@0.89.0 devcontainer exec --workspace-folder . node .github/scripts/smoke-test.mjs ``` +The default scenario is `python`; append `csharp` to the smoke command for the C# AppHost. For the SDK-free scenario, generate a temporary configuration with `node .github/scripts/prepare-config.mjs --output /tmp/aspire-no-dotnet.json --without-dotnet`, pass `--config /tmp/aspire-no-dotnet.json` to both CLI commands, and append `typescript-no-dotnet` to the smoke command. + To check restart behavior, stop the container identified by the `up` output, then run both commands again. The smoke script stops its Aspire app; the devcontainer remains running for further use. ## Code of Conduct From ac444a68c3e4bd2326d8e9b1890b743c33c4375d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 7 Sep 2026 10:19:13 -0700 Subject: [PATCH 3/3] Use supported names for generated devcontainer configurations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/prepare-config.mjs | 4 +++- .github/workflows/devcontainer.yml | 2 +- README.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/scripts/prepare-config.mjs b/.github/scripts/prepare-config.mjs index 89ceed1..55ec3f0 100644 --- a/.github/scripts/prepare-config.mjs +++ b/.github/scripts/prepare-config.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { basename, dirname, resolve } from 'node:path'; import { parseArgs } from 'node:util'; const { values } = parseArgs({ @@ -21,5 +21,7 @@ if (values['without-dotnet']) { const output = resolve(values.output); assert.notEqual(output, resolve('.devcontainer/devcontainer.json'), 'Use a separate output path for the test configuration.'); +assert.ok(['devcontainer.json', '.devcontainer.json'].includes(basename(output)), + 'The test configuration must be named devcontainer.json or .devcontainer.json.'); mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, `${JSON.stringify(configuration, null, 2)}\n`); diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index 132b3c6..75cfce4 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -48,7 +48,7 @@ jobs: - name: Prepare test configuration run: | mkdir -p "$RUNNER_TEMP/devcontainer-diagnostics" - config="$RUNNER_TEMP/devcontainer-test.json" + config="$RUNNER_TEMP/devcontainer-test/devcontainer.json" args=(--output "$config") if [ "$SCENARIO" = typescript-no-dotnet ]; then args+=(--without-dotnet) diff --git a/README.md b/README.md index ade40d1..fa1919d 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ npx --yes --package @devcontainers/cli@0.89.0 devcontainer up --workspace-folder npx --yes --package @devcontainers/cli@0.89.0 devcontainer exec --workspace-folder . node .github/scripts/smoke-test.mjs ``` -The default scenario is `python`; append `csharp` to the smoke command for the C# AppHost. For the SDK-free scenario, generate a temporary configuration with `node .github/scripts/prepare-config.mjs --output /tmp/aspire-no-dotnet.json --without-dotnet`, pass `--config /tmp/aspire-no-dotnet.json` to both CLI commands, and append `typescript-no-dotnet` to the smoke command. +The default scenario is `python`; append `csharp` to the smoke command for the C# AppHost. For the SDK-free scenario, generate a temporary configuration with `node .github/scripts/prepare-config.mjs --output /tmp/aspire-no-dotnet/devcontainer.json --without-dotnet`, pass `--config /tmp/aspire-no-dotnet/devcontainer.json` to both CLI commands, and append `typescript-no-dotnet` to the smoke command. To check restart behavior, stop the container identified by the `up` output, then run both commands again. The smoke script stops its Aspire app; the devcontainer remains running for further use.