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/prepare-config.mjs b/.github/scripts/prepare-config.mjs new file mode 100644 index 0000000..55ec3f0 --- /dev/null +++ b/.github/scripts/prepare-config.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, 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.'); +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/scripts/smoke-test.mjs b/.github/scripts/smoke-test.mjs new file mode 100644 index 0000000..bd632fe --- /dev/null +++ b/.github/scripts/smoke-test.mjs @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict'; +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'; + +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', + 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); +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', '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, 'app'); +const dotnetDirectory = join(testDirectory, 'dotnet'); +let startAttempted = false; + +try { + 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', scenario.template, + '--name', 'DevcontainerSmoke', + '--output', appDirectory, + ...(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 [...(scenario.cache ? ['cache'] : []), scenario.api, scenario.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; + }; + + 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', '--location', '--max-time', '30', url, + ], { capture: true }); + + assert.equal(get(new URL('/health', apiUrl).href), 'Healthy'); + 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 }); + } +} + +rmSync(testDirectory, { recursive: true }); +console.log(`Devcontainer ${scenarioName} checks passed.`); diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml new file mode 100644 index 0000000..75cfce4 --- /dev/null +++ b/.github/workflows/devcontainer.yml @@ -0,0 +1,109 @@ +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: ${{ 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 + 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: Prepare test configuration + run: | + mkdir -p "$RUNNER_TEMP/devcontainer-diagnostics" + config="$RUNNER_TEMP/devcontainer-test/devcontainer.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 . --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 . --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 . --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 . --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 != '' + 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..fa1919d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,78 @@ # 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. Each matrix job starts the actual devcontainer and runs [smoke checks](.github/scripts/smoke-test.mjs) before and after stopping and reopening it: + +| 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: + +```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 +``` + +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. + +## Code of Conduct This project has adopted the code of conduct defined by the Contributor Covenant to clarify expected behavior in our community.