diff --git a/.github/actions/setup-runtimes-caching/action.yml b/.github/actions/setup-runtimes-caching/action.yml
index 535a11f59..456ca0088 100644
--- a/.github/actions/setup-runtimes-caching/action.yml
+++ b/.github/actions/setup-runtimes-caching/action.yml
@@ -43,13 +43,14 @@ runs:
EXPECTED_ASPIRE_CLI_VERSION: ${{ steps.aspire-version.outputs.cli-version }}
run: |
INSTALLED_ASPIRE_CLI_VERSION="$(aspire --version)"
- case "${INSTALLED_ASPIRE_CLI_VERSION}" in
- "${EXPECTED_ASPIRE_CLI_VERSION}"|"${EXPECTED_ASPIRE_CLI_VERSION}"+*) ;;
- *)
- echo "Expected Aspire CLI version ${EXPECTED_ASPIRE_CLI_VERSION}, but found ${INSTALLED_ASPIRE_CLI_VERSION}."
- exit 1
- ;;
- esac
+ # Strip build metadata (e.g. 13.4.3+becb48e β 13.4.3) before comparing so that
+ # a newer patch release installed by the action does not fail this check.
+ INSTALLED_BASE="${INSTALLED_ASPIRE_CLI_VERSION%%+*}"
+ # Pass when installed base version >= expected version (sort -V = version ordering).
+ if ! printf '%s\n' "${EXPECTED_ASPIRE_CLI_VERSION}" "${INSTALLED_BASE}" | sort -V -C; then
+ echo "Expected Aspire CLI version >= ${EXPECTED_ASPIRE_CLI_VERSION}, but found ${INSTALLED_ASPIRE_CLI_VERSION}."
+ exit 1
+ fi
- name: Set up Python
uses: actions/setup-python@v5
diff --git a/.gitignore b/.gitignore
index 09a659f83..2b2c300e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,8 @@ examples/perl/**/local/*
**cpanfile.snapshot
**/.aspire/modules/
**/*.AppHost.TypeScript/nuget.config
+
+**/.k3s/
tsconfig.apphost.json
.ngrok
bun.lock
@@ -34,4 +36,7 @@ apphost.js
affected-tests
examples/**/.modules/*
examples/**/.aspire/modules/*
-examples/**/.aspire/integrations/*
\ No newline at end of file
+examples/**/.aspire/integrations/*
+playground/**/.modules/*
+playground/**/.aspire/modules/*
+playground/**/.aspire/integrations/*
\ No newline at end of file
diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index a904504fa..4c04b3950 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -60,6 +60,9 @@
+
+
+
@@ -216,6 +219,7 @@
+
@@ -279,6 +283,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8c2d31084..7daf92c1c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -96,6 +96,7 @@
+
diff --git a/README.md b/README.md
index c5f35f90d..6c6736d24 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ This repository contains the source code for the Aspire Community Toolkit, a col
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| - **Learn More**: [`Hosting.Golang`][golang-integration-docs]
- Stable π¦: [![CommunityToolkit.Aspire.Hosting.Golang][golang-shields]][golang-nuget]
- Preview π¦: [![CommunityToolkit.Aspire.Hosting.Golang][golang-shields-preview]][golang-nuget-preview] | A hosting integration Golang apps. |
| - **Learn More**: [`Hosting.Java`][java-integration-docs]
- Stable π¦: [![CommunityToolkit.Aspire.Hosting.Java][java-shields]][java-nuget]
- Preview π¦: [![CommunityToolkit.Aspire.Hosting.Java][java-shields-preview]][java-nuget-preview] | An integration for running Java code in Aspire either using the local JDK or using a container. |
+| - **Learn More**: [`Hosting.K3s`][k3s-integration-docs]
- Stable π¦: [![CommunityToolkit.Aspire.Hosting.K3s][k3s-shields]][k3s-nuget]
- Preview π¦: [![CommunityToolkit.Aspire.Hosting.K3s][k3s-shields-preview]][k3s-nuget-preview] | An Aspire hosting integration for [k3s](https://k3s.io/), a lightweight Kubernetes distribution by Rancher. |
| - **Learn More**: [`Hosting.NodeJS.Extensions`][nodejs-ext-integration-docs]
- Stable π¦: [![CommunityToolkit.Aspire.NodeJS.Extensions][nodejs-ext-shields]][nodejs-ext-nuget]
- Preview π¦: [![CommunityToolkit.Aspire.Hosting.JavaScript.Extensions][nodejs-ext-shields-preview]][nodejs-ext-nuget-preview] | An integration that contains some additional extensions for running Node.js applications |
| - **Learn More**: [`Hosting.Ollama`][ollama-integration-docs]
- Stable π¦: [![CommunityToolkit.Aspire.Hosting.Ollama][ollama-shields]][ollama-nuget]
- Preview π¦: [![CommunityToolkit.Aspire.Hosting.Ollama][ollama-shields-preview]][ollama-nuget-preview] | An Aspire hosting integration leveraging the [Ollama](https://ollama.com) container with support for downloading a model on startup. |
| - **Learn More**: [`OllamaSharp`][ollama-integration-docs]
- Stable π¦: [![CommunityToolkit.Aspire.OllamaSharp][ollamasharp-shields]][ollamasharp-nuget]
- Preview π¦: [![CommunityToolkit.Aspire.OllamaSharp][ollamasharp-shields-preview]][ollamasharp-nuget-preview] | An Aspire client integration for the [OllamaSharp](https://github.com/awaescher/OllamaSharp) package. |
@@ -106,6 +107,11 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org)
[java-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Java/
[java-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Java?label=nuget%20(preview)
[java-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Java/absoluteLatest
+[k3s-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-k3s
+[k3s-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.K3s
+[k3s-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.K3s/
+[k3s-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.K3s?label=nuget%20(preview)
+[k3s-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.K3s/absoluteLatest
[nodejs-ext-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-nodejs-extensions
[nodejs-ext-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.JavaScript.Extensions
[nodejs-ext-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.JavaScript.Extensions/
diff --git a/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/CommunityToolkit.Aspire.Hosting.K3s.AppHost.csproj b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/CommunityToolkit.Aspire.Hosting.K3s.AppHost.csproj
new file mode 100644
index 000000000..50ee2b3d0
--- /dev/null
+++ b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/CommunityToolkit.Aspire.Hosting.K3s.AppHost.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ enable
+ enable
+ true
+
+
+
+
+
+
+
diff --git a/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/Program.cs b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/Program.cs
new file mode 100644
index 000000000..61549964e
--- /dev/null
+++ b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/Program.cs
@@ -0,0 +1,54 @@
+// K3s hosting example
+// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Prerequisites (host machine):
+// β’ A container runtime that supports privileged Linux containers:
+// - Linux: Docker Engine 20.10+ or rootful Podman 4.0+
+// - macOS / Windows: Docker Desktop (WSL2 / Hyper-V)
+// No host-side helm or kubectl required β both run as containers.
+//
+// What this demonstrates:
+// 1. A k3s cluster starts inside a Docker container.
+// 2. app-config ConfigMap applied via AddK8sManifest (plain YAML file).
+// 3. monitoring-config ConfigMap applied via AddK8sManifest (Kustomize overlay β
+// auto-detected from kustomization.yaml; adds namespace + common labels).
+// 4. podinfo installed via Helm, waiting for both manifests first.
+// 5. A K3sServiceEndpointResource exposes the podinfo service:
+// β’ Host processes reach it at http://localhost:{port}
+// β’ DCP-network containers reach it at http://host.docker.internal:{port}
+// 6. WithDataVolume keeps the cluster state alive across AppHost restarts.
+// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+var cluster = builder
+ .AddK3sCluster("k8s")
+ .WithAgentCount(2)
+ .WithDataVolume()
+ .WithLifetime(ContainerLifetime.Persistent);
+
+var podinfo = cluster.AddHelmRelease(
+ name: "podinfo",
+ chart: "podinfo",
+ repo: "https://stefanprodan.github.io/podinfo",
+ version: "6.7.1",
+ @namespace: "podinfo");
+
+// Plain YAML file β a ConfigMap in the default namespace.
+var appConfig = cluster.AddK8sManifest("app-config", "./k8s/app-config.yaml")
+ .WaitForCompletion(podinfo);
+
+// Kustomize overlay β auto-detected because the directory contains kustomization.yaml.
+// Kustomize injects the 'monitoring' namespace and common labels into every resource
+// without modifying the source files.
+var monitoringConfig = cluster.AddK8sManifest("monitoring-config", "./k8s/monitoring")
+ .WaitForCompletion(podinfo)
+ .WaitForCompletion(appConfig);
+
+// Expose the podinfo service as an Aspire endpoint resource.
+// WaitForCompletion waits for the helm install container to exit with code 0
+// before starting the port-forward β no NodePort required.
+cluster.AddServiceEndpoint("podinfo-web", "podinfo", servicePort: 9898, @namespace: "podinfo")
+ .WaitForCompletion(podinfo)
+ .WaitForCompletion(monitoringConfig);
+
+builder.Build().Run();
diff --git a/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/Properties/launchSettings.json b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..dcfbae059
--- /dev/null
+++ b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/Properties/launchSettings.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:53201;http://localhost:53202",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:53203",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:53204"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:53202",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:53205",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:53206"
+ }
+ }
+ }
+}
diff --git a/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/k8s/app-config.yaml b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/k8s/app-config.yaml
new file mode 100644
index 000000000..57c26eb6e
--- /dev/null
+++ b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/k8s/app-config.yaml
@@ -0,0 +1,10 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data:
+ environment: development
+ log-level: info
+ # podinfo endpoint β matches the AddServiceEndpoint name in Program.cs
+ api-endpoint: http://podinfo:9898
diff --git a/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/k8s/monitoring/kustomization.yaml b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/k8s/monitoring/kustomization.yaml
new file mode 100644
index 000000000..c95a7c85a
--- /dev/null
+++ b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/k8s/monitoring/kustomization.yaml
@@ -0,0 +1,16 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+# Kustomize places every resource in this namespace.
+namespace: default
+
+# Common labels applied to all resources in this overlay.
+labels:
+- includeSelectors: true
+ pairs:
+ app.kubernetes.io/managed-by: aspire
+ environment: development
+
+
+resources:
+ - monitoring-config.yaml
diff --git a/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/k8s/monitoring/monitoring-config.yaml b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/k8s/monitoring/monitoring-config.yaml
new file mode 100644
index 000000000..217066c49
--- /dev/null
+++ b/examples/k3s/CommunityToolkit.Aspire.Hosting.K3s.AppHost/k8s/monitoring/monitoring-config.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: monitoring-config
+data:
+ scrape-interval: 30s
+ retention: 7d
+ log-level: info
diff --git a/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/apphost.mts b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/apphost.mts
new file mode 100644
index 000000000..9b0107d3f
--- /dev/null
+++ b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/apphost.mts
@@ -0,0 +1,110 @@
+import { createBuilder, ContainerLifetime } from './.aspire/modules/aspire.mjs';
+
+const builder = await createBuilder();
+
+// ββ Runtime path (actually executed) βββββββββββββββββββββββββββββββββββββββββ
+// Deploys a k3s cluster with 2 agent nodes, installs podinfo via Helm, and
+// exposes the podinfo service as an Aspire endpoint β validating the full
+// add/build/run path without relying on the configure callback.
+const cluster = await builder.addK3sCluster('k8s')
+ .withAgentCount(2);
+
+const _apiEndpoint = await cluster.apiEndpoint();
+
+const setupPodinfo = await cluster.addHelmRelease('podinfo', 'podinfo', {
+ repo: 'https://stefanprodan.github.io/podinfo',
+ version: '6.7.1',
+ namespace: 'podinfo',
+});
+
+await cluster.addServiceEndpoint('podinfo-web', 'podinfo', 9898, {
+ namespace: 'podinfo',
+}).waitForCompletion(setupPodinfo);
+
+// ββ Compile-time coverage βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Guards with false so these are type-checked but never executed.
+// Covers the full exported API surface without requiring Docker/k3s in CI.
+const includeCompileOnlyScenarios = false;
+
+if (includeCompileOnlyScenarios) {
+
+ // ββ Cluster configuration ββββββββββββββββββββββββββββββββββββββββββββββββ
+ // All K3sClusterOptions are now available as fluent builder methods.
+ const configuredCluster = await builder.addK3sCluster('k8s-configured', {
+ apiServerPort: 6443,
+ agentCount: 2
+ })
+ .withK3sVersion('v1.32.3-k3s1')
+ .withPodSubnet('10.42.0.0/16')
+ .withServiceSubnet('10.43.0.0/16')
+ .withDisabledComponent('traefik')
+ .withExtraArg('--write-kubeconfig-mode=644')
+ .withDataVolume({ name: 'k8s-data' })
+ .withHelmImage({ tag: '3.18.0' })
+ .withKubectlImage({ tag: '1.37.0' })
+ .withLifetime(ContainerLifetime.Persistent);
+
+ const _configuredApiEndpoint = await configuredCluster.apiEndpoint();
+
+ // ββ Helm release β podinfo ββββββββββββββββββββββββββββββββββββββββββββββββ
+ const podinfo = await configuredCluster.addHelmRelease('podinfo', 'podinfo', {
+ repo: 'https://stefanprodan.github.io/podinfo',
+ version: '6.7.1',
+ namespace: 'podinfo',
+ });
+
+ const _podinfoParent = await podinfo.parent();
+ const _podinfoReleaseName = await podinfo.releaseName();
+ const _podinfoNamespace = await podinfo.namespace();
+
+ // ββ K8s manifest β plain YAML file βββββββββββββββββββββββββββββββββββββββ
+ const appConfig = await configuredCluster
+ .addK8sManifest('app-config', './k8s/app-config.yaml')
+ .waitForCompletion(podinfo);
+
+ const _appConfigParent = await appConfig.parent();
+ const _appConfigPath = await appConfig.path();
+
+ // ββ K8s manifest β Kustomize overlay βββββββββββββββββββββββββββββββββββββ
+ // Auto-detected because the directory contains kustomization.yaml.
+ const monitoringConfig = await configuredCluster
+ .addK8sManifest('monitoring-config', './k8s/monitoring')
+ .waitForCompletion(podinfo)
+ .waitForCompletion(appConfig);
+
+ const _monitoringParent = await monitoringConfig.parent();
+
+ // ββ Service endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // β’ Host processes receive services__podinfo-web__url=http://localhost:{port}
+ // β’ DCP containers receive services__podinfo-web__url=http://host.docker.internal:{port}
+ const podinfoWeb = await configuredCluster
+ .addServiceEndpoint('podinfo-web', 'podinfo', 9898, {
+ namespace: 'podinfo',
+ })
+ .waitForCompletion(podinfo)
+ .waitForCompletion(monitoringConfig);
+
+ const _webParent = await podinfoWeb.parent();
+ const _webServiceName = await podinfoWeb.serviceName();
+ const _webServicePort = await podinfoWeb.servicePort();
+ const _webNamespace = await podinfoWeb.namespace();
+ const _webHostPort = await podinfoWeb.hostPort.get();
+
+ // ββ WithReference β cluster kubeconfig injection ββββββββββββββββββββββββββ
+ // β’ Project/executable: KUBECONFIG=/.k3s/k8s/local/kubeconfig.yaml
+ // β’ Container: KUBECONFIG=/tmp/k3s-kubeconfig.yaml + bind-mount
+ const _projectRef = await builder
+ .addProject('operator', '../WidgetOperator/WidgetOperator.csproj')
+ .withReference(configuredCluster);
+
+ const _containerRef = await builder
+ .addContainer('sidecar', 'myorg/sidecar')
+ .withReference(configuredCluster);
+
+ // ββ WithReference β service endpoint URL injection ββββββββββββββββββββββββ
+ const _endpointRef = await builder
+ .addProject('api', '../WidgetApi/WidgetApi.csproj')
+ .withReference(podinfoWeb);
+}
+
+await builder.build().run();
diff --git a/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/aspire.config.json b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/aspire.config.json
new file mode 100644
index 000000000..205fa756d
--- /dev/null
+++ b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/aspire.config.json
@@ -0,0 +1,9 @@
+{
+ "appHost": {
+ "path": "apphost.mts",
+ "language": "typescript/nodejs"
+ },
+ "packages": {
+ "CommunityToolkit.Aspire.Hosting.K3s": "../../../../../src/CommunityToolkit.Aspire.Hosting.K3s/CommunityToolkit.Aspire.Hosting.K3s.csproj"
+ }
+}
diff --git a/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/package-lock.json b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/package-lock.json
new file mode 100644
index 000000000..85365bff4
--- /dev/null
+++ b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/package-lock.json
@@ -0,0 +1,938 @@
+{
+ "name": "validationapphost",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "validationapphost",
+ "version": "1.0.0",
+ "dependencies": {
+ "vscode-jsonrpc": "^8.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "nodemon": "^3.1.11",
+ "tsx": "^4.19.0",
+ "typescript": "^5.3.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.41",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
+ "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.0",
+ "@esbuild/android-arm": "0.28.0",
+ "@esbuild/android-arm64": "0.28.0",
+ "@esbuild/android-x64": "0.28.0",
+ "@esbuild/darwin-arm64": "0.28.0",
+ "@esbuild/darwin-x64": "0.28.0",
+ "@esbuild/freebsd-arm64": "0.28.0",
+ "@esbuild/freebsd-x64": "0.28.0",
+ "@esbuild/linux-arm": "0.28.0",
+ "@esbuild/linux-arm64": "0.28.0",
+ "@esbuild/linux-ia32": "0.28.0",
+ "@esbuild/linux-loong64": "0.28.0",
+ "@esbuild/linux-mips64el": "0.28.0",
+ "@esbuild/linux-ppc64": "0.28.0",
+ "@esbuild/linux-riscv64": "0.28.0",
+ "@esbuild/linux-s390x": "0.28.0",
+ "@esbuild/linux-x64": "0.28.0",
+ "@esbuild/netbsd-arm64": "0.28.0",
+ "@esbuild/netbsd-x64": "0.28.0",
+ "@esbuild/openbsd-arm64": "0.28.0",
+ "@esbuild/openbsd-x64": "0.28.0",
+ "@esbuild/openharmony-arm64": "0.28.0",
+ "@esbuild/sunos-x64": "0.28.0",
+ "@esbuild/win32-arm64": "0.28.0",
+ "@esbuild/win32-ia32": "0.28.0",
+ "@esbuild/win32-x64": "0.28.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nodemon": {
+ "version": "3.1.14",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
+ "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^10.2.1",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.22.4",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
+ "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ }
+ }
+}
diff --git a/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/package.json b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/package.json
new file mode 100644
index 000000000..277aa4582
--- /dev/null
+++ b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "validationapphost",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "build": "tsc --noEmit",
+ "dev": "tsc --noEmit --watch"
+ },
+ "dependencies": {
+ "vscode-jsonrpc": "^8.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "nodemon": "^3.1.11",
+ "tsx": "^4.19.0",
+ "typescript": "^5.3.0"
+ }
+}
diff --git a/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/tsconfig.json b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/tsconfig.json
new file mode 100644
index 000000000..19805ab48
--- /dev/null
+++ b/playground/polyglot/TypeScript/CommunityToolkit.Aspire.Hosting.K3s/ValidationAppHost/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "outDir": "./dist",
+ "rootDir": "."
+ },
+ "include": [
+ "apphost.mts",
+ ".aspire/modules/**/*.mts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/CommunityToolkit.Aspire.Hosting.K3s.csproj b/src/CommunityToolkit.Aspire.Hosting.K3s/CommunityToolkit.Aspire.Hosting.K3s.csproj
new file mode 100644
index 000000000..cfaf9aa7c
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/CommunityToolkit.Aspire.Hosting.K3s.csproj
@@ -0,0 +1,19 @@
+
+
+
+ kubernetes k3s hosting cluster
+ An Aspire hosting integration for k3s. Provides AddK3sCluster, AddHelmRelease (via alpine/helm), AddK8sManifest with Kustomize support (via alpine/kubectl), and AddServiceEndpoint for in-process WebSocket port-forwarding. No host-side kubectl or helm required.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/HelmContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/HelmContainerImageTags.cs
new file mode 100644
index 000000000..885c77724
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/HelmContainerImageTags.cs
@@ -0,0 +1,8 @@
+namespace CommunityToolkit.Aspire.Hosting;
+
+internal static class HelmContainerImageTags
+{
+ internal const string Registry = "docker.io";
+ internal const string Image = "alpine/helm";
+ internal const string Tag = "3.17.3";
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/HelmReleaseResource.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/HelmReleaseResource.cs
new file mode 100644
index 000000000..3a99557ee
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/HelmReleaseResource.cs
@@ -0,0 +1,50 @@
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a Helm chart release installed into a k3s cluster.
+///
+/// The Aspire resource name; also used as the Helm release name.
+///
+/// The Helm release name passed to helm upgrade --install.
+///
+/// The Kubernetes namespace to install the chart into.
+/// The parent k3s cluster resource.
+///
+/// The release runs as an alpine/helm container. The container polls until the cluster
+/// kubeconfig is available, executes helm upgrade --install --wait, then exits with
+/// code 0. Use WaitForCompletion(helmRelease) on resources that depend on the chart
+/// being fully installed.
+///
+[AspireExport(ExposeProperties = true)]
+public sealed class HelmReleaseResource(
+ string name,
+ string releaseName,
+ string @namespace,
+ K3sClusterResource cluster)
+ : ContainerResource(name), IResourceWithParent
+{
+ ///
+ public K3sClusterResource Parent { get; } = cluster ?? throw new ArgumentNullException(nameof(cluster));
+
+ ///
+ /// Gets the Helm release name passed to helm upgrade --install.
+ ///
+ public string ReleaseName { get; } = releaseName ?? throw new ArgumentNullException(nameof(releaseName));
+
+ /// Gets the Kubernetes namespace the chart is installed into.
+ public string Namespace { get; } = @namespace ?? throw new ArgumentNullException(nameof(@namespace));
+
+ internal string? Chart { get; set; }
+ internal string? RepoUrl { get; set; }
+ internal string? Version { get; set; }
+ internal Dictionary HelmValues { get; } = new(StringComparer.Ordinal);
+
+ ///
+ /// Absolute host paths of values files to inject into the helm container via
+ /// --values /helm-values/{filename}.
+ /// Populated by WithHelmValuesFile.
+ ///
+ internal List ValuesFiles { get; } = [];
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sAgentResource.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sAgentResource.cs
new file mode 100644
index 000000000..b878bfdbd
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sAgentResource.cs
@@ -0,0 +1,22 @@
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a k3s agent (worker) node belonging to a .
+///
+/// The resource name, e.g. k8s-agent-0.
+/// The parent k3s cluster resource.
+///
+/// Agent nodes run k3s agent and join the server at https://{serverName}:6443
+/// using DCP's Docker DNS. They start in parallel with the server without a WaitFor
+/// dependency β k3s's built-in retry loop handles the connection timing. The cluster's health
+/// check waits for all 1 + AgentCount nodes to reach Ready state before the
+/// cluster resource transitions to Running. Agent nodes are created automatically by
+/// AddK3sCluster when K3sClusterOptions.AgentCount is greater than zero.
+///
+public sealed class K3sAgentResource(string name, K3sClusterResource cluster)
+ : ContainerResource(name), IResourceWithParent
+{
+ ///
+ public K3sClusterResource Parent { get; } = cluster
+ ?? throw new ArgumentNullException(nameof(cluster));
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Helm.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Helm.cs
new file mode 100644
index 000000000..ea1ea4f76
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Helm.cs
@@ -0,0 +1,294 @@
+using System.Text;
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting;
+
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+
+namespace Aspire.Hosting;
+
+///
+/// Extension methods for adding Helm release resources to a k3s cluster.
+///
+public static class K3sHelmBuilderExtensions
+{
+ ///
+ /// Installs a Helm chart into the k3s cluster.
+ ///
+ /// The k3s cluster resource builder.
+ ///
+ /// The Aspire resource name; also used as the Helm release name passed to
+ /// helm upgrade --install.
+ ///
+ ///
+ /// The chart name, e.g. argo-cd for a repo chart or oci://registry/chart
+ /// for an OCI reference. Provide when using a named repo chart.
+ ///
+ ///
+ /// Optional Helm repository URL, e.g. https://argoproj.github.io/argo-helm.
+ /// When provided, the repo is added and updated before installation.
+ ///
+ ///
+ /// Optional chart version to pin, e.g. 7.8.0. When the
+ /// latest version available in the repository is installed.
+ ///
+ ///
+ /// The Kubernetes namespace to install the release into. The namespace is created
+ /// automatically if it does not exist. Defaults to default.
+ ///
+ /// A builder for the .
+ ///
+ ///
+ /// The release runs as an alpine/helm container on the DCP network. No host-side
+ /// helm binary is required. The container exits with code 0 when the release is
+ /// fully installed and all workloads are ready. Use WaitForCompletion(helmRelease)
+ /// on resources that must start only after the chart is ready.
+ ///
+ ///
+ /// Customize the release with for individual key/value pairs
+ /// or to supply a full YAML values file.
+ ///
+ ///
+ ///
+ /// , , or is
+ /// .
+ ///
+ [AspireExport]
+ public static IResourceBuilder AddHelmRelease(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ string chart,
+ string? repo = null,
+ string? version = null,
+ string @namespace = "default")
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(name);
+ ArgumentNullException.ThrowIfNull(chart);
+
+ var cluster = builder.Resource;
+
+ var release = new HelmReleaseResource(name, releaseName: name, @namespace, cluster)
+ {
+ Chart = chart,
+ RepoUrl = repo,
+ Version = version,
+ };
+
+ cluster.AddHelmRelease(release.Name, release.ReleaseName);
+
+ // The helm installer container mounts container/kubeconfig.yaml so it can reach
+ // the k3s API via DCP DNS (https://{clusterName}:6443). The directory is created
+ // by AddK3sCluster; the kubeconfig file is written by K3sReadinessHealthCheck on
+ // first successful health check. WaitFor(cluster) guarantees the file exists.
+ // Ensure the host-side container/ directory exists so the health check can write to it.
+ var containerKubeconfigFile = Path.Combine(cluster.KubeconfigDirectory!, "container", "kubeconfig.yaml");
+ // Placeholder ensures Docker creates a file-level bind-mount, not a directory.
+ K3sBuilderExtensions.EnsureKubeconfigPlaceholder(containerKubeconfigFile);
+
+ var (helmRegistry, helmImage, helmTag) = cluster.HelmImageInfo;
+
+ return builder.ApplicationBuilder
+ .AddResource(release)
+ .WithImage(helmImage, helmTag)
+ .WithImageRegistry(helmRegistry)
+ .WithEntrypoint("/bin/sh")
+
+ // The install script is injected as /helm-install.sh via WithContainerFiles.
+ // The callback fires when the container is being started (after WaitFor(cluster)
+ // is satisfied), so all WithHelmValue() calls have been made by then.
+ .WithContainerFiles("/", (ctx, ct) =>
+ {
+ var script = BuildHelmScript(release);
+ IEnumerable items = [new ContainerFile
+ {
+ Name = "helm-install.sh",
+ Contents = script,
+ Mode = K3sFileHelpers.ExecutableScriptMode,
+ }];
+ return Task.FromResult(items);
+ })
+ .WithArgs("/helm-install.sh")
+ // Inject host-side values files declared via WithHelmValuesFile.
+ // The callback fires at container-start time so all WithHelmValuesFile() calls
+ // have been made and ValuesFiles is fully populated.
+ .WithContainerFiles("/helm-values", (ctx, ct) =>
+ {
+ IEnumerable items = [.. release.ValuesFiles
+ .Select((hostPath, i) => (ContainerFileSystemItem)new ContainerFile
+ {
+ Name = $"{i}-{Path.GetFileName(hostPath)}",
+ SourcePath = hostPath,
+ })];
+ return Task.FromResult(items);
+ })
+ // File-level mount: only the kubeconfig YAML is visible inside the container.
+ // Mounting the full container/ directory would expose it to kubectl's cache
+ // (cache/, http-cache/) and cause concurrent-container cache corruption.
+ .WithBindMount(containerKubeconfigFile, K3sFileHelpers.ContainerKubeconfigPath, isReadOnly: true)
+ .WithEnvironment("KUBECONFIG", K3sFileHelpers.ContainerKubeconfigPath)
+ .WithIconName("Rocket")
+ .ExcludeFromManifest()
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "Helm Release",
+ State = KnownResourceStates.NotStarted,
+ Properties =
+ [
+ new ResourcePropertySnapshot("ReleaseName", name),
+ new ResourcePropertySnapshot("Chart", chart),
+ new ResourcePropertySnapshot("Namespace", @namespace),
+ new ResourcePropertySnapshot("Version", version ?? "latest"),
+ ],
+ });
+ }
+
+ ///
+ /// Supplies a YAML values file to the Helm release (--values).
+ ///
+ /// The Helm release resource builder.
+ ///
+ /// Path to the YAML values file on the host. Relative paths are resolved against the
+ /// AppHost project directory. Call this method multiple times to supply additional files;
+ /// they are applied in declaration order and later files win for duplicate keys.
+ ///
+ /// The same builder, for chaining.
+ ///
+ /// Use this method for structured overrides β particularly values containing commas,
+ /// braces, or backslashes that cannot be safely expressed with .
+ /// Values files are applied before --set flags, so
+ /// always takes precedence.
+ ///
+ /// is .
+ /// is or whitespace.
+ [AspireExport]
+ public static IResourceBuilder WithHelmValuesFile(
+ this IResourceBuilder builder,
+ string path)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ var absolutePath = System.IO.Path.IsPathRooted(path)
+ ? path
+ : System.IO.Path.GetFullPath(
+ System.IO.Path.Combine(
+ builder.ApplicationBuilder.AppHostDirectory, path));
+
+ builder.Resource.ValuesFiles.Add(absolutePath);
+ return builder;
+ }
+
+ ///
+ /// Adds a Helm --set key=value override to the release.
+ ///
+ /// The Helm release resource builder.
+ ///
+ /// The Helm value path using dot notation, e.g. server.service.type.
+ /// If the same key is set more than once, the last call wins.
+ ///
+ /// The value to assign.
+ /// The same builder, for chaining.
+ ///
+ /// Helm --set metacharacters (,, {, }, \) in
+ /// or are automatically escaped.
+ /// For values that contain these characters in ways Helm cannot represent safely,
+ /// use instead.
+ ///
+ ///
+ /// , , or is
+ /// .
+ ///
+ [AspireExport]
+ public static IResourceBuilder WithHelmValue(
+ this IResourceBuilder builder,
+ string key,
+ string value)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(key);
+ ArgumentNullException.ThrowIfNull(value);
+
+ builder.Resource.HelmValues[key] = value;
+ return builder;
+ }
+
+ // ββ Script generation βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ // Visible for testing.
+ internal static string BuildHelmScript(HelmReleaseResource release)
+ {
+ var sb = new StringBuilder("#!/bin/sh\nset -e\n");
+
+ // Poll until the kubeconfig exists AND the k3s API server is reachable.
+ // DCP sets up container network aliases asynchronously, so the kubeconfig file
+ // can appear in the bind-mount before the k8s hostname resolves in the helm
+ // container. Using `helm list` (which calls the k8s API) verifies both the
+ // file and the network path before proceeding.
+ sb.AppendLine($"until [ -f {K3sFileHelpers.ContainerKubeconfigPath} ] && helm list --kubeconfig {K3sFileHelpers.ContainerKubeconfigPath} > /dev/null 2>&1; do");
+ sb.AppendLine(" echo 'Waiting for k3s cluster to be ready and reachable...'");
+ sb.AppendLine(" sleep 5");
+ sb.AppendLine("done");
+
+ if (release.RepoUrl is not null)
+ {
+ var alias = $"aspire-k3s-{release.ReleaseName}";
+ sb.AppendLine($"helm repo add --force-update {ShellEscape(alias)} {ShellEscape(release.RepoUrl)}");
+ sb.AppendLine($"helm repo update {ShellEscape(alias)}");
+ }
+
+ var chartRef = release.RepoUrl is not null
+ ? $"aspire-k3s-{release.ReleaseName}/{release.Chart}"
+ : release.Chart!;
+
+ sb.Append($"helm upgrade --install {ShellEscape(release.ReleaseName)} {ShellEscape(chartRef)}");
+ sb.Append($" --namespace {ShellEscape(release.Namespace)} --create-namespace");
+ sb.Append(" --wait --timeout 10m");
+
+ if (release.Version is not null)
+ sb.Append($" --version {ShellEscape(release.Version)}");
+
+ // Values files: injected as {index}-{filename} to guarantee uniqueness and order.
+ // Applied first so --set flags below can override individual keys.
+ // Paths are single-quoted so spaces or special characters in filenames are safe.
+ for (var i = 0; i < release.ValuesFiles.Count; i++)
+ {
+ var filename = $"{i}-{System.IO.Path.GetFileName(release.ValuesFiles[i])}";
+ sb.Append($" --values {ShellEscape($"/helm-values/{filename}")}");
+ }
+
+ // --set flags override everything above (highest Helm precedence).
+ // Values are Helm-escaped then shell-escaped:
+ // 1. HelmEscape: escapes Helm's --set parser metacharacters (`,`, `{`, `}`, `\`)
+ // so Helm treats them as literals rather than array/map/list syntax.
+ // 2. ShellEscape: wraps in POSIX single quotes so the shell passes the value
+ // to Helm without any shell interpretation.
+ // For values containing commas, braces, or backslashes that Helm --set cannot
+ // represent safely (e.g. multi-line strings), use WithHelmValuesFile instead.
+ // Apply HelmEscape to BOTH key and value: Helm's --set parser splits on commas
+ // and treats braces/brackets as list/map syntax in both positions.
+ foreach (var (key, value) in release.HelmValues)
+ sb.Append($" --set {ShellEscape($"{HelmEscape(key)}={HelmEscape(value)}")}");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Escapes Helm --set value metacharacters so that Helm's own parser treats
+ /// them as literals rather than as array/map/list syntax delimiters.
+ ///
+ private static string HelmEscape(string value) =>
+ value.Replace("\\", "\\\\") // backslash first to avoid double-escaping
+ .Replace(",", "\\,") // comma separates multiple assignments
+ .Replace("{", "\\{") // brace opens a map/list literal
+ .Replace("}", "\\}");
+
+ ///
+ /// Wraps in POSIX single quotes so that all shell
+ /// metacharacters are treated as literals. Embedded single quotes are escaped
+ /// with the standard POSIX technique: ' β '\''.
+ ///
+ private static string ShellEscape(string value) =>
+ $"'{value.Replace("'", "'\\''")}'";
+}
+
+#pragma warning restore ASPIREATS001
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Manifest.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Manifest.cs
new file mode 100644
index 000000000..3b5a0a6d1
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Manifest.cs
@@ -0,0 +1,229 @@
+using System.Text;
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting;
+
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+
+namespace Aspire.Hosting;
+
+///
+/// Extension methods for applying Kubernetes YAML manifests to a k3s cluster.
+///
+public static class K3sManifestBuilderExtensions
+{
+ ///
+ /// Applies Kubernetes YAML manifests or a Kustomize overlay to the cluster.
+ ///
+ /// The k3s cluster resource builder.
+ /// The Aspire resource name for this manifest application.
+ ///
+ /// Path to a single .yaml/.yml file, a directory of YAML files, or a
+ /// Kustomize overlay directory (containing kustomization.yaml). Relative paths
+ /// are resolved against the AppHost project directory.
+ ///
+ /// A builder for the .
+ ///
+ ///
+ /// The apply mode is detected automatically from :
+ ///
+ /// - Single file β applied with kubectl apply -f.
+ /// - Directory (no kustomization.yaml) β all .yaml/.yml
+ /// files in the directory are applied with kubectl apply -f.
+ /// - Kustomize overlay (directory contains kustomization.yaml or
+ /// kustomization.yml) β applied with kubectl apply -k. The directory
+ /// is bind-mounted so that relative references to base manifests are preserved.
+ ///
+ ///
+ ///
+ /// The container exits with code 0 after all manifests are applied and any CRDs have
+ /// reached the Established condition. No host-side kubectl binary is required.
+ /// Use WaitForCompletion(manifest) on resources that must start only after the
+ /// manifests are applied.
+ ///
+ ///
+ ///
+ /// , , or is
+ /// .
+ ///
+ [AspireExport]
+ public static IResourceBuilder AddK8sManifest(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ string path)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(name);
+ ArgumentNullException.ThrowIfNull(path);
+
+ var cluster = builder.Resource;
+
+ var absolutePath = System.IO.Path.IsPathRooted(path)
+ ? path
+ : System.IO.Path.GetFullPath(
+ System.IO.Path.Combine(builder.ApplicationBuilder.AppHostDirectory, path));
+
+ bool isDirectory = Directory.Exists(absolutePath);
+ bool isKustomize = isDirectory && IsKustomizeDirectory(absolutePath);
+
+ var manifest = new K8sManifestResource(name, absolutePath, cluster);
+ cluster.AddManifest(manifest.Name);
+
+ var containerKubeconfigFile = Path.Combine(cluster.KubeconfigDirectory!, "container", "kubeconfig.yaml");
+ // Placeholder ensures Docker creates a file-level bind-mount, not a directory.
+ K3sBuilderExtensions.EnsureKubeconfigPlaceholder(containerKubeconfigFile);
+
+ var (kubectlRegistry, kubectlImage, kubectlTag) = cluster.KubectlImageInfo;
+
+ var resourceBuilder = builder.ApplicationBuilder
+ .AddResource(manifest)
+ .WithImage(kubectlImage, kubectlTag)
+ .WithImageRegistry(kubectlRegistry)
+ .WithEntrypoint("/bin/sh")
+ // Script is injected at "/kubectl-apply.sh". The script auto-detects whether
+ // /k8s-manifests contains a kustomization.yaml and uses -k or -f accordingly.
+ .WithContainerFiles("/", (ctx, ct) =>
+ {
+ IEnumerable items = [new ContainerFile
+ {
+ Name = "kubectl-apply.sh",
+ Contents = BuildManifestScript(),
+ Mode = K3sFileHelpers.ExecutableScriptMode,
+ }];
+ return Task.FromResult(items);
+ })
+ .WithArgs("/kubectl-apply.sh")
+ .WithBindMount(containerKubeconfigFile, K3sFileHelpers.ContainerKubeconfigPath, isReadOnly: true);
+
+ if (isKustomize)
+ {
+ // Bind-mount the overlay directory so kubectl kustomize can resolve relative
+ // references to base manifests (e.g. ../../base). WithContainerFiles copies
+ // files, not directory structure, so it would break cross-directory references.
+ // Read-write is intentional here: kustomize traverses into subdirectories.
+ // The kubectl/helm scripts never write to /k8s-manifests, so this is latent.
+ resourceBuilder.WithBindMount(absolutePath, "/k8s-manifests");
+ }
+ else
+ {
+ // Single file or regular directory β copy via async callback so the file(s)
+ // need not exist when the AppHost is built (only when the container starts).
+ // This mirrors WithContainerFiles(path, hostPath) semantics but without the
+ // build-time path validation that Aspire's string overload performs.
+ resourceBuilder.WithContainerFiles("/k8s-manifests", (ctx, ct) =>
+ {
+ IEnumerable items;
+
+ if (Directory.Exists(absolutePath))
+ {
+ var files = Directory
+ .GetFiles(absolutePath, "*.yaml", SearchOption.TopDirectoryOnly)
+ .Concat(Directory.GetFiles(absolutePath, "*.yml", SearchOption.TopDirectoryOnly))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Order(StringComparer.OrdinalIgnoreCase);
+
+ items = [.. files.Select(f => new ContainerFile
+ {
+ Name = Path.GetFileName(f),
+ SourcePath = f,
+ })];
+ }
+ else
+ {
+ items = [new ContainerFile
+ {
+ Name = Path.GetFileName(absolutePath),
+ SourcePath = absolutePath,
+ }];
+ }
+
+ return Task.FromResult(items);
+ });
+ }
+
+ return resourceBuilder
+ .WithEnvironment("KUBECONFIG", K3sFileHelpers.ContainerKubeconfigPath)
+ .WithIconName("Code")
+ .ExcludeFromManifest()
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = isKustomize ? "K8s Kustomize" : "K8s Manifest",
+ State = KnownResourceStates.NotStarted,
+ Properties =
+ [
+ new ResourcePropertySnapshot("Path", absolutePath),
+ new ResourcePropertySnapshot("Mode", isKustomize ? "kustomize" : "apply"),
+ ],
+ });
+ }
+
+ // ββ Script generation βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ internal static string BuildManifestScript()
+ {
+ var sb = new StringBuilder("#!/bin/sh\nset -e\n");
+
+ // Poll until the kubeconfig exists AND the k3s API server is reachable.
+ // DCP sets up container network aliases asynchronously, so the kubeconfig file
+ // can appear in the bind-mount before the k8s hostname resolves in the kubectl
+ // container. Using `kubectl cluster-info` verifies both the file and the network.
+ sb.AppendLine($"until [ -f {K3sFileHelpers.ContainerKubeconfigPath} ] && kubectl cluster-info --kubeconfig {K3sFileHelpers.ContainerKubeconfigPath} > /dev/null 2>&1; do");
+ sb.AppendLine(" echo 'Waiting for k3s cluster to be ready and reachable...'");
+ sb.AppendLine(" sleep 5");
+ sb.AppendLine("done");
+
+ // Auto-detect kustomize: if a kustomization file is present, use -k.
+ // Otherwise use -f with server-side apply.
+ // Capture output so we can extract any CRD names that were applied.
+ sb.AppendLine("if [ -f /k8s-manifests/kustomization.yaml ] || [ -f /k8s-manifests/kustomization.yml ]; then");
+ sb.AppendLine(" echo 'Detected kustomization β using kubectl apply -k'");
+ sb.AppendLine(" APPLIED=$(kubectl apply -k /k8s-manifests --server-side --field-manager=aspire-k3s --force-conflicts)");
+ sb.AppendLine("else");
+ sb.AppendLine(" APPLIED=$(kubectl apply -f /k8s-manifests --server-side --field-manager=aspire-k3s --force-conflicts)");
+ sb.AppendLine("fi");
+ sb.AppendLine("echo \"$APPLIED\"");
+
+ // Parse the apply output for CRD names β kubectl apply prints one line per resource
+ // in the form "/ ", e.g.:
+ // customresourcedefinition.apiextensions.k8s.io/widgets.example.com created
+ // Only lines starting with "customresourcedefinition." belong to this apply.
+ // This avoids touching pre-existing or concurrently installed cluster CRDs and
+ // prevents busybox xargs from returning exit code 123 when grep finds no match.
+ sb.AppendLine("CRD_NAMES=$(echo \"$APPLIED\" | grep '^customresourcedefinition\\.' | awk '{print $1}')");
+ sb.AppendLine("if [ -n \"$CRD_NAMES\" ]; then");
+ sb.AppendLine(" # shellcheck disable=SC2086");
+ sb.AppendLine(" kubectl wait --for=condition=Established $CRD_NAMES --timeout=300s");
+ sb.AppendLine("fi");
+
+ return sb.ToString();
+ }
+
+ // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private static bool IsKustomizeDirectory(string directory) =>
+ File.Exists(System.IO.Path.Combine(directory, "kustomization.yaml")) ||
+ File.Exists(System.IO.Path.Combine(directory, "kustomization.yml"));
+
+ // Exposed for unit tests.
+ internal static IReadOnlyList ResolveFilesForTest(string path)
+ {
+ if (Directory.Exists(path))
+ {
+ return [
+ ..Directory.GetFiles(path, "*.yaml", SearchOption.TopDirectoryOnly)
+ .Concat(Directory.GetFiles(path, "*.yml", SearchOption.TopDirectoryOnly))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Order(StringComparer.OrdinalIgnoreCase)
+ ];
+ }
+
+ var dir = System.IO.Path.GetDirectoryName(path) ?? ".";
+ var pattern = System.IO.Path.GetFileName(path);
+
+ if (pattern.Contains('*') || pattern.Contains('?'))
+ return [..Directory.GetFiles(dir, pattern).Order(StringComparer.OrdinalIgnoreCase)];
+
+ return [path];
+ }
+}
+
+#pragma warning restore ASPIREATS001
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.ServiceEndpoint.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.ServiceEndpoint.cs
new file mode 100644
index 000000000..d2f5bc55d
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.ServiceEndpoint.cs
@@ -0,0 +1,249 @@
+using System.Collections.Immutable;
+using System.Net;
+using System.Net.Sockets;
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting;
+using k8s;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+
+namespace Aspire.Hosting;
+
+///
+/// Extension methods for exposing Kubernetes services from a k3s cluster into the Aspire network.
+///
+public static class K3sServiceEndpointExtensions
+{
+ ///
+ /// Exposes a Kubernetes Service from the cluster as an Aspire endpoint resource.
+ ///
+ /// The k3s cluster resource builder.
+ /// The Aspire resource name for this endpoint.
+ ///
+ /// The name of the Kubernetes Service to forward, as it appears in kubectl get svc.
+ ///
+ ///
+ /// The port number declared on the Kubernetes Service (the port field, not
+ /// targetPort). Must be in the range 1β65535.
+ ///
+ ///
+ /// The Kubernetes namespace that contains the Service. Defaults to default.
+ ///
+ ///
+ /// The URL scheme (http or https) for the injected environment variable.
+ /// When (the default), the scheme is inferred from the port:
+ /// ports 443 and 8443 use https, all others use http.
+ ///
+ /// A builder for the .
+ ///
+ ///
+ /// An in-process WebSocket port-forward is started when the cluster becomes ready,
+ /// binding to 0.0.0.0:{allocatedHostPort} so both host processes and DCP-network
+ /// containers can reach the service.
+ ///
+ ///
+ /// The endpoint transitions to Running only after the target Kubernetes Service
+ /// has at least one ready pod. Sequence chart installs or manifest applies before this
+ /// resource using WaitForCompletion on or
+ /// to prevent the port-forward from polling indefinitely.
+ ///
+ ///
+ /// Use WithReference(endpoint) on a dependent resource builder to inject the
+ /// service URL as services__{name}__url. Host processes receive
+ /// http(s)://localhost:{port}; containers receive
+ /// http(s)://host.docker.internal:{port}.
+ ///
+ ///
+ ///
+ /// , , or is
+ /// .
+ ///
+ ///
+ /// or is empty or whitespace.
+ ///
+ ///
+ /// is not in the range 1β65535.
+ ///
+ [AspireExport]
+ public static IResourceBuilder AddServiceEndpoint(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ string serviceName,
+ int servicePort,
+ string @namespace = "default",
+ string? scheme = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(@namespace);
+
+ if (servicePort is < 1 or > 65535)
+ throw new ArgumentOutOfRangeException(nameof(servicePort),
+ servicePort, "Service port must be in the range 1β65535.");
+
+ // Infer scheme from the port when not explicitly provided.
+ // Callers should pass an explicit scheme whenever the Kubernetes service port
+ // does not reliably indicate the application protocol (e.g. HTTPS on port 80).
+ var resolvedScheme = scheme ?? (servicePort is 443 or 8443 ? "https" : "http");
+
+ var cluster = builder.Resource;
+ var endpoint = new K3sServiceEndpointResource(name, serviceName, servicePort, @namespace, cluster)
+ {
+ Scheme = resolvedScheme,
+ };
+
+ var healthCheckKey = $"k3s_endpoint_{name}_ready";
+ builder.ApplicationBuilder.Services.AddHealthChecks().Add(new HealthCheckRegistration(
+ healthCheckKey,
+ sp => new K3sServiceEndpointHealthCheck(endpoint),
+ failureStatus: HealthStatus.Unhealthy,
+ tags: null));
+
+ // Dispose the forwarder when the parent cluster stops so the port is released
+ // promptly rather than waiting for the application-lifetime CT to fire.
+ // ResourceStoppedEvent fires after the cluster container has stopped β at that
+ // point port-forwarding is no longer useful regardless.
+ builder.ApplicationBuilder.Eventing.Subscribe(
+ cluster,
+ async (@event, ct) =>
+ {
+ if (endpoint.Forwarder is { } forwarder)
+ await forwarder.DisposeAsync().ConfigureAwait(false);
+ });
+
+ return builder.ApplicationBuilder
+ .AddResource(endpoint)
+ .ExcludeFromManifest()
+ .WithHealthCheck(healthCheckKey)
+ .WithIconName("ArrowRouting")
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "K3s Service Endpoint",
+ State = KnownResourceStates.NotStarted,
+ Properties =
+ [
+ new ResourcePropertySnapshot("ServiceName", serviceName),
+ new ResourcePropertySnapshot("ServicePort", servicePort.ToString()),
+ new ResourcePropertySnapshot("Namespace", @namespace),
+ ],
+ });
+ }
+
+ // WithReference(K3sServiceEndpointResource) is no longer a custom extension.
+ // K3sServiceEndpointResource implements IResourceWithConnectionString, so the standard
+ // Aspire WithReference(IResourceBuilder) overload injects
+ // services__{name}__url=http://localhost:{port} for host processes automatically.
+ // The BeforeStartEvent subscriber in AddK3sCluster overrides the URL to
+ // http://host.docker.internal:{port} for containers and adds --add-host.
+
+ // ββ Lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ internal static async Task RunEndpointAsync(
+ K3sServiceEndpointResource endpoint,
+ K3sClusterResource cluster,
+ ResourceNotificationService notifications,
+ ILogger logger,
+ CancellationToken ct)
+ {
+ await notifications.PublishUpdateAsync(endpoint,
+ state => state with { State = KnownResourceStates.Starting })
+ .ConfigureAwait(false);
+
+ try
+ {
+ var kubeconfigPath = K3sBuilderExtensions.GetLocalKubeconfigPath(cluster);
+ if (kubeconfigPath is null || !File.Exists(kubeconfigPath))
+ {
+ throw new InvalidOperationException(
+ "k3s local kubeconfig is not yet available for service endpoint.");
+ }
+
+ var hostPort = AllocatePort();
+ endpoint.HostPort = hostPort;
+
+ var scheme = endpoint.Scheme;
+
+ var forwarder = new K3sInProcessPortForwarder(
+ kubeconfigPath,
+ endpoint.Namespace,
+ endpoint.ServiceName,
+ hostPort,
+ endpoint.ServicePort,
+ isReady =>
+ {
+ endpoint.IsReady = isReady;
+ var urls = isReady
+ ? BuildUrls(scheme, endpoint.Name, hostPort, cluster.Name)
+ : ImmutableArray.Empty;
+
+ _ = notifications.PublishUpdateAsync(endpoint, s => s with
+ {
+ State = isReady ? KnownResourceStates.Running : KnownResourceStates.RuntimeUnhealthy,
+ Urls = urls,
+ });
+ });
+
+ // Retain the forwarder so ResourceStoppedEvent on the cluster can dispose it
+ // independently of the application-lifetime cancellation token.
+ endpoint.Forwarder = forwarder;
+
+ _ = Task.Run(async () =>
+ {
+ await using (forwarder.ConfigureAwait(false))
+ {
+ await forwarder.RunAsync(logger, ct).ConfigureAwait(false);
+ }
+ }, ct);
+ }
+ catch (Exception ex) when (!ct.IsCancellationRequested)
+ {
+ logger.LogError(ex, "Service endpoint '{Name}' failed to start.", endpoint.Name);
+ await notifications.PublishUpdateAsync(endpoint,
+ state => state with { State = KnownResourceStates.FailedToStart })
+ .ConfigureAwait(false);
+ }
+ }
+
+ private static ImmutableArray BuildUrls(
+ string scheme, string endpointName, int hostPort, string clusterName)
+ => [
+ new UrlSnapshot(endpointName, $"{scheme}://localhost:{hostPort}", IsInternal: false),
+ new UrlSnapshot(
+ $"{endpointName} (container)",
+ $"{scheme}://host.docker.internal:{hostPort}",
+ IsInternal: true),
+ ];
+
+ private static int AllocatePort()
+ {
+ // Probe on IPAddress.Any to match the forwarder's actual bind address.
+ // Probing on Loopback while the forwarder binds on Any can miss conflicts
+ // on non-loopback interfaces, producing a SocketException at bind time.
+ using var listener = new TcpListener(IPAddress.Any, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}
+
+///
+/// Health check that satisfies WaitFor(serviceEndpoint).
+/// Returns once the port-forward has a confirmed
+/// connection to a ready pod.
+///
+internal sealed class K3sServiceEndpointHealthCheck(K3sServiceEndpointResource endpoint) : IHealthCheck
+{
+ public Task CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ => Task.FromResult(endpoint.IsReady
+ ? HealthCheckResult.Healthy("Port-forward is active")
+ : HealthCheckResult.Unhealthy("Port-forward not yet active"));
+}
+
+#pragma warning restore ASPIREATS001
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.cs
new file mode 100644
index 000000000..da69cc309
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.cs
@@ -0,0 +1,722 @@
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+#pragma warning disable ASPIRECERTIFICATES001 // WithHttpsDeveloperCertificate is experimental
+
+namespace Aspire.Hosting;
+
+///
+/// Provides extension methods for adding k3s cluster resources to an .
+///
+public static class K3sBuilderExtensions
+{
+ ///
+ /// Adds a k3s Kubernetes cluster to the distributed application.
+ ///
+ /// The distributed application builder.
+ ///
+ /// The resource name. Also used as the DNS hostname by which containers in the DCP network
+ /// reach the cluster's API server (e.g. https://{name}:6443).
+ ///
+ ///
+ /// Host port to bind the Kubernetes API server (port 6443) to.
+ /// When (the default) a random available port is assigned.
+ ///
+ ///
+ /// Number of k3s agent (worker) nodes to add. When (the default)
+ /// a single-node cluster is created β the server node acts as both control-plane and worker.
+ /// Equivalent to calling on the returned builder.
+ ///
+ /// A builder for the .
+ ///
+ ///
+ /// The cluster runs as a privileged container using the rancher/k3s image.
+ /// No host-side kubectl, helm, or k3s binaries are required.
+ ///
+ ///
+ /// Three kubeconfig variants are written to {AppHostDirectory}/.k3s/{name}/
+ /// when the cluster becomes ready:
+ ///
+ /// - local/kubeconfig.yaml β injected into host processes via KUBECONFIG.
+ /// - container/kubeconfig.yaml β bind-mounted into containers via KUBECONFIG.
+ ///
+ /// Call WithReference(cluster) on a dependent resource builder to inject
+ /// these credentials automatically.
+ ///
+ ///
+ /// All other cluster options are available as fluent builder methods:
+ /// , , ,
+ /// , ,
+ /// , , ,
+ /// , and .
+ ///
+ ///
+ ///
+ /// or is .
+ ///
+ [AspireExport]
+ public static IResourceBuilder AddK3sCluster(
+ this IDistributedApplicationBuilder builder,
+ [ResourceName] string name,
+ int? apiServerPort = null,
+ int? agentCount = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(name);
+
+ var resource = new K3sClusterResource(name)
+ {
+ HelmImageInfo = (HelmContainerImageTags.Registry, HelmContainerImageTags.Image, HelmContainerImageTags.Tag),
+ KubectlImageInfo = (KubectlContainerImageTags.Registry, KubectlContainerImageTags.Image, KubectlContainerImageTags.Tag),
+ };
+ var tag = K3sContainerImageTags.Tag;
+
+ // ββ Kubeconfig directory on the host ββββββββββββββββββββββββββββββββββ
+ // AppHostDirectory/.k3s/{name}/ holds three sub-directories:
+ // cluster/ β bind-mounted into the k3s container; k3s writes kubeconfig.yaml here
+ // local/ β rewritten by the health check with server: https://localhost:{port}
+ // container/ β rewritten by the health check with server: https://{name}:6443
+ var kubeconfigDir = Path.Combine(builder.AppHostDirectory, ".k3s", name);
+ var clusterDir = Path.Combine(kubeconfigDir, "cluster");
+ Directory.CreateDirectory(clusterDir);
+
+ // Pre-create placeholder files for all bind-mount sources. Docker creates a
+ // DIRECTORY at the source path when the file does not yet exist, which then
+ // prevents the health check from writing the real kubeconfig atomically.
+ // The health check overwrites these placeholders once the cluster is ready.
+ EnsureKubeconfigPlaceholder(Path.Combine(kubeconfigDir, "container", "kubeconfig.yaml"));
+ EnsureKubeconfigPlaceholder(Path.Combine(kubeconfigDir, "local", "kubeconfig.yaml"));
+
+ resource.KubeconfigDirectory = kubeconfigDir;
+
+ var resourceBuilder = builder.AddResource(resource)
+ .WithImage(K3sContainerImageTags.Image, tag)
+ .WithImageRegistry(K3sContainerImageTags.Registry)
+
+ // ββ k3d-style init entrypoint βββββββββββββββββββββββββββββββββββββ
+ // Runs mount --make-rshared / and the cgroupsv2 evacuation fix before
+ // k3s starts β exactly what k3d does via /bin/k3d-entrypoint*.sh.
+ // WithContainerFiles injects the script via `docker cp` β no bind mounts,
+ // no host-side temp files, works on all platforms.
+ .WithContainerFiles("/", [new ContainerFile
+ {
+ Name = "aspire-k3s-entrypoint.sh",
+ Contents = K3sInitEntrypointScript,
+ Mode = K3sFileHelpers.ExecutableScriptMode,
+ }])
+ .WithEntrypoint("/bin/sh")
+ .WithArgs("/aspire-k3s-entrypoint.sh")
+
+ // ββ k3s server command ββββββββββββββββββββββββββββββββββββββββββββ
+ .WithArgs("server")
+
+ // NOTE: --cluster-init (embedded etcd) is intentionally NOT used.
+ // etcd stores the container's IP in its peer URL. When Docker assigns a new IP
+ // on container restart, etcd refuses to start ("not a member of the etcd cluster").
+ // k3s uses SQLite by default for single-server clusters β it handles restarts and
+ // IP changes gracefully. k3d only adds --cluster-init for HA multi-server setups.
+
+ // Add TLS SANs so the API server certificate is valid for all addresses
+ // that clients use to reach it: host-side (localhost), DCP-network containers
+ // ({resourceName}), and any forwarded address (0.0.0.0).
+ .WithArgs($"--tls-san=127.0.0.1")
+ .WithArgs($"--tls-san=localhost")
+ .WithArgs($"--tls-san={name}")
+ .WithArgs("--tls-san=0.0.0.0")
+
+ // Disable components not needed for local development.
+ // servicelb and metrics-server are the biggest resource consumers and slowest
+ // to start; disabling them speeds up cluster readiness significantly.
+ .WithArgs("--disable=servicelb")
+ .WithArgs("--disable=metrics-server")
+
+ // Reduce log verbosity β k3s writes everything to stderr which DCP surfaces
+ // as "error" level entries in the dashboard log viewer.
+ .WithArgs("-v", "0")
+ .WithArgs("--kube-apiserver-arg=v=0")
+ .WithArgs("--kube-controller-manager-arg=v=0")
+ .WithArgs("--kube-scheduler-arg=v=0")
+ // Suppress kubelet INFO-level noise including the harmless cgroupsv2 race warning.
+ .WithArgs("--kubelet-arg=v=0")
+
+ // ββ API server endpoint βββββββββββββββββββββββββββββββββββββββββββ
+ // Declared as HTTPS so the allocated host port appears in the Aspire dashboard
+ // with the correct scheme. Aspire's HTTP proxy does not intercept raw TLS TCP
+ // connections, so Kubernetes clients validate the k3s server CA cert directly
+ // without any proxy interference.
+ .WithHttpsEndpoint(
+ targetPort: 6443,
+ port: apiServerPort,
+ name: K3sClusterResource.ApiServerEndpointName)
+
+ // ββ Docker / container runtime flags (mirrors k3d) ββββββββββββββββ
+ .WithContainerRuntimeArgs("--privileged")
+ .WithContainerRuntimeArgs("--init")
+ .WithContainerRuntimeArgs("--userns=host")
+ .WithContainerRuntimeArgs("--cgroupns=host")
+ .WithContainerRuntimeArgs("--volume=/sys/fs/cgroup:/sys/fs/cgroup:rw")
+ .WithContainerRuntimeArgs("--tmpfs=/run", "--tmpfs=/var/run")
+
+ // ββ Kubeconfig bind-mount βββββββββββββββββββββββββββββββββββββββββ
+ // Mounts AppHostDirectory/.k3s/{name}/cluster/ into the container so k3s
+ // writes its kubeconfig into a host-accessible directory.
+ // K3S_KUBECONFIG_OUTPUT tells k3s where to write the kubeconfig file.
+ // The health check polls File.Exists on the host side β no docker exec needed.
+ .WithBindMount(clusterDir, "/tmp/k3s-kubeconfig")
+
+ // ββ Environment βββββββββββββββββββββββββββββββββββββββββββββββββββ
+ .WithEnvironment("K3S_TOKEN", $"aspire-k3s-{name}-token")
+ .WithEnvironment("K3S_KUBECONFIG_MODE", "644")
+ .WithEnvironment("K3S_KUBECONFIG_OUTPUT", "/tmp/k3s-kubeconfig/kubeconfig.yaml")
+
+ .WithIconName("Kubernetes");
+
+ // Create agent nodes if agentCount was supplied directly to AddK3sCluster.
+ if (agentCount is > 0)
+ AddAgentNodes(resourceBuilder, agentCount.Value, tag);
+
+ resourceBuilder.WithHealthCheck($"k3s_{name}_ready");
+
+ // Register as a singleton instance so the cached Kubernetes client and kubeconfig
+ // state survive across health-check ticks. Using a factory (sp => new ...) would
+ // create a fresh instance on every check, making _cachedClient dead state and
+ // leaking a Kubernetes/HttpClient on every tick.
+ var healthCheck = new K3sReadinessHealthCheck(resource);
+ builder.Services.AddHealthChecks().Add(new HealthCheckRegistration(
+ $"k3s_{name}_ready",
+ _ => healthCheck,
+ failureStatus: HealthStatus.Unhealthy,
+ tags: null));
+
+ // BeforeStartEvent: apply KUBECONFIG and service-URL injections declared via
+ // WithReference. The cluster owns this behavior β it knows what to inject and when.
+ // Processing is deferred to BeforeStartEvent so that resources can be wired up in
+ // any order in Program.cs without worrying about whether the cluster is configured yet.
+ builder.Eventing.Subscribe((evt, ct) =>
+ {
+ var appModel = evt.Services.GetRequiredService();
+
+ foreach (var dependent in appModel.Resources)
+ {
+ // ββ Container KUBECONFIG override βββββββββββββββββββββββββββββ
+ // Standard WithReference(cluster) already injected KUBECONFIG=
+ // for all resource types (via IResourceWithConnectionString). For containers
+ // we additionally need: a file-level bind-mount of the container-network
+ // kubeconfig variant, plus an env override that points to it.
+ //
+ // Detect via the ResourceRelationshipAnnotation that standard WithReference
+ // adds when called with our cluster (which implements IResourceWithConnectionString).
+ bool referencesThisCluster = dependent.Annotations
+ .OfType()
+ .Any(a => ReferenceEquals(a.Resource, resource));
+
+ if (referencesThisCluster && dependent is ContainerResource)
+ {
+ ApplyKubeconfigContainerOverride(dependent, resource);
+ }
+
+ // ββ Service-URL container override ββββββββββββββββββββββββββββ
+ // K3sServiceEndpointResource implements IResourceWithConnectionString, so
+ // standard WithReference injects services__ep__url=http://localhost:PORT for
+ // all resource types. For containers the URL must use host.docker.internal.
+ // Detect via the ResourceRelationshipAnnotation that standard WithReference
+ // adds when called with our endpoint (which implements IResourceWithConnectionString).
+ var endpointRefs = dependent.Annotations
+ .OfType()
+ .Select(a => a.Resource)
+ .OfType()
+ .Where(ep => ReferenceEquals(ep.Parent, resource))
+ .ToList();
+
+ if (endpointRefs.Count > 0 && dependent is ContainerResource)
+ {
+ // --add-host is needed once per container regardless of how many
+ // endpoints are referenced.
+ dependent.Annotations.Add(
+ new ContainerRuntimeArgsCallbackAnnotation(
+ args => args.Add("--add-host=host.docker.internal:host-gateway")));
+ }
+
+ foreach (var ep in endpointRefs)
+ ApplyServiceUrlContainerOverride(dependent, ep);
+ }
+
+ return Task.CompletedTask;
+ });
+
+ // The cluster's ResourceReadyEvent drives service endpoint port-forwards.
+ // HelmReleaseResource and K8sManifestResource containers are managed directly
+ // by DCP β they WaitFor the cluster and exit when their work completes.
+ builder.Eventing.Subscribe(resource, (@event, ct) =>
+ {
+ var appModel = @event.Services.GetRequiredService();
+ var notifications = @event.Services.GetRequiredService();
+ var loggerService = @event.Services.GetRequiredService();
+
+ // Start all service endpoint forwarders concurrently.
+ foreach (var ep in appModel.Resources
+ .OfType()
+ .Where(e => ReferenceEquals(e.Parent, resource)))
+ {
+ var logger = loggerService.GetLogger(ep);
+ _ = Task.Run(() => K3sServiceEndpointExtensions.RunEndpointAsync(
+ ep, resource, notifications, logger, ct), ct);
+ }
+
+ return Task.CompletedTask;
+ });
+
+ return resourceBuilder;
+ }
+
+ ///
+ /// Sets the k3s image version used by the cluster server and all its agent nodes.
+ ///
+ /// The k3s cluster resource builder.
+ ///
+ /// The k3s container image tag, e.g. v1.32.3-k3s1.
+ /// Must follow the v{major}.{minor}.{patch}-k3s{n} format.
+ ///
+ /// The same builder, for chaining.
+ ///
+ /// All agent nodes are immediately synced to the same tag to prevent version skew
+ /// beyond the Kubernetes-supported Β±1 minor version limit. The image tag is part of
+ /// the DCP container identity, so synchronisation must happen at configuration time.
+ ///
+ /// is .
+ /// is or whitespace.
+ [AspireExport]
+ public static IResourceBuilder WithK3sVersion(
+ this IResourceBuilder builder,
+ string tag)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(tag);
+
+ builder.WithImageTag(tag);
+
+ // Sync agents immediately β DCP uses the ContainerImageAnnotation to compute
+ // container identity, so the tag must be set at configuration time, not deferred
+ // to BeforeStartEvent (which fires after DCP has already determined the identity).
+ foreach (var agent in builder.Resource.AgentResources)
+ {
+ var existing = agent.Annotations.OfType().FirstOrDefault();
+ if (existing is not null)
+ {
+ agent.Annotations.Remove(existing);
+ agent.Annotations.Add(new ContainerImageAnnotation
+ {
+ Image = existing.Image,
+ Tag = tag,
+ Registry = existing.Registry,
+ });
+ }
+ }
+
+ return builder;
+ }
+
+ ///
+ /// Sets the CIDR range for pod IP addresses (--cluster-cidr).
+ ///
+ /// The k3s cluster resource builder.
+ /// The pod subnet in CIDR notation, e.g. 10.42.0.0/16.
+ /// The same builder, for chaining.
+ /// is .
+ /// is or whitespace.
+ [AspireExport]
+ public static IResourceBuilder WithPodSubnet(
+ this IResourceBuilder builder,
+ string cidr)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(cidr);
+
+ return builder.WithArgs($"--cluster-cidr={cidr}");
+ }
+
+ ///
+ /// Sets the CIDR range for Service cluster IPs (--service-cidr).
+ ///
+ /// The k3s cluster resource builder.
+ /// The service subnet in CIDR notation, e.g. 10.43.0.0/16.
+ /// The same builder, for chaining.
+ /// is .
+ /// is or whitespace.
+ [AspireExport]
+ public static IResourceBuilder WithServiceSubnet(
+ this IResourceBuilder builder,
+ string cidr)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(cidr);
+
+ return builder.WithArgs($"--service-cidr={cidr}");
+ }
+
+ ///
+ /// Disables a built-in k3s component (--disable=<component>).
+ ///
+ /// The k3s cluster resource builder.
+ ///
+ /// The component name to disable. Common values include traefik,
+ /// servicelb, metrics-server, coredns, and local-storage.
+ /// Call this method multiple times to disable more than one component.
+ ///
+ /// The same builder, for chaining.
+ /// is .
+ /// is or whitespace.
+ [AspireExport]
+ public static IResourceBuilder WithDisabledComponent(
+ this IResourceBuilder builder,
+ string component)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(component);
+
+ return builder.WithArgs($"--disable={component}");
+ }
+
+ ///
+ /// Appends a raw argument to the k3s server command line.
+ ///
+ /// The k3s cluster resource builder.
+ ///
+ /// The raw argument to append, e.g. --write-kubeconfig-mode=644.
+ /// Call this method multiple times to append additional arguments.
+ ///
+ /// The same builder, for chaining.
+ ///
+ /// Use or the dedicated CIDR methods when possible.
+ /// This method is intended for flags that have no dedicated helper.
+ ///
+ /// is .
+ /// is or whitespace.
+ [AspireExport]
+ public static IResourceBuilder WithExtraArg(
+ this IResourceBuilder builder,
+ string arg)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(arg);
+
+ return builder.WithArgs(arg);
+ }
+
+ ///
+ /// Mounts a named Docker volume at the k3s data directory so cluster state persists across
+ /// AppHost restarts.
+ ///
+ /// The k3s cluster resource builder.
+ ///
+ /// Optional volume name. When (the default) a name is generated
+ /// from the application and resource names.
+ ///
+ /// The same builder, for chaining.
+ ///
+ /// The volume covers /var/lib/rancher/k3s, which contains the SQLite database,
+ /// TLS certificates, and kubeconfig. Without this volume the cluster starts fresh on
+ /// every AppHost launch. Combine with ContainerLifetime.Persistent on the cluster
+ /// resource and its dependent Helm releases to avoid re-installing charts on every start.
+ ///
+ /// is .
+ [AspireExport]
+ public static IResourceBuilder WithDataVolume(
+ this IResourceBuilder builder,
+ string? name = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ // Idempotent: remove any existing data-volume annotation at this target so that
+ // calling WithDataVolume twice does not produce duplicate mounts that Docker rejects.
+ var existing = builder.Resource.Annotations
+ .OfType()
+ .FirstOrDefault(m => m.Target == "/var/lib/rancher/k3s");
+ if (existing is not null)
+ builder.Resource.Annotations.Remove(existing);
+
+ return builder
+ .WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), "/var/lib/rancher/k3s");
+ }
+
+ ///
+ /// Sets the number of k3s agent (worker) nodes to add to the cluster.
+ ///
+ /// The k3s cluster resource builder.
+ ///
+ /// The number of agent nodes. Zero or greater. Defaults to 0 (single-node cluster β
+ /// the server node acts as both control-plane and worker).
+ ///
+ /// The same builder, for chaining.
+ ///
+ /// Agent nodes connect to the server via DCP DNS (https://{name}:6443) and use
+ /// k3s's built-in retry loop, so no explicit WaitFor is needed. The cluster health
+ /// check waits for 1 + count nodes to reach Ready state before reporting
+ /// healthy. Use with
+ /// to keep agents alive across AppHost restarts and avoid node password hash mismatches.
+ ///
+ /// is .
+ /// is negative.
+ [AspireExport]
+ public static IResourceBuilder WithAgentCount(
+ this IResourceBuilder builder,
+ int count)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentOutOfRangeException.ThrowIfNegative(count, nameof(count));
+
+ if (count == 0) return builder;
+
+ // Use the tag already set on the cluster (either the default or from WithK3sVersion).
+ var tag = builder.Resource.Annotations
+ .OfType()
+ .FirstOrDefault()?.Tag ?? K3sContainerImageTags.Tag;
+
+ AddAgentNodes(builder, count, tag);
+ return builder;
+ }
+
+ ///
+ /// Overrides the container image used to run helm upgrade --install for
+ /// all children of this cluster.
+ ///
+ /// The k3s cluster resource builder.
+ /// Image tag, e.g. 3.18.0. keeps the current value.
+ /// Image name, e.g. alpine/helm. keeps the current value.
+ /// Registry, e.g. docker.io. keeps the current value.
+ /// The same builder, for chaining.
+ /// is .
+ [AspireExport]
+ public static IResourceBuilder WithHelmImage(
+ this IResourceBuilder builder,
+ string? tag = null,
+ string? image = null,
+ string? registry = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var (r, i, t) = builder.Resource.HelmImageInfo;
+ builder.Resource.HelmImageInfo = (registry ?? r, image ?? i, tag ?? t);
+ return builder;
+ }
+
+ ///
+ /// Overrides the container image used to run kubectl apply for all
+ /// children of this cluster.
+ ///
+ /// The k3s cluster resource builder.
+ /// Image tag, e.g. 1.37.0. keeps the current value.
+ /// Image name, e.g. alpine/kubectl. keeps the current value.
+ /// Registry, e.g. docker.io. keeps the current value.
+ /// The same builder, for chaining.
+ /// is .
+ [AspireExport]
+ public static IResourceBuilder WithKubectlImage(
+ this IResourceBuilder builder,
+ string? tag = null,
+ string? image = null,
+ string? registry = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var (r, i, t) = builder.Resource.KubectlImageInfo;
+ builder.Resource.KubectlImageInfo = (registry ?? r, image ?? i, tag ?? t);
+ return builder;
+ }
+
+ ///
+ /// Sets the container lifetime for the k3s cluster and all its agent nodes.
+ ///
+ /// The k3s cluster resource builder.
+ /// The container lifetime to apply.
+ /// The same builder, for chaining.
+ ///
+ /// Agent nodes are propagated immediately because DCP uses
+ /// to compute container identity. Deferring
+ /// propagation to BeforeStartEvent would be too late β DCP determines whether
+ /// to reuse or recreate a persistent container before that event fires, so agents
+ /// would lose their persistent identity and be recreated as new containers each run.
+ ///
+ /// is .
+ [AspireExport]
+ public static IResourceBuilder WithLifetime(
+ this IResourceBuilder builder,
+ ContainerLifetime lifetime)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.WithAnnotation(
+ new ContainerLifetimeAnnotation { Lifetime = lifetime },
+ ResourceAnnotationMutationBehavior.Replace);
+
+ foreach (var agent in builder.Resource.AgentResources)
+ {
+ foreach (var ann in agent.Annotations.OfType().ToList())
+ agent.Annotations.Remove(ann);
+ agent.Annotations.Add(new ContainerLifetimeAnnotation { Lifetime = lifetime });
+ }
+
+ return builder;
+ }
+
+ ///
+ /// Returns the path to the local/kubeconfig.yaml file for this cluster,
+ /// used by helm and manifest runners that invoke host-side tools.
+ /// Returns if the cluster directory is not yet configured.
+ ///
+ internal static string? GetLocalKubeconfigPath(K3sClusterResource cluster) =>
+ cluster.KubeconfigDirectory is null
+ ? null
+ : Path.Combine(cluster.KubeconfigDirectory, "local", "kubeconfig.yaml");
+
+ // cgroupsv2 fix adapted from moby/moby (Apache-2.0, used with permission by k3d).
+ // See: https://github.com/k3d-io/k3d/blob/main/pkg/types/fixes/assets/k3d-entrypoint-cgroupv2.sh
+ private const string K3sInitEntrypointScript = """
+ #!/bin/sh
+ # Aspire k3s init entrypoint β adapted from k3d (https://github.com/k3d-io/k3d)
+ # cgroupsv2 fix adapted from moby/moby (Apache-2.0), used with permission.
+
+ # Make mountpoints recursively shared β required for volume propagation in Docker-in-Docker.
+ mount --make-rshared / 2>/dev/null || true
+
+ # cgroupsv2: evacuate root cgroup so k3s kubelet can create pod sub-cgroups.
+ # Without this, writing to cgroup.subtree_control fails with EBUSY because
+ # init processes still live in the root cgroup.
+ if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
+ mkdir -p /sys/fs/cgroup/init
+ if command -v xargs >/dev/null 2>&1; then
+ xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null || true
+ else
+ busybox xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null || true
+ fi
+ sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \
+ > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
+ fi
+
+ exec k3s "$@"
+ """;
+
+ // ββ Agent node creation βββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ // Shared by AddK3sCluster (via options.AgentCount) and WithAgentCount.
+ // Agents use DCP DNS (K3S_URL=https://{name}:6443) and retry until the server is
+ // reachable β no WaitFor to avoid a deadlock where the cluster health check waits
+ // for nodes to be Ready while nodes wait for the cluster to be healthy.
+ private static void AddAgentNodes(
+ IResourceBuilder clusterBuilder,
+ int count,
+ string tag)
+ {
+ var resource = clusterBuilder.Resource;
+ var name = resource.Name;
+ var startIndex = resource.AgentCount;
+
+ for (var i = startIndex; i < startIndex + count; i++)
+ {
+ resource.AgentCount++;
+ var agentName = $"{name}-agent-{i}";
+ var agentResource = new K3sAgentResource(agentName, resource);
+ resource.AddAgentResource(agentResource);
+
+ clusterBuilder.ApplicationBuilder.AddResource(agentResource)
+ .WithImage(K3sContainerImageTags.Image, tag)
+ .WithImageRegistry(K3sContainerImageTags.Registry)
+ .WithContainerFiles("/", [new ContainerFile
+ {
+ Name = "aspire-k3s-entrypoint.sh",
+ Contents = K3sInitEntrypointScript,
+ Mode = K3sFileHelpers.ExecutableScriptMode,
+ }])
+ .WithEntrypoint("/bin/sh")
+ .WithArgs("/aspire-k3s-entrypoint.sh")
+ .WithArgs("agent")
+ .WithArgs("-v", "0")
+ .WithArgs("--kubelet-arg=v=0")
+ .WithEnvironment("K3S_URL", $"https://{name}:6443")
+ .WithEnvironment("K3S_TOKEN", $"aspire-k3s-{name}-token")
+ .WithEnvironment("K3S_NODE_NAME", agentName)
+ .WithContainerRuntimeArgs("--privileged")
+ .WithContainerRuntimeArgs("--init")
+ .WithContainerRuntimeArgs("--userns=host")
+ .WithContainerRuntimeArgs("--cgroupns=host")
+ .WithContainerRuntimeArgs("--volume=/sys/fs/cgroup:/sys/fs/cgroup:rw")
+ .WithContainerRuntimeArgs("--tmpfs=/run", "--tmpfs=/var/run")
+ .ExcludeFromManifest()
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "K3s Agent",
+ State = KnownResourceStates.Starting,
+ Properties = [new ResourcePropertySnapshot("Cluster", name)],
+ });
+ }
+ }
+
+ // ββ BeforeStartEvent helpers ββββββββββββββββββββββββββββββββββββββββββββββ
+
+ // Called only for containers β standard WithReference(IResourceWithConnectionString) already
+ // handles host processes by injecting KUBECONFIG= via GetConnectionStringAsync().
+ internal static void ApplyKubeconfigContainerOverride(IResource dependent, K3sClusterResource cluster)
+ {
+ // File-level bind-mount: only the kubeconfig YAML is visible inside the container.
+ // Mounting the full container/ dir would expose kubectl's cache dirs and cause
+ // concurrent-container cache corruption when multiple containers share the directory.
+ var alreadyMounted = dependent.Annotations
+ .OfType()
+ .Any(m => m.Target == K3sFileHelpers.ContainerKubeconfigPath);
+
+ if (!alreadyMounted)
+ {
+ var containerKubeconfigFile = Path.Combine(
+ cluster.KubeconfigDirectory!, "container", "kubeconfig.yaml");
+ // Placeholder ensures Docker creates a file-level mount, not a directory.
+ // AddK3sCluster already creates this; the guard handles late callers.
+ EnsureKubeconfigPlaceholder(containerKubeconfigFile);
+
+ dependent.Annotations.Add(new ContainerMountAnnotation(
+ containerKubeconfigFile,
+ K3sFileHelpers.ContainerKubeconfigPath,
+ ContainerMountType.BindMount,
+ isReadOnly: true));
+ }
+
+ // Override the KUBECONFIG env var that standard WithReference already set to the
+ // local path β containers need the container-network variant mounted at a fixed path.
+ // This callback runs after the standard one (added later in BeforeStartEvent), so last
+ // write wins in the environment variable dictionary.
+ dependent.Annotations.Add(new EnvironmentCallbackAnnotation(
+ ctx => ctx.EnvironmentVariables["KUBECONFIG"] = K3sFileHelpers.ContainerKubeconfigPath));
+ }
+
+ // Creates an empty placeholder file so Docker's bind-mount sees a file at the source
+ // path rather than creating a directory there. If a directory already exists from a
+ // previous bad run it is removed first. The health check overwrites the placeholder
+ // with the real kubeconfig content once the cluster is ready.
+ internal static void EnsureKubeconfigPlaceholder(string filePath)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
+ if (Directory.Exists(filePath))
+ Directory.Delete(filePath, recursive: true);
+ if (!File.Exists(filePath))
+ File.WriteAllText(filePath, string.Empty);
+ }
+
+ // Called only for containers. Standard WithReference(IResourceWithConnectionString)
+ // already injected services__ep__url=http://localhost:PORT for host processes via
+ // GetConnectionStringAsync(). This override switches the URL to host.docker.internal
+ // so DCP-network containers can reach the port-forward listener.
+ internal static void ApplyServiceUrlContainerOverride(IResource dependent, K3sServiceEndpointResource ep)
+ {
+ var scheme = ep.Scheme;
+ var envKey = $"services__{ep.Name}__url";
+
+ dependent.Annotations.Add(new EnvironmentCallbackAnnotation(ctx =>
+ {
+ if (ep.IsReady)
+ ctx.EnvironmentVariables[envKey] = $"{scheme}://host.docker.internal:{ep.HostPort}";
+ }));
+ }
+}
+
+#pragma warning restore ASPIREATS001
+#pragma warning restore ASPIRECERTIFICATES001
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sClusterOptions.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sClusterOptions.cs
new file mode 100644
index 000000000..392a9d49b
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sClusterOptions.cs
@@ -0,0 +1,86 @@
+using Aspire.Hosting;
+
+namespace CommunityToolkit.Aspire.Hosting;
+
+///
+/// Advanced options for .
+///
+///
+///
+/// Most settings are exposed directly as fluent builder methods β prefer those for
+/// discoverability and TypeScript polyglot compatibility:
+///
+///
+/// - β number of worker nodes
+/// - β k3s image tag
+/// - β pod CIDR (--cluster-cidr)
+/// - β service CIDR (--service-cidr)
+/// - β disable built-in components
+/// - β raw k3s server arguments
+/// - β Helm installer image
+/// - β kubectl image
+/// - β persistent data volume
+/// - β container lifetime
+///
+///
+public sealed class K3sClusterOptions
+{
+ ///
+ /// Gets or sets the pod IP address range in CIDR notation (--cluster-cidr).
+ /// Defaults to k3s built-in value of 10.42.0.0/16 when .
+ ///
+ public string? ClusterCidr { get; set; }
+
+ ///
+ /// Gets or sets the Service cluster IP address range in CIDR notation (--service-cidr).
+ /// Defaults to k3s built-in value of 10.43.0.0/16 when .
+ ///
+ public string? ServiceCidr { get; set; }
+
+ ///
+ /// Gets the list of built-in k3s components to disable (each passed as --disable=<component>).
+ ///
+ ///
+ /// Common values: traefik, coredns, local-storage.
+ /// Note that servicelb and metrics-server are already disabled by default.
+ ///
+ public IList DisabledComponents { get; } = new List();
+
+ ///
+ /// Gets the list of raw arguments appended verbatim to the k3s server command.
+ ///
+ public IList ExtraArgs { get; } = new List();
+
+ ///
+ /// Gets or sets the number of agent (worker) nodes. Defaults to 0 (single-node cluster).
+ ///
+ public int AgentCount { get; set; }
+
+ ///
+ /// Gets or sets the k3s container image tag, e.g. v1.32.3-k3s1.
+ /// When the version bundled with this package is used.
+ ///
+ public string? ImageTag { get; set; }
+
+ // ββ Helm installer image ββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /// Gets or sets the container registry for the Helm installer image. Defaults to docker.io.
+ public string HelmRegistry { get; set; } = HelmContainerImageTags.Registry;
+
+ /// Gets or sets the Helm installer image name. Defaults to alpine/helm.
+ public string HelmImage { get; set; } = HelmContainerImageTags.Image;
+
+ /// Gets or sets the Helm installer image tag. Defaults to 3.17.3.
+ public string HelmTag { get; set; } = HelmContainerImageTags.Tag;
+
+ // ββ kubectl image βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /// Gets or sets the container registry for the kubectl image. Defaults to docker.io.
+ public string KubectlRegistry { get; set; } = KubectlContainerImageTags.Registry;
+
+ /// Gets or sets the kubectl image name. Defaults to alpine/kubectl.
+ public string KubectlImage { get; set; } = KubectlContainerImageTags.Image;
+
+ /// Gets or sets the kubectl image tag. Defaults to 1.36.0.
+ public string KubectlTag { get; set; } = KubectlContainerImageTags.Tag;
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sClusterResource.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sClusterResource.cs
new file mode 100644
index 000000000..4320b183f
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sClusterResource.cs
@@ -0,0 +1,101 @@
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a k3s Kubernetes cluster running as a privileged container resource.
+/// Injects the cluster's kubeconfig into a dependent resource.
+///
+/// - Host processes receive KUBECONFIG=<host-path>/local/kubeconfig.yaml.
+/// - Containers receive KUBECONFIG=/tmp/k3s-kubeconfig.yaml and a file-level
+/// bind-mount of the container-network kubeconfig variant. Both are applied by the
+/// BeforeStartEvent subscriber registered in AddK3sCluster.
+///
+///
+///
+/// The resource name.
+[AspireExport(ExposeProperties = true)]
+public sealed class K3sClusterResource(string name)
+ : ContainerResource(name), IResourceWithConnectionString
+{
+ internal const string ApiServerEndpointName = "api";
+
+ /// Container image settings for the Helm installer, resolved from cluster options.
+ internal (string Registry, string Image, string Tag) HelmImageInfo { get; set; }
+ = ("docker.io", "alpine/helm", "3.17.3");
+
+ /// Container image settings for the kubectl manifest applier, resolved from cluster options.
+ internal (string Registry, string Image, string Tag) KubectlImageInfo { get; set; }
+ = ("docker.io", "alpine/kubectl", "1.36.0");
+
+ ///
+ /// Host-side directory that holds all kubeconfig variants for this cluster.
+ /// Set by AddK3sCluster to AppHostDirectory/.k3s/{name}/.
+ /// Sub-directories:
+ ///
+ /// - cluster/kubeconfig.yaml β raw file written by k3s (bind-mounted)
+ /// - local/kubeconfig.yaml β server: https://localhost:{port} (host processes)
+ /// - container/kubeconfig.yaml β server: https://{name}:6443 (DCP-network containers)
+ ///
+ ///
+ internal string? KubeconfigDirectory { get; set; }
+
+ // ββ IResourceWithConnectionString βββββββββββββββββββββββββββββββββββββββββ
+ // Exposes the kubeconfig path as the connection string so the standard
+ // WithReference(cluster) overload injects KUBECONFIG for host processes.
+ // Containers get a bind-mount + container-network path via BeforeStartEvent.
+
+ /// Overrides the default ConnectionStrings__ prefix so Aspire injects KUBECONFIG.
+ public string? ConnectionStringEnvironmentVariable => "KUBECONFIG";
+
+ /// Manifest expression for the local kubeconfig path.
+ public ReferenceExpression ConnectionStringExpression =>
+ KubeconfigDirectory is null
+ ? ReferenceExpression.Create($"")
+ : ReferenceExpression.Create($"{KubeconfigDirectory}/local/kubeconfig.yaml");
+
+ /// Returns the host-accessible kubeconfig path for this cluster.
+ public ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult(
+ KubeconfigDirectory is null
+ ? null
+ : Path.Combine(KubeconfigDirectory, "local", "kubeconfig.yaml"));
+
+ private EndpointReference? _apiEndpoint;
+
+ /// Gets the endpoint reference for the k3s API server (port 6443).
+ public EndpointReference ApiEndpoint => _apiEndpoint ??= new(this, ApiServerEndpointName);
+
+ // ββ Child resource tracking (Postgres pattern) ββββββββββββββββββββββββββββ
+
+ ///
+ /// Number of agent (worker) nodes. The health check waits for all
+ /// 1 + AgentCount nodes to reach Ready state before marking the cluster healthy.
+ ///
+ internal int AgentCount { get; set; }
+
+ private readonly List _agentResources = [];
+
+ /// Agent resource instances, used to propagate annotations (e.g. lifetime) to all nodes.
+ internal IReadOnlyList AgentResources => _agentResources;
+
+ internal void AddAgentResource(K3sAgentResource agent) => _agentResources.Add(agent);
+
+ private readonly Dictionary _helmReleases =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ /// A dictionary of registered Helm releases keyed by resource name.
+ [AspireExportIgnore(Reason = "Internal tracking collection; not needed by guest SDK consumers.")]
+ public IReadOnlyDictionary HelmReleases => _helmReleases;
+
+ internal void AddHelmRelease(string resourceName, string releaseName) =>
+ _helmReleases.TryAdd(resourceName, releaseName);
+
+ private readonly List _manifests = [];
+
+ /// Names of registered children.
+ [AspireExportIgnore(Reason = "Internal tracking collection; not needed by guest SDK consumers.")]
+ public IReadOnlyList Manifests => _manifests;
+
+ internal void AddManifest(string resourceName) => _manifests.Add(resourceName);
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sContainerImageTags.cs
new file mode 100644
index 000000000..15fc071d9
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sContainerImageTags.cs
@@ -0,0 +1,13 @@
+namespace Aspire.Hosting.ApplicationModel;
+
+internal static class K3sContainerImageTags
+{
+ /// docker.io
+ public const string Registry = "docker.io";
+
+ /// rancher/k3s
+ public const string Image = "rancher/k3s";
+
+ /// v1.36.0-k3s1
+ public const string Tag = "v1.36.0-k3s1";
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sFileHelpers.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sFileHelpers.cs
new file mode 100644
index 000000000..03a8123ab
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sFileHelpers.cs
@@ -0,0 +1,77 @@
+namespace CommunityToolkit.Aspire.Hosting;
+
+///
+/// Shared constants for Unix file modes and well-known in-container paths used by
+/// k3s-related containers.
+///
+internal static class K3sFileHelpers
+{
+ ///
+ /// The path inside every container that receives a k3s kubeconfig via a file-level
+ /// bind-mount from the host's AppHostDirectory/.k3s/{name}/container/kubeconfig.yaml.
+ ///
+ ///
+ ///
+ /// Placing the file under /tmp/ (which is guaranteed to exist in all POSIX
+ /// containers) rather than inside ~/.kube/ ensures that kubectl's
+ /// cache directories (~/.kube/cache/, ~/.kube/http-cache/) are created
+ /// inside the container's ephemeral filesystem rather than in the host-side
+ /// container/ directory. This prevents:
+ ///
+ /// - Host filesystem pollution with kubectl cache directories.
+ /// - Cache corruption when multiple helm or kubectl containers run concurrently
+ /// and share the same host-side mount directory.
+ ///
+ ///
+ ///
+ /// Using a file-level bind-mount (source file β target file, not directory β
+ /// directory) means only the kubeconfig YAML is visible inside the container at this
+ /// path; any other files the host may later add to container/ are not exposed.
+ ///
+ ///
+ internal const string ContainerKubeconfigPath = "/tmp/k3s-kubeconfig.yaml";
+
+ ///
+ /// Unix file mode 0755 (rwxr-xr-x) for shell scripts injected into
+ /// k3s, alpine/helm, and alpine/kubectl containers.
+ ///
+ ///
+ ///
+ /// Why 0755 and not a narrower mode like 0700?
+ ///
+ ///
+ /// The scripts are invoked as /bin/sh /script.sh β the shell reads the file,
+ /// so the execute bit on the script itself is technically not required for the current
+ /// invocation pattern. We set it anyway for two reasons:
+ ///
+ /// -
+ /// Convention: scripts intended to be executed are conventionally marked
+ /// executable. If the invocation is later changed to direct execution
+ /// (./script.sh), the permission is already correct without a
+ /// chmod inside the container.
+ ///
+ /// -
+ /// Custom image overrides: K3sClusterOptions lets callers replace
+ /// the helm/kubectl images. Non-root container users in those custom images need
+ /// OtherRead | OtherExecute to read and execute the injected scripts
+ /// when the file is owned by a different UID/GID. The default images
+ /// (alpine/helm, alpine/kubectl, rancher/k3s) all run as
+ /// root (UID 0), so UserRead | UserExecute alone would suffice for
+ /// the defaults β but 0755 is the safe choice for the general case.
+ ///
+ ///
+ ///
+ ///
+ /// Why UserWrite?
+ /// WithContainerFiles calls docker cp which sets the destination owner
+ /// to root. Without UserWrite, root cannot overwrite the file on a subsequent
+ /// AppHost restart (when the same container image layer is re-used), leading to a
+ /// silent stale-script failure. This is the same reason chmod 644 rather than
+ /// 444 is conventional for data files on Linux.
+ ///
+ ///
+ internal const UnixFileMode ExecutableScriptMode =
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
+ UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
+ UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sInProcessPortForwarder.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sInProcessPortForwarder.cs
new file mode 100644
index 000000000..182b86a62
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sInProcessPortForwarder.cs
@@ -0,0 +1,289 @@
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using k8s;
+using Microsoft.Extensions.Logging;
+
+namespace CommunityToolkit.Aspire.Hosting;
+
+///
+/// Forwards a local TCP port to a Kubernetes service using the KubernetesClient
+/// WebSocket port-forward API β no kubectl binary required.
+///
+/// The listener binds to 0.0.0.0:{localPort} so both host processes
+/// (localhost:{port}) and DCP-network containers
+/// (host.docker.internal:{port}) can reach the service.
+///
+///
+/// The callback is invoked with
+/// only after a ready pod is confirmed via ListNamespacedPodAsync β not when the
+/// TCP listener starts. This ensures WaitFor(endpoint) on dependent resources
+/// correctly waits until the k8s service has a reachable pod.
+///
+///
+internal sealed class K3sInProcessPortForwarder(
+ string kubeconfigPath,
+ string @namespace,
+ string serviceName,
+ int localPort,
+ int servicePort,
+ Action onReadyChanged,
+ Func? kubernetesFactory = null) : IAsyncDisposable
+{
+ private readonly CancellationTokenSource _cts = new();
+ private TcpListener? _listener;
+
+ // Creates a client from the kubeconfig path using the injected factory if provided,
+ // or the default KubernetesClient.BuildConfigFromConfigFile path. The factory
+ // parameter exists to enable mock injection in unit tests without a real cluster.
+ private IKubernetes CreateClient() =>
+ kubernetesFactory is not null
+ ? kubernetesFactory(kubeconfigPath)
+ : new Kubernetes(KubernetesClientConfiguration.BuildConfigFromConfigFile(kubeconfigPath));
+
+ public async Task RunAsync(ILogger logger, CancellationToken ct)
+ {
+ // Link the outer application-lifetime token with our own so either can stop the loop.
+ // _cts.Token lets DisposeAsync cancel independently of the outer token (e.g. when
+ // the cluster resource stops before the full AppHost shuts down).
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
+ var linkedCt = linked.Token;
+
+ var backoff = TimeSpan.FromSeconds(2);
+ var currentPort = localPort;
+
+ while (!linkedCt.IsCancellationRequested)
+ {
+ var listener = new TcpListener(IPAddress.Any, currentPort);
+ _listener = listener;
+ try
+ {
+ listener.Start();
+
+ if (logger.IsEnabled(LogLevel.Information))
+ logger.LogInformation(
+ "Port-forward: 0.0.0.0:{Local} β svc/{Service}.{Ns}:{Port}",
+ currentPort, serviceName, @namespace, servicePort);
+
+ // Probe the service before signalling ready β the Kubernetes service and
+ // a ready pod must exist before any connection can succeed.
+ // This makes the ready signal meaningful for WaitFor(endpoint) consumers.
+ await WaitForServiceReadyAsync(logger, linkedCt).ConfigureAwait(false);
+ onReadyChanged(true);
+
+ while (!linkedCt.IsCancellationRequested)
+ {
+ var tcp = await listener.AcceptTcpClientAsync(linkedCt).ConfigureAwait(false);
+ _ = Task.Run(
+ () => ForwardConnectionAsync(tcp, logger, linkedCt),
+ linkedCt);
+ }
+ }
+ catch (OperationCanceledException) when (linkedCt.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (InvalidOperationException ioe) when (!linkedCt.IsCancellationRequested)
+ {
+ // Non-retryable configuration error (e.g. service has no pod selector).
+ // Log and stop β retrying will never succeed.
+ logger.LogError(ioe, "Port-forward for svc/{Service}/{Ns} cannot be established.",
+ serviceName, @namespace);
+ onReadyChanged(false);
+ break;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex,
+ "Port-forward for svc/{Service} failed; retrying in {Delay}sβ¦",
+ serviceName, backoff.TotalSeconds);
+ onReadyChanged(false);
+ // Allocate a fresh port on retry β the previous one may have been
+ // stolen between our probe and the forwarder bind (TOCTOU).
+ using var probe = new TcpListener(IPAddress.Any, 0);
+ probe.Start();
+ currentPort = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+ }
+ finally
+ {
+ _listener = null;
+ listener.Stop();
+ }
+
+ if (linkedCt.IsCancellationRequested) break;
+
+ try { await Task.Delay(backoff, linkedCt).ConfigureAwait(false); }
+ catch (OperationCanceledException) { break; }
+ backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 30));
+ }
+
+ onReadyChanged(false);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ // Cancel the linked CTS so RunAsync exits its loop even if the outer
+ // application-lifetime token is still live (e.g. cluster stopped but AppHost
+ // is still running).
+ await _cts.CancelAsync().ConfigureAwait(false);
+ _listener?.Stop();
+ _cts.Dispose();
+ }
+
+ ///
+ /// Polls until the named service has at least one fully-ready pod.
+ /// This ensures the ready signal is only emitted when connections can actually succeed.
+ ///
+ private async Task WaitForServiceReadyAsync(ILogger logger, CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ using var k8sClient = CreateClient();
+
+ var svc = await k8sClient.CoreV1
+ .ReadNamespacedServiceAsync(serviceName, @namespace, cancellationToken: ct)
+ .ConfigureAwait(false);
+
+ var exposesRequestedPort = svc.Spec?.Ports?.Any(p => p.Port == servicePort) == true;
+ if (!exposesRequestedPort)
+ {
+ logger.LogDebug(
+ "Service {Service}/{Ns} does not expose requested port {ServicePort}; retryingβ¦",
+ serviceName, @namespace, servicePort);
+ }
+ else if (svc.Spec?.Selector is null or { Count: 0 })
+ {
+ throw new InvalidOperationException(
+ $"Service {serviceName}/{@namespace} has no pod selector and cannot be port-forwarded by {nameof(K3sInProcessPortForwarder)}.");
+ }
+ else
+ {
+ var labelSelector = string.Join(",",
+ svc.Spec.Selector.Select(kv => $"{kv.Key}={kv.Value}"));
+
+ var pods = await k8sClient.CoreV1
+ .ListNamespacedPodAsync(@namespace, labelSelector: labelSelector, cancellationToken: ct)
+ .ConfigureAwait(false);
+
+ var hasReadyPod = pods.Items.Any(p =>
+ p.Status?.Phase == "Running" &&
+ p.Status?.ContainerStatuses?.All(c => c.Ready) == true);
+
+ if (hasReadyPod)
+ {
+ logger.LogDebug(
+ "Service {Service}/{Ns} exposes port {Port} and has a ready pod β port-forward is ready.",
+ serviceName, @namespace, servicePort);
+ return;
+ }
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (InvalidOperationException)
+ {
+ throw; // Non-retryable β let RunAsync catch it.
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex,
+ "Service {Service}/{Ns} not yet ready; retryingβ¦", serviceName, @namespace);
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(3), ct).ConfigureAwait(false);
+ }
+ }
+
+ private async Task ForwardConnectionAsync(TcpClient tcp, ILogger logger, CancellationToken ct)
+ {
+ using var _ = tcp;
+ try
+ {
+ using var k8sClient = CreateClient();
+
+ // Resolve the service to a running pod.
+ var svc = await k8sClient.CoreV1
+ .ReadNamespacedServiceAsync(serviceName, @namespace, cancellationToken: ct)
+ .ConfigureAwait(false);
+
+ if (svc.Spec.Selector is null or { Count: 0 })
+ {
+ logger.LogWarning(
+ "Service {Service}/{Ns} has no pod selector β connection dropped.",
+ serviceName, @namespace);
+ return;
+ }
+
+ var labelSelector = string.Join(",",
+ svc.Spec.Selector.Select(kv => $"{kv.Key}={kv.Value}"));
+
+ var pods = await k8sClient.CoreV1
+ .ListNamespacedPodAsync(@namespace, labelSelector: labelSelector, cancellationToken: ct)
+ .ConfigureAwait(false);
+
+ var pod = pods.Items.FirstOrDefault(p =>
+ p.Status?.Phase == "Running" &&
+ p.Status?.ContainerStatuses?.All(c => c.Ready) == true);
+
+ if (pod is null)
+ {
+ logger.LogWarning(
+ "No ready pod found for service {Service}/{Ns} β connection dropped.",
+ serviceName, @namespace);
+ return;
+ }
+
+ // Resolve the pod container port from the service's targetPort.
+ // WebSocketNamespacedPodPortForwardAsync requires the container port, not the
+ // service port. targetPort can be a numeric string or a named port string.
+ var svcPort = svc.Spec.Ports.FirstOrDefault(p => p.Port == servicePort);
+ int podPort;
+ if (svcPort?.TargetPort?.Value is { } tp)
+ {
+ if (int.TryParse(tp, out var numeric))
+ {
+ podPort = numeric;
+ }
+ else
+ {
+ // Named targetPort β resolve against the selected pod's container ports.
+ podPort = pod.Spec.Containers
+ .SelectMany(c => c.Ports ?? [])
+ .FirstOrDefault(p => p.Name == tp)
+ ?.ContainerPort ?? servicePort;
+ }
+ }
+ else
+ {
+ podPort = servicePort;
+ }
+
+ // Open WebSocket port-forward to the pod.
+ using var ws = await k8sClient.WebSocketNamespacedPodPortForwardAsync(
+ pod.Metadata.Name, @namespace, [podPort],
+ cancellationToken: ct).ConfigureAwait(false);
+
+ using var demuxer = new StreamDemuxer(ws, StreamType.PortForward);
+ demuxer.Start();
+
+ using var k8sStream = demuxer.GetStream((byte?)0, (byte?)0);
+ using var tcpStream = tcp.GetStream();
+
+ // Bidirectional byte pump until either side closes.
+ await Task.WhenAny(
+ tcpStream.CopyToAsync(k8sStream, ct),
+ k8sStream.CopyToAsync(tcpStream, ct)).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "Port-forward connection for svc/{Service} closed.", serviceName);
+ }
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sReadinessHealthCheck.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sReadinessHealthCheck.cs
new file mode 100644
index 000000000..17b1f11c0
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sReadinessHealthCheck.cs
@@ -0,0 +1,191 @@
+using Aspire.Hosting.ApplicationModel;
+using k8s;
+using k8s.KubeConfigModels;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace CommunityToolkit.Aspire.Hosting;
+
+///
+/// Health check for .
+///
+/// Polls for the kubeconfig file written by k3s into the bind-mounted
+/// AppHostDirectory/.k3s/{name}/cluster/kubeconfig.yaml. On first appearance
+/// it rewrites the server URL for two variants:
+///
+/// - local/kubeconfig.yaml β server: https://localhost:{allocatedPort} (host processes)
+/// - container/kubeconfig.yaml β server: https://{name}:6443 (DCP-network containers)
+///
+/// Then creates a short-lived client (disposed after each check)
+/// to call ListNodeAsync, confirming that all expected nodes are Ready.
+/// No docker exec is involved β works with any container runtime.
+///
+///
+internal sealed class K3sReadinessHealthCheck(
+ K3sClusterResource resource,
+ Func? kubernetesFactory = null) : IHealthCheck
+{
+ private IKubernetes CreateClient(string path) =>
+ kubernetesFactory is not null
+ ? kubernetesFactory(path)
+ : new Kubernetes(KubernetesClientConfiguration.BuildConfigFromConfigFile(path));
+
+ ///
+ public async Task CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ // Resolve the API server port. We try two sources in order:
+ //
+ // 1. EndpointAnnotation.AllocatedEndpoint.Port β set by DCP when it allocates the
+ // endpoint. Non-blocking (returns null if not yet set) and NOT cached, so it picks
+ // up DCP's allocation on any tick after the event fires.
+ // We intentionally avoid EndpointReference.IsAllocated here because that property
+ // caches its result on first call; if the health check ticks before DCP fires the
+ // allocation event the cached false would persist forever.
+ //
+ // 2. EndpointAnnotation.Port β the statically configured host port (only non-null
+ // when the caller passed an explicit apiServerPort to AddK3sCluster). Works for
+ // both C# and polyglot AppHosts.
+
+ var annotation = resource.Annotations
+ .OfType()
+ .FirstOrDefault(a => a.Name == K3sClusterResource.ApiServerEndpointName);
+
+ int port;
+ if (annotation?.AllocatedEndpoint is { Port: > 0 } alloc)
+ {
+ port = alloc.Port;
+ }
+ else if (annotation?.Port is > 0)
+ {
+ port = annotation.Port!.Value;
+ }
+ else
+ {
+ return HealthCheckResult.Unhealthy("k3s API server endpoint not yet allocated");
+ }
+
+ return await CheckCoreAsync(port, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Core readiness check given an already-known API server .
+ /// Extracted so unit tests can call it directly with an explicit port.
+ ///
+ internal async Task CheckCoreAsync(int port, CancellationToken cancellationToken = default)
+ {
+ var dir = resource.KubeconfigDirectory;
+ if (dir is null)
+ return HealthCheckResult.Unhealthy("Kubeconfig directory not configured on resource");
+
+ var rawPath = Path.Combine(dir, "cluster", "kubeconfig.yaml");
+ if (!File.Exists(rawPath))
+ return HealthCheckResult.Unhealthy("Waiting for k3s to write kubeconfig");
+
+ try
+ {
+ var localPath = await EnsureKubeconfigVariantsAsync(rawPath, dir, port, cancellationToken)
+ .ConfigureAwait(false);
+
+ // Create a fresh client per check β no cached state, no stale connection risk.
+ // At a 5-second health-check interval the TLS handshake overhead is negligible
+ // for a local dev integration.
+ using var k8sClient = CreateClient(localPath);
+
+ var nodes = await k8sClient.CoreV1
+ .ListNodeAsync(cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
+
+ var readyCount = nodes.Items.Count(n =>
+ n.Status?.Conditions?.Any(c =>
+ c.Type == "Ready" &&
+ string.Equals(c.Status, "True", StringComparison.OrdinalIgnoreCase)) == true);
+
+ var expected = 1 + resource.AgentCount;
+ if (readyCount < expected)
+ return HealthCheckResult.Unhealthy($"k3s cluster: {readyCount}/{expected} nodes Ready");
+
+ return HealthCheckResult.Healthy("k3s cluster is ready");
+ }
+ catch (Exception ex) when (IsTlsOrAuthFailure(ex))
+ {
+ // Stale kubeconfig β cluster was recreated with new certs (e.g. data volume wiped).
+ // k3s has already written a fresh kubeconfig to rawPath (it writes once at startup,
+ // not continuously), so we must NOT delete rawPath β that would remove the fresh
+ // file and leave the health check waiting forever.
+ // Delete only the derived variants so they are regenerated from the fresh raw file
+ // on the next check cycle.
+ TryDelete(Path.Combine(dir, "local", "kubeconfig.yaml"));
+ TryDelete(Path.Combine(dir, "container", "kubeconfig.yaml"));
+ return HealthCheckResult.Unhealthy("k3s kubeconfig is stale β retrying with fresh credentials");
+ }
+ catch (Exception ex)
+ {
+ return HealthCheckResult.Unhealthy(ex.Message, ex);
+ }
+ }
+
+ ///
+ /// Reads the raw kubeconfig written by k3s, rewrites it for each consumer variant,
+ /// and writes both to disk atomically. Returns the path to the local variant.
+ ///
+ private async Task EnsureKubeconfigVariantsAsync(
+ string rawPath,
+ string dir,
+ int port,
+ CancellationToken ct)
+ {
+ var rawYaml = await File.ReadAllTextAsync(rawPath, ct).ConfigureAwait(false);
+ var parsed = KubernetesYaml.Deserialize(rawYaml);
+
+ var localDir = Path.Combine(dir, "local");
+ Directory.CreateDirectory(localDir);
+ var localPath = Path.Combine(localDir, "kubeconfig.yaml");
+ await WriteAtomicAsync(localPath, BuildConfigYaml(parsed, $"https://localhost:{port}"), ct)
+ .ConfigureAwait(false);
+
+ var containerDir = Path.Combine(dir, "container");
+ Directory.CreateDirectory(containerDir);
+ await WriteAtomicAsync(
+ Path.Combine(containerDir, "kubeconfig.yaml"),
+ BuildConfigYaml(parsed, $"https://{resource.Name}:6443"),
+ ct).ConfigureAwait(false);
+
+ return localPath;
+ }
+
+ internal static string BuildConfigYaml(K8SConfiguration source, string serverUrl)
+ {
+ var yaml = KubernetesYaml.Serialize(source);
+ var copy = KubernetesYaml.Deserialize(yaml);
+ foreach (var cluster in copy.Clusters ?? [])
+ {
+ if (cluster.ClusterEndpoint is not null)
+ cluster.ClusterEndpoint.Server = serverUrl;
+ }
+
+ return KubernetesYaml.Serialize(copy);
+ }
+
+ ///
+ /// Writes to atomically by first
+ /// writing to a sibling temp file then renaming. Readers never observe a partial write.
+ ///
+ private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct)
+ {
+ var tmp = path + ".tmp";
+ await File.WriteAllTextAsync(tmp, content, ct).ConfigureAwait(false);
+ File.Move(tmp, path, overwrite: true);
+ }
+
+ internal static bool IsTlsOrAuthFailure(Exception ex) =>
+ ex is System.Security.Authentication.AuthenticationException
+ || ex.InnerException is System.Security.Authentication.AuthenticationException
+ || (ex is k8s.Autorest.HttpOperationException op &&
+ op.Response?.StatusCode == System.Net.HttpStatusCode.Unauthorized);
+
+ private static void TryDelete(string path)
+ {
+ try { File.Delete(path); } catch { /* best effort */ }
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sReferenceAnnotations.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sReferenceAnnotations.cs
new file mode 100644
index 000000000..854297cae
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sReferenceAnnotations.cs
@@ -0,0 +1,5 @@
+// Both K3sClusterResource and K3sServiceEndpointResource implement
+// IResourceWithConnectionString. The standard Aspire WithReference overload adds a
+// ResourceRelationshipAnnotation to dependents, which the BeforeStartEvent subscriber
+// in AddK3sCluster uses to detect and apply container-specific overrides.
+// No custom marker annotations are needed.
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sServiceEndpointResource.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sServiceEndpointResource.cs
new file mode 100644
index 000000000..a27a9cfdd
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sServiceEndpointResource.cs
@@ -0,0 +1,107 @@
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+
+using CommunityToolkit.Aspire.Hosting;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a Kubernetes Service exposed from a k3s cluster as an Aspire endpoint resource.
+///
+///
+/// An in-process WebSocket port-forward bridges the cluster service to a host port when the
+/// cluster is ready. Dependent resources receive the service URL via the standard Aspire
+/// service-discovery environment variable services__{name}__url:
+///
+/// - Host processes receive http(s)://localhost:{port}.
+/// - Containers receive http(s)://host.docker.internal:{port}.
+///
+/// Use WaitFor(endpoint) on dependent resources to ensure the port-forward is active
+/// before they start.
+///
+[AspireExport(ExposeProperties = true)]
+public sealed class K3sServiceEndpointResource(
+ string name,
+ string serviceName,
+ int servicePort,
+ string @namespace,
+ K3sClusterResource cluster)
+ : Resource(name), IResourceWithParent, IResourceWithWaitSupport,
+ IResourceWithConnectionString
+{
+ ///
+ public K3sClusterResource Parent { get; } = cluster ?? throw new ArgumentNullException(nameof(cluster));
+
+ /// Gets the name of the Kubernetes Service being forwarded.
+ public string ServiceName { get; } = serviceName ?? throw new ArgumentNullException(nameof(serviceName));
+
+ /// Gets the port number declared on the Kubernetes Service.
+ public int ServicePort { get; } = servicePort;
+
+ /// Gets the Kubernetes namespace that contains the Service.
+ public string Namespace { get; } = @namespace ?? throw new ArgumentNullException(nameof(@namespace));
+
+ ///
+ /// Gets the host port bound by the port-forward listener.
+ ///
+ ///
+ /// Zero until the resource transitions to Running. Use WaitFor(endpoint)
+ /// to ensure this is populated before reading it from a dependent resource.
+ ///
+ public int HostPort { get; internal set; }
+
+ ///
+ /// The URL scheme used for services__{name}__url injection and dashboard URLs.
+ /// Set by AddServiceEndpoint; callers can override the default port-based inference
+ /// via the scheme parameter.
+ ///
+ internal string Scheme { get; init; } = "http";
+
+ ///
+ /// when the port-forward is active and accepting connections.
+ /// Set by K3sInProcessPortForwarder; read by the health check.
+ ///
+ internal volatile bool IsReady;
+
+ ///
+ /// The active port-forwarder, retained so it can be disposed when the parent cluster
+ /// stops (via ResourceStoppedEvent) or when the AppHost shuts down β whichever
+ /// comes first.
+ ///
+ internal K3sInProcessPortForwarder? Forwarder { get; set; }
+
+ ///
+ /// The environment variable name used to inject the service URL into dependents
+ /// (services__{name}__url), following the Aspire service-discovery convention.
+ ///
+ public string? ConnectionStringEnvironmentVariable => $"services__{Name}__url";
+
+ ///
+ /// Gets the manifest expression for the service URL.
+ /// Resolves to http(s)://localhost:{hostPort} when the endpoint is ready.
+ ///
+ public ReferenceExpression ConnectionStringExpression
+ {
+ get
+ {
+ // ReferenceExpression.Create only accepts IManifestExpressionProvider in
+ // format holes, not plain value types. Pre-compute to a string first.
+ var url = IsReady && HostPort > 0 ? $"{Scheme}://localhost:{HostPort}" : string.Empty;
+ return ReferenceExpression.Create($"{url}");
+ }
+ }
+
+ ///
+ /// Returns the host-accessible service URL, or if the
+ /// port-forward is not yet active.
+ ///
+ ///
+ /// Returns until the endpoint transitions to Running.
+ /// Declare WaitFor(endpoint) on dependent resources so that this value is
+ /// always populated by the time their environment variables are evaluated.
+ ///
+ public ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult(
+ IsReady && HostPort > 0
+ ? $"{Scheme}://localhost:{HostPort}"
+ : (string?)null);
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K8sManifestResource.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K8sManifestResource.cs
new file mode 100644
index 000000000..f757423c3
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K8sManifestResource.cs
@@ -0,0 +1,29 @@
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a Kubernetes manifest (or Kustomize overlay) applied to the parent k3s cluster.
+///
+/// The Aspire resource name.
+/// Absolute path to the YAML file, plain directory, or Kustomize directory.
+/// The parent k3s cluster resource.
+///
+/// The resource runs as an alpine/kubectl container. It polls until the cluster
+/// kubeconfig is available, then applies the manifests with kubectl apply --server-side
+/// and waits for any CRDs to reach the Established condition before exiting with code 0.
+/// Use WaitForCompletion(manifest) on resources that depend on these manifests being applied.
+///
+[AspireExport(ExposeProperties = true)]
+public sealed class K8sManifestResource(string name, string path, K3sClusterResource cluster)
+ : ContainerResource(name), IResourceWithParent
+{
+ ///
+ public K3sClusterResource Parent { get; } = cluster ?? throw new ArgumentNullException(nameof(cluster));
+
+ ///
+ /// Gets the absolute host path to the YAML file, plain directory, or Kustomize directory
+ /// that contains the manifests to apply.
+ ///
+ public string Path { get; } = path ?? throw new ArgumentNullException(nameof(path));
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/KubectlContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/KubectlContainerImageTags.cs
new file mode 100644
index 000000000..6ec6838d9
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/KubectlContainerImageTags.cs
@@ -0,0 +1,10 @@
+namespace CommunityToolkit.Aspire.Hosting;
+
+internal static class KubectlContainerImageTags
+{
+ internal const string Registry = "docker.io";
+ // alpine/kubectl: Alpine-based image with kubectl and /bin/sh, required by the
+ // manifest apply script. Tag matches the default k3s server Kubernetes version.
+ internal const string Image = "alpine/kubectl";
+ internal const string Tag = "1.36.0";
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/README.md b/src/CommunityToolkit.Aspire.Hosting.K3s/README.md
new file mode 100644
index 000000000..7be04c162
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/README.md
@@ -0,0 +1,258 @@
+# CommunityToolkit.Aspire.Hosting.K3s
+
+Provides extension methods and resource definitions for Aspire Distribute Apps to run a
+[k3s](https://k3s.io/) lightweight Kubernetes cluster as part of the local development
+inner loop. The cluster, Helm chart installs, manifest applies, and service endpoint
+exposures all appear as first-class resources in the Aspire dashboard β no external
+tooling beyond a supported container runtime is required.
+
+## Prerequisites
+
+A container runtime that supports privileged Linux containers:
+
+- **Docker Engine 20.10+** (Linux) or **Docker Desktop** (macOS / Windows)
+- **Podman 4.0+** (Linux, rootful only β rootless requires cgroup v2 delegation)
+
+> k3s requires `--privileged`, `--cgroupns=host`, and a writable cgroup filesystem. These
+> flags are passed automatically by the integration.
+
+## Install
+
+```dotnetcli
+dotnet add package CommunityToolkit.Aspire.Hosting.K3s
+```
+
+## Quick start
+
+```csharp
+var cluster = builder.AddK3sCluster("k8s");
+
+builder.AddProject("operator")
+ .WaitFor(cluster)
+ .WithReference(cluster); // injects KUBECONFIG automatically
+```
+
+## Cluster configuration
+
+All options are available as fluent builder methods β no callback required.
+
+```csharp
+var cluster = builder
+ .AddK3sCluster("k8s",
+ apiServerPort: 16443, // fixed host port for the API server (random by default)
+ agentCount: 2) // 1 server + 2 agent nodes
+ .WithK3sVersion("v1.32.3-k3s1") // pin k3s image tag
+ .WithPodSubnet("10.42.0.0/16") // --cluster-cidr
+ .WithServiceSubnet("10.43.0.0/16") // --service-cidr
+ .WithDisabledComponent("traefik") // --disable=traefik (repeatable)
+ .WithExtraArg("--write-kubeconfig-mode=644") // raw k3s server flag (repeatable)
+ .WithHelmImage(tag: "3.18.0") // override the alpine/helm version
+ .WithKubectlImage(tag: "1.37.0") // override the alpine/kubectl version
+ .WithDataVolume() // persist cluster state across restarts
+ .WithLifetime(ContainerLifetime.Persistent);
+```
+
+### Option reference
+
+| Method | Parameter / Effect |
+|---|---|
+| `AddK3sCluster(agentCount:)` | Number of worker nodes (0 = single-node). Equivalent to `WithAgentCount`. |
+| `WithAgentCount(n)` | Same as above, fluent alternative. The health check waits for all `1 + n` nodes to be `Ready`. |
+| `WithK3sVersion(tag)` | Overrides the k3s image tag, e.g. `v1.32.3-k3s1`. Synced to agents automatically. |
+| `WithPodSubnet(cidr)` | Sets `--cluster-cidr`. Default: k3s built-in `10.42.0.0/16`. |
+| `WithServiceSubnet(cidr)` | Sets `--service-cidr`. Default: k3s built-in `10.43.0.0/16`. |
+| `WithDisabledComponent(c)` | Passes `--disable=`. Call multiple times for multiple components. |
+| `WithExtraArg(arg)` | Appends a raw argument to `k3s server`. |
+| `WithHelmImage(registry?, image?, tag?)` | Overrides the `alpine/helm` image used by `AddHelmRelease`. |
+| `WithKubectlImage(registry?, image?, tag?)` | Overrides the `alpine/kubectl` image used by `AddK8sManifest`. |
+| `WithDataVolume(name?)` | Mounts a named Docker volume at `/var/lib/rancher/k3s`. |
+| `WithLifetime(lifetime)` | Sets `ContainerLifetime.Persistent` or `Session` for cluster and agents. |
+
+## Persistent cluster
+
+```csharp
+var cluster = builder.AddK3sCluster("k8s")
+ .WithDataVolume()
+ .WithLifetime(ContainerLifetime.Persistent);
+```
+
+`WithDataVolume` persists the k3s database, certificates, and node tokens across AppHost
+restarts. `WithLifetime(Persistent)` tells DCP to keep the Docker container alive between
+runs, making subsequent starts much faster.
+
+> **Agent count and persistence**: with persistent containers, `agentCount` must stay
+> constant across runs. Decreasing it leaves orphaned agent containers that will fail to
+> rejoin the cluster; delete them manually with `docker rm -f`.
+
+## Deploying Helm charts
+
+`AddHelmRelease` runs `helm upgrade --install --wait` inside an `alpine/helm` container.
+No host-side `helm` binary is required.
+
+```csharp
+var podinfo = cluster.AddHelmRelease(
+ name: "podinfo",
+ chart: "podinfo",
+ repo: "https://stefanprodan.github.io/podinfo",
+ version: "6.7.1",
+ @namespace: "podinfo")
+ .WithHelmValue("replicaCount", "2")
+ .WithHelmValuesFile("./deploy/podinfo-values.yaml");
+
+// Wait for the chart install to complete before starting the operator.
+builder.AddProject("operator")
+ .WaitForCompletion(podinfo)
+ .WithReference(cluster);
+```
+
+### Helm value precedence
+
+Values are applied in this order (last wins):
+
+1. `WithHelmValuesFile` β in declaration order
+2. `WithHelmValue` (`--set` flags) β always override files
+
+Use `WithHelmValuesFile` for structured overrides (values with commas, braces, or
+backslashes). `WithHelmValue` is convenient for individual scalar overrides.
+
+## Applying Kubernetes manifests
+
+`AddK8sManifest` runs `kubectl apply --server-side` inside an `alpine/kubectl` container.
+The apply mode is detected automatically from the path:
+
+| Path | Mode |
+|---|---|
+| Single `.yaml` / `.yml` file | `kubectl apply -f ` |
+| Directory (no `kustomization.yaml`) | `kubectl apply -f ` (all YAML files, lexicographic order) |
+| Directory containing `kustomization.yaml` | `kubectl apply -k ` (Kustomize) |
+
+```csharp
+// Plain YAML
+var appConfig = cluster.AddK8sManifest("app-config", "./k8s/app-config.yaml")
+ .WaitForCompletion(podinfo);
+
+// Kustomize overlay β auto-detected; directory bind-mounted to preserve base references
+var monitoring = cluster.AddK8sManifest("monitoring-config", "./k8s/monitoring")
+ .WaitForCompletion(podinfo)
+ .WaitForCompletion(appConfig);
+
+// Wait for CRD to be Established before starting the operator
+builder.AddProject("operator")
+ .WaitForCompletion(crd)
+ .WithReference(cluster);
+```
+
+## Exposing k8s services
+
+`AddServiceEndpoint` starts an in-process WebSocket port-forward bound to
+`0.0.0.0:{allocatedPort}`. No NodePort or LoadBalancer configuration is required.
+The endpoint transitions to `Running` only after the target service has a ready pod.
+
+```csharp
+var podinfoWeb = cluster
+ .AddServiceEndpoint("podinfo-web", "podinfo", servicePort: 9898, @namespace: "podinfo")
+ .WaitForCompletion(podinfo);
+
+// Host processes receive http://localhost:{port}
+builder.AddProject("api")
+ .WaitFor(podinfoWeb)
+ .WithReference(podinfoWeb);
+
+// DCP-network containers receive http://host.docker.internal:{port}
+// --add-host=host.docker.internal:host-gateway is injected automatically
+builder.AddContainer("sidecar", "myorg/sidecar")
+ .WaitFor(podinfoWeb)
+ .WithReference(podinfoWeb);
+```
+
+The injected environment variable follows the Aspire service-discovery convention:
+`services__{name}__url=http(s)://{host}:{port}`.
+
+Scheme is inferred from the port: 443 and 8443 β `https`, all others β `http`. Override
+with the `scheme` parameter: `AddServiceEndpoint("ep", "svc", 8080, scheme: "https")`.
+
+## Kubeconfig injection
+
+Both `K3sClusterResource` and `K3sServiceEndpointResource` implement
+`IResourceWithConnectionString`, so the standard Aspire `WithReference` overload handles
+credential injection automatically.
+
+```csharp
+// Projects and executables receive KUBECONFIG pointing to the host-accessible variant
+builder.AddProject("operator")
+ .WithReference(cluster); // KUBECONFIG=β¦/.k3s/k8s/local/kubeconfig.yaml
+
+// Containers receive a bind-mounted kubeconfig at /tmp/k3s-kubeconfig.yaml
+builder.AddContainer("sidecar", "myorg/sidecar")
+ .WithReference(cluster); // KUBECONFIG=/tmp/k3s-kubeconfig.yaml + file bind-mount
+```
+
+All standard Kubernetes tooling reads `KUBECONFIG` automatically:
+
+```csharp
+var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(
+ Environment.GetEnvironmentVariable("KUBECONFIG"));
+using var client = new Kubernetes(config);
+```
+
+## TypeScript polyglot AppHost
+
+All cluster options are available as fluent methods in the TypeScript SDK β no callback
+or `K3sClusterOptions` type required.
+
+```typescript
+import { createBuilder, ContainerLifetime } from './.aspire/modules/aspire.mjs';
+
+const builder = await createBuilder();
+
+const cluster = await builder
+ .addK3sCluster('k8s', { agentCount: 2 })
+ .withK3sVersion('v1.32.3-k3s1')
+ .withPodSubnet('10.42.0.0/16')
+ .withDisabledComponent('traefik')
+ .withHelmImage({ tag: '3.18.0' })
+ .withDataVolume({ name: 'k8s-data' })
+ .withLifetime(ContainerLifetime.Persistent);
+
+const podinfo = await cluster.addHelmRelease('podinfo', 'podinfo', {
+ repo: 'https://stefanprodan.github.io/podinfo',
+ version: '6.7.1',
+ namespace: 'podinfo',
+});
+
+const podinfoWeb = await cluster
+ .addServiceEndpoint('podinfo-web', 'podinfo', 9898, { namespace: 'podinfo' })
+ .waitForCompletion(podinfo);
+
+// Standard withReference β injects KUBECONFIG or services__name__url
+await builder.addProject('operator', '../MyOperator/MyOperator.csproj')
+ .withReference(cluster);
+
+await builder.addProject('api', '../MyApi/MyApi.csproj')
+ .withReference(podinfoWeb);
+
+await builder.build().run();
+```
+
+## Reaching Aspire services from k3s pods
+
+k3s pods run on the internal pod network (`10.42.0.0/16`). Flannel masquerades outbound
+pod traffic through the k3s container's DCP network IP, so pods can reach DCP services
+using `host.docker.internal` and the host-mapped port.
+
+```csharp
+var postgres = builder.AddPostgres("db");
+
+cluster.AddHelmRelease("my-operator", "my-operator-chart")
+ .WithHelmValue("database.host", "host.docker.internal")
+ .WithHelmValue("database.port",
+ postgres.GetEndpoint("tcp").Property(EndpointProperty.Port));
+```
+
+## Additional information
+
+https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-k3s
+
+## Feedback & contributing
+
+https://github.com/CommunityToolkit/Aspire
diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/api/CommunityToolkit.Aspire.Hosting.K3s.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/api/CommunityToolkit.Aspire.Hosting.K3s.cs
new file mode 100644
index 000000000..52c8f0375
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.K3s/api/CommunityToolkit.Aspire.Hosting.K3s.cs
@@ -0,0 +1,182 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+namespace Aspire.Hosting
+{
+ public static partial class K3sBuilderExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder AddK3sCluster(this IDistributedApplicationBuilder builder, string name, int? apiServerPort = null, int? agentCount = null) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithAgentCount(this ApplicationModel.IResourceBuilder builder, int count) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithDisabledComponent(this ApplicationModel.IResourceBuilder builder, string component) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithExtraArg(this ApplicationModel.IResourceBuilder builder, string arg) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithHelmImage(this ApplicationModel.IResourceBuilder builder, string? tag = null, string? image = null, string? registry = null) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithK3sVersion(this ApplicationModel.IResourceBuilder builder, string tag) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithKubectlImage(this ApplicationModel.IResourceBuilder builder, string? tag = null, string? image = null, string? registry = null) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithLifetime(this ApplicationModel.IResourceBuilder builder, ApplicationModel.ContainerLifetime lifetime) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithPodSubnet(this ApplicationModel.IResourceBuilder builder, string cidr) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithServiceSubnet(this ApplicationModel.IResourceBuilder builder, string cidr) { throw null; }
+
+ [AspireExportIgnore(Reason = "Marker only. The actual injection (KUBECONFIG env var, container bind-mount) is applied by the BeforeStartEvent subscriber registered in AddK3sCluster, which owns the behavior because the cluster knows what to inject.")]
+ public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder destination, ApplicationModel.IResourceBuilder source)
+ where TDestination : ApplicationModel.IResourceWithEnvironment { throw null; }
+ }
+
+ public static partial class K3sHelmBuilderExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder AddHelmRelease(this ApplicationModel.IResourceBuilder builder, string name, string chart, string? repo = null, string? version = null, string @namespace = "default") { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithHelmValue(this ApplicationModel.IResourceBuilder builder, string key, string value) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithHelmValuesFile(this ApplicationModel.IResourceBuilder builder, string path) { throw null; }
+ }
+
+ public static partial class K3sManifestBuilderExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder AddK8sManifest(this ApplicationModel.IResourceBuilder builder, string name, string path) { throw null; }
+ }
+
+ public static partial class K3sServiceEndpointExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder AddServiceEndpoint(this ApplicationModel.IResourceBuilder builder, string name, string serviceName, int servicePort, string @namespace = "default", string? scheme = null) { throw null; }
+
+ [AspireExportIgnore(Reason = "Marker only. The actual injection (services__name__url env var, --add-host for containers) is applied by the BeforeStartEvent subscriber registered in AddK3sCluster, which owns the behavior.")]
+ public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder destination, ApplicationModel.IResourceBuilder source)
+ where TDestination : ApplicationModel.IResourceWithEnvironment { throw null; }
+ }
+}
+
+namespace Aspire.Hosting.ApplicationModel
+{
+ [AspireExport(ExposeProperties = true)]
+ public sealed partial class HelmReleaseResource : ContainerResource, IResourceWithParent, IResourceWithParent, IResource
+ {
+ public HelmReleaseResource(string name, string releaseName, string @namespace, K3sClusterResource cluster) : base(default!, default) { }
+
+ public string Namespace { get { throw null; } }
+
+ public K3sClusterResource Parent { get { throw null; } }
+
+ public string ReleaseName { get { throw null; } }
+ }
+
+ public sealed partial class K3sAgentResource : ContainerResource, IResourceWithParent, IResourceWithParent, IResource
+ {
+ public K3sAgentResource(string name, K3sClusterResource cluster) : base(default!, default) { }
+
+ public K3sClusterResource Parent { get { throw null; } }
+ }
+
+ [AspireExport(ExposeProperties = true)]
+ public sealed partial class K3sClusterResource : ContainerResource, IResourceWithConnectionString
+ {
+ public K3sClusterResource(string name) : base(default!, default) { }
+
+ public EndpointReference ApiEndpoint { get { throw null; } }
+
+ public string? ConnectionStringEnvironmentVariable { get { throw null; } }
+
+ public ReferenceExpression ConnectionStringExpression { get { throw null; } }
+
+ [AspireExportIgnore(Reason = "Internal tracking collection; not needed by guest SDK consumers.")]
+ public System.Collections.Generic.IReadOnlyDictionary HelmReleases { get { throw null; } }
+
+ [AspireExportIgnore(Reason = "Internal tracking collection; not needed by guest SDK consumers.")]
+ public System.Collections.Generic.IReadOnlyList Manifests { get { throw null; } }
+
+ public System.Threading.Tasks.ValueTask GetConnectionStringAsync(System.Threading.CancellationToken cancellationToken = default) { throw null; }
+ }
+
+ [AspireExport(ExposeProperties = true)]
+ public sealed partial class K3sServiceEndpointResource : Resource, IResourceWithParent, IResourceWithParent, IResource, IResourceWithWaitSupport, IResourceWithConnectionString
+ {
+ public K3sServiceEndpointResource(string name, string serviceName, int servicePort, string @namespace, K3sClusterResource cluster) : base(default!) { }
+
+ public string? ConnectionStringEnvironmentVariable { get { throw null; } }
+
+ public ReferenceExpression ConnectionStringExpression { get { throw null; } }
+
+ public int HostPort { get { throw null; } }
+
+ public string Namespace { get { throw null; } }
+
+ public K3sClusterResource Parent { get { throw null; } }
+
+ public string ServiceName { get { throw null; } }
+
+ public int ServicePort { get { throw null; } }
+
+ public System.Threading.Tasks.ValueTask GetConnectionStringAsync(System.Threading.CancellationToken cancellationToken = default) { throw null; }
+ }
+
+ [AspireExport(ExposeProperties = true)]
+ public sealed partial class K8sManifestResource : ContainerResource, IResourceWithParent, IResourceWithParent, IResource
+ {
+ public K8sManifestResource(string name, string path, K3sClusterResource cluster) : base(default!, default) { }
+
+ public K3sClusterResource Parent { get { throw null; } }
+
+ public string Path { get { throw null; } }
+ }
+}
+
+namespace CommunityToolkit.Aspire.Hosting
+{
+ public sealed partial class K3sClusterOptions
+ {
+ public int AgentCount { get { throw null; } set { } }
+
+ public string? ClusterCidr { get { throw null; } set { } }
+
+ public System.Collections.Generic.IList DisabledComponents { get { throw null; } }
+
+ public System.Collections.Generic.IList ExtraArgs { get { throw null; } }
+
+ public string HelmImage { get { throw null; } set { } }
+
+ public string HelmRegistry { get { throw null; } set { } }
+
+ public string HelmTag { get { throw null; } set { } }
+
+ public string? ImageTag { get { throw null; } set { } }
+
+ public string KubectlImage { get { throw null; } set { } }
+
+ public string KubectlRegistry { get { throw null; } set { } }
+
+ public string KubectlTag { get { throw null; } set { } }
+
+ public string? ServiceCidr { get { throw null; } set { } }
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/CommunityToolkit.Aspire.Hosting.K3s.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/CommunityToolkit.Aspire.Hosting.K3s.Tests.csproj
new file mode 100644
index 000000000..42dfef9e7
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/CommunityToolkit.Aspire.Hosting.K3s.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/HelmReleaseResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/HelmReleaseResourceTests.cs
new file mode 100644
index 000000000..0fba5614e
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/HelmReleaseResourceTests.cs
@@ -0,0 +1,613 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+
+namespace CommunityToolkit.Aspire.Hosting.K3s.Tests;
+
+public class HelmReleaseResourceTests
+{
+ [Fact]
+ public void AddHelmReleaseAddsHelmReleaseResourceWithCorrectName()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Equal("argocd", resource.Name);
+ }
+
+ [Fact]
+ public void HelmReleaseResourceIsContainerResource()
+ {
+ // HelmReleaseResource extends ContainerResource β it runs bitnami/helm in Docker.
+ // No host-side helm binary required. WaitForCompletion waits for exit code 0.
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.IsAssignableFrom(resource);
+ }
+
+ [Fact]
+ public void AddHelmReleaseDefaultsReleaseName()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("my-release", "my-chart");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Equal("my-release", resource.ReleaseName);
+ }
+
+ [Fact]
+ public void AddHelmReleaseDefaultsNamespace()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Equal("default", resource.Namespace);
+ }
+
+ [Fact]
+ public void AddHelmReleaseWithExplicitNamespace()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd", @namespace: "argocd");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Equal("argocd", resource.Namespace);
+ }
+
+ [Fact]
+ public void AddHelmReleaseStoresRepoUrl()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease(
+ "argocd", "argo-cd",
+ repo: "https://argoproj.github.io/argo-helm");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Equal("https://argoproj.github.io/argo-helm", resource.RepoUrl);
+ }
+
+ [Fact]
+ public void AddHelmReleaseStoresVersion()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd", version: "7.8.0");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Equal("7.8.0", resource.Version);
+ }
+
+ [Fact]
+ public void HelmReleaseParentIsCluster()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+ cluster.AddHelmRelease("argocd", "argo-cd");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Same(cluster.Resource, resource.Parent);
+ Assert.IsAssignableFrom>(resource);
+ }
+
+ [Fact]
+ public void WithHelmValueAccumulatesValues()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd")
+ .WithHelmValue("server.service.type", "NodePort")
+ .WithHelmValue("server.insecure", "true");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Equal("NodePort", resource.HelmValues["server.service.type"]);
+ Assert.Equal("true", resource.HelmValues["server.insecure"]);
+ }
+
+ [Fact]
+ public void AddServiceEndpointAddsEndpointResource()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+ cluster.AddHelmRelease("argocd", "argo-cd");
+
+ cluster.AddServiceEndpoint("argocd-ui", "argocd-server", servicePort: 443, @namespace: "argocd");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var ep = Assert.Single(model.Resources.OfType());
+ Assert.Equal("argocd-server", ep.ServiceName);
+ Assert.Equal(443, ep.ServicePort);
+ Assert.Equal("argocd", ep.Namespace);
+ }
+
+ [Fact]
+ public void AddServiceEndpointMultipleEndpointsAllRegistered()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddServiceEndpoint("ui", "argocd-server", 443);
+ cluster.AddServiceEndpoint("http", "argocd-server", 80);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ Assert.Equal(2, model.Resources.OfType().Count());
+ }
+
+ [Fact]
+ public void HelmReleaseIsExcludedFromManifest()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Contains(ManifestPublishingCallbackAnnotation.Ignore, resource.Annotations);
+ }
+
+ // ββ BuildHelmScript tests (pure logic, no DI needed) ββββββββββββββββββββββ
+
+ private static HelmReleaseResource MakeRelease(
+ string releaseName, string chart, string? repo, string? version,
+ string @namespace, Dictionary? values = null)
+ {
+ var cluster = new K3sClusterResource("k8s");
+ var r = new HelmReleaseResource(releaseName, releaseName, @namespace, cluster)
+ {
+ Chart = chart,
+ RepoUrl = repo,
+ Version = version,
+ };
+ foreach (var kv in values ?? [])
+ r.HelmValues[kv.Key] = kv.Value;
+ return r;
+ }
+
+ [Fact]
+ public void BuildHelmScriptIncludesUpgradeInstall()
+ {
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(
+ MakeRelease("argocd", "argo-cd", null, null, "argocd"));
+
+ Assert.Contains("helm upgrade --install", script);
+ Assert.Contains("'argocd'", script);
+ Assert.Contains("'argo-cd'", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptIncludesWaitAndNamespace()
+ {
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(
+ MakeRelease("r", "chart", null, null, "my-ns"));
+
+ Assert.Contains("--wait", script);
+ Assert.Contains("--namespace 'my-ns'", script);
+ Assert.Contains("--create-namespace", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptWithRepoAddsRepoSteps()
+ {
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(
+ MakeRelease("r", "chart", "https://my-repo.example.com", null, "default"));
+
+ Assert.Contains("helm repo add", script);
+ Assert.Contains("helm repo update", script);
+ Assert.Contains("aspire-k3s-r/chart", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptWithoutRepoSkipsRepoSteps()
+ {
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(
+ MakeRelease("r", "oci://registry/chart", null, null, "default"));
+
+ Assert.DoesNotContain("helm repo add", script);
+ Assert.Contains("'oci://registry/chart'", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptIncludesVersion()
+ {
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(
+ MakeRelease("r", "chart", null, "7.8.0", "default"));
+
+ Assert.Contains("--version '7.8.0'", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptOmitsVersionWhenNull()
+ {
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(
+ MakeRelease("r", "chart", null, null, "default"));
+
+ Assert.DoesNotContain("--version", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptIncludesSetValues()
+ {
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(
+ MakeRelease("r", "chart", null, null, "default", new()
+ {
+ ["service.type"] = "NodePort",
+ ["replicaCount"] = "2",
+ }));
+
+ Assert.Contains("--set 'service.type=NodePort'", script);
+ Assert.Contains("--set 'replicaCount=2'", script);
+ }
+
+ // ββ WaitForCompletion support βββββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void HelmReleaseHasNoHealthCheckAnnotation()
+ {
+ // HelmReleaseResource is a run-to-completion container β consumers use
+ // WaitForCompletion(helmRelease) rather than WaitFor. No health check needed.
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+ cluster.AddHelmRelease("argocd", "argo-cd");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Empty(resource.Annotations.OfType());
+ }
+
+ // ββ Public API null-guard tests βββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void AddHelmReleaseShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ var action = () => builder.AddHelmRelease("argocd", "argo-cd");
+ Assert.Throws(action);
+ }
+
+ [Fact]
+ public void AddHelmReleaseShouldThrowWhenNameIsNull()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+ var action = () => cluster.AddHelmRelease(null!, "argo-cd");
+ Assert.Throws(action);
+ }
+
+ [Fact]
+ public void AddHelmReleaseShouldThrowWhenChartIsNull()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+ var action = () => cluster.AddHelmRelease("argocd", null!);
+ Assert.Throws(action);
+ }
+
+ [Fact]
+ public void WithHelmValueShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ var action = () => builder.WithHelmValue("key", "value");
+ Assert.Throws(action);
+ }
+
+ [Fact]
+ public void AddServiceEndpointShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ var action = () => builder.AddServiceEndpoint("ui", "svc", 443);
+ Assert.Throws(action);
+ }
+
+ // ββ WithHelmValuesFile tests ββββββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void BuildHelmScriptIncludesValuesFilesWithIndexPrefix()
+ {
+ var cluster = new K3sClusterResource("k8s");
+ var release = new HelmReleaseResource("argocd", "argocd", "argocd", cluster)
+ {
+ Chart = "argo-cd",
+ };
+ release.ValuesFiles.Add("/tmp/values.yaml");
+ release.ValuesFiles.Add("/tmp/values-prod.yaml");
+
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(release);
+
+ // Index prefix guarantees uniqueness even when basenames collide.
+ Assert.Contains("--values '/helm-values/0-values.yaml'", script);
+ Assert.Contains("--values '/helm-values/1-values-prod.yaml'", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptValuesFilesOrderedBeforeSetFlags()
+ {
+ // Helm precedence: --values (ascending index) β --set (highest, always wins).
+ var cluster = new K3sClusterResource("k8s");
+ var release = new HelmReleaseResource("argocd", "argocd", "argocd", cluster)
+ {
+ Chart = "argo-cd",
+ };
+ release.ValuesFiles.Add("/tmp/base.yaml");
+ release.ValuesFiles.Add("/tmp/prod.yaml");
+ release.HelmValues["key"] = "override";
+
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(release);
+
+ var firstValuesIdx = script.IndexOf("--values '/helm-values/0-", StringComparison.Ordinal);
+ var secondValuesIdx = script.IndexOf("--values '/helm-values/1-", StringComparison.Ordinal);
+ var setIdx = script.IndexOf("--set", StringComparison.Ordinal);
+
+ Assert.True(firstValuesIdx < secondValuesIdx, "0-base.yaml must precede 1-prod.yaml");
+ Assert.True(secondValuesIdx < setIdx, "--values flags must precede --set flags");
+ }
+
+ [Fact]
+ public void BuildHelmScriptCollisionSafeWithSameBasename()
+ {
+ // Two files from different directories with the same name must not collide.
+ var cluster = new K3sClusterResource("k8s");
+ var release = new HelmReleaseResource("r", "r", "default", cluster) { Chart = "chart" };
+ release.ValuesFiles.Add("/prod/values.yaml");
+ release.ValuesFiles.Add("/dev/values.yaml");
+
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(release);
+
+ Assert.Contains("--values '/helm-values/0-values.yaml'", script);
+ Assert.Contains("--values '/helm-values/1-values.yaml'", script);
+ }
+
+ [Fact]
+ public void WithHelmValuesFileAccumulatesAbsolutePaths()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ // Use a temp file so the path resolution succeeds.
+ var tempFile = Path.Combine(appBuilder.AppHostDirectory, "values.yaml");
+
+ cluster.AddHelmRelease("argocd", "argo-cd")
+ .WithHelmValuesFile("values.yaml"); // relative to AppHostDirectory
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Single(resource.ValuesFiles);
+ Assert.Equal(tempFile, resource.ValuesFiles[0]);
+ }
+
+ [Fact]
+ public void WithHelmValuesFileShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ var action = () => builder.WithHelmValuesFile("values.yaml");
+ Assert.Throws(action);
+ }
+
+ // ββ WithHelmValue override ββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void WithHelmValueOverridesDuplicateKey()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd")
+ .WithHelmValue("replicaCount", "1")
+ .WithHelmValue("replicaCount", "3");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+
+ // Dictionary last-write wins β final value must be "3".
+ Assert.Equal("3", resource.HelmValues["replicaCount"]);
+ }
+
+ [Fact]
+ public void BuildHelmScriptDuplicateKeyAppearsOnce()
+ {
+ var cluster = new K3sClusterResource("k8s");
+ var release = new HelmReleaseResource("r", "r", "default", cluster) { Chart = "chart" };
+ release.HelmValues["replicaCount"] = "3";
+
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(release);
+
+ // Dictionary deduplication: key appears exactly once in the --set flags.
+ Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "replicaCount").Cast());
+ }
+
+ // ββ HelmEscape / ShellEscape via BuildHelmScript ββββββββββββββββββββββββββ
+
+ [Fact]
+ public void BuildHelmScriptHelmEscapesCommaInValue()
+ {
+ var cluster = new K3sClusterResource("k8s");
+ var release = new HelmReleaseResource("r", "r", "default", cluster) { Chart = "chart" };
+ release.HelmValues["tags"] = "a,b,c";
+
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(release);
+
+ // Helm --set comma is a list delimiter; must be backslash-escaped.
+ Assert.Contains(@"a\,b\,c", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptHelmEscapesOpenBraceInValue()
+ {
+ var cluster = new K3sClusterResource("k8s");
+ var release = new HelmReleaseResource("r", "r", "default", cluster) { Chart = "chart" };
+ release.HelmValues["config"] = "{key:val}";
+
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(release);
+
+ Assert.Contains(@"\{key:val\}", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptHelmEscapesBackslashInValue()
+ {
+ var cluster = new K3sClusterResource("k8s");
+ var release = new HelmReleaseResource("r", "r", "default", cluster) { Chart = "chart" };
+ release.HelmValues["path"] = @"C:\data";
+
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(release);
+
+ // Backslash must be doubled so Helm does not treat it as an escape prefix.
+ Assert.Contains(@"C:\\data", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptShellEscapesSingleQuoteInValue()
+ {
+ var cluster = new K3sClusterResource("k8s");
+ var release = new HelmReleaseResource("r", "r", "default", cluster) { Chart = "chart" };
+ release.HelmValues["msg"] = "it's";
+
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(release);
+
+ // POSIX single-quote escape: ' β '\''
+ Assert.Contains("it'\\''s", script);
+ }
+
+ [Fact]
+ public void BuildHelmScriptHelmEscapesCommaInKey()
+ {
+ // Helm --set parser applies the same metacharacter rules to keys.
+ var cluster = new K3sClusterResource("k8s");
+ var release = new HelmReleaseResource("r", "r", "default", cluster) { Chart = "chart" };
+ release.HelmValues["a,b"] = "v";
+
+ var script = K3sHelmBuilderExtensions.BuildHelmScript(release);
+
+ Assert.Contains(@"a\,b=v", script);
+ }
+
+ // ββ WithHelmValuesFile with absolute path βββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void WithHelmValuesFileStoresAbsolutePathAsIs()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+ var absolutePath = Path.Combine(Path.GetTempPath(), "values.yaml");
+
+ cluster.AddHelmRelease("argocd", "argo-cd")
+ .WithHelmValuesFile(absolutePath);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ var file = Assert.Single(resource.ValuesFiles);
+ Assert.Equal(absolutePath, file);
+ }
+
+ [Fact]
+ public void WithHelmValuesFileMultipleFilesAccumulate()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd")
+ .WithHelmValuesFile("base.yaml")
+ .WithHelmValuesFile("prod.yaml");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Equal(2, resource.ValuesFiles.Count);
+ }
+
+ // ββ Cluster HelmReleases tracking βββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void ClusterTracksRegisteredHelmReleases()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+
+ cluster.AddHelmRelease("argocd", "argo-cd");
+ cluster.AddHelmRelease("cert-manager", "cert-manager");
+
+ Assert.Contains("argocd", cluster.Resource.HelmReleases.Keys);
+ Assert.Contains("cert-manager", cluster.Resource.HelmReleases.Keys);
+ Assert.Equal(2, cluster.Resource.HelmReleases.Count);
+ }
+
+ // ββ Service endpoint from a different cluster not included ββββββββββββββββ
+
+ [Fact]
+ public void ServiceEndpointParentMatchesItsCluster()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var clusterA = appBuilder.AddK3sCluster("k8s-a");
+ var clusterB = appBuilder.AddK3sCluster("k8s-b");
+
+ clusterA.AddServiceEndpoint("ep-a", "svc", 80);
+ clusterB.AddServiceEndpoint("ep-b", "svc", 80);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var eps = model.Resources.OfType().ToList();
+ Assert.Equal(2, eps.Count);
+
+ Assert.Same(clusterA.Resource, eps.Single(e => e.Name == "ep-a").Parent);
+ Assert.Same(clusterB.Resource, eps.Single(e => e.Name == "ep-b").Parent);
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sAgentNodeTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sAgentNodeTests.cs
new file mode 100644
index 000000000..c0f5eea16
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sAgentNodeTests.cs
@@ -0,0 +1,299 @@
+using Aspire.Hosting;
+using Aspire.Hosting.Eventing;
+
+namespace CommunityToolkit.Aspire.Hosting.K3s.Tests;
+
+public class K3sAgentNodeTests
+{
+ // ββ Agent creation via K3sClusterOptions βββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void AgentCountInOptionsCreatesK3sAgentResources()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+
+ appBuilder.AddK3sCluster("k8s", agentCount: 2);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var agents = model.Resources.OfType().ToList();
+
+ Assert.Equal(2, agents.Count);
+ Assert.Contains(agents, a => a.Name == "k8s-agent-0");
+ Assert.Contains(agents, a => a.Name == "k8s-agent-1");
+ }
+
+ [Fact]
+ public void AgentNodesAreChildrenOfCluster()
+ {
+ // Implements IResourceWithParent so they appear nested under k8s in the dashboard.
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s", agentCount: 1);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var agent = Assert.Single(model.Resources.OfType());
+ Assert.Same(cluster.Resource, agent.Parent);
+
+ // Non-generic IResourceWithParent β used by the dashboard for grouping.
+ var nonGeneric = agent as IResourceWithParent;
+ Assert.NotNull(nonGeneric);
+ Assert.Same(cluster.Resource, nonGeneric.Parent);
+ }
+
+ [Fact]
+ public void AgentCountZeroProducesNoAgentResources()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ Assert.Empty(model.Resources.OfType());
+ }
+
+ [Fact]
+ public void AgentCountUpdatesClusterAgentCount()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s", agentCount: 3);
+
+ Assert.Equal(3, cluster.Resource.AgentCount);
+ }
+
+ [Fact]
+ public void AgentNodesDoNotHaveWaitForDependencyOnCluster()
+ {
+ // Agents must NOT WaitFor the cluster β that would create a deadlock because the
+ // cluster health check waits for all nodes (including agents) to be Ready.
+ // Instead, k3s agent retries connecting to the server independently.
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s", agentCount: 1);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var agent = Assert.Single(model.Resources.OfType());
+ var waitAnnotations = agent.Annotations.OfType().ToList();
+
+ Assert.DoesNotContain(waitAnnotations, w => w.Resource is K3sClusterResource);
+ }
+
+ [Fact]
+ public void AgentNodesUseSameImageAsServer()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s", agentCount: 1);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var agent = Assert.Single(model.Resources.OfType());
+ var img = Assert.Single(agent.Annotations.OfType());
+ Assert.Equal(K3sContainerImageTags.Image, img.Image);
+ Assert.Equal(K3sContainerImageTags.Registry, img.Registry);
+ }
+
+ [Fact]
+ public void AgentNodesAreExcludedFromManifest()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s", agentCount: 1);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var agent = Assert.Single(model.Resources.OfType());
+ Assert.Contains(ManifestPublishingCallbackAnnotation.Ignore, agent.Annotations);
+ }
+
+ [Fact]
+ public void AgentNodesHaveEnvironmentAnnotations()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s", agentCount: 1);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var agent = Assert.Single(model.Resources.OfType());
+ var envCallbacks = agent.Annotations.OfType().ToList();
+ Assert.True(envCallbacks.Count >= 3,
+ $"Expected at least 3 env annotations (K3S_URL, K3S_TOKEN, K3S_NODE_NAME), got {envCallbacks.Count}");
+ }
+
+ [Fact]
+ public void DefaultClusterHasNoAgentNodes()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s");
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ Assert.Empty(model.Resources.OfType());
+ }
+
+ [Fact]
+ public void AgentNodeNamesFollowConvention()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("mycluster", agentCount: 3);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ Assert.Contains(model.Resources, r => r.Name == "mycluster-agent-0");
+ Assert.Contains(model.Resources, r => r.Name == "mycluster-agent-1");
+ Assert.Contains(model.Resources, r => r.Name == "mycluster-agent-2");
+ }
+
+ [Fact]
+ public void NegativeAgentCountIsIgnored()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s", agentCount: -1);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ Assert.Empty(model.Resources.OfType());
+ }
+
+ // ββ WithAgentCount ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void WithAgentCountAddsCorrectNumberOfAgentResources()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s").WithAgentCount(3);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var agents = model.Resources.OfType().ToList();
+ Assert.Equal(3, agents.Count);
+ }
+
+ [Fact]
+ public void WithAgentCountZeroIsNoOp()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s").WithAgentCount(0);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ Assert.Empty(model.Resources.OfType());
+ }
+
+ [Fact]
+ public void WithAgentCountNamesAgentsSequentially()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s").WithAgentCount(2);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var names = model.Resources.OfType().Select(r => r.Name).ToList();
+ Assert.Contains("k8s-agent-0", names);
+ Assert.Contains("k8s-agent-1", names);
+ }
+
+ [Fact]
+ public void WithAgentCountAgentsUseClusterImageTag()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder.AddK3sCluster("k8s")
+ .WithK3sVersion("v1.30.0-k3s1")
+ .WithAgentCount(1);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var agent = Assert.Single(model.Resources.OfType());
+ var img = Assert.Single(agent.Annotations.OfType());
+ Assert.Equal("v1.30.0-k3s1", img.Tag);
+ }
+
+ [Fact]
+ public void WithAgentCountIsEquivalentToConfigureAgentCount()
+ {
+ // WithAgentCount(2) should produce the same agent resources as
+ // AddK3sCluster with configure: cfg => cfg.AgentCount = 2
+ var b1 = DistributedApplication.CreateBuilder();
+ b1.AddK3sCluster("k8s", agentCount: 2);
+ using var app1 = b1.Build();
+ var agents1 = app1.Services.GetRequiredService()
+ .Resources.OfType().ToList();
+
+ var b2 = DistributedApplication.CreateBuilder();
+ b2.AddK3sCluster("k8s").WithAgentCount(2);
+ using var app2 = b2.Build();
+ var agents2 = app2.Services.GetRequiredService()
+ .Resources.OfType().ToList();
+
+ Assert.Equal(agents1.Count, agents2.Count);
+ Assert.Equal(
+ agents1.Select(a => a.Name).OrderBy(n => n),
+ agents2.Select(a => a.Name).OrderBy(n => n));
+ }
+
+ [Fact]
+ public void WithAgentCountShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+ var action = () => builder.WithAgentCount(2);
+ Assert.Throws(action);
+ }
+
+ [Fact]
+ public void WithAgentCountShouldThrowWhenCountIsNegative()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var cluster = appBuilder.AddK3sCluster("k8s");
+ var action = () => cluster.WithAgentCount(-1);
+ Assert.Throws(action);
+ }
+
+ [Fact]
+ public void WithLifetimePersistentPropagatestoAgentNodes()
+ {
+ // ContainerLifetimeAnnotation must propagate immediately at call time β
+ // DCP uses it to compute container identity before BeforeStartEvent fires.
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder
+ .AddK3sCluster("k8s", agentCount: 2)
+ .WithLifetime(ContainerLifetime.Persistent);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+ var agents = model.Resources.OfType().ToList();
+ Assert.Equal(2, agents.Count);
+
+ foreach (var agent in agents)
+ {
+ var annotation = Assert.Single(agent.Annotations.OfType());
+ Assert.Equal(ContainerLifetime.Persistent, annotation.Lifetime);
+ }
+ }
+
+ [Fact]
+ public void WithLifetimeSessionDoesNotAddPersistentAnnotationToAgents()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ appBuilder
+ .AddK3sCluster("k8s", agentCount: 1)
+ .WithLifetime(ContainerLifetime.Session);
+
+ using var app = appBuilder.Build();
+ var model = app.Services.GetRequiredService();
+ var agent = Assert.Single(model.Resources.OfType());
+ var annotation = Assert.Single(agent.Annotations.OfType());
+ Assert.Equal(ContainerLifetime.Session, annotation.Lifetime);
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sClusterOptionsTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sClusterOptionsTests.cs
new file mode 100644
index 000000000..be5df9cf0
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sClusterOptionsTests.cs
@@ -0,0 +1,86 @@
+using CommunityToolkit.Aspire.Hosting;
+
+namespace CommunityToolkit.Aspire.Hosting.K3s.Tests;
+
+public class K3sClusterOptionsTests
+{
+ // ββ Default values ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void DefaultsProduceSingleNodeEphemeralCluster()
+ {
+ var opts = new K3sClusterOptions();
+
+ Assert.Equal(0, opts.AgentCount);
+ Assert.Null(opts.ClusterCidr);
+ Assert.Null(opts.ServiceCidr);
+ Assert.Null(opts.ImageTag);
+ Assert.Empty(opts.DisabledComponents);
+ Assert.Empty(opts.ExtraArgs);
+ }
+
+ [Fact]
+ public void HelmImageDefaultsMatchPackageBundledVersion()
+ {
+ var opts = new K3sClusterOptions();
+
+ Assert.Equal(HelmContainerImageTags.Registry, opts.HelmRegistry);
+ Assert.Equal(HelmContainerImageTags.Image, opts.HelmImage);
+ Assert.Equal(HelmContainerImageTags.Tag, opts.HelmTag);
+ }
+
+ [Fact]
+ public void KubectlImageDefaultsMatchPackageBundledVersion()
+ {
+ var opts = new K3sClusterOptions();
+
+ Assert.Equal(KubectlContainerImageTags.Registry, opts.KubectlRegistry);
+ Assert.Equal(KubectlContainerImageTags.Image, opts.KubectlImage);
+ Assert.Equal(KubectlContainerImageTags.Tag, opts.KubectlTag);
+ }
+
+ // ββ Mutation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void CanSetAllScalarProperties()
+ {
+ var opts = new K3sClusterOptions
+ {
+ AgentCount = 3,
+ ClusterCidr = "10.88.0.0/16",
+ ServiceCidr = "10.89.0.0/16",
+ ImageTag = "v1.32.3-k3s1",
+ HelmRegistry = "my.registry.io",
+ HelmImage = "my/helm",
+ HelmTag = "3.18.0",
+ KubectlRegistry = "my.registry.io",
+ KubectlImage = "my/kubectl",
+ KubectlTag = "1.37.0",
+ };
+
+ Assert.Equal(3, opts.AgentCount);
+ Assert.Equal("10.88.0.0/16", opts.ClusterCidr);
+ Assert.Equal("10.89.0.0/16", opts.ServiceCidr);
+ Assert.Equal("v1.32.3-k3s1", opts.ImageTag);
+ Assert.Equal("my.registry.io", opts.HelmRegistry);
+ Assert.Equal("my/helm", opts.HelmImage);
+ Assert.Equal("3.18.0", opts.HelmTag);
+ Assert.Equal("my.registry.io", opts.KubectlRegistry);
+ Assert.Equal("my/kubectl", opts.KubectlImage);
+ Assert.Equal("1.37.0", opts.KubectlTag);
+ }
+
+ [Fact]
+ public void DisabledComponentsAndExtraArgsAccumulateItems()
+ {
+ var opts = new K3sClusterOptions();
+ opts.DisabledComponents.Add("traefik");
+ opts.DisabledComponents.Add("coredns");
+ opts.ExtraArgs.Add("--write-kubeconfig-mode=644");
+
+ Assert.Equal(2, opts.DisabledComponents.Count);
+ Assert.Single(opts.ExtraArgs);
+ Assert.Contains("traefik", opts.DisabledComponents);
+ Assert.Contains("--write-kubeconfig-mode=644", opts.ExtraArgs);
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sClusterResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sClusterResourceTests.cs
new file mode 100644
index 000000000..994675867
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sClusterResourceTests.cs
@@ -0,0 +1,757 @@
+using System.Net.Sockets;
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Eventing;
+using CommunityToolkit.Aspire.Hosting;
+
+namespace CommunityToolkit.Aspire.Hosting.K3s.Tests;
+
+public class K3sClusterResourceTests
+{
+ [Fact]
+ public void AddK3sClusterAddsResourceWithCorrectName()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+
+ appBuilder.AddK3sCluster("k8s");
+
+ using var app = appBuilder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal("k8s", resource.Name);
+ }
+
+ [Fact]
+ public void AddK3sClusterAddsCorrectContainerImage()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+
+ appBuilder.AddK3sCluster("k8s");
+
+ using var app = appBuilder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ var annotation = Assert.Single(resource.Annotations.OfType());
+
+ Assert.Equal(K3sContainerImageTags.Image, annotation.Image);
+ Assert.Equal(K3sContainerImageTags.Tag, annotation.Tag);
+ Assert.Equal(K3sContainerImageTags.Registry, annotation.Registry);
+ }
+
+ [Fact]
+ public void AddK3sClusterAddsApiServerEndpointOnPort6443()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+
+ appBuilder.AddK3sCluster("k8s");
+
+ using var app = appBuilder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ var endpoint = Assert.Single(
+ resource.Annotations.OfType(),
+ e => e.Name == K3sClusterResource.ApiServerEndpointName);
+
+ Assert.Equal(6443, endpoint.TargetPort);
+ Assert.Equal(ProtocolType.Tcp, endpoint.Protocol);
+ Assert.Equal("https", endpoint.UriScheme);
+ }
+
+ [Fact]
+ public void AddK3sClusterWithExplicitPortBindsToThatPort()
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+
+ appBuilder.AddK3sCluster("k8s", apiServerPort: 16443);
+
+ using var app = appBuilder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+ var endpoint = Assert.Single(
+ resource.Annotations.OfType