diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2b847d2..e18c2d7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ -FROM mcr.microsoft.com/devcontainers/dotnet:dev-10.0-noble +FROM mcr.microsoft.com/devcontainers/base:3-ubuntu24.04 -# Fix Yarn GPG key issue - remove the problematic Yarn repository -# that has an expired/rotated signing key causing apt-get update to fail -# See: https://github.com/devcontainers/images/issues/1752 -RUN rm -f /etc/apt/sources.list.d/yarn.list 2>/dev/null || true +# The nightly CLI needs ICU even when the standalone .NET SDK feature is omitted. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libicu74 \ + && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 57fdf48..1cce5d3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,5 @@ -// 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 (Nightly)", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "build": { "dockerfile": "Dockerfile" }, @@ -11,47 +8,47 @@ "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers/features/powershell:1": {}, "ghcr.io/azure/azure-dev/azd:0": {}, - "ghcr.io/devcontainers/features/node:1": {}, - "ghcr.io/devcontainers/features/python: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/kubectl-helm-minikube:1": { "version": "latest", "helm": "latest", "minikube": "latest" } }, - "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", + "remoteEnv": { + "PATH": "/home/vscode/.aspire/bin:/home/vscode/.dotnet/tools:${containerEnv:PATH}", + "SSL_CERT_DIR": "/usr/lib/ssl/certs:/home/vscode/.aspnet/dev-certs/trust" + }, "onCreateCommand": "bash .devcontainer/oncreate.sh", - "postStartCommand": "dotnet dev-certs https --trust", + "postStartCommand": "aspire certs trust --non-interactive", "customizations": { "vscode": { "extensions": [ + "microsoft-aspire.aspire-vscode", + "dbaeumer.vscode-eslint", + "ms-python.python", + "ms-python.vscode-pylance", "ms-dotnettools.csdevkit", "ms-azuretools.vscode-bicep", "ms-azuretools.azure-dev", "GitHub.copilot-chat", "GitHub.copilot", - "ms-kubernetes-tools.vscode-kubernetes-tools", - "microsoft-aspire.aspire-vscode" + "ms-kubernetes-tools.vscode-kubernetes-tools" ] } } - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" } diff --git a/.devcontainer/oncreate.sh b/.devcontainer/oncreate.sh index 4c60e10..1c257cf 100644 --- a/.devcontainer/oncreate.sh +++ b/.devcontainer/oncreate.sh @@ -28,7 +28,11 @@ for attempt in $(seq 1 "$MAX_ATTEMPTS"); do sleep "$RETRY_DELAY" done -echo "Clearing NuGet cache to reduce prebuild template size..." -dotnet nuget locals all --clear +if command -v dotnet >/dev/null 2>&1; then + echo "Clearing NuGet cache to reduce prebuild template size..." + dotnet nuget locals all --clear +else + echo "Standalone .NET SDK not installed; skipping NuGet cache cleanup." +fi echo "onCreateCommand completed successfully." diff --git a/.github/scripts/prepare-config.mjs b/.github/scripts/prepare-config.mjs new file mode 100644 index 0000000..4f96bd9 --- /dev/null +++ b/.github/scripts/prepare-config.mjs @@ -0,0 +1,32 @@ +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 source = resolve('.devcontainer/devcontainer.json'); +const configuration = JSON.parse(readFileSync(source, '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]]; +} + +// Keep the nightly Dockerfile and its build context valid outside .devcontainer. +configuration.build.dockerfile = resolve(dirname(source), configuration.build.dockerfile); +configuration.build.context = resolve(dirname(source), configuration.build.context ?? '.'); + +const output = resolve(values.output); +assert.notEqual(output, source, '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/prepare-config.test.mjs b/.github/scripts/prepare-config.test.mjs new file mode 100644 index 0000000..e7c18ee --- /dev/null +++ b/.github/scripts/prepare-config.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; + +const script = resolve('.github/scripts/prepare-config.mjs'); +const source = resolve('.devcontainer/devcontainer.json'); + +for (const withoutDotnet of [false, true]) { + test(`relocates the ${withoutDotnet ? 'SDK-free' : 'standard'} configuration`, t => { + const directory = mkdtempSync(join(tmpdir(), 'aspire-nightly-config-')); + t.after(() => rmSync(directory, { recursive: true })); + const original = readFileSync(source, 'utf8'); + const output = join(directory, 'nested', 'devcontainer.json'); + execFileSync(process.execPath, [ + script, '--output', output, ...(withoutDotnet ? ['--without-dotnet'] : []), + ]); + + const expected = JSON.parse(original); + expected.build.dockerfile = resolve('.devcontainer/Dockerfile'); + expected.build.context = resolve('.devcontainer'); + if (withoutDotnet) { + delete expected.features['ghcr.io/devcontainers/features/dotnet:2']; + } + const actual = JSON.parse(readFileSync(output, 'utf8')); + assert.deepEqual(actual, expected); + assert.ok(actual.remoteEnv.PATH.split(':').includes('/home/vscode/.dotnet/tools'), + 'Global .NET tools must remain on the remote PATH.'); + assert.ok(existsSync(actual.build.dockerfile)); + assert.ok(existsSync(actual.build.context)); + assert.equal(readFileSync(source, 'utf8'), original); + }); +} + +test('rejects overwriting the source configuration', () => { + const original = readFileSync(source, 'utf8'); + const result = spawnSync(process.execPath, [script, '--output', source], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Use a separate output path/); + assert.equal(readFileSync(source, 'utf8'), original); +}); + +test('requires a supported output filename', t => { + const directory = mkdtempSync(join(tmpdir(), 'aspire-nightly-config-')); + t.after(() => rmSync(directory, { recursive: true })); + const output = join(directory, 'invalid.json'); + const result = spawnSync(process.execPath, [script, '--output', output], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /must be named devcontainer.json or .devcontainer.json/); + assert.equal(existsSync(output), false); +}); + +test('requires an output path', () => { + const result = spawnSync(process.execPath, [script], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /--output is required/); +}); diff --git a/.github/scripts/smoke-test.mjs b/.github/scripts/smoke-test.mjs new file mode 100644 index 0000000..db6f4d7 --- /dev/null +++ b/.github/scripts/smoke-test.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { accessSync, constants, copyFileSync, 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', 'az']) { + execute(command, ['--version']); +} +execute('azd', ['version']); +execute('kubectl', ['version', '--client']); +execute('helm', ['version', '--short']); +execute('minikube', ['version', '--short']); +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'); +// Exercise this repository's feeds without generating sample apps in the workspace. +copyFileSync('nuget.config', join(testDirectory, 'nuget.config')); +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..f5678b2 --- /dev/null +++ b/.github/workflows/devcontainer.yml @@ -0,0 +1,112 @@ +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: Test configuration helper + run: node --test .github/scripts/prepare-config.test.mjs + + - 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 fd8b5a2..1d5ab6f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,85 @@ -# Getting started with Aspire w/ Devcontainers +# Getting started with Aspire nightly and Dev Containers -This is a repository template to streamline the process of getting started with Aspire using Devontainers 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 provides a ready-to-use development environment for Visual Studio Code Dev Containers and GitHub Codespaces, with nightly Aspire builds and additional tooling for Aspire team dogfooding. -- [Use Aspire with GitHub Codespaces](https://aspire.dev/get-started/github-codespaces/) -- [Dev Containers in Visual Studio Code](https://aspire.dev/get-started/dev-containers/) +> [!NOTE] +> This repository installs the latest daily builds of Aspire. Use [microsoft/aspire-devcontainer](https://github.com/microsoft/aspire-devcontainer) as a template for the latest released bits. Aspire team members can create a Codespace directly from this repository to take advantage of prebuilds and reduce startup time. -> 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. +## What's included -> NOTE: This repository is configured to use the latest daily builds of Aspire. Use the https://github.com/microsoft/aspire-devcontainer repository as a template for the latest _released_ bits. If you are a member of the Aspire team you can create a codespace directly from this repository to speed up the spin-up time for a Devcontainer for testing purposes. The `devcontainer.json` includes additional features which are more common for internal team testing scenarios. +The container uses an Ubuntu 24.04 base image, with language tooling installed as Dev Container Features: -# Code of Conduct +| Tooling | Purpose | +| --- | --- | +| Nightly Aspire CLI and Aspire VS Code extension | Dogfood orchestration 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 | +| Azure CLI, Azure Developer CLI (`azd`), and Azure/Bicep extensions | Exercise Azure deployment scenarios | +| kubectl, Helm, Minikube, and the Kubernetes extension | Exercise Kubernetes development and deployment scenarios | + +VS Code includes JavaScript/TypeScript support, with ESLint, Python, and Pylance extensions installed alongside the existing Aspire, .NET, Azure, Kubernetes, and GitHub Copilot extensions. Codespaces requests 8 CPUs, 32 GB of memory, and 64 GB of storage. + +## Get started + +1. Create a Codespace directly from this repository for team testing, or [create a repository from this template](https://github.com/new?template_name=aspire-devcontainer-nightly&template_owner=microsoft). +2. Alternatively, clone the repository 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. + +Unlike the released template, this repository installs the Aspire CLI through [`oncreate.sh`](.devcontainer/oncreate.sh) using the installer's `-q dev` option. Installation retries transient failures, then clears NuGet caches when the standalone SDK is present to keep Codespaces prebuilds small. The hook runs during creation/prebuild, not every restart. The [Dockerfile](.devcontainer/Dockerfile) supplies ICU for the CLI even without a standalone .NET SDK, and the remote environment puts the nightly CLI on `PATH` for terminals and lifecycle commands. + +The template does not run an application-specific restore command. Install your app's dependencies with its package manager, such as `npm install`, `uv sync`, or `dotnet restore`. The repository's [`nuget.config`](nuget.config) retains NuGet.org and the `dotnet10` feed for team testing. + +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. + +## 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 nightly 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, language and team CLI 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. + +Sample projects stay inside the container, with a copy of the repository's NuGet configuration in their parent directory so restores inherit the team feeds. 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. VS Code extensions, debugging, browser rendering, IDE port forwarding, and actual Azure/Kubernetes deployments 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-nightly-no-dotnet/devcontainer.json --without-dotnet`, pass `--config /tmp/aspire-nightly-no-dotnet/devcontainer.json` to both CLI commands, and append `typescript-no-dotnet` to the smoke command. The helper preserves the Dockerfile and build context paths when relocating the configuration. + +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.