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(), + e => e.Name == K3sClusterResource.ApiServerEndpointName); + + Assert.Equal(6443, endpoint.TargetPort); + Assert.Equal(16443, endpoint.Port); + } + + [Fact] + public void AddK3sClusterAddsServerArg() + { + 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 commandLineArgs = resource.Annotations.OfType(); + Assert.NotEmpty(commandLineArgs); + } + + [Fact] + public void WithK3sVersionOverridesImageTag() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithK3sVersion("v1.32.3-k3s1"); + + 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("v1.32.3-k3s1", annotation.Tag); + } + + [Fact] + public void AddK3sClusterHasNoVolumeByDefault() + { + // Persistence is opt-in via WithPersistentState(). + // No volume is mounted by default so the cluster is ephemeral. + 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.DoesNotContain( + resource.Annotations.OfType(), + v => v.Target == "/var/lib/rancher/k3s" && v.Type == ContainerMountType.Volume); + } + + [Fact] + public void WithDataVolumeAddsSingleVolumeMount() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithDataVolume(); + + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var volume = Assert.Single( + resource.Annotations.OfType(), + v => v.Target == "/var/lib/rancher/k3s" && v.Type == ContainerMountType.Volume); + + // VolumeNameGenerator format: {appName}-{sha256}-{resourceName}-data + Assert.EndsWith("-k8s-data", volume.Source); + } + + [Fact] + public void WithDataVolumeUsesCustomName() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithDataVolume("my-k3s-data"); + + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var volume = Assert.Single( + resource.Annotations.OfType(), + v => v.Target == "/var/lib/rancher/k3s" && v.Type == ContainerMountType.Volume); + + Assert.Equal("my-k3s-data", volume.Source); + } + + [Fact] + public void AddK3sClusterWithAgentCountParam() + { + // agentCount is now a direct nullable parameter on AddK3sCluster, + // equivalent to calling WithAgentCount() on the returned builder. + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s", agentCount: 2); + + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(2, resource.AgentCount); + Assert.Equal(2, appModel.Resources.OfType().Count()); + } + + [Fact] + public void WithReferenceAddsResourceRelationshipAnnotationForProject() + { + // K3sClusterResource implements IResourceWithConnectionString so the standard + // Aspire WithReference overload is used β€” it adds a ResourceRelationshipAnnotation. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var exe = appBuilder.AddExecutable("myapp", "myapp", "."); + + exe.WithReference(cluster); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var exeResource = Assert.Single(model.Resources.OfType()); + + // Standard WithReference adds a ResourceRelationshipAnnotation pointing to the cluster. + Assert.Contains( + exeResource.Annotations.OfType(), + a => ReferenceEquals(a.Resource, cluster.Resource)); + } + + [Fact] + public void WithReferenceSetsKubeconfigEnvForProject() + { + // Standard WithReference(K3sClusterResource) uses IResourceWithConnectionString to inject + // KUBECONFIG for host processes. Verify the env callback annotation is present. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var exe = appBuilder.AddExecutable("myapp", "myapp", "."); + exe.WithReference(cluster); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var exeResource = Assert.Single(model.Resources.OfType()); + + Assert.Contains( + exeResource.Annotations.OfType(), + a => a.Callback is not null); + } + + [Fact] + public void WithReferenceMountsKubeconfigFileForContainer() + { + // ApplyKubeconfigContainerOverride is what BeforeStartEvent calls for containers that + // have a ResourceRelationshipAnnotation pointing to the cluster. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + appBuilder.AddContainer("operator", "myorg/operator"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var clusterResource = Assert.Single(model.Resources.OfType()); + var containerResource = model.Resources.OfType().Single(r => r.Name == "operator"); + + K3sBuilderExtensions.ApplyKubeconfigContainerOverride(containerResource, clusterResource); + + var mount = containerResource.Annotations + .OfType() + .FirstOrDefault(m => m.Target == "/tmp/k3s-kubeconfig.yaml"); + + Assert.NotNull(mount); + Assert.Equal(ContainerMountType.BindMount, mount.Type); + Assert.True(mount.IsReadOnly); + Assert.EndsWith(Path.Combine(".k3s", "k8s", "container", "kubeconfig.yaml"), mount.Source); + } + + [Fact] + public void AddK3sClusterSetsKubeconfigDirectory() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var clusterBuilder = appBuilder.AddK3sCluster("k8s"); + + // KubeconfigDirectory is set by AddK3sCluster under AppHostDirectory/.k3s/{name}/ + Assert.NotNull(clusterBuilder.Resource.KubeconfigDirectory); + Assert.EndsWith(Path.Combine(".k3s", "k8s"), clusterBuilder.Resource.KubeconfigDirectory); + } + + [Fact] + public void WithPodSubnetAddsClusterCidrArg() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithPodSubnet("10.88.0.0/16"); + + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var args = resource.Annotations.OfType(); + Assert.Contains(args, a => a is not null); // arg callbacks registered + // Verify the actual arg value by evaluating the callbacks. + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in args) a.Callback(ctx); + Assert.Contains("--cluster-cidr=10.88.0.0/16", ctx.Args); + } + + [Fact] + public void WithServiceSubnetAddsServiceCidrArg() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithServiceSubnet("10.89.0.0/16"); + + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + Assert.Contains("--service-cidr=10.89.0.0/16", ctx.Args); + } + + [Fact] + public void WithDisabledComponentAddsDisableArg() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithDisabledComponent("traefik"); + + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + Assert.Contains("--disable=traefik", ctx.Args); + } + + [Fact] + public void WithExtraArgAddsRawArg() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithExtraArg("--write-kubeconfig-mode=644"); + + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + Assert.Contains("--write-kubeconfig-mode=644", ctx.Args); + } + + [Fact] + public void ApiEndpointReturnsEndpointReferenceWithCorrectName() + { + var resource = new K3sClusterResource("k8s"); + var endpoint = resource.ApiEndpoint; + + Assert.NotNull(endpoint); + Assert.Equal(K3sClusterResource.ApiServerEndpointName, endpoint.EndpointName); + } + + [Fact] + public void ApiEndpointIsCached() + { + var resource = new K3sClusterResource("k8s"); + + var first = resource.ApiEndpoint; + var second = resource.ApiEndpoint; + + Assert.Same(first, second); + } + + // ── Idempotency ─────────────────────────────────────────────────────────── + + [Fact] + public void WithDataVolumeCalledTwiceProducesOnlyOneVolumeMount() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithDataVolume().WithDataVolume(); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var mounts = resource.Annotations + .OfType() + .Where(v => v.Target == "/var/lib/rancher/k3s" && v.Type == ContainerMountType.Volume) + .ToList(); + + // Idempotent: second call replaces the first rather than duplicating the mount. + Assert.Single(mounts); + } + + [Fact] + public void WithReferenceContainerCalledTwiceProducesOnlyOneBindMount() + { + // ApplyKubeconfigContainerOverride is idempotent β€” the second call skips the + // mount because the target path is already mounted. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var container = appBuilder.AddContainer("app", "myorg/app"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var clusterResource = Assert.Single(model.Resources.OfType()); + var containerResource = model.Resources.OfType().Single(r => r.Name == "app"); + + // Simulate two WithReference calls via two direct invocations of the override. + K3sBuilderExtensions.ApplyKubeconfigContainerOverride(containerResource, clusterResource); + K3sBuilderExtensions.ApplyKubeconfigContainerOverride(containerResource, clusterResource); + + var kubeconfigMounts = containerResource.Annotations + .OfType() + .Where(m => m.Target == "/tmp/k3s-kubeconfig.yaml") + .ToList(); + + Assert.Single(kubeconfigMounts); + } + + // ── WithK3sVersion propagation ──────────────────────────────────────────── + + [Fact] + public void WithK3sVersionSyncsAllAgentImageTags() + { + // Image tag must propagate immediately β€” DCP uses ContainerImageAnnotation to + // compute container identity before BeforeStartEvent fires. + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder + .AddK3sCluster("k8s", agentCount: 2) + .WithK3sVersion("v1.30.0-k3s1"); + + 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 img = Assert.Single(agent.Annotations.OfType()); + Assert.Equal("v1.30.0-k3s1", img.Tag); + } + } + + [Fact] + public void WithK3sVersionCalledTwiceAppliesLastTag() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s") + .WithK3sVersion("v1.29.0-k3s1") + .WithK3sVersion("v1.30.0-k3s1"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var annotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal("v1.30.0-k3s1", annotation.Tag); + } + + // ── WithLifetime ────────────────────────────────────────────────────────── + + [Fact] + public void WithLifetimePersistentSetsClusterLifetimeAnnotation() + { + // Standard Aspire WithLifetime sets the annotation immediately + // on the cluster. Agent propagation happens in BeforeStartEvent. + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithLifetime(ContainerLifetime.Persistent); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var annotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(ContainerLifetime.Persistent, annotation.Lifetime); + } + + [Fact] + public void WithLifetimeSessionSetsClusterLifetimeAnnotation() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithLifetime(ContainerLifetime.Session); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var annotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(ContainerLifetime.Session, annotation.Lifetime); + } + + // ── Argument accumulation ───────────────────────────────────────────────── + + [Fact] + public void WithDisabledComponentCalledMultipleTimesAccumulatesArgs() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s") + .WithDisabledComponent("traefik") + .WithDisabledComponent("coredns"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + + Assert.Contains("--disable=traefik", ctx.Args); + Assert.Contains("--disable=coredns", ctx.Args); + } + + [Fact] + public void WithExtraArgCalledMultipleTimesAccumulatesArgs() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s") + .WithExtraArg("--write-kubeconfig-mode=644") + .WithExtraArg("--node-label=env=dev"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + + Assert.Contains("--write-kubeconfig-mode=644", ctx.Args); + Assert.Contains("--node-label=env=dev", ctx.Args); + } + + // ── Default args (absence checks) ──────────────────────────────────────── + + [Fact] + public void AddK3sClusterDefaultsToNoClusterCidrArg() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + + Assert.DoesNotContain(ctx.Args, arg => arg is string s && s.StartsWith("--cluster-cidr=")); + } + + [Fact] + public void AddK3sClusterDefaultsToNoServiceCidrArg() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + + Assert.DoesNotContain(ctx.Args, arg => arg is string s && s.StartsWith("--service-cidr=")); + } + + // ── IResourceWithConnectionString (K3sClusterResource) ─────────────────── + + [Fact] + public void ConnectionStringEnvironmentVariableIsKUBECONFIG() + { + var resource = new K3sClusterResource("k8s"); + Assert.Equal("KUBECONFIG", resource.ConnectionStringEnvironmentVariable); + } + + [Fact] + public async Task GetConnectionStringAsyncReturnsNullWhenDirectoryNotSet() + { + var resource = new K3sClusterResource("k8s") { KubeconfigDirectory = null }; + var result = await resource.GetConnectionStringAsync(); + Assert.Null(result); + } + + [Fact] + public async Task GetConnectionStringAsyncReturnsLocalKubeconfigPath() + { + var dir = Path.Combine(Path.GetTempPath(), $"k8s-{Guid.NewGuid():N}"); + var resource = new K3sClusterResource("k8s") { KubeconfigDirectory = dir }; + + var result = await resource.GetConnectionStringAsync(); + + Assert.Equal(Path.Combine(dir, "local", "kubeconfig.yaml"), result); + } + + // ── AddK3sCluster with agentCount param ────────────────────────────────── + + [Fact] + public void AddK3sClusterWithAgentCountCreatesAgents() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddK3sCluster("k8s", agentCount: 2); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + Assert.Equal(2, model.Resources.OfType().Count()); + } + + // ── WithHelmImage / WithKubectlImage ────────────────────────────────────── + + [Fact] + public void WithHelmImageSetsTagOnly() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddK3sCluster("k8s").WithHelmImage(tag: "3.18.0"); + + using var app = appBuilder.Build(); + var resource = Assert.Single(app.Services + .GetRequiredService() + .Resources.OfType()); + + var (_, _, tag) = resource.HelmImageInfo; + Assert.Equal("3.18.0", tag); + } + + [Fact] + public void WithHelmImageSetsAllComponents() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddK3sCluster("k8s") + .WithHelmImage(tag: "3.18.0", image: "my/helm", registry: "my.registry.io"); + + using var app = appBuilder.Build(); + var resource = Assert.Single(app.Services + .GetRequiredService() + .Resources.OfType()); + + var (registry, image, tag) = resource.HelmImageInfo; + Assert.Equal("my.registry.io", registry); + Assert.Equal("my/helm", image); + Assert.Equal("3.18.0", tag); + } + + [Fact] + public void WithHelmImageNullParametersPreserveCurrentValues() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var (origRegistry, origImage, _) = cluster.Resource.HelmImageInfo; + + cluster.WithHelmImage(tag: "3.18.0"); // only tag changed + + var (registry, image, tag) = cluster.Resource.HelmImageInfo; + Assert.Equal(origRegistry, registry); + Assert.Equal(origImage, image); + Assert.Equal("3.18.0", tag); + } + + [Fact] + public void WithHelmImageShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + Assert.Throws(() => builder.WithHelmImage("3.18.0")); + } + + [Fact] + public void WithKubectlImageSetsTagOnly() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddK3sCluster("k8s").WithKubectlImage(tag: "1.37.0"); + + using var app = appBuilder.Build(); + var resource = Assert.Single(app.Services + .GetRequiredService() + .Resources.OfType()); + + var (_, _, tag) = resource.KubectlImageInfo; + Assert.Equal("1.37.0", tag); + } + + [Fact] + public void WithKubectlImageSetsAllComponents() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddK3sCluster("k8s") + .WithKubectlImage(tag: "1.37.0", image: "my/kubectl", registry: "my.registry.io"); + + using var app = appBuilder.Build(); + var resource = Assert.Single(app.Services + .GetRequiredService() + .Resources.OfType()); + + var (registry, image, tag) = resource.KubectlImageInfo; + Assert.Equal("my.registry.io", registry); + Assert.Equal("my/kubectl", image); + Assert.Equal("1.37.0", tag); + } + + [Fact] + public void WithKubectlImageShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + Assert.Throws(() => builder.WithKubectlImage("1.37.0")); + } + + // ── Options configure callback ──────────────────────────────────────────── + + [Fact] + public void AddK3sClusterWithServiceCidrViaOptions() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithServiceSubnet("10.99.0.0/16"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + + Assert.Contains("--service-cidr=10.99.0.0/16", ctx.Args); + } + + [Fact] + public void AddK3sClusterWithMultipleDisabledComponentsViaOptions() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s") + .WithDisabledComponent("traefik") + .WithDisabledComponent("coredns"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + + Assert.Contains("--disable=traefik", ctx.Args); + Assert.Contains("--disable=coredns", ctx.Args); + } + + [Fact] + public void AddK3sClusterWithExtraArgsViaOptions() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddK3sCluster("k8s").WithExtraArg("--write-kubeconfig-mode=644"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var ctx = new CommandLineArgsCallbackContext([]); + foreach (var a in resource.Annotations.OfType()) + a.Callback(ctx); + + Assert.Contains("--write-kubeconfig-mode=644", ctx.Args); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sInProcessPortForwarderTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sInProcessPortForwarderTests.cs new file mode 100644 index 000000000..3671905bd --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sInProcessPortForwarderTests.cs @@ -0,0 +1,171 @@ +using k8s; +using k8s.Autorest; +using k8s.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CommunityToolkit.Aspire.Hosting.K3s.Tests; + +public class K3sInProcessPortForwarderTests +{ + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static K3sInProcessPortForwarder MakeForwarder( + List readyValues, + Func? factory = null) + => new( + kubeconfigPath: "/fake/kubeconfig.yaml", + @namespace: "default", + serviceName: "my-svc", + localPort: 0, + servicePort: 80, + onReadyChanged: v => readyValues.Add(v), + kubernetesFactory: factory); + + private static Mock BuildMockK8s( + V1Service? service = null, + V1PodList? pods = null, + Action? onPodListCalled = null) + { + var mockCoreV1 = new Mock(); + var mockK8s = new Mock(); + mockK8s.Setup(k => k.CoreV1).Returns(mockCoreV1.Object); + + // The source calls the extension method ReadNamespacedServiceAsync which internally + // calls ReadNamespacedServiceWithHttpMessagesAsync on the interface. + mockCoreV1 + .Setup(c => c.ReadNamespacedServiceWithHttpMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>(), It.IsAny())) + .ReturnsAsync(new HttpOperationResponse + { + Body = service ?? MakeService(hasPodSelector: true), + Response = new HttpResponseMessage(), + }); + + mockCoreV1 + .Setup(c => c.ListNamespacedPodWithHttpMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>(), It.IsAny())) + .ReturnsAsync(() => + { + onPodListCalled?.Invoke(); + return new HttpOperationResponse + { + Body = pods ?? MakePodList(ready: true), + Response = new HttpResponseMessage(), + }; + }); + + return mockK8s; + } + + private static V1Service MakeService(bool hasPodSelector) => new() + { + Spec = new V1ServiceSpec + { + Ports = [new V1ServicePort { Port = 80, TargetPort = 80 }], + Selector = hasPodSelector + ? new Dictionary { ["app"] = "my-svc" } + : null, + } + }; + + private static V1PodList MakePodList(bool ready) => new() + { + Items = [new V1Pod + { + Metadata = new V1ObjectMeta { Name = "my-svc-pod-0" }, + Spec = new V1PodSpec + { + Containers = [new V1Container { Ports = [new V1ContainerPort { ContainerPort = 80 }] }] + }, + Status = new V1PodStatus + { + Phase = "Running", + ContainerStatuses = [new V1ContainerStatus { Ready = ready }] + } + }] + }; + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + [Fact] + public async Task DisposeAsync_WithoutRunning_CompletesGracefully() + { + var forwarder = MakeForwarder([]); + await forwarder.DisposeAsync(); + } + + [Fact] + public async Task RunAsync_WithPreCancelledToken_ExitsImmediatelyAndSignalsNotReady() + { + var readyValues = new List(); + var forwarder = MakeForwarder(readyValues); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await forwarder.RunAsync(NullLogger.Instance, cts.Token); + + Assert.Single(readyValues); + Assert.False(readyValues[0]); + } + + // ── Ready pod ───────────────────────────────────────────────────────────── + + [Fact] + public async Task RunAsync_WhenServiceHasReadyPod_SignalsReady() + { + var readyValues = new List(); + using var cts = new CancellationTokenSource(); + + // Cancel after WaitForServiceReadyAsync confirms a ready pod, so RunAsync exits cleanly. + var mockK8s = BuildMockK8s(onPodListCalled: cts.Cancel); + + var forwarder = MakeForwarder(readyValues, _ => mockK8s.Object); + await forwarder.RunAsync(NullLogger.Instance, cts.Token); + + Assert.Contains(true, readyValues); + // RunAsync always ends with onReadyChanged(false). + Assert.False(readyValues[^1]); + } + + // ── No pod selector ─────────────────────────────────────────────────────── + + [Fact] + public async Task RunAsync_WhenServiceHasNoPodSelector_ExitsWithoutSignallingReady() + { + var readyValues = new List(); + var mockK8s = BuildMockK8s(service: MakeService(hasPodSelector: false)); + + var forwarder = MakeForwarder(readyValues, _ => mockK8s.Object); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await forwarder.RunAsync(NullLogger.Instance, cts.Token); + + // InvalidOperationException from missing selector causes the loop to break + // without ever signalling ready. + Assert.DoesNotContain(true, readyValues); + Assert.False(readyValues[^1]); + } + + // ── Not-ready pods ──────────────────────────────────────────────────────── + + [Fact] + public async Task RunAsync_WhenPodsNotReady_DoesNotSignalReadyBeforeCancellation() + { + var readyValues = new List(); + using var cts = new CancellationTokenSource(); + + int callCount = 0; + var mockK8s = BuildMockK8s( + pods: MakePodList(ready: false), + onPodListCalled: () => { if (++callCount >= 2) cts.Cancel(); }); + + var forwarder = MakeForwarder(readyValues, _ => mockK8s.Object); + await forwarder.RunAsync(NullLogger.Instance, cts.Token); + + Assert.DoesNotContain(true, readyValues); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sPublicApiTests.cs new file mode 100644 index 000000000..94f4a7c77 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sPublicApiTests.cs @@ -0,0 +1,348 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.K3s.Tests; + +public class K3sPublicApiTests +{ + [Fact] + public void AddK3sClusterShouldThrowWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + + var action = () => builder.AddK3sCluster("k8s"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddK3sClusterShouldThrowWhenNameIsNull() + { + IDistributedApplicationBuilder builder = new DistributedApplicationBuilder([]); + string name = null!; + + var action = () => builder.AddK3sCluster(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void WithK3sVersionShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithK3sVersion("v1.32.3-k3s1"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithPodSubnetShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithPodSubnet("10.42.0.0/16"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithServiceSubnetShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithServiceSubnet("10.43.0.0/16"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithDisabledComponentShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithDisabledComponent("traefik"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithExtraArgShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithExtraArg("--write-kubeconfig-mode=644"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithReferenceShouldThrowWhenDestinationIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + IResourceBuilder destination = null!; + + var action = () => destination.WithReference(cluster); + + Assert.Throws(action); + } + + [Fact] + public void WithReferenceShouldThrowWhenSourceIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var container = appBuilder.AddContainer("app", "myimage"); + IResourceBuilder source = null!; + + var action = () => container.WithReference(source); + + Assert.Throws(action); + } + + // ── WithK3sVersion argument guards ──────────────────────────────────────── + + [Fact] + public void WithK3sVersionShouldThrowWhenTagIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + string tag = null!; + + var action = () => cluster.WithK3sVersion(tag); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(tag), exception.ParamName); + } + + [Fact] + public void WithK3sVersionShouldThrowWhenTagIsWhitespace() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.WithK3sVersion(" "); + + Assert.Throws(action); + } + + // ── WithPodSubnet argument guards ───────────────────────────────────────── + + [Fact] + public void WithPodSubnetShouldThrowWhenCidrIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + string cidr = null!; + + var action = () => cluster.WithPodSubnet(cidr); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(cidr), exception.ParamName); + } + + [Fact] + public void WithPodSubnetShouldThrowWhenCidrIsWhitespace() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.WithPodSubnet(" "); + + Assert.Throws(action); + } + + // ── WithServiceSubnet argument guards ───────────────────────────────────── + + [Fact] + public void WithServiceSubnetShouldThrowWhenCidrIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + string cidr = null!; + + var action = () => cluster.WithServiceSubnet(cidr); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(cidr), exception.ParamName); + } + + [Fact] + public void WithServiceSubnetShouldThrowWhenCidrIsWhitespace() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.WithServiceSubnet(" "); + + Assert.Throws(action); + } + + // ── WithDisabledComponent argument guards ───────────────────────────────── + + [Fact] + public void WithDisabledComponentShouldThrowWhenComponentIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + string component = null!; + + var action = () => cluster.WithDisabledComponent(component); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(component), exception.ParamName); + } + + [Fact] + public void WithDisabledComponentShouldThrowWhenComponentIsWhitespace() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.WithDisabledComponent(" "); + + Assert.Throws(action); + } + + // ── WithExtraArg argument guards ────────────────────────────────────────── + + [Fact] + public void WithExtraArgShouldThrowWhenArgIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + string arg = null!; + + var action = () => cluster.WithExtraArg(arg); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(arg), exception.ParamName); + } + + [Fact] + public void WithExtraArgShouldThrowWhenArgIsWhitespace() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.WithExtraArg(" "); + + Assert.Throws(action); + } + + // ── AddServiceEndpoint argument guards ──────────────────────────────────── + + [Fact] + public void AddServiceEndpointShouldThrowWhenNameIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.AddServiceEndpoint(null!, "svc", 80); + + Assert.Throws(action); + } + + [Fact] + public void AddServiceEndpointShouldThrowWhenServiceNameIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.AddServiceEndpoint("ep", null!, 80); + + Assert.Throws(action); + } + + [Fact] + public void AddServiceEndpointShouldThrowWhenServiceNameIsWhitespace() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.AddServiceEndpoint("ep", " ", 80); + + Assert.Throws(action); + } + + [Fact] + public void AddServiceEndpointShouldThrowWhenNamespaceIsWhitespace() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.AddServiceEndpoint("ep", "svc", 80, @namespace: " "); + + Assert.Throws(action); + } + + [Fact] + public void AddServiceEndpointShouldThrowWhenPortIsZero() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.AddServiceEndpoint("ep", "svc", 0); + + Assert.Throws(action); + } + + [Fact] + public void AddServiceEndpointShouldThrowWhenPortExceeds65535() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.AddServiceEndpoint("ep", "svc", 65536); + + Assert.Throws(action); + } + + // ── WithReference (service endpoint) argument guards ───────────────────── + + [Fact] + public void WithReferenceServiceEndpointShouldThrowWhenDestinationIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 80); + IResourceBuilder destination = null!; + + var action = () => destination.WithReference(ep); + + Assert.Throws(action); + } + + [Fact] + public void WithReferenceServiceEndpointShouldThrowWhenSourceIsNull() + { + var appBuilder = new DistributedApplicationBuilder([]); + var container = appBuilder.AddContainer("app", "myimage"); + IResourceBuilder source = null!; + + var action = () => container.WithReference(source); + + Assert.Throws(action); + } + + // ── WithHelmValuesFile argument guards ──────────────────────────────────── + + [Fact] + public void WithHelmValuesFileShouldThrowWhenPathIsWhitespace() + { + var appBuilder = new DistributedApplicationBuilder([]); + var cluster = appBuilder.AddK3sCluster("k8s"); + var release = cluster.AddHelmRelease("argocd", "argo-cd"); + + var action = () => release.WithHelmValuesFile(" "); + + Assert.Throws(action); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sReadinessHealthCheckTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sReadinessHealthCheckTests.cs new file mode 100644 index 000000000..2a6e65743 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sReadinessHealthCheckTests.cs @@ -0,0 +1,392 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using k8s; +using k8s.Autorest; +using k8s.KubeConfigModels; +using k8s.Models; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using System.Net; + +namespace CommunityToolkit.Aspire.Hosting.K3s.Tests; + +public class K3sReadinessHealthCheckTests +{ + // ── CheckHealthAsync β€” early exit (no DCP allocation) ──────────────────── + + [Fact] + public async Task CheckHealthAsync_WhenNeitherAllocatedEndpointNorStaticPort_ReturnsUnhealthy() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + // No DCP, no static port β€” both annotation.AllocatedEndpoint and annotation.Port are null. + var healthCheck = new K3sReadinessHealthCheck(cluster.Resource); + + var result = await healthCheck.CheckHealthAsync(null!); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("not yet allocated", result.Description); + } + + [Fact] + public async Task CheckHealthAsync_WhenAllocatedEndpointSet_ProceedsToCheckCore() + { + // Simulates DCP having fired the endpoint allocation event. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var annotation = cluster.Resource.Annotations + .OfType() + .First(a => a.Name == K3sClusterResource.ApiServerEndpointName); + // Set AllocatedEndpoint directly β€” the same property DCP sets when it allocates the port. + annotation.AllocatedEndpoint = new AllocatedEndpoint(annotation, "localhost", 32773); + + var healthCheck = new K3sReadinessHealthCheck(cluster.Resource); + + var result = await healthCheck.CheckHealthAsync(null!); + + // cluster/kubeconfig.yaml doesn't exist in the test dir β†’ reaches CheckCoreAsync + // and returns "write kubeconfig", not "not yet allocated". + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.DoesNotContain("not yet allocated", result.Description); + Assert.Contains("write kubeconfig", result.Description); + } + + [Fact] + public async Task CheckHealthAsync_WhenStaticPortConfigured_ProceedsToCheckCore() + { + // Simulates AddK3sCluster("k8s", apiServerPort: 32773): AllocatedEndpoint is null + // but annotation.Port carries the explicit static port. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var annotation = cluster.Resource.Annotations + .OfType() + .First(a => a.Name == K3sClusterResource.ApiServerEndpointName); + annotation.Port = 32773; + + var healthCheck = new K3sReadinessHealthCheck(cluster.Resource); + + var result = await healthCheck.CheckHealthAsync(null!); + + // cluster/kubeconfig.yaml doesn't exist β†’ "write kubeconfig", not "not yet allocated". + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.DoesNotContain("not yet allocated", result.Description); + Assert.Contains("write kubeconfig", result.Description); + } + + // ── CheckCoreAsync β€” file-system and Kubernetes paths ──────────────────── + + [Fact] + public async Task CheckCoreAsync_WhenKubeconfigDirectoryIsNull_ReturnsUnhealthy() + { + var cluster = new K3sClusterResource("k8s") { KubeconfigDirectory = null }; + var healthCheck = new K3sReadinessHealthCheck(cluster, _ => Mock.Of()); + + var result = await healthCheck.CheckCoreAsync(port: 6443); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("not configured", result.Description); + } + + [Fact] + public async Task CheckCoreAsync_WhenKubeconfigFileMissing_ReturnsUnhealthy() + { + var (healthCheck, _, _) = MakeCheck(writeKubeconfig: false, nodeCount: 0, agentCount: 0); + + var result = await healthCheck.CheckCoreAsync(port: 6443); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("write kubeconfig", result.Description); + } + + [Fact] + public async Task CheckCoreAsync_WhenAllNodesReady_ReturnsHealthy() + { + var (healthCheck, _, _) = MakeCheck(writeKubeconfig: true, nodeCount: 1, agentCount: 0); + + var result = await healthCheck.CheckCoreAsync(port: 6443); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Contains("ready", result.Description); + } + + [Fact] + public async Task CheckCoreAsync_WhenNodesNotReady_ReturnsUnhealthy() + { + var (healthCheck, _, _) = MakeCheck(writeKubeconfig: true, nodeCount: 0, agentCount: 0); + + var result = await healthCheck.CheckCoreAsync(port: 6443); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("0/1 nodes Ready", result.Description); + } + + [Fact] + public async Task CheckCoreAsync_WithAgentsRequiresAllNodes() + { + // 1 server + 2 agents = 3 required; only 1 node ready β†’ unhealthy. + var (healthCheck, _, _) = MakeCheck(writeKubeconfig: true, nodeCount: 1, agentCount: 2); + + var result = await healthCheck.CheckCoreAsync(port: 6443); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("1/3", result.Description); + } + + [Fact] + public async Task CheckCoreAsync_WhenUnexpectedExceptionThrown_ReturnsUnhealthy() + { + var (healthCheck, _, _) = MakeCheck( + writeKubeconfig: true, + nodeCount: 0, + agentCount: 0, + listNodesThrows: new InvalidOperationException("cluster exploded")); + + var result = await healthCheck.CheckCoreAsync(port: 6443); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("cluster exploded", result.Description); + } + + [Fact] + public async Task CheckCoreAsync_WhenTlsFailure_ReturnsStaleKubeconfigMessage() + { + var (healthCheck, dir, _) = MakeCheck( + writeKubeconfig: true, + nodeCount: 0, + agentCount: 0, + listNodesThrows: new System.Security.Authentication.AuthenticationException("bad cert")); + + var result = await healthCheck.CheckCoreAsync(port: 6443); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("stale", result.Description); + + // The raw cluster kubeconfig must NOT be deleted β€” only the derived variants. + Assert.True(File.Exists(Path.Combine(dir, "cluster", "kubeconfig.yaml"))); + } + + [Fact] + public async Task CheckCoreAsync_WritesLocalAndContainerKubeconfigVariants() + { + var (healthCheck, dir, _) = MakeCheck(writeKubeconfig: true, nodeCount: 1, agentCount: 0); + + await healthCheck.CheckCoreAsync(port: 6443); + + Assert.True(File.Exists(Path.Combine(dir, "local", "kubeconfig.yaml"))); + Assert.True(File.Exists(Path.Combine(dir, "container", "kubeconfig.yaml"))); + } + + [Fact] + public async Task CheckCoreAsync_LocalVariantContainsLocalhostUrl() + { + var (healthCheck, dir, _) = MakeCheck(writeKubeconfig: true, nodeCount: 1, agentCount: 0); + + await healthCheck.CheckCoreAsync(port: 7777); + + var localYaml = await File.ReadAllTextAsync(Path.Combine(dir, "local", "kubeconfig.yaml")); + Assert.Contains("https://localhost:7777", localYaml); + } + + [Fact] + public async Task CheckCoreAsync_ContainerVariantContainsClusterNameUrl() + { + var (healthCheck, dir, _) = MakeCheck(writeKubeconfig: true, nodeCount: 1, agentCount: 0); + + await healthCheck.CheckCoreAsync(port: 6443); + + var containerYaml = await File.ReadAllTextAsync(Path.Combine(dir, "container", "kubeconfig.yaml")); + Assert.Contains("https://k8s:6443", containerYaml); + } + + // ── BuildConfigYaml ─────────────────────────────────────────────────────── + + [Fact] + public void BuildConfigYaml_RewritesServerUrl() + { + var source = new K8SConfiguration + { + Clusters = + [ + new Cluster { Name = "k8s", ClusterEndpoint = new ClusterEndpoint { Server = "https://original:6443" } } + ] + }; + + var yaml = K3sReadinessHealthCheck.BuildConfigYaml(source, "https://localhost:9999"); + + Assert.Contains("https://localhost:9999", yaml); + Assert.DoesNotContain("https://original:6443", yaml); + } + + [Fact] + public void BuildConfigYaml_RewritesAllClusters() + { + var source = new K8SConfiguration + { + Clusters = + [ + new Cluster { Name = "a", ClusterEndpoint = new ClusterEndpoint { Server = "https://a:6443" } }, + new Cluster { Name = "b", ClusterEndpoint = new ClusterEndpoint { Server = "https://b:6443" } }, + ] + }; + + var yaml = K3sReadinessHealthCheck.BuildConfigYaml(source, "https://new-server:6443"); + + Assert.DoesNotContain("https://a:6443", yaml); + Assert.DoesNotContain("https://b:6443", yaml); + var occurrences = yaml.Split("https://new-server:6443").Length - 1; + Assert.Equal(2, occurrences); + } + + [Fact] + public void BuildConfigYaml_WithNullClusters_DoesNotThrow() + { + var source = new K8SConfiguration { Clusters = null }; + var yaml = K3sReadinessHealthCheck.BuildConfigYaml(source, "https://localhost:6443"); + Assert.NotNull(yaml); + } + + // ── IsTlsOrAuthFailure ──────────────────────────────────────────────────── + + [Fact] + public void IsTlsOrAuthFailure_WithAuthenticationException_ReturnsTrue() + { + var ex = new System.Security.Authentication.AuthenticationException("tls failed"); + Assert.True(K3sReadinessHealthCheck.IsTlsOrAuthFailure(ex)); + } + + [Fact] + public void IsTlsOrAuthFailure_WithInnerAuthenticationException_ReturnsTrue() + { + var inner = new System.Security.Authentication.AuthenticationException("inner tls"); + var ex = new InvalidOperationException("outer", inner); + Assert.True(K3sReadinessHealthCheck.IsTlsOrAuthFailure(ex)); + } + + [Fact] + public void IsTlsOrAuthFailure_WithHttpOperationExceptionUnauthorized_ReturnsTrue() + { + var ex = new HttpOperationException + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(HttpStatusCode.Unauthorized), "") + }; + Assert.True(K3sReadinessHealthCheck.IsTlsOrAuthFailure(ex)); + } + + [Fact] + public void IsTlsOrAuthFailure_WithHttpOperationExceptionForbidden_ReturnsFalse() + { + var ex = new HttpOperationException + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(HttpStatusCode.Forbidden), "") + }; + Assert.False(K3sReadinessHealthCheck.IsTlsOrAuthFailure(ex)); + } + + [Fact] + public void IsTlsOrAuthFailure_WithRegularException_ReturnsFalse() + { + Assert.False(K3sReadinessHealthCheck.IsTlsOrAuthFailure(new InvalidOperationException("other"))); + } + + [Fact] + public void IsTlsOrAuthFailure_WithHttpRequestException_ReturnsFalse() + { + Assert.False(K3sReadinessHealthCheck.IsTlsOrAuthFailure(new HttpRequestException("network"))); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private const string MinimalKubeconfig = """ + apiVersion: v1 + clusters: + - cluster: + server: https://127.0.0.1:6443 + insecure-skip-tls-verify: true + name: k8s + contexts: + - context: + cluster: k8s + user: default + name: k8s + current-context: k8s + kind: Config + preferences: {} + users: + - name: default + user: + token: test-token + """; + + private static (K3sReadinessHealthCheck check, string dir, Mock mock) MakeCheck( + bool writeKubeconfig, + int nodeCount, + int agentCount, + Exception? listNodesThrows = null) + { + var dir = Path.Combine(Path.GetTempPath(), $"k3s-hc-{Guid.NewGuid():N}"); + var clusterDir = Path.Combine(dir, "cluster"); + Directory.CreateDirectory(clusterDir); + + if (writeKubeconfig) + File.WriteAllText(Path.Combine(clusterDir, "kubeconfig.yaml"), MinimalKubeconfig); + + var cluster = new K3sClusterResource("k8s") { KubeconfigDirectory = dir }; + for (var i = 0; i < agentCount; i++) + { + cluster.AgentCount++; + cluster.AddAgentResource(new K3sAgentResource($"k8s-agent-{i}", cluster)); + } + + var mockCoreV1 = new Mock(); + var mockK8s = new Mock(); + mockK8s.Setup(k => k.CoreV1).Returns(mockCoreV1.Object); + + // The source calls the extension method ListNodeAsync which calls + // ListNodeWithHttpMessagesAsync on the interface. + if (listNodesThrows is not null) + { + mockCoreV1 + .Setup(c => c.ListNodeWithHttpMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .ThrowsAsync(listNodesThrows); + } + else + { + var nodes = Enumerable.Range(0, nodeCount) + .Select(_ => new V1Node + { + Status = new V1NodeStatus + { + Conditions = [new V1NodeCondition { Type = "Ready", Status = "True" }] + } + }) + .ToList(); + + mockCoreV1 + .Setup(c => c.ListNodeWithHttpMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(new HttpOperationResponse + { + Body = new V1NodeList { Items = nodes }, + Response = new HttpResponseMessage(), + }); + } + + var check = new K3sReadinessHealthCheck(cluster, _ => mockK8s.Object); + return (check, dir, mockK8s); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sServiceEndpointResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sServiceEndpointResourceTests.cs new file mode 100644 index 000000000..73786feb2 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K3sServiceEndpointResourceTests.cs @@ -0,0 +1,554 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CommunityToolkit.Aspire.Hosting.K3s.Tests; + +public class K3sServiceEndpointResourceTests +{ + // ── Registration ────────────────────────────────────────────────────────── + + [Fact] + public void AddServiceEndpointAddsResourceWithCorrectName() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddServiceEndpoint("podinfo-web", "podinfo", 9898, "podinfo"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + Assert.Equal("podinfo-web", resource.Name); + } + + [Fact] + public void AddServiceEndpointStoresServiceNamePortAndNamespace() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddServiceEndpoint("ep", "my-svc", 8080, "my-ns"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + Assert.Equal("my-svc", resource.ServiceName); + Assert.Equal(8080, resource.ServicePort); + Assert.Equal("my-ns", resource.Namespace); + } + + [Fact] + public void AddServiceEndpointDefaultsNamespaceToDefault() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddServiceEndpoint("ep", "svc", 80); + + 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 AddServiceEndpointParentIsCluster() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddServiceEndpoint("ep", "svc", 80); + + 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 AddServiceEndpointIsExcludedFromManifest() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddServiceEndpoint("ep", "svc", 80); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + Assert.Contains(ManifestPublishingCallbackAnnotation.Ignore, resource.Annotations); + } + + [Fact] + public void AddServiceEndpointHasHealthCheckAnnotation() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddServiceEndpoint("ep", "svc", 80); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + Assert.Single(resource.Annotations.OfType()); + } + + [Fact] + public void AddServiceEndpointSetsInitialStateProperties() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddServiceEndpoint("ep", "my-svc", 9898, "my-ns"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var snapshot = resource.Annotations.OfType() + .Select(a => a.InitialSnapshot) + .First(); + + Assert.Equal("K3s Service Endpoint", snapshot.ResourceType); + Assert.Contains(snapshot.Properties, p => p.Name == "ServiceName" && p.Value?.ToString() == "my-svc"); + Assert.Contains(snapshot.Properties, p => p.Name == "ServicePort" && p.Value?.ToString() == "9898"); + Assert.Contains(snapshot.Properties, p => p.Name == "Namespace" && p.Value?.ToString() == "my-ns"); + } + + // ── Scheme inference ────────────────────────────────────────────────────── + + [Theory] + [InlineData(443, "https")] + [InlineData(8443, "https")] + [InlineData(80, "http")] + [InlineData(8080, "http")] + [InlineData(9898, "http")] + [InlineData(3000, "http")] + public void AddServiceEndpointInfersSchemeFromPort(int port, string expectedScheme) + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var epBuilder = cluster.AddServiceEndpoint("ep", "svc", port); + + Assert.Equal(expectedScheme, epBuilder.Resource.Scheme); + } + + [Fact] + public void AddServiceEndpointExplicitSchemeOverridesInference() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + // Port 443 would normally infer https β€” override to http. + var epBuilder = cluster.AddServiceEndpoint("ep", "svc", 443, scheme: "http"); + + Assert.Equal("http", epBuilder.Resource.Scheme); + } + + [Fact] + public void AddServiceEndpointExplicitHttpsOnNonStandardPort() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var epBuilder = cluster.AddServiceEndpoint("ep", "svc", 9000, scheme: "https"); + + Assert.Equal("https", epBuilder.Resource.Scheme); + } + + // ── Port validation ─────────────────────────────────────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(65536)] + [InlineData(100000)] + public void AddServiceEndpointThrowsForInvalidPort(int port) + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var action = () => cluster.AddServiceEndpoint("ep", "svc", port); + + Assert.Throws(action); + } + + [Theory] + [InlineData(1)] + [InlineData(65535)] + public void AddServiceEndpointAcceptsPortBoundaries(int port) + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + var ep = cluster.AddServiceEndpoint("ep", "svc", port); + + Assert.Equal(port, ep.Resource.ServicePort); + } + + // ── WithReference (service endpoint) ───────────────────────────────────── + + [Fact] + public void WithReferenceServiceEndpointAddsResourceRelationshipAnnotation() + { + // K3sServiceEndpointResource implements IResourceWithConnectionString so the + // standard Aspire WithReference overload is used β€” it adds a ResourceRelationshipAnnotation. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 80); + var container = appBuilder.AddContainer("app", "myorg/app"); + + container.WithReference(ep); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var containerResource = model.Resources.OfType().Single(r => r.Name == "app"); + + Assert.Contains( + containerResource.Annotations.OfType(), + a => ReferenceEquals(a.Resource, ep.Resource)); + } + + [Fact] + public void WithReferenceServiceEndpointAddsRuntimeArgToContainer() + { + // ApplyServiceUrlContainerOverride adds --add-host for containers. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 80); + var container = appBuilder.AddContainer("app", "myorg/app"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var containerResource = model.Resources.OfType().Single(r => r.Name == "app"); + + containerResource.Annotations.Add( + new ContainerRuntimeArgsCallbackAnnotation( + args => args.Add("--add-host=host.docker.internal:host-gateway"))); + K3sBuilderExtensions.ApplyServiceUrlContainerOverride(containerResource, ep.Resource); + + Assert.NotEmpty(containerResource.Annotations.OfType()); + } + + [Fact] + public void WithReferenceServiceEndpointAddsEnvironmentCallbackToContainer() + { + // ApplyServiceUrlContainerOverride adds an env callback for the host.docker.internal URL. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 80); + var container = appBuilder.AddContainer("app", "myorg/app"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var containerResource = model.Resources.OfType().Single(r => r.Name == "app"); + + K3sBuilderExtensions.ApplyServiceUrlContainerOverride(containerResource, ep.Resource); + + Assert.Contains( + containerResource.Annotations.OfType(), + a => a.Callback is not null); + } + + [Fact] + public void WithReferenceServiceEndpointAddsEnvironmentCallbackToExecutable() + { + // Standard WithReference(IResourceWithConnectionString) adds a lazy ConnectionStringReference + // env callback for host processes. Verify the annotation is present. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 80); + var exe = appBuilder.AddExecutable("myapp", "myapp", "."); + exe.WithReference(ep); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var exeResource = Assert.Single(model.Resources.OfType()); + + Assert.Contains( + exeResource.Annotations.OfType(), + a => a.Callback is not null); + } + + [Fact] + public void WithReferenceServiceEndpointDoesNotAddRuntimeArgToExecutable() + { + // ApplyServiceUrlContainerOverride is only called for containers; executables + // receive the URL via the standard lazy ConnectionStringReference, no --add-host needed. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 80); + var exe = appBuilder.AddExecutable("myapp", "myapp", "."); + exe.WithReference(ep); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var exeResource = Assert.Single(model.Resources.OfType()); + + Assert.Empty(exeResource.Annotations.OfType()); + } + + // ── K3sServiceEndpointHealthCheck ───────────────────────────────────────── + + [Fact] + public async Task ServiceEndpointHealthCheckReturnsUnhealthyWhenNotReady() + { + var cluster = new K3sClusterResource("k8s"); + var endpoint = new K3sServiceEndpointResource("ep", "svc", 80, "default", cluster); + endpoint.IsReady = false; + var check = new K3sServiceEndpointHealthCheck(endpoint); + + var result = await check.CheckHealthAsync(null!); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + + [Fact] + public async Task ServiceEndpointHealthCheckReturnsHealthyWhenReady() + { + var cluster = new K3sClusterResource("k8s"); + var endpoint = new K3sServiceEndpointResource("ep", "svc", 80, "default", cluster); + endpoint.IsReady = true; + var check = new K3sServiceEndpointHealthCheck(endpoint); + + var result = await check.CheckHealthAsync(null!); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task ServiceEndpointHealthCheckDefaultsToNotReady() + { + var cluster = new K3sClusterResource("k8s"); + var endpoint = new K3sServiceEndpointResource("ep", "svc", 80, "default", cluster); + // IsReady is false by default (default bool) + var check = new K3sServiceEndpointHealthCheck(endpoint); + + var result = await check.CheckHealthAsync(null!); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + + // ── IResourceWithConnectionString (K3sServiceEndpointResource) ────────── + + [Fact] + public void ConnectionStringEnvironmentVariableFollowsServiceDiscoveryConvention() + { + var cluster = new K3sClusterResource("k8s"); + var ep = new K3sServiceEndpointResource("my-ep", "svc", 80, "default", cluster); + Assert.Equal("services__my-ep__url", ep.ConnectionStringEnvironmentVariable); + } + + [Fact] + public async Task GetConnectionStringAsyncReturnsNullWhenNotReady() + { + var cluster = new K3sClusterResource("k8s"); + var ep = new K3sServiceEndpointResource("ep", "svc", 80, "default", cluster); + // IsReady=false, HostPort=0 by default + var result = await ep.GetConnectionStringAsync(); + Assert.Null(result); + } + + [Fact] + public async Task GetConnectionStringAsyncReturnsLocalhostUrlWhenReady() + { + var cluster = new K3sClusterResource("k8s"); + var ep = new K3sServiceEndpointResource("ep", "svc", 80, "default", cluster) + { + IsReady = true, + HostPort = 9898, + }; + + var result = await ep.GetConnectionStringAsync(); + + Assert.Equal("http://localhost:9898", result); + } + + [Fact] + public async Task GetConnectionStringAsyncUsesSchemeWhenReady() + { + var cluster = new K3sClusterResource("k8s"); + var ep = new K3sServiceEndpointResource("ep", "svc", 443, "default", cluster) + { + Scheme = "https", + IsReady = true, + HostPort = 7777, + }; + + var result = await ep.GetConnectionStringAsync(); + + Assert.Equal("https://localhost:7777", result); + } + + [Fact] + public void ConnectionStringExpressionIsEmptyWhenNotReady() + { + var cluster = new K3sClusterResource("k8s"); + var ep = new K3sServiceEndpointResource("ep", "svc", 80, "default", cluster); + // Not ready β€” expression should be an empty string expression. + var expr = ep.ConnectionStringExpression; + Assert.NotNull(expr); + } + + // ── Resource construction ───────────────────────────────────────────────── + + [Fact] + public void K3sServiceEndpointResourceThrowsWhenClusterIsNull() + { + K3sClusterResource cluster = null!; + var action = () => new K3sServiceEndpointResource("ep", "svc", 80, "default", cluster); + Assert.Throws(action); + } + + [Fact] + public void K3sServiceEndpointResourceThrowsWhenServiceNameIsNull() + { + var cluster = new K3sClusterResource("k8s"); + var action = () => new K3sServiceEndpointResource("ep", null!, 80, "default", cluster); + Assert.Throws(action); + } + + [Fact] + public void K3sServiceEndpointResourceThrowsWhenNamespaceIsNull() + { + var cluster = new K3sClusterResource("k8s"); + var action = () => new K3sServiceEndpointResource("ep", "svc", 80, null!, cluster); + Assert.Throws(action); + } + + // ── Env callback / IResourceWithConnectionString ────────────────────────── + // K3sServiceEndpointResource now implements IResourceWithConnectionString. + // The standard WithReference overload stores a lazy ConnectionStringReference in the + // env var (resolved at container startup by Aspire). For containers, our BeforeStartEvent + // subscriber adds a plain-string override (host.docker.internal:{port}) on top. + + [Fact] + public async Task WithReferenceServiceEndpoint_Container_SetsHostDockerInternalUrlWhenReady() + { + // ApplyServiceUrlContainerOverride (called by BeforeStartEvent for containers) writes + // the host.docker.internal URL when the endpoint is ready. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 80); + + using var app = appBuilder.Build(); + var containerResource = new ContainerResource("app"); + + ep.Resource.IsReady = true; + ep.Resource.HostPort = 9090; + + K3sBuilderExtensions.ApplyServiceUrlContainerOverride(containerResource, ep.Resource); + + var envVars = new Dictionary(); + var ctx = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + envVars); + + foreach (var cb in containerResource.Annotations.OfType()) + await cb.Callback(ctx); + + Assert.Equal("http://host.docker.internal:9090", + ctx.EnvironmentVariables["services__ep__url"]?.ToString()); + } + + [Fact] + public async Task WithReferenceServiceEndpoint_Container_DoesNotOverrideUrlWhenNotReady() + { + // When IsReady=false the override callback skips writing the URL. + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 80); + + using var app = appBuilder.Build(); + var containerResource = new ContainerResource("app"); + + // ep.IsReady is false by default. + K3sBuilderExtensions.ApplyServiceUrlContainerOverride(containerResource, ep.Resource); + + var envVars = new Dictionary(); + var ctx = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + envVars); + + foreach (var cb in containerResource.Annotations.OfType()) + await cb.Callback(ctx); + + Assert.False(ctx.EnvironmentVariables.ContainsKey("services__ep__url")); + } + + [Fact] + public async Task WithReferenceServiceEndpoint_Executable_SetsLocalhostUrlWhenReady() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 443, scheme: "https"); + var exe = appBuilder.AddExecutable("myapp", "myapp", "."); + exe.WithReference(ep); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var exeResource = Assert.Single(model.Resources.OfType()); + + ep.Resource.IsReady = true; + ep.Resource.HostPort = 7777; + + var envVars = new Dictionary(); + var ctx = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + envVars); + + foreach (var cb in exeResource.Annotations.OfType()) + await cb.Callback(ctx); + + // For host processes, standard WithReference stores a ConnectionStringReference that + // lazily resolves via K3sServiceEndpointResource.GetConnectionStringAsync(). + var rawValue = ctx.EnvironmentVariables.GetValueOrDefault("services__ep__url"); + var resolved = rawValue is IValueProvider vp + ? await vp.GetValueAsync(CancellationToken.None) + : rawValue?.ToString(); + + Assert.Equal("https://localhost:7777", resolved); + } + + [Fact] + public async Task WithReferenceServiceEndpoint_Executable_ReturnsNullUrlWhenNotReady() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var ep = cluster.AddServiceEndpoint("ep", "svc", 80); + var exe = appBuilder.AddExecutable("myapp", "myapp", "."); + exe.WithReference(ep); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + var exeResource = Assert.Single(model.Resources.OfType()); + + // ep.IsReady is false by default. + + var envVars = new Dictionary(); + var ctx = new EnvironmentCallbackContext( + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + envVars); + + foreach (var cb in exeResource.Annotations.OfType()) + await cb.Callback(ctx); + + var rawValue = ctx.EnvironmentVariables.GetValueOrDefault("services__ep__url"); + var resolved = rawValue is IValueProvider vp + ? await vp.GetValueAsync(CancellationToken.None) + : rawValue?.ToString(); + + Assert.Null(resolved); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K8sManifestResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K8sManifestResourceTests.cs new file mode 100644 index 000000000..aa8f67fc8 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K8sManifestResourceTests.cs @@ -0,0 +1,272 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.K3s.Tests; + +public class K8sManifestResourceTests +{ + // ── AddK8sManifest registration ─────────────────────────────────────────── + + [Fact] + public void AddK8sManifestAddsResourceWithCorrectName() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddK8sManifest("widget-crd", "./k8s/widget-crd.yaml"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + Assert.Equal("widget-crd", resource.Name); + } + + [Fact] + public void AddK8sManifestStoresAbsolutePath() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddK8sManifest("crds", "./k8s/crds/"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + // Path is resolved to absolute at registration time. + Assert.True(System.IO.Path.IsPathRooted(resource.Path)); + Assert.EndsWith(System.IO.Path.Combine("k8s", "crds"), + resource.Path.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AddK8sManifestParentIsCluster() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddK8sManifest("widget-crd", "./widget-crd.yaml"); + + 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 K8sManifestResourceIsContainerResource() + { + // K8sManifestResource extends ContainerResource β€” it runs bitnami/kubectl in Docker. + // No host-side kubectl binary required. WaitForCompletion waits for exit code 0. + var resource = new K8sManifestResource( + "crd", "./crd.yaml", new K3sClusterResource("k8s")); + + Assert.IsAssignableFrom(resource); + } + + [Fact] + public void AddK8sManifestIsExcludedFromManifest() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddK8sManifest("widget-crd", "./widget-crd.yaml"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + Assert.Contains(ManifestPublishingCallbackAnnotation.Ignore, resource.Annotations); + } + + [Fact] + public void ClusterTracksRegisteredManifests() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddK8sManifest("widget-crd", "./widget-crd.yaml"); + cluster.AddK8sManifest("rbac", "./rbac.yaml"); + + Assert.Contains("widget-crd", cluster.Resource.Manifests); + Assert.Contains("rbac", cluster.Resource.Manifests); + Assert.Equal(2, cluster.Resource.Manifests.Count); + } + + [Fact] + public void MultipleManifestsCanBeRegisteredOnSameCluster() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + + cluster.AddK8sManifest("crd1", "./crd1.yaml"); + cluster.AddK8sManifest("crd2", "./crd2.yaml"); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + Assert.Equal(2, model.Resources.OfType().Count()); + } + + // ── Public API null-guard tests ─────────────────────────────────────────── + + [Fact] + public void AddK8sManifestShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + var action = () => builder.AddK8sManifest("crd", "./crd.yaml"); + Assert.Throws(action); + } + + [Fact] + public void AddK8sManifestShouldThrowWhenNameIsNull() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var action = () => cluster.AddK8sManifest(null!, "./crd.yaml"); + Assert.Throws(action); + } + + [Fact] + public void AddK8sManifestShouldThrowWhenPathIsNull() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + var action = () => cluster.AddK8sManifest("crd", null!); + Assert.Throws(action); + } + + // ── File resolution ─────────────────────────────────────────────────────── + + [Fact] + public void ResolveFilesSingleFile() + { + // Create a temp file to test with + var file = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}.yaml"); + File.WriteAllText(file, "apiVersion: v1\nkind: ConfigMap"); + + try + { + var files = K3sManifestBuilderExtensions.ResolveFilesForTest(file); + Assert.Single(files, f => f == file); + } + finally + { + File.Delete(file); + } + } + + [Fact] + public void ResolveFilesDirectory() + { + var dir = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "b.yaml"), ""); + File.WriteAllText(Path.Combine(dir, "a.yaml"), ""); + File.WriteAllText(Path.Combine(dir, "c.yml"), ""); + + try + { + var files = K3sManifestBuilderExtensions.ResolveFilesForTest(dir); + + // Lexicographic order, all YAML extensions + Assert.Equal(3, files.Count); + Assert.Equal("a.yaml", Path.GetFileName(files[0])); + Assert.Equal("b.yaml", Path.GetFileName(files[1])); + Assert.Equal("c.yml", Path.GetFileName(files[2])); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + // ── Script generation ───────────────────────────────────────────────────── + + [Fact] + public void BuildManifestScriptIncludesKubectlApply() + { + var script = K3sManifestBuilderExtensions.BuildManifestScript(); + Assert.Contains("kubectl apply -f /k8s-manifests", script); + Assert.Contains("--server-side", script); + } + + [Fact] + public void BuildManifestScriptAutoDetectsKustomize() + { + // The script checks for kustomization.yaml at runtime β€” no path argument needed. + var script = K3sManifestBuilderExtensions.BuildManifestScript(); + Assert.Contains("kustomization.yaml", script); + Assert.Contains("kubectl apply -k /k8s-manifests", script); + } + + [Fact] + public void BuildManifestScriptWaitsForKubeconfigBeforeApplying() + { + var script = K3sManifestBuilderExtensions.BuildManifestScript(); + var kubeconfigWaitIndex = script.IndexOf("/root/.kube/kubeconfig.yaml", StringComparison.Ordinal); + var applyIndex = script.IndexOf("kubectl apply", StringComparison.Ordinal); + Assert.True(kubeconfigWaitIndex < applyIndex, "Kubeconfig wait must precede kubectl apply"); + } + + // ── Kustomize detection ─────────────────────────────────────────────────── + + [Fact] + public void ResolveFilesGlobPattern_ReturnsMatchingFilesOrdered() + { + var dir = Path.Combine(Path.GetTempPath(), $"glob-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "c.yaml"), ""); + File.WriteAllText(Path.Combine(dir, "a.yaml"), ""); + File.WriteAllText(Path.Combine(dir, "b.yaml"), ""); + + try + { + var globPath = Path.Combine(dir, "*.yaml"); + var files = K3sManifestBuilderExtensions.ResolveFilesForTest(globPath); + + Assert.Equal(3, files.Count); + Assert.Equal("a.yaml", Path.GetFileName(files[0])); + Assert.Equal("b.yaml", Path.GetFileName(files[1])); + Assert.Equal("c.yaml", Path.GetFileName(files[2])); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void AddK8sManifestKustomizeDirectoryShowsKustomizeResourceType() + { + var dir = Path.Combine(Path.GetTempPath(), $"kustomize-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "kustomization.yaml"), "resources: []"); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var cluster = appBuilder.AddK3sCluster("k8s"); + cluster.AddK8sManifest("kustom", dir); + + using var app = appBuilder.Build(); + var model = app.Services.GetRequiredService(); + + var resource = Assert.Single(model.Resources.OfType()); + var typeSnapshot = resource.Annotations + .OfType() + .Select(a => a.InitialSnapshot.ResourceType) + .FirstOrDefault(); + + Assert.Equal("K8s Kustomize", typeSnapshot); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +}